diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 485040712f..a6e7edfe58 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,6 +1,6 @@ name: "CodeQL" -on: [ pull_request ] +on: [ pull_request, workflow_dispatch ] jobs: lint: name: CodeQL @@ -13,8 +13,13 @@ jobs: fetch-depth: 2 - run: git checkout HEAD^2 + if: github.event_name == 'pull_request' - name: Run CodeQL run: | - docker run --rm -v $PWD:/app -w /app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ - "composer install --profile --ignore-platform-reqs && composer check" \ No newline at end of file + container=$(docker create composer:2.8) + docker cp "$container":/usr/bin/composer /tmp/composer + docker rm "$container" + docker run --rm -v $PWD:/app -v /tmp/composer:/usr/local/bin/composer:ro -w /app php:8.5.8-cli-alpine sh -c \ + "composer install --profile --ignore-platform-req='ext-*' && \ + composer check" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index ca49ca5c60..4884402e24 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -1,6 +1,6 @@ name: "Linter" -on: [ pull_request ] +on: [ pull_request, workflow_dispatch ] jobs: lint: name: Linter @@ -13,8 +13,13 @@ jobs: fetch-depth: 2 - run: git checkout HEAD^2 + if: github.event_name == 'pull_request' - name: Run Linter run: | - docker run --rm -v $PWD:/app -w /app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ - "composer install --profile --ignore-platform-reqs && composer lint" + container=$(docker create composer:2.8) + docker cp "$container":/usr/bin/composer /tmp/composer + docker rm "$container" + docker run --rm -v $PWD:/app -v /tmp/composer:/usr/local/bin/composer:ro -w /app php:8.5.8-cli-alpine sh -c \ + "composer install --profile --ignore-platform-reqs && \ + composer lint" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 318304d9d3..ffc4e094eb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,9 +6,10 @@ concurrency: env: IMAGE: databases-dev - CACHE_KEY: databases-dev-${{ github.event.pull_request.head.sha }} + CACHE_KEY: databases-dev-${{ github.event.pull_request.head.sha || github.sha }} + CACHE_5_KEY: databases-dev-cache-5-${{ github.event.pull_request.head.sha || github.sha }} -on: [pull_request] +on: [pull_request, workflow_dispatch] jobs: setup: @@ -25,6 +26,7 @@ jobs: uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1 with: context: . + file: Dockerfile push: false tags: ${{ env.IMAGE }} load: true @@ -60,34 +62,48 @@ jobs: docker compose up -d --wait - name: Run Unit Tests - run: docker compose exec tests vendor/bin/phpunit /usr/src/code/tests/unit + run: docker compose exec -e XDEBUG_MODE=off tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4 /usr/src/code/tests/unit adapter_test: - name: Adapter Tests + name: "Adapter Tests (${{ matrix.adapter }})" runs-on: ubuntu-latest needs: setup strategy: fail-fast: false matrix: - adapter: - [ - MongoDB, - MariaDB, - MySQL, - Postgres, - SQLite, - Memory, - Mirror, - Pool, - Redis, - SharedTables/MongoDB, - SharedTables/MariaDB, - SharedTables/MySQL, - SharedTables/Postgres, - SharedTables/SQLite, - SharedTables/Redis, - Schemaless/MongoDB, - ] + include: + - adapter: MongoDB + profiles: "--profile mongo" + - adapter: MariaDB + profiles: "--profile mariadb" + - adapter: MySQL + profiles: "--profile mysql" + - adapter: Postgres + profiles: "--profile postgres" + - adapter: SQLite + profiles: "" + - adapter: Memory + profiles: "" + - adapter: Mirror + profiles: "--profile mariadb --profile mariadb-mirror --profile redis-mirror" + - adapter: Pool + profiles: "--profile mysql" + - adapter: Redis + profiles: "" + - adapter: SharedTables/MongoDB + profiles: "--profile mongo" + - adapter: SharedTables/MariaDB + profiles: "--profile mariadb" + - adapter: SharedTables/MySQL + profiles: "--profile mysql" + - adapter: SharedTables/Postgres + profiles: "--profile postgres" + - adapter: SharedTables/SQLite + profiles: "" + - adapter: SharedTables/Redis + profiles: "" + - adapter: Schemaless/MongoDB + profiles: "--profile mongo" steps: - name: checkout @@ -103,7 +119,80 @@ jobs: - name: Load and Start Services run: | docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d --wait + docker compose ${{ matrix.profiles }} up -d --wait + + - name: Run Tests + run: docker compose exec -T -e XDEBUG_MODE=off tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4 /usr/src/code/tests/e2e/Adapter/${{matrix.adapter}}Test.php + + setup_cache_5: + name: Setup & Build Docker Image (utopia-php/cache 5.x) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build Docker Image + uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1 + with: + context: . + file: Dockerfile + build-args: | + UTOPIA_CACHE_VERSION=^5.1 + push: false + tags: ${{ env.IMAGE }} + load: true + cache-from: | + type=gha + type=gha,scope=cache-5 + cache-to: type=gha,mode=max,scope=cache-5 + outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar + + - name: Cache Docker Image + uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3.5.0 + with: + key: ${{ env.CACHE_5_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + + cache_5_test: + name: "Cache 5.x Tests (${{ matrix.suite }})" + runs-on: ubuntu-latest + needs: setup_cache_5 + strategy: + fail-fast: false + matrix: + include: + - suite: Unit + profiles: "" + path: /usr/src/code/tests/unit + - suite: MariaDB + profiles: "--profile mariadb" + path: /usr/src/code/tests/e2e/Adapter/MariaDBTest.php + + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Load Cache + uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3.5.0 + with: + key: ${{ env.CACHE_5_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Load and Start Services + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose ${{ matrix.profiles }} up -d --wait + + - name: Show utopia-php/cache Version + run: | + docker compose exec -T tests php -r ' + require "vendor/autoload.php"; + echo "utopia-php/cache ", \Composer\InstalledVersions::getPrettyVersion("utopia-php/cache"), PHP_EOL; + ' - name: Run Tests - run: docker compose exec -T tests vendor/bin/phpunit /usr/src/code/tests/e2e/Adapter/${{matrix.adapter}}Test.php --debug + run: docker compose exec -T -e XDEBUG_MODE=off tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4 ${{ matrix.path }} diff --git a/.gitignore b/.gitignore index 46daf3d316..1d4d5f1eeb 100755 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ Makefile .envrc .vscode tmp +*.sql diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..f20b48c742 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,124 @@ +# Utopia Database + +PHP database abstraction library with a unified API across MariaDB, MySQL, PostgreSQL, SQLite, MongoDB, Redis and an in-memory adapter. + +## Commands + +| Command | Purpose | +|---------|---------| +| `composer build` | Build Docker containers | +| `composer start` | Start all database containers in background | +| `composer test` | Run tests in Docker (ParaTest, 4 parallel processes) | +| `composer lint` | Check formatting (Pint, PSR-12) | +| `composer format` | Auto-format code | +| `composer check` | Static analysis (PHPStan, max level, 2GB) | + +Run a single test: +```bash +docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/e2e/Adapter/MariaDBTest.php +docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/unit/Validator/SomeTest.php +``` + +## Stack + +- PHP 8.5+, Docker Compose for test databases +- ParaTest (parallel PHPUnit), Pint (PSR-12), PHPStan (max level) +- Test databases: MariaDB 10.11, MySQL 8.0.43, PostgreSQL 16, SQLite, MongoDB 8.0.14 +- Redis 8.2.1 for caching tests + +## Project layout + +- **src/Database/** -- core library (PSR-4 namespace `Utopia\Database\`) + - `Database.php` -- main API class (uses trait composition for organization) + - `Adapter.php` -- base adapter class all engines extend; its abstract methods are the mandatory adapter contract + - `Adapter/` -- engine implementations: MariaDB, MySQL, Postgres, SQLite, Mongo, Memory, Redis, Pool, ReadWritePool; `Limits` and `Profile` value objects + - `Adapter/SQL.php` -- shared SQL adapter base (MariaDB, MySQL, Postgres, SQLite extend this); `Adapter/SQL/Hook/` holds the SQL permission, tenant, join and column hooks and the `WriteContext` implementation + - `Adapter/Feature/` -- optional feature interfaces only: Casting, Connection, QueryBuilder, RawQuery, Relationships, Schemaless, Spatial, Timeouts, Upserts + - `Capability.php` -- behaviour flags an adapter reports through `supports()` + - `Document.php` -- JSON document model (extends ArrayObject) + - `Collection.php` -- the collection definition (a `Document`, built with `Collection::create()`) + - `Attribute.php`, `Index.php`, `Relationship.php` -- `final readonly` schema value objects with a factory per type; `AttributeUpdate`, `CollectionUpdate`, `RelationshipUpdate` are the update models + - `Schema/` -- `Column` and `Index`, the physical schema read back from an engine + - `Mirror.php`, `Mirror/` -- database mirroring/replication, its write `Filter` and `Failure` + - `Query.php` -- query builder extension + - `Trait/` -- Database.php composition: Attributes, Collections, Databases, Documents, Indexes, Relationships, Transactions + - `Hook/` -- hook interfaces (Lifecycle, Named, Selective, Attachable, Decorator, Transform, Write, WriteContext) and hooks (Permissions, Relationships, Tenancy, Interceptor); `Hook/Mongo/` holds the MongoDB read hooks + - `Event.php`, `Event/` -- the event enum, one typed event class per event under Database/, Collection/, Attribute/, Index/, Document/, Permission/, plus Domain and DispatcherHook + - `Filter.php`, `Filter/` -- built-in filter names, and the Codec, Callback and Registry for instance filters + - `Cache/` -- `find()` query cache (`Query`, `Invalidator`) and cache bookkeeping + - `Profiler.php`, `Profiler/` -- query profiler and its `Log` entries + - `State/` -- per-coroutine state (Value, Frame) and Snapshot + - `Validator/` -- input validators, with `Authorization/`, `Queries/` (Base, Indexed, Document, Documents) and `Query/` (one per query type, `Joined/`) + - `Id.php`, `Permission.php`, `Role.php` -- id, permission and role helpers + - `Exception/` -- typed exceptions; `Schema`, `Query` and `Transaction` group related ones + +- **tests/unit/** -- unit tests for value objects, validators, hooks, events and adapters +- **tests/e2e/Adapter/** -- E2E tests against real databases + - `Base.php` -- abstract test class all adapter tests extend + - `Scopes/` -- test trait mixins (DocumentTests, AttributeTests, CollectionTests, PermissionTests, RelationshipTests, SpatialTests, VectorTests, etc.) + - Each adapter test (MariaDBTest, PostgresTest, etc.) extends Base and gets all scope traits + +## Key patterns + +**Multi-adapter:** Single `Database` class with engine-specific `Adapter` implementations. SQL adapters share `SQL.php` base; MongoDB has its own. + +**Document model:** Documents are ArrayObject subclasses with reserved attributes: `$id`, `$sequence`, `$createdAt`, `$updatedAt`, `$collection`, `$permissions`. + +**Hook system:** Pluggable hooks for permissions, relationships, tenancy filtering, and lifecycle events, registered with `Database::addHook()`. A database registers neither `Hook\Permissions` nor `Hook\Relationships` on its own. + +**Schema API:** Collection-first methods take value objects: `createCollection(Collection::create(...))`, `createAttribute($collection, Attribute::string(...))`, `createIndex($collection, Index::key(...))`, `createRelationship($collection, Relationship::oneToMany(...))`, and updates take `AttributeUpdate`, `CollectionUpdate`, `RelationshipUpdate`. + +**Adapter model:** Ask `supports(Capability::X)` for behaviour flags and `hasFeature(Feature\X::class)` for optional features (never `instanceof`: `Pool` forwards features without implementing them). Limits come from `limits()`; `Database::profile()` snapshots them for the validators. + +**Custom document types:** +```php +$database->setDocumentType('users', User::class); +$user = $database->getDocument('users', 'id123'); // Returns User instance +``` + +**Trait composition:** `Database.php` splits its API across the traits in `Trait/`. Each trait groups related operations (documents, attributes, indexes, etc.). + +**Connection pooling:** `Pool` adapter wraps multiple connections. `ReadWritePool` distributes reads and writes to separate pools. + +**Query builder:** Integrates with `utopia-php/query`. Queries grouped by type: filters, selections, aggregations, ordering, pagination. + +## Testing patterns + +- E2E tests extend `Base.php` which provides setUp/tearDown for real database connections +- Test functionality split into trait mixins in `Scopes/` -- each adapter test includes all relevant traits +- Unit tests in `tests/unit/` for value objects, validators, hooks, events and adapters with test doubles +- Tests check for `ext-swoole` and skip if missing + +## Docker services + +```bash +composer build && composer start # Start all databases +``` + +Services (activated via Docker Compose profiles): +- `mariadb` (host port 8703), `mysql` (8706), `postgres` (8701), `mongo` (9706) +- `redis` (8708) for caching +- Mirror variants for replication tests +- `adminer` (port 8700, debug profile) for database UI + +## Load testing + +```bash +bin/load --adapter=mariadb # Populate test data +bin/index --adapter=mariadb # Create indexes +bin/query --adapter=mariadb # Run queries +bin/compare # Visualize at localhost:8708 +``` + +## Conventions + +- PSR-12 via Pint, PSR-4 autoloading +- One class per file, filename matches class name +- Full type hints on all parameters and returns, readonly properties for immutable data +- Imports: alphabetical, single per statement, grouped by const/class/function +- Constants: UPPER_SNAKE_CASE +- Methods: camelCase with verb prefixes (get*, set*, create*, update*, delete*) + +## Cross-repo context + +Changes to the query builder, the `Adapter` contract or the `Pool` extension points may break appwrite, Appwrite Cloud and utopia-php/migration, which subclass `Adapter\Pool`. Run their tests after such changes. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..f519224911 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1051 @@ +# Changelog + +## 8.0.0 (unreleased) + +8.0 is a major release. Read [UPGRADE.md](UPGRADE.md) before you upgrade from 7.x: it lists every change you may +have to make, with the 7.x and 8.0 forms side by side. + +### Breaking changes + +- Document permissions and relationships are hooks, and a `Database` registers neither on its own: add + `Hook\Permissions` and `Hook\Relationships` with `addHook()`. See + [Register the permission and relationship hooks](UPGRADE.md#register-the-permission-and-relationship-hooks). +- The string constants of `Database`, `Query`, `Operator` and `Document` are replaced by enums, and the methods that + took or returned those strings take or return enum cases. `Query::getMethod()` returns a `Utopia\Query\Method` + case, so a comparison with a string is always `false`. See + [Constants are now enums](UPGRADE.md#constants-are-now-enums) and [Queries](UPGRADE.md#queries). +- `Helpers\ID`, `Helpers\Permission` and `Helpers\Role` are `Utopia\Database\Id`, `Permission` and `Role`; + `Mirroring\Filter` is `Mirror\Filter`; several validators moved. No aliases are kept. See + [Namespaces](UPGRADE.md#namespaces). +- The schema methods take and return value objects: `createCollection(Collection)`, + `createAttribute(string, Attribute)`, `createIndex(string, Index)`, `createRelationship(string, Relationship)`, and + `updateAttribute()`, `updateCollection()` and `updateRelationship()` take `AttributeUpdate`, `CollectionUpdate` and + `RelationshipUpdate`. `Attribute`, `Index` and `Relationship` are `final readonly` with private constructors and a + factory per type; `Collection::create()` builds a collection. `getCollection()` throws `Exception\NotFound` for a + missing collection, and `findCollection()` returns `null`. `updateAttributeRequired()`, `updateAttributeFormat()`, + `updateAttributeFormatOptions()`, `updateAttributeFilters()` and `updateAttributeDefault()` are removed. See + [Schema: value objects](UPGRADE.md#schema-value-objects). +- Stored attribute values are normalised per type (datetime `signed: false`; integer `size` 0 or 8; boolean, double + and id `size: 0`; strings `signed: true`), fulltext and TTL indexes store no `orders`, fulltext indexes no + `lengths`, and an index other than TTL no longer stores `ttl: 1`. `Attribute::fromDocument()` and + `Index::fromDocument()` read 7.x metadata with the same normalisation, so a legacy attribute or index equals the + one 8.0 creates. +- A stored attribute of an unknown type (`uuid7` included) or a stored relationship `onDelete` of `setDefault` or + `noAction` makes its collection unreadable until the metadata row is repaired. See + [Stored metadata](UPGRADE.md#stored-metadata). +- `Database::on()`, `Database::before()`, `Mirror::on()`, `Adapter::before()` and the `Database::EVENT_*` constants + are removed: register `Hook\Lifecycle` and `Hook\Transform` hooks with `Database::addHook()`. A lifecycle hook + receives one typed event object per event (`handle(Event\Domain $event)`). See + [Lifecycle events are hooks](UPGRADE.md#lifecycle-events-are-hooks). +- Bulk writes take one `onNext(Document $document, ?Document $previous)` callback and no `$onError`; + `Database::BATCH_SIZE` replaces `INSERT_BATCH_SIZE` and `DELETE_BATCH_SIZE`, and a larger batch size throws + `Exception\Limit`. `upsertDocumentsWithIncrease()` is `upsertDocuments(..., increase:)`. `iterate()` and + `foreach()` are removed: `cursor()`, where `limit()` caps the total. `find()` refuses aggregate and `groupBy()` + queries: use `aggregate()`. See [Bulk writes and reads](UPGRADE.md#bulk-writes-and-reads). +- Every configuration toggle is `set(bool)` with an `is`/`has` reader: `setValidation()`, `setFiltering()`, + `setLocks()`, `isPreservingDates()`, `hasSharedTables()`, `ignoreDuplicates()` and the others replace + `enableValidation()`, `disableValidation()`, `enableFilters()`, `disableFilters()`, `enableLocks()`, + `getPreserveDates()`, `getSharedTables()` and `skipDuplicates()`. See + [Configuration toggles](UPGRADE.md#configuration-toggles). +- Instance filters are `Filter\Codec` objects: the `Database` constructor takes a list of them instead of an + associative array. See [Filters](UPGRADE.md#filters). +- The 51 `Adapter::getSupportFor*()` methods are removed: check `supports(Capability::...)` or + `hasFeature(Feature\...::class)`. The 16 per-limit getters are `Adapter::limits()`, an `Adapter\Limits` value, + and `Database::profile()` snapshots limits, capabilities and features for the validators. The mandatory adapter + contract is the abstract methods of `Adapter`; `Adapter\Feature` holds the optional features only. + `Adapter\SQLite` extends `Adapter\SQL` instead of `Adapter\MariaDB`. See [Adapters](UPGRADE.md#adapters). +- `Document::getRead()`, `getCreate()`, `getUpdate()`, `getDelete()`, `getWrite()`, `find()`, `findAndReplace()` and + `findAndRemove()` are removed, and `getArrayCopy()` takes no arguments: use `getPermissionsByType()`, `only()` and + `except()`. +- `Exception\Order` and `Exception\Operator` extend `Exception\Query`, so a `catch (Exception\Query)` also catches + them. See [Errors](UPGRADE.md#errors). +- The validators that read adapter limits and capabilities take an `Adapter\Profile`. Every validator's `isValid()` + takes `mixed $value`, so a named argument `permissions:`, `roles:`, `document:` or `input:` is `value:`. See + [Validators and helpers](UPGRADE.md#validators-and-helpers). +- Cache key names changed: do not share a cache between 7.x and 8.0 processes, and flush it after the last 7.x + process has stopped. See [Caches](UPGRADE.md#caches). +- `Exception\Unique` has the message `Document with the requested unique attributes already exists` on every + adapter (was `Unique index violation`). On MariaDB and MySQL an unknown column throws `Exception\NotFound`. + `Database::sum()` throws `Exception\Query` for an attribute that is not a single number. See + [Errors](UPGRADE.md#errors). +- `Query::DEFAULT_ALIAS` is `table_main` (was `main`), `Query::groupByType()` returns a `Utopia\Database\ParsedQuery` + object (with `orderAttributes` and `orderTypes`, throwing `Exception\Query` for a cursor that is not a document), + and `Query::TYPE_ELEM_MATCH` is removed. +- `new Document()` and `Document::setAttribute('$permissions', ...)` reject non-string permissions with + `Exception\Structure`. +- Hook failures follow 7.x event by event, with three differences: an `\Error` always reaches the caller, an isolated + failure no longer skips the other hooks, and `document_purge` from `updateDocument()` and `deleteDocument()` fires + after the write's transaction commits. See [Hook failures](UPGRADE.md#hook-failures). +- `createDocuments()` under `ignoreDuplicates()` no longer counts skipped documents or passes them to `onNext`, on + every adapter. +- `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` refuse a fractional change value or a fractional + `max`/`min` on an integer attribute with `Exception\Type`, and the numeric operators refuse a fractional limit on + an integer attribute with `Exception\Structure`, before anything is written. Both accept the same whole-number + forms (for example `5`, `'5'`, `'5.0'` and `5.0`). See [Documents](UPGRADE.md#documents). +- Scopes such as `Authorization::skip()`, `silent()`, `skipRelationships()`, `withTenant()` and the other scoped + toggles apply to the calling coroutine and the coroutines it starts, not to other coroutines sharing the handle. + See [Coroutines](UPGRADE.md#coroutines). +- `Mirror::onError()` callbacks receive a `Mirror\Failure`. See [Mirror](UPGRADE.md#mirror). +- `Adapter\Pool` extension points renamed: `syncBorrowed()`, `releaseBorrowed()`, `hostname()` and `$arguments`. + See [Pool extension points](UPGRADE.md#pool-extension-points). + +### Added + +- **Joins.** `Query::join($collection, $alias, $on)`, `leftJoin()`, `rightJoin()` and `fullOuterJoin()` with a list + of `Query::on()` conditions and filters, and `crossJoin($collection, $alias)`, on the SQL adapters + (`Capability::Joins`), in `find()`, `count()`, `sum()` and `getDocument()`. + - A joined collection is read 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 under document security, and + `Exception\Authorization` otherwise. Adding a join never changes which main-collection rows are visible. + - A joined read returns what the same joins return over the documents direct reads of each collection return, + for every join type and chain of joins: an unreadable document never hides a row an outer join keeps, and a row + whose only match is unreadable comes back unmatched. + - Under shared tables every table of a join is limited to the selected tenant before rows are paired, for all join + types including PostgreSQL's native full outer join; rows without a tenant are never joined. A later right or + full outer join never pairs with another tenant's rows of an earlier table. + - A query may declare at most 8 joins (`Too many joins: at most 8 are allowed`), with or without validation + (`skipValidation()`). + - Join aliases must be identifiers, unique within the query regardless of case, and different from + `Query::DEFAULT_ALIAS` and from the key of a relationship attribute of the main collection; anything else throws + `Exception\Query`. A join alias may use any letter case or be a reserved word. + - A join without a select returns the main document plus, under each join alias, the joined collection's `$id` + and attributes as `alias.$id` and `alias.attribute`. The joined collection's internal attributes (`$tenant`, + `$permissions`, `$sequence`, `$createdAt`, `$updatedAt`) and its relationship attributes that hold a column (the + side that stores the related document's id) are returned only when a select names them, or, for the internal + attributes other than `$tenant`, when the select names `alias.*` or the read orders by them. Joined values are + never returned under a bare attribute name. + - `select('alias.*')`, alone or next to other selects, returns the joined row as a direct read of the joined + collection returns it: its `$id`, `$sequence`, `$createdAt`, `$updatedAt`, `$permissions` and attributes, but not + its `$tenant`. Joined columns named next to `*` (`select(['*', 'alias.$createdAt'])`) are returned with + everything `*` returns. An order may name a joined attribute by its bare name when only one join's collection + declares it and the main collection does not; a name several joins declare throws `Exception\Query`. + - Joined attributes are returned as a direct read of the joined collection returns them: cast to their types and + passed through every decode filter they declare, so encrypted attributes are decrypted, JSON and arrays decoded + and datetimes formatted. This applies to the implicit projection and to `select('alias.attribute')` alike. A + decode filter receives a document built from the joined row: `$id`, `$collection` and the joined attributes the + query returned (`$sequence` and the other internal attributes only when the query returns them). When an outer + join matches no row, its attributes are null, whether or not the select names `alias.$id` (outside `distinct()` + reads, which select only what they name). A cursor taken from a joined result can be passed back with + `cursorAfter()` or `cursorBefore()`: its joined values are encoded with the joined collection's filters. + - A column under a join alias (`alias.column`) must be valid on the joined collection for the query type it is + used in: an attribute the joined collection declares, or an internal attribute the query type accepts on the + main collection (`alias.$permissions` can be selected but not filtered or ordered by, and `alias.$collection` is + never accepted). Otherwise `Exception\Query` is thrown. This applies to `find()`, `count()`, `sum()` and to the + join conditions and selects of `getDocument()`. + - A join condition compares columns its tables hold: in `Query::on($left, $right)` the left column belongs to the main collection or to a join declared before it, the right one to + the joined collection. An unknown column, a relationship side that holds no column, or a join named before it is + declared throws `Exception\Query`. + - A join's ON list holds `on()` conditions and plain filters (comparisons, ranges, null checks, `contains`, + `containsAny`, `notContains`, starts/ends-with, and `and()`/`or()` of them); anything else throws + `Exception\Query` (`Unsupported join ON condition: `) in `find()`, `count()`, `sum()` and `getDocument()`. + - A cursor over a joined read names the joined row: paging a one-to-many, left, right or full outer join returns + every row once in both directions, through rows an outer join did not match and rows without a main document. A + cursor missing an order value is refused by name instead of borrowing the main document's value, and `cursor()` + over a joined read whose rows lack such a value throws before yielding a row instead of after the first batch. `getDocument()` with a join pairs the lowest-sequence joined row. See + [Paging a joined read](UPGRADE.md#paging-a-joined-read). + - With joins, a bare attribute in an aggregate function or `groupBy()` refers to the main collection's attribute + when the main collection declares it, else to the attribute of the one joined collection that declares it. A + name no collection declares, or that more than one join declares, throws `Exception\Query`; qualify it with the + join alias (`alias.attribute`). + - `search()` and `notSearch()` on a joined attribute require a fulltext index on that attribute of the joined + collection. An encrypted attribute of a joined collection cannot be filtered. + - MariaDB, MySQL and SQLite run a full outer join as two queries joined by `UNION ALL`. They accept one full outer + join per query, and a right join after it has to join on a table joined before the full outer join or on the full + outer joined table (directly or through other joins); other chains throw `Exception\Query`. PostgreSQL runs full + outer joins natively and has no such limit. A full outer join combined with a right join reads, under shared + tables, what a dedicated database reads. + - On MariaDB, MySQL and SQLite, aggregates, `groupBy()`, `having()`, `distinct()`, ordering and paging over a full + outer join apply once to the whole joined result, exactly as PostgreSQL's native full outer join does. On these + engines a `distinct()` query over a full outer join can only be ordered by selected attributes; any other order + throws `Exception\Query`. Full outer joins on these engines are ordered through internal columns that never + appear in results, so every attribute and join alias reads back unchanged. + - `updateDocuments()` and `deleteDocuments()` reject join queries. + - A `Validator\Queries\Documents` or `Validator\Queries\Document` you build yourself accepts joins and + aggregations as the `Adapter\Profile` it is given allows (`Capability::Joins`, `Capability::Aggregations`). + `Validator\Queries\Base::setJoinedCollections()` gives a query validator you build yourself the collections its + query sets may join. Without it, bare joined attribute names and searches on a join alias are rejected. +- **Aggregations.** `Query::count()`, `countDistinct()`, `sum()`, `avg()`, `min()`, `max()`, the statistical + aggregates (`stddev()`, `stddevPop()`, `stddevSamp()`, `variance()`, `varPop()`, `varSamp()`), the bitwise + aggregates (`bitAnd()`, `bitOr()`, `bitXor()`), `groupBy()` and `having()`, through + `Database::aggregate($collection, $queries)` on the SQL adapters (`Capability::Aggregations`). It returns a list of + rows (arrays), names an unaliased aggregate `_` (`` for `count('*')`) and fires + `document_aggregate`. + - In an aggregation query (one with an aggregate or a `groupBy()`), a select may name only the attributes the query + groups by. Any other selected attribute throws `Exception\Query`, including `$collection`, `$tenant` unless it is + grouped, a related document's attribute and a join alias's `alias.*`. `*` and relationship wildcards at any depth + (`key.*`, `parent.child.*`) are accepted and ignored: the rows hold the groups and the aggregates only. A join + alias cannot equal a relationship key of the main collection, so `key.*` is always the relationship's wildcard. + This applies to `aggregate()`, `count()` and `sum()`. + - `having()` conditions follow the filter rules: they compare aggregate aliases or `groupBy` attributes only, + aliases at the top level, and the fulltext and value-count rules apply inside them. + - `sum`, `avg`, `stddev*`, `variance` and `var*` require a numeric, non-array attribute, `bitAnd`, `bitOr` and + `bitXor` an integer one, and `min` and `max` one whose values are ordered (not an array, object, boolean, spatial + or vector attribute), on joined collections too. Only `count` accepts `*`. Aggregates and `groupBy()` refuse a + relationship side that holds no column. + - `Database::sum()` resolves a bare attribute name as `find()` does: the main collection's attribute, else the one + join that declares it. + - A joined group sharing its column name with another group of the query is returned under its qualified name + (`groupBy(['name', 'note.name'])` returns `name` and `note.name`); a joined group alone keeps its bare name. + - Over an empty result, `count` and `countDistinct` return `0` and every other aggregate returns `null`, on every + engine. + - SQLite has no statistical or bitwise aggregate functions: `aggregate()` rejects those aggregates there with + `Exception\Query`. Aggregate aliases are identifiers of at most 63 characters, + and an alias cannot repeat another aggregate's alias or the name a grouped attribute is returned under. + - Internal attributes under a join alias (`note.$id`, `note.$createdAt`, ...) can be grouped by. `$collection` is + never aggregated or grouped by, and `$tenant` (on the main collection or under an alias) only under shared + tables. An aggregate over a main attribute named like its own alias reads the main table when a joined + collection declares the same attribute. + - A search and a vector query filter an aggregation query; vector distance orders row reads only. +- **`Query::distinct()`** on the SQL adapters. A distinct read is ordered, and paged by a cursor, along its explicit + orders only; a `search()` and a vector query filter it without ordering it, and its rows carry no `$distance`. On + PostgreSQL and MySQL a `distinct()` query ordered by an unselected attribute throws `Exception\Query`. A distinct + row has no `$id`, so a cursor pages a distinct read by its order values: the read needs a `select()` of named + attributes and an order on each of them, or it throws `Exception\Query`. +- **Query builder.** `Database::from($collection)` returns a utopia-php/query builder over a collection's table, + `Database::query()` runs a built read and returns a list of `Document`, `Database::mutate()` runs a built write and + returns the affected row count, and `Database::schema()` returns a schema builder. `from()`, `query()` and + `mutate()` check no permissions, bypass the caches, run no hooks and throw `Exception\Authorization` unless + authorization is disabled. Under shared tables every statement a `from()` builder runs stays within the tenant + selected when it was handed out. `from($collection, $alias)` names the main table by the alias. A SQL adapter's + `builder()` (`Feature\QueryBuilder`) is the builder `from()` builds on: its `from()` and its joins take collection + ids, and once it has read a collection it reads that one only, refusing `fromTable()`, `into()`, another alias + and the multi-table writes (`updateJoin()`, `deleteJoin()`, `updateFrom()`, `deleteUsing()`), whose second table + its tenant scope does not reach. +- **`find()` query cache.** `Database::setQueryCache(new Cache\Query($cache))` caches `find()` results per + hostname, database, namespace, tenant and collection, and writes through the `Database` invalidate only the scopes + they write in: under shared tables with tenant-per-document, the scope of each written document's tenant. + Collection listings are never cached. A cache hit costs 3 cache round trips. +- **Hooks.** `Database::addHook()` registers `Hook\Lifecycle` (side effects on events), `Hook\Decorator` (modifies + the documents a read or write returns), `Hook\Transform` (rewrites SQL before it runs; `removeTransform()` removes + one), `Hook\Write` (rows of its own for each document write, such as `Hook\Permissions`), `Hook\Relationships` and + `Cache\Invalidator`, and throws for any other hook. `removeHook()` unregisters a hook or every hook of a class. A + hook implementing `Hook\Attachable` receives the database in `attach()`, so `new Relationships()` takes no + database. A lifecycle hook that also implements `Hook\Named` replaces the hook registered under the same name, and + one that implements `Hook\Selective` receives only the events its `handles()` accepts. +- **Typed events.** `Hook\Lifecycle::handle(Event\Domain $event)` receives one `final readonly` event object per + event, under `Event\{Database,Collection,Attribute,Index,Document,Permission}`, with a typed payload; bulk writes + are `Event\Document\BatchCreated`, `BatchUpdated`, `BatchDeleted` and `BatchUpserted` with their `count` (and + `created`/`updated` for upserts). New events: `database_update`, `document_upsert`, `document_aggregate`, + `attribute_rename` and `indexes_create`. An event object is built only when a registered hook handles its event. + `Event\DispatcherHook` forwards them to listeners per event class and to a PSR-14 dispatcher. +- **Value objects.** `Attribute`, `Index` and `Relationship` are `final readonly`, built by one factory per type + (`Attribute::string()`, `Attribute::integer(width: IntegerWidth::Bits64)`, `Index::key()`, `Index::ttl()`, + `Relationship::oneToMany()`, ...), with `fromDocument()`/`toDocument()` for storage and `apply()`/`with*()` for + changed copies. `Collection::create()` builds a collection, whose `attributes()` and `indexes()` return typed lists. + `AttributeUpdate`, `CollectionUpdate` and `RelationshipUpdate` describe sparse updates (`Unchanged::Value` keeps a + default or format). `Format` holds a format name and its options. `Attribute::TYPES` lists the storable column + types and `Attribute::availableTypes(Adapter\Profile)` narrows it to an adapter. `Attribute::storedType()` and + `typeFromStored()` convert between `ColumnType` cases and stored type strings. A `float` attribute type + (`ColumnType::Float`) joins `double`. +- **Schema methods.** `findCollection()`, `collectionExists()`, `createIndexes()` (validates every index, writes the + definition once), `Database::update()` (renames a database on every adapter), `internalAttributes()` and + `collectionDefinition(): Collection`. The create and update methods return the stored value object. + `getSchemaAttributes()` and `getSchemaIndexes()` return `Schema\Column` and `Schema\Index` values, and PostgreSQL + introspects its schema (`Capability::SchemaIntrospection`, with MariaDB, MySQL and SQLite). +- **Enums.** `Utopia\Database\Event`, `PermissionType`, `RelationshipType`, `RelationshipSide`, + `RelationshipDeleteAction`, `SetType`, `OperatorType`, `Filter`, `IntegerWidth`, `Unchanged` and `Capability`, and + from utopia-php/query `Method`, `ColumnType`, `IndexType`, `OrderDirection` and `CursorDirection`. +- **Adapter model.** `Adapter::supports(Capability)`, `capabilities()` and `hasFeature()`; the optional + `Adapter\Feature` interfaces `Casting`, `Connection`, `QueryBuilder`, `RawQuery`, `Relationships`, `Schemaless`, + `Spatial`, `Timeouts` and `Upserts`; `Adapter::limits()` (`Adapter\Limits`) and `Database::profile()` + (`Adapter\Profile`); `Database::setSchemaless()`; `Database::getHostname()`. `Adapter::relaxAttributeRequired()` and + the protected `Adapter\SQL::getSpatialColumnSrid(): ?int` (the SRID written into spatial column definitions, or + `null` for a dialect that cannot declare one, such as MariaDB). +- **Filter codecs.** `Filter\Codec` (`name()`, `encode()`, `decode()`), `Filter\Callback` and `Filter\Registry`. The + `Database` constructor takes a list of codecs, and `setFilters(Filter\Registry)` gives a handle a registry several + handles can share. A codec applies only to those handles and takes precedence over a global filter of the same + name (`Database::addFilter()`); the constructor's codecs take precedence over both. A codec named after a built-in + filter throws `Exception\Duplicate`. Document and query cache keys include each registered codec, by class, or by + `signature()` for a `Filter\Signed` codec such as `Filter\Callback`. +- **`Adapter\ReadWritePool`.** Sends reads to a read pool and writes to a write pool. Reads stay on the primary for a + sticky window after a write or a transaction commits (`setStickyDuration()`, default 5000 ms; `setSticky()`), and + `getDocument(..., forUpdate: true)` and `rawQuery()` always use the write pool. Metadata and configuration calls + never re-open the window, and `hostname()` always names the write pool's host. +- **Query profiling.** `Database::setProfiling()`, `isProfiling()` and `getProfiler()` with + `Utopia\Database\Profiler`, which keeps the newest `Profiler::DEFAULT_CAPACITY` (1000) entries (`setCapacity()`, + `getCapacity()`); `getQueryCount()` and `getTotalTime()` cover every query since the last `reset()`. Pooled + connections carry the profiler of the handle that borrowed them only while they are checked out. Each + `Profiler\Log` carries the statement's bound values, collection and operation (`Event` value). +- **`Utopia\Database\PDO::configure()`** for session settings that must survive a reconnect. +- **Documents.** `Document::only()` and `except()`, `fromArray()`, `getArray()`, `getDocument()`, `getDocuments()` + and the `Document::ID`, `SEQUENCE`, `COLLECTION`, `CREATED_AT`, `UPDATED_AT`, `PERMISSIONS`, `TENANT` and + `DISTANCE` key constants. +- **`Utopia\Database\Builder\SQLite`**, the query builder the SQLite adapter uses (`dialectBuilder()` returns it). It + extends `Utopia\Query\Builder\SQLite` and adds `ESCAPE '\'` to every LIKE predicate. +- `Database::cursor()` iterates over a query's matches in batches of `CURSOR_BATCH_SIZE` (100) by default (a + `limit()` in the queries caps the iteration, an `offset()` or `cursorAfter()` positions the first batch only), + `Database::rawQuery()` runs a SQL statement as written and returns its rows as documents (like `from()`, `query()` + and `mutate()` it checks no permissions, applies no tenant scope and runs only inside + `getAuthorization()->skip()`, otherwise `Exception\Authorization`), `Query::containsString()` matches a substring + of a string attribute, and the Redis adapter supports upserts. +- `Query::exists()` and `Query::notExists()` run on the SQL adapters. Each value names an attribute of the + collection that holds a column, or an `alias.attribute` of a join; other names throw `Exception\Query`. +- `Authorization::withRoles()` and `Adapter::withTenant()` scope the roles and the tenant to the calling coroutine, + and `Hook\Relationships::withEnabled()` and `withCheckExist()` scope the hook's flags the same way. + `Database::snapshot()`, `Database::withSnapshot()` and `Hook\Relationships::withSnapshot()` are `@internal`. +- Scoped toggles take their value: `withValidation()`, `withFiltering()`, `withPreserveDates()` and + `withPreserveSequence()`. +- `Database::setCacheWriterTimeout()` bounds how long an unfinished invalidation keeps a collection's document and + query cache off. +- `Mirror::awaitReplications(?int $timeout = null)` waits until every replication queued through the mirror has + reached the destination or has been reported to `onError()`, for example before a worker stops. A timeout of 0 + returns at once; a negative one throws. + `Mirror\Failure` describes a failed replication. +- `Exception\Schema`, the parent of every schema violation, `Exception::$state` (a string code such as a SQLSTATE), + `Exception\Unique::MESSAGE`, `Exception\Mismatch` (a `Duplicate` for a shared-table column of another type), + `Exception\Contention` (a `Transaction` for a lock conflict with a concurrent transaction), + `Exception\Unconfirmed` (a MongoDB commit whose result could not be confirmed), `Exception\Refused` (an adapter + that returned `false` from a schema change) and `Validator\Structure`'s `storedAttributes` parameter. + +### Changed + +- A schema change the adapter returns `false` for throws `Exception\Refused`. A refused `renameAttribute()`, + `renameIndex()` or `updateRelationship()` has one message naming its keys (`Failed to rename attribute 'a' to 'b'`) + instead of being wrapped like an adapter error (`Failed to rename attribute 'a' to 'b': Failed to rename + attribute`), and a refused `renameIndex()` no longer tries to complete an earlier rename. These three also let an + adapter's `Exception\Duplicate` or `Exception\NotFound` through unwrapped, so `renameAttribute()` of a column the + table lacks throws `Exception\NotFound` on MariaDB and MySQL as documented. `create()`, `update()`, `delete()`, + `createCollection()` and `deleteCollection()` refuse a `false` too instead of ignoring it, so `update()` and + `delete()` return `true` whenever they return. See [Errors](UPGRADE.md#errors). +- A filter on a path into an object attribute takes keys of `a-z`, `A-Z`, `0-9`, `_` and `-` only, on every adapter, + and PostgreSQL refuses any other key also when validation is skipped. See [Queries](UPGRADE.md#queries). +- `createAttributes()` fires `attribute_create` once per attribute and then `attributes_create` once with the list + (7.x fired `attribute_create` once, with an array, and never fired `attributes_create`). +- `silent($callback, $hooks)` silences the `Hook\Named` hooks with those names. A nested `silent()` never narrows + the silence around it, and silences apply to the calling coroutine and the coroutines it starts. +- `Authorization::skip()`, `Database::skipRelationships()`, `skipRelationshipsExistCheck()`, `silent()`, + `withFiltering()`, `skipFilters()`, `withValidation()`, `skipValidation()`, `withPreserveDates()`, + `withPreserveSequence()`, `withTenant()`, `withRequestTimestamp()`, `ignoreDuplicates()` and + `Authorization::withRoles()` are scoped to the calling coroutine + and the coroutines it starts; sibling coroutines sharing the handle or the `Authorization` no longer see them. The + plain setters (`setStatus()`, `enable()`, `disable()`, `reset()`, `setTenant()`, ...) still change the shared + value, except inside a scope over the same state (`disable()` inside `skip()`, `setTenant()` inside + `withTenant()`), including one the coroutine inherited from the coroutine that started it: there the change + applies to the calling coroutine and the coroutines it starts, and lasts until the scope ends. A coroutine sees a + scope only while every coroutine between it and the scope's owner is running; work that can outlive its starter + opens its own scopes. A coroutine cut off from a scope, because a coroutine between it and the scope's + owner has returned, reads the shared values or a scope opened outside every coroutine, and while a scope over the + same state is open on the handle its writes stay with it and the coroutines it starts; for `Authorization`, + `skip()` and `withRoles()` count together. Such a coroutine changes and restores state with those + scopes, not with a pair of setters. See [Coroutines](UPGRADE.md#coroutines). +- Relationship population reads its chunks of related ids concurrently only on `Adapter\Pool`, inside a coroutine + and outside `withTransaction()`, and only as many at once as `Hook\Relationships::READ_CONCURRENCY` (4) and + `Pool::getReadConcurrency()` (the connections the pool can hand out without waiting, less one) allow; elsewhere it + reads them one after another. Related documents are merged in chunk order. +- Linking an existing many-to-many related document needs update permission on it, as one-to-one, one-to-many and + many-to-one links already do, on every create and update path and at every nesting depth (7.x needed only read). + Without it the write throws `Exception\Authorization`. See [Relationships](UPGRADE.md#relationships). +- Inside a coroutine, a `Mirror` replicates `createDocuments()`, `updateDocuments()`, `upsertDocument()`, + `upsertDocuments()`, `deleteDocument()` and `deleteDocuments()` in a coroutine of + its own, so the call returns once the source write is done (7.x wrote the destination before returning). Each + replication runs under the authorization status and roles, tenant, relationship and silence state and toggles + the caller had when it made the call. A mirror applies its changes to the destination one at a time, in the order + they were made through it: the calls that reach the destination before returning (`createDocument()`, + `updateDocument()`, `increaseDocumentAttribute()`, `decreaseDocumentAttribute()`, schema changes, `create()`, + `delete()`, `exists()`, `setTimeout()`, `clearTimeout()`) first wait for the queued replications, and later + replications wait for them. The write filters' document hooks run when the replication applies. Outside a coroutine + every replication finishes before the call returns. +- A `Mirror` uses its source's `Authorization` and leaves the source's and destination's in place; in 7.x it started + with one of its own, which it also gave the source's adapter. +- `Mirror::createDocument()` returns the document written to the source, as `updateDocument()` does, instead of the + destination's copy. +- `notContains` on an array attribute excludes documents whose array is NULL or missing on every adapter; SQLite + and MongoDB used to include them. +- On MongoDB, `startsWith()` and `endsWith()` match at the start and at the end of the value. They matched the + value anywhere in the string, so `startsWith('foo')` returned `barfoo`. Both stay case-sensitive. +- On SQLite, document ids compare case-insensitively, as on MariaDB: `getDocument('Doc')` finds `doc`. +- An upsert that creates a document applies every operator to the attribute's default as it does for an existing + document: `dateAddDays()`/`dateSubDays()` shift the date, `arrayFilter()` filters the array, and the maximum or + minimum of increment, decrement, multiply, divide and power is honoured. +- `Database::setTimeout()` and `clearTimeout()` throw `Adapter does not support timeouts` on SQLite, Memory and + Redis; 7.x's SQLite ignored them. See [Errors](UPGRADE.md#errors). +- `setSchemaless(true)` throws on an adapter without a schemaless mode (every adapter but MongoDB). 7.x's + `setSupportForAttributes(false)` returned the requested value on Memory and Redis without applying it. +- Schema calls no longer retry deterministic failures of their metadata write (validation, authorization, missing + or duplicate documents, limits, conflicts, indexes, dependencies, operators, timeouts, truncation) and no longer + sleep before rethrowing them. They no longer run a metadata transaction again after its own retries gave up on a + transient failure either: a lock conflict there is attempted 3 times instead of 9. +- `withTransaction()` no longer retries deterministic failures: an invalid document (`Structure`), `NotFound`, + `Query`, `Type`, `Index`, `Dependency`, `Truncate`, any other typed library failure and any other exception (for + example `InvalidArgumentException`) run the callback once and are rethrown without sleeping, also in a nested call. + Only a failure that can succeed on another attempt is still retried twice with 7.x's backoff: an + `Exception\Transaction` (including `Contention`), a lost connection, an engine lock conflict the adapter did not + map, or a MongoDB error labelled transient, a network error before the commit or a command that was never sent. + Only the outermost `withTransaction()` retries it: a nested call rolls back to its savepoint and rethrows, so a + lock conflict that keeps failing runs a nested callback 3 times instead of 9. A MongoDB commit whose result is + unknown retries only the commit, up to 3 more times with a `majority` write concern, then fires the queued + `document_purge` events of the last attempt and throws `Exception\Unconfirmed` without running the callback again + (7.x ran the callback again); a schema call keeps the table, column or index of a definition whose commit is + unconfirmed. A MongoDB commit the server reports aborted, on the first attempt or on a commit retry, runs the + callback again: 7.x reported a first commit the server had aborted as a success, losing its writes. On SQL a + connection lost during `COMMIT` still runs the callback again, so that work is at-least-once. See + [Errors](UPGRADE.md#errors). +- `createCollection()`, `createAttribute()`, `createAttributes()` and `createIndex()` keep the table, column or + index, and `updateAttribute()`, `renameAttribute()`, `renameIndex()`, `deleteAttribute()` and `deleteIndex()` keep + their change, when only the cache invalidation after their committed definition failed or its commit is + unconfirmed, and do not repeat the write. `deleteCollection()` and `deleteRelationship()` recreate the dropped + table or columns empty in that case and throw the failure wrapped. `createRelationship()` keeps a committed + relationship and still creates its indexes in that case; when its indexes fail and the definitions cannot be + removed, the columns are kept with them. +- `createCollection()` validates attribute types up front, like `createAttribute()`, and throws + `Unknown attribute type: . Must be one of ` for an unknown one. `updateAttribute()` updates `id` + attributes and refuses relationship attributes (`Cannot update relationship as an attribute`). +- An empty attribute `format` (7.x metadata stores `''`) reads as `null`. +- `Database::VAR_BIGINT` (`'bigint'`) becomes `ColumnType::BigInteger`, whose value is `'biginteger'`. Collection + metadata still stores `'bigint'`, as in 7.x, so existing metadata needs no migration. Write a stored type with + `Attribute::storedType()` and read one with `Attribute::typeFromStored()`. +- `updateAttribute()` with `required: false` relaxes the column's `NOT NULL`; 7.x's `updateAttributeRequired(false)` + changed only the metadata. `AttributeUpdate(default: null)` removes the default, `format: null` removes the + format, and a default sent with `required: true` throws. A `Mirror` replicates attribute updates. +- A 64-bit integer attribute (`IntegerWidth::Bits64`, 7.x size 8) gets the bounds `[PHP_INT_MIN, PHP_INT_MAX]`, or + `[0, PHP_INT_MAX]` unsigned, in `increaseDocumentAttribute()`, `decreaseDocumentAttribute()` and the numeric + operators; 7.x applied the 32-bit range. +- PostgreSQL introspects its schema and reconciles orphan columns and indexes in `createAttribute()` and + `createIndex()`, as MariaDB, MySQL and SQLite do. +- An unaliased aggregate is returned under `_`, or `` for `count('*')`. +- `cursor()` treats a `limit()` as a cap on the documents it yields. +- `Adapter\Mongo::exists()` reports a database only when the server lists it (it returned `true` for every name), + and `collectionExists()` looks in the database it is given. +- `Database::delete()` still flushes the whole cache it was given, as in 7.x: on a cache shared with other databases + (one Redis), give each database handle a cache of its own. +- An index of an unknown stored type is read as a key index; creating one is refused. +- `Connection::hasError()` classifies by driver error code (MySQL and MariaDB 1053, 2002, 2006, 2013 and 4031; + SQLSTATE class 08; PostgreSQL 57P01 to 57P05) before matching messages, and its message list matches Swoole 6.2. +- `Database::setMetadata()` comments precede every statement the SQL adapters prepare, ahead of registered + `Transform` hooks. Arrays, `null` and objects are rendered as JSON. Keys and values are normalised: comment + delimiters are split, control characters become spaces and invalid UTF-8 is replaced. +- `purgeCachedQueries()` also purges the `find()` query cache, and returns `false` when either purge fails. +- `deleteDocument()` fires `document_update` for each document on the other side of a two-way relationship that the + delete changed, as 7.4.0 does. When a hook throws, `document_delete` and every related `document_update` still + fire, and the first exception reaches the caller afterwards. Finding the peers a cascade left costs a read per + related collection, which a delete skips unless an active lifecycle hook handles `document_update`. See + [`document_update` for related documents a delete changed](UPGRADE.md#document_update-for-related-documents-a-delete-changed). +- The locked read of `updateDocument()`, `increaseDocumentAttribute()`, `decreaseDocumentAttribute()` and + `deleteDocument()`, and the page reads of `updateDocuments()` and `deleteDocuments()`, use the collection + definition the write resolved instead of reading it again: a schema change committed after the write resolved its + definition, or between two pages, is not seen by that read or those pages, as the write validated and encoded + under the earlier one. A bulk write reads `_metadata` at most once per call instead of once per page. A `Database` + subclass's `getDocument()` and `find()` overrides are no longer called for these reads, and its `getCollection()` + and `findCollection()` overrides are not called by `getDocument(Database::METADATA, ...)` while no + `Hook\Decorator` is registered or events are silenced. See [Caches](UPGRADE.md#caches). +- `Adapter\Pool::syncBorrowed()` replays metadata, the profiler and transforms onto a connection only when the + connection's differ from the pool's, as it already did for write hooks; the tenant, schemaless mode, timeouts and + the other settings are still synced on every delegated call. A `Pool` subclass or custom connection adapter can + no longer rely on `resetMetadata()`, `setMetadata()`, `setProfiler()`, `resetTransforms()` or `addTransform()` + being called on every delegated call. See [Pool extension points](UPGRADE.md#pool-extension-points). + +### Deprecated + +- `Query::contains()`. Use `containsString()` for substring matching on string attributes and `containsAny()` for + array attributes. Queries whose method is `contains` (for example parsed from JSON) keep working. + +### Removed + +- `Database::on()`, `Database::before()`, `Mirror::on()` and `Adapter::before()`. +- The protected `Adapter\Pool::$pinnedAdapter` property. A subclass reads the connection the calling coroutine's + transaction pinned through `pin()`. See [Pools and profiling](UPGRADE.md#pools-and-profiling). +- The `Database::VAR_*`, `INDEX_*`, `ORDER_*`, `PERMISSION_*`, `RELATION_*`, `CURSOR_*` and `EVENT_*` constants, the + `Query::TYPE_*` constants, the `Operator::TYPE_*` constants and the `Document::SET_TYPE_*` constants, replaced by + enums, and `Database::SPATIAL_TYPES`, `ATTRIBUTE_FILTER_TYPES`, `INTERNAL_ATTRIBUTES`, `INSERT_BATCH_SIZE` and + `DELETE_BATCH_SIZE`. See [Constants are now enums](UPGRADE.md#constants-are-now-enums). +- `Utopia\Database\Helpers\*`, `Mirroring\Filter`, `Validator\Attribute`, `Validator\Index`, `Validator\Queries`, + `Validator\IndexedQueries` and `Validator\ObjectValidator`, moved without aliases. See + [Namespaces](UPGRADE.md#namespaces). +- `updateAttributeRequired()`, `updateAttributeFormat()`, `updateAttributeFormatOptions()`, + `updateAttributeFilters()`, `updateAttributeDefault()` and `getInternalAttributes()`. See + [Schema: value objects](UPGRADE.md#schema-value-objects). +- `upsertDocumentsWithIncrease()`, `iterate()`, `foreach()` and the `$onError` parameters. See + [Bulk writes and reads](UPGRADE.md#bulk-writes-and-reads). +- `enableValidation()`, `disableValidation()`, `enableFilters()`, `disableFilters()`, `enableLocks()`, + `getPreserveDates()`, `getPreserveSequence()`, `getDropUnknownAttributes()`, `getSharedTables()`, + `getTenantPerDocument()`, `skipDuplicates()`, `clearAllDocumentTypes()`, `getKeywords()` and + `Authorization::setDefaultStatus()`. See [Configuration toggles](UPGRADE.md#configuration-toggles). +- `Document::getRead()`, `getCreate()`, `getUpdate()`, `getDelete()`, `getWrite()`, `find()`, `findAndReplace()`, + `findAndRemove()` and the `getArrayCopy()` filter parameters. See [Documents](UPGRADE.md#documents). +- The 51 `Adapter::getSupportFor*()` methods and `getSupportNonUtfCharacters()`. See + [Capabilities and features](UPGRADE.md#capabilities-and-features). +- The 16 per-limit adapter getters (`getLimitFor*()`, `getMax*()`, `getCountOfDefault*()`, `getIdAttributeType()`, + `getDocumentSizeLimit()`, `getKeywords()`, `getInternalIndexesKeys()`). See + [Limits and profile](UPGRADE.md#limits-and-profile). +- The base adapter's `ping()`, `reconnect()`, `getHostname()`, `getConnectionId()`, `setSupportForAttributes()`, + `clearTimeouts()`, `enableAlterLocks()`, `setDebug()`, `getDebug()`, `resetDebug()`, `castingBefore()`, + `castingAfter()`, `setUTCDatetime()`, `decodePoint()`, `decodeLinestring()` and `decodePolygon()`, and the + per-adapter methods listed in [Methods an adapter no longer has](UPGRADE.md#methods-an-adapter-no-longer-has): + `Mongo::getSchemaAttributes()` and `getSchemaIndexes()` answer `[]`, `Mongo::getConnectionId()` is `id()`, and + `Memory::upsertDocuments()` is gone with `Feature\Upserts`. +- `SQL::getPDOAttributes()` and the protected `SQL::getPDO()` (use `getDriver()`). `Change::getOld()`, `setOld()`, + `getNew()` and `setNew()` (use the readonly `old` and `new`). +- The SQL adapters' string query builders and the hooks behind them (`getSQLConditions()`, `getSQLCondition()`, + `getSQLPermissionsCondition()`, `getFulltextValue()`, `getTenantQuery()`, `getLikeOperator()`, + `getRegexOperator()`, `getAttributeProjection()`, `getRandomOrder()`, the insert and upsert statement hooks, and + others). See [Removed adapter methods](UPGRADE.md#removed-adapter-methods). +- `Mongo::getTenantQuery()`, which returned an empty string, and `SQL::getSpatialTypeFromWKT()`. Nothing in the + library calls either. See [Removed adapter methods](UPGRADE.md#removed-adapter-methods). +- The protected adapter methods nothing called: `getSQLIndexType()` on `SQL` and `SQLite`, + `Postgres::getSQLSchema()`, `Postgres::encodeArray()`, `Postgres::decodeArray()` and + `Memory::unregisterRelationshipField()`. See [Removed adapter methods](UPGRADE.md#removed-adapter-methods). +- `Adapter\SQL::setFloatPrecision()`, `Adapter\SQLite::setEmulateMySQL()`, `getEmulateMySQL()`, + `Database::getInstanceFilters()`, `Mirror::getWriteFilters()` and `Validator\Structure::getFormats()`, which + nothing calls. See [Removed unused public methods](UPGRADE.md#removed-unused-public-methods). +- Renamed protected adapter methods with upper-case acronyms or abbreviations (`getSQLTable()`, `getSQLType()`, + `getOperatorSQL()`, `bindOperatorParams()`, `getIdentifierQuoteChar()`, ...) and `Redis::tx()`. See + [Writing or subclassing an adapter](UPGRADE.md#writing-or-subclassing-an-adapter). + +### Fixed + +- `deleteRelationship()` recreates the relationship's indexes, or its junction collection's definition, when the + adapter refuses or fails to drop the relationship, instead of leaving them dropped while the relationship stays. +- On MongoDB, `Adapter\Mongo::createCollection()` of an existing collection throws `Exception\Duplicate`, as on the + SQL adapters, instead of adopting it without its indexes; under shared tables and for the metadata collection it + first creates the collection's indexes, and `Database::createCollection()` adopts the collection. An index + conflict (code 85) throws `Exception\Index`. A collection it created but could not give its indexes is dropped, + outside a transaction and unless tables are shared, and a refused `createCollection()` stores no definition. +- Under shared tables, `createCollection()` no longer drops the table when storing the definition fails: another + tenant may already have adopted it, and the drop removed every tenant's documents. +- `createAttributes()` drops the columns it created one at a time before a later column failed, instead of leaving + them in the schema without a definition. +- `updateRelationship()` completes an earlier attempt only when every column it renames is already under its new + name and no longer under its old one, instead of adopting any column under the new key. +- On PostgreSQL with shared tables, `renameIndex()` no longer reports a rename done because another tenant's copy + of the index has either name; only the tenant's own index, or the one created with the shared table, counts. +- `updateDocument()` no longer fails with `Exception\Structure` (`Unknown attribute`) when a schema change commits + between its collection definition lookup and its locked read of the document. +- `Mirror::clearDocumentTypes()` keeps the metadata collection's `Collection` type, so the mirror's + `getCollection()` still returns a `Collection` (it returned a plain `Document`). +- `Adapter::hasFeature()` no longer tells PHPStan the adapter implements the feature: a `Pool` answers `true` for a + feature it forwards without implementing it, so code narrowed by the check called methods the pool does not have. + A spatial index created through a pooled adapter is no longer refused by an `instanceof` check. +- `sum()` and `count()` throw `Exception\Query` for a `$max` of 0 or less instead of returning 0. +- A relationship with a stored `onDelete` of `setDefault` or `noAction` is refused with `Exception\Relationship`; + it was accepted, and deleting the parent left dangling children. +- A join's ON list holding anything but `on()` conditions and filters (`limit()`, `select()`, ...) throws when the + query is built, and the join keeps its alias (utopia-php/query 0.7). +- `Collection::create(documentSecurity: false)` stores `false` (it read back `true`), and a collection's other keys + are either stored or refused with `Exception\Structure`, never dropped. +- A `Mirror` honours the `Hook\Relationships` it is given (for example `prepare: false`) on its source and its + destination; it rebuilt the hook with the default settings. +- In `deleteDocuments()`'s `onNext`, the second argument is a separate copy of the stored document, so a callback + that changes the first argument no longer changes what it is told the previous state was. +- `findOne()` fires nothing when no document matches (it fired `document_find` with `false`), and `aggregate()` + fires `document_aggregate` instead of `document_find`. +- `addHook()` throws for a hook it does not recognise instead of registering nothing. +- `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` throw `Exception\Type` for a change value of 0 or + less instead of `InvalidArgumentException`. +- `MySQL::getSpatialSqlType()` is protected, as on the other SQL adapters; MySQL made it public. +- UPGRADE.md, README.md, AGENTS.md and docs/add-new-adapter.md no longer misstate the last 7.x release (7.4.0), the + query validator parameters, the adapter contract and the project layout. +- A well-known-text polygon with holes decodes into its rings on MariaDB, MySQL and PostgreSQL; the rings were + merged into one. +- Deleting a many-to-many relationship on Redis and Memory drops its junction collection, as the SQL and MongoDB + adapters do; recreating the relationship hit the leftover junction. +- The point, linestring and polygon filters no longer swallow an encoder failure and write the value as given. +- `aggregate()` throws `Exception\Query` for two aggregates that would come back under the same default alias, + instead of collapsing their values into one key when validation is off. +- A `Hook\Relationships` added to a second database throws instead of rebinding to it, and a query cache shared by + several databases uses each caller's cache name and writer timeout instead of the last one it was set on. +- `Mirror\Failure` names `DocumentUpsert` for a failed `upsertDocument()` (it was `null`) and `IndexesCreate` for a + failed `createIndexes()`. + +- With validation skipped, the SQL adapters filter the attribute of `sum()` and the grouped columns they qualify + with a join alias, and every identifier they quote escapes the quote character. +- A delete retried on the same `Database` after its cascade failed (for example on a `Restricted` related document + or a permission failure) now runs the cascade. +- `Mirror` forwards `setCacheName()`, `setGlobalCollections()`, `resetGlobalCollections()`, + `setTenantPerDocument()`, `setCacheWriterTimeout()`, `setTimeout()`, `clearTimeout()`, `setMetadata()`, + `resetMetadata()`, `setFiltering()`, `withFiltering()`, `skipFilters()`, `setLocks()`, `setProfiling()`, + `setMigrating()` and `setFilters()` to its source and destination. +- Scopes entered on a `Mirror` apply to its source: `withTenant()`, `withPreserveDates()` and + `withPreserveSequence()` to its destination as well, `skipRelationships()`, `skipRelationshipsExistCheck()` and + `withRequestTimestamp()` to the source only. `withRequestTimestamp()` runs its callback once. +- `Mirror::setLocks()` reports a destination that cannot apply the setting through `onError()` (method + `setLocks`) instead of throwing after the source applied it, and `Mirror::create()` throws when the destination + cannot create the database. +- A `Mirror` write filter whose `beforeCreateCollection()`, `beforeUpdateCollection()`, `beforeUpdateAttribute()` or + `beforeCreateIndex()` returns `null` skips that change on the destination, as `Mirror\Filter` documents; the + mirror returns the source's result, and a collection whose creation was skipped is not replicated. +- `Mirror::createDocument()` and `updateDocument()` restore the destination's preserve-dates setting after + replicating, also when the destination write fails. +- `Mirror::upsertDocument()` and an increasing `Mirror::upsertDocuments()` run on the source and replicate to the + destination. +- `createCollection()`, `createAttribute()` and `createAttributes()` no longer modify the `Attribute` and `Index` + objects passed to them. +- `resetMetadata()` removes the query comments at once; the previous metadata no longer annotates later statements. +- Without Swoole's library, MySQL 8.0.24+'s idle disconnect (error 4031) is recognised as a lost connection and + reconnected, instead of failing the first query after every idle period. +- Linking a related document at any nesting depth of an update needs update permission on that document, and throws + `Exception\Authorization` without writing anything when the caller lacks it. +- `deleteDocuments()` with a select no longer skips a one-to-one or one-to-many `Cascade` or ignores `Restrict` on + one-to-one, one-to-many and many-to-many relationships. Cascade and restrict targets are read from storage with + permissions skipped, as `SetNull` already did, so related documents the caller cannot read are cascaded (subject + to the caller's delete permission on each) or block a `Restrict` delete. +- A nested one-to-one write that throws no longer leaves an entry on the relationship write stack. Before, every + later write on the same `Database` treated its nested relationships as one level deeper and dropped the deepest + ones without an error. +- Coroutines sharing a `Database` keep their own relationship write and cascade stacks (also in 7.x). Before, a + coroutine in the middle of a nested relationship write made another coroutine's nested writes look deeper, so their + deepest related documents were dropped without an error, and a coroutine in the middle of a cascading delete could + stop another coroutine's cascade, leaving its related documents behind. +- `Adapter\Pool` pins a transaction's connection for the coroutine that opened the transaction and the coroutines it + starts (also in 7.x). Before, every coroutine sharing the handle ran its statements on that connection while the + transaction was open, so they were committed or rolled back with it and their own `withTransaction()` became a + savepoint in it. Each call on the pinned connection also runs under its own coroutine's tenant; before, two + coroutines with different `withTenant()` scopes overwrote each other's tenant on it. +- A filter on a nested relationship path (for example `Query::equal('children.tags.name', [...])`) no longer throws + `Exception\Query` when a step of the path matches more documents than `getMaxQueryValues()`: each step reads its + matches in chunks within the limit. A path that passes through the parent side of a one-to-many or the child side + of a many-to-one no longer throws `Cannot select attributes`. +- Linking a document by value in a two-way one-to-one update stores the back-reference on the related document, and + writes its nested documents as deep as `createDocument()` does. +- A new related document created through a many-to-many update keeps the `$permissions` it was given. Before, they + were always replaced by the parent's. A related document created without `$permissions` still takes the parent's. +- A nested `withTransaction()` whose enclosing transaction was lost (for example, the server ended the session) + throws `Exception\Transaction`, and so does the outer call. Before, the nested call began a fresh top-level + transaction and committed its own writes alone while the outer call returned normally. A top-level + `withTransaction()` whose commit finds the connection no longer holds the transaction throws + `Exception\Transaction` instead of returning as if its work were stored; `commitTransaction()` no longer returns + `false` for it. +- A nested `withTransaction()` whose statement lost a deadlock on MariaDB or MySQL rethrows the deadlock + (`Exception\Contention`), and the outermost call runs again as in 7.x. The engine rolls the whole transaction back, + so nothing of that attempt is stored. The 8.0 pre-releases reported such a transaction as lost and did not run it + again. +- `Utopia\Database\PDO` refuses statements after a reconnect lost the open transaction, until the transaction is + rolled back, so a swallowed connection error can no longer make later statements autocommit. + `Utopia\Database\PDO::reconnect()` replays attributes set with `setAttribute()` after connecting. +- Increasing or decreasing an optional numeric attribute that was never set stores the result (the unset value + counts as zero), on every adapter; before, the SQL adapters left the column NULL and MongoDB rejected the update. A + `max`/`min` bound also treats the unset value as zero. +- On MariaDB and MySQL, `updateDocument()` stores a case-only rename (`abc` → `ABC`) even when it is looked up by the + new casing, and moves the document's permission rows with it. +- `find()` no longer changes the cursor document passed to `Query::cursorAfter()`/`cursorBefore()`. +- `updateDocuments()` inside `withRequestTimestamp()` compares each stored document's `$updatedAt` with the request + timestamp, instead of always throwing `Exception\Conflict`. +- `updateDocuments()` hands `onNext` every attribute it wrote decoded, once, including under a `select` that leaves + the attribute out and after a retried transaction; nothing the `select` left out is added. +- In a two-way one-to-one relationship, `updateDocument()` rejects a document already linked to another document with + `Exception\Duplicate` (`Document already has a related document`) before writing anything, and no longer rejects + a free document whose id matches a linked document of the other collection. +- `createDocuments()` under `ignoreDuplicates()` writes permissions only for the documents it inserted. A replayed id + no longer adds its permissions to the stored document (MariaDB, MySQL and SQLite; also present in 7.x), and a row + skipped for another unique value leaves no permission rows behind. On MongoDB it matches ids as its `_uid` index + does (ignoring case and accents), instead of failing on an id stored with different case, and it reports only the + documents its upserts inserted: it reads back the `$sequence` each was given, so an id the index matches or one + another writer stores first is not counted. A document it inserts without a `$sequence` gets a UUID v7 one, as + `createDocuments()` without `ignoreDuplicates()` gives it, instead of a server-generated `ObjectId`. +- `Adapter::find()` with no limit and an offset returns the rows after the offset on every SQL engine instead of + throwing (MariaDB, MySQL and SQLite rejected `OFFSET` without `LIMIT`; also in 7.x). +- A failed rollback of a metadata write no longer replaces or mislabels the error that failed the write. +- A failed `createCollection()` whose rollback also fails throws the metadata failure (message and previous + exception) instead of the rollback's error; the rollback failure is logged. +- `createAttributes()` rolls back every column it created when a driver error (for example a lock timeout or a lost + connection on PostgreSQL) interrupts dropping one of them, and throws the metadata failure with that error + appended, instead of letting the driver error escape and leaving the remaining columns. A PHP `Error` (for + example a `TypeError`) raised while dropping a column is rethrown unchanged instead of being collected. +- `renameIndex()` fails with `Failed to rename index '' to ''` and keeps the old key in the metadata when + the schema has the index under neither name, instead of recording a rename that did not happen. This holds on + PostgreSQL, MariaDB, MySQL, MongoDB, Memory and Redis; PostgreSQL, Memory and Redis used to report such a rename + as done. SQLite rebuilds the index under the new name from its definition, so its schema matches the metadata. + An index the schema already has under the new name completes the rename on every adapter but MongoDB, which + drops the old index first and fails with the driver's IndexNotFound, as before. Under shared tables a tenant's + rename also completes while the collection's shared index has either name (on PostgreSQL, while another tenant's + copy of it does); MongoDB's shared tables are unchanged. +- A failed `updateRelationship()` restores the definitions it had already written (parent, two-way child, junction + keys) and rethrows the original error. Its rollback reverses the column rename before renaming the indexes back, + so SQLite, Memory and MongoDB rebuild each index over the column it covers instead of losing it. +- Type mismatch messages spell bigint `bigint`, as stored. Spatial attribute defaults are validated. +- A stored value that newer validation rules reject no longer blocks `updateDocument()` of other attributes, and a + stored `json` value with a non-string permission no longer makes `getDocument()`, `find()` or `updateDocument()` + throw, including through mapped document types and the caches. +- `createAttribute()` and `createAttributes()` refuse a varchar of size 0 or above the maximum varchar length, as + `createCollection()` does and as 7.x did. +- A failed `createCollection()` (a declared index that fails, a timeout, a spatial index with orders, a permissions + table that fails) drops the tables it created, so the collection can be created again. It used to leave them, and + later creates failed with `Collection already exists`. When that drop fails as well (for example inside an aborted + PostgreSQL transaction or after a lost connection), the drop failure is logged and the error that failed the + create is thrown. +- `createIndex()` compares an index that exists in the schema but not in the metadata with the request (columns, + prefix lengths, key, unique, fulltext or spatial) on adapters with schema index introspection: a match is adopted, + a mismatch is dropped and recreated, as in 7.3.12. +- `createIndex()` and `createIndexes()` refuse a unique index over documents that already share a value with + `Exception\Unique` and keep no metadata for it, as 7.4.1 does. The adapter's error was taken for an index already + in the schema, so the index was recorded without being built; SQLite threw a raw `PDOException` instead. +- An index key longer than the engine allows throws `Exception\Index` (`Index key length exceeds the maximum`) on + MariaDB and MySQL, and an index row too large throws `Exception\Limit` (`Index row size exceeds the maximum`) on + PostgreSQL, as 7.4.1 does. Both threw a raw `PDOException`. +- A document stored before an attribute became required, and so holding null for it, can be updated and upserted + without a value for that attribute, as 7.4.1 does; every write failed with `Missing required attribute`. Creating a + document without it, or clearing a stored value, still fails. +- PostgreSQL: `distanceLessThan()` uses the spatial index; `ST_Distance()` alone cannot, so a radius query filtered + every row. It leads with `ST_DWithin()` on the geometry, or, for a distance in meters from a point to a point + attribute, with a degree box that holds every point in range. Lines, polygons and boxes that would reach a pole or + the antimeridian keep the exact comparison only, which stays in every case, so the boundary is still exclusive. 7.x + carries the same fix after 7.4.1. +- The SQL adapters load on a PHP without ext-swoole, which composer.json only suggests: no native parameter, return + or property type names a Swoole class, so PHP no longer fails to check `SQL::getDriver()` and the statement methods + against their parents (`Could not check compatibility ... class Swoole\Database\PDOProxy is not available`). The + Swoole proxies stay in their docblock types. +- A distance query whose distance is not finite (`NAN`, `INF`, `-INF`) is rejected with `Distance query requires a + finite distance`. PostgreSQL compares `'NaN'` as above every number, so its index condition and its exact check + disagreed on such a distance. +- `updateAttribute()` no longer fails on MongoDB, Memory and Redis when a key or unique index covers the attribute + (the index was compared with itself). +- `Validator\IndexDefinition` rejects an index definition without a type, with an unknown type, or a TTL index + without `ttl`, as 7.x did; `Index::fromDocument()` reads an unknown stored type as `key` instead of throwing. + `Validator\IndexDefinition` counts big integer columns (big integer, id, 64-bit integer) as 8 bytes toward the + maximum index length, and judges an index on a text attribute declared without a size against the engine's text + maximum instead of 0. +- `analyzeCollection()` refreshes planner statistics on PostgreSQL and SQLite (the collection's table and its + permissions table) and returns `true`; it returned `false` there. Call it after bulk loads. +- Engine errors map to library exceptions: lock conflicts (MariaDB/MySQL 1213, 1205; PostgreSQL 40P01, 40001, + 55P03; SQLite `database is locked`) to `Exception\Contention`, a subclass of `Exception\Transaction`; MariaDB/MySQL 1146 and 1072, SQLite `no such column`, PostgreSQL 22021, 42883 + and 42P01 naming an alias to `NotFound`, `Character` and `Query`; PostgreSQL's distinct() order error in any server + language. PostgreSQL `deleteCollection()` of a collection whose table is gone succeeds again, and MariaDB/MySQL drop + its permissions table too, so the collection can be created again. See [Errors](UPGRADE.md#errors). +- PostgreSQL: `updateAttribute()` on a datetime attribute (a rename through `newKey`, or any change that rewrites the + column) no longer fails with an undefined `to_timestamp(timestamp)` function, which left the column renamed and + the metadata unchanged. `createCollection()` with an index on an object path (`data.country`) creates the index on + the JSON path instead of failing with `Attribute not found`. +- MariaDB: spatial columns are declared without an SRID on every path (`POINT`, `LINESTRING`, `POLYGON`); + `createCollection()` and `updateAttribute()` wrote `POINT(4326)`, which declared no SRID on MariaDB either. +- Shared tables: `createAttribute()` and `createAttributes()` no longer drop a column another tenant's collection + uses when the requested type differs. The attribute is refused with `Exception\Duplicate` (`Attribute exists in + the shared table with another type`; `Exception\Mismatch` on PostgreSQL); a column of the same type is reused, + including on MariaDB where the engine spells `INT` as `int(11)` and JSON as `longtext`. A failed `createAttributes()` + metadata write rolls back only the columns that call created. `createIndex()` over an index another tenant uses + with another definition throws `Duplicate` (`Index exists in the shared table with another definition`). +- Shared tables: renaming an attribute tenant by tenant (`renameAttribute()`, or `updateAttribute()` with a new key) + completes for every tenant of a collection id on PostgreSQL, MariaDB, MySQL and SQLite. The first tenant renames + the shared column; each later tenant finds it renamed and only updates its own metadata (and, for + `updateAttribute()`, applies its type to the renamed column). A rename left half done by a failed metadata write + also completes when retried on PostgreSQL. On PostgreSQL, `renameIndex()` completes for a tenant whose collection + uses an index another tenant created. +- Renaming an attribute onto a column that still sits beside the old one (under shared tables, another tenant's + attribute) throws `Duplicate` (`Attribute already exists`) on every SQL engine and leaves the metadata and values + unchanged. MariaDB, MySQL and SQLite used to adopt that column and strand the attribute's values in the old one; + PostgreSQL threw a generic exception. +- A filter on a joined attribute (`alias.attribute`), in the query or in a join's ON list, and a `having()` condition + are converted as a filter on the main collection is: `contains`, `containsAny`, `containsAll` and `notContains` on + a joined array match elements rather than substrings (`containsAny('th.tags', ['a'])` no longer matches + `['banana']`), and joined datetimes, `alias.$createdAt` and `alias.$updatedAt` included, are compared in UTC + whatever offset the value carries, in `find()`, `count()`, `sum()` and `getDocument()`. A `having()` condition on + a `min` or `max` alias is compared as the aggregated attribute. +- A select that names a joined attribute (`select(['name', 'alias.name'])`) returns only what it names: the main + collection's unselected attributes are no longer returned (arrays as `[]`) next to it. +- SQLite: `contains`, `containsAny`, `containsAll` and `notContains` on array attributes compare elements by value + (strings, integers, doubles, booleans); they previously matched no element. The query builder's `jsonContains`, + `jsonNotContains` and `jsonOverlaps` filters on SQLite are fixed the same way. +- SQLite: document id lookups, permission checks and joins on `$id` use the unique `_uid` index again; they + previously scanned the table, and shared-table self-joins could run for hours. `Query::regex()` works again + through the adapter's `REGEXP` function. +- SQLite: `search()`/`notSearch()` on a joined attribute (`Query::search('alias.attribute', ...)`) use the joined + collection's fulltext index, so a multi-word term matches as it does on the main collection; a search on an + attribute without a fulltext index (validation off) matches a backslash in the term literally; `getSchemaIndexes()` + lists every index under its index id, as MariaDB and MySQL do, and under shared tables lists the indexes on the + shared table once each; `deleteIndex()` on one of several fulltext indexes no longer throws `Cannot resolve + fulltext index`. +- SQLite: `arrayRemove()` removes integers and floats, in upserts as well as updates, and an upsert that creates a + document refuses an operator result outside the 64-bit integer range with `Exception\Limit` (`Value out of + range`), as MariaDB, MySQL and PostgreSQL do. A full outer join ordered with `Query::orderRandom()` no longer + fails on SQLite. +- MongoDB: `count()` throws the driver's error (mapped as for `find()`) instead of returning `0` when the query fails + for a reason other than a timeout, for example an invalid regular expression. +- MongoDB: unique indexes that `createIndex()` creates on integer, big integer, float, boolean and datetime + attributes reject duplicates; their partial filter required a string value, so they covered no document. Unique + indexes created by `createCollection()` also cover integers past 32 bits, big integers inside 32 bits and floats + stored as integers. A unique index on an attribute without a declared type (an internal attribute such as + `$createdAt`, or any attribute of a schemaless collection) covers a value of every BSON type the driver writes but + null, arrays, binary data and timestamps included; it required a string, and 7.4.1 covers strings, numbers, + booleans, dates and objects only. Key indexes are used by queries: their partial filter now requires only that the index's first + attribute exists. Existing indexes keep their old filter until they are rebuilt: see + [MongoDB: rebuild key and unique indexes](UPGRADE.md#mongodb-rebuild-key-and-unique-indexes). +- MongoDB: `containsAll()` works on `find()` (it matched nothing there, while `count()` and `sum()` worked); renaming + or deleting an attribute whose key contains a dot acts on its stored values; `sum()` filters on and sums attributes + whose key contains a dot; a stored `null` tenant reads back as `$tenant`. `contains`, `notContains`, `notSearch`, + `notStartsWith` and `notEndsWith` match values containing `$` followed by letters (such as `$USD`) instead of + throwing. +- On an adapter without `Capability::OrderRandom` (MongoDB), `orderRandom()` fails validation with `Exception\Query` + (`Random order is not supported by this adapter`). MongoDB threw a generic `Exception` from the adapter. +- `find()` refuses `Query::distinct()` on adapters without aggregation support (Memory, Redis, MongoDB) with + `Exception\Query`, also when validation is skipped; MongoDB returned duplicate rows. +- `Operator::power()` accepts a numeric text exponent (`'2'`) on every adapter again, as in 7.x; non-numeric text is + still refused. A whole-number float limit on an operator (for example `Operator::increment(1, 9.0e18)` on a big + integer) is compared exactly on the SQL adapters, and honoured exactly on Memory and Redis when the result leaves + the native integer range; whole-number limits beyond PHP's int range on unsigned big integers and `'100.0'`-style + strings are accepted. +- Memory adapter: deleting a document by an id in another casing removes its permissions; under shared tables, + deleting or revoking a document no longer leaves another tenant's same-id document with stale permissions; + renaming a document that keeps a value in a unique index no longer throws a unique conflict; `listCollections()` + includes collections created without a tenant, as the SQL adapters do. +- Redis adapter: `updateDocuments()` and upsert updates enforce unique indexes, including collisions within the + batch; under tenant-per-document, unique indexes are checked within the document's own tenant; dropping a + collection deletes only its own keys, and `getSizeOfCollection()` and `getSizeOfCollectionOnDisk()` count only + them (a collection whose id matched a key segment, such as `doc`, reached other collections' permission data). +- Under shared tables with tenant-per-document, `upsertDocuments()` hands each document to `onNext` with its own + tenant's `$sequence` when one batch creates the same id under several tenants (SQL adapters). +- The MongoDB adapter returns server-generated `ObjectId` sequences of batch writes as strings instead of `''`, and + resolves the sequences of documents without a tenant under the adapter's tenant. +- The Redis adapter files each document's permission entries under the document's own tenant. + +#### Fixed since the 8.0 pre-releases + +These fix builds of the `feat-query-lib` branch that preceded 8.0.0. Most of them restore 7.x behaviour, so they do +not change anything for an upgrade from 7.x. + +- **Aggregations, distinct and search:** + - An aggregation query (an aggregate or a `groupBy()`) next to a fulltext `search()` or a vector query, with no + explicit order, no longer fails in the engine (MariaDB and MySQL 1064 or 1055, PostgreSQL 42803). On MariaDB it + no longer returns an extra `_relevance` column. The search and the vector query filter an aggregation query; + vector distance orders row reads only. + - A `distinct()` read next to a fulltext `search()` returns each distinct selection once. Before, it returned one + row per relevance value, each with a `_relevance` column (MariaDB, MySQL, PostgreSQL). Next to a vector query it + no longer fails on PostgreSQL (42P10, reported as `A distinct() query can only be ordered by a selected attribute + on this database`). Vector distance orders row reads only. A distinct read is ordered, and paged by a cursor, + along its explicit orders; the search and the vector query only filter it. + - A fulltext `search()` read paged with a cursor lists every match exactly once. A search only filters, as in 7.x: + the read is ordered by its explicit orders and then by `$sequence`, and a cursor pages along them. Search results + are not ranked by relevance and carry no `_relevance` attribute. + - Aggregates and `distinct()` no longer surface raw engine errors: an ungrouped select or order next to an + aggregate, an unsupported aggregate on SQLite, an alias PostgreSQL would truncate, or a main attribute + aggregated under its own name over a join is rejected or resolved before the statement runs. MariaDB and MySQL map errors 1116 and 1191, + and MySQL 3065 and PostgreSQL 42P10 (a `distinct()` query ordered by an unselected attribute), to + `Exception\Query`. + - A vector search ordered by a joined attribute pages with a cursor on PostgreSQL. + - On SQLite, `count()` and `sum()` next to a fulltext `search()` apply the search instead of throwing. + - A fulltext `search()` or `notSearch()` term splits into words at every character other than a letter, a number, + an underscore or whitespace, as in 7.x. On PostgreSQL `search('title', 'foo/bar')` matched nothing and + `search('title', 'foo,bar')` (or `;`, `?`, `#`, `%`, `=`, `:`) matched only `foo` directly followed by `bar`; + both again match either word. MariaDB and MySQL bind the same term as 7.x. + - 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`). + - A filter on a joined column is checked against the joined collection's attribute as a filter on the main + collection is checked against its own: a value of the wrong type, a comparison an array attribute does not take, + or `contains` on a number is rejected as `Exception\Query` instead of failing in the engine (PostgreSQL 22P02, + 22007, 42883) or as `Unknown PDO Type`. Vector queries cannot target a joined attribute. + - `Validator\Queries\Base` with a `length` caps every nested query group again, as in 7.x. + - `count()` and `sum()` with filters or document permissions run one flat aggregate over the table, as 7.x did, + instead of an aggregate over a derived table; only `$max` and joins keep the derived table. + - `count()` and `sum()` throw `Exception\Query` for a statement the query builder refuses, and the mapped engine + exception (`NotFound` for a missing table on SQLite) for an error while preparing, as `find()` does, instead of + raw `Utopia\Query` and `PDO` exceptions. + - An unaliased `bitAnd()`, `bitOr()` or `bitXor()` over an empty result returns `null` on MariaDB and MySQL, as an + aliased one does. +- **Query cache:** + - The `find()` query cache no longer switches off or discards other tenants' (namespaces', databases') cached + results when one of them writes. + - `listCollections()` no longer returns a stale listing when a query cache is installed. + - `Mirror::setQueryCache()` installs the query cache on the source and destination, so writes through a mirror + invalidate it, and an `Invalidator` added through a mirror is installed on the mirror too. + - Under shared tables with tenant-per-document, a write refreshes the cached `find()` results of each written + document's tenant, whichever tenant is selected on the writing `Database`, and leaves every other tenant's in + place. + - A written document's own attribute named `options` no longer names a collection to invalidate. + - A write to one document no longer retires every cached document of its collection, and single-document writes + no longer block the collection's cache while they run. `updateDocument()` invalidates the cache once instead of + twice. + - Reads and writes no longer leave a key behind in Redis each: a document has one key, with a field per selection, + as in 7.x, and a collection's `find()` results live in one hash of at most `Cache\Query`'s `slots` fields (1024 + by default), which an invalidation retires by epoch without deleting them, so a write costs the same whatever + the collection has cached. On the Redis adapters a collection's batch and schema invalidations register as + fields of one `#owners` key; adapters that store no fields keep a key per invalidation, which their purge + deletes. On Redis, keys matching `*#owner:*`, document entries whose key ends in `:#` and + query-cache keys matching `*:qcache:*#active:*` left by earlier builds are no longer read and can be deleted. + - Cache lookups cost one round trip again: a cached `getDocument()` is two round trips (was 12) and + `getCollection()`, `find()`, `count()` and `sum()` one (was 6) before their query; single-document writes take + 3 each, at or below 7.x's (create 3, update and delete 6, increase and decrease 4). + - A cached miss for one casing of a document id no longer hides another casing on engines that compare ids + case-sensitively (PostgreSQL, MongoDB). + - Reads inside `withTransaction()` are served from the document cache again, except for documents the transaction + wrote and collections whose cache it retired: a write no longer reads its collection definition from the + database inside its own transaction. Reads inside a transaction still never write to the cache. A missing + collection costs one read of `_metadata` again, as in 7.x. + - Inside `withTransaction()`, a collection definition read under `skipFilters()` and a filtered read of it no + longer share one copy: a raw `_metadata` read no longer makes later writes in the transaction fail, and a + `skipFilters()` read after a filtered one returns the raw definition. + - With `ReadWritePool`, a document or query result served by a read replica is no longer cached, so a lagging + replica cannot leave an old version in the cache for other handles. + - A writer killed between blocking and re-enabling a collection's document or query cache no longer keeps that + cache off until a flush (see `setCacheWriterTimeout()`). + - `purgeCachedCollection()` also drops the collection's cached `find()` results, `purgeCachedQueries()` returns + `false` when the cache fails, as documented, instead of throwing, and a query-cache backend error no longer makes + `find()` throw: it reads the database and logs a warning. +- **Permissions and tenancy:** + - Under shared tables, `upsertDocuments()` and `upsertDocument()` store their permission rows under the tenant, on + an adapter with no earlier write and through `Adapter\Pool` alike. + - On the SQL adapters, under shared tables with tenant-per-document, `upsertDocuments()` and + an increasing `upsertDocuments()` remove a revoked permission under the upserted document's own tenant. + - `listCollections()` and `find(Database::METADATA)` return only the definitions the caller may read, as in 7.x, + and agree with `count(Database::METADATA)`. Definitions created without a tenant are listed from every tenant of + a shared pool. + - The MongoDB adapter filters `find()`, `count()` and `sum()` by document permissions while authorization is + enabled, whether or not `Hook\Permissions` is registered, and scopes writes and `getDocument()` by tenant only, + as in 7.x. Writes authorized through update or delete permission succeed without read permission. + - Under shared tables, the MongoDB adapter upserts each document under its own tenant, falling back to the + selected one, as in 7.x. + - A cascade below the first level deletes each related document through `deleteDocument()`, as in 7.x: a related + document the caller may not read is deleted with the rest, blocks the delete under `Restrict`, and rolls the + delete back when the caller may not delete it. `updateDocument()` compares a relationship's stored keys at every + depth before linking. + - `getDocument()` records a missing document in the document cache only after an unfiltered read confirms it. + - Document permissions follow every write to `$permissions` (ArrayAccess, references, `exchangeArray()`, + `unset`), and `getPermissions()` returns a de-duplicated list again. + - Relationship population that needed more than one query (more related ids than `getMaxQueryValues()`) could leave + the handle with authorization and relationships disabled, populate related documents the caller may not read, and + deliver `document_find` events inside `silent()`. + - Permission-checked reads (`find()`, `count()`, and the raw builder's outer joins under shared tables) work again + when the database or namespace name starts with a digit or a hyphen; such names are quoted. A permissions table + or join alias that is unsafe even when quoted (a space, `;`, a quote character, an empty name) is refused with + `Utopia\Database\Exception`. + - Scoped toggles (`skipFilters()`, `skipValidation()`, `withPreserveDates()`, `withPreserveSequence()`, + `withTenant()`, `withRequestTimestamp()`, `ignoreDuplicates()`) no longer reach other coroutines sharing a handle, + and overlapping scopes in different coroutines no longer leave the handle on another scope's value. + - A setter called in a coroutine started inside a scope (`addRole()`, `removeRole()` or `cleanRoles()` inside + `withRoles()`, `disable()` or `reset()` inside `skip()`, `setTenant()` inside `withTenant()`, and the setters of + the other scoped toggles) changes only what that coroutine and the coroutines it starts see, until the scope + ends. Before, it changed the shared value: the coroutine that called it did not see the change, every other + coroutine sharing the handle did, and the change outlived the scope, so a `disable()` inside `skip()` left + authorization off and an `addRole()` inside `withRoles()` gave every caller the scope's roles. +- **Hooks and events:** + - Every document write fires `document_purge` again, once per purged document. + - `silent($callback, $listeners)` no longer silences every hook. + - `document_purge` for writes inside `withTransaction()` fires after the outer transaction commits instead of + inside it, and not at all when it rolls back or for a retried attempt. It fires for a committed write even when + the cache invalidation after the commit fails. + - A failed nested `withTransaction()` on an adapter without savepoints (MongoDB) no longer drops the + `document_purge` events of writes that commit with the caller. + - `deleteCollection()` and `delete()` purge their caches before `collection_delete` and `database_delete` run, and + `updateRelationship()` fires `attribute_update` after both sides are renamed, so a hook that throws an `\Error` + leaves no stale cache entry and no metadata that disagrees with the columns. +- **Documents and schema:** + - 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. + - A create that writes its new related documents in one pass files a related document given as an associative + array under its relationship's collection (MongoDB threw `Duplicate`), leaves a lock conflict to the outermost + `withTransaction()` to retry, as in 7.x, and no longer applies attribute filters twice when it falls back to + writing the related documents one by one. + - `updateDocuments()` with an `Operator` decodes the refetched batch once; `count()` and `sum()` on a missing + collection throw `Exception\NotFound`; a case-only `$id` rename in `updateDocument()` is applied; and every + internal metadata write runs Structure validation, as in 7.x. + - `deleteCollection(Database::METADATA)` succeeds again, as in 7.x: it purges every cached definition before it + drops the metadata table. + - Unstorable attribute types (`timestamp`, `serial`, `smallserial`, `bigserial` and the other types no adapter can + store) are rejected when an attribute is created, including when `createAttribute()` adopts an existing column; + increments and numeric operators accept integer, bigint, float and double attributes only. + - A failed column change in `updateAttribute()` leaves the stored definition unchanged: relaxing `required` runs + before the metadata write. + - Reads on MongoDB, Memory and Redis return a stored document that has a non-string permission with that entry + dropped, as the SQL adapters do, instead of throwing `Exception\Structure`. + - The Redis adapter throws `Exception\Unique`, not a plain `Duplicate`, for unique index violations. + - Filter codecs stay on the handles that share their `Filter\Registry` instead of replacing global filters. + - A write no longer throws `Failed to finish document cache invalidation` after it commits when the cache is + flushed while the write invalidates its collection's cached documents, for example by `delete()` of another + database that shares the cache. +- **SQL adapters:** + - A transparent reconnect of `Utopia\Database\PDO` keeps MariaDB and MySQL statement timeouts; the timeout also + applies to the statement retried after the reconnect. + - `setMetadata()` values reach the database as query comments again. + - On SQLite, pattern queries match `_`, `%` and `\` literally again, and index names use the filtered tenant again. + - `ignoreDuplicates()` on PostgreSQL skips only a stored id again and throws `Unique` for a collision on another + unique index, as in 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. + - On MySQL, a read with five or more joins no longer spends seconds choosing a join order: the joined collections' + document permission checks stay subqueries instead of each joining the optimizer's search, which built about ten + million partial plans for eight checked joins. Reads with up to four joins are planned as before. Outer joins to + document-security collections no longer run their permission check as a semi-join either (seconds per read, or + timeouts, with one to four joins). + - PostgreSQL writes a transaction's statement timeout once, with `SET LOCAL statement_timeout`, and changes it only + when a statement needs a different one, instead of wrapping every statement of the transaction in `SET LOCAL` and + `SET LOCAL statement_timeout = DEFAULT`. A timeout scoped to one event still does not reach the other statements + of the transaction. Outside a transaction the session-level `SET`/`RESET` pair is unchanged. + - MariaDB and MySQL no longer read the driver to clear a timeout that was never set, so a pooled checkout without a + timeout issues no statement and does not require a PDO. + - SQL adapters no longer keep a per-collection spatial column list for the life of the process. +- **Pools:** + - A borrowed connection keeps its cached limits unless the shared-tables mode changes, and + `Database::getMinDateTime()` and `getMaxDateTime()` return copies, so a caller can no longer move the limit for + every validator. + - Writing a spatial value through `Pool` no longer checks a connection out to encode it. + - `ReadWritePool` serves reads from the primary after a write or transaction commits, routes locking reads, + `rawQuery()` and the reads that decide a write (the batch of `updateDocuments()` and `deleteDocuments()`, the + lookup of `upsertDocuments()`) to the write pool, and no longer keeps reads on the primary after metadata or + configuration calls. + - Pooled connections no longer keep the profiler of the handle that last borrowed them. + - `Adapter\Pool` passes `Database::setLocks()` on to every borrowed connection, and answers capability + questions without checking a connection out after the first (a cached read no longer needs eight connections). +- **Mirror:** + - A replicated `upsertDocument()` keeps the source's dates on the destination. + - A replication no longer changes the caller's authorization status. It runs under the status, roles, tenant, + relationship and silence state the caller had when it made the call, also after the caller left a `skip()`, + `skipRelationships()` or `silent()` scope, so a write made inside `skip()` no longer fails on the destination. + - Changes reach the destination one at a time, in the order they were made through the mirror; a failed + replication is reported to `onError()` and does not hold back later ones. A write to a document created through a + relationship, a write made before returning, and a relationship or collection change after queued writes to the + related collection no longer overtake earlier replications, and replications no longer share the destination's + connection, preserve-dates or skip-duplicates settings. + - Outside a coroutine, replications finish before the call returns; before, a destination write that yielded + never resumed. Synchronous replications inside `withTenant()` use the caller's tenant on the destination. + - An exception from a write filter's `before*` document hook is reported to `onError()` under the write's method + and skips that replication, as in 7.x, instead of reaching the caller after the source write. + - Decorators added through a mirror apply to the documents its writes return and hand `onNext`, not only to reads; + the destination receives undecorated documents. +- **Performance:** + - Permission checks no longer use `SELECT DISTINCT` in their subquery (SQLite built a temporary B-tree per read). + - A delete whose set-null relationship clears related documents keeps them only when `deleteDocument()` reports + them, for a two-way relationship while an active lifecycle hook handles `document_update`, instead of holding + every cleared document until the delete returns. + - The query and document caches list a collection's owner registrations (`HKEYS`) only until the cache shows it + keeps hash fields, instead of twice per invalidation. + - SQLite: `createDocument()` reads the new sequence from `PDO::lastInsertId()` instead of a + `SELECT last_insert_rowid()` statement: one statement fewer per document, as in 7.x. + - MongoDB: `find()` reads the internal attribute definitions once per process instead of once per returned row, and + skips list keys when it restores stored field names. + - MongoDB: `find()`, `createDocuments()`, `updateDocuments()` and `upsertDocuments()` cast a page or batch of + documents in one `castAfter()` call, one pool checkout instead of one per document. + - MariaDB and MySQL: a one-to-many joined read breaks ties on the joined `$id` only when its rows show the join or a + cursor is used. A left-joined read with a limit, no filter or search on a joined attribute and a main order + (`$sequence` or `$id` among them) joins only the page of main documents it can reach, fulltext searches on main + attributes included, instead of sorting the whole join. Inner, right and full outer one-to-many joins, filters and + searches on joined attributes and orders that start with a joined attribute still sort the whole join. See + [Joins](UPGRADE.md#joins). + - MySQL: under shared tables, an inner or left join whose own ON no index of the joined collection serves reads the + tenant's rows of the joined table once, matching its tenant as a range (`_tenant >= ? AND _tenant <= ?`), + instead of looking them all up again for every row the join pairs (50 s to 0.6 s for four such joins over 50 000 + rows). Joins an index serves, other join types and reads spanning several tenants are unchanged. +- **Tooling:** + - The `bin/` tasks (`load`, `index`, `query`, `relationships`, `operators`) start again: `bin/cli.php` no longer + registers a resource with a class the locked `utopia-php/di` does not have, and it loads the autoloader relative + to itself. `bin/query` builds its connection with `Utopia\Database\PDO`, like the other tasks. + +### Known limitations + +- A transaction begun on the adapter directly (`getAdapter()->startTransaction()`) is not an invalidation scope: each + write inside it invalidates the caches and fires `document_purge` before that transaction commits. Use + `withTransaction()`. +- A join on an unindexed attribute is accepted, but on a large collection it can exceed the statement timeout. On + MariaDB 10.11, which has no hash join and prunes the join orders it plans, a join whose ON columns are unindexed + can be very slow: index the attributes your join conditions compare. +- On MySQL, left joins on a joined collection's own `$id` can be slow right after the collection is created or + bulk-loaded, until InnoDB's automatic statistics recalculation has run (seconds, with the default + `STATS_AUTO_RECALC`). Run `ANALYZE TABLE` after a bulk load. + +### Dependencies + +- Requires `utopia-php/query` 0.7 and `utopia-php/async` 0.2. `utopia-php/async` requires `opis/closure`, which the + library itself does not use. `Utopia\Async\Serializer::unserialize()` no longer decodes closure payloads; use + `Serializer::unserializeTrusted()` for data from a trusted channel. +- utopia-php/query 0.7 has one join form with a required alias (`Query::join($collection, $alias, $on)`), carries + join and aggregate aliases in a property (`getAlias()`), types cursors as `array|object`, and refuses an ON list + member other than `on()` or a filter. Its PostgreSQL builder quotes each key of a JSON path it reads or writes, and + its MongoDB builder rejects an empty field name or one that starts with `$` in a filter. + +### Development + +- The test suite fails on PHP warnings, notices and deprecations, on risky tests, and on PHPUnit's own notices and + deprecations, raised in `src/` or `tests/`. +- CI tests utopia-php/cache 5.x: a second image built with `UTOPIA_CACHE_VERSION=^5.1` runs the unit suite and the + MariaDB adapter tests against it. Test cache doubles declare `save(..., int $ttl = 0)` so they load on 4.x and 5.x. +- A slim PHPStan rule in the library's own CI rejects magic `PDOStatement` property reads and hooked properties on + the hot paths (PHP 8.5's tracing JIT miscompiles them, php/php-src#22084). Consumers are not affected. +- The `bin/` benchmark tasks register `Hook\Permissions` and `Hook\Relationships`, so they measure the same work as + 7.4.0. +- `composer.json` declares `8.0.x-dev` as the branch alias of `dev-main`, so `"utopia-php/database": "^8.0"` resolves + before 8.0.0 is tagged (with `"minimum-stability": "dev"` and `"prefer-stable": true` in the root package). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0057824ec2..48990cd21c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,15 +80,17 @@ docker compose up -d --build To run all unit tests, use the following Docker command: ```bash -docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests +docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/unit ``` To run tests for a single file, use the following Docker command structure: ```bash -docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/Database/[FILE_PATH] +docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/[FILE_PATH] ``` +`FILE_PATH` is relative to `tests/` and starts with `unit/` or `e2e/`. + To run static code analysis, use the following phpstan command: ```bash @@ -110,7 +112,7 @@ To test your DB changes under load: ```bash docker compose exec tests bin/load --adapter=[adapter] --limit=[limit] [--name=[name]] -# [adapter]: either 'mongodb' or 'mariadb', no quotes +# [adapter]: 'mariadb', 'mysql' or 'postgres', no quotes # [limit]: integer of total documents to generate # [name]: (optional) name for new database ``` @@ -120,7 +122,7 @@ docker compose exec tests bin/load --adapter=[adapter] --limit=[limit] [--name=[ ```bash docker compose exec tests bin/index --adapter=[adapter] --name=[name] -# [adapter]: either 'mongodb' or 'mariadb', no quotes +# [adapter]: 'mariadb', 'mysql' or 'postgres', no quotes # [name]: name of filled database by bin/load ``` @@ -129,7 +131,7 @@ docker compose exec tests bin/index --adapter=[adapter] --name=[name] ```bash docker compose exec tests bin/query --adapter=[adapter] --limit=[limit] --name=[name] -# [adapter]: either 'mongodb' or 'mariadb', no quotes +# [adapter]: 'mariadb', 'mysql' or 'postgres', no quotes # [limit]: integer of query limit (default 25) # [name]: name of filled database by bin/load ``` diff --git a/Dockerfile b/Dockerfile index d43c2a167d..2272ff54ff 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,20 @@ RUN composer install \ --no-scripts \ --prefer-dist +ARG UTOPIA_CACHE_VERSION="" + +RUN if [ -n "$UTOPIA_CACHE_VERSION" ]; then \ + composer update utopia-php/cache \ + --with "utopia-php/cache:$UTOPIA_CACHE_VERSION" \ + --with-dependencies \ + --ignore-platform-reqs \ + --optimize-autoloader \ + --no-interaction \ + --no-plugins \ + --no-scripts \ + --prefer-dist; \ + fi + FROM php:8.5.8-cli-alpine AS compile ENV PHP_REDIS_VERSION="6.3.0" \ @@ -115,8 +129,6 @@ RUN EXT_DIR=$(php-config --extension-dir) \ RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini RUN echo extension=swoole.so >> /usr/local/etc/php/conf.d/swoole.ini RUN echo extension=pcov.so >> /usr/local/etc/php/conf.d/pcov.ini -RUN echo extension=xdebug.so >> /usr/local/etc/php/conf.d/xdebug.ini - RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini @@ -131,6 +143,6 @@ COPY ./dev /usr/src/code/dev RUN if [ "$DEBUG" = "true" ]; then cp /usr/src/code/dev/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini; fi RUN if [ "$DEBUG" = "true" ]; then mkdir -p /tmp/xdebug; fi RUN if [ "$DEBUG" = "false" ]; then rm -rf /usr/src/code/dev; fi -RUN if [ "$DEBUG" = "false" ]; then rm -f $(php-config --extension-dir)/xdebug.so; fi +RUN if [ "$DEBUG" = "false" ]; then rm -f /usr/local/etc/php/conf.d/xdebug.ini; fi CMD [ "tail", "-f", "/dev/null" ] diff --git a/README.md b/README.md index 309966b1d3..5977733716 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Utopia framework database library is simple and lite library for managing applic Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free, and can be used as standalone with any other PHP project or framework. +Upgrading from 7.x? Read [UPGRADE.md](UPGRADE.md). [CHANGELOG.md](CHANGELOG.md) lists what is new in each release. + ## Getting Started Install using composer: @@ -26,17 +28,32 @@ A list of the utopia/php concepts and their relevant equivalent using the differ - **Document** - A simple JSON object that will be stored in one of the utopia/database collections. For SQL-based adapters, this will be equivalent to a row. For a No-SQL adapter, this will equivalent to a native document. - **Attribute** - A simple document attribute. For SQL-based adapters, this will be equivalent to a column. For a No-SQL adapter, this will equivalent to a native document field. - **Index** - A simple collection index used to improve the performance of your database queries. -- **Permissions** - Using permissions, you can decide which roles have read, create, update and delete access for a specific document. The special attribute `$permissions` is used to store permission metadata for each document in the collection. A permission role can be any string you want. You can use `$authorization->addRole()` to delegate new roles to your users, once obtained a new role a user would gain read, create, update or delete access to a relevant document. +- **Permissions** - Using permissions, you can decide which roles have read, create, update and delete access for a specific document. The special attribute `$permissions` is used to store permission metadata for each document in the collection. A permission role can be any string you want. You can use `$database->getAuthorization()->addRole()` to delegate new roles to your users, once obtained a new role a user would gain read, create, update or delete access to a relevant document. +- **Hooks** - Objects registered with `$database->addHook()` that take part in database operations: document permissions and relationships are hooks, and so are your own event listeners. See [Hooks](#hooks). ### Filters -Attribute filters are functions that manipulate attributes before saving them to the database and after retrieving them from the database. You can add filters using the `Database::addFilter($name, $encode, $decode)` where `$name` is the name of the filter that we can add later to attribute `filters` array. `$encode` and `$decode` are the functions used to encode and decode the attribute, respectively. There are also instance-level filters that can only be defined while constructing the `Database` instance. Instance level filters override the static filters if they have the same name. +Attribute filters encode an attribute's value before it is saved and decode it after it is read. List a filter's name in an attribute's `filters`. + +- `Database::addFilter($name, $encode, $decode)` registers a filter for every `Database` instance in the process. Its callbacks receive the value, the document and the database. +- A `Utopia\Database\Filter\Codec` (`name()`, `encode()`, `decode()`) belongs to the instances you give it to: pass a list of codecs to the `Database` constructor, or put them on a `Filter\Registry` given to several instances with `setFilters()`. `Filter\Callback` builds a codec from two closures. Codecs override global filters of the same name. + +```php +use Utopia\Database\Database; +use Utopia\Database\Filter\Callback; + +$database = new Database($adapter, $cache, [ + new Callback('trim', fn (mixed $value) => \trim($value), fn (mixed $value) => $value), +]); +``` ### Custom Document Types The database library supports mapping custom document classes to specific collections, enabling a domain-driven design approach. This allows you to create collection-specific classes (like `User`, `Post`, `Product`) that extend the base `Document` class with custom methods and business logic. ```php +use Utopia\Database\Document; + // Define a custom document class class User extends Document { @@ -71,14 +88,16 @@ if ($user->isAdmin()) { ### Reserved Attributes - `$id` - the document unique ID, you can set your own custom ID or a random UID will be generated by the library. +- `$sequence` - the document's internal sequence number, set by the database when the document is created. - `$createdAt` - the document creation date, this attribute is automatically set when the document is created. - `$updatedAt` - the document update date, this attribute is automatically set when the document is updated. - `$collection` - an attribute containing the name of the collection the document is stored in. - `$permissions` - an attribute containing an array of strings. Each string represent a specific action and role. If your user obtains that role for that action they will have access for this document. +- `$tenant` - the tenant a document belongs to, when collections are shared between tenants (`setSharedTables(true)`). ### Attribute Types -The database document interface only supports primitives types (`strings`, `integers`, `floats`, and `booleans`) translated to their native database types for each of the relevant database adapters. Complex types like arrays or objects will be encoded to JSON strings when stored and decoded back when fetched from their adapters. +Attributes are built with a factory per type on `Utopia\Database\Attribute` (`Attribute::string()`, `integer()`, `lineString()`, ...). Their types are cases of `Utopia\Query\Schema\ColumnType`: `String`, `Varchar`, `Text`, `MediumText`, `LongText`, `Integer`, `BigInteger`, `Float`, `Double`, `Boolean`, `Datetime`, `Id`, `Relationship`, `Object`, `Point`, `Linestring`, `Polygon` and `Vector`. `Attribute::TYPES` lists them, and object, spatial and vector attributes need an adapter that supports them. Attributes of the other types can hold an array of values (`array: true`). Arrays and objects are encoded to JSON when stored and decoded back when fetched, where the adapter has no native type for them. ### Supported Databases @@ -86,15 +105,20 @@ Below is a list of supported databases, and their compatibly tested versions alo | Adapter | Status | Version | |----------|--------|---------| -| MariaDB | ✅ | 10.5 | +| MariaDB | ✅ | 10.11 | | MySQL | ✅ | 8.0 | -| Postgres | ✅ | 13.0 | +| Postgres | ✅ | 16 | | SQLite | ✅ | 3.38 | +| MongoDB | ✅ | 8.0 | +| Redis | ✅ | 8.2 | +| Memory | ✅ | - | ` ✅ - supported ` ` 🛠 - work in progress` +What an adapter supports is reported by `$database->getAdapter()->supports(Capability::...)` and `$database->getAdapter()->hasFeature(Feature\...::class)`. Joins and aggregations, for example, run on the SQL adapters. + ### Limitations #### MariaDB, MySQL, Postgres, SQLite @@ -121,31 +145,29 @@ Below is a list of supported databases, and their compatibly tested versions alo ### Connecting to a Database +`Utopia\Database\PDO` wraps PHP's PDO: it reconnects when a connection is lost outside a transaction and retries the call. Each SQL adapter also accepts a plain `PDO`. Pass the PDO attributes your application needs; the ones below are what the adapters expect. + #### MariaDB ```php require_once __DIR__ . '/vendor/autoload.php'; -use PDO; -use Utopia\Database\Database; -use Utopia\Cache\Cache; use Utopia\Cache\Adapter\Memory; +use Utopia\Cache\Cache; use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Database; +use Utopia\Database\PDO; -$dbHost = 'mariadb'; -$dbPort = '3306'; -$dbUser = 'root'; -$dbPass = 'password'; -$pdoConfig = [ - PDO::ATTR_TIMEOUT => 3, // Seconds - PDO::ATTR_PERSISTENT => true, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => true, - PDO::ATTR_STRINGIFY_FETCHES => true, -]; +$host = 'mariadb'; +$port = 3306; -$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, $pdoConfig); +$pdo = new PDO("mysql:host={$host};port={$port};charset=utf8mb4", 'root', 'password', [ + \PDO::ATTR_TIMEOUT => 3, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, +]); $cache = new Cache(new Memory()); // or use any cache adapter you wish @@ -157,30 +179,26 @@ $database = new Database(new MariaDB($pdo), $cache); ```php require_once __DIR__ . '/vendor/autoload.php'; -use PDO; -use Utopia\Database\Database; -use Utopia\Cache\Cache; use Utopia\Cache\Adapter\Memory; +use Utopia\Cache\Cache; use Utopia\Database\Adapter\MySQL; +use Utopia\Database\Database; +use Utopia\Database\PDO; -$dbHost = 'mysql'; -$dbPort = '3306'; -$dbUser = 'root'; -$dbPass = 'password'; -$pdoConfig = [ - PDO::ATTR_TIMEOUT => 3, // Seconds - PDO::ATTR_PERSISTENT => true, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => true, - PDO::ATTR_STRINGIFY_FETCHES => true, -]; +$host = 'mysql'; +$port = 3306; -$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, $pdoConfig); +$pdo = new PDO("mysql:host={$host};port={$port};charset=utf8mb4", 'root', 'password', [ + \PDO::ATTR_TIMEOUT => 3, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, +]); $cache = new Cache(new Memory()); // or use any cache adapter you wish -$database = new Database(new MySql($pdo), $cache); +$database = new Database(new MySQL($pdo), $cache); ``` #### Postgres @@ -188,26 +206,22 @@ $database = new Database(new MySql($pdo), $cache); ```php require_once __DIR__ . '/vendor/autoload.php'; -use PDO; -use Utopia\Database\Database; -use Utopia\Cache\Cache; use Utopia\Cache\Adapter\Memory; +use Utopia\Cache\Cache; use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Database; +use Utopia\Database\PDO; -$dbHost = 'postgres'; -$dbPort = '5432'; -$dbUser = 'root'; -$dbPass = 'password'; -$pdoConfig = [ - PDO::ATTR_TIMEOUT => 3, // Seconds - PDO::ATTR_PERSISTENT => true, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => true, - PDO::ATTR_STRINGIFY_FETCHES => true, -]; +$host = 'postgres'; +$port = 5432; -$pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, $pdoConfig); +$pdo = new PDO("pgsql:host={$host};port={$port}", 'root', 'password', [ + \PDO::ATTR_TIMEOUT => 3, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, +]); $cache = new Cache(new Memory()); // or use any cache adapter you wish @@ -219,23 +233,19 @@ $database = new Database(new Postgres($pdo), $cache); ```php require_once __DIR__ . '/vendor/autoload.php'; -use PDO; -use Utopia\Database\Database; -use Utopia\Cache\Cache; use Utopia\Cache\Adapter\Memory; +use Utopia\Cache\Cache; use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Database; +use Utopia\Database\PDO; -$dbPath = '/path/to/database.sqlite'; -$pdoConfig = [ - PDO::ATTR_TIMEOUT => 3, // Seconds - PDO::ATTR_PERSISTENT => true, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => true, - PDO::ATTR_STRINGIFY_FETCHES => true, -]; +$path = '/path/to/database.sqlite'; -$pdo = new PDO("sqlite:{$dbPath}", $pdoConfig); +$pdo = new PDO("sqlite:{$path}", null, null, [ + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_STRINGIFY_FETCHES => true, +]); $cache = new Cache(new Memory()); // or use any cache adapter you wish @@ -247,28 +257,87 @@ $database = new Database(new SQLite($pdo), $cache); ```php require_once __DIR__ . '/vendor/autoload.php'; -use Utopia\Database\Database; -use Utopia\Cache\Cache; use Utopia\Cache\Adapter\Memory; +use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Database; use Utopia\Mongo\Client; // from utopia-php/mongo -$dbHost = 'mongo'; -$dbPort = 27017; -$dbUser = 'root'; -$dbPass = 'password'; -$dbName = 'dbName'; - -$mongoClient = new Client($dbName, $dbHost, $dbPort, $dbUser, $dbPass, true); +$client = new Client('database', 'mongo', 27017, 'root', 'password', true); $cache = new Cache(new Memory()); // or use any cache adapter you wish -$database = new Database(new Mongo($mongoClient), $cache); +$database = new Database(new Mongo($client), $cache); +``` + +### Hooks + +Document permissions and relationships are hooks. Register both after you create the `Database`: + +```php +use Utopia\Database\Hook\Permissions; +use Utopia\Database\Hook\Relationships; + +// Writes, moves and deletes the permission rows that MariaDB, MySQL and SQLite check document permissions +// against. PostgreSQL checks the row's own _permissions column; MongoDB, Memory and Redis keep permissions +// with the document. +$database->addHook(new Permissions()); + +// Populates related documents and handles nested writes and cascades +$database->addHook(new Relationships()); ``` +Without `Hook\Permissions`, MariaDB, MySQL and SQLite neither write nor remove permission rows, and without +`Hook\Relationships` no `onDelete` rule runs. See +[UPGRADE.md](UPGRADE.md#register-the-permission-and-relationship-hooks). + +To act on database events, register a lifecycle hook. Its `handle()` receives one typed event object per event, such as `Event\Document\Created` with its `collection` and `document`. A hook that also implements `Selective` receives only the events its `handles()` accepts, and one that implements `Named` replaces the hook already registered under its name and can be silenced by name with `silent()`. + +```php +use Utopia\Database\Event; +use Utopia\Database\Event\Document\Created; +use Utopia\Database\Event\Document\Deleted; +use Utopia\Database\Event\Domain; +use Utopia\Database\Hook\Lifecycle; +use Utopia\Database\Hook\Named; +use Utopia\Database\Hook\Selective; + +final class AuditLog implements Lifecycle, Named, Selective +{ + /** @var list */ + public array $entries = []; + + public function getName(): string + { + return 'audit-log'; + } + + public function handles(Event $event): bool + { + return $event === Event::DocumentCreate || $event === Event::DocumentDelete; + } + + public function handle(Domain $event): void + { + if ($event instanceof Created || $event instanceof Deleted) { + $this->entries[] = $event->event->value.':'.$event->document->getId(); + } + } +} + +$auditLog = new AuditLog(); +$database->addHook($auditLog); + +// Run a callback without the named hooks, or without any hook when no names are given +$database->silent(fn () => $database->ping(), ['audit-log']); +``` + +A `Utopia\Database\Hook\Transform` rewrites SQL statements before they run, and a `Utopia\Database\Hook\Decorator` modifies the documents that reads and writes return. Both are registered with `addHook()` too, and `removeHook()` unregisters a hook. `Event\DispatcherHook` forwards events to listeners registered per event class and to a PSR-14 dispatcher. + ### Database Methods ```php +use Utopia\Database\Capability; // Get namespace $database->getNamespace(); @@ -286,432 +355,326 @@ $database->setDatabase( name: 'dbName' ); -// Creates a new database. +// Check if a database exists +if ($database->exists(database: 'dbName')) { + // Delete a database + $database->delete( + database: 'dbName' + ); +} + +// Creates a new database. // Uses default database as the name. $database->create(); +// Renames a database +$database->update( + database: 'dbName', + new: 'archive' +); + // Returns an array of all databases $database->list(); -// Delete database -$database->delete( - name: 'mydb' +// Check if collection exists +$database->collectionExists( + collection: 'users', + database: 'dbName' ); // Ping database it returns true if the database is alive $database->ping(); -// Check if database exists -$database->exists( - database: 'mydb' -); - -// Check if collection exists -$database->exists( - database: 'mydb', - collection: 'users' -); - -// Listen to events - -// Event Types -Database::EVENT_ALL -Database::EVENT_DATABASE_CREATE, -Database::EVENT_DATABASE_LIST, -Database::EVENT_COLLECTION_CREATE, -Database::EVENT_COLLECTION_LIST, -Database::EVENT_COLLECTION_READ, -Database::EVENT_ATTRIBUTE_CREATE, -Database::EVENT_ATTRIBUTE_UPDATE, -Database::EVENT_INDEX_CREATE, -Database::EVENT_DOCUMENT_CREATE, -Database::EVENT_DOCUMENT_UPDATE, -Database::EVENT_DOCUMENT_READ, -Database::EVENT_DOCUMENT_FIND, -Database::EVENT_DOCUMENT_COUNT, -Database::EVENT_DOCUMENT_SUM, -Database::EVENT_DOCUMENT_INCREASE, -Database::EVENT_DOCUMENT_DECREASE, -Database::EVENT_INDEX_DELETE, -Database::EVENT_DOCUMENT_DELETE, -Database::EVENT_ATTRIBUTE_DELETE, -Database::EVENT_COLLECTION_DELETE, -Database::EVENT_DATABASE_DELETE, - -$database->on( - Database::EVENT_ALL, - function($event, $data) { - // Do something - } -); - // Get Database Adapter $database->getAdapter(); -// Get List of keywords that cannot be used -$database->getKeywords(); +// Limits, capabilities and features of the adapter +$profile = $database->profile(); +$profile->limits->keywords; // names that cannot be used as attribute keys +$profile->supports(Capability::Joins); ``` ### Collection Methods ```php -// Creates two new collection named '$namespace_$collectionName' with attribute names '_id', '_uid', '_createdAt', '_updatedAt', '_permissions' -// The second collection is named '$namespace_$collectionName_perms' with attribute names '_id', '_type', '_permission', '_document' -$database->createCollection( - id: 'users' -); - -// Create collection with attributes and indexes -$attributes = [ - new Document([ - '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ], - 'name' => 'Jhon', - 'age' => 20 - ]), - new Document([ - '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ], - 'name' => 'Doe', - 'age' => 34 - ]), -] - -$indexes = [ - new Document([ - '$id' => ID::unique(), - 'type' => Database::INDEX_KEY, - 'attributes' => ['name'], - 'lengths' => [256], - 'orders' => ['ASC'], - ]), - new Document([ - '$id' => ID::unique(), - 'type' => Database::INDEX_KEY, - 'attributes' => ['name', 'age'], - 'lengths' => [128, 128], - 'orders' => ['ASC'], - ]) -]; - -$database->createCollection( - id: 'users', - attributes: $attributes, - indexes: $indexes -); - -// Update Collection Permissions -$database->updateCollection( +use Utopia\Database\Attribute; +use Utopia\Database\Collection; +use Utopia\Database\CollectionUpdate; +use Utopia\Database\Index; +use Utopia\Database\Permission; +use Utopia\Database\Role; +use Utopia\Query\OrderDirection; + +// Creates a new collection named 'users'. The SQL adapters store it as the table '$namespace_users', +// with the table '$namespace_users_perms' for its document permissions. +$database->createCollection(Collection::create( id: 'users', + attributes: [ + Attribute::string('name', size: 256), + Attribute::integer('age'), + ], + indexes: [ + Index::key('idx_name', ['name'], lengths: [256], orders: [OrderDirection::Asc]), + Index::key('idx_name_age', ['name', 'age'], lengths: [128, null], orders: [OrderDirection::Asc, OrderDirection::Desc]), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: true, +)); + +// Update Collection Permissions; a field left null keeps its stored value +$database->updateCollection('users', new CollectionUpdate( + permissions: [ + Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ], - documentSecurity: true -); + documentSecurity: true, +)); -// Get Collection -$database->getCollection( - id: 'users' -); +// Get Collection; throws Exception\NotFound when it does not exist +$collection = $database->getCollection('users'); +$collection->attributes(); // list of Attribute +$collection->indexes(); // list of Index + +// Find Collection; null when it does not exist +$database->findCollection('users'); // List Collections $database->listCollections( - limit: 25, + limit: 25, offset: 0 ); -// Deletes the two collections named 'namespace_$collectionName' and 'namespace_$collectionName_perms' -$database->deleteCollection( - id: 'users' -); - // Delete cached documents of a collection -$database->purgeCachedCollection( - collection: 'users' -); +$database->purgeCachedCollection('users'); + +// Deletes the collection and its permissions table +$database->createCollection(Collection::create(id: 'drafts')); +$database->deleteCollection('drafts'); ``` ### Attribute Methods ```php -// Data types -Database::VAR_STRING -Database::VAR_INTEGER -Database::VAR_FLOAT -Database::VAR_BOOLEAN -Database::VAR_DATETIME - +use Utopia\Database\Attribute; +use Utopia\Database\AttributeUpdate; +use Utopia\Database\Collection; +use Utopia\Database\Format; +use Utopia\Database\IntegerWidth; +use Utopia\Database\Permission; +use Utopia\Database\Role; +use Utopia\Database\Validator\Structure; +use Utopia\Query\Schema\ColumnType; +use Utopia\Validator\Range; + +$database->createCollection(Collection::create( + id: 'movies', + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], +)); -// Creates a new attribute named '$attributeName' in the '$namespace_$collectionName' collection. -$database->createAttribute( - collection: 'movies', - id: 'name', - type: Database::VAR_STRING, - size: 128, - required: true -); +// Creates a new attribute named 'name' in the 'movies' collection and returns the stored attribute. +// Every type has a factory: Attribute::string(), integer(), float(), boolean(), datetime(), point(), ... +$database->createAttribute('movies', Attribute::string('name', size: 128, required: true)); // New attribute with optional parameters -$database->createAttribute( - collection: 'movies', - id: 'genres', - type: Database::VAR_STRING, - size: 128, - required: true, - default: null, - signed: true, - array: false, - format: null, - formatOptions: [], - filters: [] -); - -// Updates the attribute named '$attributeName' in the '$namespace_$collectionName' collection. -$database-> updateAttribute( - collection: 'movies', - id: 'genres', - type: Database::VAR_STRING, - size: 128, - required: true, - default: null, - signed: true, - array: false, - format: null, - formatOptions: [], - filters: [] -); - -// Update the required status of an attribute -$database->updateAttributeRequired( - collection: 'movies', - id: 'genres', - required: true -); +$database->createAttribute('movies', Attribute::string( + 'genres', + size: 128, + required: false, + default: null, + array: true, + filters: [], +)); + +// Creates several attributes at once +$database->createAttributes('movies', [ + Attribute::string('director', size: 128), + Attribute::integer('year'), + Attribute::integer('views', signed: false, width: IntegerWidth::Bits64), + Attribute::float('price'), + Attribute::boolean('active'), +]); -// Update the attribute format -$database->updateAttributeFormat( - collection: 'movies', - id: 'genres', - format: null, +// A format is a validator registered for an attribute type +Structure::addFormat( + 'year', + fn (array $attribute) => new Range($attribute['formatOptions']['min'] ?? 0, $attribute['formatOptions']['max'] ?? 9999), + ColumnType::Integer ); -// Update the attribute format options -$database->updateAttributeFormatOptions( - collection: 'movies', - id: 'genres', - formatOptions: [] -); +// Updates an attribute. Only the fields given change; default: null and format: null remove them. +$database->updateAttribute('movies', 'year', new AttributeUpdate( + required: true, + format: new Format('year', ['min' => 1888, 'max' => 2100]), +)); -// Update the attribute filters -$database->updateAttributeFilters( - collection: 'movies', - id: 'genres', - filters: [] -); - -// Update the default value of an attribute -$database->updateAttributeDefault( - collection: 'movies', - id: 'genres', - default: 'sci-fi' -); +$database->updateAttribute('movies', 'director', new AttributeUpdate(default: 'Unknown')); // Check if attribute can be added to a collection -$collection = $database->getCollection('movies'); - -$attribute = new Document([ - '$id' => ID::unique(), - 'type' => Database::VAR_INTEGER, - 'size' => 256, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], -]); - -$database->checkAttribute( - collection: $collection, - attribute: $attribute -); +$database->checkAttribute('movies', Attribute::integer('rating')); // Get Adapter attribute limit $database->getLimitForAttributes(); // if 0 then no limit // Get Adapter index limit -$database->getLimitForIndexes(); +$database->getLimitForIndexes(); -// Renames the attribute from old to new in the '$namespace_$collectionName' collection. +// Renames the attribute from old to new in the 'movies' collection. +$database->createAttribute('movies', Attribute::string('tagline', size: 256)); $database->renameAttribute( collection: 'movies', - old: 'genres', - new: 'genres2' + old: 'tagline', + new: 'slogan' ); -// Deletes the attribute in the '$namespace_$collectionName' collection. -$database->deleteAttribute( - collection: 'movies', - id: 'genres' -); +// Deletes the attribute in the 'movies' collection. +$database->deleteAttribute('movies', 'slogan'); ``` ### Index Methods ```php -// Index types -Database::INDEX_KEY, -Database::INDEX_FULLTEXT -Database::INDEX_UNIQUE -Database::INDEX_SPATIAL -Database::INDEX_ARRAY - -// Insertion Order -Database::ORDER_ASC -Database::ORDER_DESC - - -// Creates a new index named '$indexName' in the '$namespace_$collectionName' collection. -// Note: The size for the index will be taken from the size of the attribute -$database->createIndex( - collection: 'movies', - id: 'index1', Database::INDEX_KEY, - attributes: ['name', 'genres'], - lengths: [128,128], - orders: [Database::ORDER_ASC, Database::ORDER_DESC] -); +use Utopia\Database\Index; +use Utopia\Query\OrderDirection; + +// Every index type has a factory: Index::key(), unique(), fulltext(), trigram(), spatial(), object(), ttl(), +// hnswEuclidean(), hnswCosine() and hnswDot(). Orders are OrderDirection::Asc and OrderDirection::Desc. + +// Creates a new index named 'index1' in the 'movies' collection and returns the stored index. +$database->createIndex('movies', Index::key( + 'index1', + ['name', 'year'], + lengths: [128, null], + orders: [OrderDirection::Asc, OrderDirection::Desc] +)); + +// Creates several indexes and writes the collection definition once +$database->createIndexes('movies', [ + Index::fulltext('index_name_search', ['name']), + Index::unique('index_director_year', ['director', 'year']), +]); -// Rename index from old to new in the '$namespace_$collectionName' collection. +// Rename index from old to new in the 'movies' collection. $database->renameIndex( - collection: 'movies', - old: 'index1', + collection: 'movies', + old: 'index1', new: 'index2' ); -// Deletes the index in the '$namespace_$collectionName' collection. -$database->deleteIndex( - collection: 'movies', - id: 'index2' -); -``` +// Deletes the index in the 'movies' collection. +$database->deleteIndex('movies', 'index2'); +``` ### Relationship Methods ```php -// Relationship types -Database::RELATION_ONE_TO_ONE -Database::RELATION_ONE_TO_MANY -Database::RELATION_MANY_TO_ONE -Database::RELATION_MANY_TO_MANY - -// Creates a relationship between the two collections with the default reference attributes -$database->createRelationship( - collection: 'movies', - relatedCollection: 'users', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true -); - +use Utopia\Database\Attribute; +use Utopia\Database\Collection; +use Utopia\Database\Permission; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Role; + +// Each relationship type has a factory: Relationship::oneToOne(), oneToMany(), manyToOne() and manyToMany(). +// What happens to related documents when a document is deleted is a RelationshipDeleteAction: +// Restrict (the default), Cascade or SetNull. + +// Creates a relationship between the two collections with the default reference attributes: +// 'users' on 'movies', and 'movies' on 'users' +$database->createRelationship('movies', Relationship::oneToOne('users', twoWay: true)); // Create a relationship with custom reference attributes -$database->createRelationship( - collection: 'movies', - relatedCollection: 'users', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - id: 'movies_id', - twoWayKey: 'users_id' -); - -// Relationship onDelete types -Database::RELATION_MUTATE_CASCADE, -Database::RELATION_MUTATE_SET_NULL, -Database::RELATION_MUTATE_RESTRICT, +$database->createCollection(Collection::create( + id: 'reviews', + attributes: [Attribute::string('body', size: 1024)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], +)); + +$database->createRelationship('movies', Relationship::oneToMany( + 'reviews', + key: 'reviews', + twoWay: true, + twoWayKey: 'movie', + onDelete: RelationshipDeleteAction::Cascade +)); // Update the relationship with the default reference attributes -$database->updateRelationship( - collection: 'movies', - id: 'users', - onDelete: Database::RELATION_MUTATE_CASCADE -); +$database->updateRelationship('movies', 'users', new RelationshipUpdate( + onDelete: RelationshipDeleteAction::SetNull +)); // Update the relationship with custom reference attributes -$database->updateRelationship( - collection: 'movies', - id: 'users', - onDelete: Database::RELATION_MUTATE_CASCADE, - newKey: 'movies_id', - newTwoWayKey: 'users_id', +$database->updateRelationship('movies', 'users', new RelationshipUpdate( + key: 'viewer', + twoWayKey: 'favoriteMovie', twoWay: true -); +)); // Delete the relationship with the default or custom reference attributes -$database->deleteRelationship( - collection: 'movies', - id: 'users' -); +$database->deleteRelationship('movies', 'viewer'); ``` ### Document Methods ```php -use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Document; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; +use Utopia\Database\Query; +use Utopia\Database\Role; +use Utopia\Database\SetType; // Id helpers -ID::unique(padding: 12) // Creates an id of length 7 + padding -ID::custom(id: 'my_user_3235') +Id::unique(padding: 12); // Creates an id of 13 characters from uniqid() followed by 12 random hex characters +Id::custom(id: 'my_user_3235'); // Role helpers -Role::any() -Role::guests() +Role::any(); +Role::guests(); Role::user( - identifier: ID::unique() - status: 'active' //optional -) -Role::users() + identifier: Id::unique(), + status: 'verified' // optional +); +Role::users(); Role::team( - identifier: ID::unique() -) + identifier: Id::unique() +); Role::team( - identifier: ID::unique() - dimension: '123' //team:id/dimension -) + identifier: Id::unique(), + dimension: '123' // team:id/dimension +); Role::label( identifier: 'admin' -) -Role::members( - identifier: ID::unique() -) - - +); +Role::member( + identifier: Id::unique() +); // Permission helpers -Permission::read(Role::any()), -Permission::create(Role::user(ID::unique())), -Permission::update(Role::user(ID::unique(padding: 23))), -Permission::delete(Role::user(ID::custom(id: 'my_user_3235'))) +Permission::read(Role::any()); +Permission::create(Role::user(Id::unique())); +Permission::update(Role::user(Id::unique(padding: 23))); +Permission::delete(Role::user(Id::custom(id: 'my_user_3235'))); // To create a document $document = new Document([ '$permissions' => [ Permission::read(Role::any()), - Permission::create(Role::user(ID::custom('1x'))), - Permission::update(Role::user(ID::unique(12))), - Permission::delete(Role::user($customId)), + Permission::update(Role::user(Id::custom('1x'))), + Permission::delete(Role::user(Id::unique(12))), ], - '$id' => ID::unique(), + '$id' => Id::unique(), 'name' => 'Captain Marvel', 'director' => 'Anna Boden & Ryan Fleck', 'year' => 2019, @@ -721,7 +684,7 @@ $document = new Document([ ]); $document = $database->createDocument( - collection: 'movies', + collection: 'movies', document: $document ); @@ -734,152 +697,203 @@ $document->getId(); // Check whether document in empty $document->isEmpty(); -// Increase an attribute in a document +// Increase an attribute in a document $database->increaseDocumentAttribute( - collection: 'movies', + collection: 'movies', id: $document->getId(), - attribute: 'name', - value: 24, + attribute: 'price', + value: 5, max: 100 ); // Decrease an attribute in a document $database->decreaseDocumentAttribute( - collection: 'movies', + collection: 'movies', id: $document->getId(), - attribute: 'name', - value: 24, - min: 100 + attribute: 'price', + value: 5, + min: 0 ); // Update the value of an attribute in a document -// Set types -Document::SET_TYPE_ASSIGN, // Assign the new value directly -Document::SET_TYPE_APPEND, // Append the new value to end of the array -Document::SET_TYPE_PREPEND // Prepend the new value to start of the array -Note: Using append/prepend with an attribute which is not an array, it will be set to an array containing the new value. +// Set types are cases of SetType: +// SetType::Assign assigns the new value directly (the default), +// SetType::Append appends the new value to the end of an array, +// SetType::Prepend prepends the new value to the start of an array. +// Appending or prepending to an attribute that is not an array sets it to an array containing the new value. +$document->setAttribute('name', 'Captain Marvel (2019)') + ->setAttribute('genres', 'superhero', SetType::Append); -$document->setAttribute(key: 'name', 'Chris Smoove') - ->setAttribute(key: 'age', 33, Document::SET_TYPE_ASSIGN); - -$database->updateDocument( - collection: 'users', - id: $document->getId(), +$document = $database->updateDocument( + collection: 'movies', + id: $document->getId(), document: $document -); +); // Update the permissions of a document -$document->setAttribute('$permissions', Permission::read(Role::any()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::create(Role::any()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::update(Role::any()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::delete(Role::any()), Document::SET_TYPE_APPEND) - -$database->updateDocument( - collection: 'users', - id: $document->getId(), +$document->setAttribute('$permissions', Permission::read(Role::users()), SetType::Append) + ->setAttribute('$permissions', Permission::update(Role::users()), SetType::Append); + +$document = $database->updateDocument( + collection: 'movies', + id: $document->getId(), document: $document ); -// Info regarding who has permission to read, create, update and delete a document -$document->getRead(); // returns an array of roles that have permission to read the document -$document->getCreate(); // returns an array of roles that have permission to create the document -$document->getUpdate(); // returns an array of roles that have permission to update the document -$document->getDelete(); // returns an array of roles that have permission to delete the document +// Roles that have permission to read, update and delete the document +$document->getPermissionsByType(PermissionType::Read); +$document->getPermissionsByType(PermissionType::Update); +$document->getPermissionsByType(PermissionType::Delete); + +// The document as an array, with or without some top-level keys +$document->only(['name', 'year']); +$document->except(['$permissions']); // Get document with all attributes $database->getDocument( - collection: 'movies', + collection: 'movies', id: $document->getId() -); +); // Get document with a sub-set of attributes $database->getDocument( - collection: 'movies', - id: $document->getId(), + collection: 'movies', + id: $document->getId(), queries: [ Query::select(['name', 'director', 'year']) ] ); -// Find documents +// Find documents // Query Types -Query::equal(attribute: "...", values: ["...", "..."]), -Query::notEqual(attribute: "...", value: "..."), -Query::lessThan(attribute: "...", value: 100), -Query::lessThanEqual(attribute: "...", value: 1000), -Query::greaterThan(attribute: "...", value: 1000), -Query::greaterThanEqual(attribute: "...", value: ...), -Query::contains(attribute: "...", values: ["...", "..."]), -Query::between(attribute: "...", start: 100, end: 1000), -Query::search(attribute: "...", value: "..."), -Query::select(attributes: ["...", "..."]), -Query::orderDesc(attribute: "..."), -Query::orderAsc(attribute: "..."), -Query::isNull(attribute: "..."), -Query::isNotNull(attribute: "..."), -Query::startsWith(attribute: "...", value: "..."), -Query::endsWith(attribute: "...", value: "..."), -Query::limit(value: 35), -Query::offset(value: 0), +$queries = [ + Query::equal(attribute: 'name', values: ['Captain Marvel', 'Frozen']), + Query::notEqual(attribute: 'director', value: 'Unknown'), + Query::lessThan(attribute: 'year', value: 2030), + Query::lessThanEqual(attribute: 'year', value: 2030), + Query::greaterThan(attribute: 'year', value: 2000), + Query::greaterThanEqual(attribute: 'year', value: 2000), + Query::containsAny(attribute: 'genres', values: ['action', 'comics']), // array attributes + Query::containsString(attribute: 'director', values: ['Boden']), // string attributes + Query::between(attribute: 'year', start: 2000, end: 2030), + Query::search(attribute: 'name', value: 'Marvel'), // needs a fulltext index on the attribute + Query::select(['name', 'year']), + Query::orderDesc(attribute: 'year'), + Query::orderAsc(attribute: 'name'), + Query::isNull(attribute: 'director'), + Query::isNotNull(attribute: 'director'), + Query::startsWith(attribute: 'name', value: 'Captain'), + Query::endsWith(attribute: 'director', value: 'Fleck'), + Query::limit(value: 35), + Query::offset(value: 0), +]; $database->find( - collection: 'movies', - queries: [ - Query::equal(attribute: 'name', values: ['Captain Marvel']), - Query::notEqual(attribute: 'year', value: 2019) - ], - timeout: 1 //timeout is optional -); - -// Find a document + collection: 'movies', + queries: [ + Query::equal(attribute: 'name', values: ['Captain Marvel (2019)']), + Query::notEqual(attribute: 'year', value: 2020) + ] +); + +// Find a document $database->findOne( - collection: 'movies', - queries: [ - Query::equal(attribute: 'name', values: ['Captain Marvel']), - Query::lessThan(attribute: 'year', value: 2019) + collection: 'movies', + queries: [ + Query::equal(attribute: 'name', values: ['Captain Marvel (2019)']), + Query::lessThan(attribute: 'year', value: 2030) ] -); +); + +// Read every match in batches of 100; a limit() caps how many are yielded +foreach ($database->cursor('movies', [Query::greaterThan('year', 2000)], batchSize: 100) as $movie) { + $movie->getId(); +} -// Get count of documents +// Get count of documents $database->count( - collection: 'movies', - queries: [ - Query::equal(attribute: 'name', values: ['Captain Marvel']), - Query::greaterThan(attribute: 'year', value: 2019) - ], + collection: 'movies', + queries: [ + Query::equal(attribute: 'name', values: ['Captain Marvel (2019)']), + Query::greaterThan(attribute: 'year', value: 2000) + ], max: 1000 // Max is optional ); // Get the sum of an attribute from all the documents $database->sum( - collection: 'movies', - attribute: 'price', - queries: [ - Query::greaterThan(attribute: 'year', value: 2019) + collection: 'movies', + attribute: 'price', + queries: [ + Query::greaterThan(attribute: 'year', value: 2000) ], max: null // max = null means no limit -); +); + +// Delete a cached document +// Note: Cached Documents or Collections are automatically deleted when a document or collection is updated or deleted +$database->purgeCachedDocument('movies', $document->getId()); + +// Delete documents in batches of at most Database::BATCH_SIZE; onNext receives each deleted document +$deleted = []; +$database->deleteDocuments( + collection: 'movies', + queries: [Query::lessThan(attribute: 'year', value: 1900)], + onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + } +); // Delete a document $database->deleteDocument( - collection: 'movies', + collection: 'movies', id: $document->getId() ); +``` -// Delete a cached document -Note: Cached Documents or Collections are automatically deleted when a document or collection is updated or deleted -$database->purgeCachedDocument( - collection: 'movies', - id: $document->getId() -); +### Joins and Aggregations + +The SQL adapters run joins and aggregations (`Capability::Joins`, `Capability::Aggregations`). A join names its alias and lists its conditions; a joined collection is read with the same permissions as a direct read of it, and its attributes come back under the join's alias. Aggregations run through `aggregate()`, which returns one row per group. +```php +use Utopia\Database\Document; +use Utopia\Database\Query; + +$movie = $database->createDocument('movies', new Document([ + 'name' => 'Frozen', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + 'price' => 19.99, + 'active' => true, + 'genres' => ['animation'], +])); + +$database->createDocument('reviews', new Document([ + 'body' => 'A classic', + 'movie' => $movie->getId(), +])); + +// Join the reviews to their movies, aliased 'm' +$database->find('reviews', [ + Query::join('movies', 'm', [Query::on('movie', '$id')]), + Query::select(['body', 'm.name']), +]); + +// Aggregate: one row per group, holding the groups and the aggregates +$database->aggregate('movies', [ + Query::count('*', 'movies'), + Query::avg('price', 'averagePrice'), + Query::groupBy(['active']), +]); ``` +Index the attributes your join conditions compare. On MariaDB and MySQL, a one-to-many join can sort the whole join before the limit; see [Joins](UPGRADE.md#joins) in the upgrade guide for when it does not. + ## System Requirements -Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. +Utopia Framework requires PHP 8.5 or later. We recommend using the latest PHP version whenever possible. ## Contributing diff --git a/SPEC.md b/SPEC.md index 3c2422a523..1706f39b44 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,7 +8,10 @@ This library will abstract multiple database technologies using the adapter's de * MariaDB * MySQL * Postgres +* SQLite * MongoDB +* Redis +* Memory ## Data Types @@ -36,36 +39,43 @@ Databases that don't support the storage of complex data types should store them Each database adapter should support the following action for fast storing and retrieval of collections of documents. **Databases** (Schemas for MariaDB) -* create -* delete +* create(string $name) +* update(string $name, string $new) +* exists(string $database) +* delete(string $name) **Collections** (Tables for MariaDB) -* createCollection($name) -* deleteCollection($name) +* createCollection(string $collection, array $attributes = [], array $indexes = []) +* collectionExists(string $database, string $collection) +* deleteCollection(string $collection) **Attributes** (Table columns for MariaDB) -* createAttribute(string $collection, string $name, string $type) -* deleteAttribute(string $collection, string $name) +* createAttribute(string $collection, Attribute $attribute) +* updateAttribute(string $collection, string $key, Attribute $attribute) +* renameAttribute(string $collection, string $old, string $new) +* deleteAttribute(string $collection, string $key) **Indices** (Table indices for MariaDB) -* createIndex(string $collection, string $name, string $type) -* deleteIndex(string $collection, string $name, string $type) +* createIndex(string $collection, Index $index) +* renameIndex(string $collection, string $old, string $new) +* deleteIndex(string $collection, string $key) **Documents** (Table rows columns for MariaDB) -* getDocument(string $collection, $id) -* createDocument(string $collection, array $data) -* updateDocument(string $collection, $id, array $data) -* deleteDocument(string $collection, $id) +* getDocument(Document $collection, string $id) +* createDocument(Document $collection, Document $document) +* updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions) +* deleteDocument(Document $collection, string $id) + +**Limits** +* limits(): Adapter\Limits ## Queries Each database adapter should allow querying simple and advanced queries in consideration of underline limitations. Method for quering data: -* find(string $collection, $filters) -* findFirst(string $collection, $filters) -* findLast(string $collection, $filters) -* count(string $collection, $filters) +* find(Document $collection, array $queries) +* count(Document $collection, array $queries) ### Supported Query Operations * Equal (==) diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000000..5871c5e343 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,2431 @@ +# Upgrading from 7.x to 8.0 + +This guide lists the changes you may need to make when you move from utopia-php/database 7.x (last release 7.4.1) +to 8.0. [CHANGELOG.md](CHANGELOG.md) lists everything that is new in 8.0. If you built against the unreleased +`feat-query-lib` branch, also read [Changes since the 8.0 pre-releases](#changes-since-the-80-pre-releases). + +- [Before you start](#before-you-start) +- [Register the permission and relationship hooks](#register-the-permission-and-relationship-hooks) +- [Constants are now enums](#constants-are-now-enums) +- [Namespaces](#namespaces) +- [Queries](#queries) +- [Schema: value objects](#schema-value-objects) +- [Lifecycle events are hooks](#lifecycle-events-are-hooks) +- [Relationships](#relationships) +- [Documents](#documents) +- [Bulk writes and reads](#bulk-writes-and-reads) +- [Configuration toggles](#configuration-toggles) +- [Coroutines](#coroutines) +- [Errors](#errors) +- [Caches](#caches) +- [Filters](#filters) +- [Adapters](#adapters) +- [Mirror](#mirror) +- [Validators and helpers](#validators-and-helpers) +- [Removed unused public methods](#removed-unused-public-methods) +- [Changes since the 8.0 pre-releases](#changes-since-the-80-pre-releases) +- [Rules for features new in 8.0](#rules-for-features-new-in-80) +- [Known limitations](#known-limitations) + +## Before you start + +- PHP 8.5 or later is required, as for 7.x. +- Two new dependencies are installed with the library. `utopia-php/query` 0.7 provides the query, schema and + builder types that 8.0 uses in its signatures (`Utopia\Query\Method`, `Utopia\Query\Schema\ColumnType`, ...). + `utopia-php/async` 0.2 is used by `Mirror` replication and the relationship hook. Its + `Utopia\Async\Serializer::unserialize()` no longer decodes closure payloads: code of your own that calls it on + closure payloads switches to `Serializer::unserializeTrusted()` (see utopia-php/async's UPGRADE.md). +- Many constants became enums, and many signatures now take or return enum cases. PHP never treats an enum case as + equal to a string, so a comparison like `$query->getMethod() === 'equal'` is now always `false` and raises no + error. Run PHPStan at level 4 or higher on your code after upgrading: it reports these comparisons. +- Most API changes have a one-to-one replacement. Each table below lists the 7.x form and the 8.0 form; every + removed name is listed with its replacement. 8.0 ships no aliases for removed names. + +## Register the permission and relationship hooks + +Document permissions and relationships are now hooks, and a `Database` registers neither on its own. Register both +right after you create the `Database`: + +```php +use Utopia\Database\Hook\Permissions; +use Utopia\Database\Hook\Relationships; + +$database->addHook(new Permissions()); +$database->addHook(new Relationships()); +``` + +`addHook()` throws `Utopia\Database\Exception` for a hook it does not recognise. A hook that implements +`Hook\Attachable` is given the database it is added to through `attach(Database $database)`, so hooks are configured +in their constructor and never take the database there: `new Relationships(bool $prepare = true)`, +`new Tenancy(string $column = Storage::TENANT)`. A `Relationships` hook belongs to one database: adding it to a second +one throws, so give each database its own instance (or a `clone`). `removeHook($hook)` unregisters a hook instance, or every hook of a +class when given its class name. + +- `Hook\Permissions` writes, moves and deletes the rows of each collection's permissions table (`_perms`) when a + document's `$permissions` change. What depends on those rows differs by engine: + - MariaDB, MySQL and SQLite check document-level permissions in `find()`, `count()` and `sum()` against these + rows. Without the hook they write no new rows and neither revoke nor delete existing ones, so these reads keep + following the rows written before (for example by 7.x): a permission removed with `updateDocument()` still makes + the document readable through `find()` while `getDocument()` refuses it, a deleted document's rows stay, and a + document created again with the same id is readable by the roles the deleted one granted. A document whose + permissions were written without the hook is returned by `find()` only through a collection-level permission. + - PostgreSQL checks the `_permissions` column of the document's own row, as in 7.x, so its reads follow the + current `$permissions` with or without the hook. The hook still maintains the `_perms` rows there. + - MongoDB, Memory and Redis keep permissions with the document and do not need it. +- `Hook\Relationships` populates related documents on reads and runs nested writes, the `onDelete` rules + (`Cascade`, `SetNull`, `Restrict`) and the relationship permission checks. Without it, a read returns a + relationship attribute's stored value (the related document's id) instead of the related document, a nested + related document cannot be written, and deleting a document leaves the documents related to it unchanged: no + cascade runs, no key is set to null and `Restrict` does not block the delete. + +## Constants are now enums + +Every removed string constant maps to an enum case with the same backing value, except `Database::VAR_BIGINT` (see +[Attribute types](#attribute-types)). Stored metadata and query strings do not change. + +### `Database` + +| 7.x | 8.0 | +|---|---| +| `VAR_STRING`, `VAR_VARCHAR`, `VAR_TEXT`, `VAR_MEDIUMTEXT`, `VAR_LONGTEXT`, `VAR_INTEGER`, `VAR_BOOLEAN`, `VAR_DATETIME`, `VAR_ID`, `VAR_OBJECT`, `VAR_VECTOR`, `VAR_RELATIONSHIP`, `VAR_POINT`, `VAR_LINESTRING`, `VAR_POLYGON` | `Utopia\Query\Schema\ColumnType::String`, `Varchar`, `Text`, `MediumText`, `LongText`, `Integer`, `Boolean`, `Datetime`, `Id`, `Object`, `Vector`, `Relationship`, `Point`, `Linestring`, `Polygon` | +| `VAR_UUID7` | `ColumnType::Uuid7`. It names MongoDB's sequence id type (`Database::getIdAttributeType()`) and is not an attribute type: an attribute stored as `uuid7` is refused (see [Stored metadata](#stored-metadata)) | +| `VAR_FLOAT` (`'double'`) | `ColumnType::Double`. `ColumnType::Float` (`'float'`) is a separate, new type | +| `VAR_BIGINT` (`'bigint'`) | `ColumnType::BigInteger`, whose value is `'biginteger'` (see [Attribute types](#attribute-types)) | +| `STRING_TYPES` | No replacement: list the string cases (`String`, `Varchar`, `Text`, `MediumText`, `LongText`) | +| `SPATIAL_TYPES` | `$attribute->isSpatial()` on an `Attribute` | +| `ATTRIBUTE_FILTER_TYPES` | No replacement: the `datetime()`, `point()`, `lineString()`, `polygon()`, `vector()` and `object()` factories add their filter themselves. The built-in filter names are the `Utopia\Database\Filter` cases | +| `INTERNAL_ATTRIBUTES` | `$database->internalAttributes()`, a list of `Attribute` (tenant-aware) | +| `INSERT_BATCH_SIZE`, `DELETE_BATCH_SIZE` | `Database::BATCH_SIZE` (see [Bulk writes and reads](#bulk-writes-and-reads)) | +| `INDEX_KEY`, `INDEX_UNIQUE`, `INDEX_FULLTEXT`, `INDEX_SPATIAL`, `INDEX_OBJECT`, `INDEX_TRIGRAM`, `INDEX_TTL`, `INDEX_HNSW_EUCLIDEAN`, `INDEX_HNSW_COSINE`, `INDEX_HNSW_DOT` | `Utopia\Query\Schema\IndexType::Key`, `Unique`, `Fulltext`, `Spatial`, `Object`, `Trigram`, `Ttl`, `HnswEuclidean`, `HnswCosine`, `HnswDot` | +| `ORDER_ASC`, `ORDER_DESC` | `Utopia\Query\OrderDirection::Asc`, `Desc`, for index orders and adapter order types alike | +| `ORDER_RANDOM` | `OrderDirection::Random` for queries (`Query::orderRandom()`). An index refuses it with `Exception\Index` | +| `PERMISSION_CREATE`, `PERMISSION_READ`, `PERMISSION_UPDATE`, `PERMISSION_DELETE`, `PERMISSION_WRITE` | `Utopia\Database\PermissionType::Create`, `Read`, `Update`, `Delete`, `Write` | +| `PERMISSIONS` | `[PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete]` | +| `RELATION_ONE_TO_ONE`, `RELATION_ONE_TO_MANY`, `RELATION_MANY_TO_ONE`, `RELATION_MANY_TO_MANY` | `Utopia\Database\RelationshipType::OneToOne`, `OneToMany`, `ManyToOne`, `ManyToMany` | +| `RELATION_MUTATE_CASCADE`, `RELATION_MUTATE_RESTRICT`, `RELATION_MUTATE_SET_NULL` | `Utopia\Database\RelationshipDeleteAction::Cascade`, `Restrict`, `SetNull` | +| `RELATION_SIDE_PARENT`, `RELATION_SIDE_CHILD` | `Utopia\Database\RelationshipSide::Parent`, `Child` | +| `CURSOR_AFTER`, `CURSOR_BEFORE` | `Utopia\Query\CursorDirection::After`, `Before` | +| `EVENT_*` (all 33) | `Utopia\Database\Event` cases: `EVENT_DOCUMENT_CREATE` is `Event::DocumentCreate`, `EVENT_ALL` is `Event::All`, and so on. The values are unchanged (`Event::DocumentCreate->value === 'document_create'`). New cases: `DatabaseUpdate`, `DocumentUpsert`, `DocumentAggregate`, `AttributeRename`, `IndexesCreate` | +| `COLLECTION` (protected) | `Database::collectionDefinition(): Collection` | + +### `Query` + +- The 48 `TYPE_*` constants are replaced by `Utopia\Query\Method` cases with the same values. Most names map + directly (`TYPE_EQUAL` is `Method::Equal`, `TYPE_CURSOR_AFTER` is `Method::CursorAfter`); these four do not: + `TYPE_GREATER` is `Method::GreaterThan`, `TYPE_GREATER_EQUAL` is `Method::GreaterThanEqual`, `TYPE_LESSER` is + `Method::LessThan` and `TYPE_LESSER_EQUAL` is `Method::LessThanEqual`. `Query::TYPE_ELEM_MATCH` is removed. +- `Query::TYPES` is removed. `Query::VECTOR_TYPES` is replaced by `Method::isVector()`; `Method` also has + `isFilter()`, `isSpatial()`, `isNested()`, `isAggregate()` and `isJoin()`. +- `Query::LOGICAL_TYPES` is public and holds `Method` cases. + +### `Operator` + +- The `TYPE_*` constants are replaced by `Utopia\Database\OperatorType` cases with the same values + (`TYPE_INCREMENT` is `OperatorType::Increment`, `TYPE_ARRAY_APPEND` is `OperatorType::ArrayAppend`, ...). +- `Operator::TYPES` is replaced by `OperatorType::cases()`. The protected `NUMERIC_TYPES`, `ARRAY_TYPES`, + `STRING_TYPES`, `BOOLEAN_TYPES` and `DATE_TYPES` are replaced by `OperatorType::isNumeric()`, `isArray()`, + `isString()`, `isBoolean()` and `isDate()`. + +### `Document` + +`Document::SET_TYPE_ASSIGN`, `SET_TYPE_APPEND` and `SET_TYPE_PREPEND` are replaced by +`Utopia\Database\SetType::Assign`, `Append` and `Prepend`. + +### Other constants + +- `Validator\Query\Select::INTERNAL_ATTRIBUTES` (protected) is removed. `$database->internalAttributes()` returns the + internal attributes as `Attribute` value objects, including `$tenant` under shared tables. +- `Adapter\SQL::VECTOR_DISTANCE_COLUMN` (protected) is replaced by `Utopia\Database\Storage::DISTANCE`. + +### Arguments that take enum cases + +These methods take or return enum cases where 7.x used the constants' strings: + +| Method | Argument or return value | +|---|---| +| `Database::find()`, `cursor()` | `PermissionType $forPermission = PermissionType::Read` | +| `Database::setTimeout()`, `clearTimeout()` | `Event $event = Event::All` | +| `Database::getIdAttributeType()` | returns a `ColumnType` case (was a string) | +| `Document::setAttribute()` | `SetType $type = SetType::Assign` | +| `Document::getPermissionsByType()` | `PermissionType\|string $type`: a string reads a type of your own, such as `execute` | +| `Query::getMethod()` | returns a `Method` case (see [Queries](#queries)) | +| `Operator::__construct()`, `setMethod()` | `OperatorType $method` | +| `Operator::getMethod()` | returns an `OperatorType` case | + +## Namespaces + +Moved classes keep no alias under their 7.x name. + +| 7.x | 8.0 | +|---|---| +| `Utopia\Database\Helpers\ID` | `Utopia\Database\Id` | +| `Utopia\Database\Helpers\Permission`, `Helpers\Role` | `Utopia\Database\Permission`, `Utopia\Database\Role` | +| `Utopia\Database\Mirroring\Filter` | `Utopia\Database\Mirror\Filter`; its `init()` is `initialize()` | +| `Validator\Attribute` | `Validator\AttributeDefinition` (see [Validators and helpers](#validators-and-helpers)) | +| `Validator\Index` | `Validator\IndexDefinition` | +| `Validator\Queries` | `Validator\Queries\Base` | +| `Validator\IndexedQueries` | `Validator\Queries\Indexed` | +| `Validator\ObjectValidator` | `Validator\ObjectValue` | + +```php +// 7.x +use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; + +// 8.0 +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\Role; + +$permissions = [Permission::read(Role::user(Id::unique()))]; +``` + +## Queries + +- `Utopia\Database\Query` now extends `Utopia\Query\Query`. Every 7.x method still exists, and the query wire format + (`Query::parse()`, JSON) is unchanged. +- `Query::getMethod()` returns a `Utopia\Query\Method` case instead of a string, and `Operator::getMethod()` returns + an `OperatorType` case. Compare with cases: `$query->getMethod() === Method::Equal`. A comparison with a string is + always `false` and raises no error (see [Before you start](#before-you-start)). `setMethod()`, `isMethod()` and the + constructor accept a `Method` case or its string value. +- `Query::groupByType()` returns a `Utopia\Database\ParsedQuery` object instead of an array. It extends the query + library's `ParsedQuery` with `orderAttributes` and `orderTypes` (`OrderDirection` cases); read the groups from its + properties. A cursor that is not a document throws `Exception\Query`. `Query::groupForDatabase()` is removed. +- `Query::cursorAfter()` and `Query::cursorBefore()` take `array|object` instead of `Document`. Pass the cursor + document, as before. `Validator\Query\Cursor` accepts a `Document` or a document id and refuses an array. +- Aggregates and joins carry their alias in a property: read it with `getAlias()`. `toArray()` writes it as + `"alias"`, and `parse()` reads it there as well as from the 7.x positions. +- `Query::parse()`, `parseQuery()` and `parseQueries()` take a trailing `bool $allowRaw = false`. Raw queries are + refused unless it is `true`, so leave it `false` for input you do not control. +- `Query::orderAsc()` and `Query::orderDesc()` take an optional `?Utopia\Query\NullsPosition $nulls`. +- The factory methods return `static` instead of `Query`. +- `Query::contains()` is deprecated, and every call raises `E_USER_DEPRECATED`. Use `containsString()` for substring + matching on string attributes and `containsAny()` for array attributes. Queries whose method is `contains` (for + example parsed from JSON) keep working. +- `Query::DEFAULT_ALIAS`, the alias of the queried collection in generated SQL, is now `table_main` (it was + `main`). Raw SQL fragments or selections that named `main.` have to use `Query::DEFAULT_ALIAS`. A join may + not use it as its alias, in any letter case. +- `notContains` on an array attribute excludes documents whose array is NULL (or missing, on MongoDB) on every + adapter. SQLite and MongoDB used to include them. To match those documents as well, combine it with + `Query::isNull()` in `Query::or()`. +- On SQLite, `contains`, `containsAny`, `containsAll` and `notContains` on array attributes compare elements by value + (strings, integers, doubles, booleans). +- On MongoDB, `startsWith()` and `endsWith()` are anchored: `startsWith('foo')` no longer returns `barfoo`, and + `endsWith('foo')` no longer returns `foobar`. Both remain case-sensitive on MongoDB (on MariaDB and MySQL they + follow the column collation). A caller that relied on the substring behaviour should use `containsString()`. +- `orderRandom()` is validated against `Capability::OrderRandom`. On MongoDB, which does not support it, `find()` + throws `Utopia\Database\Exception\Query` (`Random order is not supported by this adapter`) instead of a generic + `Exception`. +- `distinct()` needs `Capability::Aggregations`. On Memory, Redis and MongoDB, `find()` refuses it with + `Utopia\Database\Exception\Query` (`Distinct queries are not supported by this adapter`) also when validation is + skipped; MongoDB returned duplicate rows then. +- A filter on a path into an object attribute (`meta.address.city`) takes keys of `a-z`, `A-Z`, `0-9`, `_` and `-` + only. The query validator refuses any other key with `Utopia\Database\Exception\Query` on every adapter (7.x + refused such keys on PostgreSQL only), and on PostgreSQL the query is refused also when validation is skipped. +- On PostgreSQL, an exact search (`search('title', '"foo bar"')`) matches the words as an adjacent phrase, as on + MariaDB, MySQL and SQLite, and `notSearch()` with an exact term excludes only that phrase. In 7.4.0 PostgreSQL + matched both words in any order. To match both words in any order, pass a `search()` for each word. + +## Schema: value objects + +The schema methods take value objects instead of long lists of scalar arguments, and the create and update methods +return what they stored. `Attribute`, `Index` and `Relationship` are `final readonly` classes with a private +constructor: build them with their factories. `Collection` stays a `Document`, because it is the metadata row, and is +built with `Collection::create()`. Updates take `AttributeUpdate`, `CollectionUpdate` and `RelationshipUpdate`. + +```php +// 7.x +$database->createCollection('movies', $attributes, $indexes, [Permission::read(Role::any())], true); +$database->createAttribute('movies', 'year', Database::VAR_INTEGER, 0, true); +$database->createIndex('movies', 'idx_year', Database::INDEX_KEY, ['year'], [], [Database::ORDER_DESC]); +$database->createRelationship('movies', 'reviews', Database::RELATION_ONE_TO_MANY, true, 'reviews', 'movie', Database::RELATION_MUTATE_CASCADE); +$database->updateAttributeRequired('movies', 'year', false); +$database->updateRelationship('movies', 'reviews', onDelete: Database::RELATION_MUTATE_SET_NULL); +$database->updateCollection('movies', [Permission::read(Role::any())], false); + +// 8.0 +$database->createCollection(Collection::create( + id: 'movies', + attributes: $attributes, + indexes: $indexes, + permissions: [Permission::read(Role::any())], + documentSecurity: true, +)); +$database->createAttribute('movies', Attribute::integer('year', required: true)); +$database->createIndex('movies', Index::key('idx_year', ['year'], orders: [OrderDirection::Desc])); +$database->createRelationship('movies', Relationship::oneToMany( + 'reviews', + key: 'reviews', + twoWay: true, + twoWayKey: 'movie', + onDelete: RelationshipDeleteAction::Cascade, +)); +$database->updateAttribute('movies', 'year', new AttributeUpdate(required: false)); +$database->updateRelationship('movies', 'reviews', new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull)); +$database->updateCollection('movies', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: false)); +``` + +Every schema method names its collection argument `$collection`, and its attribute, index or relationship argument +`$key`. + +| 7.x | 8.0 | +|---|---| +| `createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document` | `createCollection(Collection $collection): Collection` | +| `updateCollection(string $id, array $permissions, bool $documentSecurity): Document` | `updateCollection(string $collection, CollectionUpdate $update): Collection`. A `null` field keeps the stored value | +| `getCollection(string $id): Document`, an empty `Document` when the collection is missing | `getCollection(string $collection): Collection`, which throws `Exception\NotFound` when it is missing | +| — | `findCollection(string $collection): ?Collection`, `null` when the collection is missing. A miss fires no event | +| `deleteCollection(string $id): bool` | `deleteCollection(string $collection): void` | +| `exists(?string $database = null, ?string $collection = null): bool` | `exists(?string $database = null): bool` for a database, `collectionExists(string $collection, ?string $database = null): bool` for a collection | +| `createAttribute(string $collection, string $id, string $type, int $size, bool $required, mixed $default = null, bool $signed = true, bool $array = false, ?string $format = null, array $formatOptions = [], array $filters = []): bool` | `createAttribute(string $collection, Attribute $attribute): Attribute` | +| `createAttributes(string $collection, array $attributes): bool`, with attribute arrays | `createAttributes(string $collection, array $attributes): array`, a list of `Attribute` in and out | +| `updateAttribute(string $collection, string $id, ?string $type = null, ?int $size = null, ?bool $required = null, mixed $default = null, ?bool $signed = null, ?bool $array = null, ?string $format = null, ?array $formatOptions = null, ?array $filters = null, ?string $newKey = null): Document` | `updateAttribute(string $collection, string $key, AttributeUpdate $update): Attribute` | +| `updateAttributeRequired()`, `updateAttributeFormat()`, `updateAttributeFormatOptions()`, `updateAttributeFilters()`, `updateAttributeDefault()` | `updateAttribute()` with the `AttributeUpdate` field of the same name (`required`, `format`, `filters`, `default`) | +| `renameAttribute(string $collection, string $old, string $new): bool` | `renameAttribute(string $collection, string $old, string $new): void`. `AttributeUpdate(key: ...)` renames as well | +| `deleteAttribute(string $collection, string $id): bool` | `deleteAttribute(string $collection, string $key): void` | +| `checkAttribute(Document $collection, Document $attribute): bool` | `checkAttribute(string $collection, Attribute $attribute): bool` | +| `createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 1): bool` | `createIndex(string $collection, Index $index): Index` | +| — | `createIndexes(string $collection, array $indexes): array`. It validates every index first, creates each one, then writes the definition once | +| `renameIndex(string $collection, string $old, string $new): bool` | `renameIndex(string $collection, string $old, string $new): void` | +| `deleteIndex(string $collection, string $id): bool` | `deleteIndex(string $collection, string $key): void` | +| `createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, ?string $id = null, ?string $twoWayKey = null, string $onDelete = Database::RELATION_MUTATE_RESTRICT): bool` | `createRelationship(string $collection, Relationship $relationship): Relationship`, with both keys resolved | +| `updateRelationship(string $collection, string $id, ?string $newKey = null, ?string $newTwoWayKey = null, ?bool $twoWay = null, ?string $onDelete = null): bool` | `updateRelationship(string $collection, string $key, RelationshipUpdate $update): Relationship`. It works from either side | +| `deleteRelationship(string $collection, string $id): bool` | `deleteRelationship(string $collection, string $key): void` | +| `getInternalAttributes(): array` | `internalAttributes(): array`, a list of `Attribute` | +| `getSchemaAttributes(string $collection): array`, `getSchemaIndexes(string $collection): array`, lists of `Document` | Lists of `Schema\Column` and `Schema\Index` (see [Schema introspection](#schema-introspection)) | +| `getIdAttributeType(): string` | `getIdAttributeType(): ColumnType` | +| — | `update(string $database, string $new): bool` renames a database (see [Renaming a database](#renaming-a-database)) | + +`create()`, `exists()`, `update()` and `delete()` on a database keep returning `bool`. + +### `Attribute` + +One factory per type. Each takes only the arguments its type uses: + +| Factory | Notes | +|---|---| +| `string($key, int $size = Database::LENGTH_KEY, ...)`, `varchar(...)` | Also `required`, `default`, `array`, `format`, `filters` | +| `text($key, ?int $size = null, ...)`, `mediumText(...)`, `longText(...)` | `size: null` means the engine's maximum for the type | +| `integer($key, ..., bool $signed = true, bool $array = false, IntegerWidth $width = IntegerWidth::Bits32, ...)` | A 7.x integer of size 8 or more is `width: IntegerWidth::Bits64` | +| `bigInteger(...)`, `float(...)`, `double(...)` | `signed`, `array`, `format`, `filters` | +| `boolean($key, bool $required = false, bool\|array\|null $default = null, bool $array = false, array $filters = [])` | | +| `datetime($key, bool $required = false, string\|array\|null $default = null, bool $array = false)` | Adds the `datetime` filter | +| `point($key, ...)`, `lineString($key, ...)`, `polygon($key, ...)` | Never arrays; add their filter | +| `vector($key, int $dimensions, ...)` | The size is the dimension count | +| `object($key, ...)`, `id($key, ...)` | | +| `relationship($key, Relationship $relationship, RelationshipSide $side)` | Built for you by `createRelationship()` | + +```php +// 7.x +$database->createAttribute('movies', 'rating', Database::VAR_INTEGER, 8, false, 0, false, false, 'range', ['min' => 0, 'max' => 10], ['encrypt']); + +// 8.0 +$database->createAttribute('movies', Attribute::integer( + 'rating', + default: 0, + signed: false, + width: IntegerWidth::Bits64, + format: new Format('range', ['min' => 0, 'max' => 10]), + filters: ['encrypt'], +)); +``` + +- **Properties, not getters.** Read `$attribute->key`, `type` (`ColumnType`), `size` (`?int`, `null` where the type + has no size), `required`, `default`, `signed`, `array`, `format` (`?Format`, with `name` and `options`), `filters` + (`list`), `relationship` (`?Relationship`) and `side` (`?RelationshipSide`). A 7.x attribute's + `format` and `formatOptions` are one `Format`. `filters` takes `Utopia\Database\Filter` cases or names. +- **Helpers.** `width(): ?IntegerWidth` (integers only), `resolvedSize(): int`, `isSpatial()`, `isNumeric()`, + `isInteger()`, `bounds(): ?NumericBounds`, the static `Attribute::isRelationship(Document $attribute)`, and + `apply(AttributeUpdate $update)` and `withFilters(array $filters)`, which return a changed copy. + `Attribute::availableTypes(Adapter\Profile $profile)` lists the types an adapter supports, out of `Attribute::TYPES`. +- **Storage.** `Attribute::fromDocument()` reads a stored attribute document and `toDocument()` writes one with the + same keys. `Attribute::fromArray()` reads an array. `Attribute::typeFromStored()` and `Attribute::storedType()` + convert between `ColumnType` cases and stored type strings (see [Attribute types](#attribute-types)). +- **Library fields only.** `status` and `options` are not attribute fields any more: `fromDocument()` ignores both, + except a relationship's options, which become `relationship` and `side`. Keep application fields such as a + `status` in your own documents. +- **Normalised values.** The factories, `apply()`, `withFilters()`, `fromDocument()` and `fromArray()` keep each + type's invariants: a type's own filter is always kept, `size` is `null` where the factory takes no size, and + spatial, object and vector attributes are never arrays. A stored 7.x attribute reads as the attribute its factory + builds: a datetime stored `signed: true` reads unsigned, an integer stored with size 4 reads as `Bits32` with no + size, and a spatial attribute stored with a size or an array flag is read without them. Stored values are + normalised per type: + + | Factory | Stored values | + |---|---| + | `datetime()` | `signed: false`, `filters: ['datetime']` | + | `point()`, `lineString()`, `polygon()` | `array: false`, `filters: []` | + | `vector()` | `size: `, `array: false`, `filters: ['vector']` | + | `object()` | `array: false`, `filters: ['object']` | + | `integer()` | `size` 0 (`Bits32`) or 8 (`Bits64`) | + | `boolean()`, `double()`, `id()` | `size: 0` | + | `string()`, `varchar()`, `text()`, `mediumText()`, `longText()` | `signed: true` | + | `relationship()` | the 7.x `options` shape plus `side` | + + Code that compares stored attribute documents field by field (for example to detect drift) has to compare these + normalised values: compare the attributes `fromDocument()` reads, not the raw documents, which keep the 7.x + values until the collection's attributes are next written. +- **64-bit integers.** `bounds()` gives a `Bits64` integer the bounds `[PHP_INT_MIN, PHP_INT_MAX]` signed and + `[0, PHP_INT_MAX]` unsigned; 7.x applied the 32-bit range. `increaseDocumentAttribute()`, + `decreaseDocumentAttribute()` and the numeric operators on a size-8 integer accept values past 2147483647. + +### `Index` + +| Factory | Notes | +|---|---| +| `key($key, array $attributes, array $lengths = [], array $orders = [])`, `unique(...)` | Orders are `?OrderDirection` | +| `fulltext($key, array $attributes)`, `trigram($key, array $attributes)` | No lengths or orders | +| `spatial($key, string $attribute, ?OrderDirection $order = null)`, `object($key, string $attribute)` | One attribute | +| `hnswEuclidean($key, string $attribute)`, `hnswCosine(...)`, `hnswDot(...)` | One vector attribute | +| `ttl($key, string $attribute, int $ttl)` | `ttl` of at least 1 | + +```php +// 7.x +$database->createIndex('movies', 'expiry', Database::INDEX_TTL, ['expiresAt'], [], [], 3600); + +// 8.0 +$database->createIndex('movies', Index::ttl('expiry', 'expiresAt', 3600)); +``` + +- Read `$index->key`, `type` (`IndexType`), `attributes`, `lengths` (`list`), `orders` + (`list`) and `ttl` (`?int`, TTL indexes only). `withKey()`, `withLengths()` and `withOrders()` + return a changed copy. +- `OrderDirection::Random` and a `ttl` below 1 throw `Exception\Index`. +- Fulltext and TTL indexes store no `orders`, fulltext indexes store no `lengths`, and an index other than TTL no + longer stores `ttl: 1`. `Index::fromDocument()` reads both shapes, dropping the `lengths` and `orders` a 7.x + fulltext or TTL index stored, the 7.x `'asc'`/`'desc'` orders, and the legacy type `index` as a key index. + +### `Relationship` + +`Relationship::oneToOne()`, `oneToMany()`, `manyToOne()` and `manyToMany()` take +`(string $relatedCollection, ?string $key = null, bool $twoWay = false, ?string $twoWayKey = null, +RelationshipDeleteAction $onDelete = RelationshipDeleteAction::Restrict)`. The collection it belongs to is the first +argument of `createRelationship()`. A `null` key is derived from the related collection's id and a `null` two-way key +from the source collection's id; `createRelationship()` returns the relationship with both resolved. + +- Read `$relationship->relatedCollection`, `type` (`RelationshipType`), `twoWay`, `key`, `twoWayKey` and + `onDelete` (`RelationshipDeleteAction`). `inverse(string $collection)` is the other side's view, and + `apply(RelationshipUpdate $update)` a changed copy. +- `onDelete` has three cases: `Cascade`, `Restrict` and `SetNull`. `RelationshipDeleteAction::toForeignKeyAction()` + converts for the adapter. +- A relationship attribute of a `Collection` carries its `Relationship` and `RelationshipSide` in + `$attribute->relationship` and `$attribute->side`. + +### `Collection` + +```php +// 7.x +$collection = $database->getCollection('movies'); +if ($collection->isEmpty()) { + // missing +} +foreach ($collection->getAttribute('attributes', []) as $attribute) { + $type = $attribute->getAttribute('type'); +} + +// 8.0 +$collection = $database->findCollection('movies'); +if ($collection === null) { + // missing +} +foreach ($collection->attributes() as $attribute) { + $type = $attribute->type; +} +``` + +- `Collection::create(string $id, string $name = '', array $attributes = [], array $indexes = [], ?array $permissions + = null, bool $documentSecurity = true, array $metadata = [])` builds one; `Collection::fromArray()` and + `Collection::fromDocument()` read a stored definition. +- `attributes()` and `indexes()` return the definition as `Attribute` and `Index` lists, built on the first call. + `name()`, `documentSecurity()` and `declaredPermissions(): ?array` read the other fields. + `Collection::NAME`, `ATTRIBUTES`, `INDEXES` and `DOCUMENT_SECURITY` name the stored keys. +- `documentSecurity` is always stored: `Collection::create(documentSecurity: false)` reads back `false`. +- `$metadata` takes only keys the metadata collection stores. `create()` refuses `attributes`, `indexes` and + `documentSecurity` in it (pass them as arguments), and `createCollection()` throws `Exception\Structure` for a + collection carrying any other key, before anything is created. + +### Updates + +`new AttributeUpdate(?ColumnType $type = null, ?int $size = null, ?bool $required = null, mixed $default = +Unchanged::Value, ?bool $signed = null, ?bool $array = null, Format|Unchanged|null $format = Unchanged::Value, ?array +$filters = null, ?string $key = null)` is sparse: `null` keeps a field. `default` and `format` use +`Unchanged::Value` to keep the stored value, because `null` is a value for them: + +- `default: null` removes the default, and `format: null` removes the format. +- A default on an attribute stored as required throws, and so does a default sent together with `required: true`. + `required: true` alone removes the stored default. +- `required: false` relaxes the column's `NOT NULL`. 7.x's `updateAttributeRequired(false)` changed only the + metadata and left the column `NOT NULL`. +- `key` renames the attribute, like `renameAttribute()`. +- A `Mirror` replicates attribute updates to its destination. + +`new CollectionUpdate(?array $permissions = null, ?bool $documentSecurity = null)` and +`new RelationshipUpdate(?string $key = null, ?string $twoWayKey = null, ?bool $twoWay = null, +?RelationshipDeleteAction $onDelete = null)` follow the same rule. + +### Stored metadata + +Collection metadata written by 7.x reads without migration: every 7.4 `VAR_*` type hydrates. Two kinds of stored +value make a collection unreadable, because hydrating its definition throws, so every read and write of the +collection fails until the row is repaired: + +- an attribute type outside `Attribute::TYPES`, `uuid7` included: `Exception\Structure`; +- a relationship `onDelete` of `setDefault` or `noAction`: `Exception\Relationship`. + +Repair such a row before you upgrade, while 7.x still reads it: set a supported `onDelete` with +`updateRelationship()`, and delete or recreate the attribute with a supported type. After the upgrade, edit the +collection's row in the metadata table (`_metadata`): in its `attributes`, set the relationship's `options.onDelete` +on both sides to `cascade`, `restrict` or `setNull`, or remove the attribute of the unknown type. Then drop the cached +definition with `$database->purgeCachedDocument(Database::METADATA, $collection)`. + +An index of an unknown stored type is read as a key index, so it does not block reads. Creating such an index is +refused. + +### Attribute types + +- `Database::VAR_BIGINT` is replaced by `ColumnType::BigInteger`, whose value is `'biginteger'`. The type stored in + collection metadata is still `'bigint'`, exactly as in 7.x: existing rows need no migration and new bigint + attributes are written as `'bigint'` too. Do not compare or write stored type strings against + `ColumnType::BigInteger->value`. Use the value object (`$attribute->type === ColumnType::BigInteger`), read a raw + string with `Attribute::typeFromStored($type)` (it accepts `'bigint'` and `'biginteger'`), and write one with + `Attribute::storedType($type)` (it returns `'bigint'` for `ColumnType::BigInteger` and the enum value for every + other type). Error messages that name a type use the stored spelling too: a bigint default mismatch reads + `Default value … does not match given type bigint`, as in 7.x. +- `toDocument()` and the attribute event payloads report bigint attributes as `'bigint'`. +- An empty `format` means no format: an attribute read from metadata written by 7.x, which stored `''`, has a + `null` format. + +### Collections and attributes + +- `createCollection()` checks every attribute like `createAttribute()`: object, spatial and vector attributes need + the adapter to support them. An unknown type cannot reach it: `Attribute::fromArray()` and `fromDocument()` refuse + it with `Exception\Structure`. In 7.x the SQL adapters failed with `Unknown type: ` and MongoDB created the + collection. +- `createCollection()`, `createAttribute()` and `createAttributes()` return what they stored: the filters added for + `datetime`, `object`, spatial and vector attributes, and the index `lengths` and `orders` adjusted for the adapter. + The value objects you pass are never changed, so shared definitions, such as the ones in a config array, are safe + to pass directly. In 7.x `createCollection()` wrote these changes into the documents it was given. +- `updateAttribute()` now updates `id` attributes (7.x threw `Unknown attribute type: id`), and it refuses + relationship attributes with `Cannot update relationship as an attribute`: use `updateRelationship()`. +- The default of a point, linestring or polygon attribute is validated like a value of that attribute: a point + needs two numeric coordinates in range, a linestring at least two points, a polygon closed rings of at least four + points. 7.x accepted any array. +- `createAttribute()` and `createAttributes()` refuse a varchar of size 0 or above the maximum varchar length, as + `createCollection()` does and as 7.x did. +- **Index length accounting.** Big integer columns (big integer, id, and 64-bit integer) count 8 bytes toward the + maximum index length, and a `text()`, `mediumText()` or `longText()` attribute without a size counts as the + engine's maximum for its type. An index that relied on the old undercount has to give the text column a prefix + length. +- **Existing indexes.** On adapters with `Capability::SchemaIntrospection`, `createIndex()` compares an index that + exists in the schema but not in the metadata with the request (columns, prefix lengths, key, unique, fulltext or + spatial): a match is adopted and a mismatch is dropped and recreated, as in 7.3.12. +- `analyzeCollection()` refreshes planner statistics on PostgreSQL and SQLite (the collection's table and its + permissions table) and returns `true`; it returned `false` there. Call it after bulk loads (migrations, imports): + a freshly loaded collection otherwise plans joins with empty-table statistics, and SQLite never gathers + statistics on its own. +- On PostgreSQL `getSizeOfCollection()` reports the table's relation size, which a delete does not reduce until the + table is vacuumed; `analyzeCollection()` refreshes statistics only. + +### Schema introspection + +`getSchemaAttributes()` returns `list` (`name`, `type` as the engine's canonical native type, +`length`, `nullable`) and `getSchemaIndexes()` returns `list` (`name`, `type` as `IndexType`, +`columns`, `lengths`), read back from the engine. Both are meaningful only where the adapter declares +`Capability::SchemaIntrospection`: MariaDB, MySQL, SQLite and PostgreSQL. MongoDB, Memory and Redis return `[]`. + +PostgreSQL now introspects its schema, so it reconciles orphan columns and indexes like the other SQL adapters: +`createAttribute()` and `createIndex()` adopt or replace a column or index the schema has but the metadata lacks. +PostgreSQL reports a fulltext index as a key index and no prefix lengths. An index whose name PostgreSQL shortened to +its md5 form, or another tenant's index of a shared table, is not matched and is created as before. + +### Renaming a database + +`Database::update(string $database, string $new): bool` renames a database and fires `Event\Database\Updated`. It +retires the cached definitions and documents of the moved collections under both names. Every adapter refuses it +under shared tables, also when called on the adapter directly. + +| Adapter | How | +|---|---| +| MariaDB, MySQL | Moves every table into the new schema in one atomic `RENAME TABLE`, then drops the old schema when it is empty (a table created there during the rename keeps it). Grants on the old schema do not move | +| PostgreSQL | `ALTER SCHEMA ... RENAME TO` | +| SQLite | Nothing to move: SQLite stores no database name, so `update()` succeeds and `exists()` is always `false` | +| Memory, Redis | Re-keys the stored data, and rolls back on failure | +| MongoDB | Moves each collection, and rolls back on failure. A sharded cluster throws `Exception`. The client stays bound to the old database: build a new client for the new name | + +## Lifecycle events are hooks + +`Database::on()`, `Database::before()`, `Mirror::on()` and `Adapter::before()` are removed, and so are the +`Database::EVENT_*` constants. Everything is registered with `Database::addHook()`, which dispatches on the hook's +type and throws for a hook it does not recognise: + +| Hook interface | Receives | Replaces | +|---|---|---| +| `Utopia\Database\Hook\Lifecycle` | a typed event object per event, for side effects | `on()` | +| `Utopia\Database\Hook\Decorator` | each document a read or write returns, to modify it | nothing in 7.x | +| `Utopia\Database\Hook\Transform` | each SQL statement before it runs | `before()` | +| `Utopia\Database\Hook\Write` | document writes, to write rows of their own, such as `Hook\Permissions` | built into 7.x | +| `Utopia\Database\Hook\Relationships` | relationship resolution and mutation | built into 7.x | +| `Utopia\Database\Cache\Invalidator` | the writes that invalidate the `find()` query cache | nothing in 7.x | + +`removeHook($hook)` unregisters a hook instance, or every hook of a class given its class name. + +### Lifecycle hooks (replaces `on()` and the `EVENT_*` constants) + +A listener is now an object implementing `Utopia\Database\Hook\Lifecycle` and registered with +`Database::addHook()`. Its `handle(Event\Domain $event): void` receives one typed event object per event: every +`Event` case except `All` has a `final readonly` class under `Utopia\Database\Event\{Database,Collection,Attribute, +Index,Document,Permission}`, and `$event->event` is the `Event` case. Match on the class to read its typed payload. To +keep 7.x's by-name behaviour, also implement `Utopia\Database\Hook\Named` (`getName(): string`): + +- Registering a named hook replaces the lifecycle hook already registered under that name, in its position. + Re-registering on every request or job no longer stacks listeners. Hooks without a name are appended every time + they are added. +- A name is unique per `Database`, across all events. 7.x kept one listener per event and name, so + `on(EVENT_DOCUMENT_CREATE, 'usage', ...)` and `on(EVENT_DOCUMENT_DELETE, 'usage', ...)` were two listeners. In 8.0 + several `Named` hooks with the same name keep only the one registered last: handle all of a name's events in one + hook, or give each hook its own name. +- `silent()` can silence a named hook on its own (see below). +- A hook that also implements `Hook\Selective` (`handles(Event $event): bool`) receives only the events it accepts. + An event object is built only when a registered hook handles its event. + +```php +// 7.x +$database->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function (mixed $document) { + // ... +}); + +// 8.0 +final class Usage implements Lifecycle, Named, Selective +{ + public function getName(): string + { + return 'calculate-usage'; + } + + public function handles(Event $event): bool + { + return $event === Event::DocumentCreate; + } + + public function handle(Domain $event): void + { + if ($event instanceof Event\Document\Created) { + // $event->collection, $event->document + } + } +} + +$database->addHook(new Usage()); +``` + +On a `Mirror`, lifecycle hooks are registered on the source database. `Mirror::silent()` silences both the source +and the mirror. + +### Event classes + +Each event's payload is a typed property; 7.x passed arrays, strings, `false` and `Document(['modified' => N])`. + +| Event | Class | Properties | +|---|---|---| +| `database_create`, `database_update`, `database_delete`, `database_list` | `Event\Database\Created`, `Updated`, `Deleted`, `Listed` | `database`; `new` (update); `databases` (list) | +| `collection_create`, `collection_read`, `collection_update`, `collection_delete` | `Event\Collection\Created`, `Read`, `Updated`, `Deleted` | `collection`, `definition` (`Collection`) | +| `collection_list` | `Event\Collection\Listed` | `collections` | +| `attribute_create`, `attribute_update`, `attribute_delete` | `Event\Attribute\Created`, `Updated`, `Deleted` | `collection`, `attribute` (`Attribute`) | +| `attribute_rename` | `Event\Attribute\Renamed` | `collection`, `old`, `attribute` | +| `attributes_create` | `Event\Attribute\BatchCreated` | `collection`, `attributes` | +| `index_create`, `index_delete` | `Event\Index\Created`, `Deleted` | `collection`, `index` (`Index`) | +| `index_rename` | `Event\Index\Renamed` | `collection`, `old`, `index` | +| `indexes_create` | `Event\Index\BatchCreated` | `collection`, `indexes` | +| `document_create`, `document_read`, `document_update`, `document_delete` | `Event\Document\Created`, `Read`, `Updated`, `Deleted` | `collection`, `document` | +| `document_upsert` | `Event\Document\Upserted` | `collection`, `document`, `created` | +| `document_increase`, `document_decrease` | `Event\Document\Increased`, `Decreased` | `collection`, `document`, `attribute` | +| `documents_create`, `documents_update`, `documents_delete` | `Event\Document\BatchCreated`, `BatchUpdated`, `BatchDeleted` | `collection`, `count` | +| `documents_upsert` | `Event\Document\BatchUpserted` | `collection`, `count`, `created`, `updated` | +| `document_find` | `Event\Document\Found` | `collection`, `documents` | +| `document_aggregate` | `Event\Document\Aggregated` | `collection`, `rows` | +| `document_count`, `document_sum` | `Event\Document\Counted`, `Summed` | `collection`, `count`; `attribute`, `sum` | +| `document_purge` | `Event\Document\Purged` | `collection`, `id` | +| `permissions_create`, `permissions_read`, `permissions_delete` | `Event\Permission\Created`, `Read`, `Deleted` | `collection`, `document`, `permissions` | + +`Event::domain()` names the class of a case. New events: `upsertDocument()` fires `document_upsert` (not +`documents_upsert`), `renameAttribute()` fires `attribute_rename` (it fired `attribute_update`), `createIndexes()` +fires `indexes_create` after each `index_create`, `aggregate()` fires `document_aggregate` (not `document_find`), +and `update()` fires `database_update`. `findOne()` and `findCollection()` fire nothing on a miss; 7.x's `findOne()` +fired `document_find` with `false`. + +### `silent()` + +`silent(callable $callback, ?array $hooks = null)`: + +- `null` silences every lifecycle hook, and every decorator, for the duration of the callback. This is unchanged. +- A list of names silences only the lifecycle hooks that implement `Named` with one of those names. Unnamed hooks and + hooks with other names keep firing, and so do decorators. The list holds hook names, not event names: + `silent($fn, ['document_create'])` silences a hook named `document_create`, not the event. +- Difference from 7.x when nesting: 7.x replaced the silenced set with the inner call's list, so + `silent(fn () => $db->silent($fn, ['a']))` re-enabled every other listener inside. In 8.0, an inner `silent()` + never narrows the silence around it. +- Silences apply to the calling coroutine and the coroutines it starts, not to other coroutines sharing the handle + (see [Coroutines](#coroutines)). + +### Hook failures + +Whether a lifecycle hook's exception reaches the caller depends on the event, as in 7.x: + +| Events | A hook throws an `\Exception` | +|---|---| +| `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` fired by a document write or `purgeCachedDocument()` | The exception reaches the caller; the remaining hooks do not run. | +| All other events: `database_*`, `collection_*`, `attribute_create`, `attributes_create`, `attribute_update`, `attribute_delete`, `index_rename`, `index_delete`, and `document_purge` fired by an attribute schema change | The exception is swallowed; the remaining hooks still run. | + +An `\Error` (for example a `TypeError`) 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 a document write now fires after the outermost transaction has committed, including for a + write inside `withTransaction()`, and not at all when that transaction rolls back. In 7.x `updateDocument()` and + `deleteDocument()` fired it inside the transaction, so a failing purge listener made the transaction retry and + then roll back. Now the write stays committed and the call throws; if several documents were written, every + `document_purge` is still delivered before the first listener exception reaches the caller. + +A `Decorator`'s exception always reaches the caller. + +### `document_purge` + +It fires once per written document from `updateDocument()` (for both the old and the new `$id` when the id changes), +`updateDocuments()`, `upsertDocuments()`, `increaseDocumentAttribute()`, `decreaseDocumentAttribute()`, +`deleteDocument()` and `deleteDocuments()`, and from `purgeCachedDocument()`. The event is +`Event\Document\Purged` with the document's `collection` and `id`. As in 7.x, `createDocument()` and `createDocuments()` do +not fire it. Attribute schema changes fire it for the collection's metadata document (`$collection` = `_metadata`). + +A write fires it after the outermost transaction commits: inside `withTransaction()` the events of every write wait +for the outer commit, a rollback drops them, and a retried attempt announces once. Each event runs under the tenant +and the `silent()` scope in force when its document was written. If the cache invalidation after the commit fails, +the write throws with its data committed, after `document_purge` has fired. `purgeCachedDocument()` fires it at once. +On an adapter without savepoints (MongoDB), a nested `withTransaction()` that fails is not rolled back on its own: +when the caller catches the failure, the nested writes commit with the caller and their events fire after that +commit. + +### `document_update` for related documents a delete changed + +As in 7.4.0, `deleteDocument()` fires `document_update` (`Event::DocumentUpdate`) for each document on the other side +of a two-way relationship that the delete changed, after its own `document_delete`. A lifecycle hook that handles +`document_update` receives them with no change. The payload is the related `Document`: + +- A document the delete wrote (set-null clearing its key) arrives as that write returned it, with the cleared key set + to `null`. A document it did not write (the parent of a deleted one-to-many child, a many-to-many peer, a peer under + `Restrict`) arrives as read off the deleted document, without the back-reference key. Read it back to use more + than `$id` and `$collection`. +- One-way peers, documents a cascade removed anywhere down its chain, and the deleted document itself are not + reported. `deleteDocuments()`, a cascade's own deletes and a delete inside `silent()` report nothing. +- Telling a cascade's survivors from what it removed takes a read per related collection. A delete makes those reads + only while an active lifecycle hook handles `document_update`: any hook that does not implement `Hook\Selective`, + or one whose `handles(Event::DocumentUpdate)` is `true` (for `Event\DispatcherHook`, a listener for + `Event\Document\Updated` or a PSR-14 dispatcher). Implement `Hook\Selective` on a hook that ignores + `document_update` to spare its deletes those reads. +- They are read and written with permissions skipped and are not checked against the caller's read permission: + treat them as privileged, like the documents `deleteDocuments()` and `upsertDocuments()` pass to `$onNext`. +- They fire when the delete's own transaction returns, like `document_delete`, not when an outer transaction + commits. A delete whose commit fails and is retried reports only what the committed attempt changed. +- Difference from 7.4.0: when a hook throws, `document_delete` and every related `document_update` still fire, and + the first exception reaches the caller afterwards. 7.4.0 stopped at the first failure. + +### `attribute_create` from `createAttributes()` + +`createAttributes()` fires `attribute_create` (`Event\Attribute\Created`) once per attribute, then +`attributes_create` (`Event\Attribute\BatchCreated`) once, with the list. In 7.x, `createAttributes()` fired +`attribute_create` once, with the array of attribute documents as payload, and nothing fired `attributes_create`. + +### `Event\DispatcherHook` + +`Event\DispatcherHook` forwards the typed events to listeners registered per class with +`on(string $eventClass, callable $listener)` and to an optional PSR-14 dispatcher. It handles an event only while a +listener or the dispatcher can receive it. + +```php +$dispatcher = new DispatcherHook(); +$dispatcher->on(Event\Document\Created::class, function (Event\Document\Created $event): void { + // $event->collection, $event->document +}); +$database->addHook($dispatcher); +``` + +A bulk write never delivers a single-document event. `Event\Document\Updated` has no `$previous` property. Every +listener and the PSR-14 dispatcher run; the first `\Exception` among them is then rethrown, and whether it reaches the +caller follows the table under [Hook failures](#hook-failures). An `\Error` always reaches the caller at once. + +### Query transforms (replaces `before()`) + +A `before($event, $name, $callback)` transformation becomes a `Utopia\Database\Hook\Transform`. Its +`transform(Event $event, string $query): string` receives every statement with the event that runs it, so filter by +event inside it. It is registered under its class name, and `removeTransform(MyTransform::class)` removes it. + +```php +// 7.x +$database->before(Database::EVENT_DOCUMENT_FIND, 'label', fn (string $sql) => '/* listing */ '.$sql); + +// 8.0 +final class Label implements Transform +{ + public function transform(Event $event, string $query): string + { + return $event === Event::DocumentFind ? '/* listing */ '.$query : $query; + } +} + +$database->addHook(new Label()); +$database->removeTransform(Label::class); +``` + +### Write hooks + +A `Hook\Write` stands alone; it does not extend `Utopia\Query\Hook\Write`. `Hook\Interceptor` implements every +method as a no-op, so a hook overrides only what it needs: + +- `decorateRow(array $row, Hook\RowMetadata $metadata): array` adjusts each row before it is written. `$metadata->tenant` + is the document's tenant, else the adapter's. +- `afterDocumentCreate()`, `afterDocumentUpdate()`, `afterDocumentBatchUpdate()`, `afterDocumentUpsert()` and + `afterDocumentDelete()` take a `Hook\WriteContext` as their last argument. `afterDocumentUpdate()` receives the id + the document is stored under. +- `Hook\WriteContext` is an interface the SQL adapters implement: `builder()`, whose `from($table)` reads or deletes + the table's rows kept to the adapter's tenant (the builder then reads that table only) and whose `into()` and + `fromTable()`, before any `from()`, take the name `rawTable()` returns and keep it to no tenant; + `rawTable(string $table)`; `run(Statement $statement, Event $event): bool`; `fetch(Statement $statement, Event + $event): array`; `decorateRow(array $row, Document $document)`; `skipPermissions(Document $document): bool` (the + update keeps that document's permissions); and `ignoreDuplicates()`. + +### Subclasses of `Database` + +- The protected `trigger(string $event, mixed $args = null)` is removed. Event dispatch (`listens()`, `dispatch()`) + is internal: register a `Hook\Lifecycle` to act on events. +- The protected `createDocumentInstance()` is `newDocument()`. +- `casting()` and `applySelectFiltersToDocuments()` are internal, and so are `getRelationshipHook()` and the public + helpers of `Hook\Relationships` that `Database` calls. +- `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` accept numeric strings as well + (`string|int|float`), for unsigned 64-bit values, so an override has to widen its parameter types. +- `Database` is now composed of the traits in `Utopia\Database\Trait`. Its public methods are unchanged by that. +- `getDocument()` and `find()` overrides are not called for the locked read of `updateDocument()`, + `increaseDocumentAttribute()`, `decreaseDocumentAttribute()` and `deleteDocument()` or the page reads of + `updateDocuments()` and `deleteDocuments()`. `getCollection()` and `findCollection()` overrides are not called by + `getDocument(Database::METADATA, ...)` while no `Hook\Decorator` is registered or events are silenced (`silent()` + without hook names). +- The protected `$listeners` and `$silentListeners` properties are gone. Registered lifecycle hooks are in the + protected `$lifecycleHooks`; to silence or test for silence, use `silent()` and the protected + `areEventsSilenced()`. +- The protected `decodeAttribute()` applies the filter it is given. `decode()` reads `setFiltering()` and + `skipFilters()` once per document and calls it only for the filters they leave enabled, so an override that relied + on the method returning the value unchanged while filters are disabled no longer has to check. + +## Relationships + +- A retried delete no longer skips its cascade. In 7.x, a cascade that threw (for example on a `Restricted` related + document or a permission failure) left the relationship on the database's internal delete stack, and a later delete + of the same document on the same `Database` instance then deleted it without cascading. The cascade now always + runs. +- Cascade, set-null and link permission failures throw `Utopia\Database\Exception\Authorization` and roll back the + write, as in 7.x. Related documents are now read and linked in chunks of + `min(Database::RELATION_QUERY_CHUNK_SIZE, getMaxQueryValues())`; a cascade deletes them one at a time through + `deleteDocument()`, so a related document the caller may not read is deleted with the rest or, under `Restrict`, + blocks the delete. +- Linking an existing related document, at any nesting depth of a create or an update, needs update permission on + that document, for every relationship type; without it the write throws `Utopia\Database\Exception\Authorization` + and nothing is written. In 7.x a many-to-many link needed only read permission on the related document, because + the link is a row in the junction collection rather than a column on the related document; it now needs update + permission too, whether the value is an id or a document. A related document the same write creates needs no + update permission. Relinking a document that is already linked needs only read permission, and removing a link + needs no update permission on the unlinked document. +- `deleteDocuments()` applies `Cascade` and `Restrict` to every related document, also when its queries include a + select (7.x skipped the cascade and ignored `Restrict` then) and also to related documents the caller cannot read. + Each document a cascade reaches is deleted through `deleteDocument()`, so one the caller may not delete throws + `Utopia\Database\Exception\Authorization` and rolls the batch back. + +## Documents + +- `new Document([...])` and `Document::setAttribute('$permissions', ...)` throw `Utopia\Database\Exception\Structure` + (`Every permission must be of type string`) when a permission is not a string. In 7.x the entry was kept, and + `createDocument()`/`updateDocument()` rejected it with the same message as a `Utopia\Database\Exception`, but only + while validation was enabled. `setAttribute('$permissions', $value)` also throws + (`$permissions must be of type array`) when `$value` is neither an array nor `null`, matching the constructor. + Permissions set through the constructor or `setAttribute()` are de-duplicated and re-indexed, and + `getPermissions()` always returns a list. +- Reading stored data never throws for a non-string permission: `Document::fromRow()`, which the SQL adapters use, + and the new `Document::fromStorage()`, which the MongoDB, Memory and Redis adapters use, drop such entries at + every nesting level. The same holds for a `json` attribute whose value is shaped like a document (7.x stored such + values with non-string permissions), for documents rebuilt from the document and query caches, and for a mapped + document type (`setDocumentType()`), which keeps its class. Otherwise `fromStorage()` builds a document like the + constructor: nested arrays carrying `$id` or `$collection` become `Document`s, and a non-string `$id` or a + `$permissions` value that is not an array still throws. Build caller input with the constructor, which rejects + non-string permissions. +- `updateDocument()` validates only the values it changes: an attribute whose value equals the stored one is not + validated again, so a value an older release accepted (for example a list in an `object` attribute) no longer + blocks updates of other attributes. Writing such a value still fails. Collection definitions are always validated + in full. +- `Document::setAttribute()` takes a `SetType` case, and `Document::getPermissionsByType()` takes a + `PermissionType` case or, for a permission type of your own, its string. +- **`createDocuments()` under `ignoreDuplicates()`** (7.x `skipDuplicates()`) returns and counts only the documents it inserted, and hands only + those to `onNext`. A document skipped because its id is already stored is neither counted nor emitted (7.x + counted and emitted it). Of a batch that repeats an id, only the first copy is written. Permissions are written + only for inserted documents: in 7.x the skipped copy's permissions were added to the stored document on MariaDB, + MySQL and SQLite, so `find()`, `count()` and `sum()` could return it to roles its own permissions do not grant. +- **Fractional numbers on integer attributes.** `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` + throw `Utopia\Database\Exception\Type` before anything is written, on every adapter and also on schemaless + collections that declare the attribute as an integer: + - for a fractional change value (`Change value must be an integer.`). 7.x passed it to the engine, which rounded + it on MariaDB and MySQL, failed on PostgreSQL, and stored a float in the integer attribute on SQLite, MongoDB, + Memory and Redis. Pass an integer change value, or use a float attribute for fractional counters. + - for a fractional `max` or `min` (`Max must be an integer.`, `Min must be an integer.`). Integer bounds are + compared with exact integer arithmetic, so 64-bit and unsigned values never pass through a float. The bound + accepts the same whole numbers as an operator limit: an integer, an integer string, a string with only zero + decimals such as `'102.0'`, or a float without a fractional part such as `102.0`, each converted exactly. Pass + a whole bound, for example `floor($max)` or `ceil($min)`, which admits the same integer values. + + Change values and bounds on float and double attributes may still be fractional. +- **Operator limits on integer attributes.** The `max` or `min` limit of `Operator::increment()`, `decrement()`, + `multiply()`, `divide()` and `power()` on an integer or big integer attribute has to be a whole number: an + integer, an integer string, or a float without a fractional part such as `9.0e18`. A fractional limit such as + `102.4` is refused before anything is written with `Utopia\Database\Exception\Structure` + (`Cannot apply operator: max/min limit must be a whole number for integer attribute '', got + `). With validation skipped that check does not run, and Memory and Redis refuse such a limit with + `Utopia\Database\Exception\Operator` when the result leaves PHP's integer range. Limits on float and double + attributes are unchanged. +- **Operators on upserts that create a document.** An upsert that creates a document applies every operator to the + attribute's default, as it does for an existing document: `dateAddDays()` and `dateSubDays()` shift the date, + `arrayFilter()` filters the array, and the maximum or minimum of increment, decrement, multiply, divide and power + is honoured. Code that relied on the default being stored unchanged gets the operator's result. +- **`Document` methods.** + + | 7.x | 8.0 | + |---|---| + | `getRead()`, `getCreate()`, `getUpdate()`, `getDelete()`, `getWrite()` | `getPermissionsByType(PermissionType::Read)` and so on. `Write` covers create, update and delete | + | `getArrayCopy(array $allow = [], array $disallow = [])` | `getArrayCopy()` without arguments, `only(array $keys)` for `$allow` and `except(array $keys)` for `$disallow` | + | `find()`, `findAndReplace()`, `findAndRemove()` | No replacement: read and write the attribute with `getAttribute()` and `setAttribute()` | + + `only()` and `except()` filter top-level keys only, as `$allow` and `$disallow` did, and convert nested documents + to arrays like `getArrayCopy()`; `only([])` returns `[]`. `Document::fromRow()` and `fromStorage()` are internal. + +```php +// 7.x +$readers = $document->getRead(); +$response = $document->getArrayCopy(disallow: ['$permissions']); + +// 8.0 +$readers = $document->getPermissionsByType(PermissionType::Read); +$response = $document->except(['$permissions']); +``` + +## Bulk writes and reads + +| 7.x | 8.0 | +|---|---| +| `createDocuments($collection, $documents, $batchSize = INSERT_BATCH_SIZE, $onNext, $onError)` | `createDocuments(string $collection, array $documents, int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null): int` | +| `updateDocuments($collection, $updates, $queries, $batchSize, $onNext, $onError)` | `updateDocuments(string $collection, Document $updates, array $queries = [], int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null): int` | +| `upsertDocuments($collection, $documents, $batchSize, $onNext, $onError)` | `upsertDocuments(string $collection, array $documents, int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null, ?string $increase = null): int` | +| `upsertDocumentsWithIncrease($collection, $attribute, $documents, $onNext, $onError, $batchSize)` | `upsertDocuments(..., increase: $attribute)` | +| `deleteDocuments($collection, $queries, $batchSize = DELETE_BATCH_SIZE, $onNext, $onError)` | `deleteDocuments(string $collection, array $queries = [], int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null): int` | +| `iterate($collection, $queries, $forPermission)`, `foreach($collection, $callback, $queries, $forPermission)` | `cursor(string $collection, array $queries = [], int $batchSize = Database::CURSOR_BATCH_SIZE, PermissionType $forPermission = PermissionType::Read): Generator` | +| `find()` with aggregates or `groupBy()` | `aggregate(string $collection, array $queries): array`, a list of rows | +| `purgeCachedDocument(string $collectionId, ?string $id): bool`, `purgeCachedCollection(string $collectionId): bool` | `purgeCachedDocument(string $collection, string $id): void`, `purgeCachedCollection(string $collection): void` | + +```php +// 7.x +$database->deleteDocuments('sessions', [$expired], onNext: fn (Document $deleted, Document $old) => $log($old), onError: fn (Throwable $error) => $report($error)); +foreach ($database->iterate('sessions', [Query::limit(25)]) as $session) { + // ... +} + +// 8.0 +$database->deleteDocuments('sessions', [$expired], onNext: fn (Document $deleted, ?Document $previous) => $log($previous)); +foreach ($database->cursor('sessions', batchSize: 25) as $session) { + // ... +} +``` + +- **`$onNext`** has one shape everywhere: `callable(Document $document, ?Document $previous): void`. `$previous` is + `null` on create, the stored document an update or upsert replaced, and on delete a separate copy of the deleted + document, so changing the first argument does not change it. `$onError` is removed: an exception from `$onNext` aborts the call, like any + other callback. +- **Batch size.** `Database::BATCH_SIZE` (1000) replaces `INSERT_BATCH_SIZE` and `DELETE_BATCH_SIZE`. A batch size + above it throws `Exception\Limit` instead of being reduced to it; one below 1 still writes one document at a time. +- **`cursor()`** is the only generator. It reads the matches in batches of `$batchSize` (default + `Database::CURSOR_BATCH_SIZE`, 100). A `limit()` in the queries caps how many documents it yields; an `offset()` + or `cursorAfter()` positions the first batch only; `cursorBefore()` throws `Utopia\Database\Exception` + (`Cursor before not supported in this method.`) when called. In `iterate()` and `foreach()` a `limit()` set the + page size, and without one they read pages of 25: move the limit to `batchSize`, or pass `batchSize: 25`, to keep + the page size. +- **`aggregate()`** returns `list>`. An unaliased aggregate comes back under + `_`, or `` for `count('*')`; give it an alias to choose the name. Two aggregates that + would come back under one name (two identical unaliased ones, or a default that equals another's alias) throw + `Exception\Query`. It fires `document_aggregate`. `find()` refuses aggregate and `groupBy()` queries with `Exception\Query`. +- **`sum()` and `count()`** throw `Exception\Query` for a `$max` of 0 or less; they returned 0. +- **`increaseDocumentAttribute()` and `decreaseDocumentAttribute()`** throw `Exception\Type` for a change value of 0 + or less; they threw `InvalidArgumentException`. +- **`findOne()`** fires no `document_find` on a miss. +- **`deleteDocument()`** still returns `bool`. + +## Configuration toggles + +Every toggle has one form: `set(bool): static` sets it, `is()` or `has()` reads it, and a +scoped form takes the value and a callback. Setters return `static` and `reset`/`clear` methods return `void`. + +| 7.x | 8.0 | +|---|---| +| `enableValidation()`, `disableValidation()` | `setValidation(bool $validation)`; read with `isValidating()`; scope with `withValidation(bool $validation, callable $callback)` or `skipValidation(callable $callback)` | +| `enableFilters()`, `disableFilters()` | `setFiltering(bool $filtering)`; read with `isFiltering()`; scope with `withFiltering(bool $filtering, callable $callback, ?array $filters = null)` or `skipFilters(callable $callback, ?array $filters = null)` | +| `enableLocks(bool $enabled)` | `setLocks(bool $locks)` | +| `getPreserveDates()`, `withPreserveDates(callable $callback)` | `isPreservingDates()`, `withPreserveDates(bool $preserve, callable $callback)` | +| `getPreserveSequence()`, `withPreserveSequence(callable $callback)` | `isPreservingSequence()`, `withPreserveSequence(bool $preserve, callable $callback)` | +| `skipDuplicates(callable $callback)` (database and adapter) | `ignoreDuplicates(callable $callback)` | +| `getDropUnknownAttributes()` | `isDroppingUnknownAttributes()` | +| `getSharedTables()` (database and adapter) | `hasSharedTables()` | +| `getTenantPerDocument()` (database and adapter) | `isTenantPerDocument()` | +| `clearDocumentType(string $collection): static` | `clearDocumentType(string $collection): void` | +| `clearAllDocumentTypes(): static` | `clearDocumentTypes(): void`, which keeps the metadata collection's `Collection` type | +| `setMigrating(): self`, `setMaxQueryValues(): self`, `setAuthorization(): self` | Return `static` | +| `silent(callable $callback, ?array $listeners = null)` | `silent(callable $callback, ?array $hooks = null)` | +| `getKeywords()` | `$database->profile()->limits->keywords` | +| `getConnectionId(): string` | `getConnectionId(): ?string`, `null` on an adapter without `Feature\Connection` (Memory) | +| — | `getHostname(): ?string`. `ping()` returns `true` and `reconnect()` does nothing without `Feature\Connection` | +| Adapter `setDatabase()`, `setSharedTables()`, `setTenant()`, `setTenantPerDocument()` returned `bool` | Return `static` | +| Adapter `setDebug()`, `getDebug()`, `resetDebug()` | `setMetadata()`, `getMetadata()`, `resetMetadata()`, which also reach the query comments | +| Adapter `enableAlterLocks(bool $enable)` | `Database::setLocks(bool $locks)` | +| `Authorization::setDefaultStatus(bool $status)` | `new Authorization(defaultStatus: $status)` | +| `Authorization::addRole()`, `removeRole()`, `cleanRoles()`, `setStatus()`, `enable()`, `disable()` returned `void` | Return `static` | +| `new Authorization\Input(string $action, array $permissions)` | `new Authorization\Input(PermissionType\|string $action, array $permissions)`: pass a `PermissionType` case for the built-in actions; an action of your own, such as `execute` or `subscribe`, stays a string | + +`setProfiling(bool)` and `isProfiling()` turn the query profiler on and off (see +[Pools and profiling](#pools-and-profiling)). +`setTenant()` and `withTenant()` are unchanged. + +```php +// 7.x +$database->disableValidation(); +$database->withPreserveDates(fn () => $database->createDocument('logs', $log)); +$database->skipDuplicates(fn () => $database->createDocuments('logs', $logs)); + +// 8.0 +$database->setValidation(false); +$database->withPreserveDates(true, fn () => $database->createDocument('logs', $log)); +$database->ignoreDuplicates(fn () => $database->createDocuments('logs', $logs)); +``` + +## Coroutines + +Under Swoole, several coroutines can share one `Database` and one `Authorization`. The scopes that 7.x applied to the +whole handle now apply to the calling coroutine and the coroutines it starts; sibling coroutines sharing the handle +or the `Authorization` do not see them: + +- `Authorization::skip()` and `Authorization::withRoles()`; +- `silent()`, `skipRelationships()`, `skipRelationshipsExistCheck()`, `withFiltering()` and `skipFilters()`, + `withValidation()` and `skipValidation()`, `withPreserveDates()`, `withPreserveSequence()`, `withTenant()`, + `withRequestTimestamp()`, and `ignoreDuplicates()` on the database and on the adapter. + +A plain setter is scoped only by a scope over the same state: + +- `Authorization::setStatus()`, `enable()`, `disable()` and `reset()` by `Authorization::skip()`; +- `Authorization::addRole()`, `removeRole()` and `cleanRoles()` by `Authorization::withRoles()`; +- `setTenant()` by `withTenant()`; +- `setValidation()` by `withValidation()` and `skipValidation()`; +- `setFiltering()` by `withFiltering()` and `skipFilters()`, with or without filter names; +- `Hook\Relationships::setEnabled()` by `Hook\Relationships::withEnabled()` and `skipRelationships()`; +- `setPreserveDates()` by `withPreserveDates()`, and `setPreserveSequence()` by `withPreserveSequence()`. + +Outside every scope over its state, a setter in the coroutine that opened a scope, or in a coroutine it started that +is still connected to it, changes the shared value, as in 7.x: `disable()` inside `withRoles()` turns authorization +off for every coroutine sharing the `Authorization`. A coroutine cut off from the scope is covered in the next +paragraph. Inside such a scope, whether the calling coroutine opened it or inherited it from the coroutine that +started it, a setter changes only what the calling coroutine and the coroutines it starts see, and only until the +scope ends. When the scope ends, the value is what it was before the scope, as in 7.x. + +A coroutine sees a scope only while every coroutine between it and the scope's owner is still running, because +Swoole cannot report the parent of a coroutine that has finished. In a coroutine cut off this way: + +- reads see the shared values, or a scope opened outside every coroutine, but never a scope opened in a coroutine + (not the owner's tenant, status or roles), and on `Adapter\Pool` it borrows a connection of its own outside the + scope's transaction; +- writes stay its own: while a scope over that state is open on the handle, even one another coroutine opened, a + setter changes only what that coroutine and the coroutines it starts see, until it ends, and never the shared + value. For `Authorization`, `skip()` and `withRoles()` each count as a scope over both the status + and the roles; +- with no such scope open, a setter changes the shared value, as in 7.x. + +A connected coroutine and a cut-off one treat a setter differently on purpose. A connected coroutine knows the scopes +it runs under, so a write to a state none of them covers follows the 7.x rule: a grandchild's `disable()` inside +`withRoles()` turns authorization off for every coroutine. A cut-off coroutine cannot tell which scope it ran under, +so it keeps the write local: the same `disable()` there applies only to it and the coroutines it starts. + +A cut-off coroutine must not change and then restore state with a pair of setters, such as `disable()` then +`enable()`, or `setTenant($tenant)` then `setTenant($original)`. Each write is shared or local depending on whether a +scope over that state is open on the handle at that moment, so the restore can stay local while the change stays +shared. Use `skip()`, `withRoles()`, `withTenant()` or another `with*()` scope, which always restores the value +when it ends. + +While a cut-off coroutine holding such a local write is alive, reads of that state take the slower scoped path in +every coroutine sharing the handle, as they do while any scope over it is open, so a long-lived coroutine should open +its own scopes around the work that needs them rather than call setters. + +Work that can outlive the coroutine that started it loses that coroutine's scopes once it returns, so it opens the +scopes it needs itself, inside the child. `Hook\Relationships::withEnabled()` and `withCheckExist()` scope the hook's +own flags the same way as the database scopes. + +`Database::snapshot()`, `Database::withSnapshot()` and `Hook\Relationships::withSnapshot()` are `@internal`: +`Mirror` and the relationship hook use them to carry the caller's state (the authorization status and roles, the +relationship, silence and filter state, the tenant, the toggles and the request timestamp, but not a transaction) into +the coroutines they start. They are not part of the public API. + +Relationship population reads its chunks of related ids concurrently only on `Adapter\Pool`, inside a coroutine and +outside `withTransaction()`, and only as many at once as `Hook\Relationships::READ_CONCURRENCY` (4) and +`Pool::getReadConcurrency()` (the connections the pool can hand out without waiting, less one) allow; elsewhere it +reads them one after another. Related documents are merged in chunk order. + +Each coroutine sharing a handle tracks its own relationship writes and cascading deletes, so a nested write or a +cascade in one coroutine never cuts another coroutine's short. On `Adapter\Pool`, a transaction belongs to the +coroutine that opened it and the coroutines it starts; see [Pools and profiling](#pools-and-profiling). + +## Errors + +- **Exception hierarchy.** `Exception\Schema` is the new parent of `Structure`, `Type`, `Character`, `Truncate`, + `Index`, `Dependency`, `Limit` and `Relationship`, so one `catch (Exception\Schema)` handles every schema violation. + `Order` and `Operator` now extend `Exception\Query`: a `catch (Exception\Query)` also catches them, so put a + `catch` of `Order` or `Operator` before it. `Exception\Transaction` keeps exactly its subtree (`Contention`); + `Timeout` and `Unconfirmed` keep their parents, so `withTransaction()` never runs them again. +- **Exception constructor.** `Exception::__construct(string $message = '', int|string $code = 0, ?\Throwable + $previous = null)` takes every argument as optional and keeps a string code, such as the SQLSTATE `HY000`, in + `public readonly ?string $state`; a numeric string is also the integer code. `Exception\Order::__construct(string + $message, ?string $attribute = null, int|string $code = 0, ?\Throwable $previous = null)` takes the attribute + second. +- **Unique index violations.** Every adapter now reports a unique index violation as + `Utopia\Database\Exception\Unique` with the message `Document with the requested unique attributes already exists` + (7.x: `Unique index violation`). The class and its hierarchy are unchanged: `Unique` extends `Duplicate`, and a + conflicting document `$id` still throws a plain `Duplicate` with `Document already exists`. Match on the class, + not the message: catch `Unique` before `Duplicate` to tell the two apart. `Exception\Unique` has no constructor of + its own and never rewrites the message it is given. The message is `Exception\Unique::MESSAGE`, also when + `createIndex()` refuses a unique index over documents that already share a value (7.4.1: `Unique index violation`, + and `Cannot create unique index: existing rows already contain duplicate values` on Redis). +- **`ignoreDuplicates()` on PostgreSQL** skips only a document whose id is stored, as in 7.x: a new id that collides + on another unique index throws `Utopia\Database\Exception\Unique`. MariaDB, MySQL and SQLite cannot name the + index to ignore and, as in 7.x, skip such a row without error. +- **Retries of metadata writes.** Schema calls that persist a collection definition (`createAttribute()`, + `createIndex()`, their update, rename and delete siblings, `createRelationship()`) no longer retry a failure that + fails the same way every time: `Authorization`, `Character`, `Conflict`, `Dependency`, `Duplicate` (and `Unique` + and `Mismatch`), `Index`, `Limit`, `NotFound`, `Operator`, `Order`, `Query`, `Refused`, `Relationship`, + `Restricted`, `Structure`, `Timeout`, `Truncate`, `Type` and `Unconfirmed` are thrown on the first attempt. A failure the + metadata write's transaction retries itself (see [Transaction retries](#errors)) is not run again by the schema + call, so its retries do not multiply. Other failures, such as an unavailable cache, are still attempted up to three + times. +- **Transaction retries.** `withTransaction()` runs the callback again only after a failure that can succeed on + another attempt: an `Exception\Transaction` (a lock conflict, which is `Exception\Contention`, or a failed begin, + commit or rollback), a lost connection (also as the cause of another failure), an engine lock conflict the adapter + did not map, or, on MongoDB, an error labelled `TransientTransactionError`, a network error before the commit or a + command that was never sent. Every other failure runs the callback once and is rethrown at once, + in a nested call too: every typed library failure (`Structure`, `NotFound`, `Query`, `Type`, `Index`, + `Dependency`, `Truncate`, `Duplicate`, `Timeout`, ...) and any other exception. 7.x retried everything but + `Duplicate`, `Restricted`, `Authorization`, `Relationship`, `Conflict`, `Limit` and `Timeout` twice, sleeping 50 ms + and 100 ms, before rethrowing the same failure. A callback that throws its own exception to have the transaction + run again must throw `Exception\Transaction` (or `Exception\Contention`) instead. Only the outermost + `withTransaction()` retries: a call nested in another rolls back to its savepoint and rethrows, and the outermost + call runs the whole unit again, releasing its locks between attempts. A lock conflict that keeps failing therefore + runs a nested callback 3 times, not 9 (7.x retried in the savepoint too). A call nested in a transaction begun + with `startTransaction()` still retries in its savepoint. +- **Commits with an unknown result.** On MongoDB, when the result of a commit is unknown, `withTransaction()` sends + only the commit again, up to 3 more times, with a `majority` write concern and a 10 s `wtimeout`, and does not run + the callback again. Each retry can wait up to that 10 s while it holds the connection. The result is unknown after + a network error, a primary change or shutdown, `MaxTimeMSExpired` (50) or `ExceededTimeLimit` (262) during the + commit, or an error labelled `UnknownTransactionCommitResult`, so codes 50 and 262 at commit no longer surface as + `Exception\Timeout`. After a socket timeout or send failure the client drops the connection with its sessions, so + the commit cannot be sent again and `Unconfirmed` is thrown after a 50 ms pause, when the first retry finds the + session gone: only a commit that failed with a primary change, shutdown, time limit or label is sent again, and a + retry the client could not send is tried once more. If the commit still cannot be confirmed, `withTransaction()` + throws `Utopia\Database\Exception\Unconfirmed`, whose `getPrevious()` is the first commit error, and does not run + the callback again: treat the work as possibly committed and re-read before acting on it. The queued + `document_purge` events of its last attempt still fire, because its writes may be stored. 7.x ran the whole + callback again, which could store its writes twice. A schema call whose definition write ends in `Unconfirmed` + rethrows it unchanged and keeps the table, column or index, as after a failure once the definition is stored (see + below). On a sharded cluster (`mongos`) the adapter runs without transactions, as on a standalone server. +- **Commits the server reports aborted.** On MongoDB, a commit that the server reports aborted (`NoSuchTransaction` + (251) or `WriteConflict` (112)) stored nothing, so `withTransaction()` runs the callback again, within its usual 2 + retries, whether it was the first commit or a retry; when the retries run out it throws `Utopia\Database\Exception` + with an `Exception\Transaction` cause. 7.x reported a first commit the server had aborted as a success, so the + callback's writes were lost while the call returned normally. MongoDB aborts the whole transaction on a failed + write in it, so a callback that catches a failed write (a `Duplicate`, for example) and carries on now runs again + and then fails, where 7.x returned without storing any of its writes. On the SQL adapters a connection lost + during `COMMIT` still runs the callback again: the work is at-least-once on a SQL connection loss during `COMMIT`. +- **Failed rollbacks of metadata writes.** When a definition cannot be persisted and the schema change's rollback + fails too, the thrown `Utopia\Database\Exception` names the persistence error first and the rollback's after + `| Cleanup error:`, and its `getPrevious()` is always the persistence error. In 7.x the message labelled the two the + other way round, and some calls reported only the rollback's error. +- **Failures after a schema change committed.** When a definition is stored and only the cache invalidation or + events after it fail, or its commit ends in `Exception\Unconfirmed`, the schema call rethrows that failure + unchanged, does not retry it and does not undo the physical change: `createCollection()`, `createAttribute()`, + `createAttributes()` and `createIndex()` keep the table, column or index, `updateAttribute()`, + `renameAttribute()` and `renameIndex()` keep the change, and `deleteAttribute()` and `deleteIndex()` leave the + column or index dropped. `createRelationship()` also completes the relationship's indexes before rethrowing it. +- **Deletes whose definition may be stored.** `deleteCollection()` and `deleteRelationship()` drop the table or + columns before they remove the definition. When that removal fails, also after its commit or with an + `Exception\Unconfirmed` commit, they recreate the table or columns empty and throw a `Utopia\Database\Exception` + whose `getPrevious()` is the failure. If the removal was stored, this leaves an empty table or column without a + definition: `createRelationship()` with the same key reuses the columns, and `createCollection()` with the same id + throws `Exception\Duplicate` unless tables are shared, where it reuses the table (on MongoDB creating the + collection's indexes again). The library does not run `deleteCollection()` again, and the wrapper no longer has the + `Unconfirmed` class, so a caller that retries on it runs the delete again: harmless when the removal was not + stored, and `Exception\NotFound` when it was. +- **`deleteCollection()` deletes the relationships first.** It deletes each of the collection's relationships, with + their columns, junction collections and the related collections' two-way attributes, before it drops the + collection's own table. When the table drop or the definition removal then fails, the relationships stay deleted + while the collection remains. Running `deleteCollection()` again converges: it finds no relationships left and + drops the rest. A `Mirror` forwards the delete to its destination only after the source succeeds, so until the + retry the source has lost the relationships the destination still has. +- **Adapter refusals.** When an adapter returns `false` from a schema change instead of raising an error, the call + throws `Utopia\Database\Exception\Refused`, a subclass of `Utopia\Database\Exception`, whose `getPrevious()` is + `null`. This covers `create()`, `update()`, `delete()`, `createCollection()`, `deleteCollection()`, + `createAttribute()`, `createAttributes()`, `updateAttribute()`, `deleteAttribute()`, `renameAttribute()`, + `createIndex()`, `createIndexes()`, `deleteIndex()`, `renameIndex()`, `createRelationship()`, + `updateRelationship()` and `deleteRelationship()`, and nothing is written to the metadata or the caches after a + refusal. Most keep their 7.x message (`Failed to create attribute`), and the database and collection calls, which + ignored a `false` in 7.x (a refused `createCollection()` stored its definition anyway), say `Failed to create + database`, `Failed to delete database`, `Failed to create collection` and `Failed to delete collection`. A refused + rename or relationship update has one message naming its keys: `Failed to rename attribute 'a' to 'b'`, `Failed to + rename index 'a' to 'b'`, `Failed to rename database 'a' to 'b'` and `Failed to update relationship 'k'`. + `update()` and `delete()` therefore return `true` whenever they return. An adapter still answers `true` for a + state that already holds, such as a database that exists or is already gone. 7.x wrapped those three refusals in + the message it uses for an adapter error (`Failed to rename attribute 'a' to 'b': Failed to rename attribute`), + so a refusal looked like an engine failure. An error the adapter raises reaches the caller as itself, + except from `renameAttribute()`, `renameIndex()` and `updateRelationship()`: there an `Exception\Duplicate` or + `Exception\NotFound` (such as MariaDB/MySQL 1054 for a missing column) still reaches the caller as itself, and any + other error is wrapped in a plain `Utopia\Database\Exception` (`Failed to rename attribute 'a' to 'b': `) + whose `getPrevious()` is the error. `renameIndex()` and `updateRelationship()` first check whether an earlier + attempt already made the change, and complete it when it did. 7.x wrapped `Duplicate` and `NotFound` too. Catch + `Exception\Refused` to tell a refusal apart instead of matching the message or the nesting of causes. A refused + `renameIndex()` no longer renames back and forth to complete an earlier rename: an adapter returns `false` only when the index is under neither name. When `createAttributes()` + falls back to creating the columns one at a time, a column the adapter does not create is refused too; 7.x stored + it in the metadata as created, and drops the columns it had already created. On MongoDB, `createCollection()` + drops a collection it created but could not give its indexes, so a later create no longer adopts it without + them; see [MongoDB: collections](#mongodb-collections). +- **`deleteRelationship()` whose drop fails.** The relationship's indexes, or its junction collection's definition, + are removed before the adapter drops its columns or junction tables, because SQLite cannot drop an indexed column + and the engines disagree on what a column drop does to its indexes. When the adapter refuses the drop or raises an + error, the indexes and the junction definition are now recreated and the failure is rethrown unchanged; 7.x left + them dropped while the relationship stayed. +- **Engine errors mapped to library exceptions.** + + | Engine condition | Exception | + |---|---| + | MariaDB/MySQL deadlock (1213) or lock wait timeout (1205); PostgreSQL deadlock (40P01), serialization failure (40001) or lock not available (55P03); SQLite `database is locked` (5) | `Exception\Contention`, a subclass of `Exception\Transaction`, which `withTransaction()` retries twice before rethrowing | + | MariaDB/MySQL statement on a missing table (1146) | `Exception\NotFound` (`Collection not found`), as 1051, PostgreSQL 42P01 and SQLite `no such table` | + | MariaDB/MySQL index on a column the table lacks (1072); SQLite `no such column` | `Exception\NotFound` (`Attribute not found`), as 1054 and PostgreSQL 42703 | + | PostgreSQL invalid UTF-8 (22021) | `Exception\Character` (`Invalid character`), as MariaDB/MySQL 1366 | + | PostgreSQL 42P01 naming something other than a collection table (an undeclared or mis-quoted alias) | `Exception\Query` (`Query references an undefined table or alias`); a missing collection table stays `NotFound` | + | PostgreSQL 42P10 of a `distinct()` read ordered by an unselected attribute | `Exception\Query`, in any server language (`lc_messages`) | + | PostgreSQL undefined function or operator (42883), for example `max()` over a boolean | `Exception\Query` | + | `renameAttribute()` or `updateAttribute()` with a new key onto a column that exists beside the old one (every SQL engine) | `Exception\Duplicate` (`Attribute already exists`); PostgreSQL threw a generic `Exception`, and the other engines adopted the column | + | Shared tables: a column another tenant's collection stores with another type | `Exception\Duplicate` (`Attribute exists in the shared table with another type`); PostgreSQL throws its subclass `Exception\Mismatch` | + +- `Database::deleteCollection()` on PostgreSQL succeeds again when the collection's table is already gone (7.x + behaviour): the metadata is removed and the permissions table is dropped. On MariaDB and MySQL it drops the + permissions table in that case too, so the collection can be created again. +- **MongoDB `count()`** no longer returns `0` when the query fails: it throws the mapped driver error (a timeout + still throws `Exception\Timeout`). Code that treated a failed count as zero has to catch the exception. +- **Features an adapter lacks.** On an adapter without timeouts (SQLite, Memory, Redis), `Database::setTimeout()` and + `clearTimeout()` throw `Utopia\Database\Exception` (`Adapter does not support timeouts`). 7.x's SQLite, which + inherited them from MariaDB, ignored them. `getConnectionId()` and `getHostname()` return `null` on an adapter + without `Feature\Connection` (Memory). `schema()` throws `Schema builder is not supported by this adapter` + where `from()` throws for the query builder. `getSchemaAttributes()` and `getSchemaIndexes()` return `[]` + without `Capability::SchemaIntrospection`. +- **Unknown columns on MariaDB and MySQL.** A statement that names a column the table lacks (1054) now throws + `Utopia\Database\Exception\NotFound` (`Attribute not found`) instead of a raw `PDOException`, as PostgreSQL + already did. This includes a table that has drifted from its metadata: `find()`, `count()` and `sum()` that + filter, order or group by the missing column, writes that name it, `updateAttribute()` and `renameAttribute()` on + it, and raw queries. +- **`Database::sum()`** now validates its attribute (the second argument) the way a `Query::sum()` aggregate is + validated, and throws `Utopia\Database\Exception\Query` when it does not name a numeric, non-array attribute: of + the main collection or, under a join alias, of the collection that join reads. In 7.x an unknown attribute reached + the engine and failed there, and a string or array attribute returned 0 (or failed on PostgreSQL). Sum only + numeric attributes. With joins, a bare name resolves as in an aggregation query (see + [Aggregations](#aggregations)), and a joined attribute can always be qualified with its alias + (`sum('orders', 'item.price', [$join])`). + +## Caches + +- **Cache key names changed: do not share a cache between 7.x and 8.0 processes.** A document is cached in one hash + per document, as in 7.x, but the key now includes the database name + (`{cacheName}-cache-{hostname}:{database}:{namespace}:{tenant}:collection:{collection}:{id}`; 7.x used + `{cacheName}-cache-{hostname}:{namespace}:{tenant}:collection:{collection}:{id}`), with one field per selection + whose value records the collection epoch it was filled under. 7.x and 8.0 keys are disjoint, so neither version + reads or invalidates the other's entries. During a rolling upgrade on one cache this holds in both directions: a + 7.x process keeps serving documents, permissions and collection definitions that an 8.0 process has changed, and + an 8.0 process keeps serving what a 7.x process has changed (a revoked permission included), until the entry + expires after the cache TTL (`Database::TTL`, 24 hours). Deploy without overlap, or run both sides without a cache + during the overlap (for example with `Utopia\Cache\Adapter\None`; a write from an uncached side cannot invalidate + the other side's entries), and flush the cache once the last 7.x process has stopped. +- **Invalidation.** A single-document write (`createDocument()`, `updateDocument()`, `increaseDocumentAttribute()`, + `decreaseDocumentAttribute()`, `deleteDocument()`) and `purgeCachedDocument()` purge only that document, inside the + transaction and again after the outermost commit or rollback; other cached documents of the collection stay cached. + Batch writes and schema changes retire the collection's cached documents at once. If the purge after a commit fails, + the write throws with the data committed, and the collection's cached documents are retired instead. +- **A write reads under the definition it resolved.** The locked read of `updateDocument()`, + `increaseDocumentAttribute()`, `decreaseDocumentAttribute()` and `deleteDocument()`, and every page read of + `updateDocuments()` and `deleteDocuments()`, use the collection definition the write resolved first. A schema + change committed after that, or between two pages, is not seen by that read or those pages; the write already + validated and encoded under the earlier definition. A bulk write reads `_metadata` at most once per call instead + of once per page. See [Subclasses of `Database`](#subclasses-of-database) for the overrides these reads skip. +- **Transactions.** Inside `withTransaction()` a read uses the cache only for documents the transaction has not + written; it never fills the cache. A transaction started on the adapter directly reads uncached (see + [Known limitations](#known-limitations)). +- **Read replicas.** With `ReadWritePool`, reads served by a replica are not cached; only reads the pool sends to the + primary (in a transaction or within the sticky window after a write) fill the document and query caches. +- **Collection definitions carry the document-cache epoch.** A cached collection definition holds, per tenant, the + epoch its collection's documents are cached under, so a cached `getDocument()` costs two cache round trips (the + definition and the document) and `getCollection()`, `find()`, `count()` and `sum()` one before their query, as in + 7.x. Batch writes and schema changes purge the collection's definition when they retire its documents, so the + first read afterwards also reads the definition from the database once. +- Use a cache adapter with generations (`Utopia\Cache\Feature\Leasable`) for the document cache too: without them a + read that overlaps a write can cache the previous row until the next write or the TTL. +- **Query cache layout.** `find()` results are cached in one hash per collection scope. Each query and role context + maps to one of `slots` fields (`new Cache\Query($cache, slots: $count)`, 1024 by default), and the value records the + query and the epoch it was filled under, so a hit needs both to match. An invalidation publishes a new epoch and + deletes nothing: an older result stays until a fill of its slot replaces it. The number of keys and fields no + longer grows with writes or with distinct queries, and a write's cost does not depend on how many results are + cached. A collection scope keeps at most `slots` results, so its memory is bounded by `slots` times its largest + result, and queries sharing a slot evict each other. All of a collection's results share one key: under Redis + Cluster they live on one slot, and an invalidation rejects every in-flight fill of that collection. On a cache + without fields (Memory, Filesystem) a collection scope holds one result at a time. +- **Abandoned writes.** A write that blocks a collection's cache and never finishes its invalidation (a worker killed + mid-transaction) no longer keeps that cache off until a flush. For the document cache the limit is + `$database->setCacheWriterTimeout($seconds)`, 3600 seconds by default. The query cache uses the writer timeout + and the cache name of the `Database` that calls it, so one query cache can be shared by several handles. Reads resume once the unfinished write is older than the + timeout, and the next write re-enables the cache once every other unfinished write is older than the timeout. A + transaction that runs longer than the timeout is treated as abandoned: raise it above your longest transaction, + and use the same value in every process (the shortest one applies). +- **Keys left by 8.0 pre-releases.** On Redis, keys matching `*#owner:*`, document entries whose key ends in + `:#` and query-cache keys matching `*:qcache:*#active:*` are no longer read and can be deleted. On + Redis a purged document keeps one key holding its generation, with no expiry: the key count grows with the number + of document ids ever written, not with the number of writes. +- `purgeCachedCollection()` also invalidates the collection's cached `find()` results, and + `purgeCachedCollection('_metadata')` reads the collection list and purges each cached definition. +- With a query cache installed, a cache error in `find()` logs a warning and reads the database, as `getDocument()` + does. +- **`purgeCachedQueries()` also purges the `find()` query cache.** + `Database::purgeCachedQueries($collection, $namespace = null)` rotates the `withCache()` region under + `getQueryCacheKey()` as in 7.x. When a query cache is installed with `setQueryCache()`, it now also invalidates the + collection's `find()` results in that namespace. It returns `false` if either purge fails. It does not throw for a + cache failure: the error is logged as a warning. +- **`delete()` flushes the whole cache.** `Database::delete()` flushes the cache it was given, as in 7.x. On a cache + shared with other databases or applications (one Redis), that removes their entries too: give each database + handle a cache of its own, or delete databases through `update()` and a cleanup of your own where that matters. +- **Cache keys.** `getCacheKeys()`, `getCacheBaseKeys()` and `getQueryCacheKey()` stay public. + `getQueryCacheField()` is internal. +- **Filters that belong to one handle** go in the `Database` constructor's `$filters` or on a `Filter\Registry` (see + [Filters](#filters)). + +## Filters + +`Database::addFilter(string $name, callable $encode, callable $decode)` still registers a filter for every handle in +the process; its callbacks receive the value, the document and the database. A filter that belongs to one handle is +a `Utopia\Database\Filter\Codec` (`name()`, `encode(mixed $value)`, `decode(mixed $value)`), which receives only the +value; 7.x passed constructor filters the document and the database as well. `Filter\Callback` builds one from two +closures. + +| 7.x | 8.0 | +|---|---| +| `new Database($adapter, $cache, ['name' => ['encode' => $encode, 'decode' => $decode]])` | `new Database($adapter, $cache, [new Filter\Callback('name', $encode, $decode)])`, a list of `Filter\Codec` | +| `getInstanceFilters()` | The codecs given to the constructor, or `getFilters(): Filter\Registry` | + +```php +// 7.x +$database = new Database($adapter, $cache, [ + 'trim' => [ + 'encode' => fn (mixed $value) => \trim($value), + 'decode' => fn (mixed $value) => $value, + ], +]); + +// 8.0 +$database = new Database($adapter, $cache, [ + new Filter\Callback('trim', fn (mixed $value) => \trim($value), fn (mixed $value) => $value), +]); +``` + +- `setFilters(Filter\Registry $filters): static` gives a handle a registry of codecs, which several handles can + share; `Filter\Registry::register(Filter\Codec $codec): static`, `get(string $name)` and `has(string $name)` manage + it. A codec takes precedence over a global filter of the same name, and the constructor's codecs over both. +- A codec named after a built-in filter (`Utopia\Database\Filter`: `json`, `datetime`, `point`, `linestring`, + `polygon`, `vector`, `object`) throws `Exception\Duplicate`. A codec that needs the document or the database is a + global filter: register it with `addFilter()`. +- Cache keys tell codecs apart by class. A codec class whose instances encode differently implements + `Filter\Signed` (`signature(): string`) so each instance gets its own key; `Filter\Callback` does. +- List a filter in an attribute's `filters`, by name or as a `Filter` case. + +## Adapters + +This section matters if you check adapter capabilities, read adapter limits, subclass an adapter or write your own. +[docs/add-new-adapter.md](docs/add-new-adapter.md) describes the contract a new adapter implements. + +### Capabilities and features + +The 51 `getSupportFor*()` methods of `Adapter` are removed. A behaviour flag is a `Utopia\Database\Capability` case +checked with `$adapter->supports(Capability::X)`. A group of methods an adapter may or may not implement is an +optional `Utopia\Database\Adapter\Feature\*` interface, checked with `$adapter->hasFeature(Feature\X::class)`. Use +`hasFeature()`, never `instanceof`: `Adapter\Pool` forwards the optional feature methods to the adapter it borrows +without implementing their interfaces, so only `hasFeature()` answers correctly for a pooled adapter. +`$database->profile()` answers both without asking the adapter again (see [Limits and profile](#limits-and-profile)). + +| 7.x | 8.0 | +|---|---| +| `getSupportForAlterLocks()` | `supports(Capability::AlterLock)` | +| `getSupportForAttributeResizing()` | `supports(Capability::AttributeResizing)` | +| `getSupportForAttributes()` | `supports(Capability::DefinedAttributes)` | +| `getSupportForCaching()` | `supports(Capability::Caching)` | +| `getSupportForCastIndexArray()` | `supports(Capability::IndexArrayCast)` | +| `getSupportForCasting()` | `! hasFeature(Feature\Casting::class)`: the library casts the values of every adapter that does not implement `Feature\Casting` | +| `getSupportForFulltextIndex()` | `supports(Capability::IndexFulltext)` | +| `getSupportForFulltextWildcardIndex()` | `supports(Capability::IndexFulltextWildcard)` | +| `getSupportForGetConnectionId()`, `getSupportForHostname()`, `getSupportForReconnection()` | `hasFeature(Feature\Connection::class)` | +| `getSupportForIdenticalIndexes()` | `supports(Capability::IndexIdentical)` | +| `getSupportForIndex()` | `supports(Capability::IndexKey)` | +| `getSupportForIndexArray()` | `supports(Capability::IndexArray)` | +| `getSupportForIntegerBooleans()` | `supports(Capability::IntegerBooleans)` | +| `getSupportForInternalCasting()`, `getSupportForUTCCasting()` | `hasFeature(Feature\Casting::class)` | +| `getSupportForMultipleFulltextIndexes()` | `supports(Capability::IndexFulltextMultiple)` | +| `getSupportForNestedTransactions()` | `supports(Capability::TransactionNested)` | +| `getSupportForObject()` | `supports(Capability::Objects)` | +| `getSupportForObjectIndexes()` | `supports(Capability::IndexObject)` | +| `getSupportForOperators()` | `supports(Capability::Operators)` | +| `getSupportForOrderRandom()` | `supports(Capability::OrderRandom)` | +| `getSupportForRelationships()` | `hasFeature(Feature\Relationships::class)` | +| `getSupportForSchemaAttributes()`, `getSupportForSchemaIndexes()` | `supports(Capability::SchemaIntrospection)` | +| `getSupportForSchemas()` | `supports(Capability::Schemas)` | +| `getSupportForSpatialAttributes()` | `hasFeature(Feature\Spatial::class)` | +| `getSupportForSpatialAxisOrder()` | `supports(Capability::SpatialAxisOrder)` | +| `getSupportForSpatialIndexNull()` | `supports(Capability::IndexSpatialNull)` | +| `getSupportForSpatialIndexOrder()` | `supports(Capability::IndexSpatialOrder)` | +| `getSupportForTTLIndexes()` | `supports(Capability::IndexTtl)` | +| `getSupportForTimeouts()` | `hasFeature(Feature\Timeouts::class)` | +| `getSupportForTransactionRetries()` | `supports(Capability::TransactionRetries)` | +| `getSupportForTrigramIndex()` | `supports(Capability::IndexTrigram)` | +| `getSupportForUniqueIndex()` | `supports(Capability::IndexUnique)` | +| `getSupportForUnsignedBigInt()` | `supports(Capability::UnsignedBigInt)` | +| `getSupportForUpdateLock()` | `supports(Capability::UpdateLock)` | +| `getSupportForUpsertOnUniqueIndex()` | `supports(Capability::UpsertOnUniqueIndex)` | +| `getSupportForUpserts()` | `hasFeature(Feature\Upserts::class)` | +| `getSupportForVectors()` | `supports(Capability::Vectors)` | +| `getSupportNonUtfCharacters()` | `supports(Capability::NonUtfCharacters)` | +| `getSupportForBatchCreateAttributes()`, `getSupportForBatchOperations()`, `getSupportForBoundaryInclusiveContains()`, `getSupportForCacheSkipOnFailure()`, `getSupportForDistanceBetweenMultiDimensionGeometryInMeters()`, `getSupportForJSONOverlaps()`, `getSupportForNumericCasting()`, `getSupportForOptionalSpatialAttributeWithExistingRows()`, `getSupportForPCRERegex()`, `getSupportForPOSIXRegex()`, `getSupportForQueryContains()`, `getSupportForRegex()` | No replacement: every adapter behaves the same way, or nothing branches on the answer | + +`Capability::Joins` and `Capability::Aggregations` are new. `Capability::SchemaIntrospection` is declared by MariaDB, +MySQL, SQLite and PostgreSQL. Every case is declared by at least one adapter. To list what an adapter reports, call +`$adapter->capabilities()`. + +The optional features: + +| Interface | Methods | Implemented by | +|---|---|---| +| `Feature\Casting` | `castBefore(Document $collection, Document $document): Document`, `castAfter(Document $collection, array $documents): array`, `castDatetime(string $value): mixed` | MongoDB | +| `Feature\Connection` | `ping(): bool`, `reconnect(): void`, `id(): string`, `hostname(): string` | the SQL adapters, MongoDB, Redis | +| `Feature\QueryBuilder` | `builder(): Builder`, whose `from()` takes a collection id, `schema(): Schema` | the SQL adapters | +| `Feature\RawQuery` | `rawQuery(string $query, array $bindings = []): array`, `rawMutation(string $query, array $bindings = []): int` | the SQL adapters | +| `Feature\Relationships` | `createRelationship(string $collection, Relationship $relationship): bool`, `updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool`, `deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool` | the SQL adapters, MongoDB, Memory, Redis | +| `Feature\Schemaless` | `setSchemaless(bool $schemaless): static`, `isSchemaless(): bool` | MongoDB | +| `Feature\Spatial` | `encode(mixed $value, ColumnType $type): string`, `decode(string $value, ColumnType $type): array` | MariaDB, MySQL, PostgreSQL | +| `Feature\Timeouts` | `setTimeout(int $milliseconds, Event $event = Event::All): void`, `clearTimeout(Event $event = Event::All): void`, `getTimeout(Event $event = Event::All): int` | MariaDB, MySQL, PostgreSQL, MongoDB, `Pool` | +| `Feature\Upserts` | `upsertDocument(Document $collection, Change $change): Document`, `upsertDocuments(Document $collection, array $changes, ?string $increase = null): array` | the SQL adapters, MongoDB, Redis | + +`Change` is `final readonly` with public `old` and `new`; 7.x's `getOld()`, `setOld()`, `getNew()` and `setNew()` are +removed. + +```php +// 7.x +if ($adapter->getSupportForFulltextIndex() && $adapter->getSupportForTimeouts()) { + $adapter->setTimeout(5000); +} +$limit = $adapter->getLimitForAttributes(); + +// 8.0 +if ($adapter->supports(Capability::IndexFulltext) && $adapter->hasFeature(Feature\Timeouts::class)) { + $database->setTimeout(5000); +} +$limit = $adapter->limits()->attributes; +``` + +### Limits and profile + +The 16 per-limit getters of `Adapter` are one `Adapter\Limits` value, `$adapter->limits()`: + +| 7.x | `Limits` property | +|---|---| +| `getLimitForString()`, `getMaxVarcharLength()` | `string`, `varchar` | +| `getLimitForInt()`, `getLimitForBigInt()` | `integer`, `bigInteger` | +| `getLimitForAttributes()`, `getLimitForIndexes()` | `attributes`, `indexes` | +| `getCountOfDefaultAttributes()`, `getCountOfDefaultIndexes()` | `defaultAttributes`, `defaultIndexes` | +| `getMaxIndexLength()`, `getMaxUIDLength()`, `getDocumentSizeLimit()` | `indexLength`, `uidLength`, `documentSize` | +| `getMinDateTime()`, `getMaxDateTime()` | `minDateTime`, `maxDateTime` | +| `getIdAttributeType()` | `idType` (a `ColumnType`) | +| `getKeywords()`, `getInternalIndexesKeys()` | `keywords`, `internalIndexKeys` | + +`Database` keeps `getLimitForAttributes()`, `getLimitForIndexes()`, `getMaxIndexLength()`, `getMaxVarcharLength()`, +`getMinDateTime()`, `getMaxDateTime()`, `getIdAttributeType()` and `getMaxUidLength()` (7.x adapters spelled it +`getMaxUIDLength()`), all read from `limits()`. + +`$database->profile(): Adapter\Profile` snapshots the adapter's `limits`, `capabilities`, `features`, `sharedTables` +and `migrating` once per `Database`, and is rebuilt by `setSharedTables()`, `setMigrating()` and `setSchemaless()`. +Its `supports()` and `hasFeature()` answer without a call into the adapter, except +`supports(Capability::DefinedAttributes)`, which it asks the adapter every time, because a pooled connection's +schema mode can change. The validators take it (see [Validators and helpers](#validators-and-helpers)). + +### Schemaless mode + +`setSupportForAttributes(bool $support)` is removed from every adapter. Switch MongoDB's schemaless mode with +`$database->setSchemaless(! $support)` (`Feature\Schemaless`), which also rebuilds the profile; +`setSchemaless(true)` throws on an adapter that always enforces its attributes. Calling the adapter's +`setSchemaless()` directly leaves the `Database` profile stale. `Adapter\Pool` puts every borrowed connection in the +mode. + +### Connection feature + +- `ping()`, `reconnect()`, `getConnectionId()` and `getHostname()` are `Feature\Connection`'s `ping()`, `reconnect()`, + `id()` and `hostname()`. Memory has no connection. `Database::ping()`, `reconnect()`, `getConnectionId()` and + `getHostname()` forward them. +- SQLite and MongoDB report their handle's `spl_object_id()` as the connection id, which is unique only within the + process; Redis reports `'0'`. +- The protected `SQL::getPDO()` is removed: `getDriver(): object` returns the PDO (or proxy). `getDriver()` returns + the adapter itself on Memory and the client on Redis. +- `SQL::getPDOAttributes()` is removed. Pass PDO attributes yourself when you connect (`ATTR_ERRMODE => + ERRMODE_EXCEPTION`, `ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC`, `ATTR_EMULATE_PREPARES => true`, + `ATTR_STRINGIFY_FETCHES => true`, and a timeout as you need). +- `Adapter::clearTimeouts()` is removed: `clearTimeout(Event::All)` clears every event. + +### Writing or subclassing an adapter + +- The abstract methods of `Adapter` are the whole mandatory contract. 8.0 pre-releases also declared them in six + `Feature` interfaces (`Attributes`, `Collections`, `Databases`, `Documents`, `Indexes`, `Transactions`), which are + removed. Report optional behaviour by overriding `capabilities()`, and implement the optional `Feature` + interfaces your adapter supports. +- `Adapter\SQLite` now extends `Adapter\SQL` instead of `Adapter\MariaDB`. A check like + `$adapter instanceof MariaDB` no longer matches SQLite: check capabilities and features instead. The MariaDB + methods SQLite inherited in 7.x, such as `setTimeout()` and `getViolatedKey()`, are no longer available on it. +- Changed adapter signatures: + + | 7.x | 8.0 | + |---|---| + | `exists(string $database, ?string $collection = null): bool` | `exists(string $database): bool` and `collectionExists(string $database, string $collection): bool` | + | — | `update(string $name, string $new): bool` (abstract; see [Renaming a database](#renaming-a-database)) | + | `createCollection(string $name, array $attributes = [], array $indexes = [])` | `createCollection(string $collection, array $attributes = [], array $indexes = [])` with lists of `Attribute` and `Index` | + | `deleteCollection(string $id)` | `deleteCollection(string $collection)` | + | `createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false)` | `createAttribute(string $collection, Attribute $attribute)` | + | `updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false)` | `updateAttribute(string $collection, string $key, Attribute $attribute)`; the target key is `$attribute->key` | + | `deleteAttribute(string $collection, string $id)` | `deleteAttribute(string $collection, string $key)` | + | `createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1)` | `createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = [])` | + | `deleteIndex(string $collection, string $id)` | `deleteIndex(string $collection, string $key)` | + | `createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = '')` | `Feature\Relationships::createRelationship(string $collection, Relationship $relationship)` | + | `updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null)` | `Feature\Relationships::updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update)` | + | `deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side)` | `Feature\Relationships::deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side)` | + | `deleteDocument(string $collection, string $id)`, `deleteDocuments(string $collection, ...)`, `increaseDocumentAttribute(string $collection, ...)`, `getSequences(string $collection, ...)` | Take the collection `Document`, like every other document method | + | `updateDocuments(Document $collection, Document $updates, array $documents)` | `updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = [])`: the ids whose permissions the update keeps | + | `upsertDocuments(Document $collection, string $attribute, array $changes)` | `Feature\Upserts::upsertDocuments(Document $collection, array $changes, ?string $increase = null)`, plus `upsertDocument(Document $collection, Change $change)` | + | `getSchemaAttributes(string $collection): array`, `getSchemaIndexes(string $collection): array` | Abstract, returning `list` and `list` | + | `getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string` | `getColumnType(Attribute $attribute): ?string` (abstract; `null` on non-SQL adapters) | + | `castingBefore()`, `castingAfter(Document $collection, Document $document)`, `setUTCDatetime()` | `Feature\Casting::castBefore()`, `castAfter(Document $collection, array $documents)` over a page, `castDatetime()` | + | `decodePoint()`, `decodeLinestring()`, `decodePolygon()` | `Feature\Spatial::encode()` and `decode(string $value, ColumnType $type)`; the per-shape decoders are protected | + | `find(..., string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ)` | `find(..., CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read)`; `$orderTypes` holds `OrderDirection` cases | + | `setTimeout(int $milliseconds, string $event = Database::EVENT_ALL)`, `clearTimeout(string $event)`, `getTimeout(): int` | `Feature\Timeouts`, with `Event $event = Event::All` on all three | + | `increaseDocumentAttribute(..., int\|float $value, ..., int\|float\|null $min = null, int\|float\|null $max = null)` | Numbers may also be numeric strings (`string\|int\|float`), for unsigned 64-bit values | + | `supports()` and the getters taking a `$name`/`$id` | Parameters named `$capability`, `$collection` and `$key` | + | `SQL::__construct(mixed $pdo)` | `SQL::__construct(object $pdo)`: a `Utopia\Database\PDO`, a PDO-compatible proxy or a native `PDO` | + | `SQL::deleteAttribute(string $collection, string $id, bool $array = false)` | `deleteAttribute(string $collection, string $key)` | + | `SQL::execute(mixed $statement)` | `execute(mixed $statement, ?Event $event = null)`, on `Adapter\SQL` only | + | `SQL::getSQLType(string $type, ...)` | `getSqlType()`, taking `ColumnType` cases | + | `SQL::getOperatorSQL(string $column, Operator $operator, array &$binds)` | `getOperatorSql(string $column, Operator $operator, int &$bindIndex)` | + | `setDatabase()`, `setSharedTables()`, `setTenant()`, `setTenantPerDocument()`: `bool` | Return `static` | + +- Protected and public methods use camel-cased acronyms and full words. Rename your overrides: + + | 7.x | 8.0 | + |---|---| + | `getSQLTable()`, `getSQLTableRaw()` | `qualifyTable(string $database, string $namespace, string $name)`: `getTable()` and `getTableRaw()` are final and both derive from it | + | `getSQLType()`, `getSQLIndex()`, `getSpatialSQLType()`, `getOperatorSQL()`, `getPDOType()`, `getSQLReadableDistance()`, `getFTS5Value()` | `getSqlType()`, `getSqlIndex()`, `getSpatialSqlType()` (protected on MySQL too), `getOperatorSql()`, `getPdoType()`, `getSqlReadableDistance()`, `getFts5Value()` | + | `convertArrayToWKT()`, `isExtendedISODatetime()`, `convertUTCDateToString()` | `convertArrayToWkt()`, `isExtendedIsoDatetime(string $value)`, `convertUtcDateToString()` | + | `bindOperatorParams()`, `getIdentifierQuoteChar()` | `bindOperatorParameters()`, `getIdentifierQuote()` | + | `getSpatialGeomFromText()`, `getSpatialAxisOrderSpec()` | `getSpatialGeometryFromText()`, `getSpatialAxisOrder()` | + | `replaceChars()` | `replaceCharacters()` | + | `Redis::tx()` | `Redis::transaction()` | + | `getLockType()` (SQL), `listCollections()` and `getTenantFilters()` (MongoDB) | Protected | + | Parameters `$stmt`, `$fn`, `$op`, `$attrs`, `$val`, `$quoteChar` | `$statement`, `$callback`, `$operation`, `$attributes`, `$value`, `$quoteCharacter` | + +- `quote()` and `execute()` are declared on `Adapter\SQL` only. Keywords and internal index keys are `Limits` fields. +- `Adapter::relaxAttributeRequired(string $collection, string $id): bool` is new. The library calls it when an + attribute becomes optional without a column change; it does nothing by default, and PostgreSQL drops the column's + `NOT NULL` there. +- `SQL::getSpatialColumnSrid(): ?int` (protected) is new. It returns the SRID written into spatial column + definitions, or `null` for a dialect that cannot declare one (MariaDB). `getSpatialSqlType()` follows it as + well, so a dialect returning `null` declares spatial columns without an SRID on `createCollection()`, + `createAttribute()`, `createAttributes()` and `updateAttribute()` alike. +- `SQL::insertOrIgnore(SQLBuilder $builder): Statement`, `SQL::supportsInsertReturning(): bool` and + `SQL::documentKeyColumns(): array` (protected) are new. Under `ignoreDuplicates()` an adapter's `createDocuments()` + must return only the documents it inserted; the SQL adapters learn them from `RETURNING`, or, where + `supportsInsertReturning()` is false (MySQL), from reading the ids before and, when rows were skipped, after the + insert. +- `SQL` declares `abstract protected function getColumnNames(string $collection): array` (the table's physical + column names, empty when the table is missing). `SQL::renameAttribute()` and the engines' `updateAttribute()` use + it to complete a rename another tenant of a shared table already ran. +- `SQL::getNullOrder(): OrderDirection` (protected) returns the direction in which the engine sorts null before + every other value (`OrderDirection::Asc` by default; the PostgreSQL adapter returns `OrderDirection::Desc`). A SQL + adapter for an engine that sorts nulls last in ascending order overrides it, or a cursor over a joined read skips + or repeats rows holding null. +- Declare `Capability::TransactionNested` only when a failed nested transaction rolls back to its savepoint and + leaves the enclosing transaction open. `Database` drops the `document_purge` events of a failed nested call only + on such adapters; without it they fire with the enclosing commit. +- `Adapter::abandonTransaction(): void` (protected, a no-op by default) is new. The outermost `withTransaction()` + calls it after every failed attempt, once the transaction counter no longer counts the attempt's transaction; an + adapter whose connection can still hold part of it (a transaction lost with the connection, or one a failed + rollback left open) ends it there. The SQL adapters roll back whatever the connection still reports. +- `Adapter::isIgnoringDuplicates(): bool` and `Database::isIgnoringDuplicates(): bool` (protected) report whether the + calling coroutine runs under `ignoreDuplicates()`. +- `Utopia\Database\PDOStatement::getQueryString(): string` returns the wrapped statement's `queryString` without + going through the magic `__get()`. +- `Adapter::withTenant($tenant, $callback)` scopes the tenant to the calling coroutine. `Database::withTenant()` uses + it and no longer calls `setTenant()`, so an adapter that overrides `setTenant()` to react to tenant changes has to + key such state by `getTenant()` instead. +- Write hooks and the tenant hook are registered by the library. `hasTenantHook()` and `hasPermissionHook()` are + removed; `getTenantHook()` and `getWriteHooks()` are internal. `removeWriteHook()` also takes an instance. +- An adapter's `getDocument()` must not change the `$collection` it is given: for `Database::METADATA` it is one + definition shared by the whole process. + +### Methods an adapter no longer has + +Methods that went with a feature exist only on the adapters that implement it, and the limit getters are gone from +every adapter. Besides the `getSupportFor*()` methods and the per-limit getters, these public 7.x methods are gone: + +| Adapter | Removed public methods | Use instead | +|---|---|---| +| Every adapter | `getConnectionId()`, `getHostname()`, `ping()`, `reconnect()` on the base class | `Feature\Connection` (`id()`, `hostname()`, `ping()`, `reconnect()`) where implemented; the `Database` forwards | +| Every adapter | `setSupportForAttributes()`, `getSupportNonUtfCharacters()`, `clearTimeouts()`, `enableAlterLocks()`, `getKeywords()`, `getInternalIndexesKeys()`, `getTenantQuery()`, `before()`, `setDebug()`, `getDebug()`, `resetDebug()` | See the sections above | +| Every adapter | `getColumnType(string $type, ...)` | `getColumnType(Attribute $attribute)` | +| SQL adapters (MariaDB, MySQL, PostgreSQL, SQLite) | `castingBefore()`, `castingAfter()`, `setUTCDatetime()`, `decodePoint()`, `decodeLinestring()`, `decodePolygon()`, `getPDOAttributes()`, `getSpatialTypeFromWKT()`, `getLikeOperator()`, `getRegexOperator()`, `getSQLConditions()` | Library casting; `encode()`/`decode()`; your own PDO attributes; the query builders | +| MariaDB | `getSpatialSQLType()` (public in 7.4) | Protected `getSpatialSqlType()` | +| MySQL | `getSpatialSQLType()` (public) | Protected `getSpatialSqlType()` | +| SQLite | `setEmulateMySQL()`, `getEmulateMySQL()`, and the MariaDB methods it inherited (`setTimeout()`, `getViolatedKey()`, ...) | A subclass with `protected bool $emulateMySQL = true` | +| MongoDB | `getConnectionId()` is now `id()`; `getSchemaAttributes()` and `getSchemaIndexes()` return `[]`; `castingBefore()`, `castingAfter()`, `setUTCDatetime()`; `listCollections()` and `getTenantFilters()` (now protected); `setSupportForAttributes()` | `id()`; no introspection; `Feature\Casting`; `Database::listCollections()`; `setSchemaless()` | +| Memory | `getConnectionId()`, `ping()`, `reconnect()`, `getHostname()`, `upsertDocuments()` | None: Memory has no connection and no upserts (`Feature\Upserts`) | +| Memory, Redis | `castingBefore()`, `castingAfter()`, `setUTCDatetime()`, `decodePoint()`, `decodeLinestring()`, `decodePolygon()` | Library casting; no spatial attributes | +| Redis | `tx()` | `transaction()` | + +### Pool extension points + +`Adapter\Pool` subclasses override its protected extension points. These signatures are stable in 8.0; mark every +override `#[\Override]` so that a later rename fails loudly instead of being skipped. Renamed against 7.x and the +8.0 pre-releases: + +| Before | 8.0 | +|---|---| +| `delegate(string $method, array $args): mixed` | `delegate(string $method, array $arguments): mixed` (public) | +| `delegateFeature(string $feature, string $method, array $args)` | `delegateFeature(string $feature, string $method, array $arguments)`; `$feature` names an optional feature | +| `borrowAndInvoke(string $method, array $args, ?string $feature = null)` | `borrowAndInvoke(string $method, array $arguments, ?string $feature = null)` | +| `invokeDelegated(Adapter $adapter, string $method, array $args, ?string $feature = null)` | `invokeDelegated(Adapter $adapter, string $method, array $arguments, ?string $feature = null)` | +| `syncBorrowedAdapter(Adapter $adapter)` | `syncBorrowed(Adapter $adapter)` | +| `releaseBorrowedAdapter(Adapter $adapter)` | `releaseBorrowed(Adapter $adapter)` | +| `getHostname(): string`, `ping(): bool` | `hostname(): string`, `ping(): bool` (`Feature\Connection` methods, which `Pool` forwards) | + +`pin()`, `withTransaction()`, `inTransaction()` and `getReadConcurrency()` are unchanged. The protected +`Pool::$pinnedAdapter` property is removed (see [Pools and profiling](#pools-and-profiling)). `Pool` implements +`Feature\Timeouts` itself and forwards every other optional feature through `delegateFeature()`. + +`syncBorrowed()` sets the database, namespace, shared-tables and tenant-per-document modes, tenant, authorization, +locks, schemaless mode and timeouts on the connection on every delegated call. It replays metadata, the profiler, +transforms and write hooks only when the connection's differ from the pool's: `resetMetadata()` and `setMetadata()` +when its `getMetadata()` is not identical, `setProfiler()` when it holds another profiler, and `resetTransforms()` +and `addTransform()` when its transforms are not the same instances under the same names in the same order. A +`Pool` subclass or a custom connection adapter must not rely on those methods being called on every delegated call. +`releaseBorrowed()` still clears a borrowed connection's profiler after each checkout. + +### Removed adapter methods + +Queries now compile through the utopia-php/query builders (`builder()`), transforms through `Hook\Transform`, and +tenant and permission conditions through the hooks in `Utopia\Database\Adapter\SQL\Hook` and +`Utopia\Database\Hook\Mongo`. The 7.x methods behind the old string-building path are removed. Nothing in 8.0 calls them, so an adapter subclass that overrides or +calls one must drop the override or the call. + +| Removed | Visibility in 7.x | Replacement | +|---|---|---| +| `SQL::getSQLConditions(array $queries, array &$binds, string $separator = 'AND', ?string $forCollection = null)` | public | The adapter's query builder (`builder()`) | +| `SQL::getSQLConditionsForCollection()` | protected | The same | +| `getSQLCondition(Query $query, array &$binds, ?string $forCollection = null)` on `SQL` (abstract), `MariaDB`, `Postgres` and `SQLite` | protected | The same | +| `SQL::getSQLOperator()` | protected | The same | +| `handleSpatialQueries()` on `MariaDB` and `Postgres` | protected | The same | +| `handleDistanceSpatialQueries()` on `MariaDB`, `MySQL` and `Postgres` | protected | The same | +| `Postgres::handleObjectQueries()` | protected | The same | +| `SQLite::getLikeCondition()` | protected | The same. SQLite's `LIKE ... ESCAPE` lives in `Utopia\Database\Builder\SQLite` | +| `getSQLPermissionsCondition()` on `SQL` and `Postgres` | protected | The permission hooks in `Utopia\Database\Adapter\SQL\Hook\Permission` (`Filter`, `Join`, `OuterJoin`) | +| `getSQLVectorDistance()` on `SQL` and `Postgres` | protected | The query builder | +| `Adapter::getTenantQuery()` (abstract) and its implementations on `SQL`, `Memory`, `Redis`, `Pool` and `Mongo` | public | `Adapter\SQL\Hook\Tenant\Filter` (SQL) and `Hook\Mongo\Tenant` (MongoDB) | +| `getInsertKeyword()` on `SQL`, `Postgres` and `SQLite`, and `getInsertSuffix()` and `getInsertPermissionsSuffix()` on `SQL` and `Postgres` | protected | `SQL::insertOrIgnore(SQLBuilder $builder): Statement`, which `Postgres` overrides to name the id as the conflict target | +| `getUpsertStatement()` on `SQL` (abstract), `MariaDB`, `Postgres` and `SQLite` | protected, public on `MariaDB` and `SQLite` | The same | +| `SQL::registerOperatorBind()` | protected | `getOperatorSql()` binds operator values itself | +| `SQL::getFulltextValue()` and `Postgres::getFulltextValue()` | protected | utopia-php/query's builders normalize search terms (`compileSearchExpression()`) | +| `Adapter::before()` and `Adapter::trigger()` | public, protected | `Hook\Transform`, registered with `Database::addHook()` | +| `SQL::getLikeOperator()`, `SQL::getRegexOperator()`, `Postgres::getLikeOperator()` and `Postgres::getRegexOperator()` | public | The query builders emit `LIKE`/`ILIKE` and `REGEXP`/`~` | +| `Adapter::getAttributeProjection()` (abstract) and its implementations on `SQL`, `Memory`, `Redis` and `Pool` (`Mongo` keeps a private one) | protected | The query builders build the projection | +| `getRandomOrder()` on `SQL` (abstract), `MariaDB`, `Postgres` and `SQLite` | protected | The query builders' `compileRandom()` emits `RAND()`/`RANDOM()` for `Query::orderRandom()` | +| `SQL::getSpatialTypeFromWKT()` | public | None. The type is the text before the first `(` of the WKT, lower-cased | +| `getSQLIndexType()` on `SQL` and `SQLite` | protected | Each adapter's `createIndex()`, which builds the whole index statement | +| `Postgres::getSQLSchema()` | protected | `getTable()`, which qualifies the table with the schema | +| `Postgres::encodeArray()` and `Postgres::decodeArray()` | protected | None. Array attributes are `JSONB` columns | +| `Memory::unregisterRelationshipField()` | protected | None | + +`escapeWildcards()` is kept, and so is the public helper `Query::isSpatialAttribute()`, even where nothing in the +library calls it any more. + +### SQL adapter subclasses that override `dialectBuilder()` + +The SQLite adapter builds its queries with `Utopia\Database\Builder\SQLite`, not `Utopia\Query\Builder\SQLite`. SQLite +has no default LIKE escape character, and this builder adds `ESCAPE '\'` so that `startsWith`, `endsWith`, +`containsString`, `containsAny`, `containsAll`, `notContains`, `notStartsWith` and `notEndsWith` match `_`, `%` and +`\` literally, as they did in 7.x. If you subclass `Utopia\Database\Adapter\SQLite` and override `dialectBuilder()`, +return `Utopia\Database\Builder\SQLite` or a subclass of it. A plain `Utopia\Query\Builder\SQLite` treats `_` and `%` +as wildcards and a backslash as a literal character. + +The protected `dialectBuilder()` returns the builder a SQL adapter builds every statement with, unscoped. The final +`builder()` scopes it with the adapter's `scope()` for `Database::from()`, and the adapter's own statements scope it +with the tenants they read. Its `from()` takes a collection id and reads the collection's table, mapping attributes to +columns and, under shared tables, keeping the statement to the tenant, so the builder must implement +`Utopia\Database\Builder\Scoping` with the `Utopia\Database\Builder\ScopesCollections` trait: +`return new MyBuilder();`. The table names come from `qualifyTable()`; `getTable()` and `getTableRaw()` are final, so +an adapter that stores its tables under other names overrides `qualifyTable()`. + +### Connections + +- **Session settings and reconnects.** `Utopia\Database\PDO` reconnects on its own when a call outside a + transaction finds the connection gone, and retries the call on the new connection. A new connection starts a new + database session, so session state set earlier with `exec()` is gone after a reconnect. Set session state that + must last through `configure()` instead: + + ```php + $pdo->configure('time_zone', "SET time_zone = '+00:00'"); + ``` + + `configure(string $setting, string $statement)` runs the statement now and again on every connection a later + reconnect opens, before the retried call runs there. A later statement for the same `$setting` replaces the + earlier one. Attributes set with `setAttribute()` after connecting are replayed the same way, before the session + statements. If a statement fails on the new connection, `reconnect()` throws and the wrapper keeps the old + connection, so the next call reconnects again instead of running on an unconfigured session. The MariaDB and + MySQL adapters keep `setTimeout()` this way, so a timeout also bounds the statement that triggered the reconnect. + Behind Swoole's `PDOProxy`, which reconnects without replaying session state, the adapter sets the timeout again + before its next statement; the statement the proxy itself retries after its reconnect runs without it. +- **Lost-connection detection.** `Connection::hasError()` decides by the driver's error first: MySQL and MariaDB + errors 1053, 2002, 2006, 2013 and 4031, SQLSTATE class `08`, and PostgreSQL `57P01` to `57P05`. Statement timeouts + (MariaDB 1969, MySQL 3024, PostgreSQL 57014) are not lost connections and are never retried. The message fallback + carries Swoole 6.2's `DetectsLostConnections` list, so a PHP without ext-swoole, or with + `swoole.enable_library=Off`, detects the same lost connections as one with it. +- **Lost transactions.** When the connection loses a transaction that `withTransaction()` calls are nested in, the + nested call and every enclosing call throw `Utopia\Database\Exception\Transaction` (`Failed to execute + transaction: the transaction was lost before it could commit`) and are not retried. Nothing written in the lost + transaction is committed. A nested call that fails transiently while its enclosing transaction holds is rolled + back to its savepoint and rethrown, and the outermost call runs again; a top-level call that failed to begin, or + whose work failed transiently, is still retried (see [Transaction retries](#errors)). A + top-level commit that finds the connection no longer holds the transaction (a reconnect the callback did not + surface) throws `Exception\Transaction` too, and the callback is not run again: statements after such a reconnect + may already have run on their own. Code that catches an expected exception (for example `Duplicate`) from a + nested call and carries on no longer receives that exception when the transaction was lost underneath it: catch + `Exception\Transaction` around the outermost call and run the whole unit again. A callback that catches the nested + call's `Exception\Transaction` and returns fails the same way instead of reporting the lost work as committed. The + outermost call ends what is left of the lost transaction on the connection before it throws, so the connection runs + the next statement. A transaction the engine rolled + back over a lock conflict is not lost: MariaDB and MySQL roll the whole transaction back, savepoints included, when + a statement loses a deadlock (1213), or a lock wait timeout (1205) with `innodb_rollback_on_timeout`. The nested + calls rethrow that `Exception\Contention` unchanged, and the outermost call runs again, as in 7.x, because nothing + of the attempt is stored. +- **Statements after a lost transaction.** When `Utopia\Database\PDO` reconnects because a statement inside a + transaction found the connection gone, it rethrows and then refuses every statement (`exec()`, `query()`, + `prepare()`, `beginTransaction()`, `commit()`) with a `PDOException` until the transaction is ended with + `rollBack()`, a `ROLLBACK` statement or `reconnect()`. A `rollBack()` that itself finds the connection gone ends the + transaction too: it rethrows and refuses nothing after it. `inTransaction()` reports the transaction until then, and + each refusal's `getPrevious()` is the lost connection error. Code that catches the connection error inside + `withTransaction()` and carries on no longer writes on the new connection in autocommit: the transaction is rolled + back and, at the top level, runs again. + +### MongoDB: rebuild key and unique indexes + +8.0 changes the partial filter of the key and unique indexes the MongoDB adapter creates, and fixes two defects that +7.x and the 8.0 pre-releases share: + +- Unique indexes: `createIndex()` gave every attribute the filter `{attr: {$exists: true, $type: 'string'}}`, + whatever its type, so a unique index on an integer, big integer, float, boolean or datetime attribute covered no + document and accepted duplicates. `createCollection()` required `int` for integers, `long` for big integers and + `double` for floats, which left out integers past 32 bits, big integers inside 32 bits and floats stored as + integers. 8.0 requires every type a value of the attribute can be stored as, and for an attribute without a + declared type (an internal attribute such as `$createdAt`, or any attribute of a schemaless collection) every BSON + type the driver writes but null, arrays, binary data and timestamps included, where 7.4.1 covers strings, numbers, + booleans, dates and objects only. +- Key indexes: both paths added a `$type` clause, and MongoDB uses a partial index only for queries that imply its + filter, which a filter on a value never does for `$type`. No query used these indexes, whatever the attribute + type. 8.0 gives a key index `{first attribute: {$exists: true}}` only, which a filter on a non-null value of that + attribute implies, so a single or compound key index serves any filter on its first attribute. + +Existing indexes keep the filter they were created with. The library does not rebuild them; run this step once per +database after upgrading. It covers both changes, so one rebuild is enough. + +1. For every collection, read its `indexes` and `attributes` from the database's metadata collection + (`Database::getCollection()`), and pick: + - every index of type `key`, whatever its attributes' types; + - every index of type `unique` with at least one attribute of type `integer`, `biginteger` (stored as `bigint`), + `float`, `double`, `boolean` or `datetime`, or one without a declared type (an internal attribute, or any + attribute in a schemaless database). +2. For a `unique` index, look for duplicates first, because the rebuilt index enforces uniqueness and its creation + fails while duplicates exist: `createIndex()` throws `Exception\Unique`, and only a raw driver call sees error + `11000`. Group the documents that + hold a value for every attribute of the index by those attributes (and by `_tenant` under shared tables), and list + the groups with more than one document, for example: + + db.getCollection('_').aggregate([ + { $match: { : { $exists: true, $ne: null } } }, + { $group: { _id: { tenant: '$_tenant', value: '$' }, count: { $sum: 1 }, ids: { $push: '$_uid' } } }, + { $match: { count: { $gt: 1 } } }, + ]) + + The stored field name is the attribute key with each `.` written as `__dot__`. Resolve every duplicate (change or + remove documents) before step 3. +3. Drop and recreate the index with the same key, type, attributes, lengths and orders: + `Database::deleteIndex($collection, $key)` and then `Database::createIndex($collection, $index)`. Between the two + calls the collection has no such index: a unique constraint is not enforced and queries do not use it, so run the + step when the collection takes no writes. A key index needs no duplicate check. + +Unique indexes whose attributes are all declared strings (`string`, `varchar`, `text`, `mediumtext`, `longtext`, +`id`, `uuid7`), fulltext and TTL indexes, and the internal `_uid`, `_createdAt`, `_updatedAt` and `_permissions` indexes +need no rebuild. You can tell a rebuilt index by its `partialFilterExpression` (`db..getIndexes()`): a +key index names only its first field, with no `$type`, and a unique index on an integer attribute has +`$type: ['int', 'long']`. + +### MongoDB: collections + +`Adapter\Mongo::createCollection()` of a collection that already exists throws `Exception\Duplicate`, as the SQL +adapters do for an existing table. Under shared tables, and for the metadata collection, it first creates the +collection's indexes on the existing collection, so a collection another tenant or process created without them gets +them, and then throws `Duplicate`; `Database::createCollection()` adopts such a collection and stores its definition. +Outside shared tables `Database::createCollection()` reports the existing collection as `Duplicate` and leaves it as +it is. A collection whose indexes conflict with ones it already has under the same name (code 85) throws +`Exception\Index` instead of `Duplicate`. Under shared tables every tenant's collection of an id shares one set of +indexes, so a tenant that declares an index under a name another tenant uses with other fields fails with the +server's index-spec conflict (code 86). A collection the create made but could not give its indexes is dropped, +except inside a transaction, whose abort discards it, and under shared tables, where another tenant may already use +it; a failed create never drops a shared collection. + +`Adapter\Mongo::exists()` reports a database only when the server lists it; it returned `true` for every name. +`collectionExists()` looks in the database it is given. + +### SQLite + +- **Document ids compare case-insensitively.** `getDocument()`, `equal('$id', ...)`, `notEqual('$id', ...)` and joins + on `$id` compare `_uid` with `COLLATE NOCASE`, matching the unique index and MariaDB: `getDocument('Doc')` finds + `doc`. Code that relied on SQLite telling `doc` and `Doc` apart must not: the unique index never allowed both. +- **Shared-table files created by 7.x or the 8.0 pre-releases** keep unique indexes that declare + `_tenant COLLATE NOCASE`. + Lookups still use them once `ANALYZE` has run (`analyzeCollection()`); without statistics, joins walk the tenant's + rows once per joined alias. Recreate them once per collection (uniqueness is unchanged, `_tenant` is an integer; + `{tenant}` is the tenant segment of the existing index names): + + ```sql + DROP INDEX `{namespace}_{tenant}_{collection}__index1`; + CREATE UNIQUE INDEX `{namespace}_{tenant}_{collection}__index1` ON `{namespace}_{collection}` (`_tenant`, `_uid` COLLATE NOCASE); + DROP INDEX `{namespace}_{tenant}_{collection}_perms__index_1`; + CREATE UNIQUE INDEX `{namespace}_{tenant}_{collection}_perms__index_1` ON `{namespace}_{collection}_perms` (`_tenant`, `_document` COLLATE NOCASE, `_type` COLLATE NOCASE, `_permission` COLLATE NOCASE); + ``` + + Plain-table files need nothing. +- **Regex.** `Query::regex()` works on SQLite through the adapter's `REGEXP` function, which `Utopia\Database\PDO` + and `Pdo\Sqlite` connections register; patterns are PCRE (`preg_match`, case-sensitive, `u` flag). +- **`getSchemaIndexes()` returns index ids.** Each `Schema\Index`'s `name` is the index id (`email`, + `_index1`), not the SQLite object name (`{namespace}_{tenant}_{collection}_email`) or the FTS5 table + (`…_{hash}_fts`). To read the physical names, query `sqlite_master`. Under shared tables the list includes indexes + other tenants created on the shared table, since they cover every tenant's rows. + +### Shared tables + +All tenants' collections with the same id share one physical table. Several tenants declaring the same collection id +with the same attributes is the normal case. + +- **Conflicting attributes and indexes are refused.** `createAttribute()` and `createAttributes()` over a column + another tenant created reuse it when the column type matches, and throw `Utopia\Database\Exception\Duplicate` + (`Attribute exists in the shared table with another type`) when it does not (on PostgreSQL its subclass + `Exception\Mismatch`); a refused batch creates none of its columns. Before, the column was dropped with the other + tenant's values. `createIndex()` over an index another tenant uses throws `Duplicate` (`Index exists in the shared + table with another definition`) when the definition differs. Outside shared tables an orphaned column or index + (left by a partial failure) is still dropped and recreated. While migrating shared tables (`setMigrating(true)`) + neither check runs, as before. +- **Renames.** A rename is physical: the first tenant to rename an attribute of a shared collection id renames the + column for every tenant, and tenants whose metadata still has the old key read it as null until their own rename + runs. Run the rename for every tenant (as project-by-project migrations do). A later tenant's rename + (`renameAttribute()`, or `updateAttribute()` with a new key) completes without DDL when the old column is gone and + the new one exists; otherwise the engine decides (the old column missing is `NotFound`, a new column beside the old + one is `Duplicate`). + +### Query comments + +`Database::setMetadata()` values are written as `/* key: value */` comments at the start of the SQL statements, as +in 7.x, ahead of the registered `Transform` hooks. + +- Comments now precede every statement the SQL adapters prepare, including raw queries, `ping()`, + `id()`, schema introspection and SQLite's transaction statements. 7.x annotated only statements that + went through an event transformation. Statements PDO issues itself (begin, commit, rollback, `lastInsertId()`), + savepoints and the timeout session statements carry none. +- A `Transform` receives the SQL with the comment block first. Do not anchor patterns on the statement keyword at + the start of the string. +- `resetMetadata()` takes effect at once. In 7.x the last metadata transformation stayed registered after a reset. +- Scalars and `Stringable` values are rendered as text (`true` as `1`, `false` as an empty string, as in 7.x). + Arrays, `null` and other objects are rendered as JSON. In 7.x an array printed `Array` with a warning and a + non-`Stringable` object threw. +- Keys and values are rewritten where needed: `/*` becomes `/ *`, `*/` becomes `* /`, control characters and + U+2028/U+2029 become spaces, and invalid UTF-8 bytes are replaced with mbstring's substitute character (`?` by + default). + +## Mirror + +- **`onError()`.** `onError(callable $callback): static` callbacks receive one `Mirror\Failure`, with the `method` + that failed on the destination, its `event` (`?Event`: for example `upsertDocument` reports `DocumentUpsert`, + `createIndexes` `IndexesCreate`, `renameAttribute` `AttributeRename` and `update` `DatabaseUpdate`) and the + `error`. 7.x passed `(string $action, \Throwable $error)`. + + ```php + // 7.x + $mirror->onError(fn (string $action, \Throwable $error) => $logger->error($action, ['error' => $error])); + + // 8.0 + $mirror->onError(fn (Failure $failure) => $logger->error($failure->method, ['error' => $failure->error])); + ``` + +- **Write filters** are `Utopia\Database\Mirror\Filter` (was `Mirroring\Filter`); its `init()` is `initialize()`. +- **Hooks.** `$mirror->addHook(new Relationships(...))` keeps the given hook and attaches a copy to the source and + one to the destination, so its configuration (`prepare`, a subclass) reaches both. `removeHook()` also removes a + hook from the source and the destination. `addLifecycleHook()` is protected. +- **Query cache.** Install the query cache through the mirror: `$mirror->setQueryCache($queryCache)` installs it on + the source and the destination as well, so writes through the mirror invalidate the cache its reads use. An + `Invalidator` added with `$mirror->addHook()` is installed on the mirror and the source. +- **Setters reach the wrapped databases.** Besides `setDatabase()`, `setNamespace()`, `setSharedTables()`, + `setTenant()`, `setMaxQueryValues()`, `setCache()`, `setAuthorization()`, validation and the document-type + setters, a mirror now forwards `setQueryCache()`, `setCacheName()`, `setGlobalCollections()`, + `resetGlobalCollections()`, `setTenantPerDocument()`, `setCacheWriterTimeout()`, `setTimeout()`, `clearTimeout()`, + `setMetadata()`, `resetMetadata()`, `setFiltering()`, `withFiltering()`, `skipFilters()`, `setLocks()`, + `setProfiling()`, `setMigrating()` and `setFilters()` to its source and destination. In 7.x these changed the + mirror alone. +- **Timeouts on the destination.** A destination that cannot apply a timeout (MariaDB applies it on the connection) + is reported through `onError()` with the method `setTimeout` or `clearTimeout`; the call itself succeeds when the + source applied it. `setLocks()` reports a destination failure the same way, with the method `setLocks`. +- **`create()`.** A destination that cannot create the database makes `create()` throw, after the source has + created it, so a mirror never reports a database its destination lacks. Every other call forwarded to the + destination reports a destination failure through `onError()` and returns the source's result. +- **Profiling.** `$mirror->setProfiling(true)` enables profiling on the source and the destination, and + `$mirror->getProfiler()` returns the source's profiler, which records the mirror's queries, because both use the + source's adapter. The destination's queries are recorded by `$mirror->getDestination()->getProfiler()`. +- **Scoped setters.** `withTenant()`, `withPreserveDates()`, `withPreserveSequence()`, `withValidation()`, + `skipValidation()`, `withFiltering()` and `skipFilters()` called on a mirror open on the mirror, its source and its destination for the duration of the + callback. `skipRelationships()`, `skipRelationshipsExistCheck()` and `withRequestTimestamp()` open on the mirror + and its source only; the destination applies the source's resulting documents with preserved dates. + `withRequestTimestamp()` now runs its callback once; 7.x ran it once per database when a destination was set. +- **Replication.** Inside a coroutine (a Swoole server), `createDocuments()`, `updateDocuments()`, the upserts, + `deleteDocument()` and `deleteDocuments()` return once the source write is done and replicate in a coroutine of + their own; a destination failure reaches `onError()` later. Every other call that reaches the destination + (`createDocument()`, `updateDocument()`, `increaseDocumentAttribute()`, `decreaseDocumentAttribute()`, the schema + changes, `create()`, `delete()`, `exists()`, `setTimeout()` and `clearTimeout()`) applies there before it returns, + after the replications queued before it. Outside a coroutine all replication finishes before the call returns. In + 7.x every write replicated before returning. + - Each replication runs under the state the caller had at the time of the call: authorization status and roles, + tenant, relationship and silence state and the toggles (see [Coroutines](#coroutines)), without the request + timestamp. A write made inside `skip()` replicates under it even after the caller has left the scope. + - A mirror applies its changes to the destination one at a time, in the order they were made through it, whichever + documents, related documents or schema they reach: a write never overtakes an earlier one, a schema change waits + for every queued replication, and two replications never share the destination's connection, so the destination + needs no `Adapter\Pool`. A failed replication is reported to `onError()` and does not hold back later ones. The + order holds per mirror; writes through two mirrors over the same destination are not ordered with each other. + - A write through the mirror made while a replication applies, from `onError()` or a write filter, is part of that + replication and applies at once. + - The write filters' document hooks (`beforeCreateDocument()`, ...) run when the replication applies, in its + coroutine. + - `$mirror->awaitReplications(?int $timeout = null)` returns once every replication queued so far has reached + the destination or has been reported to `onError()`, or once `$timeout` milliseconds have passed, without an + error. `awaitReplications(0)` returns at once, and a negative timeout throws. Call it before a worker stops, or queued replications are lost. `delete()` waits for them before it + deletes the destination database. +- **Authorization.** `new Mirror($source, $destination)` leaves the source's and the destination's `Authorization` in + place, and the mirror uses the source's, so roles and `skip()` scopes set on it apply to reads and writes through + the mirror. In 7.x the mirror started with an `Authorization` of its own, which it also gave the source's adapter. + `$mirror->setAuthorization()` sets one on the mirror, its source and its destination, as before. +- **Write filters.** A `null` return from `beforeCreateCollection()`, `beforeUpdateCollection()`, + `beforeCreateAttribute()`, `beforeUpdateAttribute()` or `beforeCreateIndex()` skips that change on the destination, + and a collection whose creation was skipped is not replicated. An exception from a filter hook is reported to + `onError()` under the write's method and skips that replication; the source change stands. +- **Decorators.** Decorator hooks added through a mirror stay on the mirror: they decorate what its reads and writes + return (including the documents bulk writes hand `onNext`), and the destination receives undecorated documents. + `createDocument()` returns the document written to the source, as `updateDocument()` does, instead of the + destination's copy. +- **Upserts.** `upsertDocument()` and an increasing `upsertDocuments()` through a mirror now run on the source and + are replicated to the destination, like a plain `upsertDocuments()`; in 7.x they were never replicated. Hooks + registered through the mirror receive `document_purge` for each upserted document, `document_upsert` for + `upsertDocument()` and `documents_upsert` once per `upsertDocuments()` call. A failed replication is reported to + `onError()` with the method `upsertDocument` or `upsertDocuments`. + +## Validators and helpers + +The validators that depended on adapter limits and capabilities take the `Adapter\Profile` of the `Database` +(`$database->profile()`) instead of positional limits and booleans. + +| 7.x | 8.0 | +|---|---| +| `new Validator\Attribute(array $attributes, array $schemaAttributes = [], int $maxAttributes = 0, int $maxWidth = 0, ... 14 more limits and booleans)` | `new Validator\AttributeDefinition(array $attributes, Adapter\Profile $profile, array $schemaAttributes = [], ?\Closure $attributeCount = null, ?\Closure $attributeWidth = null, ?\Closure $filter = null)` | +| `new Validator\Index(array $attributes, array $indexes, int $maxLength, array $reservedKeys = [], ... 15 booleans)` | `new Validator\IndexDefinition(array $attributes, array $indexes, Adapter\Profile $profile)` | +| `new Queries\Documents(array $attributes, array $indexes, string $idAttributeType, int $maxValuesCount = 5000, int $maxUIDLength = 36, \DateTime $minAllowedDate, \DateTime $maxAllowedDate, bool $supportForAttributes = true, bool $supportUnsignedBigInt = true)` | `new Queries\Documents(array $attributes, array $indexes, Adapter\Profile $profile, int $maxValuesCount = 5000)` | +| `new Queries\Document(array $attributes, bool $supportForAttributes = true)` | `new Queries\Document(array $attributes, Adapter\Profile $profile, int $maxValuesCount = 5000)` | +| `new Structure(Document $collection, string $idAttributeType, \DateTime $minAllowedDate, \DateTime $maxAllowedDate, bool $supportForAttributes = true, bool $supportUnsignedBigInt = true, ?Document $currentDocument = null)` | `new Structure(Document $collection, Adapter\Profile $profile, ?Document $currentDocument = null, array $storedAttributes = [])` | +| `new Query\Order(array $attributes = [], bool $supportForAttributes = true)` | `new Query\Order(array $attributes = [], bool $supportForAttributes = true, bool $supportForOrderRandom = true)` | +| `Validator\Queries`, `Validator\IndexedQueries` | `Validator\Queries\Base`, `Validator\Queries\Indexed` | +| `Validator\ObjectValidator` | `Validator\ObjectValue` | + +```php +// 7.x +$validator = new Index($attributes, $indexes, $adapter->getMaxIndexLength(), [], $adapter->getSupportForIndexArray(), ...); + +// 8.0 +$validator = new IndexDefinition($attributes, $indexes, $database->profile()); +``` + +- `Queries\Documents` and `Queries\Document` accept joins, aggregations and `$tenant` exactly as the profile allows: + joins with `Capability::Joins`, aggregate functions, `groupBy`, `having` and `distinct` with + `Capability::Aggregations`, and `select('$tenant')` under shared tables. +- `Utopia\Database\Validator\Query\Select`, `Aggregate` and `GroupBy` accept `$tenant` only when constructed with + `sharedTables: true` (third constructor argument). A validator built directly rejects `select('$tenant')` without + it; `Database::find()` already rejected it without shared tables in 7.x. +- `Query\Order` refuses `orderRandom()` when constructed with `supportForOrderRandom: false`. +- The new `Utopia\Database\Validator\Query\Join` takes the main collection's attributes (`new Join($attributes)`) to + check the columns of a join condition. `new Join()` accepts any column of the main collection. + `Validator\Query\Joined\Collection` and `Validator\Query\Joined\Attributes` check the joined collections. +- `Query\Filter` defaults `supportUnsignedBigInt` to `true`, as in 7.x. +- `Validator\Query\Cursor` accepts a `Document` or a document id, and refuses an array. +- The protected `Database::getDocumentsValidator(Document $collection, array $joinedCollections = [])` is new in 8.0. +- Changed validator signatures: `AttributeDefinition`'s `check*()` methods take an `Attribute`, + `IndexDefinition`'s take an `Index` (`checkTTLIndexes()` is `checkTtlIndexes()`), `getRequiredFilters()` and + `validateDefaultTypes()` take a `ColumnType`, `Structure::addFormat()`, `getFormat()` and `hasFormat()` (and those + of `PartialStructure`) take a `ColumnType`, `Query\Filter::isValidAttributeAndValues()` takes a `Method` case, and + `Validator\Spatial::isWKTString()` is `isWktString()`. `Validator\Operator` takes a trailing + `bool $supportUnsignedBigInt = true`. Every `isValid()` takes `mixed $value`: a call that names the argument + after the 7.x parameter (`$permissions` on `Permissions`, `$roles` on `Roles`, `$document` on `Structure` and + `PartialStructure`, `$input` on `Authorization`) passes `value:` instead. +- `Validator\Permissions` and `Permission::aggregate()` take their allowed permission types as `PermissionType` + cases; a list of strings throws a `TypeError`. `Validator\Authorization\Input` takes a `PermissionType` case, or + the string of an action of your own such as `execute`. +- `Validator\Authorization`'s status is no longer a `protected bool $status` property; subclasses read and change it + through `getStatus()`, `setStatus()` and `skip()`. `skip()` is scoped to the calling coroutine and the coroutines + it starts; `setStatus()`, `enable()`, `disable()` and `reset()` change the shared status unless called inside + such a scope (see [Coroutines](#coroutines)). `setDefaultStatus()` is a constructor argument, + `new Authorization(bool $defaultStatus = true)`. `restore()` is internal. +- `Validator\Structure` takes `array $storedAttributes = []`: the attributes whose values are the stored ones, which + it does not validate again. `Database::updateDocument()` passes it. +- `Database::convertQueries()` takes an optional `array $joinedCollections` (join alias => collection). With it, + filters on `alias.attribute`, the filters of join ON lists and `having()` conditions in the list are converted + too; aggregates and selects in the list are left as they are. Without it the method converts as before. + +## Removed unused public methods + +Nothing in the library, Appwrite, Appwrite Cloud or utopia-php/migration calls these 7.x methods. + +| Removed | Replacement | +|---|---| +| `Adapter\SQL::setFloatPrecision(int $precision)` | Floats are bound with 17 digits. A subclass can set the protected `$floatPrecision` property | +| `Adapter\SQLite::setEmulateMySQL()`, `getEmulateMySQL()` | A subclass sets the protected `$emulateMySQL` property to `true` | +| `Database::getInstanceFilters()` | The codecs given to the constructor, or `getFilters()` | +| `Mirror::getWriteFilters()` | The `$filters` given to the constructor. A subclass reads the protected `$writeFilters` property | +| `Validator\Structure::getFormats()` | `Structure::hasFormat($name, $type)` and `Structure::getFormat($name, $type)` | + +## Changes since the 8.0 pre-releases + +Appwrite, Appwrite Cloud and utopia-php/migration built against the unreleased `feat-query-lib` branch. These names +from those builds changed before 8.0.0; none of them exists in 7.x. + +| Pre-release | 8.0.0 | +|---|---| +| `new Collection(...)`, `new Attribute(...)`, `new Index(...)`, `new Relationship(...)`, the 18 `Attribute\*` subclasses | `Collection::create()` and the factories; the constructors are private | +| `Attribute::getKey()`, `getType()`, `isArray()`, `getFormat()` and the other getters, `$attribute->type` through magic `__get()` | Public readonly properties (`$attribute->type`) | +| `Attribute::persistedType()`, `normalizeType()`, `tryNormalizeType()` | `Attribute::storedType()`, `Attribute::typeFromStored()` | +| `Attribute::isSpatialType($type)` and the other static `is*Type()`, `getNumericBounds()`, `setFilters()` | `isSpatial()`, `isNumeric()`, `isInteger()`, `bounds()`, `withFilters()` on the value object | +| `Attribute::linestring()`, `Index::fullText()`, `Index::index()` | `Attribute::lineString()`, `Index::fulltext()`, `Index::key()` | +| `Attribute::availableTypes(objects:, spatial:, vectors:)` | `Attribute::availableTypes(Adapter\Profile $profile)` | +| `Index::getIndexedAttributes()`, `setLengths()`, `setOrders()`, orders as `Utopia\Query\Schema\Order` | `$index->attributes`, `withLengths()`, `withOrders()`, orders as `OrderDirection` | +| `Relationship(collection: ...)`, `getSourceCollection()`, `RelationType`, `RelationSide`, `ForeignKeyAction $onDelete` | `createRelationship($collection, ...)`, `RelationshipType`, `RelationshipSide`, `RelationshipDeleteAction` | +| `Collection::getDeclaredAttributes()`, `getIndexes()`, `getName()`, `getDeclaredPermissions()`, `hasDocumentSecurity()`, `$metadata`, `isEmpty()` on a missing collection | `attributes()`, `indexes()`, `name()`, `declaredPermissions()`, `documentSecurity()`, `Collection::create(metadata:)`, `findCollection()` | +| `createRelationship(Relationship $relationship)` | `createRelationship(string $collection, Relationship $relationship)` | +| `updateAttribute(..., ColumnType\|string\|null $type, ...)`, `updateRelationship(..., ?ForeignKeyAction $onDelete)`, `updateCollection(string $id, ...)` | `AttributeUpdate`, `RelationshipUpdate`, `CollectionUpdate` | +| `Database::ATTRIBUTE_FILTER_COLUMN_TYPES`, `Database::INTERNAL_ATTRIBUTES`, static `internalAttributes()`, `getInternalAttributes()`, `internalAttributeDocuments()` | The factories add their filters; `$database->internalAttributes()` | +| `Validator\Attribute`, `Validator\Index` with positional booleans, `Queries\Bounds` | `AttributeDefinition`, `IndexDefinition` with an `Adapter\Profile`; `$profile->limits` | +| The six mandatory `Feature\{Attributes,Collections,Databases,Documents,Indexes,Transactions}` interfaces | The abstract methods of `Adapter` | +| `Feature\SchemaAttributes`, `Feature\SchemaIndexes`, `Feature\ColumnTypes` | `Capability::SchemaIntrospection` and the abstract `getSchemaAttributes()`, `getSchemaIndexes()`, `getColumnType()` | +| `Feature\InternalCasting` (`castingBefore()`, `castingAfter()`, `castingAfterDocuments()`), `Feature\UTCCasting` (`setUTCDatetime()`) | `Feature\Casting` (`castBefore()`, `castAfter()` over a page, `castDatetime()`) | +| `Feature\ConnectionId` (`getConnectionId()`), `Capability::Hostname`, `Capability::Reconnection` | `Feature\Connection` (`id()`, `hostname()`, `ping()`, `reconnect()`) | +| `Feature\QueryBuilder::getBuilder()`, `getSchema()`, `createSchemaBuilder()` | `builder()`, `schema()` | +| `Feature\Spatial::decodePoint()`, `decodeLinestring()`, `decodePolygon()`, `Database::encodeSpatialData()` | `Feature\Spatial::encode()`, `decode()` | +| `Feature\Upserts::upsertDocuments(Document $collection, string $attribute, array $changes)`, `Change::setOld()`, `setNew()`, `getOld()`, `getNew()` | `upsertDocument()`, `upsertDocuments(Document $collection, array $changes, ?string $increase = null)`, `$change->old`, `$change->new` | +| `Capability::Casting` | `Feature\Casting`: the library casts unless the adapter implements it | +| `Capability::AtomicTransactions`, `BatchCreateAttributes`, `BatchOperations`, `BoundaryInclusive`, `CacheSkipOnFailure`, `JSONOverlaps`, `MultiDimensionDistance`, `NumericCasting`, `OptionalSpatial`, `PCRE`, `POSIX`, `QueryContains`, `Regex`, `StatisticalAggregates`, `BitwiseAggregates` | Removed. SQLite refuses the statistical and bitwise aggregates with `Exception\Query` | +| `Capability::Index`, `UniqueIndex`, `Fulltext`, `FulltextWildcard`, `MultipleFulltextIndexes`, `TrigramIndex`, `TTLIndexes`, `ObjectIndexes`, `CastIndexArray`, `IdenticalIndexes`, `SpatialIndexNull`, `SpatialIndexOrder`, `NestedTransactions` | `IndexKey`, `IndexUnique`, `IndexFulltext`, `IndexFulltextWildcard`, `IndexFulltextMultiple`, `IndexTrigram`, `IndexTtl`, `IndexObject`, `IndexArrayCast`, `IndexIdentical`, `IndexSpatialNull`, `IndexSpatialOrder`, `TransactionNested` | +| `Adapter::getMaxUIDLength()` and the other limit getters, `Database::getMaxUIDLength()` | `limits()`; `Database::getMaxUidLength()` | +| `SQL::getPDO()` (public, deprecated) | `getDriver()` | +| `Database::execute()` | `query()` for reads (a list of `Document`), `mutate()` for writes (the affected row count) | +| `Database::enableProfiling()`, `disableProfiling()`, `Profiler\QueryProfiler`, `Profiler\QueryLog` | `setProfiling(bool)`, `Utopia\Database\Profiler`, `Profiler\Log` | +| `Database::setTypeRegistry()`, `Type\Custom`, `Type\TypeRegistry`, an associative constructor `$filters` | `setFilters()`, `Filter\Codec`, `Filter\Registry`, a list of `Filter\Codec` | +| `Cache\QueryCache` with `$cacheName` and `writerTimeout` arguments | `Cache\Query`, which uses those of the `Database` calling it | +| `Authorization::withStatus()` | `skip()`, or `setStatus()` inside any `with*()` scope | +| `Hook\Lifecycle::handle(Event $event, mixed $data)` | `handle(Event\Domain $event)` | +| `Event\Documents\Created`, `Updated`, `Deleted` | `Event\Document\BatchCreated`, `BatchUpdated`, `BatchDeleted` | +| `Event\Domain::$occurredAt` | Removed | +| `Cache\Invalidator` as a `Hook\Lifecycle` | A hook of its own kind; `addHook()` still takes it | +| `Hook\Write` extending `Utopia\Query\Hook\Write`, its row-level `after*(string $table, ...)` methods, the closure-bag write context, `Document::SKIP_PERMISSIONS_UPDATE` | `decorateRow(array $row, Hook\RowMetadata $metadata)`, the document-level `afterDocument*()` methods and `Hook\WriteContext::skipPermissions()` | +| `new Relationships($database)` | `new Relationships()`, attached by `addHook()` | +| `Adapter::hasTenantHook()`, `hasPermissionHook()` | Removed | +| `Hook\PermissionFilter`, `PermissionJoinFilter`, `PermissionAllowNullUid`, `OuterJoinPermissionFilter` | `Adapter\SQL\Hook\Permission\Filter`, `Join`, `AllowNullUid`, `OuterJoin` | +| `Hook\TenantFilter`, `RawTenantFilter`, `OuterJoinTenantFilter`, `RawOuterJoinTenantFilter` | `Adapter\SQL\Hook\Tenant\Filter`, `Raw`, `OuterJoin`, `RawOuterJoin` | +| `Hook\JoinChain`, `OuterJoinChainFilter`, `AllowNullColumn` | `Adapter\SQL\Hook\Join\Chain`, `Join\OuterChain`, `Column\AllowNull` | +| `Hook\Read`, `Hook\Mongo\PermissionFilter`, `Hook\Mongo\TenantFilter` | `Hook\Mongo\Read`, `Hook\Mongo\Permission`, `Hook\Mongo\Tenant` | +| `Traits\`, `Builder\PostgreSQL`, `State\Scope` | `Trait\`, `Builder\Postgres`, `State\Frame` | +| `Validator\Query\JoinedCollection`, `JoinedAttributes` | `Validator\Query\Joined\Collection`, `Joined\Attributes` | +| A plain `class` extending `Hook\PermissionFilter`; `Hook\TenantFilter`, `Hook\Mongo\PermissionFilter` and `Hook\Mongo\TenantFilter` as base classes | `Adapter\SQL\Hook\Permission\Filter` is `readonly`, so a subclass, such as one an adapter's `newPermissionHook()` or `newJoinPermissionHook()` override returns, is declared `readonly class`; `Adapter\SQL\Hook\Tenant\Filter`, `Hook\Mongo\Permission` and `Hook\Mongo\Tenant` are `final readonly` | +| The protected SQL adapter methods `compileAdapterFilter()`, `getOperatorBuilderExpression()`, `getOperatorUpsertExpression()` and `getVectorOrderRaw()` returning `array{expression: string, bindings: list}`; `compileAdapterFilter()`'s `$joins` as `list` | They return `Adapter\SQL\Expression` (`$sql`, `$bindings`), nullable where the array was; `$joins` is a `list` (`$table`, `$alias`) | +| `Cache\Region` with writable `$ttl` and `$enabled`; `Profiler\QueryLog` as a base class | `Cache\Region` and `Profiler\Log` are `final readonly`: pass a new `Region` to `Cache\Query::setRegion()` | +| `Storage::PERMS_SUFFIX`, `PERM_DOCUMENT`, `PERM_TYPE`, `PERM_PERMISSION` | `Storage::PERMISSIONS_TABLE_SUFFIX`, `PERMISSIONS_DOCUMENT`, `PERMISSIONS_TYPE`, `PERMISSIONS_PERMISSION` | +| `Query::join($collection, $left, $right, $operator, $alias)` and the other column-form joins, `getJoinAlias()`, `Storage::joinAlias()` | `Query::join($collection, $alias, [Query::on(...)])`, `getAlias()`; every join names its alias | +| `Query::groupForDatabase()` | `Query::groupByType()`, which returns a `ParsedQuery` | +| `Document::INTERNAL_ID` | `Document::SEQUENCE` | +| `Hook\Permissions::UNCHANGED`, `WriteContext::skipPermissions()` without arguments | `WriteContext::skipPermissions(Document $document)`, fed by `Adapter::updateDocuments(..., $skipPermissions)` | +| `Snapshot::$skipDuplicates`, `skippingDuplicates()` | `Snapshot::$ignoreDuplicates`, `isIgnoringDuplicates()` | +| `Mirror::onError(callable(string $action, \Throwable $error))`, `awaitReplications()` without a timeout | `onError(callable(Mirror\Failure $failure))`, `awaitReplications(?int $timeout = null)` | +| `Adapter::setDebug()`, `getDebug()`, `resetDebug()` | `setMetadata()`, `getMetadata()`, `resetMetadata()` | +| `Exception::__construct(string $message, ...)` with a string code cast to 0 | Every argument optional; a string code kept in `$state` | + +## Rules for features new in 8.0 + +These features do not exist in 7.x. Their rules are listed here because they differ from what a reader of the 7.x +API might expect. [CHANGELOG.md](CHANGELOG.md) describes the features themselves. + +### Joins + +Every join names its alias and takes its conditions as a list: `Query::join(string $collection, string $alias, array +$on)`, `leftJoin()`, `rightJoin()` and `fullOuterJoin()` take `Query::on(string $left, string $right, string $operator += '=')` conditions and filters, and `crossJoin(string $collection, string $alias)` and `naturalJoin()` take none. In +`on()` the left column belongs to the main collection or to a join declared before it, and the right one to the +joined collection. An ON +list member that is not `on()` or a filter (`limit()`, `select()`, an order, ...) throws +`Utopia\Query\Exception\ValidationException` when the query is built. + +```php +$database->find('reviews', [ + Query::join('movies', 'm', [Query::on('movie', '$id')]), + Query::select(['body', 'm.name']), +]); +``` + +- A joined collection is read exactly as a direct `find()` on it would be. If the caller holds the collection-level + permission, every row of the joined collection is visible. If not, and the collection has document security, only + the rows the caller holds document-level read on are visible. If neither applies, the query throws + `Utopia\Database\Exception\Authorization`. Adding a join never hides main-collection rows that are readable + through the collection grant, in `find()`, `count()`, `sum()` and `getDocument()`. +- A query can declare at most 8 joins (`Validator\Query\Join::MAX_PER_QUERY`). More is rejected with + `Utopia\Database\Exception\Query` (`Too many joins: at most 8 are allowed`) by `find()`, `count()`, `sum()` and + `getDocument()`, also inside `skipValidation()`. +- Join aliases must be identifiers, unique within the query regardless of case, and different from + `Query::DEFAULT_ALIAS` and from the key of a relationship attribute of the main collection; anything else throws + `Utopia\Database\Exception\Query`. An alias equal to a relationship key is refused with + `Join alias "" is the key of the relationship attribute "": give the join another alias`, so + `alias.attribute` and `alias.*` never mean both a joined column and a related document's attribute. Relationship + keys are matched by exact name. +- A join's ON list (`Query::join($collection, $alias, [...])`) holds `Query::on()` conditions and the plain filters + `equal`, `notEqual`, `greaterThan`, `greaterThanEqual`, `lessThan`, `lessThanEqual`, `between`, `notBetween`, + `isNull`, `isNotNull`, `contains`, `containsAny`, `notContains`, `startsWith`, `notStartsWith`, `endsWith` and + `notEndsWith`, alone or inside `and()`/`or()`. Anything else in an ON list (`limit`, `offset`, cursors, orders, + selects, aggregates, joins, `containsAll`, `search`, `regex`, `exists`, vector and spatial queries) throws + `Utopia\Database\Exception\Query` (`Unsupported join ON condition: `) in `find()`, `count()`, `sum()` and + `getDocument()`. +- A filter on a joined column (`alias.attribute`) is checked against the joined collection's attribute exactly as a + filter on the main collection is checked against its own: type, size, array-ness and the `contains` rules. A vector + query cannot target a joined attribute (`Vector queries cannot be used on a joined attribute: `). +- A filter on a joined column, in the query, in a join's ON list or in `having()`, is also converted as a filter on + the main collection is: array `contains*` filters match elements, and datetimes (with `alias.$createdAt` and + `alias.$updatedAt`) are compared in UTC. +- A select of joined attributes returns what it names. The main collection's unselected attributes are left out, as + for any select; only a select of a related document's attributes (`relationship.attribute`) returns the others as + well, as in 7.x. +- A join without a select, or with `select(['*'])`, returns under each join alias the joined collection's `$id` and + attributes as `alias.$id` and `alias.attribute`, plus each joined internal attribute the read orders by + (`alias.$sequence`, `alias.$createdAt`, `alias.$updatedAt`), so its rows can be passed back as a cursor. +- A select may name `alias.*`, alone or next to other selects: it returns the joined row as a direct read of the + joined collection returns it, its `$id`, `$sequence`, `$createdAt`, `$updatedAt`, `$permissions` and attributes as + `alias.$id`, `alias.$sequence`, ... and `alias.attribute`. It never returns the joined `$tenant`: select + `alias.$tenant` to read it. A row an outer join left unmatched holds null for each of them. An aggregation query + still rejects `alias.*` as an ungrouped select, and an alias the query does not join is not found. +- Joined columns named next to `*` are returned with everything `*` returns: `select(['*', 'alias.$createdAt'])` + returns the main document, the joined `$id` and attributes, and `alias.$createdAt`. +- An order may name a joined attribute by its bare name when the main collection does not declare it and exactly one + join's collection does (`orderAsc('price')` over a join whose collection declares `price`); a name the main + collection declares always orders by the main collection. A bare name more than one join declares throws + `Utopia\Database\Exception\Query` (`Attribute "" is ambiguous across joins; qualify it with a join alias`). + A bare order pages with a cursor like its qualified form: the cursor row holds the value under `alias.price`. +- Index the attributes your join conditions compare. A join on an unindexed attribute is accepted, but the engine + has to scan the joined table to pair its rows, and on a large collection such a read can exceed the statement + timeout. On MariaDB 10.11 a join whose ON columns are unindexed can be very slow: MariaDB has no hash join, and its + optimizer prunes the join orders it considers, so it can pair each row with a scan of the joined table. On MySQL + under shared tables, an inner or left join whose own ON no index serves reads the tenant's rows of the joined table once, + matching its tenant as a range, instead of once per row the join pairs. +- On MySQL, a joined collection's permission check is kept out of the optimizer's semi-join search (`NO_SEMIJOIN`) + when the collection is left, right or full outer joined, and for every joined collection from five joins. Inner + joins below five keep semi-joins. Right after a collection is created or bulk-loaded, until InnoDB's automatic + statistics recalculation has run (seconds, with the default `STATS_AUTO_RECALC`), a left join on a joined + collection's own `$id` can be slow. Run `ANALYZE TABLE` after a bulk load to avoid that window. +- On PostgreSQL, planning a read takes about 2.6 to 3.2 times longer with each join from the fifth: on a + development machine, about 2.7 ms with five permission-checked joins, 6.9 ms with six and 20.7 ms with seven. It + stops growing at eight joins, where PostgreSQL's default `join_collapse_limit` ends its search for a join order. + Keep a read to about five joins, or split it into several reads. +- On MariaDB and MySQL, a one-to-many joined read is ordered by the joined `$id` behind the main `$sequence` only when + its rows show the join (no `select()`, `*`, or a joined attribute) or it pages with a cursor; see + [Paging a joined read](#paging-a-joined-read). Without a bound, the engine sorts the whole join before applying the + limit. A read bounds the sort when every join is a left join, no filter or search names a joined attribute, the order + starts with main attributes (the main `$sequence` or `$id` among them) and it has a limit: the read first picks the + main documents its page can reach (`offset + limit`, plus the cursor's own), including those matched by a fulltext + search on main attributes, and joins only those. Inner, right and full outer one-to-many joins, filters and searches + on joined attributes and orders that start with a joined attribute still sort the whole join on MariaDB and MySQL. + PostgreSQL is not affected. Index the join keys, and for a large one-to-many read select main attributes only or use + left joins. +- `Database::updateDocuments()` and `Database::deleteDocuments()` do not accept join queries. They throw + `Utopia\Database\Exception\Query` with `Join queries are not supported for bulk updates` or + `Join queries are not supported for bulk deletes`. +- On adapters without joins or aggregations (Memory, MongoDB, Redis), `find()`, `aggregate()`, `count()` and + `sum()` reject those queries during validation with `Invalid query method: `. +- MariaDB, MySQL and SQLite run a full outer join as two queries joined by `UNION ALL`. They accept one full outer + join per query, and a right join after it has to join on a table joined before the full outer join or on the full + outer joined table (directly or through other joins); other chains throw `Utopia\Database\Exception\Query`. + PostgreSQL runs full outer joins natively and has no such limit. A full outer join combined with a right join + under shared tables reads what a dedicated database reads, on every engine. + +#### Paging a joined read + +- A cursor over a joined read names the row the read returned, not only its main document. A joined row read is + ordered by its explicit orders, then by the main `$sequence` (unless an order names the main `$id` or `$sequence`), + then, for each join that can pair a row with several joined rows, by that join's `alias.$id` ascending (unless an + order names that alias's `$id` or `$sequence`). An inner or left join whose condition compares the joined `$id` with + `=` pairs at most one joined row and adds no order. Every page is ordered the same way, so paging with + `cursorAfter()` or `cursorBefore()` returns each joined row exactly once. +- Pass a row the same read returned as the cursor. It has to carry every value the read orders by, under the name the + read orders by (`note.score`, `$sequence`, `note.$id`); a missing value throws `Utopia\Database\Exception\Order` + (`Cursor has no value for order attribute 'note.$id'. …`). A value is never taken from the main document's attribute + of the same name. A read whose `select()` names attributes without `*` has to select every joined value it orders + by, a paged join's `alias.$id` included, to be paged; `alias.*` selects them all. A read without a select, or + with `*`, returns them. + `cursor()` checks the last row of each full batch before yielding the batch, so such a read throws + before the first row; a read that fits in one batch is not paged and needs no such value. +- A value may be null (a row an outer join did not match, a nullable attribute). Nulls keep the engine's position: + first in ascending order on MariaDB, MySQL and SQLite, last on PostgreSQL, and the cursor pages through them. +- A row a right or full outer join returned without a main document (its `$id` is `''`) is a valid cursor for that + read. A read without joins or `distinct()` still refuses a cursor document without an `$id` + (`Invalid query: Invalid cursor: …`). +- `getDocument()` with a join that matches several joined rows returns the one with the lowest `$sequence`, join by + join in join order. + +### Aggregations + +Aggregation queries run through `Database::aggregate($collection, $queries)`, which returns the rows as arrays (see +[Bulk writes and reads](#bulk-writes-and-reads)); `find()` refuses them. + +```php +$rows = $database->aggregate('orders', [ + Query::count('*', 'orders'), + Query::sum('total', 'revenue'), + Query::groupBy(['status']), +]); +// [['status' => 'paid', 'orders' => 12, 'revenue' => 340.5], ...] +``` + +- An aggregation query is one with an aggregate (`count`, `countDistinct`, `sum`, `avg`, `min`, `max`, the + statistical and the bitwise aggregates) or a `groupBy()`. A `groupBy()` without an aggregate counts. A select in + an aggregation query may name only the attributes the query groups by; any other select throws + `Utopia\Database\Exception\Query` + (`Invalid query: Cannot select "": an aggregation query can only select the attributes it groups by`). + Group by the attribute or leave it out of the select. `*` and relationship wildcards (`key.*`, `parent.child.*`) + are accepted and ignored, so a listing that always adds them keeps working. A join alias's `alias.*` is rejected + like any ungrouped select. A join alias cannot equal a relationship key of the main collection (see + [Joins](#joins)), so `key.*` is always the relationship's wildcard. The rule applies to `aggregate()`, `count()` + and `sum()`, which validate their query set the same way. +- An order in an aggregation query may name only an aggregate alias or an attribute the query groups by, and + `orderRandom()` is accepted; any other order throws `Utopia\Database\Exception\Query` + (`Invalid query: Cannot order by "": an aggregation query can only order by its groups and aggregates`). + A grouped attribute that only a joined collection declares matches its bare name and its aliased name alike. +- A `Utopia\Database\Validator\Query\Select` or `Validator\Query\Order` built directly applies its rule only when + it is handed the query set's aggregates and groups (`setAggregations()`, `setGroupBy()`), as + `Utopia\Database\Validator\Queries\Base` does. +- `having()` conditions follow the filter rules. Each condition compares an aggregate alias of the same query, or an + attribute passed to `groupBy()`. Compare aliases only at the top level of `having()`. `search()` inside `having()` + needs a fulltext index, and value lists are capped by `setMaxQueryValues()`. +- `sum`, `avg`, `stddev*` and `variance`/`var*` need a numeric, non-array attribute. `bitAnd`, `bitOr` and `bitXor` + need an integer attribute. Only `count` accepts `*`. +- `min` and `max` need an attribute whose values every engine can order: not an array, object, boolean, spatial or + vector attribute (PostgreSQL has neither function for its BOOLEAN, JSONB, GEOMETRY and VECTOR columns), on the main + collection and under a join alias. Otherwise they throw `Utopia\Database\Exception\Query` (`Aggregate + requires an attribute whose values are ordered, not an array, object, boolean, spatial or vector one: + `). `count` and `countDistinct` accept every attribute. +- Aggregates and `groupBy()` refuse a relationship attribute on a side that holds no column (the parent side of a + one-to-many, the child side of a many-to-one and of a one-way one-to-one, either side of a many-to-many), as filters + do: `Cannot aggregate virtual relationship attribute: `, `Cannot group by virtual relationship attribute: + `. +- SQLite has no statistical (`stddev`, `stddevPop`, `stddevSamp`, `variance`, `varPop`, `varSamp`) or bitwise + (`bitAnd`, `bitOr`, `bitXor`) aggregate functions, and `aggregate()` throws `Utopia\Database\Exception\Query` for + them there. +- An aggregate alias is an identifier (letters, digits and `_`, not starting with a digit) of at most 63 characters + (`Validator\Query\Aggregate::MAX_ALIAS_LENGTH`), and it cannot repeat another aggregate's alias or the name a + grouped attribute is returned under. +- Over an empty result, `count` and `countDistinct` return `0` and every other aggregate returns `null`, on every + adapter. `Database::sum()` still returns `0` for no rows, as in 7.x. +- With joins, a bare attribute in an aggregate function or `groupBy()` refers to the main collection's attribute + when the main collection declares it, else to the attribute of the one joined collection that declares it. A name + no collection declares, or that more than one join declares, throws `Utopia\Database\Exception\Query`; qualify it + with the join alias (`alias.attribute`). +- `Database::sum($collection, $attribute, $queries)` resolves a bare `$attribute` the same way: the main + collection's attribute when it declares one, else the attribute of the one join that declares it + (`sum('orders', 'price', [$join])`). A name several joins declare throws the ambiguity error above. +- Each group is returned under the column name the engine gives it (`groupBy(['note.name'])` returns `name`), unless + another group of the query is returned under the same name: then the joined group is returned under its qualified + name. `groupBy(['name', 'note.name'])` returns `name` (the main collection's) and `note.name`; two joined groups of + one name (`groupBy(['a.code', 'b.code'])`) return `a.code` and `b.code`. +- Over an empty result an unaliased `bitAnd`, `bitOr` or `bitXor` returns `null` as an aliased one does (MariaDB and + MySQL answer them with every bit set or `0`). On PostgreSQL every unaliased `bitAnd` is named `bit_and`, so give + them aliases to read more than one. +- An aggregation query is not ordered by vector distance: a vector query only filters it, keeping the rows that have + a vector, and a fulltext `search()` only filters it, as it filters every read. +- On MariaDB under shared tables, an aggregation query or a `distinct()` read can examine many more rows than on + dedicated tables: the engine reads the tenant's rows in the order of the index it groups by and checks permissions + row by row, instead of checking permissions first. With 50,000 documents and one tenant it takes about 5 times as + long as on dedicated tables (43 to 50 ms against 9 to 10 ms on a development machine). The more tenants a table + holds, the fewer rows each tenant's range covers. MySQL and PostgreSQL read the same way in both modes. + +### `distinct()` + +- A `distinct()` read is ordered only by its explicit orders. Next to a vector query it is not ordered by distance, + and its rows carry no `$distance`: the vector query only filters it, keeping the rows that have a vector. To order + by distance, read rows without `distinct()`. A `search()` filters a distinct read, as it filters every read. A + distinct row has no `$id`, so a cursor on a distinct read pages by its order values alone: pass a row the read + returned, and give the read a `select()` of named attributes and an order on each of them. Otherwise the read + throws `Utopia\Database\Exception\Query` (`A cursor on a distinct() read pages along its orders, …`). +- Vector distance orders row reads only, not aggregation queries or distinct reads. A distinct row stands for every + row with its selected values, so it has no single distance, and PostgreSQL and MySQL order a `SELECT DISTINCT` + only by selected columns. Without an explicit order, a distinct read's rows come back in engine order. +- On PostgreSQL and MySQL, `distinct()` with an order on an attribute the select leaves out throws + `Utopia\Database\Exception\Query` (`A distinct() query can only be ordered by a selected attribute on this + database`). Select the order attribute as well. On MariaDB, MySQL and SQLite the same applies to a `distinct()` + query over a full outer join. + +### Fulltext search + +A fulltext `search()` only filters, as in 7.x. A search read is ordered by its explicit orders and then by +`$sequence`, and a cursor pages along them. Results are not ranked by relevance and carry no `_relevance` +attribute. + +### Query builder: `from()`, `query()`, `mutate()` and `rawQuery()` + +`Database::from($collection)` returns a utopia-php/query builder over the collection's table. +`Database::query($statement)` runs a read as written and returns a list of `Document`, and +`Database::mutate($statement)` runs a write and returns the affected row count. They are available on the SQL +adapters only +(`Feature\QueryBuilder`); elsewhere they throw `Utopia\Database\Exception`. They are an escape hatch for statements +the document API cannot express: they check no permissions, bypass the document and query caches (call +`purgeCachedDocument()` for documents you change: it also invalidates the collection's cached `find()` results), +write no permission rows, skip validation and run no hooks or events (a `Mirror` does not replicate them). Both throw +`Utopia\Database\Exception\Authorization` unless authorization is disabled: build and run them inside +`$database->getAuthorization()->skip(fn () => ...)`. + +`Database::rawQuery($sql, $bindings)` is the same escape hatch for SQL you write yourself: it runs the statement as +written, checks no permissions, applies no tenant scope (under shared tables it reads every tenant's rows unless the +SQL limits them) and runs no hooks or events. It throws `Utopia\Database\Exception\Authorization` unless +authorization is disabled: run it inside `$database->getAuthorization()->skip(fn () => ...)`. + +Skipping authorization does not skip tenancy for `from()`. Under shared tables a read stays within the tenant selected when +`from()` handed the builder out: the main table and every table joined through the builder's join methods. An +`update()` or `delete()` is kept to the tenant on its main table; the builder's join methods do not apply to them. +To read another tenant, select it with `setTenant()` or `withTenant()`; there is no other opt-out. A right or full +outer join needs the main table named as `from()` names it, and a statement without a main table is refused +(`Utopia\Database\Exception\Query`). Not scoped for you: SQL you write yourself, and builders not obtained from +`from()` (subqueries, unions, lateral joins). + +`from()` builds on the adapter's `builder()` (`Feature\QueryBuilder`), which takes no collection: +`$adapter->builder()->from($collection, $alias)` is the builder `Database::from($collection, $alias)` hands out, +without the `Database`'s executor. Its `from()` takes a collection id, and the alias names the main table, which is +then the name the tenant condition and a right or full outer join pair with. Its joins take collection ids too, +whether they were added before or after `from()` and through the join methods, `filter()` or `queries()`. + +It reads that one collection under that alias. `from()` of it again (after `reset()`, say) keeps its tenant scope, +and everything that would leave the scope behind throws `Utopia\Database\Exception\Query`; start another builder +instead: + +- `from()` of another collection, or of the same collection under another alias +- `fromTable()` and `into()` +- `scope()` +- a dialect's multi-table write, whose second table the tenant scope does not reach: `updateJoin()` and + `deleteJoin()` on MySQL and MariaDB, `updateFrom()` and `deleteUsing()` on PostgreSQL + +A builder that names no collection is kept to no tenant: `fromNone()`, `fromTable()`, which reads a table by the +name it is stored under (as do its joins), and `into()`. An insert goes through such a builder, run with +`mutate()`, and writes exactly the columns you give it, `$tenant` included: +`$database->mutate($adapter->builder()->into($table)->set([...])->insert())`, with `$table` the name the table is +stored under. + +### `exists()` and `notExists()` + +`Query::exists([...])` and `Query::notExists(...)` run on the SQL adapters and take attribute names as their values. +On adapters with defined attributes each name has to be an attribute of the collection that holds a column, or an +`alias.attribute` of a join; an internal column name (`_permissions`), an unknown attribute, a relationship side +without a column or a related document's attribute throws `Utopia\Database\Exception\Query`. Schemaless adapters +accept any name. + +### Query cache + +`setQueryCache(new Cache\Query($cache))` caches `find()` results per hostname, database, namespace, tenant and +collection. Writes through the `Database` invalidate only the scope they write in. `purgeCachedDocument()` and +`purgeCachedQueries($collection)` invalidate it as well; call one of them after changing data behind the library's +back. + +- Use a cache adapter with generations (`Utopia\Cache\Feature\Leasable`: Redis and Redis\Multiplexing, and Pool, + Sharding and CircuitBreaker when the adapters they wrap have them). With any other adapter a collection's cache + stays off for one region TTL after each write, because concurrent writers cannot be told apart. +- Collection listings (`find('_metadata')`, `listCollections()`) are never cached. +- Under tenant-per-document, a write invalidates the scope of each written document's tenant. + +### Pools and profiling + +- **Query profiling.** `Database::setProfiling(true)` attaches a `Utopia\Database\Profiler` that + records the SQL statements the adapter runs. The profiler keeps the newest `Profiler::DEFAULT_CAPACITY` + (1000) entries; older entries are dropped as new ones arrive. Change the bound with + `$database->getProfiler()?->setCapacity($entries)` (at least 1). `getQueryCount()` and `getTotalTime()` cover every + statement logged since the last `reset()`, including dropped ones, so they can exceed what `getLogs()` returns. + `setProfiling(false)` stops recording and detaches the profiler from the adapter; what was captured stays readable + through `getProfiler()->getLogs()` until `reset()`. Behind `Adapter\Pool` a connection carries the handle's + profiler only while it is checked out. A `Pool` subclass that checks connections out itself (with + `$this->pool->use(...)`) should call `$this->releaseBorrowed($adapter)` before giving the connection back. + Each `Utopia\Database\Profiler\Log` carries the statement's bound values (`bindings`), the collection it + reads (`collection`, for the statements `getDocument()`, `find()`, `count()` and `sum()` run) and the operation + that ran it (`operation`, the `Utopia\Database\Event` value, such as `document_find`). Statements that bind by + hand (`rawQuery()`, schema changes) log no bindings, and writes log no collection. `Profiler\Log` has no + `explainPlan`. +- **Capability questions behind a `Pool`.** `supports()`, `capabilities()` and `hasFeature()` ask a connection the + first time and are then answered without one, for every handle built over the same `Utopia\Pools\Pool`. + `supports(Capability::DefinedAttributes)` is the exception: it reports the schema mode of the connection that + answers, so it always asks one. `Database::setLocks()` reaches every borrowed connection. +- **Transactions behind a `Pool`.** `withTransaction()` pins one connection for the coroutine that calls it and the + coroutines it starts. Other coroutines sharing the handle borrow connections of their own and run outside the + transaction; in 7.x their statements ran on the pinned connection, inside the transaction, and their own + `withTransaction()` became a savepoint in it. A coroutine started inside the transaction shares the pinned + connection, so it must not run a statement while its parent runs one. Every call on the pinned connection runs + under the calling coroutine's tenant. The protected `Pool::$pinnedAdapter` property is removed: a subclass reads + the pinned connection through `pin()`, and can override it. +- **Read/write splitting (`Adapter\ReadWritePool`).** Reads go to the read pool and writes to the write pool. After + a write returns, or a `withTransaction()` block finishes, reads stay on the write pool for the sticky window + (`setStickyDuration()`, default 5000 ms; `setSticky(false)` turns it off), so a caller reads its own writes. + `getDocument(..., forUpdate: true)` and `rawQuery()` always use the write pool, since a locking read must run on + the primary and raw SQL may write; both also open the sticky window. So do the reads that decide a write: the + batch `updateDocuments()` and `deleteDocuments()` select, and the document `upsertDocuments()` compares against. Calls that touch no data (capability checks + such as `supports()`, `capabilities()` and `hasFeature()`, `limits()`, value casting, and configuration such as + `setSchemaless()`) are answered wherever a read would be and never open the sticky window. + `hostname()` always names the write pool's host, because it namespaces document and query cache keys, and is + looked up once per handle. `getDriver()` counts as a write: code that runs statements through the raw driver gets + a write-pool connection. + +## Known limitations + +- A transaction begun on the adapter directly (`$database->getAdapter()->startTransaction()`) is not seen by the + cache invalidation or the `document_purge` queue that `withTransaction()` keeps. Each write inside it invalidates + the caches and fires `document_purge` when that write returns, inside the adapter transaction and before it + commits, and a rollback does not withdraw them. Reads inside it are not served from the document cache. Group + writes with `withTransaction()` instead. diff --git a/bin/cli.php b/bin/cli.php index 77f462eabe..3afcf1a25a 100644 --- a/bin/cli.php +++ b/bin/cli.php @@ -1,24 +1,33 @@ 3, + PDO::ATTR_PERSISTENT => true, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::ATTR_STRINGIFY_FETCHES => true, +]; + $cli = new CLI(); -include 'tasks/load.php'; -include 'tasks/index.php'; -include 'tasks/query.php'; -include 'tasks/relationships.php'; -include 'tasks/operators.php'; +include __DIR__.'/tasks/load.php'; +include __DIR__.'/tasks/index.php'; +include __DIR__.'/tasks/query.php'; +include __DIR__.'/tasks/relationships.php'; +include __DIR__.'/tasks/operators.php'; $cli ->error() ->inject('error') - ->action(function ($error) { + ->action(function (Throwable $error) { Console::error($error->getMessage()); }); diff --git a/bin/tasks/index.php b/bin/tasks/index.php index 256f23ce10..cc7b9eef5e 100644 --- a/bin/tasks/index.php +++ b/bin/tasks/index.php @@ -1,10 +1,5 @@ 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MariaDB::class, - 'pdoAttr' => MariaDB::getPDOAttributes(), + 'pdoAttr' => PDO_ATTRIBUTES, ], 'mysql' => [ 'host' => 'mysql', @@ -48,7 +52,7 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MySQL::class, - 'pdoAttr' => MySQL::getPDOAttributes(), + 'pdoAttr' => PDO_ATTRIBUTES, ], 'postgres' => [ 'host' => 'postgres', @@ -57,12 +61,13 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", 'adapter' => Postgres::class, - 'pdoAttr' => Postgres::getPDOAttributes(), + 'pdoAttr' => PDO_ATTRIBUTES, ], ]; - if (!isset($dbAdapters[$adapter])) { + if (! isset($dbAdapters[$adapter])) { Console::error("Adapter '{$adapter}' not supported"); + return; } @@ -75,38 +80,41 @@ $cfg['pdoAttr'] ); - $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + $database = new Database(new ($cfg['adapter'])($pdo), $cache); + $database + ->addHook(new Permissions()) + ->addHook(new Relationships()) ->setDatabase($name) ->setNamespace($namespace) ->setSharedTables($sharedTables); Console::info("Creating key index 'createdGenre' on 'articles' for created > '2010-01-01 05:00:00' and genre = 'travel'"); $start = microtime(true); - $database->createIndex('articles', 'createdGenre', Database::INDEX_KEY, ['created', 'genre'], [], [Database::ORDER_DESC, Database::ORDER_DESC]); + $database->createIndex('articles', Index::key(key: 'createdGenre', attributes: ['created', 'genre'], orders: [OrderDirection::Desc, OrderDirection::Desc])); $time = microtime(true) - $start; Console::success("Index 'createdGenre' created in {$time} seconds"); Console::info("Creating key index 'genre' on 'articles' for genres: fashion, finance, sports"); $start = microtime(true); - $database->createIndex('articles', 'genre', Database::INDEX_KEY, ['genre'], [], [Database::ORDER_ASC]); + $database->createIndex('articles', Index::key(key: 'genre', attributes: ['genre'], orders: [OrderDirection::Asc])); $time = microtime(true) - $start; Console::success("Index 'genre' created in {$time} seconds"); Console::info("Creating key index 'views' on 'articles' for views > 100000"); $start = microtime(true); - $database->createIndex('articles', 'views', Database::INDEX_KEY, ['views'], [], [Database::ORDER_DESC]); + $database->createIndex('articles', Index::key(key: 'views', attributes: ['views'], orders: [OrderDirection::Desc])); $time = microtime(true) - $start; Console::success("Index 'views' created in {$time} seconds"); Console::info("Creating fulltext index 'fulltextsearch' on 'articles' for search term 'Alice'"); $start = microtime(true); - $database->createIndex('articles', 'fulltextsearch', Database::INDEX_FULLTEXT, ['text']); + $database->createIndex('articles', Index::fulltext(key: 'fulltextsearch', attributes: ['text'])); $time = microtime(true) - $start; Console::success("Index 'fulltextsearch' created in {$time} seconds"); Console::info("Creating key index 'tags' on 'articles' for tags containing 'tag1'"); $start = microtime(true); - $database->createIndex('articles', 'tags', Database::INDEX_KEY, ['tags']); + $database->createIndex('articles', Index::key(key: 'tags', attributes: ['tags'])); $time = microtime(true) - $start; Console::success("Index 'tags' created in {$time} seconds"); }); diff --git a/bin/tasks/load.php b/bin/tasks/load.php index 17206de1f1..cfb3b3cdc2 100644 --- a/bin/tasks/load.php +++ b/bin/tasks/load.php @@ -10,12 +10,17 @@ use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Hook\Permissions; +use Utopia\Database\Hook\Relationships; +use Utopia\Database\Index; use Utopia\Database\PDO; +use Utopia\Database\Permission; +use Utopia\Database\Role; use Utopia\Validator\Boolean; use Utopia\Validator\Integer; use Utopia\Validator\Text; @@ -25,7 +30,6 @@ $genresPool = ['fashion', 'food', 'travel', 'music', 'lifestyle', 'fitness', 'diy', 'sports', 'finance']; $tagsPool = ['short', 'quick', 'easy', 'medium', 'hard']; - /** * @Example * docker compose exec tests bin/load --adapter=mariadb --limit=1000 @@ -35,11 +39,10 @@ ->desc('Load database with mock data for testing') ->param('adapter', '', new Text(0), 'Database adapter') ->param('limit', 0, new Integer(true), 'Total number of records to add to database') - ->param('name', 'myapp_' . uniqid(), new Text(0), 'Name of created database.', true) + ->param('name', 'myapp_'.uniqid(), new Text(0), 'Name of created database.', true) ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) ->action(function (string $adapter, int $limit, string $name, bool $sharedTables) { - $createSchema = function (Database $database): void { if ($database->exists($database->getDatabase())) { $database->delete($database->getDatabase()); @@ -47,28 +50,27 @@ $database->getAuthorization()->addRole(Role::any()->toString()); $database->create(); - $database->createCollection('articles', permissions: [ + $database->createCollection(Collection::create(id: 'articles', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ]); - - $database->createAttribute('articles', 'author', Database::VAR_STRING, 256, true); - $database->createAttribute('articles', 'created', Database::VAR_DATETIME, 0, true, filters: ['datetime']); - $database->createAttribute('articles', 'text', Database::VAR_STRING, 5000, true); - $database->createAttribute('articles', 'genre', Database::VAR_STRING, 256, true); - $database->createAttribute('articles', 'views', Database::VAR_INTEGER, 0, true); - $database->createAttribute('articles', 'tags', Database::VAR_STRING, 0, true, array: true); - $database->createIndex('articles', 'text', Database::INDEX_FULLTEXT, ['text']); + ])); + + $database->createAttribute('articles', Attribute::string(key: 'author', size: 256, required: true)); + $database->createAttribute('articles', Attribute::datetime(key: 'created', required: true)); + $database->createAttribute('articles', Attribute::string(key: 'text', size: 5000, required: true)); + $database->createAttribute('articles', Attribute::string(key: 'genre', size: 256, required: true)); + $database->createAttribute('articles', Attribute::integer(key: 'views', required: true)); + $database->createAttribute('articles', Attribute::string(key: 'tags', size: 0, required: true, array: true)); + $database->createIndex('articles', Index::fulltext(key: 'text', attributes: ['text'])); }; - $start = null; $namespace = '_ns'; $cache = new Cache(new NoCache()); Console::info("Filling {$adapter} with {$limit} records: {$name}"); - //Runtime::enableCoroutine(); + // Runtime::enableCoroutine(); $dbAdapters = [ 'mariadb' => [ @@ -79,7 +81,7 @@ 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'driver' => 'mysql', 'adapter' => MariaDB::class, - 'attrs' => MariaDB::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], 'mysql' => [ 'host' => 'mysql', @@ -89,7 +91,7 @@ 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'driver' => 'mysql', 'adapter' => MySQL::class, - 'attrs' => MySQL::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], 'postgres' => [ 'host' => 'postgres', @@ -99,19 +101,20 @@ 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", 'driver' => 'pgsql', 'adapter' => Postgres::class, - 'attrs' => Postgres::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], ]; - if (!isset($dbAdapters[$adapter])) { + if (! isset($dbAdapters[$adapter])) { Console::error("Adapter '{$adapter}' not supported"); + return; } $cfg = $dbAdapters[$adapter]; $dsn = ($cfg['dsn'])($cfg['host'], $cfg['port']); - //Co\run(function () use (&$start, $limit, $name, $sharedTables, $namespace, $cache, $cfg) { + // Co\run(function () use (&$start, $limit, $name, $sharedTables, $namespace, $cache, $cfg) { $pdo = new PDO( $dsn, $cfg['user'], @@ -119,12 +122,15 @@ $cfg['attrs'] ); - $createSchema( - (new Database(new ($cfg['adapter'])($pdo), $cache)) - ->setDatabase($name) - ->setNamespace($namespace) - ->setSharedTables($sharedTables) - ); + $database = new Database(new ($cfg['adapter'])($pdo), $cache); + $database + ->addHook(new Permissions()) + ->addHook(new Relationships()) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables); + + $createSchema($database); $pool = new PDOPool( (new PDOConfig()) @@ -132,7 +138,7 @@ ->withHost($cfg['host']) ->withPort($cfg['port']) ->withDbName($name) - //->withCharset('utf8mb4') + // ->withCharset('utf8mb4') ->withUsername($cfg['user']) ->withPassword($cfg['pass']), 128 @@ -141,29 +147,30 @@ $start = \microtime(true); for ($i = 0; $i < $limit / 1000; $i++) { - //\go(function () use ($cfg, $pool, $name, $namespace, $sharedTables, $cache) { + // \go(function () use ($cfg, $pool, $name, $namespace, $sharedTables, $cache) { try { - //$pdo = $pool->get(); + // $pdo = $pool->get(); - $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + $database = new Database(new ($cfg['adapter'])($pdo), $cache); + $database + ->addHook(new Permissions()) + ->addHook(new Relationships()) ->setDatabase($name) ->setNamespace($namespace) ->setSharedTables($sharedTables); createDocuments($database); - //$pool->put($pdo); + // $pool->put($pdo); } catch (\Throwable $error) { - Console::error('Coroutine error: ' . $error->getMessage()); + Console::error('Coroutine error: '.$error->getMessage()); } - //}); + // }); } $time = microtime(true) - $start; Console::success("Completed in {$time} seconds"); }); - - function createDocuments(Database $database): void { global $namesPool, $genresPool, $tagsPool; @@ -176,16 +183,16 @@ function createDocuments(Database $database): void $bytes = \random_bytes(intdiv($length + 1, 2)); $text = \substr(\bin2hex($bytes), 0, $length); $tagCount = \mt_rand(1, count($tagsPool)); - $tagKeys = (array)\array_rand($tagsPool, $tagCount); + $tagKeys = (array) \array_rand($tagsPool, $tagCount); $tags = \array_map(fn ($k) => $tagsPool[$k], $tagKeys); $documents[] = new Document([ '$permissions' => [ Permission::read(Role::any()), - ...array_map(fn () => Permission::read(Role::user(mt_rand(0, 999999999))), range(1, 4)), - ...array_map(fn () => Permission::create(Role::user(mt_rand(0, 999999999))), range(1, 3)), - ...array_map(fn () => Permission::update(Role::user(mt_rand(0, 999999999))), range(1, 3)), - ...array_map(fn () => Permission::delete(Role::user(mt_rand(0, 999999999))), range(1, 3)), + ...array_map(fn () => Permission::read(Role::user((string) mt_rand(0, 999999999))), range(1, 4)), + ...array_map(fn () => Permission::create(Role::user((string) mt_rand(0, 999999999))), range(1, 3)), + ...array_map(fn () => Permission::update(Role::user((string) mt_rand(0, 999999999))), range(1, 3)), + ...array_map(fn () => Permission::delete(Role::user((string) mt_rand(0, 999999999))), range(1, 3)), ], 'author' => $namesPool[\array_rand($namesPool)], 'created' => DateTime::now(), diff --git a/bin/tasks/operators.php b/bin/tasks/operators.php index d351b0ca13..2f53f2895c 100644 --- a/bin/tasks/operators.php +++ b/bin/tasks/operators.php @@ -14,24 +14,28 @@ * The --seed parameter allows you to pre-populate the collection with a specified * number of documents to test how operators perform with varying amounts of existing data. */ - global $cli; use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Cache; use Utopia\Console; +use Utopia\Database\Adapter\Feature; use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Adapter\Postgres; use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Hook\Permissions; +use Utopia\Database\Hook\Relationships; use Utopia\Database\Operator; use Utopia\Database\PDO; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; use Utopia\Validator\Integer; use Utopia\Validator\Text; @@ -41,23 +45,20 @@ ->param('adapter', '', new Text(0), 'Database adapter (mariadb, postgres, sqlite)') ->param('iterations', 1000, new Integer(true), 'Number of iterations per test', true) ->param('seed', 0, new Integer(true), 'Number of documents to pre-seed the collection with', true) - ->param('name', 'operator_benchmark_' . uniqid(), new Text(0), 'Name of test database', true) + ->param('name', 'operator_benchmark_'.uniqid(), new Text(0), 'Name of test database', true) ->action(function (string $adapter, int $iterations, int $seed, string $name) { $namespace = '_ns'; $cache = new Cache(new NoCache()); - Console::info("============================================================="); - Console::info(" OPERATOR PERFORMANCE BENCHMARK"); - Console::info("============================================================="); + Console::info('============================================================='); + Console::info(' OPERATOR PERFORMANCE BENCHMARK'); + Console::info('============================================================='); Console::info("Adapter: {$adapter}"); Console::info("Iterations: {$iterations}"); Console::info("Seed Documents: {$seed}"); Console::info("Database: {$name}"); Console::info("=============================================================\n"); - // ------------------------------------------------------------------ - // Adapter configuration - // ------------------------------------------------------------------ $dbAdapters = [ 'mariadb' => [ 'host' => 'mariadb', @@ -66,7 +67,7 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MariaDB::class, - 'attrs' => MariaDB::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], 'mysql' => [ 'host' => 'mysql', @@ -75,7 +76,7 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MySQL::class, - 'attrs' => MySQL::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], 'postgres' => [ 'host' => 'postgres', @@ -84,21 +85,22 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", 'adapter' => Postgres::class, - 'attrs' => Postgres::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], 'sqlite' => [ 'host' => ':memory:', 'port' => 0, 'user' => '', 'pass' => '', - 'dsn' => static fn (string $host, int $port) => "sqlite::memory:", + 'dsn' => static fn (string $host, int $port) => 'sqlite::memory:', 'adapter' => SQLite::class, 'attrs' => [], ], ]; - if (!isset($dbAdapters[$adapter])) { + if (! isset($dbAdapters[$adapter])) { Console::error("Adapter '{$adapter}' not supported. Available: mariadb, postgres, sqlite"); + return; } @@ -109,7 +111,10 @@ // Initialize database connection $pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], $cfg['attrs']); - $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + $database = new Database(new ($cfg['adapter'])($pdo), $cache); + $database + ->addHook(new Permissions()) + ->addHook(new Relationships()) ->setDatabase($name) ->setNamespace($namespace); @@ -128,8 +133,9 @@ Console::success("\nBenchmark completed successfully!"); } catch (\Throwable $e) { - Console::error("Error: " . $e->getMessage()); - Console::error("Trace: " . $e->getTraceAsString()); + Console::error('Error: '.$e->getMessage()); + Console::error('Trace: '.$e->getTraceAsString()); + return; } }); @@ -139,7 +145,7 @@ */ function setupTestEnvironment(Database $database, string $name, int $seed): void { - Console::info("Setting up test environment..."); + Console::info('Setting up test environment...'); // Delete database if it exists if ($database->exists($name)) { @@ -147,41 +153,41 @@ function setupTestEnvironment(Database $database, string $name, int $seed): void } $database->create(); - $authorization->addRole(Role::any()->toString()); + $database->getAuthorization()->addRole(Role::any()->toString()); // Create test collection - $database->createCollection('operators_test', permissions: [ + $database->createCollection(Collection::create(id: 'operators_test', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), - ]); + ])); // Create attributes for all operator types // Numeric attributes - $database->createAttribute('operators_test', 'counter', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute('operators_test', 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute('operators_test', 'multiplier', Database::VAR_FLOAT, 0, false, 1.0); - $database->createAttribute('operators_test', 'divider', Database::VAR_FLOAT, 0, false, 100.0); - $database->createAttribute('operators_test', 'modulo_val', Database::VAR_INTEGER, 0, false, 100); - $database->createAttribute('operators_test', 'power_val', Database::VAR_FLOAT, 0, false, 2.0); + $database->createAttribute('operators_test', Attribute::integer(key: 'counter', required: false, default: 0)); + $database->createAttribute('operators_test', Attribute::float(key: 'score', required: false, default: 0.0)); + $database->createAttribute('operators_test', Attribute::float(key: 'multiplier', required: false, default: 1.0)); + $database->createAttribute('operators_test', Attribute::float(key: 'divider', required: false, default: 100.0)); + $database->createAttribute('operators_test', Attribute::integer(key: 'modulo_val', required: false, default: 100)); + $database->createAttribute('operators_test', Attribute::float(key: 'power_val', required: false, default: 2.0)); // String attributes - $database->createAttribute('operators_test', 'name', Database::VAR_STRING, 200, false, 'test'); - $database->createAttribute('operators_test', 'text', Database::VAR_STRING, 500, false, 'initial'); - $database->createAttribute('operators_test', 'description', Database::VAR_STRING, 500, false, 'foo bar baz'); + $database->createAttribute('operators_test', Attribute::string(key: 'name', size: 200, required: false, default: 'test')); + $database->createAttribute('operators_test', Attribute::string(key: 'text', size: 500, required: false, default: 'initial')); + $database->createAttribute('operators_test', Attribute::string(key: 'description', size: 500, required: false, default: 'foo bar baz')); // Boolean attributes - $database->createAttribute('operators_test', 'active', Database::VAR_BOOLEAN, 0, false, true); + $database->createAttribute('operators_test', Attribute::boolean(key: 'active', required: false, default: true)); // Array attributes - $database->createAttribute('operators_test', 'tags', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute('operators_test', 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute('operators_test', 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute('operators_test', Attribute::string(key: 'tags', size: 50, required: false, default: null, array: true)); + $database->createAttribute('operators_test', Attribute::integer(key: 'numbers', required: false, default: null, signed: true, array: true)); + $database->createAttribute('operators_test', Attribute::string(key: 'items', size: 50, required: false, default: null, array: true)); // Date attributes - $database->createAttribute('operators_test', 'created_at', Database::VAR_DATETIME, 0, false, null, false, false, null, [], ['datetime']); - $database->createAttribute('operators_test', 'updated_at', Database::VAR_DATETIME, 0, false, null, false, false, null, [], ['datetime']); + $database->createAttribute('operators_test', Attribute::datetime(key: 'created_at', required: false, default: null, array: false)); + $database->createAttribute('operators_test', Attribute::datetime(key: 'updated_at', required: false, default: null, array: false)); // Seed documents if requested if ($seed > 0) { @@ -210,7 +216,7 @@ function seedDocuments(Database $database, int $count): void for ($i = 0; $i < $remaining; $i++) { $docNum = ($batch * $batchSize) + $i; $docs[] = new Document([ - '$id' => 'seed_' . $docNum, + '$id' => 'seed_'.$docNum, '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -221,13 +227,13 @@ function seedDocuments(Database $database, int $count): void 'divider' => round(rand(5000, 15000) / 100, 2), 'modulo_val' => rand(50, 200), 'power_val' => round(rand(100, 300) / 100, 2), - 'name' => 'seed_doc_' . $docNum, - 'text' => 'Seed text for document ' . $docNum, - 'description' => 'This is seed document ' . $docNum . ' with some foo bar baz content', + 'name' => 'seed_doc_'.$docNum, + 'text' => 'Seed text for document '.$docNum, + 'description' => 'This is seed document '.$docNum.' with some foo bar baz content', 'active' => (bool) rand(0, 1), - 'tags' => ['seed', 'tag' . ($docNum % 10), 'category' . ($docNum % 5)], + 'tags' => ['seed', 'tag'.($docNum % 10), 'category'.($docNum % 5)], 'numbers' => [rand(1, 10), rand(11, 20), rand(21, 30)], - 'items' => ['item' . ($docNum % 3), 'item' . ($docNum % 7)], + 'items' => ['item'.($docNum % 3), 'item'.($docNum % 7)], 'created_at' => DateTime::now(), 'updated_at' => DateTime::now(), ]); @@ -243,7 +249,7 @@ function seedDocuments(Database $database, int $count): void } $seedTime = microtime(true) - $seedStart; - Console::success("Seeding completed in " . number_format($seedTime, 2) . "s\n"); + Console::success('Seeding completed in '.number_format($seedTime, 2)."s\n"); } /** @@ -262,7 +268,7 @@ function runAllBenchmarks(Database $database, int $iterations): array $results[$name] = $benchmark(); } catch (\Throwable $e) { $failed[$name] = $e->getMessage(); - Console::warning(" ⚠️ {$name} failed: " . $e->getMessage()); + Console::warning(" ⚠️ {$name} failed: ".$e->getMessage()); } }; @@ -343,6 +349,7 @@ function runAllBenchmarks(Database $database, int $iterations): array Operator::increment(1), function ($doc) { $doc->setAttribute('counter', $doc->getAttribute('counter', 0) + 1); + return $doc; }, ['counter' => 0] @@ -356,6 +363,7 @@ function ($doc) { Operator::decrement(1), function ($doc) { $doc->setAttribute('counter', $doc->getAttribute('counter', 100) - 1); + return $doc; }, ['counter' => 100] @@ -369,6 +377,7 @@ function ($doc) { Operator::multiply(1.1), function ($doc) { $doc->setAttribute('multiplier', $doc->getAttribute('multiplier', 1.0) * 1.1); + return $doc; }, ['multiplier' => 1.0] @@ -382,6 +391,7 @@ function ($doc) { Operator::divide(1.1), function ($doc) { $doc->setAttribute('divider', $doc->getAttribute('divider', 100.0) / 1.1); + return $doc; }, ['divider' => 100.0] @@ -396,6 +406,7 @@ function ($doc) { function ($doc) { $val = $doc->getAttribute('modulo_val', 100); $doc->setAttribute('modulo_val', $val % 7); + return $doc; }, ['modulo_val' => 100] @@ -409,6 +420,7 @@ function ($doc) { Operator::power(1.001), function ($doc) { $doc->setAttribute('power_val', pow($doc->getAttribute('power_val', 2.0), 1.001)); + return $doc; }, ['power_val' => 2.0] @@ -422,7 +434,8 @@ function ($doc) { 'text', Operator::stringConcat('x'), function ($doc) { - $doc->setAttribute('text', $doc->getAttribute('text', 'initial') . 'x'); + $doc->setAttribute('text', $doc->getAttribute('text', 'initial').'x'); + return $doc; }, ['text' => 'initial'] @@ -436,6 +449,7 @@ function ($doc) { Operator::stringReplace('foo', 'bar'), function ($doc) { $doc->setAttribute('description', str_replace('foo', 'bar', $doc->getAttribute('description', 'foo bar baz'))); + return $doc; }, ['description' => 'foo bar baz'] @@ -449,7 +463,8 @@ function ($doc) { 'active', Operator::toggle(), function ($doc) { - $doc->setAttribute('active', !$doc->getAttribute('active', true)); + $doc->setAttribute('active', ! $doc->getAttribute('active', true)); + return $doc; }, ['active' => true] @@ -466,6 +481,7 @@ function ($doc) { $tags = $doc->getAttribute('tags', ['initial']); $tags[] = 'new'; $doc->setAttribute('tags', $tags); + return $doc; }, ['tags' => ['initial']] @@ -481,6 +497,7 @@ function ($doc) { $tags = $doc->getAttribute('tags', ['initial']); array_unshift($tags, 'first'); $doc->setAttribute('tags', $tags); + return $doc; }, ['tags' => ['initial']] @@ -496,6 +513,7 @@ function ($doc) { $numbers = $doc->getAttribute('numbers', [1, 2, 3]); array_splice($numbers, 1, 0, [99]); $doc->setAttribute('numbers', $numbers); + return $doc; }, ['numbers' => [1, 2, 3]] @@ -511,6 +529,7 @@ function ($doc) { $tags = $doc->getAttribute('tags', ['keep', 'unwanted', 'also']); $tags = array_values(array_filter($tags, fn ($t) => $t !== 'unwanted')); $doc->setAttribute('tags', $tags); + return $doc; }, ['tags' => ['keep', 'unwanted', 'also']] @@ -525,6 +544,7 @@ function ($doc) { function ($doc) { $tags = $doc->getAttribute('tags', ['a', 'b', 'a', 'c', 'b']); $doc->setAttribute('tags', array_values(array_unique($tags))); + return $doc; }, ['tags' => ['a', 'b', 'a', 'c', 'b']] @@ -539,6 +559,7 @@ function ($doc) { function ($doc) { $tags = $doc->getAttribute('tags', ['keep', 'remove', 'this']); $doc->setAttribute('tags', array_values(array_intersect($tags, ['keep', 'this']))); + return $doc; }, ['tags' => ['keep', 'remove', 'this']] @@ -553,6 +574,7 @@ function ($doc) { function ($doc) { $tags = $doc->getAttribute('tags', ['keep', 'remove', 'this']); $doc->setAttribute('tags', array_values(array_diff($tags, ['remove']))); + return $doc; }, ['tags' => ['keep', 'remove', 'this']] @@ -567,6 +589,7 @@ function ($doc) { function ($doc) { $numbers = $doc->getAttribute('numbers', [1, 3, 5, 7, 9]); $doc->setAttribute('numbers', array_values(array_filter($numbers, fn ($n) => $n > 5))); + return $doc; }, ['numbers' => [1, 3, 5, 7, 9]] @@ -583,6 +606,7 @@ function ($doc) { $date = new \DateTime($doc->getAttribute('created_at', DateTime::now())); $date->modify('+1 day'); $doc->setAttribute('created_at', DateTime::format($date)); + return $doc; }, ['created_at' => DateTime::now()] @@ -598,6 +622,7 @@ function ($doc) { $date = new \DateTime($doc->getAttribute('updated_at', DateTime::now())); $date->modify('-1 day'); $doc->setAttribute('updated_at', DateTime::format($date)); + return $doc; }, ['updated_at' => DateTime::now()] @@ -611,16 +636,17 @@ function ($doc) { Operator::dateSetNow(), function ($doc) { $doc->setAttribute('updated_at', DateTime::now()); + return $doc; }, ['updated_at' => DateTime::now()] )); // Report any failures - if (!empty($failed)) { + if (! empty($failed)) { Console::warning("\n⚠️ Some benchmarks failed:"); foreach ($failed as $name => $error) { - Console::warning(" - {$name}: " . substr($error, 0, 100)); + Console::warning(" - {$name}: ".substr($error, 0, 100)); } } @@ -637,10 +663,10 @@ function benchmarkOperation( bool $isBulk, bool $useOperators ): array { - $displayName = strtoupper($operation) . ($useOperators ? ' (with ops)' : ' (no ops)'); + $displayName = strtoupper($operation).($useOperators ? ' (with ops)' : ' (no ops)'); Console::info("Benchmarking {$displayName}..."); - $docId = 'bench_op_' . strtolower($operation) . '_' . ($useOperators ? 'ops' : 'noops'); + $docId = 'bench_op_'.strtolower($operation).'_'.($useOperators ? 'ops' : 'noops'); // Create initial document $baseData = [ @@ -650,7 +676,7 @@ function benchmarkOperation( ], 'counter' => 0, 'name' => 'test', - 'score' => 100.0 + 'score' => 100.0, ]; $database->createDocument('operators_test', new Document(array_merge(['$id' => $docId], $baseData))); @@ -662,11 +688,11 @@ function benchmarkOperation( if ($operation === 'updateDocument') { if ($useOperators) { $database->updateDocument('operators_test', $docId, new Document([ - 'counter' => Operator::increment(1) + 'counter' => Operator::increment(1), ])); } else { $database->updateDocument('operators_test', $docId, new Document([ - 'counter' => $i + 1 + 'counter' => $i + 1, ])); } } elseif ($operation === 'updateDocuments') { @@ -680,7 +706,7 @@ function benchmarkOperation( // because updateDocuments with queries would apply the same value to all matching docs $doc = $database->getDocument('operators_test', $docId); $database->updateDocument('operators_test', $docId, new Document([ - 'counter' => $i + 1 + 'counter' => $i + 1, ])); } } elseif ($operation === 'upsertDocument') { @@ -689,24 +715,24 @@ function benchmarkOperation( '$id' => $docId, 'counter' => Operator::increment(1), 'name' => 'test', - 'score' => 100.0 + 'score' => 100.0, ])); } else { $database->upsertDocument('operators_test', new Document([ '$id' => $docId, 'counter' => $i + 1, 'name' => 'test', - 'score' => 100.0 + 'score' => 100.0, ])); } } elseif ($operation === 'upsertDocuments') { if ($useOperators) { $database->upsertDocuments('operators_test', [ - new Document(['$id' => $docId, 'counter' => Operator::increment(1), 'name' => 'test', 'score' => 100.0]) + new Document(['$id' => $docId, 'counter' => Operator::increment(1), 'name' => 'test', 'score' => 100.0]), ]); } else { $database->upsertDocuments('operators_test', [ - new Document(['$id' => $docId, 'counter' => $i + 1, 'name' => 'test', 'score' => 100.0]) + new Document(['$id' => $docId, 'counter' => $i + 1, 'name' => 'test', 'score' => 100.0]), ]); } } @@ -718,7 +744,7 @@ function benchmarkOperation( // Cleanup $database->deleteDocument('operators_test', $docId); - Console::success(" Time: {$timeOp}s | Memory: " . formatBytes($memOp)); + Console::success(" Time: {$timeOp}s | Memory: ".formatBytes($memOp)); return [ 'operation' => $operation, @@ -753,8 +779,9 @@ function benchmarkOperatorAcrossOperations( foreach ($operationTypes as $opType => $method) { // Skip upsert operations if not supported - if (str_contains($method, 'upsert') && !$database->getAdapter()->getSupportForUpserts()) { + if (str_contains($method, 'upsert') && ! ($database->getAdapter() instanceof Feature\Upserts)) { Console::warning(" Skipping {$opType} (not supported by adapter)"); + continue; } @@ -772,7 +799,7 @@ function benchmarkOperatorAcrossOperations( // Create documents for with-operator test $docIdsWith = []; for ($i = 0; $i < $docCount; $i++) { - $docId = 'bench_with_' . strtolower($operatorName) . '_' . strtolower($opType) . '_' . $i; + $docId = 'bench_with_'.strtolower($operatorName).'_'.strtolower($opType).'_'.$i; $docIdsWith[] = $docId; $database->createDocument('operators_test', new Document(array_merge(['$id' => $docId], $baseData))); } @@ -780,7 +807,7 @@ function benchmarkOperatorAcrossOperations( // Create documents for without-operator test $docIdsWithout = []; for ($i = 0; $i < $docCount; $i++) { - $docId = 'bench_without_' . strtolower($operatorName) . '_' . strtolower($opType) . '_' . $i; + $docId = 'bench_without_'.strtolower($operatorName).'_'.strtolower($opType).'_'.$i; $docIdsWithout[] = $docId; $database->createDocument('operators_test', new Document(array_merge(['$id' => $docId], $baseData))); } @@ -792,7 +819,7 @@ function benchmarkOperatorAcrossOperations( for ($i = 0; $i < $iterations; $i++) { if ($method === 'updateDocument') { $database->updateDocument('operators_test', $docIdsWith[0], new Document([ - $attribute => $operator + $attribute => $operator, ])); } elseif ($method === 'updateDocuments') { $updates = new Document([$attribute => $operator]); @@ -915,24 +942,21 @@ function benchmarkOperatorAcrossOperations( function displayResults(array $results, string $adapter, int $iterations, int $seed): void { Console::info("\n============================================================="); - Console::info(" BENCHMARK RESULTS"); - Console::info("============================================================="); + Console::info(' BENCHMARK RESULTS'); + Console::info('============================================================='); Console::info("Adapter: {$adapter}"); Console::info("Iterations per test: {$iterations}"); Console::info("Seeded documents: {$seed}"); Console::info("=============================================================\n"); - // ================================================================== - // OPERATION TYPE RESULTS - // ================================================================== Console::info("=== OPERATION PERFORMANCE (Overhead Check) ===\n"); Console::info("This section verifies NO OVERHEAD for non-operator operations:\n"); $opTypes = ['UPDATE_SINGLE', 'UPDATE_BULK', 'UPSERT_SINGLE', 'UPSERT_BULK']; foreach ($opTypes as $opType) { - $noOpsKey = $opType . '_NO_OPS'; - $withOpsKey = $opType . '_WITH_OPS'; + $noOpsKey = $opType.'_NO_OPS'; + $withOpsKey = $opType.'_WITH_OPS'; if (isset($results[$noOpsKey]) && isset($results[$withOpsKey])) { $noOps = $results[$noOpsKey]; @@ -941,10 +965,10 @@ function displayResults(array $results, string $adapter, int $iterations, int $s $timeNoOps = number_format($noOps['time'], 4); $timeWithOps = number_format($withOps['time'], 4); - Console::info(str_pad($opType, 20) . ":"); + Console::info(str_pad($opType, 20).':'); Console::info(" NO operators: {$timeNoOps}s"); Console::info(" WITH operators: {$timeWithOps}s"); - Console::info(""); + Console::info(''); } } @@ -990,7 +1014,7 @@ function displayResults(array $results, string $adapter, int $iterations, int $s Console::info("\n{$categoryName} Operators:"); foreach ($operators as $operatorName) { - if (!isset($results[$operatorName])) { + if (! isset($results[$operatorName])) { continue; } @@ -998,8 +1022,9 @@ function displayResults(array $results, string $adapter, int $iterations, int $s Console::info("\n {$operatorName}:"); - if (!isset($result['operations'])) { - Console::warning(" No results (benchmark failed)"); + if (! isset($result['operations'])) { + Console::warning(' No results (benchmark failed)'); + continue; } @@ -1040,14 +1065,14 @@ function displayResults(array $results, string $adapter, int $iterations, int $s // Summary statistics $avgSpeedup = $totalCount > 0 ? $totalSpeedup / $totalCount : 0; - Console::info("\n" . str_repeat('=', array_sum($colWidths) + 5)); - Console::info("SUMMARY:"); + Console::info("\n".str_repeat('=', array_sum($colWidths) + 5)); + Console::info('SUMMARY:'); Console::info(" Total operators tested: {$totalCount}"); - Console::info(" Average speedup: " . number_format($avgSpeedup, 2) . "x"); + Console::info(' Average speedup: '.number_format($avgSpeedup, 2).'x'); // Performance insights - Console::info("\n" . str_repeat('=', array_sum($colWidths) + 5)); - Console::info("PERFORMANCE INSIGHTS:"); + Console::info("\n".str_repeat('=', array_sum($colWidths) + 5)); + Console::info('PERFORMANCE INSIGHTS:'); // Flatten results for fastest/slowest calculation $flattenedResults = []; @@ -1063,25 +1088,23 @@ function displayResults(array $results, string $adapter, int $iterations, int $s } } - if (!empty($flattenedResults)) { + if (! empty($flattenedResults)) { $fastest = array_reduce( $flattenedResults, - fn ($carry, $item) => - $carry === null || $item['speedup'] > $carry['speedup'] ? $item : $carry + fn ($carry, $item) => $carry === null || $item['speedup'] > $carry['speedup'] ? $item : $carry ); $slowest = array_reduce( $flattenedResults, - fn ($carry, $item) => - $carry === null || $item['speedup'] < $carry['speedup'] ? $item : $carry + fn ($carry, $item) => $carry === null || $item['speedup'] < $carry['speedup'] ? $item : $carry ); if ($fastest) { - Console::success(" Fastest: {$fastest['operator']} ({$fastest['operation']}) - " . number_format($fastest['speedup'], 2) . "x speedup"); + Console::success(" Fastest: {$fastest['operator']} ({$fastest['operation']}) - ".number_format($fastest['speedup'], 2).'x speedup'); } if ($slowest) { - Console::warning(" Slowest: {$slowest['operator']} ({$slowest['operation']}) - " . number_format($slowest['speedup'], 2) . "x speedup"); + Console::warning(" Slowest: {$slowest['operator']} ({$slowest['operation']}) - ".number_format($slowest['speedup'], 2).'x speedup'); } } @@ -1104,7 +1127,7 @@ function formatBytes(int $bytes): string $power = floor(log($bytes, 1024)); $power = min($power, count($units) - 1); - return $sign . round($bytes / pow(1024, $power), 2) . ' ' . $units[$power]; + return $sign.round($bytes / pow(1024, $power), 2).' '.$units[$power]; } /** @@ -1112,14 +1135,14 @@ function formatBytes(int $bytes): string */ function cleanup(Database $database, string $name): void { - Console::info("Cleaning up test environment..."); + Console::info('Cleaning up test environment...'); try { if ($database->exists($name)) { $database->delete($name); } - Console::success("Cleanup complete."); + Console::success('Cleanup complete.'); } catch (\Throwable $e) { - Console::warning("Cleanup failed: " . $e->getMessage()); + Console::warning('Cleanup failed: '.$e->getMessage()); } } diff --git a/bin/tasks/query.php b/bin/tasks/query.php index d6c9987142..8889e6c59a 100644 --- a/bin/tasks/query.php +++ b/bin/tasks/query.php @@ -1,10 +1,5 @@ task('query') ->desc('Query mock data') @@ -38,15 +40,13 @@ for ($i = 0; $i < $count; $i++) { $authorization->addRole($faker->numerify('user####')); } + return \count($authorization->getRoles()); }; $namespace = '_ns'; $cache = new Cache(new NoCache()); - // ------------------------------------------------------------------ - // Adapter configuration - // ------------------------------------------------------------------ $dbAdapters = [ 'mariadb' => [ 'host' => 'mariadb', @@ -55,7 +55,7 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MariaDB::class, - 'pdoAttr' => MariaDB::getPDOAttributes(), + 'pdoAttr' => PDO_ATTRIBUTES, ], 'mysql' => [ 'host' => 'mysql', @@ -64,7 +64,7 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MySQL::class, - 'pdoAttr' => MySQL::getPDOAttributes(), + 'pdoAttr' => PDO_ATTRIBUTES, ], 'postgres' => [ 'host' => 'postgres', @@ -73,12 +73,13 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", 'adapter' => Postgres::class, - 'pdoAttr' => Postgres::getPDOAttributes(), + 'pdoAttr' => PDO_ATTRIBUTES, ], ]; - if (!isset($dbAdapters[$adapter])) { + if (! isset($dbAdapters[$adapter])) { Console::error("Adapter '{$adapter}' not supported"); + return; } @@ -91,7 +92,10 @@ $cfg['pdoAttr'] ); - $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + $database = new Database(new ($cfg['adapter'])($pdo), $cache); + $database + ->addHook(new Permissions()) + ->addHook(new Relationships()) ->setDatabase($name) ->setNamespace($namespace) ->setSharedTables($sharedTables); @@ -104,38 +108,38 @@ Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, - 'results' => runQueries($database, $limit) + 'results' => runQueries($database, $limit), ]; $count = $setRoles($database->getAuthorization(), $faker, 100); Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, - 'results' => runQueries($database, $limit) + 'results' => runQueries($database, $limit), ]; $count = $setRoles($database->getAuthorization(), $faker, 400); Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, - 'results' => runQueries($database, $limit) + 'results' => runQueries($database, $limit), ]; $count = $setRoles($database->getAuthorization(), $faker, 500); Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, - 'results' => runQueries($database, $limit) + 'results' => runQueries($database, $limit), ]; $count = $setRoles($database->getAuthorization(), $faker, 1000); Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, - 'results' => runQueries($database, $limit) + 'results' => runQueries($database, $limit), ]; - if (!file_exists('bin/view/results')) { + if (! file_exists('bin/view/results')) { \mkdir('bin/view/results', 0777, true); } @@ -145,40 +149,39 @@ \fclose($results); }); - function runQueries(Database $database, int $limit): array { $results = []; // Recent travel blogs - $results["Querying greater than, equal[1] and limit"] = runQuery([ + $results['Querying greater than, equal[1] and limit'] = runQuery([ Query::greaterThan('created', '2010-01-01 05:00:00'), Query::equal('genre', ['travel']), - Query::limit($limit) + Query::limit($limit), ], $database); // Favorite genres - $results["Querying equal[3] and limit"] = runQuery([ + $results['Querying equal[3] and limit'] = runQuery([ Query::equal('genre', ['fashion', 'finance', 'sports']), - Query::limit($limit) + Query::limit($limit), ], $database); // Popular posts $results["Querying greaterThan, limit({$limit})"] = runQuery([ Query::greaterThan('views', 100000), - Query::limit($limit) + Query::limit($limit), ], $database); // Fulltext search $results["Query search, limit({$limit})"] = runQuery([ Query::search('text', 'Alice'), - Query::limit($limit) + Query::limit($limit), ], $database); // Tags contain query $results["Querying contains[1], limit({$limit})"] = runQuery([ Query::contains('tags', ['tag1']), - Query::limit($limit) + Query::limit($limit), ], $database); return $results; @@ -187,13 +190,14 @@ function runQueries(Database $database, int $limit): array function runQuery(array $query, Database $database) { $info = array_map(function (Query $q) { - return $q->getAttribute() . ': ' . $q->getMethod() . ' = ' . implode(',', $q->getValues()); + return $q->getAttribute().': '.$q->getMethod()->value.' = '.implode(',', $q->getValues()); }, $query); - Console::info("Running query: [" . implode(', ', $info) . "]"); + Console::info('Running query: ['.implode(', ', $info).']'); $start = microtime(true); $database->find('articles', $query); $time = microtime(true) - $start; Console::success("Query executed in {$time} seconds"); + return $time; } diff --git a/bin/tasks/relationships.php b/bin/tasks/relationships.php index 790845b9cd..84836e4916 100644 --- a/bin/tasks/relationships.php +++ b/bin/tasks/relationships.php @@ -13,13 +13,20 @@ use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Hook\Permissions; +use Utopia\Database\Hook\Relationships; +use Utopia\Database\IntegerWidth; use Utopia\Database\PDO; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\Role; use Utopia\Validator\Boolean; use Utopia\Validator\Integer; use Utopia\Validator\Text; @@ -33,13 +40,12 @@ * @Example * docker compose exec tests bin/relationships --adapter=mariadb --limit=1000 */ - $cli ->task('relationships') ->desc('Load database with mock relationships for testing') ->param('adapter', '', new Text(0), 'Database adapter') ->param('limit', 0, new Integer(true), 'Total number of records to add to database') - ->param('name', 'myapp_' . uniqid(), new Text(0), 'Name of created database.', true) + ->param('name', 'myapp_'.uniqid(), new Text(0), 'Name of created database.', true) ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) ->param('runs', 1, new Integer(true), 'Number of times to run benchmarks', true) ->action(function (string $adapter, int $limit, string $name, bool $sharedTables, int $runs) { @@ -55,67 +61,67 @@ } $database->getAuthorization()->addRole(Role::any()->toString()); $database->create(); - $database->createCollection('authors', permissions: [ + $database->createCollection(Collection::create(id: 'authors', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); - $database->createAttribute('authors', 'name', Database::VAR_STRING, 256, true); - $database->createAttribute('authors', 'created', Database::VAR_DATETIME, 0, true, filters: ['datetime']); - $database->createAttribute('authors', 'bio', Database::VAR_STRING, 5000, true); - $database->createAttribute('authors', 'avatar', Database::VAR_STRING, 256, true); - $database->createAttribute('authors', 'website', Database::VAR_STRING, 256, true); - - $database->createCollection('articles', permissions: [ + ])); + $database->createAttribute('authors', Attribute::string(key: 'name', size: 256, required: true)); + $database->createAttribute('authors', Attribute::datetime(key: 'created', required: true)); + $database->createAttribute('authors', Attribute::string(key: 'bio', size: 5000, required: true)); + $database->createAttribute('authors', Attribute::string(key: 'avatar', size: 256, required: true)); + $database->createAttribute('authors', Attribute::string(key: 'website', size: 256, required: true)); + + $database->createCollection(Collection::create(id: 'articles', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); - $database->createAttribute('articles', 'title', Database::VAR_STRING, 256, true); - $database->createAttribute('articles', 'text', Database::VAR_STRING, 5000, true); - $database->createAttribute('articles', 'genre', Database::VAR_STRING, 256, true); - $database->createAttribute('articles', 'views', Database::VAR_INTEGER, 0, true); - $database->createAttribute('articles', 'tags', Database::VAR_STRING, 0, true, array: true); - - $database->createCollection('users', permissions: [ + ])); + $database->createAttribute('articles', Attribute::string(key: 'title', size: 256, required: true)); + $database->createAttribute('articles', Attribute::string(key: 'text', size: 5000, required: true)); + $database->createAttribute('articles', Attribute::string(key: 'genre', size: 256, required: true)); + $database->createAttribute('articles', Attribute::integer(key: 'views', required: true)); + $database->createAttribute('articles', Attribute::string(key: 'tags', size: 0, required: true, array: true)); + + $database->createCollection(Collection::create(id: 'users', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); - $database->createAttribute('users', 'username', Database::VAR_STRING, 256, true); - $database->createAttribute('users', 'email', Database::VAR_STRING, 256, true); - $database->createAttribute('users', 'password', Database::VAR_STRING, 256, true); + ])); + $database->createAttribute('users', Attribute::string(key: 'username', size: 256, required: true)); + $database->createAttribute('users', Attribute::string(key: 'email', size: 256, required: true)); + $database->createAttribute('users', Attribute::string(key: 'password', size: 256, required: true)); - $database->createCollection('comments', permissions: [ + $database->createCollection(Collection::create(id: 'comments', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); - $database->createAttribute('comments', 'content', Database::VAR_STRING, 256, true); - $database->createAttribute('comments', 'likes', Database::VAR_INTEGER, 8, true, signed: false); + ])); + $database->createAttribute('comments', Attribute::string(key: 'content', size: 256, required: true)); + $database->createAttribute('comments', Attribute::integer(key: 'likes', width: IntegerWidth::Bits64, required: true, signed: false)); - $database->createCollection('profiles', permissions: [ + $database->createCollection(Collection::create(id: 'profiles', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); - $database->createAttribute('profiles', 'bio_extended', Database::VAR_STRING, 10000, true); - $database->createAttribute('profiles', 'social_links', Database::VAR_STRING, 256, true, array: true); - $database->createAttribute('profiles', 'verified', Database::VAR_BOOLEAN, 0, true); + ])); + $database->createAttribute('profiles', Attribute::string(key: 'bio_extended', size: 10000, required: true)); + $database->createAttribute('profiles', Attribute::string(key: 'social_links', size: 256, required: true, array: true)); + $database->createAttribute('profiles', Attribute::boolean(key: 'verified', required: true)); - $database->createCollection('categories', permissions: [ + $database->createCollection(Collection::create(id: 'categories', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); - $database->createAttribute('categories', 'name', Database::VAR_STRING, 256, true); - $database->createAttribute('categories', 'description', Database::VAR_STRING, 1000, true); - - $database->createRelationship('authors', 'articles', Database::RELATION_MANY_TO_MANY, true, onDelete: Database::RELATION_MUTATE_SET_NULL); - $database->createRelationship('articles', 'comments', Database::RELATION_ONE_TO_MANY, true, twoWayKey: 'article', onDelete: Database::RELATION_MUTATE_CASCADE); - $database->createRelationship('users', 'comments', Database::RELATION_ONE_TO_MANY, true, twoWayKey: 'user', onDelete: Database::RELATION_MUTATE_CASCADE); - $database->createRelationship('authors', 'profiles', Database::RELATION_ONE_TO_ONE, true, twoWayKey: 'author', onDelete: Database::RELATION_MUTATE_CASCADE); - $database->createRelationship('articles', 'categories', Database::RELATION_MANY_TO_ONE, true, id: 'category', twoWayKey: 'articles', onDelete: Database::RELATION_MUTATE_SET_NULL); + ])); + $database->createAttribute('categories', Attribute::string(key: 'name', size: 256, required: true)); + $database->createAttribute('categories', Attribute::string(key: 'description', size: 1000, required: true)); + + $database->createRelationship('authors', Relationship::manyToMany(relatedCollection: 'articles', twoWay: true, onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('articles', Relationship::oneToMany(relatedCollection: 'comments', twoWay: true, twoWayKey: 'article', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship('users', Relationship::oneToMany(relatedCollection: 'comments', twoWay: true, twoWayKey: 'user', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship('authors', Relationship::oneToOne(relatedCollection: 'profiles', twoWay: true, twoWayKey: 'author', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship('articles', Relationship::manyToOne(relatedCollection: 'categories', twoWay: true, key: 'category', twoWayKey: 'articles', onDelete: RelationshipDeleteAction::SetNull)); }; $dbAdapters = [ @@ -126,7 +132,7 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MariaDB::class, - 'attrs' => MariaDB::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], 'mysql' => [ 'host' => 'mysql', @@ -135,7 +141,7 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", 'adapter' => MySQL::class, - 'attrs' => MySQL::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], 'postgres' => [ 'host' => 'postgres', @@ -144,12 +150,13 @@ 'pass' => 'password', 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", 'adapter' => Postgres::class, - 'attrs' => Postgres::getPDOAttributes(), + 'attrs' => PDO_ATTRIBUTES, ], ]; - if (!isset($dbAdapters[$adapter])) { + if (! isset($dbAdapters[$adapter])) { Console::error("Adapter '{$adapter}' not supported"); + return; } @@ -162,7 +169,10 @@ $cfg['attrs'] ); - $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + $database = new Database(new ($cfg['adapter'])($pdo), $cache); + $database + ->addHook(new Permissions()) + ->addHook(new Relationships()) ->setDatabase($name) ->setNamespace($namespace) ->setSharedTables($sharedTables); @@ -192,7 +202,10 @@ try { $pdo = $pool->get(); - $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + $database = new Database(new ($cfg['adapter'])($pdo), $cache); + $database + ->addHook(new Permissions()) + ->addHook(new Relationships()) ->setDatabase($name) ->setNamespace($namespace) ->setSharedTables($sharedTables); @@ -234,20 +247,19 @@ displayBenchmarkResults($results, $runs); }); - function createGlobalDocuments(Database $database, int $limit): array { global $genresPool, $namesPool; // Scale categories based on limit (minimum 9, scales up to 100 max) - $numCategories = min(100, max(9, (int)($limit / 10000))); + $numCategories = min(100, max(9, (int) ($limit / 10000))); $categoryDocs = []; for ($i = 0; $i < $numCategories; $i++) { $genre = $genresPool[$i % count($genresPool)]; $categoryDocs[] = new Document([ - '$id' => 'category_' . \uniqid(), - 'name' => \ucfirst($genre) . ($i >= count($genresPool) ? ' ' . ($i + 1) : ''), - 'description' => 'Articles about ' . $genre, + '$id' => 'category_'.\uniqid(), + 'name' => \ucfirst($genre).($i >= count($genresPool) ? ' '.($i + 1) : ''), + 'description' => 'Articles about '.$genre, ]); } @@ -255,13 +267,13 @@ function createGlobalDocuments(Database $database, int $limit): array $database->createDocuments('categories', $categoryDocs); // Scale users based on limit (10% of total documents) - $numUsers = max(1000, (int)($limit / 10)); + $numUsers = max(1000, (int) ($limit / 10)); $userDocs = []; for ($u = 0; $u < $numUsers; $u++) { $userDocs[] = new Document([ - '$id' => 'user_' . \uniqid(), - 'username' => $namesPool[\array_rand($namesPool)] . '_' . $u, - 'email' => 'user' . $u . '@example.com', + '$id' => 'user_'.\uniqid(), + 'username' => $namesPool[\array_rand($namesPool)].'_'.$u, + 'email' => 'user'.$u.'@example.com', 'password' => \bin2hex(\random_bytes(8)), ]); } @@ -291,18 +303,18 @@ function createRelationshipDocuments(Database $database, array $categories, arra 'name' => $namesPool[array_rand($namesPool)], 'created' => DateTime::now(), 'bio' => \substr(\bin2hex(\random_bytes(32)), 0, 100), - 'avatar' => 'https://example.com/avatar/' . $a, - 'website' => 'https://example.com/user/' . $a, + 'avatar' => 'https://example.com/avatar/'.$a, + 'website' => 'https://example.com/user/'.$a, ]); // Create profile for author (one-to-one relationship) $profile = new Document([ 'bio_extended' => \substr(\bin2hex(\random_bytes(128)), 0, 500), 'social_links' => [ - 'https://twitter.com/author' . $a, - 'https://linkedin.com/in/author' . $a, + 'https://twitter.com/author'.$a, + 'https://linkedin.com/in/author'.$a, ], - 'verified' => (bool)\mt_rand(0, 1), + 'verified' => (bool) \mt_rand(0, 1), ]); $author->setAttribute('profiles', $profile); @@ -310,7 +322,7 @@ function createRelationshipDocuments(Database $database, array $categories, arra $authorArticles = []; for ($i = 0; $i < $numArticlesPerAuthor; $i++) { $article = new Document([ - 'title' => 'Article ' . ($i + 1) . ' by ' . $author->getAttribute('name'), + 'title' => 'Article '.($i + 1).' by '.$author->getAttribute('name'), 'text' => \substr(\bin2hex(\random_bytes(64)), 0, \mt_rand(100, 200)), 'genre' => $genresPool[array_rand($genresPool)], 'views' => \mt_rand(0, 1000), @@ -322,7 +334,7 @@ function createRelationshipDocuments(Database $database, array $categories, arra $comments = []; for ($c = 0; $c < $numCommentsPerArticle; $c++) { $comment = new Document([ - 'content' => 'Comment ' . ($c + 1), + 'content' => 'Comment '.($c + 1), 'likes' => \mt_rand(0, 10000), 'user' => $users[\array_rand($users)], ]); @@ -463,36 +475,36 @@ function benchmarkPagination(Database $database): array function displayRelationshipStructure(): void { Console::success("\n========================================"); - Console::success("Relationship Structure"); + Console::success('Relationship Structure'); Console::success("========================================\n"); - Console::info("Collections:"); - Console::log(" • authors (name, created, bio, avatar, website)"); - Console::log(" • articles (title, text, genre, views, tags[])"); - Console::log(" • comments (content, likes)"); - Console::log(" • users (username, email, password)"); - Console::log(" • profiles (bio_extended, social_links[], verified)"); - Console::log(" • categories (name, description)"); - Console::log(""); - - Console::info("Relationships:"); - Console::log(" ┌─────────────────────────────────────────────────────────────┐"); - Console::log(" │ authors ◄─────────────► articles (Many-to-Many) │"); - Console::log(" │ └─► profiles (One-to-One) │"); - Console::log(" │ │"); - Console::log(" │ articles ─────────────► comments (One-to-Many) │"); - Console::log(" │ └─► categories (Many-to-One) │"); - Console::log(" │ │"); - Console::log(" │ users ────────────────► comments (One-to-Many) │"); - Console::log(" └─────────────────────────────────────────────────────────────┘"); - Console::log(""); - - Console::info("Relationship Coverage:"); - Console::log(" ✓ One-to-One: authors ◄─► profiles"); - Console::log(" ✓ One-to-Many: articles ─► comments, users ─► comments"); - Console::log(" ✓ Many-to-One: articles ─► categories"); - Console::log(" ✓ Many-to-Many: authors ◄─► articles"); - Console::log(""); + Console::info('Collections:'); + Console::log(' • authors (name, created, bio, avatar, website)'); + Console::log(' • articles (title, text, genre, views, tags[])'); + Console::log(' • comments (content, likes)'); + Console::log(' • users (username, email, password)'); + Console::log(' • profiles (bio_extended, social_links[], verified)'); + Console::log(' • categories (name, description)'); + Console::log(''); + + Console::info('Relationships:'); + Console::log(' ┌─────────────────────────────────────────────────────────────┐'); + Console::log(' │ authors ◄─────────────► articles (Many-to-Many) │'); + Console::log(' │ └─► profiles (One-to-One) │'); + Console::log(' │ │'); + Console::log(' │ articles ─────────────► comments (One-to-Many) │'); + Console::log(' │ └─► categories (Many-to-One) │'); + Console::log(' │ │'); + Console::log(' │ users ────────────────► comments (One-to-Many) │'); + Console::log(' └─────────────────────────────────────────────────────────────┘'); + Console::log(''); + + Console::info('Relationship Coverage:'); + Console::log(' ✓ One-to-One: authors ◄─► profiles'); + Console::log(' ✓ One-to-Many: articles ─► comments, users ─► comments'); + Console::log(' ✓ Many-to-One: articles ─► categories'); + Console::log(' ✓ Many-to-Many: authors ◄─► articles'); + Console::log(''); } /** @@ -524,7 +536,7 @@ function displayBenchmarkResults(array $results, int $runs): void } Console::success("\n========================================"); - Console::success("Benchmark Results (Average of {$runs} run" . ($runs > 1 ? 's' : '') . ")"); + Console::success("Benchmark Results (Average of {$runs} run".($runs > 1 ? 's' : '').')'); Console::success("========================================\n"); // Calculate column widths @@ -532,19 +544,19 @@ function displayBenchmarkResults(array $results, int $runs): void $timeWidth = 12; // Print header - $header = str_pad('Collection', $collectionWidth) . ' | '; + $header = str_pad('Collection', $collectionWidth).' | '; foreach ($benchmarkLabels as $label) { - $header .= str_pad($label, $timeWidth) . ' | '; + $header .= str_pad($label, $timeWidth).' | '; } Console::info($header); Console::info(str_repeat('-', strlen($header))); // Print results for each collection foreach ($collections as $collection) { - $row = str_pad(ucfirst($collection), $collectionWidth) . ' | '; + $row = str_pad(ucfirst($collection), $collectionWidth).' | '; foreach ($benchmarks as $benchmark) { $time = number_format($averages[$benchmark][$collection] * 1000, 2); // Convert to ms - $row .= str_pad($time . ' ms', $timeWidth) . ' | '; + $row .= str_pad($time.' ms', $timeWidth).' | '; } Console::log($row); } diff --git a/bin/view/index.php b/bin/view/index.php index 4afb1e6775..57091f586e 100644 --- a/bin/view/index.php +++ b/bin/view/index.php @@ -38,12 +38,12 @@ const results = $path, - 'data' => \json_decode(\file_get_contents("{$directory}/{$path}"), true) + 'data' => \json_decode(\file_get_contents("{$directory}/{$path}"), true), ]; } diff --git a/composer.json b/composer.json index db8e13e7d4..f143757626 100755 --- a/composer.json +++ b/composer.json @@ -2,11 +2,20 @@ "name": "utopia-php/database", "description": "A simple library to manage application persistence using multiple database adapters", "type": "library", - "keywords": ["php","framework", "upf", "utopia", "database"], + "keywords": [ + "php", + "framework", + "upf", + "utopia", + "database" + ], "license": "MIT", "minimum-stability": "stable", + "prefer-stable": true, "autoload": { - "psr-4": {"Utopia\\Database\\": "src/Database"} + "psr-4": { + "Utopia\\Database\\": "src/Database" + } }, "autoload-dev": { "psr-4": { @@ -25,12 +34,14 @@ ], "test": [ "Composer\\Config::disableProcessTimeout", - "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml" + "docker compose exec tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4" ], "lint": "php -d memory_limit=2G ./vendor/bin/pint --test", "format": "php -d memory_limit=2G ./vendor/bin/pint", - "check": "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G", - "coverage": "./vendor/bin/coverage-check ./tmp/clover.xml 90" + "check": [ + "./vendor/bin/phpstan analyse --memory-limit 2G", + "./vendor/bin/phpstan analyse -c phpstan-bin.neon --memory-limit 2G" + ] }, "require": { "php": ">=8.5", @@ -41,29 +52,36 @@ "utopia-php/validators": "^1.0", "utopia-php/console": "^0.1 || ^0.2", "utopia-php/cache": "^4.0 || ^5.0", - "utopia-php/pools": "2.*", - "utopia-php/mongo": "1.*" + "utopia-php/pools": "^2.0", + "utopia-php/mongo": "^1.5.4", + "utopia-php/query": "^0.7", + "utopia-php/async": "^0.2" }, "require-dev": { - "fakerphp/faker": "1.23.*", - "phpunit/phpunit": "9.*", - "pcov/clobber": "2.*", + "fakerphp/faker": "^1.23", + "phpunit/phpunit": "^12.5", + "brianium/paratest": "^7.20", "swoole/ide-helper": "5.1.3", "utopia-php/cli": "^0.24.4", - "laravel/pint": "*", - "phpstan/phpstan": "1.*", - "rregeer/phpunit-coverage-check": "0.3.*" + "laravel/pint": "^1.29", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0" }, - "suggests": { + "suggest": { "ext-redis": "Needed to support Redis Cache Adapter", - "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", - "mongodb/mongodb": "Needed to support MongoDB Database Adapter" - + "ext-swoole": "Needed to scope lifecycle hook silencing per coroutine and to detect lost connections", + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter" }, "config": { "allow-plugins": { "php-http/discovery": false, "tbachert/spi": false } + }, + "extra": { + "branch-alias": { + "dev-feat-query-lib": "8.0.x-dev", + "dev-main": "8.0.x-dev" + } } } diff --git a/composer.lock b/composer.lock index 29e572447e..31552c252e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,26 +4,27 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a9f496f37199aeff8159e3ee3b9e0f2", + "content-hash": "0a7734fcb3b96b8ea7fb86fdb7897aec", "packages": [ { "name": "brick/math", - "version": "0.18.0", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { + "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "2.1.22", "phpunit/phpunit": "^11.5" }, @@ -55,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -63,7 +64,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "composer/semver", @@ -144,23 +145,23 @@ }, { "name": "google/protobuf", - "version": "v5.35.1", + "version": "v4.33.6", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb" + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/55bb4a7d6739b5af0927b96213c1371a3afb7cfb", - "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", "shasum": "" }, "require": { - "php": ">=8.2.0" + "php": ">=8.1.0" }, "require-dev": { - "phpunit/phpunit": ">=11.5.0 <12.0.0" + "phpunit/phpunit": ">=10.5.62 <11.0.0" }, "suggest": { "ext-bcmath": "Need to support JSON deserialization" @@ -182,86 +183,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.35.1" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" }, - "time": "2026-06-11T21:19:23+00:00" - }, - { - "name": "mongodb/mongodb", - "version": "2.1.2", - "source": { - "type": "git", - "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", - "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2.0", - "ext-mongodb": "^2.1", - "php": "^8.1", - "psr/log": "^1.1.4|^2|^3", - "symfony/polyfill-php85": "^1.32" - }, - "replace": { - "mongodb/builder": "*" - }, - "require-dev": { - "doctrine/coding-standard": "^12.0", - "phpunit/phpunit": "^10.5.35", - "rector/rector": "^2.1.4", - "squizlabs/php_codesniffer": "^3.7", - "vimeo/psalm": "6.5.*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "MongoDB\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Andreas Braun", - "email": "andreas.braun@mongodb.com" - }, - { - "name": "Jeremy Mikola", - "email": "jmikola@gmail.com" - }, - { - "name": "Jérôme Tamarelle", - "email": "jerome.tamarelle@mongodb.com" - } - ], - "description": "MongoDB driver library", - "homepage": "https://jira.mongodb.org/browse/PHPLIB", - "keywords": [ - "database", - "driver", - "mongodb", - "persistence" - ], - "support": { - "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" - }, - "time": "2025-10-06T12:12:40+00:00" + "time": "2026-03-18T17:32:05+00:00" }, { "name": "nyholm/psr7", @@ -409,16 +333,16 @@ }, { "name": "open-telemetry/api", - "version": "1.10.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", - "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", "shasum": "" }, "require": { @@ -475,7 +399,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-07-06T12:28:04+00:00" + "time": "2026-02-25T13:24:05+00:00" }, { "name": "open-telemetry/context", @@ -602,20 +526,20 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.10.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "66f04d0e448ad333033bfc7baae1aa56330be088" + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66f04d0e448ad333033bfc7baae1aa56330be088", - "reference": "66f04d0e448ad333033bfc7baae1aa56330be088", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", "shasum": "" }, "require": { - "google/protobuf": "^3.22 || ^4.0 || ^5.0", + "google/protobuf": "^3.22 || ^4.0", "php": "^8.0" }, "suggest": { @@ -661,20 +585,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-06-17T12:06:32+00:00" + "time": "2025-10-19T06:44:33+00:00" }, { "name": "open-telemetry/sdk", - "version": "1.15.0", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "77e1aa73850154abb86937d52a70883edc3b4547" + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/77e1aa73850154abb86937d52a70883edc3b4547", - "reference": "77e1aa73850154abb86937d52a70883edc3b4547", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", "shasum": "" }, "require": { @@ -682,7 +606,7 @@ "nyholm/psr7-server": "^1.1", "open-telemetry/api": "^1.8", "open-telemetry/context": "^1.4", - "open-telemetry/sem-conv": "^1.38.0", + "open-telemetry/sem-conv": "^1.0", "php": "^8.1", "php-http/discovery": "^1.14", "psr/http-client": "^1.0", @@ -705,10 +629,7 @@ "spi": { "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", - "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig", - "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySemConv", - "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySpanKind", - "OpenTelemetry\\SDK\\ConfigEnv\\Distribution\\DistributionConfigurationSdk" + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig" ], "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" @@ -718,7 +639,7 @@ ] }, "branch-alias": { - "dev-main": "1.14.x-dev" + "dev-main": "1.12.x-dev" } }, "autoload": { @@ -761,7 +682,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-07-14T13:09:54+00:00" + "time": "2026-03-21T11:50:01+00:00" }, { "name": "open-telemetry/sem-conv", @@ -820,6 +741,71 @@ }, "time": "2026-01-21T04:14:03+00:00" }, + { + "name": "opis/closure", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/opis/closure.git", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/closure/zipball/b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Opis\\Closure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary data.", + "homepage": "https://opis.io/closure", + "keywords": [ + "anonymous classes", + "anonymous functions", + "closure", + "function", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/opis/closure/issues", + "source": "https://github.com/opis/closure/tree/4.5.0" + }, + "time": "2026-03-05T13:32:42+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -1240,20 +1226,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1312,22 +1298,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -1340,7 +1326,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1365,7 +1351,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -1376,29 +1362,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.16", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/c513ed0ba5d1784a6b55fc84190dbe4451b12f41", - "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -1466,7 +1448,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.16" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -1486,20 +1468,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T16:20:51+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "75d7043853a42837e68111812f4d964b01e5101c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", "shasum": "" }, "require": { @@ -1512,7 +1494,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1548,7 +1530,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" }, "funding": [ { @@ -1559,29 +1541,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2025-04-29T11:18:49+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -1633,7 +1611,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -1653,20 +1631,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.38.1", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", - "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", "shasum": "" }, "require": { @@ -1713,7 +1691,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" }, "funding": [ { @@ -1733,20 +1711,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:45:58+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.41.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -1793,87 +1771,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-07-01T12:47:55+00:00" - }, - { - "name": "symfony/polyfill-php85", - "version": "v1.41.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -1893,20 +1791,20 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -1924,7 +1822,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1960,7 +1858,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -1980,7 +1878,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "tbachert/spi", @@ -2034,24 +1932,86 @@ }, "time": "2025-06-29T15:42:06+00:00" }, + { + "name": "utopia-php/async", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/async.git", + "reference": "c7925a2ad7cf0fceebb3f93200c95a269126f692" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/async/zipball/c7925a2ad7cf0fceebb3f93200c95a269126f692", + "reference": "c7925a2ad7cf0fceebb3f93200c95a269126f692", + "shasum": "" + }, + "require": { + "opis/closure": "4.*", + "php": ">=8.1" + }, + "require-dev": { + "amphp/amp": "3.*", + "amphp/parallel": "2.*", + "amphp/process": "^2.0", + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.5.45", + "react/child-process": "0.*", + "react/event-loop": "1.*", + "swoole/ide-helper": "*" + }, + "suggest": { + "amphp/amp": "Required for Amp promise adapter", + "amphp/parallel": "Required for Amp parallel adapter", + "ext-ev": "Required for ReactPHP event loop (recommended for best performance)", + "ext-parallel": "Required for parallel adapter (requires PHP ZTS build)", + "ext-sockets": "Required for Swoole Process adapter", + "ext-swoole": "Required for Swoole Thread and Process adapters (recommended for best performance)", + "react/child-process": "Required for ReactPHP parallel adapter", + "react/event-loop": "Required for ReactPHP promise and parallel adapters" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Async\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Appwrite Team", + "email": "team@appwrite.io" + } + ], + "description": "High-performance concurrent + parallel library with Promise and Parallel execution support for PHP.", + "support": { + "issues": "https://github.com/utopia-php/async/issues", + "source": "https://github.com/utopia-php/async/tree/0.2.0" + }, + "time": "2026-09-29T14:07:22+00:00" + }, { "name": "utopia-php/cache", - "version": "5.0.0", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "0d0752785fc81b5afd6571f4291763166a6532bc" + "reference": "92e02dab63606234b993b841ebf4c58845dd4620" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/0d0752785fc81b5afd6571f4291763166a6532bc", - "reference": "0d0752785fc81b5afd6571f4291763166a6532bc", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/92e02dab63606234b993b841ebf4c58845dd4620", + "reference": "92e02dab63606234b993b841ebf4c58845dd4620", "shasum": "" }, "require": { "ext-json": "*", "php": ">=8.4", - "utopia-php/circuit-breaker": "^0.4", + "utopia-php/circuit-breaker": "^0.3", "utopia-php/pools": "^2.0", "utopia-php/telemetry": "^0.4" }, @@ -2089,22 +2049,22 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/5.0.0" + "source": "https://github.com/utopia-php/cache/tree/4.0.2" }, - "time": "2026-08-21T11:04:48+00:00" + "time": "2026-08-12T07:48:59+00:00" }, { "name": "utopia-php/circuit-breaker", - "version": "0.4.0", + "version": "0.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/circuit-breaker.git", - "reference": "c6d93c7ba9d895cf906360e000f74ff762d83e17" + "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/c6d93c7ba9d895cf906360e000f74ff762d83e17", - "reference": "c6d93c7ba9d895cf906360e000f74ff762d83e17", + "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/5fbc3802471b0d1b4260bd9f5544514e6929b481", + "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481", "shasum": "" }, "require": { @@ -2148,9 +2108,9 @@ ], "support": { "issues": "https://github.com/utopia-php/circuit-breaker/issues", - "source": "https://github.com/utopia-php/circuit-breaker/tree/0.4.0" + "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.2" }, - "time": "2026-08-21T10:20:24+00:00" + "time": "2026-08-05T18:07:20+00:00" }, { "name": "utopia-php/console", @@ -2202,21 +2162,20 @@ }, { "name": "utopia-php/mongo", - "version": "1.5.3", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "be29ee2d84b9f7efdfc6fea5b4b2d4bf95ff5ec4" + "reference": "34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/be29ee2d84b9f7efdfc6fea5b4b2d4bf95ff5ec4", - "reference": "be29ee2d84b9f7efdfc6fea5b4b2d4bf95ff5ec4", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88", + "reference": "34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88", "shasum": "" }, "require": { - "ext-mongodb": "2.1.*", - "mongodb/mongodb": "2.1.*", + "ext-mongodb": "^2.1.0", "php": ">=8.0", "ramsey/uuid": "4.9.*" }, @@ -2257,9 +2216,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.5.3" + "source": "https://github.com/utopia-php/mongo/tree/1.5.4" }, - "time": "2026-08-13T01:55:11+00:00" + "time": "2026-09-09T05:42:37+00:00" }, { "name": "utopia-php/pools", @@ -2317,6 +2276,50 @@ }, "time": "2026-08-05T18:07:20+00:00" }, + { + "name": "utopia-php/query", + "version": "0.7.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/query.git", + "reference": "16a09301c2508e6bbaf87a84f8ef70ac1810d47d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/query/zipball/16a09301c2508e6bbaf87a84f8ef70ac1810d47d", + "reference": "16a09301c2508e6bbaf87a84f8ef70ac1810d47d", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "mongodb/mongodb": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Query\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library providing a query abstraction for filtering, ordering, and pagination", + "keywords": [ + "framework", + "php", + "query", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/query/issues", + "source": "https://github.com/utopia-php/query/tree/0.7.0" + }, + "time": "2026-10-07T03:52:19+00:00" + }, { "name": "utopia-php/telemetry", "version": "0.4.6", @@ -2411,35 +2414,56 @@ ], "packages-dev": [ { - "name": "doctrine/instantiator", - "version": "2.1.0", + "name": "brianium/paratest", + "version": "v7.20.0", "source": { "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + "url": "https://github.com/paratestphp/paratest.git", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { - "php": "^8.4" + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { - "doctrine/coding-standard": "^14", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.58" + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], "type": "library", "autoload": { "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + "ParaTest\\": [ + "src/" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -2448,36 +2472,39 @@ ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" } ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", "keywords": [ - "constructor", - "instantiate" + "concurrent", + "parallel", + "phpunit", + "testing" ], "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" } ], - "time": "2026-01-05T06:47:08+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "fakerphp/faker", @@ -2543,37 +2570,156 @@ "time": "2024-01-02T13:46:09+00:00" }, { - "name": "laravel/pint", - "version": "v1.30.4", + "name": "fidry/cpu-core-counter", + "version": "1.3.0", "source": { "type": "git", - "url": "https://github.com/laravel/pint.git", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28" + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "ext-tokenizer": "*", - "ext-xml": "*", - "php": "^8.2.0" + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" }, "require-dev": { - "composer/semver": "^3.4.4", - "friendsofphp/php-cs-fixer": "^3.95.18", - "illuminate/view": "^12.65.0", - "larastan/larastan": "^3.10.0", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", "laravel-zero/framework": "^12.1.0", - "laravel/agent-detector": "^2.0.2", - "laravel/prompts": "^0.3.22", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.7" + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -2610,7 +2756,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-08-05T16:47:22+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "myclabs/deep-copy", @@ -2674,30 +2820,37 @@ }, { "name": "nikic/php-parser", - "version": "v4.19.5", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", - "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" ], "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, "autoload": { "psr-4": { "PhpParser\\": "lib/PhpParser" @@ -2719,43 +2872,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.5" - }, - "time": "2025-12-06T11:45:25+00:00" - }, - { - "name": "pcov/clobber", - "version": "v2.0.3", - "source": { - "type": "git", - "url": "https://github.com/krakjoe/pcov-clobber.git", - "reference": "4c30759e912e6e5d5bf833fb3d77b5bd51709f05" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/krakjoe/pcov-clobber/zipball/4c30759e912e6e5d5bf833fb3d77b5bd51709f05", - "reference": "4c30759e912e6e5d5bf833fb3d77b5bd51709f05", - "shasum": "" - }, - "require": { - "ext-pcov": "^1.0", - "nikic/php-parser": "^4.2" - }, - "bin": [ - "bin/pcov" - ], - "type": "library", - "autoload": { - "psr-4": { - "pcov\\Clobber\\": "src/pcov/clobber" - } + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "notification-url": "https://packagist.org/downloads/", - "support": { - "issues": "https://github.com/krakjoe/pcov-clobber/issues", - "source": "https://github.com/krakjoe/pcov-clobber/tree/v2.0.3" - }, - "time": "2019-10-29T05:03:37+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -2877,15 +2996,15 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.34", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4dd89ca7aa30fdc6760be21550d583bcc32e8476", - "reference": "4dd89ca7aa30fdc6760be21550d583bcc32e8476", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -2926,39 +3045,93 @@ "type": "github" } ], - "time": "2026-07-28T10:04:39+00:00" + "time": "2026-04-29T13:31:09+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.32", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -2967,7 +3140,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -2996,40 +3169,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2024-08-22T04:23:01+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3056,36 +3241,49 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -3093,7 +3291,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3119,7 +3317,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -3127,32 +3326,32 @@ "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -3178,7 +3377,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -3186,32 +3386,32 @@ "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -3237,7 +3437,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -3245,54 +3446,49 @@ "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.35", + "version": "12.5.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", - "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", - "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", + "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.10", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.8", - "sebastian/global-state": "^5.0.8", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.6", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.0", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, "bin": [ "phpunit" @@ -3300,7 +3496,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -3332,7 +3528,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" }, "funding": [ { @@ -3340,78 +3536,32 @@ "type": "other" } ], - "time": "2026-07-06T14:48:07+00:00" - }, - { - "name": "rregeer/phpunit-coverage-check", - "version": "0.3.1", - "source": { - "type": "git", - "url": "https://github.com/richardregeer/phpunit-coverage-check.git", - "reference": "9618fa74477fbc448c1b0599bef5153d170094bd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/richardregeer/phpunit-coverage-check/zipball/9618fa74477fbc448c1b0599bef5153d170094bd", - "reference": "9618fa74477fbc448c1b0599bef5153d170094bd", - "shasum": "" - }, - "require": { - "php": ">=7.0.0" - }, - "bin": [ - "bin/coverage-check" - ], - "type": "library", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Richard Regeer", - "email": "rich2309@gmail.com" - } - ], - "description": "Check the code coverage using the clover report of phpunit", - "keywords": [ - "ci", - "code coverage", - "php", - "phpunit", - "testing", - "unittest" - ], - "support": { - "issues": "https://github.com/richardregeer/phpunit-coverage-check/issues", - "source": "https://github.com/richardregeer/phpunit-coverage-check/tree/0.3.1" - }, - "time": "2019-10-14T07:04:13+00:00" + "time": "2026-04-18T06:12:49+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.2", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -3434,40 +3584,60 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2024-03-02T06:27:43+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { - "name": "sebastian/code-unit", - "version": "1.0.8", + "name": "sebastian/comparator", + "version": "7.1.6", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -3482,190 +3652,78 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" } ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "name": "sebastian/complexity", + "version": "5.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-09-28T05:30:19+00:00" - }, - { - "name": "sebastian/comparator", - "version": "4.0.10", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", - "shasum": "" - }, - "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - } - ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", - "type": "tidelift" - } - ], - "time": "2026-01-24T09:22:56+00:00" - }, - { - "name": "sebastian/complexity", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -3688,7 +3746,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { @@ -3696,33 +3755,33 @@ "type": "github" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "4.0.6", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -3754,7 +3813,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" }, "funding": [ { @@ -3762,27 +3822,27 @@ "type": "github" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2025-02-07T04:55:46+00:00" }, { "name": "sebastian/environment", - "version": "5.1.5", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-posix": "*" @@ -3790,7 +3850,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -3809,7 +3869,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -3817,42 +3877,55 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.8", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -3894,7 +3967,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" }, "funding": [ { @@ -3914,38 +3988,35 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:03:27+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.8", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -3964,13 +4035,14 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" }, "funding": [ { @@ -3990,33 +4062,33 @@ "type": "tidelift" } ], - "time": "2025-08-10T07:10:35+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.4", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -4039,7 +4111,8 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" }, "funding": [ { @@ -4047,34 +4120,34 @@ "type": "github" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2025-02-07T04:57:28+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -4096,7 +4169,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -4104,32 +4178,32 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -4151,7 +4225,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { @@ -4159,32 +4234,32 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.6", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -4214,7 +4289,8 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { @@ -4234,32 +4310,32 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:57:39+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { - "name": "sebastian/resource-operations", - "version": "3.0.4", + "name": "sebastian/type", + "version": "6.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -4274,46 +4350,58 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-03-14T16:00:52+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { - "name": "sebastian/type", - "version": "3.2.1", + "name": "sebastian/version", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -4332,11 +4420,12 @@ "role": "lead" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -4344,60 +4433,59 @@ "type": "github" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { - "name": "sebastian/version", - "version": "3.0.2", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, + "type": "library", "autoload": { "classmap": [ - "src/" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { "name": "swoole/ide-helper", @@ -4432,24 +4520,519 @@ "time": "2024-06-17T05:45:20+00:00" }, { - "name": "theseer/tokenizer", - "version": "1.3.1", + "name": "symfony/console", + "version": "v8.0.8", "source": { "type": "git", - "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + "url": "https://github.com/symfony/console.git", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:13:48+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" }, "type": "library", "autoload": { @@ -4471,7 +5054,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -4479,7 +5062,7 @@ "type": "github" } ], - "time": "2025-11-17T20:03:58+00:00" + "time": "2025-12-08T11:19:18+00:00" }, { "name": "utopia-php/cli", @@ -4629,7 +5212,7 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": {}, - "prefer-stable": false, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": ">=8.5", diff --git a/docker-compose.yml b/docker-compose.yml index bbd6976e5f..10b3c036c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: image: databases-dev build: context: . + dockerfile: Dockerfile args: DEBUG: true networks: @@ -19,29 +20,11 @@ services: - ./docker-compose.yml:/usr/src/code/docker-compose.yml environment: PHP_IDE_CONFIG: serverName=tests - depends_on: - postgres: - condition: service_healthy - postgres-mirror: - condition: service_healthy - mariadb: - condition: service_healthy - mariadb-mirror: - condition: service_healthy - mysql: - condition: service_healthy - mysql-mirror: - condition: service_healthy - redis: - condition: service_healthy - redis-mirror: - condition: service_healthy - mongo: - condition: service_healthy adminer: image: adminer container_name: utopia-adminer + profiles: [debug] restart: always ports: - "8700:8080" @@ -55,6 +38,7 @@ services: args: POSTGRES_VERSION: 16 container_name: utopia-postgres + profiles: [postgres] networks: - database ports: @@ -77,6 +61,7 @@ services: args: POSTGRES_VERSION: 16 container_name: utopia-postgres-mirror + profiles: [postgres-mirror] networks: - database ports: @@ -95,6 +80,7 @@ services: mariadb: image: mariadb:10.11 container_name: utopia-mariadb + profiles: [mariadb] command: mariadbd --max_allowed_packet=1G networks: - database @@ -112,6 +98,7 @@ services: mariadb-mirror: image: mariadb:10.11 container_name: utopia-mariadb-mirror + profiles: [mariadb-mirror] command: mariadbd --max_allowed_packet=1G networks: - database @@ -129,6 +116,7 @@ services: mongo: image: mongo:8.0.14 container_name: utopia-mongo + profiles: [mongo] entrypoint: ["/entrypoint.sh"] networks: - database @@ -161,6 +149,7 @@ services: mongo-express: image: mongo-express container_name: mongo-express + profiles: [debug] depends_on: mongo: condition: service_healthy @@ -176,6 +165,7 @@ services: mysql: image: mysql:8.0.43 container_name: utopia-mysql + profiles: [mysql] networks: - database ports: @@ -198,6 +188,7 @@ services: mysql-mirror: image: mysql:8.0.43 container_name: utopia-mysql-mirror + profiles: [mysql-mirror] networks: - database ports: @@ -220,6 +211,7 @@ services: redis: image: redis:8.2.1-alpine3.22 container_name: utopia-redis + restart: always ports: - "8708:6379" networks: @@ -234,6 +226,8 @@ services: redis-mirror: image: redis:8.2.1-alpine3.22 container_name: utopia-redis-mirror + profiles: [redis-mirror] + restart: always ports: - "8709:6379" networks: diff --git a/docs/add-new-adapter.md b/docs/add-new-adapter.md index 4205cd9b8e..389bb47eb1 100644 --- a/docs/add-new-adapter.md +++ b/docs/add-new-adapter.md @@ -1,53 +1,137 @@ # Add new Database Adapter -To get started with implementing a new adapter, start by reviewing the [specification](/SPEC.md) to understand the goals of this library. The specification defines the NoSQL-inspired API methods and contains all of the functions a new adapter must support, including types, queries, paging, indexes, and especially emojis ❤️.. The capabilities of each adapter are defined in the `getSupportFor*` and `get*Limit` methods. +To get started with implementing a new adapter, start by reviewing the [specification](/SPEC.md) to understand the goals of this library. The specification defines the NoSQL-inspired API methods and contains all of the functions a new adapter must support, including types, queries, paging, indexes, and especially emojis ❤️. + +An adapter describes what it supports in four ways: + +- **Mandatory methods.** The abstract methods of `Utopia\Database\Adapter` are the whole contract; every adapter implements them. They cover databases, collections, attributes, indexes, documents, transactions, schema introspection, limits and the driver. +- **Optional features.** A group of methods an adapter may or may not provide is an interface in `src/Database/Adapter/Feature/`. Implement the ones your database supports. Callers check them with `$adapter->hasFeature(Feature\Upserts::class)`. +- **Capabilities.** A behaviour flag is a `Utopia\Database\Capability` case. Override `capabilities()` and return the cases your adapter supports (start from `parent::capabilities()`); callers check them with `$adapter->supports(Capability::IndexFulltext)`. +- **Limits.** `limits()` returns one `Adapter\Limits` value with every size and count the library validates against. + +### Mandatory methods + +| Group | Abstract methods on `Adapter` | +|---|---| +| Databases | `create(string $name)`, `update(string $name, string $new)`, `exists(string $database)`, `list()`, `delete(string $name)` | +| Collections | `createCollection(string $collection, array $attributes = [], array $indexes = [])`, `collectionExists(string $database, string $collection)`, `deleteCollection(string $collection)`, `analyzeCollection(string $collection)`, `getSizeOfCollection()`, `getSizeOfCollectionOnDisk()` | +| Attributes | `createAttribute(string $collection, Attribute $attribute)`, `createAttributes(string $collection, array $attributes)`, `updateAttribute(string $collection, string $key, Attribute $attribute)`, `renameAttribute()`, `deleteAttribute(string $collection, string $key)`, `getColumnType(Attribute $attribute): ?string`, `getAttributeWidth()`, `getCountOfAttributes()` | +| Indexes | `createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = [])`, `renameIndex()`, `deleteIndex(string $collection, string $key)`, `getCountOfIndexes()` | +| Introspection | `getSchemaAttributes(string $collection): array` (`list`), `getSchemaIndexes(string $collection): array` (`list`) | +| Documents | `getDocument()`, `createDocument()`, `createDocuments()`, `updateDocument()`, `updateDocuments()`, `increaseDocumentAttribute()`, `deleteDocument()`, `deleteDocuments()`, `getSequences()`, `find()`, `count()`, `sum()`. Every one takes the collection `Document` as its first argument | +| Transactions | `startTransaction()`, `commitTransaction()`, `rollbackTransaction()` | +| Other | `limits(): Adapter\Limits`, `getDriver(): object` | + +`attributes` and `indexes` are lists of the `Attribute` and `Index` value objects; read their typed properties (`$attribute->type`, `$index->orders`). + +An engine without schema introspection returns `[]` from `getSchemaAttributes()` and `getSchemaIndexes()`, and `null` from `getColumnType()`, and does not declare `Capability::SchemaIntrospection`. An engine that can read its columns and indexes back declares it: the library then compares the schema with the metadata to adopt or replace orphan columns and indexes. Report each column's canonical native type, the same spelling `getColumnType()` returns for an attribute. + +`update()` renames a database. Refuse it under shared tables (`hasSharedTables()`), and roll back a rename that fails part-way. + +### Limits + +```php +public function limits(): Limits +{ + return new Limits( + string: 1_073_741_824, + varchar: 16_381, + integer: 4_294_967_295, + bigInteger: Database::MAX_BIG_INT, + attributes: 1_017, + indexes: 64, + defaultAttributes: \count(Database::INTERNAL_ATTRIBUTE_KEYS), + defaultIndexes: \count(Database::INTERNAL_INDEXES), + indexLength: 768, + uidLength: 255, + documentSize: 65_535, + minDateTime: new \DateTime('0000-01-01'), + maxDateTime: new \DateTime('9999-12-31'), + idType: ColumnType::Integer, + keywords: [], + internalIndexKeys: [], + ); +} +``` + +A limit of 0 means none. `idType` is the type of the `$sequence` column. `keywords` are names the engine reserves, which attribute keys may not use. + +### Optional features + +| Interface | Implement it when | +|---|---| +| `Feature\Casting` (`castBefore()`, `castAfter()` over a page of documents, `castDatetime()`) | The engine returns native PHP types. Without it the library casts every value read | +| `Feature\Connection` (`ping()`, `reconnect()`, `id()`, `hostname()`) | The adapter holds a connection | +| `Feature\Timeouts` (`setTimeout()`, `clearTimeout()`, `getTimeout()`) | The engine can bound a statement's run time. The `Adapter\Timeout` trait keeps the per-event state | +| `Feature\Relationships` (`createRelationship()`, `updateRelationship()`, `deleteRelationship()`) | The adapter stores relationship attributes and junctions | +| `Feature\Upserts` (`upsertDocument(Document $collection, Change $change)`, `upsertDocuments(Document $collection, array $changes, ?string $increase = null)`) | The engine can insert or update in one statement | +| `Feature\Spatial` (`encode()`, `decode()`) | The engine stores point, linestring and polygon attributes | +| `Feature\Schemaless` (`setSchemaless()`, `isSchemaless()`) | The engine can store attributes the collection does not declare | +| `Feature\QueryBuilder` (`builder()`, `schema()`), `Feature\RawQuery` (`rawQuery()`, `rawMutation()`) | The engine runs utopia-php/query builder statements or raw statements | + +`Adapter\Pool` forwards every optional feature to the adapter it borrows without implementing the interface, so the library always asks `hasFeature()`, never `instanceof`. ### File Structure -Below are outlined the most useful files for adding a new database adapter: +Below are outlined the most useful files for adding a new database adapter: ```bash . ├── src # Source code -│   └── Database -│   ├── Adapter/ # Where your new adapter goes! -│   ├── Adapter.php # Parent class for individual adapters -│   ├── Database.php # Database class - calls individual adapter methods -│   ├── Document.php # Document class - -│   └── Query.php # Query class - holds query attributes, methods, and values +│ └── Database +│ ├── Adapter/ # Where your new adapter goes! +│ │ ├── Feature/ # Optional feature interfaces +│ │ ├── Limits.php # The adapter's limits +│ │ ├── SQL.php # Shared base of the SQL adapters +│ │ └── SQL/Hook/ # Permission, tenant and join hooks of the SQL adapters +│ ├── Adapter.php # Parent class and mandatory contract +│ ├── Attribute.php # Attribute value object +│ ├── Capability.php # Behaviour flags an adapter reports +│ ├── Database.php # Database class - calls individual adapter methods +│ ├── Document.php # Document class +│ ├── Hook/ # Permission, tenancy and relationship hooks +│ ├── Index.php # Index value object +│ └── Query.php # Query class - holds query attributes, methods, and values └── tests - └── Database - ├── Adapter/ # Extended from Base - └── Base.php # Parent class that holds all tests + ├── e2e + │ └── Adapter/ # One test class per adapter, extending Base.php + │ ├── Base.php # Parent class that pulls in the Scopes test traits + │ └── Scopes/ # The shared tests every adapter runs + └── unit/ ``` - ### Extend the Adapter -Create your `NewDB.php` file in `src/Database/Adapter/` and extend the parent class: +Create your `NewDB.php` file in `src/Database/Adapter/` and extend the parent class, implementing the optional feature interfaces your database supports: ```php scope($this->scope());`. Override `qualifyTable()` if your tables are stored under other names. + Only include dependencies strictly necessary for the database, preferably official PHP libraries, if available. -### Testing with Docker +### Testing with Docker -The existing test suite is helpful when developing a new database adapter. To get started with testing, add a stanza to `docker-compose.yml` with your new database, using existing adapters as examples. Use official Docker images from trusted sources, and provide the necessary `environment` variables for startup. Then, create a new file for your NewDB in `tests/Database/Adapter`, extending the `Base.php` test class. The specific `docker-compose` command for testing can be found in the [README](/README.md#tests). +The existing test suite is helpful when developing a new database adapter. To get started with testing, add a stanza to `docker-compose.yml` with your new database, using existing adapters as examples. Use official Docker images from trusted sources, and provide the necessary `environment` variables for startup. Then, create `tests/e2e/Adapter/NewDBTest.php` extending the `Base.php` test class and implement `getDatabase()`. The tests that do not apply to your adapter skip themselves by its capabilities and features. The `docker compose` commands for testing are in [CONTRIBUTING.md](/CONTRIBUTING.md#tests). ### Tips and Tricks @@ -56,13 +140,15 @@ The existing test suite is helpful when developing a new database adapter. To ge - Create indexes for `$id` and `$permissions` fields by default for query performance. - Filter new IDs with `$this->filter($id);` - Prioritize code performance. -- The Query and Queries validators contain the information about which queries the adapters support. -- The [Authorization validator](/src/Database/Validator/Authorization.php) is used to check permissions for searching methods `find()` and `sort()`. Ensure these methods only return documents with the correct `read` permissions. -- The `Database` class has useful constants like types and definitions. Prefer these constants when comparing strings. +- Report only the capabilities your adapter honours: `Database` and the query validators refuse queries that need a capability the adapter does not report (for example joins without `Capability::Joins`). +- `find()`, `count()` and `sum()` receive the permission type to check; only return documents the caller's roles may read. The SQL adapters filter with the hooks in `src/Database/Adapter/SQL/Hook` (`Permission\Filter`, `Tenant\Filter`), MongoDB with `Hook\Mongo\Permission` and `Hook\Mongo\Tenant`. +- Compare types, index types and orders with the enums (`Utopia\Query\Schema\ColumnType`, `IndexType`, `Utopia\Query\OrderDirection`, ...), not strings. +- Declare `Capability::TransactionNested` only when a failed nested transaction rolls back to its savepoint and leaves the enclosing transaction open. #### SQL Databases - Treat Collections as tables and Documents as rows, with attributes as columns. NoSQL databases are more straight-forward to translate. -- For row-level permissions, create a pair of tables: one for data and one for permissions. The MariaDB adapter demonstrates the implementation. +- For row-level permissions, create a pair of tables: one for data and one for permissions (`{collection}_perms`). `Hook\Permissions`, registered on the `Database`, writes the permission rows through the adapter's `Hook\WriteContext`. The MariaDB adapter demonstrates the implementation. #### NoSQL Databases -- NoSQL databases may not need to implement the attribute functions. See the MongoDB adapter as an example. \ No newline at end of file +- NoSQL databases may not need to implement the attribute functions. See the MongoDB adapter as an example: it implements `Feature\Schemaless`, which `Database::setSchemaless()` switches, and reports `Capability::DefinedAttributes` only while it enforces the collection's attributes. +- An engine that returns native types implements `Feature\Casting`, as MongoDB does; the library then leaves value casting to the adapter. diff --git a/phpstan-bin.neon b/phpstan-bin.neon new file mode 100644 index 0000000000..34c274f312 --- /dev/null +++ b/phpstan-bin.neon @@ -0,0 +1,20 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + level: 5 + checkMissingOverrideMethodAttribute: true + paths: + - bin + scanFiles: + - stubs/Swoole/Database/DetectsLostConnections.stub.php + - stubs/Swoole/Database/PDOConfig.stub.php + - stubs/Swoole/Database/PDOPool.stub.php + - stubs/Swoole/Database/PDOProxy.stub.php + - stubs/Swoole/Database/PDOStatementProxy.stub.php + stubFiles: + - stubs/Swoole/Database/DetectsLostConnections.stub.php + - stubs/Swoole/Database/PDOConfig.stub.php + - stubs/Swoole/Database/PDOPool.stub.php + - stubs/Swoole/Database/PDOProxy.stub.php + - stubs/Swoole/Database/PDOStatementProxy.stub.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000000..061931ca36 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,28 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + level: max + checkMissingOverrideMethodAttribute: true + paths: + - src + - tests + excludePaths: + - tests/unit/Adapter/Data + - tests/unit/PHPStan/Data + scanFiles: + - stubs/Swoole/Database/DetectsLostConnections.stub.php + - stubs/Swoole/Database/PDOProxy.stub.php + - stubs/Swoole/Database/PDOStatementProxy.stub.php + stubFiles: + - stubs/Swoole/Database/DetectsLostConnections.stub.php + - stubs/Swoole/Database/PDOProxy.stub.php + - stubs/Swoole/Database/PDOStatementProxy.stub.php + +services: + - + class: Tests\Unit\PHPStan\MagicPropertyFetchRule + arguments: + sourceDirectory: %rootDir%/../../../src + tags: + - phpstan.rules.rule diff --git a/phpunit.xml b/phpunit.xml index 2a0531cfd0..a5ce7eb2c7 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,22 @@ - @@ -17,4 +26,10 @@ ./tests/e2e/Adapter - \ No newline at end of file + + + ./src + ./tests + + + diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4d2f0ee38f..769bba3ead 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -3,49 +3,60 @@ namespace Utopia\Database; use Exception; +use Throwable; +use Utopia\Database\Adapter\Limits; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Authorization as AuthorizationException; -use Utopia\Database\Exception\Conflict as ConflictException; +use Utopia\Database\Exception\Contention as ContentionException; use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Exception\Limit as LimitException; -use Utopia\Database\Exception\Relationship as RelationshipException; -use Utopia\Database\Exception\Restricted as RestrictedException; +use Utopia\Database\Exception\Index as IndexException; +use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; +use Utopia\Database\Hook\Transform; +use Utopia\Database\Hook\Write; +use Utopia\Database\State\Value; use Utopia\Database\Validator\Authorization; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\Schema\IndexType; +/** + * Abstract base class for all database adapters, providing shared state management and a contract for database operations. + */ abstract class Adapter { + protected const string MAX_DATETIME = '9999-12-31 23:59:59'; + protected string $database = ''; + protected string $hostname = ''; protected string $namespace = ''; protected bool $sharedTables = false; - protected int|string|null $tenant = null; + /** @var Value|null */ + private ?Value $scopedTenant = null; - protected bool $tenantPerDocument = false; + /** @var Value|null */ + private ?Value $ignoringDuplicates = null; - protected int $timeout = 0; + protected bool $tenantPerDocument = false; protected int $inTransaction = 0; - protected bool $alterLocks = false; - - protected bool $skipDuplicates = false; - /** - * @var array + * withTransaction() calls in progress on this adapter. The outermost one owns the retries. */ - protected array $debug = []; + private int $transactionCalls = 0; + + protected bool $locks = false; /** - * @var array> + * @var array */ - protected array $transformations = [ - '*' => [], - ]; + protected array $transforms = []; /** * @var array @@ -53,13 +64,58 @@ abstract class Adapter protected array $metadata = []; /** - * @var Authorization + * @var list */ + protected array $writeHooks = []; + + protected ?Profiler $profiler = null; + protected Authorization $authorization; + /** @var array|null */ + protected ?array $capabilities = null; + + protected ?Limits $limits = null; + + public function supports(Capability $capability): bool + { + if ($this->capabilities === null) { + $this->capabilities = []; + foreach ($this->capabilities() as $declared) { + $this->capabilities[$declared->name] = true; + } + } + + return isset($this->capabilities[$capability->name]); + } + + /** + * Whether this adapter offers the optional methods of a Feature interface. A proxy such as Pool answers for + * the adapter it delegates to without implementing the interface itself, so callers ask this rather than + * use instanceof. + * + * @param class-string $feature + */ + public function hasFeature(string $feature): bool + { + return $this instanceof $feature; + } + /** - * @param Authorization $authorization + * Get the list of capabilities this adapter supports. * + * @return array + */ + public function capabilities(): array + { + return [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + ]; + } + + /** * @return $this */ public function setAuthorization(Authorization $authorization): self @@ -69,51 +125,52 @@ public function setAuthorization(Authorization $authorization): self return $this; } + /** + * Get the authorization instance used for permission checks. + * + * @return Authorization The current authorization instance. + */ public function getAuthorization(): Authorization { return $this->authorization; } - /** - * @param string $key - * @param mixed $value - * - * @return $this - */ - public function setDebug(string $key, mixed $value): static + + public function setProfiler(?Profiler $profiler): static { - $this->debug[$key] = $value; + $this->profiler = $profiler; return $this; } - /** - * @return array - */ - public function getDebug(): array + public function getProfiler(): ?Profiler { - return $this->debug; + return $this->profiler; } /** - * @return static + * @throws DatabaseException */ - public function resetDebug(): static + public function setDatabase(string $name): static { - $this->debug = []; + $this->database = $this->filter($name); return $this; } /** - * Set Namespace. - * + * Get Database from current scope + */ + public function getDatabase(): string + { + return $this->database; + } + + /** * Set namespace to divide different scope of data sets * - * @param string $namespace - * * @return $this - * @throws DatabaseException * + * @throws DatabaseException */ public function setNamespace(string $namespace): static { @@ -123,12 +180,7 @@ public function setNamespace(string $namespace): static } /** - * Get Namespace. - * * Get namespace of current set scope - * - * @return string - * */ public function getNamespace(): string { @@ -136,9 +188,6 @@ public function getNamespace(): string } /** - * Set Hostname. - * - * @param string $hostname * @return $this */ public function setHostname(string $hostname): static @@ -149,125 +198,98 @@ public function setHostname(string $hostname): static } /** - * Get Hostname. - * - * @return string + * Whether tenants share tables, told apart by the tenant column. */ - public function getHostname(): string + public function setSharedTables(bool $sharedTables): static { - return $this->hostname; + if ($this->sharedTables !== $sharedTables) { + $this->sharedTables = $sharedTables; + $this->limits = null; + } + + return $this; } - /** - * Set Database. - * - * Set database to use for current scope - * - * @param string $name - * - * @return bool - * @throws DatabaseException - */ - public function setDatabase(string $name): bool + public function hasSharedTables(): bool { - $this->database = $this->filter($name); - - return true; + return $this->sharedTables; } /** - * Get Database. - * - * Get Database from current scope - * - * @return string - * + * The tenant statements run as under shared tables. */ - public function getDatabase(): string + public function setTenant(int|string|null $tenant): static { - return $this->database; + $this->scopedTenant()->set($tenant); + + return $this; } /** - * Set Shared Tables. - * - * Set whether to share tables between tenants + * Get tenant to use for shared tables. * - * @param bool $sharedTables - * - * @return bool + * `_tenant` is an INT UNSIGNED column, so the engine reads "001" and "1" + * as the same tenant and returns both rows for either. Normalising every + * digit-only string mirrors that. Keeping them apart in PHP would be worse + * than the collapse: the scope comparison and the cache key would claim a + * distinction the rows do not have. */ - public function setSharedTables(bool $sharedTables): bool + public function getTenant(): int|string|null { - $this->sharedTables = $sharedTables; + $tenant = $this->currentTenant(); + if (\is_string($tenant) && \ctype_digit($tenant)) { + return (int) $tenant; + } - return true; + return $tenant; } /** - * Get Share Tables. + * Run the callback with the tenant set for the calling coroutine and the coroutines it starts. * - * Get whether to share tables between tenants + * @template T * - * @return bool + * @param callable(): T $callback + * @return T */ - public function getSharedTables(): bool + public function withTenant(int|string|null $tenant, callable $callback): mixed { - return $this->sharedTables; + return $this->scopedTenant()->with($tenant, $callback); } /** - * Set Tenant. - * - * Set tenant to use if tables are shared - * - * @param int|string|null $tenant - * - * @return bool + * The tenant the calling coroutine's statements run as, exactly as it was set. */ - public function setTenant(int|string|null $tenant): bool + protected function currentTenant(): int|string|null { - $this->tenant = $tenant; - - return true; + return $this->scopedTenant()->get(); } /** - * Get Tenant. - * - * Get tenant to use for shared tables - * - * @return int|string|null + * @return Value */ - public function getTenant(): int|string|null + private function scopedTenant(): Value { - return $this->tenant; + if ($this->scopedTenant === null) { + /** @var Value $scopedTenant */ + $scopedTenant = new Value(null); + $this->scopedTenant = $scopedTenant; + } + + return $this->scopedTenant; } /** - * Set Tenant Per Document. - * - * Set whether to use a different tenant for each document - * - * @param bool $tenantPerDocument - * - * @return bool + * Whether a document carries its own tenant instead of the adapter's. */ - public function setTenantPerDocument(bool $tenantPerDocument): bool + public function setTenantPerDocument(bool $tenantPerDocument): static { $this->tenantPerDocument = $tenantPerDocument; - return true; + return $this; } - /** - * Get Tenant Per Document. - * - * Get whether to use a different tenant for each document - * - * @return bool - */ - public function getTenantPerDocument(): bool + public function isTenantPerDocument(): bool { return $this->tenantPerDocument; } @@ -275,29 +297,16 @@ public function getTenantPerDocument(): bool /** * Set metadata for query comments * - * @param string $key - * @param mixed $value * @return $this */ public function setMetadata(string $key, mixed $value): static { $this->metadata[$key] = $value; - $output = ''; - foreach ($this->metadata as $key => $value) { - $output .= "/* {$key}: {$value} */\n"; - } - - $this->before(Database::EVENT_ALL, 'metadata', function ($query) use ($output) { - return $output . $query; - }); - return $this; } /** - * Get metadata - * * @return array */ public function getMetadata(): array @@ -305,73 +314,104 @@ public function getMetadata(): array return $this->metadata; } + public function resetMetadata(): void + { + $this->metadata = []; + } + + /** + * Whether ALTER TABLE statements take LOCK=SHARED, on the engines that support it. + */ + public function setLocks(bool $locks): static + { + $this->locks = $locks; + + return $this; + } + /** - * Clear existing metadata + * Register a write hook that intercepts document write operations. * + * @param Write $hook The write hook to add. * @return $this */ - public function resetMetadata(): static + public function addWriteHook(Write $hook): static { - $this->metadata = []; + $this->writeHooks[] = $hook; return $this; } /** - * Set a global timeout for database queries in milliseconds. - * - * This function allows you to set a maximum execution time for all database - * queries executed using the library, or a specific event specified by the - * event parameter. Once this timeout is set, any database query that takes - * longer than the specified time will be automatically terminated by the library, - * and an appropriate error or exception will be raised to handle the timeout condition. - * - * @param int $milliseconds The timeout value in milliseconds for database queries. - * @param string $event The event the timeout should fire for - * @return void + * @internal + */ + public function getTenantHook(): ?Hook\Tenancy + { + foreach ($this->writeHooks as $hook) { + if ($hook instanceof Hook\Tenancy) { + return $hook; + } + } + + return null; + } + + /** + * Remove a write hook, or every write hook of a class. * - * @throws Exception The provided timeout value must be greater than or equal to 0. + * @param Write|class-string $hook + * @return $this */ - abstract public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void; + public function removeWriteHook(Write|string $hook): static + { + $this->writeHooks = \array_values(\array_filter( + $this->writeHooks, + static fn (Write $registered): bool => \is_string($hook) ? ! $registered instanceof $hook : $registered !== $hook, + )); - public function getTimeout(): int + return $this; + } + + /** + * @internal + * + * @return list + */ + public function getWriteHooks(): array { - return $this->timeout; + return $this->writeHooks; } /** - * Clears a global timeout for database queries. + * Register a named query transform hook that modifies queries before execution. * - * @param string $event - * @return void + * @param string $name Unique name for the transform. + * @param Transform $transform The query transform hook to add. + * @return $this */ - public function clearTimeout(string $event): void + public function addTransform(string $name, Transform $transform): static { - // Clear existing callback - $this->before($event, 'timeout'); + $this->transforms[$name] = $transform; - // Adapters that apply the timeout from this property on every statement - // (e.g. Postgres SET statement_timeout) would otherwise keep enforcing a - // cleared timeout on all subsequent queries. - $this->timeout = 0; + return $this; } /** - * Clears every timeout this adapter carries, for any event. + * Remove a query transform hook by name. * - * A pooled connection outlives the handle that configured it, so the handle - * that takes it next has to be able to reset it without knowing which - * events the previous one set a timeout for. - * - * @return void + * @param string $name The name of the transform to remove. + * @return $this */ - public function clearTimeouts(): void + public function removeTransform(string $name): static { - foreach (\array_keys($this->transformations) as $event) { - $this->clearTimeout($event); - } + unset($this->transforms[$name]); + + return $this; + } - $this->timeout = 0; + public function resetTransforms(): void + { + $this->transforms = []; } /** @@ -379,7 +419,6 @@ public function clearTimeouts(): void * * If a transaction is already active, this will only increment the transaction count and return true. * - * @return bool * @throws DatabaseException */ abstract public function startTransaction(): bool; @@ -391,7 +430,6 @@ abstract public function startTransaction(): bool; * If there is more than one active transaction, this decrement the transaction count and return true. * If the transaction count is 1, it will be commited, the transaction count will be reset to 0, and return true. * - * @return bool * @throws DatabaseException */ abstract public function commitTransaction(): bool; @@ -402,15 +440,12 @@ abstract public function commitTransaction(): bool; * If no transaction is active, this will be a no-op and will return false. * If 1 or more transactions are active, this will roll back all transactions, reset the count to 0, and return true. * - * @return bool * @throws DatabaseException */ abstract public function rollbackTransaction(): bool; /** * Check if a transaction is active. - * - * @return bool */ public function inTransaction(): bool { @@ -418,487 +453,367 @@ public function inTransaction(): bool } /** - * Run a callback with skipDuplicates enabled. - * Duplicate key errors during createDocuments() will be silently skipped - * instead of thrown. Nestable — saves and restores previous state. + * Run the callback with createDocuments() skipping the documents whose id or unique key already exists instead + * of failing. Nestable, and scoped to the calling coroutine and the coroutines it starts. * * @template T - * @param callable(): T $callback + * + * @param callable(): T $callback * @return T */ - public function skipDuplicates(callable $callback): mixed + public function ignoreDuplicates(callable $callback): mixed { - $previous = $this->skipDuplicates; - $this->skipDuplicates = true; + return $this->ignoringDuplicates()->with(true, $callback); + } - try { - return $callback(); - } finally { - $this->skipDuplicates = $previous; - } + /** + * Whether the calling coroutine runs under ignoreDuplicates(). + */ + protected function isIgnoringDuplicates(): bool + { + return $this->ignoringDuplicates()->get(); } /** + * @return Value + */ + private function ignoringDuplicates(): Value + { + return $this->ignoringDuplicates ??= new Value(false); + } + + /** + * Run the callback in a transaction, retrying an attempt that failed transiently up to twice (see isRetryable()); + * any other failure is rethrown at once. Only the outermost call retries: a call nested in another + * withTransaction() rolls back to its savepoint and rethrows, and the outermost call runs the whole unit again, so + * the retries do not multiply. A call nested in a transaction begun with startTransaction() retries in its + * savepoint. A nested call whose enclosing transaction is gone throws `Exception\Transaction`, or the + * `Exception\Contention` that made the engine roll the transaction back, which the outermost call retries because + * nothing of that attempt is stored. A callback that returns after its transaction was lost underneath it fails + * the same way. The outermost call never leaves the connection holding what remains of a failed transaction. + * * @template T - * @param callable(): T $callback + * + * @param callable(): T $callback * @return T - * @throws \Throwable + * + * @throws Throwable */ public function withTransaction(callable $callback): mixed { $sleep = 50_000; // 50 milliseconds $retries = 2; + $depth = $this->inTransaction; + $enclosed = $this->transactionCalls > 0; + $outermost = $depth === 0 && ! $enclosed; + $this->transactionCalls++; - for ($attempts = 0; $attempts <= $retries; $attempts++) { - try { - $this->startTransaction(); - $result = $callback(); - $this->commitTransaction(); - return $result; - } catch (\Throwable $action) { - $rollback = null; + try { + for ($attempts = 0; $attempts <= $retries; $attempts++) { + $started = false; try { - $this->rollbackTransaction(); - } catch (\Throwable $rollbackError) { - // Not every adapter resets the depth counter when its - // rollback throws (e.g. Redis), so reset it here to avoid - // leaking transaction state onto the reused connection. - $rollback = $rollbackError; - $this->inTransaction = 0; - } + $this->startTransaction(); + $started = true; + $result = $callback(); + if ($this->inTransaction <= $depth) { + throw new TransactionException('Failed to commit transaction: the transaction was lost before the callback returned'); + } + $this->commitTransaction(); + + return $result; + } catch (Throwable $action) { + $rollback = null; + $lost = $started && $this->inTransaction <= $depth; + if (! $lost) { + try { + $this->rollbackTransaction(); + } catch (Throwable $rollbackError) { + $rollback = $rollbackError; + $this->inTransaction = 0; + } + + $lost = $this->inTransaction < $depth; + } - if ( - $action instanceof DuplicateException || - $action instanceof RestrictedException || - $action instanceof AuthorizationException || - $action instanceof RelationshipException || - $action instanceof ConflictException || - $action instanceof LimitException || - $action instanceof TimeoutException - ) { - throw $action; - } + if ($outermost) { + $this->abandonTransaction(); + } - if ($attempts < $retries) { - \usleep($sleep * ($attempts + 1)); - continue; - } + if ($lost) { + if (! $action instanceof ContentionException) { + throw new TransactionException('Failed to execute transaction: the transaction was lost before it could commit', previous: $action); + } - throw $rollback ?? $action; - } - } + if ($depth > 0) { + throw $action; + } + } - throw new TransactionException('Failed to execute transaction'); - } + if ($enclosed || ! $this->isRetryable($action)) { + throw $action; + } - /** - * Apply a transformation to a query before an event occurs - * - * @param string $event - * @param string $name - * @param ?callable $callback - * @return static - */ - public function before(string $event, string $name = '', ?callable $callback = null): static - { - if (!isset($this->transformations[$event])) { - $this->transformations[$event] = []; - } + if ($attempts < $retries) { + \usleep($sleep * ($attempts + 1)); - if (\is_null($callback)) { - unset($this->transformations[$event][$name]); - } else { - $this->transformations[$event][$name] = $callback; - } + continue; + } - return $this; - } + throw $rollback ?? $action; + } + } - protected function trigger(string $event, mixed $query): mixed - { - foreach ($this->transformations[Database::EVENT_ALL] as $callback) { - $query = $callback($query); - } - foreach (($this->transformations[$event] ?? []) as $callback) { - $query = $callback($query); + throw new TransactionException('Failed to execute transaction'); + } finally { + $this->transactionCalls--; } - - return $query; } /** - * Quote a string - * - * @param string $string - * @return string + * End what the connection still holds of a transaction the adapter no longer counts, such as one lost with the + * connection or left open by a failed rollback, so that the connection's next statement runs outside it. */ - abstract protected function quote(string $string): string; + protected function abandonTransaction(): void + { + } /** - * Ping Database - * - * @return bool + * Whether withTransaction() runs an attempt that failed with this again: it can succeed when it runs again. The + * transaction itself failing (a lock conflict, or a failed begin, commit or rollback) or a transient driver failure + * anywhere in the chain can; a typed failure of this library, or any other failure, would fail the same way again. */ - abstract public function ping(): bool; + public function isRetryable(Throwable $failure): bool + { + for ($cause = $failure; $cause !== null; $cause = $cause->getPrevious()) { + if ($cause instanceof TransactionException) { + return true; + } - /** - * Reconnect Database - */ - abstract public function reconnect(): void; + if ($cause instanceof DatabaseException && $cause::class !== DatabaseException::class) { + return false; + } + + if ($this->isTransient($cause)) { + return true; + } + } + + return false; + } /** - * Create Database - * - * @param string $name - * - * @return bool + * Whether the driver raised the error for a condition that can clear on its own, such as a lost connection. */ + protected function isTransient(Throwable $error): bool + { + return Connection::hasError($error); + } + abstract public function create(string $name): bool; /** - * Check if database exists - * Optionally check if collection exists in database - * - * @param string $database database name - * @param string|null $collection (optional) collection name + * Rename a database, moving every collection, document, index and permission it holds. * - * @return bool + * @throws NotFoundException when no database is named $name + * @throws DuplicateException when a database is already named $new + * @throws DatabaseException when the engine cannot move the database */ - abstract public function exists(string $database, ?string $collection = null): bool; + abstract public function update(string $name, string $new): bool; + + abstract public function exists(string $database): bool; + + abstract public function collectionExists(string $database, string $collection): bool; /** - * List Databases - * * @return array */ abstract public function list(): array; - /** - * Delete Database - * - * @param string $name - * - * @return bool - */ abstract public function delete(string $name): bool; /** - * Create Collection - * - * @param string $name - * @param array $attributes (optional) - * @param array $indexes (optional) - * @return bool + * @param list $attributes + * @param list $indexes */ - abstract public function createCollection(string $name, array $attributes = [], array $indexes = []): bool; + abstract public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool; - /** - * Delete Collection - * - * @param string $id - * - * @return bool - */ - abstract public function deleteCollection(string $id): bool; + abstract public function deleteCollection(string $collection): bool; /** * Analyze a collection updating its metadata on the database engine - * - * @param string $collection - * @return bool */ abstract public function analyzeCollection(string $collection): bool; /** - * Create Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @return bool * @throws TimeoutException * @throws DuplicateException */ - abstract public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool; + abstract public function createAttribute(string $collection, Attribute $attribute): bool; /** - * Create Attributes + * @param list $attributes * - * @param string $collection - * @param array> $attributes - * @return bool * @throws TimeoutException * @throws DuplicateException */ abstract public function createAttributes(string $collection, array $attributes): bool; /** - * Update Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param string|null $newKey - * @param bool $required - * - * @return bool + * Alter the column stored under $key to match $attribute, renaming it when $attribute->key differs. */ - abstract public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool; + abstract public function updateAttribute(string $collection, string $key, Attribute $attribute): bool; /** - * Delete Attribute + * Relax a column's null constraint when an attribute stops being required. * - * @param string $collection - * @param string $id - * - * @return bool + * Most engines carry required-ness in the structure validator rather than + * the column once it exists, so this does nothing by default. Postgres + * overrides it because it created the column NOT NULL and keeps that + * through every other alter. */ - abstract public function deleteAttribute(string $collection, string $id): bool; + public function relaxAttributeRequired(string $collection, string $id): bool + { + return true; + } + + abstract public function deleteAttribute(string $collection, string $key): bool; + + abstract public function renameAttribute(string $collection, string $old, string $new): bool; /** - * Rename Attribute + * The columns the engine holds for a collection. Empty where the adapter does not declare + * Capability::SchemaIntrospection, which is what tells "not introspectable" from "no columns". * - * @param string $collection - * @param string $old - * @param string $new - * @return bool + * @return list */ - abstract public function renameAttribute(string $collection, string $old, string $new): bool; + abstract public function getSchemaAttributes(string $collection): array; /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $id - * @param string $twoWayKey - * @return bool + * The indexes the engine holds for a collection. Empty where the adapter does not declare + * Capability::SchemaIntrospection. + * + * @return list */ - abstract public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool; + abstract public function getSchemaIndexes(string $collection): array; /** - * Update Relationship + * The type getSchemaIndexes() reports for an index created as $type: an engine that stores an index of one + * type as another reports the type it stores. * - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @param string|null $newKey - * @param string|null $newTwoWayKey - * @return bool + * @internal */ - abstract public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool; + public function getSchemaIndexType(IndexType $type): IndexType + { + return $type; + } /** - * Delete Relationship - * - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @return bool + * The native column type the adapter creates for an attribute, in the spelling of Schema\Column::$type; null where + * the engine has no column types. */ - abstract public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool; + abstract public function getColumnType(Attribute $attribute): ?string; /** - * Rename Index - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool + * @param array $indexAttributeTypes + * @param array $collation */ + abstract public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool; + + abstract public function deleteIndex(string $collection, string $key): bool; + abstract public function renameIndex(string $collection, string $old, string $new): bool; + abstract public function createDocument(Document $collection, Document $document): Document; + /** - * Create Index + * Create Documents in batches * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - * @param array $collation - * @param int $ttl + * @param array $documents + * @return array The documents written; under ignoreDuplicates() the skipped ones are left out * - * @return bool + * @throws DatabaseException */ - abstract public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool; + abstract public function createDocuments(Document $collection, array $documents): array; /** - * Delete Index - * - * @param string $collection - * @param string $id - * - * @return bool - */ - abstract public function deleteIndex(string $collection, string $id): bool; - - /** - * Get Document - * - * @param Document $collection - * @param string $id - * @param array $queries - * @param bool $forUpdate - * @return Document + * @param array $queries */ abstract public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document; - /** - * Create Document - * - * @param Document $collection - * @param Document $document - * - * @return Document - */ - abstract public function createDocument(Document $collection, Document $document): Document; - - /** - * Create Documents in batches - * - * @param Document $collection - * @param array $documents - * - * @return array - * - * @throws DatabaseException - */ - abstract public function createDocuments(Document $collection, array $documents): array; - - /** - * Update Document - * - * @param Document $collection - * @param string $id - * @param Document $document - * @param bool $skipPermissions - * - * @return Document - */ abstract public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document; /** - * Update documents - * * Updates all documents which match the given query. * - * @param Document $collection - * @param Document $updates - * @param array $documents - * - * @return int + * @param array $documents + * @param array $skipPermissions Ids of the documents whose permissions the update keeps, so a + * permissions write hook leaves their permission rows alone * * @throws DatabaseException */ - abstract public function updateDocuments(Document $collection, Document $updates, array $documents): int; + abstract public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int; /** - * Create documents if they do not exist, otherwise update them. - * - * If attribute is not empty, only the specified attribute will be increased, by the new value in each document. + * Increase or decrease attribute value * - * @param Document $collection - * @param string $attribute - * @param array $changes - * @return array + * @throws Exception */ - abstract public function upsertDocuments( + abstract public function increaseDocumentAttribute( Document $collection, + string $id, string $attribute, - array $changes - ): array; + int|float|string $value, + string $updatedAt, + int|float|string|null $min = null, + int|float|string|null $max = null + ): bool; - /** - * @param string $collection - * @param array $documents - * @return array - */ - abstract public function getSequences(string $collection, array $documents): array; + abstract public function deleteDocument(Document $collection, string $id): bool; /** - * Delete Document - * - * @param string $collection - * @param string $id - * - * @return bool + * @param array $sequences + * @param array $permissionIds */ - abstract public function deleteDocument(string $collection, string $id): bool; + abstract public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int; /** - * Delete Documents + * Find Documents * - * @param string $collection - * @param array $sequences - * @param array $permissionIds + * Find data sets using chosen queries * - * @return int + * @param array $queries + * @param array $orderAttributes + * @param array<\Utopia\Query\OrderDirection> $orderTypes + * @param array $cursor + * @return array */ - abstract public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int; + abstract public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array; /** - * Find Documents - * - * Find data sets using chosen queries + * Count Documents * - * @param Document $collection - * @param array $queries - * @param int|null $limit - * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor - * @param string $cursorDirection - * @param string $forPermission - * @return array + * @param array $queries */ - abstract public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array; + abstract public function count(Document $collection, array $queries = [], ?int $max = null): int; /** * Sum an attribute * - * @param Document $collection - * @param string $attribute - * @param array $queries - * @param int|null $max - * - * @return int|float + * @param array $queries */ abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int; /** - * Count Documents - * - * @param Document $collection - * @param array $queries - * @param int|null $max - * - * @return int + * @param array $documents + * @return array */ - abstract public function count(Document $collection, array $queries = [], ?int $max = null): int; + abstract public function getSequences(Document $collection, array $documents): array; + + abstract public function limits(): Limits; /** * Get Collection Size of the raw data * - * @param string $collection - * @return int * @throws DatabaseException */ abstract public function getSizeOfCollection(string $collection): int; @@ -906,464 +821,168 @@ abstract public function getSizeOfCollection(string $collection): int; /** * Get Collection Size on the disk * - * @param string $collection - * @return int * @throws DatabaseException */ abstract public function getSizeOfCollectionOnDisk(string $collection): int; /** - * Get max STRING limit - * - * @return int - */ - abstract public function getLimitForString(): int; - - /** - * Get max INT limit - * - * @return int - */ - abstract public function getLimitForInt(): int; - - /** - * Get max BIGINT limit - * - * @return int - */ - abstract public function getLimitForBigInt(): int; - - /** - * Get maximum attributes limit. - * - * @return int - */ - abstract public function getLimitForAttributes(): int; - - /** - * Get maximum index limit. - * - * @return int - */ - abstract public function getLimitForIndexes(): int; - - /** - * @return int - */ - abstract public function getMaxIndexLength(): int; - - /** - * Get the maximum VARCHAR length for this adapter - * - * @return int + * Estimate maximum number of bytes required to store a document in $collection. + * Byte requirement varies based on column type and size. + * Needed to satisfy MariaDB/MySQL row width limit. + * Return 0 when no restrictions apply to row width */ - abstract public function getMaxVarcharLength(): int; + abstract public function getAttributeWidth(Document $collection): int; /** - * Get the maximum UID length for this adapter - * - * @return int + * Get current attribute count from collection document */ - abstract public function getMaxUIDLength(): int; + abstract public function getCountOfAttributes(Document $collection): int; /** - * Get the minimum supported DateTime value + * The attributes a collection definition declares, also when it is a metadata row read straight from storage + * that still holds them as JSON. * - * @return \DateTime - */ - abstract public function getMinDateTime(): \DateTime; - - /** - * Get the primitive type of the primary key type for this adapter + * @internal * - * @return string - */ - abstract public function getIdAttributeType(): string; - - /** - * Get the maximum supported DateTime value + * @return list * - * @return \DateTime + * @throws StructureException */ - public function getMaxDateTime(): \DateTime + protected static function collectionAttributes(Document $collection): array { - return new \DateTime('9999-12-31 23:59:59'); + return Collection::fromDocument($collection)->attributes(); } /** - * Is schemas supported? - * - * @return bool - */ - abstract public function getSupportForSchemas(): bool; - - /** - * Are attributes supported? - * - * @return bool - */ - abstract public function getSupportForAttributes(): bool; - - /** - * Are schema attributes supported? - * - * @return bool - */ - abstract public function getSupportForSchemaAttributes(): bool; - - /** - * Are schema indexes supported? - * - * @return bool - */ - abstract public function getSupportForSchemaIndexes(): bool; - - /** - * Is index supported? - * - * @return bool - */ - abstract public function getSupportForIndex(): bool; - - /** - * Is indexing array supported? - * - * @return bool - */ - abstract public function getSupportForIndexArray(): bool; - - /** - * Is cast index as array supported? - * - * @return bool - */ - abstract public function getSupportForCastIndexArray(): bool; - - /** - * Is unique index supported? - * - * @return bool - */ - abstract public function getSupportForUniqueIndex(): bool; - - /** - * Is fulltext index supported? - * - * @return bool - */ - abstract public function getSupportForFulltextIndex(): bool; - - /** - * Is fulltext wildcard supported? - * - * @return bool - */ - abstract public function getSupportForFulltextWildcardIndex(): bool; - - - /** - * Does the adapter handle casting? + * The attributes a collection definition declares followed by every internal attribute, `$tenant` included. * - * @return bool - */ - abstract public function getSupportForCasting(): bool; - - /** - * Does the adapter handle array Contains? - * - * @return bool - */ - abstract public function getSupportForQueryContains(): bool; - - /** - * Are timeouts supported? + * @internal * - * @return bool - */ - abstract public function getSupportForTimeouts(): bool; - - /** - * Are relationships supported? - * - * @return bool - */ - abstract public function getSupportForRelationships(): bool; - - abstract public function getSupportForUpdateLock(): bool; - - /** - * Are batch operations supported? - * - * @return bool - */ - abstract public function getSupportForBatchOperations(): bool; - - /** - * Is attribute resizing supported? - * - * @return bool - */ - abstract public function getSupportForAttributeResizing(): bool; - - /** - * Is get connection id supported? - * - * @return bool - */ - abstract public function getSupportForGetConnectionId(): bool; - - /** - * Is upserting supported? + * @return list * - * @return bool + * @throws StructureException */ - abstract public function getSupportForUpserts(): bool; - - /** - * Is upsert via arbitrary unique indexes supported? - * - * @return bool - */ - abstract public function getSupportForUpsertOnUniqueIndex(): bool; - - /** - * Is vector type supported? - * - * @return bool - */ - abstract public function getSupportForVectors(): bool; - - /** - * Is Cache Fallback supported? - * - * @return bool - */ - abstract public function getSupportForCacheSkipOnFailure(): bool; - - /** - * @return bool - */ - abstract public function getSupportForCaching(): bool; - - /** - * Is reconnection supported? - * - * @return bool - */ - abstract public function getSupportForReconnection(): bool; - - /** - * Is hostname supported? - * - * @return bool - */ - abstract public function getSupportForHostname(): bool; - - /** - * Is creating multiple attributes in a single query supported? - * - * @return bool - */ - abstract public function getSupportForBatchCreateAttributes(): bool; - - /** - * Is spatial attributes supported? - * - * @return bool - */ - abstract public function getSupportForSpatialAttributes(): bool; - - /** - * Are object (JSON) attributes supported? - * - * @return bool - */ - abstract public function getSupportForObject(): bool; - - /** - * Are object (JSON) indexes supported? - * - * @return bool - */ - abstract public function getSupportForObjectIndexes(): bool; - - /** - * Does the adapter support null values in spatial indexes? - * - * @return bool - */ - abstract public function getSupportForSpatialIndexNull(): bool; - - /** - * Does the adapter support operators? - * - * @return bool - */ - abstract public function getSupportForOperators(): bool; + protected static function collectionAttributesWithInternal(Document $collection): array + { + return Collection::fromDocument($collection)->attributesWith(Database::internalAttributesFor(true)); + } /** - * Adapter supports optional spatial attributes with existing rows. + * The indexes a collection definition declares, also when it is a metadata row read straight from storage + * that still holds them as JSON. * - * @return bool - */ - abstract public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool; - - /** - * Does the adapter support order attribute in spatial indexes? + * @internal * - * @return bool - */ - abstract public function getSupportForSpatialIndexOrder(): bool; - - /** - * Does the adapter support spatial axis order specification? + * @return list * - * @return bool + * @throws IndexException */ - abstract public function getSupportForSpatialAxisOrder(): bool; + protected static function collectionIndexes(Document $collection): array + { + return Collection::fromDocument($collection)->indexes(); + } /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool + * Get current index count from collection document */ - abstract public function getSupportForBoundaryInclusiveContains(): bool; + abstract public function getCountOfIndexes(Document $collection): int; - /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool - */ - abstract public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool; + protected function getInternalKeyForAttribute(string $attribute): string + { + return Storage::column($attribute); + } /** - * Does the adapter support multiple fulltext indexes? + * Process-lifetime cache for {@see self::filter()}. Keys are referentially + * stable across the request lifetime and frequently re-queried per-row, so + * caching the regex result amortizes the preg_replace cost across all + * decode/encode/build passes. Bounded to avoid unbounded growth from + * unusual input. * - * @return bool + * @var array */ - abstract public function getSupportForMultipleFulltextIndexes(): bool; + private static array $filteredKeyCache = []; + private const int FILTERED_KEY_CACHE_LIMIT = 4096; /** - * Does the adapter support identical indexes? - * - * @return bool - */ - abstract public function getSupportForIdenticalIndexes(): bool; - - /** - * Does the adapter support random order by? + * Filter Keys * - * @return bool + * @throws DatabaseException */ - abstract public function getSupportForOrderRandom(): bool; + public function filter(string $value): string + { + if (isset(self::$filteredKeyCache[$value])) { + return self::$filteredKeyCache[$value]; + } - /** - * Get current attribute count from collection document - * - * @param Document $collection - * @return int - */ - abstract public function getCountOfAttributes(Document $collection): int; + $filtered = \preg_replace("/[^A-Za-z0-9_\-]/", '', $value); - /** - * Get current index count from collection document - * - * @param Document $collection - * @return int - */ - abstract public function getCountOfIndexes(Document $collection): int; + if (\is_null($filtered)) { + throw new DatabaseException('Failed to filter key'); + } - /** - * Returns number of attributes used by default. - * - * @return int - */ - abstract public function getCountOfDefaultAttributes(): int; + if (\count(self::$filteredKeyCache) >= self::FILTERED_KEY_CACHE_LIMIT) { + self::$filteredKeyCache = []; + } - /** - * Returns number of indexes used by default. - * - * @return int - */ - abstract public function getCountOfDefaultIndexes(): int; + return self::$filteredKeyCache[$value] = $filtered; + } /** - * Get maximum width, in bytes, allowed for a SQL row - * Return 0 when no restrictions apply + * The row as every write hook decorates a row written for the document. * - * @return int + * @param array $row + * @return array */ - abstract public function getDocumentSizeLimit(): int; + protected function decorateRow(array $row, Document $document): array + { + if ($this->writeHooks === []) { + return $row; + } - /** - * Estimate maximum number of bytes required to store a document in $collection. - * Byte requirement varies based on column type and size. - * Needed to satisfy MariaDB/MySQL row width limit. - * Return 0 when no restrictions apply to row width - * - * @param Document $collection - * @return int - */ - abstract public function getAttributeWidth(Document $collection): int; + $metadata = new Hook\RowMetadata($document->getTenant() ?? $this->currentTenant()); + foreach ($this->writeHooks as $hook) { + $row = $hook->decorateRow($row, $metadata); + } - /** - * Get list of keywords that cannot be used - * - * @return array - */ - abstract public function getKeywords(): array; + return $row; + } /** - * Get an attribute projection given a list of selected attributes + * Run the callable once per registered write hook, in registration order. * - * @param array $selections - * @param string $prefix - * @return mixed + * @param callable(Write): void $callback */ - abstract protected function getAttributeProjection(array $selections, string $prefix): mixed; + protected function runWriteHooks(callable $callback): void + { + foreach ($this->writeHooks as $hook) { + $callback($hook); + } + } /** * Get all selected attributes from queries * - * @param Query[] $queries - * @return string[] + * @param array $queries + * @return array */ protected function getAttributeSelections(array $queries): array { $selections = []; foreach ($queries as $query) { - switch ($query->getMethod()) { - case Query::TYPE_SELECT: - foreach ($query->getValues() as $value) { - $selections[] = $value; - } - break; + if ($query->getMethod() === Method::Select) { + foreach ($query->getValues() as $value) { + /** @var string $value */ + $selections[] = $value; + } } } return $selections; } - /** - * Filter Keys - * - * @param string $value - * @return string - * @throws DatabaseException - */ - public function filter(string $value): string - { - $value = \preg_replace("/[^A-Za-z0-9_\-]/", '', $value); - - if (\is_null($value)) { - throw new DatabaseException('Failed to filter key'); - } - - return $value; - } - protected function escapeWildcards(string $value): string { $wildcards = [ @@ -1381,7 +1000,7 @@ protected function escapeWildcards(string $value): string ')', '{', '}', - '|' + '|', ]; foreach ($wildcards as $wildcard) { @@ -1392,268 +1011,7 @@ protected function escapeWildcards(string $value): string } /** - * Increase or decrease attribute value - * - * @param string $collection - * @param string $id - * @param string $attribute - * @param int|float $value - * @param string $updatedAt - * @param int|float|null $min - * @param int|float|null $max - * @return bool - * @throws Exception - */ - abstract public function increaseDocumentAttribute( - string $collection, - string $id, - string $attribute, - int|float $value, - string $updatedAt, - int|float|null $min = null, - int|float|null $max = null - ): bool; - - /** - * Returns the connection ID identifier - * - * @return string - */ - abstract public function getConnectionId(): string; - - /** - * Get List of internal index keys names - * - * @return array - */ - abstract public function getInternalIndexesKeys(): array; - - /** - * Get Schema Attributes - * - * @param string $collection - * @return array - * @throws DatabaseException - */ - abstract public function getSchemaAttributes(string $collection): array; - - /** - * Get Schema Indexes - * - * Returns physical index definitions from the database schema. - * - * @param string $collection - * @return array - * @throws DatabaseException - */ - abstract public function getSchemaIndexes(string $collection): array; - - /** - * Get the expected column type for a given attribute type. - * - * Returns the database-native column type string (e.g. "VARCHAR(255)", "BIGINT") - * that would be used when creating a column for the given attribute parameters. - * Returns an empty string if the adapter does not support this operation. - * - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param bool $required - * @return string - * @throws \Utopia\Database\Exception For unknown types on adapters that support column-type resolution. - */ - public function getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string - { - return ''; - } - - /** - * Get the query to check for tenant when in shared tables mode - * - * @param string $collection The collection being queried - * @param string $alias The alias of the parent collection if in a subquery - * @return string - */ - abstract public function getTenantQuery(string $collection, string $alias = ''): string; - - /** - * @param mixed $stmt - * @return bool - */ - abstract protected function execute(mixed $stmt): bool; - - /** - * Decode a WKB or textual POINT into [x, y] - * - * @param string $wkb - * @return float[] Array with two elements: [x, y] - */ - abstract public function decodePoint(string $wkb): array; - - /** - * Decode a WKB or textual LINESTRING into [[x1, y1], [x2, y2], ...] - * - * @param string $wkb - * @return float[][] Array of points, each as [x, y] - */ - abstract public function decodeLinestring(string $wkb): array; - - /** - * Decode a WKB or textual POLYGON into [[[x1, y1], [x2, y2], ...], ...] - * - * @param string $wkb - * @return float[][][] Array of rings, each ring is an array of points [x, y] - */ - abstract public function decodePolygon(string $wkb): array; - - public function getSupportForUnsignedBigInt(): bool - { - return false; - } - - /** - * Returns the document after casting - * @param Document $collection - * @param Document $document - * @return Document - */ - abstract public function castingBefore(Document $collection, Document $document): Document; - - /** - * Returns the document after casting - * @param Document $collection - * @param Document $document - * @return Document - */ - abstract public function castingAfter(Document $collection, Document $document): Document; - - /** - * Is internal casting supported? - * - * @return bool - */ - abstract public function getSupportForInternalCasting(): bool; - - /** - * Is UTC casting supported? - * - * @return bool - */ - abstract public function getSupportForUTCCasting(): bool; - - /** - * Set UTC Datetime - * - * @param string $value - * @return mixed - */ - abstract public function setUTCDatetime(string $value): mixed; - - /** - * Set support for attributes - * - * @param bool $support - * @return bool - */ - abstract public function setSupportForAttributes(bool $support): bool; - - /** - * Does the adapter require booleans to be converted to integers (0/1)? - * - * @return bool - */ - abstract public function getSupportForIntegerBooleans(): bool; - - /** - * Does the adapter have support for ALTER TABLE locking modes? - * - * When enabled, adapters can specify lock behavior (e.g., LOCK=SHARED) - * during ALTER TABLE operations to control concurrent access. - * - * @return bool - */ - abstract public function getSupportForAlterLocks(): bool; - - /** - * @param bool $enable - * - * @return $this - */ - public function enableAlterLocks(bool $enable): self - { - $this->alterLocks = $enable; - - return $this; - } - - /** - * Handle non utf characters supported? - * - * @return bool - */ - abstract public function getSupportNonUtfCharacters(): bool; - - /** - * Does the adapter support trigram index? - * - * @return bool - */ - abstract public function getSupportForTrigramIndex(): bool; - - /** - * Is PCRE regex supported? - * PCRE (Perl Compatible Regular Expressions) supports \b for word boundaries - * - * @return bool - */ - abstract public function getSupportForPCRERegex(): bool; - - /** - * Is POSIX regex supported? - * POSIX regex uses \y for word boundaries instead of \b - * - * @return bool - */ - abstract public function getSupportForPOSIXRegex(): bool; - - /** - * Is regex supported at all? - * Returns true if either PCRE or POSIX regex is supported - * - * @return bool - */ - public function getSupportForRegex(): bool - { - return $this->getSupportForPCRERegex() || $this->getSupportForPOSIXRegex(); - } - - /** - * Are ttl indexes supported? - * - * @return bool - */ - public function getSupportForTTLIndexes(): bool - { - return false; - } - - /** - * Does the adapter support transaction retries? - * - * @return bool - */ - abstract public function getSupportForTransactionRetries(): bool; - - /** - * Does the adapter support nested transactions? - * - * @return bool - */ - abstract public function getSupportForNestedTransactions(): bool; - - /** - * @return mixed + * The client the adapter talks to its engine through, such as a PDO or a MongoDB client. */ - abstract public function getDriver(): mixed; + abstract public function getDriver(): object; } diff --git a/src/Database/Adapter/CursorBoundary.php b/src/Database/Adapter/CursorBoundary.php new file mode 100644 index 0000000000..a47c839f37 --- /dev/null +++ b/src/Database/Adapter/CursorBoundary.php @@ -0,0 +1,18 @@ + $documents + * @return array The documents under the keys they were given with + */ + public function castAfter(Document $collection, array $documents): array; + + /** + * Convert a datetime string to the value the engine compares stored datetimes with. + */ + public function castDatetime(string $value): mixed; +} diff --git a/src/Database/Adapter/Feature/Connection.php b/src/Database/Adapter/Feature/Connection.php new file mode 100644 index 0000000000..c5428cd2d9 --- /dev/null +++ b/src/Database/Adapter/Feature/Connection.php @@ -0,0 +1,22 @@ + $bindings Parameter bindings for prepared statements. + * @return array The query results as Document objects. + */ + public function rawQuery(string $query, array $bindings = []): array; + + /** + * @param array $bindings Parameter bindings for prepared statements. + */ + public function rawMutation(string $query, array $bindings = []): int; +} diff --git a/src/Database/Adapter/Feature/Relationships.php b/src/Database/Adapter/Feature/Relationships.php new file mode 100644 index 0000000000..a31d82ea19 --- /dev/null +++ b/src/Database/Adapter/Feature/Relationships.php @@ -0,0 +1,28 @@ + [x, y] for a point, a list of points for a linestring, a list of rings for a polygon + * + * @throws DatabaseException When the type is not spatial or the value cannot be decoded + */ + public function decode(string $value, ColumnType $type): array; +} diff --git a/src/Database/Adapter/Feature/Timeouts.php b/src/Database/Adapter/Feature/Timeouts.php new file mode 100644 index 0000000000..d10a050618 --- /dev/null +++ b/src/Database/Adapter/Feature/Timeouts.php @@ -0,0 +1,20 @@ + $changes + * @return array The written documents, in the order of $changes + */ + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array; +} diff --git a/src/Database/Adapter/Limits.php b/src/Database/Adapter/Limits.php new file mode 100644 index 0000000000..9300f4fef6 --- /dev/null +++ b/src/Database/Adapter/Limits.php @@ -0,0 +1,39 @@ + $keywords + * @param list $internalIndexKeys + */ + public function __construct( + public int $string, + public int $varchar, + public int $integer, + public int $bigInteger, + public int $attributes, + public int $indexes, + public int $defaultAttributes, + public int $defaultIndexes, + public int $indexLength, + public int $uidLength, + public int $documentSize, + public DateTime $minDateTime, + public DateTime $maxDateTime, + public ColumnType $idType, + public array $keywords, + public array $internalIndexKeys, + ) { + } +} diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index d478e4a8dd..04f9d826ec 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -3,11 +3,23 @@ namespace Utopia\Database\Adapter; use Exception; +use PDO; use PDOException; +use PDOStatement; +use Swoole\Database\PDOProxy; +use Swoole\Database\PDOStatementProxy; +use Throwable; +use Utopia\Database\Adapter\SQL\Wkt; +use Utopia\Database\Attribute; +use Utopia\Database\Builder\MariaDB as MariaDBBuilder; +use Utopia\Database\Builder\Scoping; +use Utopia\Database\Capability; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Character as CharacterException; +use Utopia\Database\Exception\Contention as ContentionException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; @@ -17,803 +29,563 @@ use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\ID; +use Utopia\Database\Index; use Utopia\Database\Operator; -use Utopia\Database\Query; - -class MariaDB extends SQL +use Utopia\Database\OperatorType; +use Utopia\Database\PDO as DatabasePDO; +use Utopia\Database\PDOStatement as DatabasePDOStatement; +use Utopia\Database\Schema\Column as SchemaColumn; +use Utopia\Database\Schema\Index as SchemaIndex; +use Utopia\Database\Storage; +use Utopia\Query\Builder\SQL as SQLBuilder; +use Utopia\Query\Query as BaseQuery; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\MySQL as MySQLSchema; + +class MariaDB extends SQL implements Feature\Spatial, Feature\Timeouts { + use Timeout; + /** - * Create Database - * - * @param string $name - * @return bool - * @throws Exception - * @throws PDOException + * @return array */ - public function create(string $name): bool + #[\Override] + public function capabilities(): array { - $name = $this->filter($name); + return array_merge(parent::capabilities(), [ + Capability::IntegerBooleans, + Capability::AlterLock, + Capability::IndexFulltextWildcard, + Capability::IndexSpatialOrder, + Capability::NonUtfCharacters, + Capability::SchemaIntrospection, + Capability::UpsertOnUniqueIndex, + Capability::UnsignedBigInt, + ]); + } + + #[\Override] + public function id(): string + { + $result = $this->dialectBuilder()->fromNone()->selectRaw('CONNECTION_ID()')->build(); + $statement = $this->prepareStatement($result->query); - if ($this->exists($name)) { - return true; + if (! $statement->execute()) { + return ''; } - $sql = "CREATE DATABASE `{$name}` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;"; + $column = $statement->fetchColumn(); - $sql = $this->trigger(Database::EVENT_DATABASE_CREATE, $sql); - - return $this->getPDO() - ->prepare($sql) - ->execute(); + return \is_scalar($column) ? (string) $column : ''; } /** - * Delete Database - * - * @param string $name - * @return bool * @throws Exception * @throws PDOException */ - public function delete(string $name): bool + #[\Override] + public function create(string $name): bool { $name = $this->filter($name); - $sql = "DROP DATABASE `{$name}`;"; + if ($this->exists($name)) { + return true; + } - $sql = $this->trigger(Database::EVENT_DATABASE_DELETE, $sql); + $result = $this->schema()->createDatabase($name); + $sql = $result->query; - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::DatabaseCreate); } /** - * Create Collection + * MariaDB and MySQL cannot rename a database, so every table moves into a new one in a single atomic + * `RENAME TABLE` and the emptied database is dropped. Grants on the old database do not move. A table + * created in the old database during the rename keeps it from being dropped. Shared tables refuse the + * rename: other tenants' rows share the database. * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool - * @throws Exception - * @throws PDOException + * @throws DatabaseException */ - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + #[\Override] + public function update(string $name, string $new): bool { - $id = $this->filter($name); - - /** @var array $attributeStrings */ - $attributeStrings = []; - - /** @var array $indexStrings */ - $indexStrings = []; - - $hash = []; - - foreach ($attributes as $key => $attribute) { - $attrId = $this->filter($attribute->getId()); - $hash[$attrId] = $attribute; - - $attrType = $this->getSQLType( - $attribute->getAttribute('type'), - $attribute->getAttribute('size', 0), - $attribute->getAttribute('signed', true), - $attribute->getAttribute('array', false), - $attribute->getAttribute('required', false) - ); + if ($this->hasSharedTables()) { + throw new DatabaseException('Cannot rename a database while shared tables are enabled'); + } - // Ignore relationships with virtual attributes - if ($attribute->getAttribute('type') === Database::VAR_RELATIONSHIP) { - $options = $attribute->getAttribute('options', []); - $relationType = $options['relationType'] ?? null; - $twoWay = $options['twoWay'] ?? false; - $side = $options['side'] ?? null; - - if ( - $relationType === Database::RELATION_MANY_TO_MANY - || ($relationType === Database::RELATION_ONE_TO_ONE && !$twoWay && $side === Database::RELATION_SIDE_CHILD) - || ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) - || ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) - ) { - continue; - } - } + $name = $this->filter($name); + $new = $this->filter($new); - $attributeStrings[$key] = "`{$attrId}` {$attrType}, "; + if (! $this->exists($name)) { + throw new NotFoundException('Database not found'); } - foreach ($indexes as $key => $index) { - $indexId = $this->filter($index->getId()); - $indexType = $index->getAttribute('type'); - - $indexAttributes = $index->getAttribute('attributes'); - foreach ($indexAttributes as $nested => $attribute) { - $indexLength = $index->getAttribute('lengths')[$nested] ?? ''; - $indexLength = (empty($indexLength)) ? '' : '(' . (int)$indexLength . ')'; - $indexOrder = $index->getAttribute('orders')[$nested] ?? ''; - if ($indexType === Database::INDEX_SPATIAL && !$this->getSupportForSpatialIndexOrder() && !empty($indexOrder)) { - throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); - } - $indexAttribute = $this->getInternalKeyForAttribute($attribute); - $indexAttribute = $this->filter($indexAttribute); + if ($this->exists($new)) { + throw new DuplicateException('Database already exists'); + } - if ($indexType === Database::INDEX_FULLTEXT) { - $indexOrder = ''; - } + $tables = $this->getTables($name); + $schema = $this->schema(); - $indexAttributes[$nested] = "`{$indexAttribute}`{$indexLength} {$indexOrder}"; + $this->executeStatement($schema->createDatabase($new)->query, Event::DatabaseCreate); - if (!empty($hash[$indexAttribute]['array']) && $this->getSupportForCastIndexArray()) { - $indexAttributes[$nested] = '(CAST(`' . $indexAttribute . '` AS char(' . Database::MAX_ARRAY_INDEX_LENGTH . ') ARRAY))'; - } - } + if ($tables !== []) { + $moves = \array_map( + fn (string $table): string => "{$this->quote($name)}.{$this->quote($table)} TO {$this->quote($new)}.{$this->quote($table)}", + $tables, + ); - $indexAttributes = \implode(", ", $indexAttributes); + try { + $this->execute($this->prepareStatement('RENAME TABLE '.\implode(', ', $moves))); + } catch (Throwable $error) { + $this->executeStatement($schema->dropDatabase($new)->query, Event::DatabaseDelete); - if ($this->sharedTables && $indexType !== Database::INDEX_FULLTEXT && $indexType !== Database::INDEX_SPATIAL) { - // Add tenant as first index column for best performance - $indexAttributes = "_tenant, {$indexAttributes}"; + throw $error instanceof PDOException ? $this->processException($error) : $error; } - - $indexStrings[$key] = "{$indexType} `{$indexId}` ({$indexAttributes}),"; - } - - $collection = " - CREATE TABLE {$this->getSQLTable($id)} ( - _id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - _uid VARCHAR(255) NOT NULL, - _createdAt DATETIME(3) DEFAULT NULL, - _updatedAt DATETIME(3) DEFAULT NULL, - _permissions MEDIUMTEXT DEFAULT NULL, - PRIMARY KEY (_id), - " . \implode(' ', $attributeStrings) . " - " . \implode(' ', $indexStrings) . " - "; - - if ($this->sharedTables) { - $collection .= " - _tenant INT(11) UNSIGNED DEFAULT NULL, - UNIQUE KEY _uid (_uid, _tenant), - KEY _created_at (_tenant, _createdAt), - KEY _updated_at (_tenant, _updatedAt), - KEY _tenant_id (_tenant, _id) - "; - } else { - $collection .= " - UNIQUE KEY _uid (_uid), - KEY _created_at (_createdAt), - KEY _updated_at (_updatedAt) - "; - } - - $collection .= ")"; - $collection = $this->trigger(Database::EVENT_COLLECTION_CREATE, $collection); - - $permissions = " - CREATE TABLE {$this->getSQLTable($id . '_perms')} ( - _id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - _type VARCHAR(12) NOT NULL, - _permission VARCHAR(255) NOT NULL, - _document VARCHAR(255) NOT NULL, - PRIMARY KEY (_id), - "; - - if ($this->sharedTables) { - $permissions .= " - _tenant INT(11) UNSIGNED DEFAULT NULL, - UNIQUE INDEX _index1 (_document, _tenant, _type, _permission), - INDEX _permission (_tenant, _permission, _type) - "; - } else { - $permissions .= " - UNIQUE INDEX _index1 (_document, _type, _permission), - INDEX _permission (_permission, _type) - "; } - $permissions .= ")"; - $permissions = $this->trigger(Database::EVENT_COLLECTION_CREATE, $permissions); - - try { - $this->getPDO() - ->prepare($collection) - ->execute(); - - $this->getPDO() - ->prepare($permissions) - ->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + if ($this->getTables($name) !== []) { + throw new DatabaseException("Database {$name} was renamed to {$new} but holds tables created during the rename, so it was not dropped"); } - return true; + return $this->executeStatement($schema->dropDatabase($name)->query, Event::DatabaseDelete); } /** - * Get collection size on disk + * @return list * - * @param string $collection - * @return int * @throws DatabaseException */ - public function getSizeOfCollectionOnDisk(string $collection): int + private function getTables(string $database): array { - $collection = $this->filter($collection); - $collection = $this->getNamespace() . '_' . $collection; - $database = $this->getDatabase(); - $name = $database . '/' . $collection; - $permissions = $database . '/' . $collection . '_perms'; - - $collectionSize = $this->getPDO()->prepare(" - SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) - FROM INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES - WHERE NAME = :name - "); + $result = $this->dialectBuilder() + ->fromTable('INFORMATION_SCHEMA.TABLES') + ->selectRaw('TABLE_NAME') + ->filter([BaseQuery::equal('TABLE_SCHEMA', [$database])]) + ->build(); - $permissionsSize = $this->getPDO()->prepare(" - SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) - FROM INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES - WHERE NAME = :permissions - "); - - $collectionSize->bindParam(':name', $name); - $permissionsSize->bindParam(':permissions', $permissions); + $statement = $this->executeResult($result, Event::DatabaseList); try { - $collectionSize->execute(); - $permissionsSize->execute(); - $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn(); - } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); + $this->execute($statement); + $rows = $statement->fetchAll(); + $statement->closeCursor(); + } catch (PDOException $error) { + throw $this->processException($error); } - return $size; - } - - /** - * Get Collection Size of the raw data - * - * @param string $collection - * @return int - * @throws DatabaseException - */ - public function getSizeOfCollection(string $collection): int - { - $collection = $this->filter($collection); - $collection = $this->getNamespace() . '_' . $collection; - $database = $this->getDatabase(); - $permissions = $collection . '_perms'; - - // Both tables in one round trip. Keep the equality predicates: LIKE and IN are - // not indexed here, they scan every table in the schema. - $statement = $this->getPDO()->prepare(" - SELECT SUM(size) FROM ( - SELECT data_length + index_length AS size - FROM INFORMATION_SCHEMA.TABLES - WHERE table_name = :name AND - table_schema = :database_name - UNION ALL - SELECT data_length + index_length AS size - FROM INFORMATION_SCHEMA.TABLES - WHERE table_name = :permissions AND - table_schema = :database_permissions - ) AS sizes - "); - - $statement->bindParam(':name', $collection); - $statement->bindParam(':permissions', $permissions); - $statement->bindParam(':database_name', $database); - $statement->bindParam(':database_permissions', $database); - - try { - $statement->execute(); - $size = $statement->fetchColumn(); - } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); + $tables = []; + foreach ($rows as $row) { + $table = \is_array($row) ? ($row['TABLE_NAME'] ?? $row['table_name'] ?? null) : null; + if (\is_string($table)) { + $tables[] = $table; + } } - return (int) $size; + return $tables; } /** - * Delete collection + * @param list $attributes + * @param list $indexes * - * @param string $id - * @return bool * @throws Exception * @throws PDOException */ - public function deleteCollection(string $id): bool + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool { - $id = $this->filter($id); + $id = $this->filter($collection); + $schema = $this->schema(); + $sharedTables = $this->sharedTables; + + $hash = []; + foreach ($attributes as $attribute) { + $hash[$this->filter($attribute->key)] = $attribute; + } - $sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')};"; + $table = $schema->table($this->getTableRaw($id)); + $table->id(Storage::SEQUENCE); + $table->string(Storage::UID, 255); + $table->datetime(Storage::CREATED_AT, 3)->nullable()->default(null); + $table->datetime(Storage::UPDATED_AT, 3)->nullable()->default(null); + $table->mediumText(Storage::PERMISSIONS)->nullable()->default(null); - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); + foreach ($attributes as $attribute) { + if (! self::storesColumn($attribute)) { + continue; + } - try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + $table->rawColumn('`'.$this->filter($attribute->key).'` '.$this->getAttributeSqlType($attribute)); } - } - /** - * Analyze a collection updating it's metadata on the database engine - * - * @param string $collection - * @return bool - * @throws DatabaseException - */ - public function analyzeCollection(string $collection): bool - { - $name = $this->filter($collection); + foreach ($indexes as $index) { + $indexId = $this->filter($index->key); + $indexType = $index->type; + $indexColumns = []; - $sql = "ANALYZE TABLE {$this->getSQLTable($name)}"; + foreach ($index->attributes as $nested => $attribute) { + $indexOrder = $index->orders[$nested]->value ?? ''; - $stmt = $this->getPDO()->prepare($sql); - return $stmt->execute(); - } + if ($indexType === IndexType::Spatial && ! $this->supports(Capability::IndexSpatialOrder) && ! empty($indexOrder)) { + throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); + } - /** - * Get Schema Attributes - * - * @param string $collection - * @return array - * @throws DatabaseException - */ - public function getSchemaAttributes(string $collection): array - { - $schema = $this->getDatabase(); - $collection = $this->getNamespace().'_'.$this->filter($collection); + $indexAttribute = $this->filter($this->getInternalKeyForAttribute($attribute)); - try { - $stmt = $this->getPDO()->prepare(' - SELECT - COLUMN_NAME as _id, - COLUMN_DEFAULT as columnDefault, - IS_NULLABLE as isNullable, - DATA_TYPE as dataType, - CHARACTER_MAXIMUM_LENGTH as characterMaximumLength, - NUMERIC_PRECISION as numericPrecision, - NUMERIC_SCALE as numericScale, - DATETIME_PRECISION as datetimePrecision, - COLUMN_TYPE as columnType, - COLUMN_KEY as columnKey, - EXTRA as extra - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table - '); - $stmt->bindParam(':schema', $schema); - $stmt->bindParam(':table', $collection); - $stmt->execute(); - $results = $stmt->fetchAll(); - $stmt->closeCursor(); - - foreach ($results as $index => $document) { - $document['$id'] = $document['_id']; - unset($document['_id']); - - $results[$index] = new Document($document); + $indexColumns[] = $this->compileIndexColumn( + $indexAttribute, + isset($hash[$indexAttribute]) && $hash[$indexAttribute]->array, + $index->lengths[$nested] ?? 0, + $indexType === IndexType::Fulltext ? '' : $indexOrder, + ); + } + + if ($sharedTables && $indexType !== IndexType::Fulltext && $indexType !== IndexType::Spatial) { + \array_unshift($indexColumns, $this->quote(Storage::TENANT)); } - return $results; + $table->addIndex($indexId, [], $indexType, rawColumns: $indexColumns); + } - } catch (PDOException $e) { - throw new DatabaseException('Failed to get schema attributes', $e->getCode(), $e); + if ($sharedTables) { + $table->rawColumn(Storage::TENANT.' INT(11) UNSIGNED DEFAULT NULL'); + $table->uniqueIndex([Storage::UID, Storage::TENANT], Storage::UID); + $table->index([Storage::TENANT, Storage::CREATED_AT], Storage::INDEX_CREATED_AT); + $table->index([Storage::TENANT, Storage::UPDATED_AT], Storage::INDEX_UPDATED_AT); + $table->index([Storage::TENANT, Storage::SEQUENCE], Storage::INDEX_TENANT_ID); + } else { + $table->uniqueIndex([Storage::UID], Storage::UID); + $table->index([Storage::CREATED_AT], Storage::INDEX_CREATED_AT); + $table->index([Storage::UPDATED_AT], Storage::INDEX_UPDATED_AT); + } + + $collectionResult = $table->create(); + $collection = $collectionResult->query; + + $permissionsTable = $schema->table($this->getTableRaw(Storage::permissionsTable($id))); + $permissionsTable->id(Storage::SEQUENCE); + $permissionsTable->string(Storage::PERMISSIONS_TYPE, 12); + $permissionsTable->string(Storage::PERMISSIONS_PERMISSION, 255); + $permissionsTable->string(Storage::PERMISSIONS_DOCUMENT, 255); + + if ($sharedTables) { + $permissionsTable->integer(Storage::TENANT)->unsigned()->nullable()->default(null); + $permissionsTable->uniqueIndex([Storage::PERMISSIONS_DOCUMENT, Storage::TENANT, Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION], Storage::INDEX_1); + $permissionsTable->index([Storage::TENANT, Storage::PERMISSIONS_PERMISSION, Storage::PERMISSIONS_TYPE], Storage::PERMISSIONS_PERMISSION); + } else { + $permissionsTable->uniqueIndex([Storage::PERMISSIONS_DOCUMENT, Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION], Storage::INDEX_1); + $permissionsTable->index([Storage::PERMISSIONS_PERMISSION, Storage::PERMISSIONS_TYPE], Storage::PERMISSIONS_PERMISSION); + } + + $permissionsResult = $permissionsTable->create(); + $permissions = $permissionsResult->query; + + $created = false; + + try { + $this->executeStatement($collection, Event::CollectionCreate); + $created = true; + $this->executeStatement($permissions, Event::CollectionCreate); + } catch (PDOException $exception) { + $error = $this->processException($exception); + + if ($created && ! $error instanceof DuplicateException) { + $this->discardCreatedCollection($id); + } + + throw $error; } + + return true; } /** - * Update Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param string|null $newKey - * @param bool $required - * @return bool - * @throws DatabaseException + * @throws Exception + * @throws PDOException */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + #[\Override] + public function deleteCollection(string $collection): bool { - $name = $this->filter($collection); - $id = $this->filter($id); - $newKey = empty($newKey) ? null : $this->filter($newKey); - $type = $this->getSQLType($type, $size, $signed, $array, $required); - if (!empty($newKey)) { - $sql = "ALTER TABLE {$this->getSQLTable($name)} CHANGE COLUMN `{$id}` `{$newKey}` {$type};"; - } else { - $sql = "ALTER TABLE {$this->getSQLTable($name)} MODIFY `{$id}` {$type};"; - } + $id = $this->filter($collection); - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + $schema = $this->schema(); + $main = $schema->table($this->getTableRaw($id))->drop(); + $permissions = $schema->table($this->getTableRaw(Storage::permissionsTable($id)))->dropIfExists(); try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($main->query.'; '.$permissions->query, Event::CollectionDelete); } catch (PDOException $e) { - throw $this->processException($e); + $error = $this->processException($e); + if ($error instanceof NotFoundException) { + $this->executeStatement($permissions->query, Event::CollectionDelete); + } + + throw $error; } } /** - * @param string $collection - * @param string $id - * @param string $type - * @param string $relatedCollection - * @param bool $twoWay - * @param string $twoWayKey - * @return bool * @throws DatabaseException */ - public function createRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay = false, - string $id = '', - string $twoWayKey = '' - ): bool { + #[\Override] + public function analyzeCollection(string $collection): bool + { $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $id = $this->filter($id); - $twoWayKey = $this->filter($twoWayKey); - $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false, false, false); - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - $sql = "ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL;"; - - if ($twoWay) { - $sql .= "ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL;"; - } - break; - case Database::RELATION_ONE_TO_MANY: - $sql = "ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL;"; - break; - case Database::RELATION_MANY_TO_ONE: - $sql = "ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL;"; - break; - case Database::RELATION_MANY_TO_MANY: - return true; - default: - throw new DatabaseException('Invalid relationship type'); - } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + $result = $this->schema()->analyzeTable($this->getTableRaw($name)); + $sql = $result->query; - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::CollectionUpdate); } /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @param string|null $newKey - * @param string|null $newTwoWayKey - * @return bool * @throws DatabaseException */ - public function updateRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side, - ?string $newKey = null, - ?string $newTwoWayKey = null, - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + #[\Override] + public function getSizeOfCollectionOnDisk(string $collection): int + { + $collection = $this->filter($collection); + $collection = $this->getNamespace().'_'.$collection; + $database = $this->getDatabase(); + $name = $database.'/'.$collection; + $permissions = $database.'/'.Storage::permissionsTable($collection); - if (!\is_null($newKey)) { - $newKey = $this->filter($newKey); - } - if (!\is_null($newTwoWayKey)) { - $newTwoWayKey = $this->filter($newTwoWayKey); - } + $builder = $this->dialectBuilder(); - $sql = ''; + $collectionResult = $builder + ->fromTable('INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES') + ->selectRaw('SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)') + ->filter([BaseQuery::equal('NAME', [$name])]) + ->build(); - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($key !== $newKey) { - $sql = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`;"; - } - if ($twoWay && $twoWayKey !== $newTwoWayKey) { - $sql .= "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;"; - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - if ($twoWayKey !== $newTwoWayKey) { - $sql = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;"; - } - } else { - if ($key !== $newKey) { - $sql = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`;"; - } - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - if ($twoWayKey !== $newTwoWayKey) { - $sql = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;"; - } - } else { - if ($key !== $newKey) { - $sql = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`;"; - } - } - break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); + $permissionsResult = $builder->reset() + ->fromTable('INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES') + ->selectRaw('SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)') + ->filter([BaseQuery::equal('NAME', [$permissions])]) + ->build(); - $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); + $collectionSize = $this->executeResult($collectionResult, Event::CollectionRead); + $permissionsSize = $this->executeResult($permissionsResult, Event::CollectionRead); - if (!\is_null($newKey)) { - $sql = "ALTER TABLE {$junction} RENAME COLUMN `{$key}` TO `{$newKey}`;"; - } - if ($twoWay && !\is_null($newTwoWayKey)) { - $sql .= "ALTER TABLE {$junction} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;"; - } - break; - default: - throw new DatabaseException('Invalid relationship type'); + foreach ($collectionResult->bindings as $i => $v) { + $collectionSize->bindValue($i + 1, $v); } - - if (empty($sql)) { - return true; + foreach ($permissionsResult->bindings as $i => $v) { + $permissionsSize->bindValue($i + 1, $v); } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + try { + $this->execute($collectionSize); + $this->execute($permissionsSize); + $collSizeVal = $collectionSize->fetchColumn(); + $permSizeVal = $permissionsSize->fetchColumn(); + $size = (int) (\is_numeric($collSizeVal) ? $collSizeVal : 0) + (int) (\is_numeric($permSizeVal) ? $permSizeVal : 0); + } catch (PDOException $e) { + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); + } - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $size; } /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @return bool * @throws DatabaseException */ - public function deleteRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $sql = "ALTER TABLE {$table} DROP COLUMN `{$key}`;"; - if ($twoWay) { - $sql .= "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;"; - } - } elseif ($side === Database::RELATION_SIDE_CHILD) { - $sql = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;"; - if ($twoWay) { - $sql .= "ALTER TABLE {$table} DROP COLUMN `{$key}`;"; - } - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - $sql = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;"; - } else { - $sql = "ALTER TABLE {$table} DROP COLUMN `{$key}`;"; - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $sql = "ALTER TABLE {$table} DROP COLUMN `{$key}`;"; - } else { - $sql = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;"; - } - break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); - - $junction = $side === Database::RELATION_SIDE_PARENT - ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()) - : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence()); - - $perms = $side === Database::RELATION_SIDE_PARENT - ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() . '_perms') - : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence() . '_perms'); - - $sql = "DROP TABLE {$junction}; DROP TABLE {$perms}"; - break; - default: - throw new DatabaseException('Invalid relationship type'); - } + #[\Override] + public function getSizeOfCollection(string $collection): int + { + $collection = $this->filter($collection); + $collection = $this->getNamespace().'_'.$collection; + $database = $this->getDatabase(); + $permissions = Storage::permissionsTable($collection); + + $result = $this->dialectBuilder() + ->fromNone() + ->selectRaw( + 'SUM(size) FROM ( + SELECT data_length + index_length AS size + FROM INFORMATION_SCHEMA.TABLES + WHERE table_name = ? AND + table_schema = ? + UNION ALL + SELECT data_length + index_length AS size + FROM INFORMATION_SCHEMA.TABLES + WHERE table_name = ? AND + table_schema = ? + ) AS sizes', + [$collection, $database, $permissions, $database] + ) + ->build(); + + $statement = $this->executeResult($result, Event::CollectionRead); - if (empty($sql)) { - return true; + try { + $this->execute($statement); + $size = $statement->fetchColumn(); + } catch (PDOException $e) { + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); + return (int) (\is_numeric($size) ? $size : 0); + } - return $this->getPDO() - ->prepare($sql) - ->execute(); + /** + * MariaDB has no column SRID attribute: MySQL's `SRID n` column syntax is a parse error there. + */ + #[\Override] + protected function getSpatialColumnSrid(): ?int + { + return null; } /** - * Rename Index - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool - * @throws Exception + * @throws DatabaseException */ - public function renameIndex(string $collection, string $old, string $new): bool + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool { - $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); + $name = $this->filter($collection); + $id = $this->filter($key); + $newKey = $attribute->key === $key ? null : $this->filter($attribute->key); + $sqlType = $this->getAttributeSqlType($attribute); + $schema = $this->schema(); + $tableRaw = $this->getTableRaw($name); + + if (! empty($newKey) && $this->isRenamed($collection, $id, $newKey)) { + $id = $newKey; + $newKey = null; + } - $sql = "ALTER TABLE {$this->getSQLTable($collection)} RENAME INDEX `{$old}` TO `{$new}`;"; + if (! empty($newKey)) { + $result = $schema->changeColumn($tableRaw, $id, $newKey, $sqlType); + } else { + $result = $schema->modifyColumn($tableRaw, $id, $sqlType); + } - $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); + $sql = $result->query; - return $this->getPDO() - ->prepare($sql) - ->execute(); + try { + return $this->executeStatement($sql, Event::AttributeUpdate); + } catch (PDOException $error) { + throw $this->processException($error); + } } /** - * Create Index + * @param array $indexAttributeTypes + * @param array $collation * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - * @return bool * @throws DatabaseException */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool { - $metadataCollection = new Document(['$id' => Database::METADATA]); + $metadataCollection = new Document([Document::ID => Database::METADATA]); $collection = $this->getDocument($metadataCollection, $collection); if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); } - /** - * We do not have sequence's added to list, since we check only for array field - */ - $collectionAttributes = \json_decode($collection->getAttribute('attributes', []), true); + $collectionAttributes = self::collectionAttributes($collection); + $id = $this->filter($index->key); + $type = $index->type; - $id = $this->filter($id); + $schema = $this->schema(); + $tableName = $this->getTableRaw($collection->getId()); - foreach ($attributes as $i => $attr) { - $attribute = null; + $columns = []; + foreach ($index->attributes as $position => $key) { + $array = false; foreach ($collectionAttributes as $collectionAttribute) { - if (\strtolower($collectionAttribute['$id']) === \strtolower($attr)) { - $attribute = $collectionAttribute; + if (\strtolower($collectionAttribute->key) === \strtolower($key)) { + $array = $collectionAttribute->array; break; } } - $order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i]; - $length = empty($lengths[$i]) ? '' : '(' . (int)$lengths[$i] . ')'; - - $attr = $this->getInternalKeyForAttribute($attr); - $attr = $this->filter($attr); - - $attributes[$i] = "`{$attr}`{$length} {$order}"; + $columns[] = $this->compileIndexColumn( + $this->filter($this->getInternalKeyForAttribute($key)), + $array, + $index->lengths[$position] ?? 0, + $type === IndexType::Fulltext ? '' : ($index->orders[$position]->value ?? ''), + ); + } - if ($this->getSupportForCastIndexArray() && !empty($attribute['array'])) { - $attributes[$i] = '(CAST(`' . $attr . '` AS char(' . Database::MAX_ARRAY_INDEX_LENGTH . ') ARRAY))'; - } + if ($this->sharedTables && $type !== IndexType::Fulltext && $type !== IndexType::Spatial) { + \array_unshift($columns, $this->quote(Storage::TENANT)); } - $sqlType = match ($type) { - Database::INDEX_KEY => 'INDEX', - Database::INDEX_UNIQUE => 'UNIQUE INDEX', - Database::INDEX_FULLTEXT => 'FULLTEXT INDEX', - Database::INDEX_SPATIAL => 'SPATIAL INDEX', - default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL), + $unique = $type === IndexType::Unique; + $schemaType = match ($type) { + IndexType::Key, IndexType::Unique => '', + IndexType::Fulltext => 'fulltext', + IndexType::Spatial => 'spatial', + default => throw new DatabaseException('Unknown index type: '.$type->value.'. Must be one of '.IndexType::Key->value.', '.IndexType::Unique->value.', '.IndexType::Fulltext->value.', '.IndexType::Spatial->value), }; - $attributes = \implode(', ', $attributes); - - if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT && $type !== Database::INDEX_SPATIAL) { - // Add tenant as first index column for best performance - $attributes = "_tenant, {$attributes}"; - } - - $sql = "CREATE {$sqlType} `{$id}` ON {$this->getSQLTable($collection->getId())} ({$attributes})"; - $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); + $result = $schema->createIndex( + $tableName, + $id, + [], + unique: $unique, + type: $schemaType, + rawColumns: $columns, + ); + $sql = $result->query; try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - // Existing rows violate the new unique index. Classified here because - // processException() can't parse the key from a localized message. - if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) { - throw new UniqueException('Unique index violation', $e->getCode(), $e); + return $this->executeStatement($sql, Event::IndexCreate); + } catch (PDOException $error) { + if ($error->getCode() === '23000' && isset($error->errorInfo[1]) && $error->errorInfo[1] === 1062) { + throw new UniqueException(UniqueException::MESSAGE, $error->getCode(), $error); } - throw $this->processException($e); + throw $this->processException($error); } } /** - * Delete Index - * - * @param string $collection - * @param string $id - * @return bool + * Render one key part of an index. Parts are rendered in the caller's order and handed to the + * schema builder as raw columns, because it places raw columns after all named ones. + */ + private function compileIndexColumn(string $column, bool $array, int $length, string $order): string + { + if ($array && $this->supports(Capability::IndexArrayCast)) { + return '(CAST('.$this->quote($column).' AS char('.Database::MAX_ARRAY_INDEX_LENGTH.') ARRAY))'; + } + + $part = $this->quote($column); + if ($length > 0) { + $part .= '('.$length.')'; + } + if ($order !== '') { + $part .= ' '.$order; + } + + return $part; + } + + /** * @throws Exception * @throws PDOException */ - public function deleteIndex(string $collection, string $id): bool + #[\Override] + public function deleteIndex(string $collection, string $key): bool { $name = $this->filter($collection); - $id = $this->filter($id); + $id = $this->filter($key); - $sql = "ALTER TABLE {$this->getSQLTable($name)} DROP INDEX `{$id}`;"; + $schema = $this->schema(); + $result = $schema->dropIndex($this->getTableRaw($name), $id); - $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); + $sql = $result->query; try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::IndexDelete); } catch (PDOException $e) { - if ($e->getCode() === "42000" && $e->errorInfo[1] === 1091) { + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) { return true; } @@ -822,303 +594,177 @@ public function deleteIndex(string $collection, string $id): bool } /** - * Create Document - * - * @param Document $collection - * @param Document $document - * @return Document + * @throws Exception + */ + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + $collection = $this->filter($collection); + $old = $this->filter($old); + $new = $this->filter($new); + + $result = $this->schema()->renameIndex($this->getTableRaw($collection), $old, $new); + $sql = $result->query; + + return $this->executeStatement($sql, Event::IndexRename); + } + + /** * @throws Exception * @throws PDOException * @throws DuplicateException * @throws \Throwable */ + #[\Override] public function createDocument(Document $collection, Document $document): Document { try { + $this->syncWriteHooks(); + $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); + $attributes[Storage::CREATED_AT] = $document->getCreatedAt(); + $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt(); + $attributes[Storage::PERMISSIONS] = \json_encode($document->getPermissions()); + $name = $this->filter($collection); - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + $builder = $this->dialectBuilder()->into($this->getTableRaw($name)); + $row = [Storage::UID => $document->getId()]; + + if (! empty($document->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); } - $name = $this->filter($collection); - $columns = ''; - $columnNames = ''; + $spatialMap = \array_fill_keys($spatialAttributes, true); - /** - * Insert Attributes - */ - $bindIndex = 0; foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); - $bindKey = 'key_' . $bindIndex; - $columns .= "`{$column}`, "; - if (in_array($attribute, $spatialAttributes)) { - $columnNames .= $this->getSpatialGeomFromText(':' . $bindKey) . ", "; + + if (isset($spatialMap[$attribute])) { + $value = $this->encodeSpatialWriteValue($value); + $value = (\is_bool($value)) ? (int) $value : $value; + $row[$column] = $value; + $builder->insertColumnExpression($column, $this->getSpatialGeometryFromText('?')); } else { - $columnNames .= ':' . $bindKey . ', '; + if (\is_array($value)) { + $value = \json_encode($value); + } + $value = (\is_bool($value)) ? (int) $value : $value; + $row[$column] = $value; } - $bindIndex++; - } - - // Insert internal ID if set - if (!empty($document->getSequence())) { - $bindKey = '_id'; - $columns .= "_id, "; - $columnNames .= ':' . $bindKey . ', '; } - $sql = " - INSERT INTO {$this->getSQLTable($name)} ({$columns} _uid) - VALUES ({$columnNames} :_uid) - "; + $row = $this->decorateRow($row, $document); + $builder->set($row); + $result = $builder->insert(); + $statement = $this->executeResult($result, Event::DocumentCreate); - $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql); + $this->execute($statement); - $stmt = $this->getPDO()->prepare($sql); + $document[Document::SEQUENCE] = $this->getDriver()->lastInsertId(); - $stmt->bindValue(':_uid', $document->getId()); - - if (!empty($document->getSequence())) { - $stmt->bindValue(':_id', $document->getSequence()); + if (empty($document[Document::SEQUENCE])) { + throw new DatabaseException('Error creating document empty "'.Document::SEQUENCE.'"'); } - $attributeIndex = 0; - foreach ($attributes as $value) { - if (\is_array($value)) { - $value = \json_encode($value); + $context = $this->writeContext(); + try { + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $context)); + } catch (PDOException $e) { + $isOrphanedPermission = $e->getCode() === '23000' + && isset($e->errorInfo[1]) + && $e->errorInfo[1] === 1062 + && \str_contains($e->getMessage(), Storage::INDEX_1); + + if (! $isOrphanedPermission) { + throw $e; } - $bindKey = 'key_' . $attributeIndex; - $attribute = $this->filter($attribute); - $value = (\is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; - } + // Clean up orphaned permissions from a previous failed delete, then retry + $cleanupBuilder = $this->newBuilder(Storage::permissionsTable($name)); + $cleanupBuilder->filter([BaseQuery::equal(Storage::PERMISSIONS_DOCUMENT, [$document->getId()])]); + $cleanupResult = $cleanupBuilder->delete(); + $cleanupStmt = $this->executeResult($cleanupResult, Event::PermissionsDelete); + $this->execute($cleanupStmt); - $permissions = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { - $tenantBind = $this->sharedTables ? ", :_tenant" : ''; - $permission = \str_replace('"', '', $permission); - $permission = "('{$type}', '{$permission}', :_uid {$tenantBind})"; - $permissions[] = $permission; - } + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $context)); } + } catch (PDOException $e) { + throw $this->processException($e); + } - if (!empty($permissions)) { - $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $permissions = \implode(', ', $permissions); - - $sqlPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) - VALUES {$permissions}; - "; - - $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); - $stmtPermissions->bindValue(':_uid', $document->getId()); - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $document->getTenant()); - } - } - - $stmt->execute(); - - $document['$sequence'] = $this->pdo->lastInsertId(); - - if (empty($document['$sequence'])) { - throw new DatabaseException('Error creating document empty "$sequence"'); - } - - if (isset($stmtPermissions)) { - try { - $stmtPermissions->execute(); - } catch (PDOException $e) { - $isOrphanedPermission = $e->getCode() === '23000' - && isset($e->errorInfo[1]) - && $e->errorInfo[1] === 1062 - && \str_contains($e->getMessage(), '_index1'); - - if (!$isOrphanedPermission) { - throw $e; - } - - // Clean up orphaned permissions from a previous failed delete, then retry - $sql = "DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)}"; - $cleanup = $this->getPDO()->prepare($sql); - $cleanup->bindValue(':_uid', $document->getId()); - if ($this->sharedTables) { - $cleanup->bindValue(':_tenant', $document->getTenant()); - } - $cleanup->execute(); - - $stmtPermissions->execute(); - } - } - } catch (PDOException $e) { - throw $this->processException($e); - } - - return $document; - } + return $document; + } /** - * Update Document - * - * @param Document $collection - * @param string $id - * @param Document $document - * @param bool $skipPermissions - * @return Document * @throws Exception * @throws PDOException * @throws DuplicateException * @throws \Throwable */ + #[\Override] public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { try { + $this->syncWriteHooks(); + $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); - $attributes['_uid'] = $document->getId(); + $attributes[Storage::CREATED_AT] = $document->getCreatedAt(); + $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt(); + $attributes[Storage::PERMISSIONS] = json_encode($document->getPermissions()); $name = $this->filter($collection); - $columns = ''; - - if (!$skipPermissions) { - $newUid = $document->offsetExists('$id') ? $document->getId() : $id; - - $sql = " - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); - - $stmtRemovePermissions = $this->getPDO()->prepare($sql); - $stmtRemovePermissions->bindValue(':_uid', $id); - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $document->getTenant()); - } - - $values = []; - $binds = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $permission) { - $tenantPlaceholder = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$tenantPlaceholder})"; - $binds[":_add_{$type}_{$i}"] = $permission; - } - } - - if (!empty($values)) { - $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - - $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$tenantColumn}) - VALUES " . \implode(', ', $values); - - $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); - - $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $newUid); - if ($this->sharedTables) { - $stmtAddPermissions->bindValue(":_tenant", $document->getTenant()); - } - - foreach ($binds as $key => $permission) { - $stmtAddPermissions->bindValue($key, $permission); - } - } - } - - /** - * Update Attributes - */ - $keyIndex = 0; - $operatorBinds = []; + $operators = []; foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); - - // Check if this is an operator or regular attribute if (Operator::isOperator($value)) { - $operatorSQL = $this->getOperatorSQL($column, $value, $operatorBinds); - $columns .= $operatorSQL . ','; - } else { - $bindKey = 'key_' . $keyIndex; - - if (in_array($attribute, $spatialAttributes)) { - $columns .= "`{$column}`" . '=' . $this->getSpatialGeomFromText(':' . $bindKey) . ','; - } else { - $columns .= "`{$column}`" . '=:' . $bindKey . ','; - } - $keyIndex++; + $operators[$attribute] = $value; } } - $sql = " - UPDATE {$this->getSQLTable($name)} - SET " . \rtrim($columns, ',') . " - WHERE _id=:_sequence - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); - - $stmt = $this->getPDO()->prepare($sql); - - $stmt->bindValue(':_sequence', $document->getSequence()); - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); + $builder = $this->newBuilder($name); + $regularRow = []; + if ($document->getId() !== $id) { + $regularRow[Storage::UID] = $document->getId(); } - $keyIndex = 0; + $spatialMap = \array_fill_keys($spatialAttributes, true); + foreach ($attributes as $attribute => $value) { - // Handle operators separately - if (Operator::isOperator($value)) { - continue; - } + $column = $this->filter($attribute); - // Convert spatial arrays to WKT, json_encode non-spatial arrays - if (\in_array($attribute, $spatialAttributes, true)) { + if (isset($operators[$attribute])) { + $operation = $operators[$attribute]; + if ($operation instanceof Operator) { + $expression = $this->getOperatorBuilderExpression($column, $operation); + $builder->setRaw($column, $expression->sql, $expression->bindings); + } + } elseif (isset($spatialMap[$attribute])) { + $value = $this->encodeSpatialWriteValue($value); + $value = (\is_bool($value)) ? (int) $value : $value; + $builder->setRaw($column, $this->getSpatialGeometryFromText('?'), [$value]); + } else { if (\is_array($value)) { - $value = $this->convertArrayToWKT($value); + $value = \json_encode($value); } - } elseif (is_array($value)) { - $value = json_encode($value); + $value = (\is_bool($value)) ? (int) $value : $value; + $regularRow[$column] = $value; } - - $bindKey = 'key_' . $keyIndex; - $value = (is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $keyIndex++; } - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + $builder->set($regularRow); + $filters = [BaseQuery::equal(Storage::SEQUENCE, [$document->getSequence()])]; + $builder->filter($filters); + $result = $builder->update(); + $statement = $this->executeResult($result, Event::DocumentUpdate); - $stmt->execute(); - - if (isset($stmtRemovePermissions)) { - $stmtRemovePermissions->execute(); - } - if (isset($stmtAddPermissions)) { - $stmtAddPermissions->execute(); - } + $this->execute($statement); + $context = $this->writeContext($skipPermissions ? [$document->getId() => true] : []); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpdate($name, $id, $document, $context)); } catch (PDOException $e) { throw $this->processException($e); } @@ -1127,1028 +773,653 @@ public function updateDocument(Document $collection, string $id, Document $docum } /** - * @param string $tableName - * @param string $columns - * @param array $batchKeys - * @param array $attributes - * @param array $bindValues - * @param string $attribute - * @param array $operators - * @return mixed * @throws DatabaseException */ - public function getUpsertStatement( - string $tableName, - string $columns, - array $batchKeys, - array $attributes, - array $bindValues, - string $attribute = '', - array $operators = [] - ): mixed { - $getUpdateClause = function (string $attribute, bool $increment = false): string { - $attribute = $this->quote($this->filter($attribute)); - - if ($increment) { - $new = "{$attribute} + VALUES({$attribute})"; - } else { - $new = "VALUES({$attribute})"; - } - - if ($this->sharedTables) { - return "{$attribute} = IF(_tenant = VALUES(_tenant), {$new}, {$attribute})"; - } + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + if ($milliseconds <= 0) { + throw new DatabaseException('Timeout must be greater than 0'); + } - return "{$attribute} = {$new}"; - }; + if ($event === Event::All) { + $this->applyTimeout($milliseconds); + } - $updateColumns = []; - $operatorBinds = []; + $this->setTimeoutState($milliseconds, $event); + } - if (!empty($attribute)) { - // Increment specific column by its new value in place - $updateColumns = [ - $getUpdateClause($attribute, increment: true), - $getUpdateClause('_updatedAt'), - ]; - } else { - foreach (\array_keys($attributes) as $attr) { - /** - * @var string $attr - */ - $filteredAttr = $this->filter($attr); - - if (isset($operators[$attr])) { - $operatorSQL = $this->getOperatorSQL($filteredAttr, $operators[$attr], $operatorBinds); - if ($operatorSQL !== null) { - $updateColumns[] = $operatorSQL; - } - } else { - if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) { - $updateColumns[] = $getUpdateClause($filteredAttr); - } - } - } + #[\Override] + public function clearTimeout(Event $event = Event::All): void + { + if ($event === Event::All) { + $this->applyTimeout(0); } - $stmt = $this->getPDO()->prepare( - " - INSERT INTO {$this->getSQLTable($tableName)} {$columns} - VALUES " . \implode(', ', $batchKeys) . " - ON DUPLICATE KEY UPDATE - " . \implode(', ', $updateColumns) - ); + $this->clearTimeoutState($event); + } - foreach ($bindValues as $key => $binding) { - $stmt->bindValue($key, $binding, $this->getPDOType($binding)); - } + #[\Override] + protected function getMaxPointSize(): int + { + // https://dev.mysql.com/doc/refman/8.4/en/gis-data-formats.html#gis-internal-format + return 25; + } - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + #[\Override] + public function encode(mixed $value, ColumnType $type): string + { + return Wkt::encode($value, $type); + } - return $stmt; + /** + * @return array + */ + #[\Override] + public function decode(string $value, ColumnType $type): array + { + return match ($type) { + ColumnType::Point => $this->decodePoint($value), + ColumnType::Linestring => $this->decodeLinestring($value), + ColumnType::Polygon => $this->decodePolygon($value), + default => throw new DatabaseException('Unknown spatial type: '.$type->value), + }; } /** - * Increase or decrease an attribute value + * Decode a WKB or WKT POINT into a coordinate array [x, y]. * - * @param string $collection - * @param string $id - * @param string $attribute - * @param int|float $value - * @param string $updatedAt - * @param int|float|null $min - * @param int|float|null $max - * @return bool - * @throws DatabaseException + * @param string $wkb The WKB binary or WKT string + * @return array + * + * @throws DatabaseException If the input is invalid. */ - public function increaseDocumentAttribute( - string $collection, - string $id, - string $attribute, - int|float $value, - string $updatedAt, - int|float|null $min = null, - int|float|null $max = null - ): bool { - $name = $this->filter($collection); - $attribute = $this->filter($attribute); - - $sqlMax = $max !== null ? " AND `{$attribute}` <= :max" : ''; - $sqlMin = $min !== null ? " AND `{$attribute}` >= :min" : ''; - - $sql = " - UPDATE {$this->getSQLTable($name)} - SET - `{$attribute}` = `{$attribute}` + :val, - `_updatedAt` = :updatedAt - WHERE _uid = :_uid - {$this->getTenantQuery($collection)} - "; + protected function decodePoint(string $wkb): array + { + if (str_starts_with(strtoupper($wkb), 'POINT(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, $end - $start); + $coords = explode(' ', trim($inside)); - $sql .= $sqlMax . $sqlMin; + return [(float) $coords[0], (float) $coords[1]]; + } - $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); + /** + * [0..3] SRID (4 bytes, little-endian) + * [4] Byte order (1 = little-endian, 0 = big-endian) + * [5..8] Geometry type (with SRID flag bit) + * [9..] Geometry payload (coordinates, etc.) + */ + if (strlen($wkb) < 25) { + throw new DatabaseException('Invalid WKB: too short for POINT'); + } - $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':_uid', $id); - $stmt->bindValue(':val', $value); - $stmt->bindValue(':updatedAt', $updatedAt); + // 4 bytes SRID first → skip to byteOrder at offset 4 + $byteOrder = ord($wkb[4]); + $littleEndian = ($byteOrder === 1); - if ($max !== null) { - $stmt->bindValue(':max', $max); - } - if ($min !== null) { - $stmt->bindValue(':min', $min); + if (! $littleEndian) { + throw new DatabaseException('Only little-endian WKB supported'); } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); + + // After SRID (4) + byteOrder (1) + type (4) = 9 bytes + $coordsBin = substr($wkb, 9, 16); + if (strlen($coordsBin) !== 16) { + throw new DatabaseException('Invalid WKB: missing coordinate bytes'); } - try { - $stmt->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + $coords = unpack('d2', $coordsBin); + if ($coords === false || ! isset($coords[1], $coords[2])) { + throw new DatabaseException('Invalid WKB: failed to unpack coordinates'); } - return true; + return [(float) (is_numeric($coords[1]) ? $coords[1] : 0), (float) (is_numeric($coords[2]) ? $coords[2] : 0)]; } /** - * Delete Document + * Decode a WKB or WKT LINESTRING into an array of coordinate pairs. * - * @param string $collection - * @param string $id - * @return bool - * @throws Exception - * @throws PDOException + * @param string $wkb The WKB binary or WKT string + * @return array> + * + * @throws DatabaseException If the input is invalid. */ - public function deleteDocument(string $collection, string $id): bool + protected function decodeLinestring(string $wkb): array { - try { - $name = $this->filter($collection); - - $sql = " - DELETE FROM {$this->getSQLTable($name)} - WHERE _uid = :_uid - {$this->getTenantQuery($collection)} - "; + if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, $end - $start); - $sql = $this->trigger(Database::EVENT_DOCUMENT_DELETE, $sql); + $points = explode(',', $inside); - $stmt = $this->getPDO()->prepare($sql); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); - $stmt->bindValue(':_uid', $id); - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } + return [(float) $coords[0], (float) $coords[1]]; + }, $points); + } - $sql = " - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; + // Skip 1 byte (endianness) + 4 bytes (type) + 4 bytes (SRID) + $offset = 9; - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + // Number of points (4 bytes little-endian) + $numPointsArr = unpack('V', substr($wkb, $offset, 4)); + if ($numPointsArr === false || ! isset($numPointsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of points'); + } - $stmtPermissions = $this->getPDO()->prepare($sql); - $stmtPermissions->bindValue(':_uid', $id); + $numPoints = $numPointsArr[1]; + $offset += 4; - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); - } + $points = []; + for ($i = 0; $i < $numPoints; $i++) { + $xArr = unpack('d', substr($wkb, $offset, 8)); + $yArr = unpack('d', substr($wkb, $offset + 8, 8)); - if (!$stmt->execute()) { - throw new DatabaseException('Failed to delete document'); + if ($xArr === false || ! isset($xArr[1]) || $yArr === false || ! isset($yArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack point coordinates'); } - $deleted = $stmt->rowCount(); - - if (!$stmtPermissions->execute()) { - throw new DatabaseException('Failed to delete permissions'); - } - } catch (\Throwable $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + $points[] = [(float) (is_numeric($xArr[1]) ? $xArr[1] : 0), (float) (is_numeric($yArr[1]) ? $yArr[1] : 0)]; + $offset += 16; } - return $deleted; + return $points; } /** - * Handle distance spatial queries + * Decode a WKB or WKT POLYGON into an array of rings, each containing coordinate pairs. * - * @param Query $query - * @param array $binds - * @param string $attribute - * @param string $type - * @param string $alias - * @param string $placeholder - * @return string - */ - protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string - { - $distanceParams = $query->getValues()[0]; - $wkt = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_0"] = $wkt; - $binds[":{$placeholder}_1"] = $distanceParams[1]; - - $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true; - - switch ($query->getMethod()) { - case Query::TYPE_DISTANCE_EQUAL: - $operator = '='; - break; - case Query::TYPE_DISTANCE_NOT_EQUAL: - $operator = '!='; - break; - case Query::TYPE_DISTANCE_GREATER_THAN: - $operator = '>'; - break; - case Query::TYPE_DISTANCE_LESS_THAN: - $operator = '<'; - break; - default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); - } - - if ($useMeters) { - $wktType = $this->getSpatialTypeFromWKT($wkt); - $attrType = strtolower($type); - if ($wktType != Database::VAR_POINT || $attrType != Database::VAR_POINT) { - throw new QueryException('Distance in meters is not supported between '.$attrType . ' and '. $wktType); - } - return "ST_DISTANCE_SPHERE({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::EARTH_RADIUS . ") {$operator} :{$placeholder}_1"; - } - return "ST_Distance({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ") {$operator} :{$placeholder}_1"; - } - - /** - * Handle spatial queries + * @param string $wkb The WKB binary or WKT string + * @return array>> * - * @param Query $query - * @param array $binds - * @param string $attribute - * @param string $type - * @param string $alias - * @param string $placeholder - * @return string + * @throws DatabaseException If the input is invalid. */ - protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string + protected function decodePolygon(string $wkb): array { - switch ($query->getMethod()) { - case Query::TYPE_CROSSES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_NOT_CROSSES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + // POLYGON((x1,y1),(x2,y2)) + if (str_starts_with($wkb, 'POLYGON((')) { + $start = strpos($wkb, '((') + 2; + $end = strrpos($wkb, '))'); + $inside = substr($wkb, $start, $end - $start); - case Query::TYPE_DISTANCE_EQUAL: - case Query::TYPE_DISTANCE_NOT_EQUAL: - case Query::TYPE_DISTANCE_GREATER_THAN: - case Query::TYPE_DISTANCE_LESS_THAN: - return $this->handleDistanceSpatialQueries($query, $binds, $attribute, $type, $alias, $placeholder); + $rings = \preg_split('/\)\s*,\s*\(/', $inside) ?: [$inside]; - case Query::TYPE_INTERSECTS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + return array_map(function ($ring) { + $points = explode(',', $ring); - case Query::TYPE_NOT_INTERSECTS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + return array_map(function ($point) { + $coords = explode(' ', trim($point)); - case Query::TYPE_OVERLAPS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_NOT_OVERLAPS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_TOUCHES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_NOT_TOUCHES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_EQUAL: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_NOT_EQUAL: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_CONTAINS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_NOT_CONTAINS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; - - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: - return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; - - default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + return [(float) $coords[0], (float) $coords[1]]; + }, $points); + }, $rings); } - } - - /** - * Get SQL Condition - * - * @param Query $query - * @param array $binds - * @return string - * @throws Exception - */ - protected function getSQLCondition(Query $query, array &$binds, ?string $forCollection = null): string - { - $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - - $attribute = $query->getAttribute(); - $attribute = $this->filter($attribute); - $attribute = $this->quote($attribute); - $alias = $this->quote(Query::DEFAULT_ALIAS); - $placeholder = ID::unique(); - if ($query->isSpatialAttribute()) { - return $this->handleSpatialQueries($query, $binds, $attribute, $query->getAttributeType(), $alias, $placeholder); + if (str_starts_with($wkb, '0x') || ctype_xdigit($wkb)) { + $wkb = hex2bin(str_starts_with($wkb, '0x') ? substr($wkb, 2) : $wkb); + if ($wkb === false) { + throw new DatabaseException('Invalid hex WKB'); + } } - switch ($query->getMethod()) { - case Query::TYPE_OR: - case Query::TYPE_AND: - $conditions = []; - /* @var $q Query */ - foreach ($query->getValue() as $q) { - $conditions[] = $this->getSQLCondition($q, $binds, $forCollection); - } - - $method = strtoupper($query->getMethod()); - - return empty($conditions) ? '' : ' '. $method .' (' . implode(' AND ', $conditions) . ')'; - - case Query::TYPE_SEARCH: - $fulltextValue = $this->getFulltextValue($query->getValue()); - if ($fulltextValue === '') { - return '0 = 1'; - } - $binds[":{$placeholder}_0"] = $fulltextValue; - - return "MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; - - case Query::TYPE_NOT_SEARCH: - $fulltextValue = $this->getFulltextValue($query->getValue()); - if ($fulltextValue === '') { - return '1 = 1'; - } - $binds[":{$placeholder}_0"] = $fulltextValue; - - return "NOT (MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE))"; - - case Query::TYPE_BETWEEN: - $binds[":{$placeholder}_0"] = $query->getValues()[0]; - $binds[":{$placeholder}_1"] = $query->getValues()[1]; - - return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; - - case Query::TYPE_NOT_BETWEEN: - $binds[":{$placeholder}_0"] = $query->getValues()[0]; - $binds[":{$placeholder}_1"] = $query->getValues()[1]; - - return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; - - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: - - return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; - case Query::TYPE_CONTAINS_ALL: - if ($query->onArray()) { - $binds[":{$placeholder}_0"] = json_encode($query->getValues()); - return "JSON_CONTAINS({$alias}.{$attribute}, :{$placeholder}_0)"; - } - // no break - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: - case Query::TYPE_NOT_CONTAINS: - if ($query->onArray()) { - $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; - - if ($this->getSupportForJSONOverlaps()) { - $binds[":{$placeholder}_0"] = json_encode($query->getValues()); - return $isNot - ? "NOT (JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0))" - : "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)"; - } + if (strlen($wkb) < 21) { + throw new DatabaseException('WKB too short to be a POLYGON'); + } - // JSON_CONTAINS per element OR'd together — exact - // element match without LIKE's substring false positives - // (`%2%` matching `[12, 200]`, `%"apple"%` matching - // `["pineapple"]`). - $conditions = []; - foreach ($query->getValues() as $key => $value) { - $binds[":{$placeholder}_{$key}"] = json_encode($value); - $conditions[] = "JSON_CONTAINS({$alias}.{$attribute}, :{$placeholder}_{$key})"; - } - if (empty($conditions)) { - return ''; - } - $expression = '(' . implode(' OR ', $conditions) . ')'; - return $isNot ? "NOT {$expression}" : $expression; - } - // no break - default: - $conditions = []; - $isNotQuery = in_array($query->getMethod(), [ - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_NOT_CONTAINS - ]); - - foreach ($query->getValues() as $key => $value) { - $value = match ($query->getMethod()) { - Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', - Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', - Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS => '%' . $this->escapeWildcards($value) . '%', - default => $value - }; - - $binds[":{$placeholder}_{$key}"] = $value; - if ($isNotQuery) { - $conditions[] = "{$alias}.{$attribute} NOT {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; - } else { - $conditions[] = "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; - } - } + // MySQL SRID-aware WKB layout: 4 bytes SRID prefix + $offset = 4; - $separator = $isNotQuery ? ' AND ' : ' OR '; - return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; + $byteOrder = ord($wkb[$offset]); + if ($byteOrder !== 1) { + throw new DatabaseException('Only little-endian WKB supported'); } - } + $offset += 1; - /** - * Get SQL Type - * - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param bool $required - * @return string - * @throws DatabaseException - */ - protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string - { - if (in_array($type, Database::SPATIAL_TYPES)) { - return $this->getSpatialSQLType($type, $required); - } - if ($array === true) { - return 'JSON'; + $typeArr = unpack('V', substr($wkb, $offset, 4)); + if ($typeArr === false || ! isset($typeArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack geometry type'); } - switch ($type) { - case Database::VAR_ID: - return 'BIGINT UNSIGNED'; + $type = \is_numeric($typeArr[1]) ? (int) $typeArr[1] : 0; + $hasSRID = ($type & 0x20000000) === 0x20000000; + $geomType = $type & 0xFF; + $offset += 4; - case Database::VAR_STRING: - // $size = $size * 4; // Convert utf8mb4 size to bytes - if ($size > Database::MAX_MEDIUMTEXT_BYTES) { - return 'LONGTEXT'; - } + if ($geomType !== 3) { // 3 = POLYGON + throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); + } - if ($size > Database::MAX_TEXT_BYTES) { - return 'MEDIUMTEXT'; - } + if ($hasSRID) { + $offset += 4; + } - if ($size > $this->getMaxVarcharLength()) { - return 'TEXT'; - } + $numRingsArr = unpack('V', substr($wkb, $offset, 4)); - return "VARCHAR({$size})"; + if ($numRingsArr === false || ! isset($numRingsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of rings'); + } - case Database::VAR_VARCHAR: - if ($size <= 0) { - throw new DatabaseException('VARCHAR size ' . $size . ' is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.'); - } - if ($size > $this->getMaxVarcharLength()) { - throw new DatabaseException('VARCHAR size ' . $size . ' exceeds maximum varchar length ' . $this->getMaxVarcharLength() . '. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.'); - } - return "VARCHAR({$size})"; + $numRings = $numRingsArr[1]; + $offset += 4; - case Database::VAR_TEXT: - return 'TEXT'; + $rings = []; - case Database::VAR_MEDIUMTEXT: - return 'MEDIUMTEXT'; + for ($r = 0; $r < $numRings; $r++) { + $numPointsArr = unpack('V', substr($wkb, $offset, 4)); - case Database::VAR_LONGTEXT: - return 'LONGTEXT'; + if ($numPointsArr === false || ! isset($numPointsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of points'); + } - case Database::VAR_INTEGER: // We don't support zerofill: https://stackoverflow.com/a/5634147/2299554 - $signed = ($signed) ? '' : ' UNSIGNED'; + $numPoints = $numPointsArr[1]; + $offset += 4; + $ring = []; - if ($size >= 8) { // INT = 4 bytes, BIGINT = 8 bytes - return 'BIGINT' . $signed; + for ($p = 0; $p < $numPoints; $p++) { + $xArr = unpack('d', substr($wkb, $offset, 8)); + if ($xArr === false) { + throw new DatabaseException('Failed to unpack X coordinate from WKB.'); } - return 'INT' . $signed; - - case Database::VAR_BIGINT: - $signed = ($signed) ? '' : ' UNSIGNED'; - return 'BIGINT' . $signed; - - case Database::VAR_FLOAT: - $signed = ($signed) ? '' : ' UNSIGNED'; - return 'DOUBLE' . $signed; + $x = (float) (is_numeric($xArr[1]) ? $xArr[1] : 0); - case Database::VAR_BOOLEAN: - return 'TINYINT(1)'; + $yArr = unpack('d', substr($wkb, $offset + 8, 8)); + if ($yArr === false) { + throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); + } - case Database::VAR_RELATIONSHIP: - return 'VARCHAR(255)'; + $y = (float) (is_numeric($yArr[1]) ? $yArr[1] : 0); - case Database::VAR_DATETIME: - return 'DATETIME(3)'; + $ring[] = [$x, $y]; + $offset += 16; + } - default: - throw new DatabaseException('Unknown type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_VARCHAR . ', ' . Database::VAR_TEXT . ', ' . Database::VAR_MEDIUMTEXT . ', ' . Database::VAR_LONGTEXT . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_BIGINT . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); + $rings[] = $ring; } - } - - /** - * Get PDO Type - * - * @param mixed $value - * @return int - * @throws Exception - */ - protected function getPDOType(mixed $value): int - { - return match (gettype($value)) { - 'string','double' => \PDO::PARAM_STR, - 'integer', 'boolean' => \PDO::PARAM_INT, - 'NULL' => \PDO::PARAM_NULL, - default => throw new DatabaseException('Unknown PDO Type for ' . \gettype($value)), - }; - } - - /** - * Get the SQL function for random ordering - * - * @return string - */ - protected function getRandomOrder(): string - { - return 'RAND()'; - } - - /** - * Size of POINT spatial type - * - * @return int - */ - protected function getMaxPointSize(): int - { - // https://dev.mysql.com/doc/refman/8.4/en/gis-data-formats.html#gis-internal-format - return 25; - } - public function getMinDateTime(): \DateTime - { - return new \DateTime('1000-01-01 00:00:00'); + return $rings; } - public function getMaxDateTime(): \DateTime - { - return new \DateTime('9999-12-31 23:59:59'); - } + private const string TIMEOUT_SETTING = 'timeout'; - /** - * Is fulltext Wildcard index supported? - * - * @return bool - */ - public function getSupportForFulltextWildcardIndex(): bool - { - return true; - } + /** The session timeout last set, in milliseconds. */ + private int $appliedTimeout = 0; /** - * Does the adapter handle Query Array Overlaps? - * - * @return bool + * The Swoole PDOProxy round the timeout was set in: the proxy's reconnects open + * sessions at the server default, while Utopia\Database\PDO replays the timeout + * on the sessions its reconnects open. */ - public function getSupportForJSONOverlaps(): bool - { - return true; - } - - public function getSupportForIntegerBooleans(): bool - { - return true; - } + private int $appliedRound = 0; /** - * Are timeouts supported? - * - * @return bool + * MySQL and MariaDB run every statement of a multi-statement string, but report only the first one's error from + * execute(); a later statement's error surfaces when its result is reached. Each result is reached here, so a + * statement that fails after another succeeded fails the call instead of being lost. */ - public function getSupportForTimeouts(): bool + #[\Override] + protected function executeStatement(string $sql, Event $event): bool { - return true; - } + $statement = $this->prepareStatement($sql, $event); + $executed = $this->execute($statement); - public function getSupportForUpserts(): bool - { - return true; - } - - public function getSupportForUpsertOnUniqueIndex(): bool - { - return true; - } - - public function getSupportForSchemaAttributes(): bool - { - return true; - } - - public function getSupportForSchemaIndexes(): bool - { - return true; - } - - public function getSchemaIndexes(string $collection): array - { - $schema = $this->getDatabase(); - $collection = $this->getNamespace() . '_' . $this->filter($collection); - - try { - $stmt = $this->getPDO()->prepare(' - SELECT - INDEX_NAME as indexName, - COLUMN_NAME as columnName, - NON_UNIQUE as nonUnique, - SEQ_IN_INDEX as seqInIndex, - INDEX_TYPE as indexType, - SUB_PART as subPart - FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table - ORDER BY INDEX_NAME, SEQ_IN_INDEX - '); - $stmt->bindParam(':schema', $schema); - $stmt->bindParam(':table', $collection); - $stmt->execute(); - $rows = $stmt->fetchAll(); - $stmt->closeCursor(); - - $grouped = []; - foreach ($rows as $row) { - $name = $row['indexName']; - if (!isset($grouped[$name])) { - $grouped[$name] = [ - '$id' => $name, - 'indexName' => $name, - 'indexType' => $row['indexType'], - 'nonUnique' => (int)$row['nonUnique'], - 'columns' => [], - 'lengths' => [], - ]; - } - $grouped[$name]['columns'][] = $row['columnName']; - $grouped[$name]['lengths'][] = $row['subPart'] !== null ? (int)$row['subPart'] : null; - } - - return \array_map(fn ($idx) => new Document($idx), \array_values($grouped)); - } catch (PDOException $e) { - throw new DatabaseException('Failed to get schema indexes', $e->getCode(), $e); - } - } - - /** - * Set max execution time - * @param int $milliseconds - * @param string $event - * @return void - * @throws DatabaseException - */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void - { - if (!$this->getSupportForTimeouts()) { - return; - } - if ($milliseconds <= 0) { - throw new DatabaseException('Timeout must be greater than 0'); + while ($statement->nextRowset()) { } - $this->timeout = $milliseconds; - - $seconds = $milliseconds / 1000; - - $this->before($event, 'timeout', function ($sql) use ($seconds) { - return "SET STATEMENT max_statement_time = {$seconds} FOR " . $sql; - }); + return $executed; } /** - * @return string + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement */ - public function getConnectionId(): string - { - $stmt = $this->getPDO()->query("SELECT CONNECTION_ID();"); - return $stmt->fetchColumn(); - } - - public function getInternalIndexesKeys(): array + #[\Override] + protected function execute(mixed $statement, ?Event $event = null): bool { - return ['primary', '_created_at', '_updated_at', '_tenant_id']; - } - - protected function processException(PDOException $e): \Exception - { - if ($e->getCode() === '22007' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1366) { - return new CharacterException('Invalid character', $e->getCode(), $e); - } - - // Timeout - if ($e->getCode() === '70100' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1969) { - return new TimeoutException('Query timed out', $e->getCode(), $e); - } + $event ??= $this->getStatementEvent($statement); + $baseline = $this->getTimeout(); + $timeout = $event === null ? $baseline : $this->getTimeout($event); + $this->applyTimeout($timeout); - // Duplicate table - if ($e->getCode() === '42S01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1050) { - return new DuplicateException('Collection already exists', $e->getCode(), $e); - } - - // Duplicate column - if ($e->getCode() === '42S21' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1060) { - return new DuplicateException('Attribute already exists', $e->getCode(), $e); - } - - // Duplicate index - if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1061) { - return new DuplicateException('Index already exists', $e->getCode(), $e); - } - - // Index key too long - if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1071) { - return new IndexException('Index key length exceeds the maximum', $e->getCode(), $e); - } - - // Duplicate row - if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) { - $key = $this->getViolatedKey($e->getMessage()); - if ($key === '_index1') { - return new DuplicateException('Duplicate permissions for document', $e->getCode(), $e); - } - if ($key !== null && $key !== '_uid' && $key !== 'PRIMARY') { - return new UniqueException('Unique index violation', $e->getCode(), $e); + $exception = null; + try { + return parent::execute($statement, $event); + } catch (Throwable $error) { + $exception = $error; + throw $error; + } finally { + if ($timeout !== $baseline) { + try { + $this->applyTimeout($baseline); + } catch (Throwable $error) { + if ($exception === null) { + throw $error; + } + } } - return new DuplicateException('Document already exists', $e->getCode(), $e); } + } - // Data is too big for column resize - if (($e->getCode() === '22001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1406) || - ($e->getCode() === '01000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1265)) { - return new TruncateException('Resize would result in data truncation', $e->getCode(), $e); + private function applyTimeout(int $milliseconds): void + { + if ($milliseconds === 0 && $this->appliedTimeout === 0) { + return; } - // Numeric value out of range - if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1264 || $e->errorInfo[1] === 1690)) { - return new LimitException('Value out of range', $e->getCode(), $e); + $round = $this->getSessionRound(); + if ($round !== $this->appliedRound) { + $this->appliedTimeout = 0; + $this->appliedRound = $round; } - // Numeric value out of range - if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1690) { - return new LimitException('Value is out of range', $e->getCode(), $e); + if ($milliseconds === $this->appliedTimeout) { + return; } - // Unknown database - if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) { - return new NotFoundException('Database not found', $e->getCode(), $e); + $statement = $this->getTimeoutStatement($milliseconds); + $driver = $this->getDriver(); + if ($driver instanceof DatabasePDO) { + $driver->configure(self::TIMEOUT_SETTING, $statement); + } else { + $driver->exec($statement); } - // Unknown collection - if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) { - return new NotFoundException('Collection not found', $e->getCode(), $e); - } + $this->appliedTimeout = $milliseconds; + $this->appliedRound = $this->getSessionRound(); + } - // Unknown collection - // We have two of same, because docs point to 1051. - // Keeping previous 1049 (above) just in case it's for older versions - if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1051) { - return new NotFoundException('Collection not found', $e->getCode(), $e); - } + protected function getTimeoutStatement(int $milliseconds): string + { + return 'SET max_statement_time = '.\sprintf('%.6F', $milliseconds / 1000.0); + } - // Unknown column - if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) { - return new NotFoundException('Attribute not found', $e->getCode(), $e); - } + private function getSessionRound(): int + { + $driver = $this->getDriver(); - return $e; + return $driver instanceof PDOProxy ? $driver->getRound() : 0; + } + + #[\Override] + protected function getConflictTenantExpression(string $column): string + { + $quoted = $this->quote($this->filter($column)); + $tenant = Storage::TENANT; + + return "IF({$tenant} = VALUES({$tenant}), VALUES({$quoted}), {$quoted})"; + } + + #[\Override] + protected function getConflictIncrementExpression(string $column): string + { + $quoted = $this->quote($this->filter($column)); + + return "{$quoted} + VALUES({$quoted})"; + } + + #[\Override] + protected function getConflictTenantIncrementExpression(string $column): string + { + $quoted = $this->quote($this->filter($column)); + $tenant = Storage::TENANT; + + return "IF({$tenant} = VALUES({$tenant}), {$quoted} + VALUES({$quoted}), {$quoted})"; + } + + #[\Override] + protected function dialectBuilder(): SQLBuilder&Scoping + { + return new MariaDBBuilder(); + } + + #[\Override] + protected function boundsJoinedSort(): bool + { + return true; + } + + #[\Override] + public function schema(): MySQLSchema + { + return new MySQLSchema(); } /** - * Extract the index name from a duplicate entry error, e.g. - * "Duplicate entry 'x' for key 'movies._uid'" resolves to "_uid". - * Returns null when the message cannot be parsed. + * @return list + * + * @throws DatabaseException */ - protected function getViolatedKey(string $message): ?string + #[\Override] + public function getSchemaAttributes(string $collection): array { - if (\preg_match("/for key '(?:[^'.]*\.)?([^']+)'/", $message, $matches) === 1) { - return $matches[1]; + $schema = $this->getDatabase(); + $table = $this->getNamespace().'_'.$this->filter($collection); + + try { + $statement = $this->prepareStatement(' + SELECT + COLUMN_NAME AS name, + COLUMN_TYPE AS type, + CHARACTER_MAXIMUM_LENGTH AS length, + IS_NULLABLE AS nullable + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table + ORDER BY ORDINAL_POSITION + ', Event::CollectionRead); + $statement->bindParam(':schema', $schema); + $statement->bindParam(':table', $table); + $this->execute($statement); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + $statement->closeCursor(); + } catch (PDOException $e) { + throw new DatabaseException('Failed to get schema attributes', $e->getCode(), $e); } - return null; - } + $columns = []; + foreach ($rows as $row) { + if (! \is_array($row) || ! \is_string($row['name'] ?? null)) { + continue; + } - protected function quote(string $string): string - { - return "`{$string}`"; + $type = $row['type'] ?? ''; + $length = $row['length'] ?? null; + $columns[] = new SchemaColumn( + name: $row['name'], + type: $this->canonicalColumnType(\is_string($type) ? $type : ''), + length: \is_numeric($length) ? (int) $length : null, + nullable: ($row['nullable'] ?? '') === 'YES', + ); + } + + return $columns; } /** - * Get operator SQL - * Override to handle MariaDB/MySQL-specific operators + * @return array * - * @param string $column - * @param Operator $operator - * @param array $binds - * @return ?string + * @throws DatabaseException */ - protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string + #[\Override] + protected function getColumnNames(string $collection): array + { + return \array_map( + static fn (SchemaColumn $column): string => $column->name, + $this->getSchemaAttributes($collection), + ); + } + + #[\Override] + protected function getOperatorSql(string $column, Operator $operator, int &$bindIndex): ?string { $quotedColumn = $this->quote($column); $method = $operator->getMethod(); $values = $operator->getValues(); switch ($method) { - // Numeric operators - case Operator::TYPE_INCREMENT: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Increment: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); - // Compare with the operand moved across (`col > max - val`) instead of - // `col + val > max`, so the guard never overflows BIGINT when col is near the - // integer range limit. Inclusive: a result landing exactly on max still applies. + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) + :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) + :$bindKey"; - case Operator::TYPE_DECREMENT: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Decrement: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $minKey = $this->registerOperatorBind($binds, $values[1]); - // `col < min + val` rather than `col - val < min`: overflow-safe near the - // integer range limit. Inclusive: a result landing exactly on min still applies. + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) - :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) - :$bindKey"; - case Operator::TYPE_MULTIPLY: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Multiply: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); - // Compare via division (`col > max/val`, sign-aware) instead of computing - // `col * val`, which would overflow BIGINT for large operands. The factor's - // sign flips the inequality. Inclusive: a result exactly on max still applies. + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0) WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) * :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) * :$bindKey"; - case Operator::TYPE_DIVIDE: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Divide: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $minKey = $this->registerOperatorBind($binds, $values[1]); + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) / :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) / :$bindKey"; - case Operator::TYPE_MODULO: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Modulo: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = MOD(COALESCE({$quotedColumn}, 0), :$bindKey)"; - case Operator::TYPE_POWER: + case OperatorType::Power: $exponent = $values[0] ?? 1; - $bindKey = $this->registerOperatorBind($binds, $exponent); + if (! \is_int($exponent) && ! \is_float($exponent)) { + throw new OperatorException('Power exponent must be numeric'); + } + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); - $col = "COALESCE({$quotedColumn}, 0)"; + $maxKey = "op_{$bindIndex}"; + $bindIndex++; - // Leave the value unchanged only for undefined inputs, then apply the power if - // the result stays within the max. The exponent is constant, so only the - // undefined guard its value can actually trigger is emitted. + $columnValue = "COALESCE({$quotedColumn}, 0)"; $oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0; + $guards = []; - $whens = []; if ($exponent < 0) { - // 0 to a negative power is undefined (POWER would error / return NULL). - $whens[] = "WHEN {$col} = 0 THEN {$col}"; + $guards[] = "WHEN {$columnValue} = 0 THEN {$columnValue}"; } if (\floor($exponent) != $exponent) { - // A negative base to a fractional exponent is not a real number. - $whens[] = "WHEN {$col} < 0 THEN {$col}"; + $guards[] = "WHEN {$columnValue} < 0 THEN {$columnValue}"; } - // Cap by magnitude via logarithms so POWER() never runs on a value that would - // overflow (base^exp > max <=> exp * LOG(base) > LOG(max)). if ($exponent == 0) { - // Every base to the zeroth power is 1 (including 0^0), which the magnitude - // check below can't see for a base of 0. The result 1 exceeds the max when - // max < 1, i.e. LOG(max) < 0 (LOG also coerces the bound value numerically). - $whens[] = "WHEN LOG(:$maxKey) < 0 THEN {$col}"; + $guards[] = "WHEN LOG(:$maxKey) < 0 THEN {$columnValue}"; } elseif ($oddInteger) { - // An odd exponent keeps a negative base negative, and a negative result is - // always within a positive max, so only cap positive bases; negative bases - // fall through to POWER() and their (negative) result is applied. - $whens[] = "WHEN {$col} > 0 AND :$bindKey * LOG({$col}) > LOG(:$maxKey) THEN {$col}"; + $guards[] = "WHEN {$columnValue} > 0 AND :$bindKey * LOG({$columnValue}) > LOG(:$maxKey) THEN {$columnValue}"; } else { - // Otherwise the result is non-negative, so its magnitude equals its value — - // cap either sign. ABS() keeps LOG() defined for a negative even-power base. - $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LOG(ABS({$col})) > LOG(:$maxKey) THEN {$col}"; + $guards[] = "WHEN {$columnValue} <> 0 AND :$bindKey * LOG(ABS({$columnValue})) > LOG(:$maxKey) THEN {$columnValue}"; } - $whenSql = \implode(' ', $whens); - return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; + return "{$quotedColumn} = CASE ".\implode(' ', $guards)." ELSE POWER({$columnValue}, :$bindKey) END"; } + return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; - // String operators - case Operator::TYPE_STRING_CONCAT: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? ''); + case OperatorType::StringConcat: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CONCAT(COALESCE({$quotedColumn}, ''), :$bindKey)"; - case Operator::TYPE_STRING_REPLACE: - $searchKey = $this->registerOperatorBind($binds, $values[0] ?? ''); - $replaceKey = $this->registerOperatorBind($binds, $values[1] ?? ''); + case OperatorType::StringReplace: + $searchKey = "op_{$bindIndex}"; + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = REPLACE({$quotedColumn}, :$searchKey, :$replaceKey)"; - // Boolean operators - case Operator::TYPE_TOGGLE: + case OperatorType::Toggle: return "{$quotedColumn} = NOT COALESCE({$quotedColumn}, FALSE)"; - // Array operators - case Operator::TYPE_ARRAY_APPEND: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayAppend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)"; - case Operator::TYPE_ARRAY_PREPEND: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayPrepend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; - case Operator::TYPE_ARRAY_INSERT: - $indexKey = $this->registerOperatorBind($binds, $values[0] ?? 0); - $valueKey = $this->registerOperatorBind($binds, json_encode($values[1] ?? null)); + case OperatorType::ArrayInsert: + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_ARRAY_INSERT( {$quotedColumn}, CONCAT('$[', :$indexKey, ']'), JSON_EXTRACT(:$valueKey, '$') )"; - case Operator::TYPE_ARRAY_REMOVE: - $removeValue = $values[0] ?? null; - // Cast scalars to string so the value binds as PDO::PARAM_STR, preserving the - // pre-refactor behavior (it was bound with an explicit PARAM_STR). JSON_TABLE - // extracts `value` as TEXT, so the search term must compare as text — without - // the cast, getPDOType() would bind a number as PARAM_INT. Do not drop it. - $removeValue = is_array($removeValue) ? json_encode($removeValue) : (string)$removeValue; - $bindKey = $this->registerOperatorBind($binds, $removeValue); + case OperatorType::ArrayRemove: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(value) FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt WHERE value != :$bindKey ), JSON_ARRAY())"; - case Operator::TYPE_ARRAY_UNIQUE: + case OperatorType::ArrayUnique: return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(DISTINCT jt.value) FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt ), JSON_ARRAY())"; - case Operator::TYPE_ARRAY_INTERSECT: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayIntersect: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(jt1.value) FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt1 @@ -2158,8 +1429,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) ), JSON_ARRAY())"; - case Operator::TYPE_ARRAY_DIFF: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayDiff: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(jt1.value) FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt1 @@ -2169,11 +1442,12 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) ), JSON_ARRAY())"; - case Operator::TYPE_ARRAY_FILTER: - $condition = $values[0] ?? 'equal'; - $filterValue = $values[1] ?? null; - $conditionKey = $this->registerOperatorBind($binds, $condition); - $valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue)); + case OperatorType::ArrayFilter: + $conditionKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(value) FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt @@ -2190,171 +1464,194 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi END ), JSON_ARRAY())"; - // Date operators - case Operator::TYPE_DATE_ADD_DAYS: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0); + case OperatorType::DateAddDays: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = DATE_ADD({$quotedColumn}, INTERVAL :$bindKey DAY)"; - case Operator::TYPE_DATE_SUB_DAYS: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0); + case OperatorType::DateSubDays: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = DATE_SUB({$quotedColumn}, INTERVAL :$bindKey DAY)"; - case Operator::TYPE_DATE_SET_NOW: + case OperatorType::DateSetNow: return "{$quotedColumn} = NOW()"; default: - throw new OperatorException("Invalid operator: {$method}"); + throw new OperatorException('Invalid operator'); } } - public function getSupportForNumericCasting(): bool + /** + * @return list + * + * @throws DatabaseException + */ + #[\Override] + public function getSchemaIndexes(string $collection): array { - return true; - } + $schema = $this->getDatabase(); + $table = $this->getNamespace().'_'.$this->filter($collection); - public function getSupportForIndexArray(): bool - { - return true; - } + try { + $statement = $this->prepareStatement(' + SELECT + INDEX_NAME AS name, + COLUMN_NAME AS columnName, + NON_UNIQUE AS nonUnique, + INDEX_TYPE AS indexType, + SUB_PART AS subPart + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table + ORDER BY INDEX_NAME, SEQ_IN_INDEX + ', Event::CollectionRead); + $statement->bindParam(':schema', $schema); + $statement->bindParam(':table', $table); + $this->execute($statement); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + $statement->closeCursor(); + } catch (PDOException $e) { + throw new DatabaseException('Failed to get schema indexes', $e->getCode(), $e); + } - public function getSupportForSpatialAttributes(): bool - { - return true; - } + $grouped = []; + foreach ($rows as $row) { + if (! \is_array($row) || ! \is_string($row['name'] ?? null) || $row['name'] === '') { + continue; + } - public function getSupportForObject(): bool - { - return false; - } + $name = $row['name']; + if (! isset($grouped[$name])) { + $indexType = \is_string($row['indexType'] ?? null) ? \strtoupper($row['indexType']) : ''; + $nonUnique = \is_numeric($row['nonUnique'] ?? null) ? (int) $row['nonUnique'] : 1; + $grouped[$name] = [ + 'type' => match (true) { + $indexType === 'FULLTEXT' => IndexType::Fulltext, + $indexType === 'SPATIAL' => IndexType::Spatial, + $nonUnique === 0 => IndexType::Unique, + default => IndexType::Key, + }, + 'columns' => [], + 'lengths' => [], + ]; + } - public function getSupportForUnsignedBigInt(): bool - { - return true; - } + $subPart = $row['subPart'] ?? null; + $grouped[$name]['columns'][] = \is_string($row['columnName'] ?? null) ? $row['columnName'] : ''; + $grouped[$name]['lengths'][] = \is_numeric($subPart) ? (int) $subPart : null; + } - /** - * Are object (JSON) indexes supported? - * - * @return bool - */ - public function getSupportForObjectIndexes(): bool - { - return false; - } + $indexes = []; + foreach ($grouped as $name => $index) { + $indexes[] = new SchemaIndex((string) $name, $index['type'], $index['columns'], $index['lengths']); + } - /** - * Get Support for Null Values in Spatial Indexes - * - * @return bool - */ - public function getSupportForSpatialIndexNull(): bool - { - return false; + return $indexes; } - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ - public function getSupportForBoundaryInclusiveContains(): bool - { - return true; - } - /** - * Does the adapter support order attribute in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexOrder(): bool + #[\Override] + protected function processException(PDOException $e): Exception { - return true; - } + if ($e->getCode() === '22007' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1366) { + return new CharacterException('Invalid character', $e->getCode(), $e); + } - /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool - */ - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool - { - return false; - } + if ($e->getCode() === '70100' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1969) { + return new TimeoutException('Query timed out', $e->getCode(), $e); + } - public function getSpatialSQLType(string $type, bool $required): string - { - $srid = Database::DEFAULT_SRID; - $nullability = ''; - - if (!$this->getSupportForSpatialIndexNull()) { - if ($required) { - $nullability = ' NOT NULL'; - } else { - $nullability = ' NULL'; + if ($e->getCode() === '42S01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1050) { + return new DuplicateException('Collection already exists', $e->getCode(), $e); + } + + if ($e->getCode() === '42S21' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1060) { + return new DuplicateException('Attribute already exists', $e->getCode(), $e); + } + + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1061) { + return new DuplicateException('Index already exists', $e->getCode(), $e); + } + + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1071) { + return new IndexException('Index key length exceeds the maximum', $e->getCode(), $e); + } + + if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) { + $key = $this->getViolatedKey($e->getMessage()); + if ($key === Storage::INDEX_1) { + return new DuplicateException('Duplicate permissions for document', $e->getCode(), $e); } + if ($key !== null && $key !== Storage::UID && $key !== 'PRIMARY') { + return new UniqueException(UniqueException::MESSAGE, $e->getCode(), $e); + } + + return new DuplicateException('Document already exists', $e->getCode(), $e); } - switch ($type) { - case Database::VAR_POINT: - return "POINT($srid)$nullability"; + if (($e->getCode() === '22001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1406) || + ($e->getCode() === '01000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1265)) { + return new TruncateException('Resize would result in data truncation', $e->getCode(), $e); + } - case Database::VAR_LINESTRING: - return "LINESTRING($srid)$nullability"; + if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1264 || $e->errorInfo[1] === 1690)) { + return new LimitException('Value out of range', $e->getCode(), $e); + } - case Database::VAR_POLYGON: - return "POLYGON($srid)$nullability"; + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1690) { + return new LimitException('Value is out of range', $e->getCode(), $e); } - return ''; - } + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) { + return new NotFoundException('Database not found', $e->getCode(), $e); + } - /** - * Does the adapter support spatial axis order specification? - * - * @return bool - */ - public function getSupportForSpatialAxisOrder(): bool - { - return false; - } + if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1051 || $e->errorInfo[1] === 1146)) { + return new NotFoundException('Collection not found', $e->getCode(), $e); + } - /** - * Adapter supports optional spatial attributes with existing rows. - * - * @return bool - */ - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return true; - } + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); + } - public function getSupportForAlterLocks(): bool - { - return true; - } + if ($e->getCode() === '42S22' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1054) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); + } - public function getSupportNonUtfCharacters(): bool - { - return true; - } + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1072) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); + } - public function getSupportForTrigramIndex(): bool - { - return false; - } + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1116) { + return new QueryException('Too many tables in a join', $e->getCode(), $e); + } - public function getSupportForPCRERegex(): bool - { - return true; - } + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1191) { + return new QueryException('Searching requires a fulltext index on the searched attributes', $e->getCode(), $e); + } - public function getSupportForPOSIXRegex(): bool - { - return false; + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3065) { + return new QueryException('A distinct() query can only be ordered by a selected attribute on this database', $e->getCode(), $e); + } + + if ($e->getCode() === '40001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1213) { + return new ContentionException('Deadlock detected', $e->getCode(), $e); + } + + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1205) { + return new ContentionException('Lock wait timeout exceeded', $e->getCode(), $e); + } + + return $e; } - public function getSupportForTTLIndexes(): bool + protected function getViolatedKey(string $message): ?string { - return false; + if (\preg_match("/for key '(?:[^'.]*\.)?([^']+)'/", $message, $matches) !== 1) { + return null; + } + + return $matches[1]; } } diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index c1a374dec2..071f1fd2cd 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -3,6 +3,8 @@ namespace Utopia\Database\Adapter; use Utopia\Database\Adapter; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -12,8 +14,22 @@ use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Unique as UniqueException; +use Utopia\Database\Index; use Utopia\Database\Operator; +use Utopia\Database\OperatorType; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Storage; +use Utopia\Database\Validator\BigInt; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; /** * In-process drop-in for the SQL adapters that keeps all data in PHP @@ -28,7 +44,7 @@ * Spatial types and vector search throw a DatabaseException — those * features only make sense against a real engine. */ -class Memory extends Adapter +class Memory extends Adapter implements Feature\Relationships { /** * Map of database name to the set of collection storage keys it owns. @@ -48,10 +64,10 @@ class Memory extends Adapter protected array $permissions = []; /** - * Inverted permission lookup: collectionKey → documentId → type → set. + * Inverted permission lookup: collectionKey → tenantBucket → documentId → type → set. * Maintained alongside `$permissions` to give O(|doc-perms|) deletion on writes. * - * @var array>>> + * @var array>>>> */ protected array $permissionsByDocument = []; @@ -89,16 +105,38 @@ class Memory extends Adapter */ protected array $filterCache = []; - protected bool $supportForAttributes = true; - public function __construct() { // No external resources to initialise } - public function getDriver(): mixed + /** + * The adapter holds its data itself, so it is its own driver. + */ + #[\Override] + public function getDriver(): static { - return 'memory'; + return $this; + } + + /** + * @return array + */ + #[\Override] + public function capabilities(): array + { + return array_merge(parent::capabilities(), [ + Capability::Schemas, + Capability::IndexFulltext, + Capability::AttributeResizing, + Capability::Objects, + Capability::IndexObject, + Capability::Operators, + Capability::OrderRandom, + Capability::DefinedAttributes, + Capability::TransactionNested, + Capability::Caching, + ]); } protected function key(string $collection): string @@ -137,9 +175,11 @@ protected function locateDocument(string $key, string $collectionId, string $id) if ($this->sharedTables && $collectionId === Database::METADATA) { $lower = \strtolower($id); foreach ($this->data[$key]['documents'] as $storageKey => $candidate) { + $uid = $candidate[Storage::UID] ?? ''; if ( - \strtolower((string) ($candidate['_uid'] ?? '')) === $lower - && ($candidate['_tenant'] ?? null) === null + \is_string($uid) + && \strtolower($uid) === $lower + && ($candidate[Storage::TENANT] ?? null) === null ) { return [$storageKey, $candidate]; } @@ -149,21 +189,7 @@ protected function locateDocument(string $key, string $collectionId, string $id) return null; } - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void - { - // No-op: nothing to time out in-memory - } - - public function ping(): bool - { - return true; - } - - public function reconnect(): void - { - // No-op - } - + #[\Override] public function startTransaction(): bool { $this->journals[] = []; @@ -172,6 +198,7 @@ public function startTransaction(): bool return true; } + #[\Override] public function commitTransaction(): bool { if ($this->inTransaction === 0) { @@ -194,6 +221,7 @@ public function commitTransaction(): bool return true; } + #[\Override] public function rollbackTransaction(): bool { if ($this->inTransaction === 0) { @@ -228,6 +256,7 @@ protected function journal(\Closure $inverse): void * Memoised `Adapter::filter()` — the regex pass is the hottest call in * the find inner loop and the inputs (attribute names) are bounded. */ + #[\Override] public function filter(string $value): string { if (isset($this->filterCache[$value])) { @@ -239,6 +268,7 @@ public function filter(string $value): string return $filtered; } + #[\Override] public function create(string $name): bool { if (! isset($this->databases[$name])) { @@ -251,19 +281,91 @@ public function create(string $name): bool return true; } - public function exists(string $database, ?string $collection = null): bool + /** + * Shared tables refuse the rename: other tenants' documents share the database. + * + * @throws DatabaseException + */ + #[\Override] + public function update(string $name, string $new): bool { - if ($collection === null) { - return isset($this->databases[$database]); + if ($this->hasSharedTables()) { + throw new DatabaseException('Cannot rename a database while shared tables are enabled'); } - if (! isset($this->databases[$database])) { - return false; + if (! isset($this->databases[$name])) { + throw new NotFoundException('Database not found'); + } + + if (isset($this->databases[$new])) { + throw new DuplicateException('Database already exists'); + } + + $previous = $this->databases[$name]; + $prefix = $name.'.'; + $moved = []; + foreach ($previous as $slot => $collectionKey) { + $moved[$slot] = \str_starts_with($collectionKey, $prefix) + ? $new.'.'.\substr($collectionKey, \strlen($prefix)) + : $collectionKey; + $this->moveCollection($collectionKey, $moved[$slot]); + } + + unset($this->databases[$name]); + $this->databases[$new] = $moved; + + $this->journal(function () use ($name, $new, $previous, $moved): void { + foreach ($previous as $slot => $collectionKey) { + $this->moveCollection($moved[$slot], $collectionKey); + } + unset($this->databases[$new]); + $this->databases[$name] = $previous; + }); + + return true; + } + + private function moveCollection(string $from, string $to): void + { + if ($from === $to) { + return; + } + + self::moveEntry($this->data, $from, $to); + self::moveEntry($this->permissions, $from, $to); + self::moveEntry($this->permissionsByDocument, $from, $to); + self::moveEntry($this->permissionsByPermission, $from, $to); + self::moveEntry($this->uniqueIndexHashes, $from, $to); + } + + /** + * @template T + * + * @param array $store + */ + private static function moveEntry(array &$store, string $from, string $to): void + { + if (! \array_key_exists($from, $store)) { + return; } + $store[$to] = $store[$from]; + unset($store[$from]); + } + + #[\Override] + public function exists(string $database): bool + { + return isset($this->databases[$database]); + } + + #[\Override] + public function collectionExists(string $database, string $collection): bool + { return isset($this->databases[$database][$this->filter($collection)]); } + #[\Override] public function list(): array { $databases = []; @@ -274,6 +376,7 @@ public function list(): array return $databases; } + #[\Override] public function delete(string $name): bool { if (! isset($this->databases[$name])) { @@ -334,9 +437,14 @@ public function delete(string $name): bool return true; } - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + /** + * @param list $attributes + * @param list $indexes + */ + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool { - $key = $this->key($name); + $key = $this->key($collection); if (isset($this->data[$key])) { throw new DuplicateException('Collection already exists'); } @@ -355,29 +463,16 @@ public function createCollection(string $name, array $attributes = [], array $in if (! isset($this->databases[$database])) { $this->databases[$database] = []; } - $databaseSlot = $this->filter($name); + $databaseSlot = $this->filter($collection); $this->databases[$database][$databaseSlot] = $key; } foreach ($attributes as $attribute) { - $attrId = $this->filter($attribute->getId()); - $this->data[$key]['attributes'][$attrId] = [ - 'type' => $attribute->getAttribute('type'), - 'size' => $attribute->getAttribute('size', 0), - 'signed' => $attribute->getAttribute('signed', true), - 'array' => $attribute->getAttribute('array', false), - 'required' => $attribute->getAttribute('required', false), - ]; + $this->data[$key]['attributes'][$this->filter($attribute->key)] = self::attributeEntry($attribute); } foreach ($indexes as $index) { - $indexId = $this->filter($index->getId()); - $this->data[$key]['indexes'][$indexId] = [ - 'type' => $index->getAttribute('type'), - 'attributes' => $index->getAttribute('attributes', []), - 'lengths' => $index->getAttribute('lengths', []), - 'orders' => $index->getAttribute('orders', []), - ]; + $this->data[$key]['indexes'][$this->filter($index->key)] = self::indexEntry($index); } $this->journal(function () use ($key, $database, $databaseSlot): void { @@ -388,7 +483,7 @@ public function createCollection(string $name, array $attributes = [], array $in $this->permissionsByPermission[$key], $this->uniqueIndexHashes[$key], ); - if ($database !== '' && $databaseSlot !== null) { + if ($databaseSlot !== null) { unset($this->databases[$database][$databaseSlot]); } }); @@ -396,9 +491,37 @@ public function createCollection(string $name, array $attributes = [], array $in return true; } - public function deleteCollection(string $id): bool + /** + * @return array{type: string, size: int, signed: bool, array: bool, required: bool} + */ + private static function attributeEntry(Attribute $attribute): array + { + return [ + 'type' => $attribute->type->value, + 'size' => $attribute->size ?? 0, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, + ]; + } + + /** + * @return array{type: string, attributes: list, lengths: list, orders: list} + */ + private static function indexEntry(Index $index): array + { + return [ + 'type' => $index->type->value, + 'attributes' => $index->attributes, + 'lengths' => $index->lengths, + 'orders' => \array_map(static fn (?OrderDirection $order): ?string => $order?->value, $index->orders), + ]; + } + + #[\Override] + public function deleteCollection(string $collection): bool { - $key = $this->key($id); + $key = $this->key($collection); $previousData = $this->data[$key] ?? null; $previousPermissions = $this->permissions[$key] ?? null; $previousByDocument = $this->permissionsByDocument[$key] ?? null; @@ -412,7 +535,7 @@ public function deleteCollection(string $id): bool $this->permissionsByPermission[$key], $this->uniqueIndexHashes[$key], ); - $filtered = $this->filter($id); + $filtered = $this->filter($collection); $databaseSlots = []; foreach ($this->databases as $name => $collections) { if (isset($collections[$filtered]) && $collections[$filtered] === $key) { @@ -445,27 +568,23 @@ public function deleteCollection(string $id): bool return true; } + #[\Override] public function analyzeCollection(string $collection): bool { return false; } - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool { $key = $this->key($collection); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } - $id = $this->filter($id); + $id = $this->filter($attribute->key); $previous = $this->data[$key]['attributes'][$id] ?? null; - $this->data[$key]['attributes'][$id] = [ - 'type' => $type, - 'size' => $size, - 'signed' => $signed, - 'array' => $array, - 'required' => $required, - ]; + $this->data[$key]['attributes'][$id] = self::attributeEntry($attribute); $this->journal(function () use ($key, $id, $previous): void { if ($previous === null) { @@ -478,73 +597,65 @@ public function createAttribute(string $collection, string $id, string $type, in return true; } + /** + * @param list $attributes + */ + #[\Override] public function createAttributes(string $collection, array $attributes): bool { foreach ($attributes as $attribute) { - $this->createAttribute( - $collection, - (string) $attribute['$id'], - (string) $attribute['type'], - (int) ($attribute['size'] ?? 0), - (bool) ($attribute['signed'] ?? true), - (bool) ($attribute['array'] ?? false), - (bool) ($attribute['required'] ?? false), - ); + $this->createAttribute($collection, $attribute); } return true; } - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool { - $key = $this->key($collection); - if (! isset($this->data[$key])) { + $collectionKey = $this->key($collection); + if (! isset($this->data[$collectionKey])) { throw new NotFoundException('Collection not found'); } - $id = $this->filter($id); - if (! empty($newKey) && $newKey !== $id) { - $this->renameAttribute($collection, $id, $newKey); - $id = $this->filter($newKey); + $id = $this->filter($key); + if ($attribute->key !== $key) { + $this->renameAttribute($collection, $id, $attribute->key); + $id = $this->filter($attribute->key); } - $previous = $this->data[$key]['attributes'][$id] ?? null; - $this->data[$key]['attributes'][$id] = [ - 'type' => $type, - 'size' => $size, - 'signed' => $signed, - 'array' => $array, - 'required' => $required, - ]; + $previous = $this->data[$collectionKey]['attributes'][$id] ?? null; + $this->data[$collectionKey]['attributes'][$id] = self::attributeEntry($attribute); - $this->journal(function () use ($key, $id, $previous): void { + $this->journal(function () use ($collectionKey, $id, $previous): void { if ($previous === null) { - unset($this->data[$key]['attributes'][$id]); + unset($this->data[$collectionKey]['attributes'][$id]); } else { - $this->data[$key]['attributes'][$id] = $previous; + $this->data[$collectionKey]['attributes'][$id] = $previous; } }); return true; } - public function deleteAttribute(string $collection, string $id): bool + #[\Override] + public function deleteAttribute(string $collection, string $key): bool { - $key = $this->key($collection); - if (! isset($this->data[$key])) { + $table = $this->key($collection); + if (! isset($this->data[$table])) { return true; } - $id = $this->filter($id); - $previousAttribute = $this->data[$key]['attributes'][$id] ?? null; + $id = $this->filter($key); + $previousAttribute = $this->data[$table]['attributes'][$id] ?? null; if ($previousAttribute === null) { // Nothing to do; attribute was never registered. return true; } $previousValues = []; - unset($this->data[$key]['attributes'][$id]); - foreach ($this->data[$key]['documents'] as $storageKey => &$document) { + unset($this->data[$table]['attributes'][$id]); + foreach ($this->data[$table]['documents'] as $storageKey => &$document) { if (\array_key_exists($id, $document)) { $previousValues[$storageKey] = $document[$id]; unset($document[$id]); @@ -554,58 +665,85 @@ public function deleteAttribute(string $collection, string $id): bool $previousIndexes = []; $previousUniqueHashes = []; - foreach ($this->data[$key]['indexes'] as $indexId => &$index) { - $attributes = $index['attributes'] ?? []; + foreach ($this->data[$table]['indexes'] as $indexId => $index) { + $attributes = \is_array($index['attributes'] ?? null) ? $index['attributes'] : []; + $indexLengths = \is_array($index['lengths'] ?? null) ? $index['lengths'] : []; + $indexOrders = \is_array($index['orders'] ?? null) ? $index['orders'] : []; $filtered = []; $lengths = []; $orders = []; $touched = false; foreach ($attributes as $i => $attribute) { + if (! \is_string($attribute)) { + continue; + } if ($this->filter($attribute) === $id) { $touched = true; continue; } $filtered[] = $attribute; - if (isset($index['lengths'][$i])) { - $lengths[] = $index['lengths'][$i]; + if (isset($indexLengths[$i])) { + $lengths[] = $indexLengths[$i]; } - if (isset($index['orders'][$i])) { - $orders[] = $index['orders'][$i]; + if (isset($indexOrders[$i])) { + $orders[] = $indexOrders[$i]; } } if ($touched) { $previousIndexes[$indexId] = $index; - if (($index['type'] ?? '') === Database::INDEX_UNIQUE - && isset($this->uniqueIndexHashes[$key][$indexId])) { - $previousUniqueHashes[$indexId] = $this->uniqueIndexHashes[$key][$indexId]; - unset($this->uniqueIndexHashes[$key][$indexId]); + if (($index['type'] ?? '') === IndexType::Unique->value + && isset($this->uniqueIndexHashes[$table][$indexId])) { + $previousUniqueHashes[$indexId] = $this->uniqueIndexHashes[$table][$indexId]; + unset($this->uniqueIndexHashes[$table][$indexId]); } } $index['attributes'] = $filtered; $index['lengths'] = $lengths; $index['orders'] = $orders; + $this->data[$table]['indexes'][$indexId] = $index; } - unset($index); - $this->journal(function () use ($key, $id, $previousAttribute, $previousValues, $previousIndexes, $previousUniqueHashes): void { - $this->data[$key]['attributes'][$id] = $previousAttribute; + $this->journal(function () use ($table, $id, $previousAttribute, $previousValues, $previousIndexes, $previousUniqueHashes): void { + if (! isset($this->data[$table])) { + return; + } + $this->data[$table]['attributes'][$id] = $previousAttribute; foreach ($previousValues as $storageKey => $value) { - if (isset($this->data[$key]['documents'][$storageKey])) { - $this->data[$key]['documents'][$storageKey][$id] = $value; + if (isset($this->data[$table]['documents'][$storageKey])) { + $this->data[$table]['documents'][$storageKey][$id] = $value; } } - foreach ($previousIndexes as $indexId => $value) { - $this->data[$key]['indexes'][$indexId] = $value; + foreach ($previousIndexes as $indexId => $previousIndex) { + $this->data[$table]['indexes'][$indexId] = $previousIndex; } - foreach ($previousUniqueHashes as $indexId => $value) { - $this->uniqueIndexHashes[$key][$indexId] = $value; + foreach ($previousUniqueHashes as $indexId => $hashes) { + $this->uniqueIndexHashes[$table][$indexId] = $hashes; } }); return true; } + #[\Override] + public function getSchemaAttributes(string $collection): array + { + return []; + } + + #[\Override] + public function getSchemaIndexes(string $collection): array + { + return []; + } + + #[\Override] + public function getColumnType(Attribute $attribute): ?string + { + return null; + } + + #[\Override] public function renameAttribute(string $collection, string $old, string $new): bool { $key = $this->key($collection); @@ -635,10 +773,10 @@ public function renameAttribute(string $collection, string $old, string $new): b $touchedIndexes = []; foreach ($this->data[$key]['indexes'] as $indexId => &$index) { - $attributes = $index['attributes'] ?? []; + $attributes = \is_array($index['attributes'] ?? null) ? $index['attributes'] : []; $changed = false; foreach ($attributes as $i => $attribute) { - if ($this->filter($attribute) === $old) { + if (\is_string($attribute) && $this->filter($attribute) === $old) { $attributes[$i] = $new; $changed = true; } @@ -651,32 +789,40 @@ public function renameAttribute(string $collection, string $old, string $new): b unset($index); $this->journal(function () use ($key, $old, $new, $touchedDocs, $touchedIndexes): void { - $this->data[$key]['attributes'][$old] = $this->data[$key]['attributes'][$new]; - unset($this->data[$key]['attributes'][$new]); + if (! isset($this->data[$key])) { + return; + } + $entry = &$this->data[$key]; + $entry['attributes'][$old] = $entry['attributes'][$new]; + unset($entry['attributes'][$new]); foreach ($touchedDocs as $storageKey) { - if (! isset($this->data[$key]['documents'][$storageKey])) { + if (! isset($entry['documents'][$storageKey])) { continue; } - $document = &$this->data[$key]['documents'][$storageKey]; + $document = &$entry['documents'][$storageKey]; $document[$old] = $document[$new]; unset($document[$new]); unset($document); } foreach ($touchedIndexes as $indexId) { - $attributes = $this->data[$key]['indexes'][$indexId]['attributes'] ?? []; + $attributes = \is_array($entry['indexes'][$indexId]['attributes'] ?? null) + ? $entry['indexes'][$indexId]['attributes'] + : []; foreach ($attributes as $i => $attribute) { - if ($this->filter($attribute) === $new) { + if (\is_string($attribute) && $this->filter($attribute) === $new) { $attributes[$i] = $old; } } - $this->data[$key]['indexes'][$indexId]['attributes'] = $attributes; + $entry['indexes'][$indexId]['attributes'] = $attributes; } + unset($entry); }); return true; } - public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool { // Memory stores documents as flexible maps, so the relationship "column" // is registered on the attribute list rather than added as a physical @@ -685,69 +831,68 @@ public function createRelationship(string $collection, string $relatedCollection // which selects the column even when no rows have a value. // The M2M junction collection itself is created by the wrapper through // the standard createCollection path. - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - $this->registerRelationshipField($collection, $id); - if ($twoWay) { + $relatedCollection = $relationship->relatedCollection; + $key = $relationship->key ?? ''; + $twoWayKey = $relationship->twoWayKey ?? ''; + + switch ($relationship->type) { + case RelationshipType::OneToOne: + $this->registerRelationshipField($collection, $key); + if ($relationship->twoWay) { $this->registerRelationshipField($relatedCollection, $twoWayKey); } break; - case Database::RELATION_ONE_TO_MANY: + case RelationshipType::OneToMany: $this->registerRelationshipField($relatedCollection, $twoWayKey); break; - case Database::RELATION_MANY_TO_ONE: - $this->registerRelationshipField($collection, $id); + case RelationshipType::ManyToOne: + $this->registerRelationshipField($collection, $key); break; - case Database::RELATION_MANY_TO_MANY: - // Junction columns live on the junction collection, which is - // created with explicit attributes by the wrapper. + case RelationshipType::ManyToMany: break; - default: - throw new DatabaseException('Invalid relationship type'); } return true; } - public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool { - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); - $newKey = $newKey !== null ? $this->filter($newKey) : null; - $newTwoWayKey = $newTwoWayKey !== null ? $this->filter($newTwoWayKey) : null; + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $newKey = $update->key === null ? null : $this->filter($update->key); + $newTwoWayKey = $update->twoWayKey === null ? null : $this->filter($update->twoWayKey); + $twoWay = $update->twoWay ?? $relationship->twoWay; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($newKey !== null && $newKey !== $key) { + switch ($relationship->type) { + case RelationshipType::OneToOne: + if (($twoWay || $side === RelationshipSide::Parent) && $newKey !== null && $newKey !== $key) { $this->renameDocumentField($collection, $key, $newKey); } - if ($twoWay && $newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + if (($twoWay || $side === RelationshipSide::Child) && $newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { $this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey); } break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { $this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey); } - } else { - if ($newKey !== null && $newKey !== $key) { - $this->renameDocumentField($collection, $key, $newKey); - } + } elseif ($newKey !== null && $newKey !== $key) { + $this->renameDocumentField($collection, $key, $newKey); } break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Child) { if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { $this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey); } - } else { - if ($newKey !== null && $newKey !== $key) { - $this->renameDocumentField($collection, $key, $newKey); - } + } elseif ($newKey !== null && $newKey !== $key) { + $this->renameDocumentField($collection, $key, $newKey); } break; - case Database::RELATION_MANY_TO_MANY: + case RelationshipType::ManyToMany: $junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side); if ($junction !== null) { if ($newKey !== null && $newKey !== $key) { @@ -758,21 +903,22 @@ public function updateRelationship(string $collection, string $relatedCollection } } break; - default: - throw new DatabaseException('Invalid relationship type'); } return true; } - public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool { - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $twoWay = $relationship->twoWay; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + switch ($relationship->type) { + case RelationshipType::OneToOne: + if ($side === RelationshipSide::Parent) { $this->dropDocumentField($collection, $key); if ($twoWay) { $this->dropDocumentField($relatedCollection, $twoWayKey); @@ -784,25 +930,26 @@ public function deleteRelationship(string $collection, string $relatedCollection } } break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { $this->dropDocumentField($relatedCollection, $twoWayKey); } else { $this->dropDocumentField($collection, $key); } break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Parent) { $this->dropDocumentField($collection, $key); } else { $this->dropDocumentField($relatedCollection, $twoWayKey); } break; - case Database::RELATION_MANY_TO_MANY: - // Junction collection is dropped by the wrapper via cleanupCollection. + case RelationshipType::ManyToMany: + $junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side); + if ($junction !== null) { + $this->deleteCollection($junction); + } break; - default: - throw new DatabaseException('Invalid relationship type'); } return true; @@ -822,7 +969,7 @@ protected function registerRelationshipField(string $collection, string $field): $field = $this->filter($field); $previous = $this->data[$key]['attributes'][$field] ?? null; $this->data[$key]['attributes'][$field] = [ - 'type' => Database::VAR_RELATIONSHIP, + 'type' => ColumnType::Relationship->value, 'size' => 0, 'signed' => true, 'array' => false, @@ -837,25 +984,6 @@ protected function registerRelationshipField(string $collection, string $field): }); } - /** - * Unregister a relationship field from the collection's attribute list. - */ - protected function unregisterRelationshipField(string $collection, string $field): void - { - $key = $this->key($collection); - if (! isset($this->data[$key])) { - return; - } - $field = $this->filter($field); - $previous = $this->data[$key]['attributes'][$field] ?? null; - unset($this->data[$key]['attributes'][$field]); - if ($previous !== null) { - $this->journal(function () use ($key, $field, $previous): void { - $this->data[$key]['attributes'][$field] = $previous; - }); - } - } - /** * Rename a field across every document in a collection, preserving null * entries so subsequent reads that join on the new key still resolve. @@ -898,9 +1026,6 @@ protected function renameDocumentField(string $collection, string $oldKey, strin }); } - /** - * Remove a field from every document in a collection. - */ protected function dropDocumentField(string $collection, string $field): void { $key = $this->key($collection); @@ -935,7 +1060,7 @@ protected function dropDocumentField(string $collection, string $field): void * Mirrors Database::getJunctionCollection — the junction is named after * the parent/child sequence pair. */ - protected function resolveJunctionCollection(string $collection, string $relatedCollection, string $side): ?string + protected function resolveJunctionCollection(string $collection, string $relatedCollection, RelationshipSide $side): ?string { $metadataKey = $this->key(Database::METADATA); if (! isset($this->data[$metadataKey])) { @@ -948,17 +1073,18 @@ protected function resolveJunctionCollection(string $collection, string $related return null; } - $collectionSequence = $collectionDoc[1]['_id'] ?? null; - $relatedSequence = $relatedDoc[1]['_id'] ?? null; - if ($collectionSequence === null || $relatedSequence === null) { + $collectionSequence = $collectionDoc[1][Storage::SEQUENCE] ?? null; + $relatedSequence = $relatedDoc[1][Storage::SEQUENCE] ?? null; + if (! \is_scalar($collectionSequence) || ! \is_scalar($relatedSequence)) { return null; } - return $side === Database::RELATION_SIDE_PARENT + return $side === RelationshipSide::Parent ? '_'.$collectionSequence.'_'.$relatedSequence : '_'.$relatedSequence.'_'.$collectionSequence; } + #[\Override] public function renameIndex(string $collection, string $old, string $new): bool { $key = $this->key($collection); @@ -970,7 +1096,7 @@ public function renameIndex(string $collection, string $old, string $new): bool $new = $this->filter($new); if (! isset($this->data[$key]['indexes'][$old])) { - return true; + return isset($this->data[$key]['indexes'][$new]); } $this->data[$key]['indexes'][$new] = $this->data[$key]['indexes'][$old]; @@ -995,20 +1121,19 @@ public function renameIndex(string $collection, string $old, string $new): bool return true; } - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool { $key = $this->key($collection); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } + $type = $index->type->value; + $attributes = $index->attributes; + $hashTable = []; - if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) { - // MariaDB rejects CREATE UNIQUE INDEX with errno 1062 when existing - // rows contain duplicates, which surfaces as UniqueException. Mirror - // that contract so Database::createIndex fails the same way. - // Build the hash table while we scan so we can reuse it for fast - // probes after the index lands — no second pass over the rows. + if ($type === IndexType::Unique->value && ! empty($attributes)) { foreach ($this->data[$key]['documents'] as $docKey => $row) { $signature = []; foreach ($attributes as $attribute) { @@ -1020,31 +1145,25 @@ public function createIndex(string $collection, string $id, string $type, array continue; } if ($this->sharedTables) { - \array_unshift($signature, $row['_tenant'] ?? null); + \array_unshift($signature, $row[Storage::TENANT] ?? null); } $hash = \serialize($signature); if (isset($hashTable[$hash])) { - // Under shared tables the duplicates may be another tenant's rows - throw new UniqueException('Unique index violation'); + throw new UniqueException(UniqueException::MESSAGE); } $hashTable[$hash] = $docKey; } } - $id = $this->filter($id); - $this->data[$key]['indexes'][$id] = [ - 'type' => $type, - 'attributes' => $attributes, - 'lengths' => $lengths, - 'orders' => $orders, - ]; - if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) { + $id = $this->filter($index->key); + $this->data[$key]['indexes'][$id] = self::indexEntry($index); + if ($type === IndexType::Unique->value && ! empty($attributes)) { $this->uniqueIndexHashes[$key][$id] = $hashTable; } $this->journal(function () use ($key, $id, $type): void { unset($this->data[$key]['indexes'][$id]); - if ($type === Database::INDEX_UNIQUE) { + if ($type === IndexType::Unique->value) { unset($this->uniqueIndexHashes[$key][$id]); } }); @@ -1052,33 +1171,35 @@ public function createIndex(string $collection, string $id, string $type, array return true; } - public function deleteIndex(string $collection, string $id): bool + #[\Override] + public function deleteIndex(string $collection, string $key): bool { - $key = $this->key($collection); - if (! isset($this->data[$key])) { + $table = $this->key($collection); + if (! isset($this->data[$table])) { return true; } - $id = $this->filter($id); - $previousIndex = $this->data[$key]['indexes'][$id] ?? null; - $previousHash = $this->uniqueIndexHashes[$key][$id] ?? null; + $id = $this->filter($key); + $previousIndex = $this->data[$table]['indexes'][$id] ?? null; + $previousHash = $this->uniqueIndexHashes[$table][$id] ?? null; unset( - $this->data[$key]['indexes'][$id], - $this->uniqueIndexHashes[$key][$id], + $this->data[$table]['indexes'][$id], + $this->uniqueIndexHashes[$table][$id], ); - $this->journal(function () use ($key, $id, $previousIndex, $previousHash): void { + $this->journal(function () use ($table, $id, $previousIndex, $previousHash): void { if ($previousIndex !== null) { - $this->data[$key]['indexes'][$id] = $previousIndex; + $this->data[$table]['indexes'][$id] = $previousIndex; } if ($previousHash !== null) { - $this->uniqueIndexHashes[$key][$id] = $previousHash; + $this->uniqueIndexHashes[$table][$id] = $previousHash; } }); return true; } + #[\Override] public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { $key = $this->key($collection->getId()); @@ -1101,7 +1222,7 @@ public function getDocument(Document $collection, string $id, array $queries = [ $row = $this->projectRow($row, $selections); } - return new Document($row); + return Document::fromStorage($row); } /** @@ -1112,12 +1233,11 @@ private function getSelectAttributes(array $queries): array { $selected = []; foreach ($queries as $query) { - if (! $query instanceof Query) { - continue; - } - if ($query->getMethod() === Query::TYPE_SELECT) { + if ($query->getMethod() === Method::Select) { foreach ($query->getValues() as $value) { - $selected[] = (string) $value; + if (\is_string($value)) { + $selected[] = $value; + } } } } @@ -1154,7 +1274,16 @@ private function projectRow(array $row, array $selections): array return $projected; } + #[\Override] public function createDocument(Document $collection, Document $document): Document + { + return $this->insertDocument($collection, $document) ?? $document; + } + + /** + * @return Document|null The stored document, or null when ignoreDuplicates() skipped it + */ + private function insertDocument(Document $collection, Document $document): ?Document { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { @@ -1163,13 +1292,14 @@ public function createDocument(Document $collection, Document $document): Docume $docKey = $this->documentKey($document->getId(), $document->getTenant()); if (isset($this->data[$key]['documents'][$docKey])) { - if ($this->skipDuplicates) { + if ($this->isIgnoringDuplicates()) { // Mirrors MariaDB's `INSERT IGNORE` — duplicate primary key is // silently dropped and the existing row's sequence is returned. $existing = $this->data[$key]['documents'][$docKey]; - $document['$sequence'] = (string) $existing['_id']; + $existingId = $existing[Storage::SEQUENCE] ?? ''; + $document[Document::SEQUENCE] = \is_scalar($existingId) ? (string) $existingId : ''; - return $document; + return null; } throw new DuplicateException('Document already exists'); } @@ -1178,28 +1308,30 @@ public function createDocument(Document $collection, Document $document): Docume try { $this->checkUniqueSignatures($key, $signatures, $docKey); } catch (DuplicateException $e) { - if ($this->skipDuplicates) { - return $document; + if ($this->isIgnoringDuplicates()) { + return null; } throw $e; } - $sequenceBefore = $this->data[$key]['sequence']; + $entry = &$this->data[$key]; + $sequenceBefore = $entry['sequence']; $sequence = $document->getSequence(); if (empty($sequence)) { - $this->data[$key]['sequence']++; - $sequence = $this->data[$key]['sequence']; + $entry['sequence']++; + $sequence = $entry['sequence']; } else { $sequence = (int) $sequence; - if ($sequence > $this->data[$key]['sequence']) { - $this->data[$key]['sequence'] = $sequence; + if ($sequence > $entry['sequence']) { + $entry['sequence'] = $sequence; } } $row = $this->documentToRow($document); - $row['_id'] = $sequence; + $row[Storage::SEQUENCE] = $sequence; - $this->data[$key]['documents'][$docKey] = $row; + $entry['documents'][$docKey] = $row; + unset($entry); $this->journal(function () use ($key, $docKey, $sequenceBefore): void { unset($this->data[$key]['documents'][$docKey]); $this->data[$key]['sequence'] = $sequenceBefore; @@ -1211,21 +1343,40 @@ public function createDocument(Document $collection, Document $document): Docume $this->writePermissions($key, $document); - $document['$sequence'] = (string) $sequence; + $document[Document::SEQUENCE] = (string) $sequence; return $document; } + #[\Override] public function createDocuments(Document $collection, array $documents): array { + // Mirror SQL's batch-level sequence consistency check: every document + // in a batch must either set $sequence or omit it. SQL adapters reject + // mixed batches up front; Memory must match so application code that + // catches the resulting DatabaseException behaves the same. + $hasSequence = null; + foreach ($documents as $document) { + $sequenceSet = ! empty($document->getSequence()); + if ($hasSequence === null) { + $hasSequence = $sequenceSet; + } elseif ($hasSequence !== $sequenceSet) { + throw new DatabaseException('All documents must have an sequence if one is set'); + } + } + $created = []; foreach ($documents as $document) { - $created[] = $this->createDocument($collection, $document); + $inserted = $this->insertDocument($collection, $document); + if ($inserted !== null) { + $created[] = $inserted; + } } return $created; } + #[\Override] public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { $key = $this->key($collection->getId()); @@ -1242,14 +1393,14 @@ public function updateDocument(Document $collection, string $id, Document $docum // Resolve any Operator-typed attributes against the existing row before // computing the new payload so unique-index checks see the post-update // values, matching MariaDB's atomic UPDATE semantics. - $resolvedAttrs = $this->applyOperators($document->getAttributes(), $existing); - foreach ($resolvedAttrs as $attribute => $value) { + $resolved = $this->applyOperators($document->getAttributes(), $existing); + foreach ($resolved as $attribute => $value) { $document->setAttribute($attribute, $value); } $newId = $document->getId(); $newKey = $this->documentKey($newId); - if ($newId !== $id && isset($this->data[$key]['documents'][$newKey])) { + if ($newKey !== $oldKey && isset($this->data[$key]['documents'][$newKey])) { throw new DuplicateException('Document already exists'); } @@ -1268,38 +1419,45 @@ public function updateDocument(Document $collection, string $id, Document $docum $oldSignatures = $this->rowUniqueSignatures($key, $existing); $this->checkUniqueSignatures($key, $newSignatures, $oldKey); - $row['_id'] = $existing['_id']; - if ($this->sharedTables && \array_key_exists('_tenant', $existing)) { + $row[Storage::SEQUENCE] = $existing[Storage::SEQUENCE]; + if ($this->sharedTables && \array_key_exists(Storage::TENANT, $existing)) { // Preserve the row's stored tenant — MariaDB's UPDATE statements // never rewrite `_tenant` and tests rely on the original tenant // (e.g. the metadata NULL-tenant rows) surviving an update. - $row['_tenant'] = $existing['_tenant']; + $row[Storage::TENANT] = $existing[Storage::TENANT]; } + $tenantValue = $existing[Storage::TENANT] ?? $this->getTenant(); $newKey = $this->sharedTables - ? ($existing['_tenant'] ?? $this->getTenant()).'|'.\strtolower($newId) + ? (\is_scalar($tenantValue) ? (string) $tenantValue : '').'|'.\strtolower($newId) : \strtolower($newId); - $oldKeyHadRow = isset($this->data[$key]['documents'][$oldKey]); - $previousAtNewKey = $this->data[$key]['documents'][$newKey] ?? null; + $entry = &$this->data[$key]; + $oldKeyHadRow = isset($entry['documents'][$oldKey]); + $previousAtNewKey = $entry['documents'][$newKey] ?? null; if ($newId !== $id || $newKey !== $oldKey) { - unset($this->data[$key]['documents'][$oldKey]); + unset($entry['documents'][$oldKey]); } - $this->data[$key]['documents'][$newKey] = $row; + $entry['documents'][$newKey] = $row; + unset($entry); $this->journal(function () use ($key, $oldKey, $newKey, $existing, $oldKeyHadRow, $previousAtNewKey): void { + if (! isset($this->data[$key])) { + return; + } + $entry = &$this->data[$key]; if ($oldKey !== $newKey) { if ($previousAtNewKey === null) { - unset($this->data[$key]['documents'][$newKey]); + unset($entry['documents'][$newKey]); } else { - $this->data[$key]['documents'][$newKey] = $previousAtNewKey; + $entry['documents'][$newKey] = $previousAtNewKey; } if ($oldKeyHadRow) { - $this->data[$key]['documents'][$oldKey] = $existing; + $entry['documents'][$oldKey] = $existing; } } else { - $this->data[$key]['documents'][$oldKey] = $existing; + $entry['documents'][$oldKey] = $existing; } }); @@ -1308,16 +1466,8 @@ public function updateDocument(Document $collection, string $id, Document $docum // post-update, register the new binding. $allIndexes = \array_unique([...\array_keys($oldSignatures), ...\array_keys($newSignatures)]); foreach ($allIndexes as $indexId) { - $this->probeUniqueHash( - $key, - $indexId, - $newSignatures[$indexId] ?? null, - $oldSignatures[$indexId] ?? null, - $newKey, - ); - // Old key removal: if the docKey changed, also drop any binding - // pointing at the old key (the probeUniqueHash above keys against - // $newKey, so a stale binding under $oldKey is left untouched). + // A rename moves the row to $newKey: release the binding the row + // holds under $oldKey first, or a value it keeps reads as taken. if ($oldKey !== $newKey) { $oldHash = $oldSignatures[$indexId] ?? null; if ($oldHash !== null @@ -1328,6 +1478,13 @@ public function updateDocument(Document $collection, string $id, Document $docum }); } } + $this->probeUniqueHash( + $key, + $indexId, + $newSignatures[$indexId] ?? null, + $oldSignatures[$indexId] ?? null, + $newKey, + ); } if (! $skipPermissions) { @@ -1365,7 +1522,8 @@ public function updateDocument(Document $collection, string $id, Document $docum return $document; } - public function updateDocuments(Document $collection, Document $updates, array $documents): int + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int { if (empty($documents)) { return 0; @@ -1376,11 +1534,11 @@ public function updateDocuments(Document $collection, Document $updates, array $ throw new NotFoundException('Collection not found'); } - $attrs = $updates->getAttributes(); + $attributes = $updates->getAttributes(); $hasCreatedAt = ! empty($updates->getCreatedAt()); $hasUpdatedAt = ! empty($updates->getUpdatedAt()); - $hasPermissions = $updates->offsetExists('$permissions'); - if (empty($attrs) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) { + $hasPermissions = $updates->offsetExists(Document::PERMISSIONS); + if (empty($attributes) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) { return 0; } @@ -1398,15 +1556,15 @@ public function updateDocuments(Document $collection, Document $updates, array $ $existingRow = $this->data[$key]['documents'][$docKey]; // Resolve operators per-row — each document's existing values feed - // back into operator evaluation, so $attrs cannot be evaluated + // back into operator evaluation, so $attributes cannot be evaluated // once and reused. - $resolvedAttrs = $this->applyOperators($attrs, $existingRow); + $resolved = $this->applyOperators($attributes, $existingRow); - $merged = ! empty($resolvedAttrs) - ? new Document(\array_merge( + $merged = ! empty($resolved) + ? Document::fromStorage(\array_merge( $this->rowToDocument($existingRow), - $resolvedAttrs, - ['$id' => $uid] + $resolved, + [Document::ID => $uid] )) : null; @@ -1416,7 +1574,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ $prepared[] = [ 'uid' => $uid, 'docKey' => $docKey, - 'attrs' => $resolvedAttrs, + 'attrs' => $resolved, 'newSignatures' => $newSignatures, 'oldSignatures' => $oldSignatures, ]; @@ -1443,11 +1601,11 @@ public function updateDocuments(Document $collection, Document $updates, array $ } } if (! $existingIsSelf) { - throw new UniqueException('Unique index violation'); + throw new UniqueException(UniqueException::MESSAGE); } } if (isset($pendingByIndex[$indexId][$hash]) && $pendingByIndex[$indexId][$hash] !== $docKey) { - throw new UniqueException('Unique index violation'); + throw new UniqueException(UniqueException::MESSAGE); } $pendingByIndex[$indexId][$hash] = $docKey; } @@ -1457,23 +1615,23 @@ public function updateDocuments(Document $collection, Document $updates, array $ foreach ($prepared as $entry) { $uid = $entry['uid']; $docKey = $entry['docKey']; - $resolvedAttrs = $entry['attrs']; + $resolved = $entry['attrs']; $previousRow = $this->data[$key]['documents'][$docKey]; $row = &$this->data[$key]['documents'][$docKey]; - foreach ($resolvedAttrs as $attribute => $value) { + foreach ($resolved as $attribute => $value) { $row[$this->filter($attribute)] = $value; } if ($hasCreatedAt) { - $row['_createdAt'] = $updates->getCreatedAt(); + $row[Storage::CREATED_AT] = $updates->getCreatedAt(); } if ($hasUpdatedAt) { - $row['_updatedAt'] = $updates->getUpdatedAt(); + $row[Storage::UPDATED_AT] = $updates->getUpdatedAt(); } if ($hasPermissions) { - $row['_permissions'] = $updates->getPermissions(); + $row[Storage::PERMISSIONS] = $updates->getPermissions(); } unset($row); @@ -1483,14 +1641,13 @@ public function updateDocuments(Document $collection, Document $updates, array $ if ($hasPermissions) { $this->removePermissionsForDocument($key, $uid, $tenant, $this->sharedTables); - foreach (Database::PERMISSIONS as $type) { + foreach ([PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete] as $type) { foreach ($updates->getPermissionsByType($type) as $permission) { - $this->addPermissionEntry($key, $uid, (string) $type, (string) $permission, $tenant); + $this->addPermissionEntry($key, $uid, $type->value, (string) $permission, $tenant); } } } - // Sync unique-index hashes per-row. $allIndexes = \array_unique([...\array_keys($entry['oldSignatures']), ...\array_keys($entry['newSignatures'])]); foreach ($allIndexes as $indexId) { $this->probeUniqueHash( @@ -1506,14 +1663,10 @@ public function updateDocuments(Document $collection, Document $updates, array $ return \count($prepared); } - public function upsertDocuments(Document $collection, string $attribute, array $changes): array - { - throw new DatabaseException('Upsert is not implemented in the Memory adapter'); - } - - public function getSequences(string $collection, array $documents): array + #[\Override] + public function getSequences(Document $collection, array $documents): array { - $key = $this->key($collection); + $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { return $documents; } @@ -1526,16 +1679,18 @@ public function getSequences(string $collection, array $documents): array // — the lookup must use each document's own tenant, not the adapter's current tenant. $existing = $this->data[$key]['documents'][$this->documentKey($doc->getId(), $doc->getTenant())] ?? null; if ($existing !== null) { - $documents[$index]->setAttribute('$sequence', (string) $existing['_id']); + $existingId = $existing[Storage::SEQUENCE] ?? ''; + $documents[$index]->setAttribute(Document::SEQUENCE, \is_scalar($existingId) ? (string) $existingId : ''); } } return $documents; } - public function deleteDocument(string $collection, string $id): bool + #[\Override] + public function deleteDocument(Document $collection, string $id): bool { - $key = $this->key($collection); + $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { // MariaDB throws when the collection itself is gone (PDO unknown // table → NotFoundException). A missing document inside an existing @@ -1556,7 +1711,6 @@ public function deleteDocument(string $collection, string $id): bool $this->data[$key]['documents'][$docKey] = $existing; }); - // Drop unique-index hash bindings for this row. foreach ($oldSignatures as $indexId => $hash) { if (($this->uniqueIndexHashes[$key][$indexId][$hash] ?? null) === $docKey) { unset($this->uniqueIndexHashes[$key][$indexId][$hash]); @@ -1566,15 +1720,16 @@ public function deleteDocument(string $collection, string $id): bool } } - $tenant = $this->getTenant(); - $this->removePermissionsForDocument($key, $id, $tenant, $this->sharedTables); + $storedId = $existing[Storage::UID] ?? $id; + $this->removePermissionsForDocument($key, \is_string($storedId) ? $storedId : $id, $this->getTenant(), $this->sharedTables); return true; } - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { - $key = $this->key($collection); + $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } @@ -1590,11 +1745,13 @@ public function deleteDocuments(string $collection, array $sequences, array $per // With sharedTables the row map is keyed by "tenant|uid" so sequence // collisions across tenants are possible. Skip rows that don't belong // to the current tenant so we never delete another tenant's data. - if ($this->sharedTables && ($row['_tenant'] ?? null) !== $this->getTenant()) { + if ($this->sharedTables && ($row[Storage::TENANT] ?? null) !== $this->getTenant()) { continue; } - if (isset($seqSet[(string) ($row['_id'] ?? '')])) { - $deletedIds[(string) ($row['_uid'] ?? $docKey)] = true; + $rowId = $row[Storage::SEQUENCE] ?? ''; + $rowUid = $row[Storage::UID] ?? $docKey; + if (isset($seqSet[\is_scalar($rowId) ? (string) $rowId : ''])) { + $deletedIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true; $oldSignatures = $this->rowUniqueSignatures($key, $row); unset($this->data[$key]['documents'][$docKey]); $this->journal(function () use ($key, $docKey, $row): void { @@ -1632,14 +1789,15 @@ public function deleteDocuments(string $collection, array $sequences, array $per return $count; } - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } - $rows = $this->fusedFilter($key, $collection->getId(), $queries, $forPermission); + $rows = $this->fusedFilter($key, $collection->getId(), $queries, $forPermission->value); $rows = $this->applyOrdering($rows, $orderAttributes, $orderTypes, $cursorDirection); $rows = $this->applyCursor($rows, $orderAttributes, $orderTypes, $cursor, $cursorDirection); @@ -1653,16 +1811,17 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $selections = $this->extractSelections($queries); $results = []; foreach ($rows as $row) { - $results[] = new Document($this->rowToDocument($row, $selections, $key)); + $results[] = Document::fromStorage($this->rowToDocument($row, $selections, $key)); } - if ($cursorDirection === Database::CURSOR_BEFORE) { + if ($cursorDirection === CursorDirection::Before) { $results = \array_reverse($results); } return $results; } + #[\Override] public function count(Document $collection, array $queries = [], ?int $max = null): int { $key = $this->key($collection->getId()); @@ -1670,7 +1829,7 @@ public function count(Document $collection, array $queries = [], ?int $max = nul throw new NotFoundException('Collection not found'); } - $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ); + $rows = $this->fusedFilter($key, $collection->getId(), $queries, PermissionType::Read->value); if (! is_null($max)) { // MariaDB applies LIMIT :max inside the COUNT subquery — LIMIT 0 @@ -1681,6 +1840,7 @@ public function count(Document $collection, array $queries = [], ?int $max = nul return \count($rows); } + #[\Override] public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int { $key = $this->key($collection->getId()); @@ -1688,7 +1848,7 @@ public function sum(Document $collection, string $attribute, array $queries = [] throw new NotFoundException('Collection not found'); } - $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ); + $rows = $this->fusedFilter($key, $collection->getId(), $queries, PermissionType::Read->value); if (! is_null($max)) { $rows = \array_slice($rows, 0, $max); @@ -1698,21 +1858,23 @@ public function sum(Document $collection, string $attribute, array $queries = [] $isFloat = false; $column = $this->filter($attribute); foreach ($rows as $row) { - if (! \array_key_exists($column, $row) || $row[$column] === null) { + $value = $row[$column] ?? null; + if ($value === null || ! \is_numeric($value)) { continue; } - if (\is_float($row[$column])) { + if (\is_float($value)) { $isFloat = true; } - $sum += $row[$column]; + $sum += $value; } return $isFloat ? (float) $sum : (int) $sum; } - public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, string $updatedAt, int|float|null $min = null, int|float|null $max = null): bool + #[\Override] + public function increaseDocumentAttribute(Document $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool { - $key = $this->key($collection); + $key = $this->key($collection->getId()); $docKey = $this->documentKey($id); if (! isset($this->data[$key]['documents'][$docKey])) { throw new NotFoundException('Document not found'); @@ -1720,9 +1882,10 @@ public function increaseDocumentAttribute(string $collection, string $id, string $column = $this->filter($attribute); $previousValue = $this->data[$key]['documents'][$docKey][$column] ?? null; - $previousUpdatedAt = $this->data[$key]['documents'][$docKey]['_updatedAt'] ?? null; + $previousUpdatedAt = $this->data[$key]['documents'][$docKey][Storage::UPDATED_AT] ?? null; $current = $previousValue ?? 0; - $current = is_numeric($current) ? $current + 0 : 0; + $exact = (\is_int($current) || (\is_string($current) && BigInt::isIntegerString($current))) + && (\is_int($value) || (\is_string($value) && BigInt::isIntegerString($value))); // MariaDB encodes the bound check as part of the WHERE clause against // the current column value (`attr <= :max` / `attr >= :min`); when the @@ -1730,32 +1893,53 @@ public function increaseDocumentAttribute(string $collection, string $id, string // still returns true. Mirror that — silent no-op on bound violation. // The Database layer pre-subtracts $value from $max (and adds it to // $min), so the comparison stays against the pre-update value. - if (! is_null($min) && $current < $min) { - return true; - } - if (! is_null($max) && $current > $max) { - return true; + if ($exact) { + $current = BigInt::toNative($current); + $value = BigInt::toNative($value); + if (! is_null($min) && BigInt::compare($current, $min) < 0) { + return true; + } + if (! is_null($max) && BigInt::compare($current, $max) > 0) { + return true; + } + $result = BigInt::add($current, $value); + } else { + $current = $this->numericValue($current, 0) ?? 0; + $value = $this->numericValue($value, 0) ?? 0; + if (! is_null($min) && $current < $min) { + return true; + } + if (! is_null($max) && $current > $max) { + return true; + } + $result = $current + $value; } - $this->data[$key]['documents'][$docKey][$column] = $current + $value; - $this->data[$key]['documents'][$docKey]['_updatedAt'] = $updatedAt; + $this->data[$key]['documents'][$docKey][$column] = $result; + $this->data[$key]['documents'][$docKey][Storage::UPDATED_AT] = $updatedAt; $this->journal(function () use ($key, $docKey, $column, $previousValue, $previousUpdatedAt): void { + if (! isset($this->data[$key]['documents'][$docKey])) { + return; + } + $row = &$this->data[$key]['documents'][$docKey]; if ($previousValue === null) { - unset($this->data[$key]['documents'][$docKey][$column]); + unset($row[$column]); } else { - $this->data[$key]['documents'][$docKey][$column] = $previousValue; + $row[$column] = $previousValue; } if ($previousUpdatedAt === null) { - unset($this->data[$key]['documents'][$docKey]['_updatedAt']); + unset($row[Storage::UPDATED_AT]); } else { - $this->data[$key]['documents'][$docKey]['_updatedAt'] = $previousUpdatedAt; + $row[Storage::UPDATED_AT] = $previousUpdatedAt; } + unset($row); }); return true; } + #[\Override] public function getSizeOfCollection(string $collection): int { $key = $this->key($collection); @@ -1766,507 +1950,127 @@ public function getSizeOfCollection(string $collection): int return \strlen(\serialize($this->data[$key])); } + #[\Override] public function getSizeOfCollectionOnDisk(string $collection): int { return $this->getSizeOfCollection($collection); } - public function getLimitForString(): int - { - return 4294967295; - } - - public function getLimitForInt(): int - { - return 4294967295; + /** + * No index is bounded by bytes here, but index validation subtracts from the index length, so it is + * Mongo's positive cap. + */ + #[\Override] + public function limits(): Limits + { + return $this->limits ??= new Limits( + string: 4294967295, + varchar: 16381, + integer: 4294967295, + bigInteger: Database::MAX_BIG_INT, + attributes: 1017, + indexes: 64, + defaultAttributes: \count(Database::internalAttributesFor(true)), + defaultIndexes: \count(Database::INTERNAL_INDEXES), + indexLength: 1024, + uidLength: 255, + documentSize: 0, + minDateTime: new \DateTime('0001-01-01 00:00:00'), + maxDateTime: new \DateTime(self::MAX_DATETIME), + idType: ColumnType::Integer, + keywords: [], + internalIndexKeys: [], + ); } - public function getLimitForAttributes(): int + #[\Override] + public function getCountOfAttributes(Document $collection): int { - return 1017; + return \count(self::collectionAttributes($collection)) + $this->limits()->defaultAttributes; } - public function getLimitForIndexes(): int + #[\Override] + public function getCountOfIndexes(Document $collection): int { - return 64; + return \count(self::collectionIndexes($collection)) + $this->limits()->defaultIndexes; } - public function getMaxIndexLength(): int + #[\Override] + public function getAttributeWidth(Document $collection): int { - // Memory does not enforce per-index byte limits, but the Database - // layer expects a positive cap so callers can derive sizes via - // arithmetic (e.g. `getMaxIndexLength() - 68`). Match Mongo's value. - return 1024; + return 0; } - public function getMaxVarcharLength(): int + /** + * @return array + */ + protected function documentToRow(Document $document): array { - return 16381; - } + $row = []; + foreach ($document->getAttributes() as $attribute => $value) { + // Store native PHP values directly — no JSON encoding for arrays. + // The Database casting layer accepts already-decoded arrays + // (decodeArrayValue / decodeObjectValue both pass through arrays). + $row[$this->filter($attribute)] = $value; + } - public function getMaxUIDLength(): int - { - return 255; - } + $row[Storage::UID] = $document->getId(); + $row[Storage::CREATED_AT] = $document->getCreatedAt(); + $row[Storage::UPDATED_AT] = $document->getUpdatedAt(); + $row[Storage::PERMISSIONS] = $document->getPermissions(); + if ($this->sharedTables) { + // Mirror MariaDB: the row's `_tenant` follows the document's own + // tenant — that matters in tenantPerDocument mode where the + // adapter's current tenant is null but each document is tagged. + $row[Storage::TENANT] = $document->getTenant() ?? $this->getTenant(); + } - public function getMinDateTime(): \DateTime - { - return new \DateTime('0001-01-01 00:00:00'); + return $row; } - public function getIdAttributeType(): string + /** + * Translate a stored row into a Document payload. Array attributes are kept + * as JSON strings so the Database layer's `casting`/`decode` filters do the + * decoding (mirroring how the SQL adapters return raw column values). Only + * a SELECT projection — when supplied — is enforced here, restricting the + * returned payload to the requested attributes plus the internal columns + * MariaDB always projects (`$id`, `$sequence`, `$createdAt`, `$updatedAt`, + * `$permissions`, `$tenant`, `$collection`). + * + * @param array $row + * @param array|null $selections + * @return array + */ + protected function rowToDocument(array $row, ?array $selections = null, ?string $storageKey = null): array { - return Database::VAR_INTEGER; - } - - public function getSupportForSchemas(): bool - { - return true; - } - - public function getSupportForAttributes(): bool - { - return $this->supportForAttributes; - } - - public function setSupportForAttributes(bool $support): bool - { - $this->supportForAttributes = $support; - - return $this->supportForAttributes; - } - - public function getSupportForSchemaAttributes(): bool - { - return false; - } - - public function getSupportForSchemaIndexes(): bool - { - return false; - } - - public function getSupportForIndex(): bool - { - return true; - } - - public function getSupportForIndexArray(): bool - { - return false; - } - - public function getSupportForCastIndexArray(): bool - { - return false; - } - - public function getSupportForUniqueIndex(): bool - { - return true; - } - - public function getSupportForFulltextIndex(): bool - { - return true; - } - - public function getSupportForFulltextWildcardIndex(): bool - { - return false; - } - - public function getSupportForCasting(): bool - { - // Memory stores native PHP types where possible but JSON-encodes array - // attributes on write. Returning true asks the Database layer's - // `casting` step to JSON-decode array columns and coerce scalar types - // — same behaviour as the SQL adapters. - return true; - } - - public function getSupportForQueryContains(): bool - { - return true; - } - - public function getSupportForTimeouts(): bool - { - return false; - } - - public function getSupportForRelationships(): bool - { - return true; - } - - public function getSupportForUpdateLock(): bool - { - return false; - } - - public function getSupportForBatchOperations(): bool - { - return true; - } - - public function getSupportForAttributeResizing(): bool - { - return true; - } - - public function getSupportForGetConnectionId(): bool - { - return false; - } - - public function getSupportForUpserts(): bool - { - return false; - } - - public function getSupportForUpsertOnUniqueIndex(): bool - { - return false; - } - - public function getSupportForVectors(): bool - { - return false; - } - - public function getSupportForCacheSkipOnFailure(): bool - { - return false; - } - - public function getSupportForCaching(): bool - { - return true; - } - - public function getSupportForReconnection(): bool - { - return false; - } - - public function getSupportForHostname(): bool - { - return false; - } - - public function getSupportForBatchCreateAttributes(): bool - { - return true; - } - - public function getSupportForSpatialAttributes(): bool - { - return false; - } - - public function getSupportForObject(): bool - { - return true; - } - - public function getSupportForObjectIndexes(): bool - { - return true; - } - - public function getSupportForSpatialIndexNull(): bool - { - return false; - } - - public function getSupportForOperators(): bool - { - return true; - } - - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return false; - } - - public function getSupportForSpatialIndexOrder(): bool - { - return false; - } - - public function getSupportForSpatialAxisOrder(): bool - { - return false; - } - - public function getSupportForBoundaryInclusiveContains(): bool - { - return false; - } - - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool - { - return false; - } - - public function getSupportForMultipleFulltextIndexes(): bool - { - return false; - } - - public function getSupportForIdenticalIndexes(): bool - { - return false; - } - - public function getSupportForOrderRandom(): bool - { - return true; - } - - public function getCountOfAttributes(Document $collection): int - { - return \count($collection->getAttribute('attributes', [])) + $this->getCountOfDefaultAttributes(); - } - - public function getCountOfIndexes(Document $collection): int - { - return \count($collection->getAttribute('indexes', [])) + $this->getCountOfDefaultIndexes(); - } - - public function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } - - public function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); - } - - public function getDocumentSizeLimit(): int - { - return 0; - } - - public function getAttributeWidth(Document $collection): int - { - return 0; - } - - public function getKeywords(): array - { - return []; - } - - protected function getAttributeProjection(array $selections, string $prefix): mixed - { - return $selections; - } - - public function getConnectionId(): string - { - return '0'; - } - - public function getInternalIndexesKeys(): array - { - return []; - } - - public function getSchemaAttributes(string $collection): array - { - return []; - } - - public function getSchemaIndexes(string $collection): array - { - return []; - } - - public function getTenantQuery(string $collection, string $alias = ''): string - { - return ''; - } - - protected function execute(mixed $stmt): bool - { - return true; - } - - protected function quote(string $string): string - { - return '"'.$string.'"'; - } - - public function decodePoint(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Memory adapter'); - } - - public function decodeLinestring(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Memory adapter'); - } - - public function decodePolygon(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Memory adapter'); - } - - public function castingBefore(Document $collection, Document $document): Document - { - return $document; - } - - public function castingAfter(Document $collection, Document $document): Document - { - return $document; - } - - /** - * Get max BIGINT limit - * - * @return int - */ - public function getLimitForBigInt(): int - { - return Database::MAX_BIG_INT; - } - - public function getSupportForInternalCasting(): bool - { - return false; - } - - public function getSupportForUTCCasting(): bool - { - return false; - } - - public function setUTCDatetime(string $value): mixed - { - return $value; - } - - public function getSupportForIntegerBooleans(): bool - { - return false; - } - - public function getSupportForAlterLocks(): bool - { - return false; - } - - public function getSupportNonUtfCharacters(): bool - { - // Memory is a pass-through PHP array, so it does NOT actively reject - // non-UTF-8 byte sequences. Returning false skips the inherited - // non-UTF-character scope test that asserts adapter rejection. - return false; - } - - public function getSupportForTrigramIndex(): bool - { - return false; - } - - public function getSupportForPCRERegex(): bool - { - return true; - } - - public function getSupportForPOSIXRegex(): bool - { - return false; - } - - public function getSupportForTransactionRetries(): bool - { - return false; - } - - public function getSupportForNestedTransactions(): bool - { - return true; - } - - // ----------------------------------------------------------------- - // Internal helpers - // ----------------------------------------------------------------- - - /** - * @return array - */ - protected function documentToRow(Document $document): array - { - $row = []; - foreach ($document->getAttributes() as $attribute => $value) { - // Store native PHP values directly — no JSON encoding for arrays. - // The Database casting layer accepts already-decoded arrays - // (decodeArrayValue / decodeObjectValue both pass through arrays). - $row[$this->filter($attribute)] = $value; - } - - $row['_uid'] = $document->getId(); - $row['_createdAt'] = $document->getCreatedAt(); - $row['_updatedAt'] = $document->getUpdatedAt(); - $row['_permissions'] = $document->getPermissions(); - if ($this->sharedTables) { - // Mirror MariaDB: the row's `_tenant` follows the document's own - // tenant — that matters in tenantPerDocument mode where the - // adapter's current tenant is null but each document is tagged. - $row['_tenant'] = $document->getTenant() ?? $this->getTenant(); - } - - return $row; - } - - /** - * Translate a stored row into a Document payload. Array attributes are kept - * as JSON strings so the Database layer's `casting`/`decode` filters do the - * decoding (mirroring how the SQL adapters return raw column values). Only - * a SELECT projection — when supplied — is enforced here, restricting the - * returned payload to the requested attributes plus the internal columns - * MariaDB always projects (`$id`, `$sequence`, `$createdAt`, `$updatedAt`, - * `$permissions`, `$tenant`, `$collection`). - * - * @param array $row - * @param array|null $selections - * @return array - */ - protected function rowToDocument(array $row, ?array $selections = null, ?string $storageKey = null): array - { - $allowed = null; - if ($selections !== null && $selections !== [] && ! \in_array('*', $selections, true)) { - $allowed = []; - foreach ($selections as $selection) { - $allowed[$this->filter($selection)] = true; - } - } + $allowed = null; + if ($selections !== null && $selections !== [] && ! \in_array('*', $selections, true)) { + $allowed = []; + foreach ($selections as $selection) { + $allowed[$this->filter($selection)] = true; + } + } $document = []; foreach ($row as $key => $value) { switch ($key) { - case '_id': - $document['$sequence'] = (string) $value; + case Storage::SEQUENCE: + $document[Document::SEQUENCE] = \is_scalar($value) ? (string) $value : ''; break; - case '_uid': - $document['$id'] = $value; + case Storage::UID: + $document[Document::ID] = $value; break; - case '_tenant': - $document['$tenant'] = $value; + case Storage::TENANT: + $document[Document::TENANT] = $value; break; - case '_createdAt': - $document['$createdAt'] = $value; + case Storage::CREATED_AT: + $document[Document::CREATED_AT] = $value; break; - case '_updatedAt': - $document['$updatedAt'] = $value; + case Storage::UPDATED_AT: + $document[Document::UPDATED_AT] = $value; break; - case '_permissions': - $document['$permissions'] = $value ?? []; + case Storage::PERMISSIONS: + $document[Document::PERMISSIONS] = $value ?? []; break; default: if ($allowed !== null && ! isset($allowed[$key])) { @@ -2280,7 +2084,7 @@ protected function rowToDocument(array $row, ?array $selections = null, ?string // MariaDB selecting a `DEFAULT NULL` column even when no row has set it. if ($storageKey !== null && isset($this->data[$storageKey]['attributes'])) { foreach ($this->data[$storageKey]['attributes'] as $attributeId => $definition) { - if (($definition['type'] ?? null) !== Database::VAR_RELATIONSHIP) { + if (($definition['type'] ?? null) !== ColumnType::Relationship->value) { continue; } if ($allowed !== null && ! isset($allowed[$attributeId])) { @@ -2303,7 +2107,7 @@ protected function extractSelections(array $queries): array { $selections = []; foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { + if ($query->getMethod() === Method::Select) { foreach ($query->getValues() as $value) { if (\is_string($value)) { $selections[] = $value; @@ -2319,9 +2123,9 @@ protected function writePermissions(string $key, Document $document): void { $uid = $document->getId(); $tenant = $document->getTenant() ?? $this->getTenant(); - foreach (Database::PERMISSIONS as $type) { + foreach ([PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete] as $type) { foreach ($document->getPermissionsByType($type) as $permission) { - $this->addPermissionEntry($key, $uid, $type, $permission, $tenant); + $this->addPermissionEntry($key, $uid, $type->value, (string) $permission, $tenant); } } } @@ -2340,20 +2144,15 @@ protected function addPermissionEntry(string $key, string $document, string $typ 'tenant' => $tenant, ]; $this->permissions[$key][] = $entry; - $this->permissionsByDocument[$key][$document][$type][$clean] = true; - $bucket = $tenant === null ? '__null__' : (string) $tenant; + $bucket = $this->permissionBucket($tenant); + $this->permissionsByDocument[$key][$bucket][$document][$type][$clean] = true; $this->permissionsByPermission[$key][$type][$bucket][$clean][$document] = true; $flatIndex = \array_key_last($this->permissions[$key]); $this->journal(function () use ($key, $flatIndex, $document, $type, $clean, $bucket): void { unset($this->permissions[$key][$flatIndex]); - unset($this->permissionsByDocument[$key][$document][$type][$clean]); - if (empty($this->permissionsByDocument[$key][$document][$type])) { - unset($this->permissionsByDocument[$key][$document][$type]); - if (empty($this->permissionsByDocument[$key][$document])) { - unset($this->permissionsByDocument[$key][$document]); - } - } + unset($this->permissionsByDocument[$key][$bucket][$document][$type][$clean]); + $this->pruneDocumentPermissions($key, $bucket, $document, $type); unset($this->permissionsByPermission[$key][$type][$bucket][$clean][$document]); if (empty($this->permissionsByPermission[$key][$type][$bucket][$clean])) { unset($this->permissionsByPermission[$key][$type][$bucket][$clean]); @@ -2373,17 +2172,19 @@ protected function addPermissionEntry(string $key, string $document, string $typ */ protected function removePermissionsForDocument(string $key, string $documentId, int|string|null $tenantScope, bool $sharedTablesScope): array { - $byType = $this->permissionsByDocument[$key][$documentId] ?? null; - if ($byType === null) { - return []; - } - - $removed = []; - foreach ($byType as $type => $set) { - foreach (\array_keys($set) as $permission) { - $removed[] = ['document' => $documentId, 'type' => (string) $type, 'permission' => (string) $permission]; + $buckets = $sharedTablesScope + ? [$this->permissionBucket($tenantScope)] + : \array_keys($this->permissionsByDocument[$key] ?? []); + $indexed = false; + foreach ($buckets as $bucket) { + if (isset($this->permissionsByDocument[$key][$bucket][$documentId])) { + $indexed = true; + break; } } + if (! $indexed) { + return []; + } // Walk the flat list once, dropping matching entries while respecting // the tenant scope. We collect the original flat-list keys because @@ -2400,7 +2201,7 @@ protected function removePermissionsForDocument(string $key, string $documentId, } $journalEntries[$index] = $entry; unset($this->permissions[$key][$index]); - $bucket = $entry['tenant'] === null ? '__null__' : (string) $entry['tenant']; + $bucket = $this->permissionBucket($entry['tenant']); unset($this->permissionsByPermission[$key][$entry['type']][$bucket][$entry['permission']][$documentId]); if (empty($this->permissionsByPermission[$key][$entry['type']][$bucket][$entry['permission']])) { unset($this->permissionsByPermission[$key][$entry['type']][$bucket][$entry['permission']]); @@ -2408,20 +2209,15 @@ protected function removePermissionsForDocument(string $key, string $documentId, unset($this->permissionsByPermission[$key][$entry['type']][$bucket]); } } - unset($this->permissionsByDocument[$key][$documentId][$entry['type']][$entry['permission']]); - if (empty($this->permissionsByDocument[$key][$documentId][$entry['type']])) { - unset($this->permissionsByDocument[$key][$documentId][$entry['type']]); - } - } - if (empty($this->permissionsByDocument[$key][$documentId] ?? [])) { - unset($this->permissionsByDocument[$key][$documentId]); + unset($this->permissionsByDocument[$key][$bucket][$documentId][$entry['type']][$entry['permission']]); + $this->pruneDocumentPermissions($key, $bucket, $documentId, $entry['type']); } $this->journal(function () use ($key, $journalEntries): void { foreach ($journalEntries as $index => $entry) { $this->permissions[$key][$index] = $entry; - $this->permissionsByDocument[$key][$entry['document']][$entry['type']][$entry['permission']] = true; - $bucket = $entry['tenant'] === null ? '__null__' : (string) $entry['tenant']; + $bucket = $this->permissionBucket($entry['tenant']); + $this->permissionsByDocument[$key][$bucket][$entry['document']][$entry['type']][$entry['permission']] = true; $this->permissionsByPermission[$key][$entry['type']][$bucket][$entry['permission']][$entry['document']] = true; } }); @@ -2429,6 +2225,26 @@ protected function removePermissionsForDocument(string $key, string $documentId, return \array_values($journalEntries); } + protected function permissionBucket(int|string|null $tenant): string + { + return $tenant === null ? '__null__' : (string) $tenant; + } + + protected function pruneDocumentPermissions(string $key, string $bucket, string $documentId, string $type): void + { + if (! empty($this->permissionsByDocument[$key][$bucket][$documentId][$type])) { + return; + } + unset($this->permissionsByDocument[$key][$bucket][$documentId][$type]); + if (! empty($this->permissionsByDocument[$key][$bucket][$documentId])) { + return; + } + unset($this->permissionsByDocument[$key][$bucket][$documentId]); + if (empty($this->permissionsByDocument[$key][$bucket])) { + unset($this->permissionsByDocument[$key][$bucket]); + } + } + /** * Update the unique-index hash table for a row mutation. Pass the new * signature ($newSignature) and the old signature ($oldSignature) — pass @@ -2439,7 +2255,7 @@ protected function probeUniqueHash(string $key, string $indexId, ?string $newHas { if ($newHash !== null && isset($this->uniqueIndexHashes[$key][$indexId][$newHash]) && $this->uniqueIndexHashes[$key][$indexId][$newHash] !== $docKey) { - throw new UniqueException('Unique index violation'); + throw new UniqueException(UniqueException::MESSAGE); } $previousValueAtNew = $newHash !== null ? ($this->uniqueIndexHashes[$key][$indexId][$newHash] ?? null) : null; @@ -2479,15 +2295,18 @@ protected function rowUniqueSignatures(string $key, array $row): array { $result = []; foreach ($this->data[$key]['indexes'] ?? [] as $indexId => $index) { - if (($index['type'] ?? '') !== Database::INDEX_UNIQUE) { + if (($index['type'] ?? '') !== IndexType::Unique->value) { continue; } $attributes = $index['attributes'] ?? []; - if (empty($attributes)) { + if (! \is_array($attributes) || empty($attributes)) { continue; } $signature = []; foreach ($attributes as $attribute) { + if (! \is_string($attribute)) { + continue; + } $signature[] = $this->normalizeIndexValue($this->resolveAttributeValue($row, $attribute)); } if (\in_array(null, $signature, true)) { @@ -2498,7 +2317,7 @@ protected function rowUniqueSignatures(string $key, array $row): array // tenant into the hash key so two tenants holding the same // value do not collide. if ($this->sharedTables) { - \array_unshift($signature, $row['_tenant'] ?? null); + \array_unshift($signature, $row[Storage::TENANT] ?? null); } $result[$indexId] = \serialize($signature); } @@ -2515,24 +2334,30 @@ protected function documentUniqueSignatures(string $key, Document $document): ar { $result = []; foreach ($this->data[$key]['indexes'] ?? [] as $indexId => $index) { - if (($index['type'] ?? '') !== Database::INDEX_UNIQUE) { + if (($index['type'] ?? '') !== IndexType::Unique->value) { continue; } $attributes = $index['attributes'] ?? []; - if (empty($attributes)) { + if (! \is_array($attributes) || empty($attributes)) { continue; } $signature = []; foreach ($attributes as $attribute) { + if (! \is_string($attribute)) { + continue; + } $signature[] = $this->normalizeIndexValue($this->resolveDocumentValue($document, $attribute)); } if (\in_array(null, $signature, true)) { continue; } // Match rowUniqueSignatures: under shared tables, scope by the - // current adapter tenant so cross-tenant collisions never throw. + // tenant the row will actually be stored under. documentToRow + // writes `_tenant = $document->getTenant() ?? $this->getTenant()`, + // so the read- and write-side signatures must agree on that + // fallback or duplicate detection skips across tenants. if ($this->sharedTables) { - \array_unshift($signature, $this->getTenant()); + \array_unshift($signature, $document->getTenant() ?? $this->getTenant()); } $result[$indexId] = \serialize($signature); } @@ -2560,7 +2385,7 @@ protected function fusedFilter(string $key, string $collectionId, array $queries $effectiveQueries = []; foreach ($queries as $query) { $method = $query->getMethod(); - if (\in_array($method, [Query::TYPE_SELECT, Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, Query::TYPE_LIMIT, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) { + if (\in_array($method, [Method::Select, Method::OrderAsc, Method::OrderDesc, Method::OrderRandom, Method::Limit, Method::Offset, Method::CursorAfter, Method::CursorBefore], true)) { continue; } $effectiveQueries[] = $query; @@ -2570,20 +2395,23 @@ protected function fusedFilter(string $key, string $collectionId, array $queries $tenant = $tenantCheck ? $this->getTenant() : null; $allowNullTenant = $tenantCheck && $collectionId === Database::METADATA; - $allowSet = $this->buildPermissionAllowSet($key, $forPermission); + $allowSet = $this->buildPermissionAllowSet($key, $forPermission, $tenant); + $tenantlessAllowSet = $allowNullTenant ? $this->buildPermissionAllowSet($key, $forPermission, null) : null; $output = []; foreach ($documents as $row) { + $rowAllowSet = $allowSet; if ($tenantCheck) { - $rowTenant = $row['_tenant'] ?? null; + $rowTenant = $row[Storage::TENANT] ?? null; if ($allowNullTenant && $rowTenant === null) { - // visible + $rowAllowSet = $tenantlessAllowSet; } elseif ($rowTenant !== $tenant) { continue; } } - if ($allowSet !== null && ! isset($allowSet[$row['_uid'] ?? ''])) { + $rowUid = $row[Storage::UID] ?? ''; + if ($rowAllowSet !== null && (! \is_string($rowUid) || ! isset($rowAllowSet[$rowUid]))) { continue; } @@ -2611,7 +2439,7 @@ protected function matches(array $row, Query $query): bool { $method = $query->getMethod(); - if ($method === Query::TYPE_AND) { + if ($method === Method::And) { foreach ($query->getValues() as $sub) { if (! ($sub instanceof Query) || ! $this->matches($row, $sub)) { return false; @@ -2621,7 +2449,7 @@ protected function matches(array $row, Query $query): bool return true; } - if ($method === Query::TYPE_OR) { + if ($method === Method::Or) { foreach ($query->getValues() as $sub) { if ($sub instanceof Query && $this->matches($row, $sub)) { return true; @@ -2641,8 +2469,17 @@ protected function matches(array $row, Query $query): bool } switch ($method) { - case Query::TYPE_EQUAL: + case Method::Equal: + // SQL three-valued logic: `col = NULL` is unknown — null rows + // never match an explicit equality, even when callers pass + // `[null]`. Use `Query::isNull()` for that case. + if ($value === null) { + return false; + } foreach ($queryValues as $candidate) { + if ($candidate === null) { + continue; + } if ($this->looseEquals($value, $candidate)) { return true; } @@ -2650,12 +2487,19 @@ protected function matches(array $row, Query $query): bool return false; - case Query::TYPE_NOT_EQUAL: + case Method::NotEqual: // SQL: NULL != x evaluates to NULL (i.e. excluded), not true. if ($value === null) { return false; } foreach ($queryValues as $candidate) { + // SQL three-valued logic: `col NOT IN (..., NULL, ...)` + // is unknown for every row — exclude. Mirrors the null- + // candidate handling in Method::Equal above. Use + // `Query::isNotNull()` for the explicit not-null intent. + if ($candidate === null) { + return false; + } if ($this->looseEquals($value, $candidate)) { return false; } @@ -2663,28 +2507,28 @@ protected function matches(array $row, Query $query): bool return true; - case Query::TYPE_LESSER: + case Method::LessThan: return $value !== null && $value < $queryValues[0]; - case Query::TYPE_LESSER_EQUAL: + case Method::LessThanEqual: return $value !== null && $value <= $queryValues[0]; - case Query::TYPE_GREATER: + case Method::GreaterThan: return $value !== null && $value > $queryValues[0]; - case Query::TYPE_GREATER_EQUAL: + case Method::GreaterThanEqual: return $value !== null && $value >= $queryValues[0]; - case Query::TYPE_IS_NULL: + case Method::IsNull: return $value === null; - case Query::TYPE_IS_NOT_NULL: + case Method::IsNotNull: return $value !== null; - case Query::TYPE_BETWEEN: + case Method::Between: return $value !== null && $value >= $queryValues[0] && $value <= $queryValues[1]; - case Query::TYPE_NOT_BETWEEN: + case Method::NotBetween: // SQL: NULL NOT BETWEEN x AND y evaluates to NULL (excluded). if ($value === null) { return false; @@ -2692,27 +2536,27 @@ protected function matches(array $row, Query $query): bool return $value < $queryValues[0] || $value > $queryValues[1]; - case Query::TYPE_STARTS_WITH: + case Method::StartsWith: return \is_string($value) && \is_string($queryValues[0]) && \str_starts_with($value, $queryValues[0]); - case Query::TYPE_NOT_STARTS_WITH: + case Method::NotStartsWith: if ($value === null) { return false; } return ! \is_string($value) || ! \is_string($queryValues[0]) || ! \str_starts_with($value, $queryValues[0]); - case Query::TYPE_ENDS_WITH: + case Method::EndsWith: return \is_string($value) && \is_string($queryValues[0]) && \str_ends_with($value, $queryValues[0]); - case Query::TYPE_NOT_ENDS_WITH: + case Method::NotEndsWith: if ($value === null) { return false; } return ! \is_string($value) || ! \is_string($queryValues[0]) || ! \str_ends_with($value, $queryValues[0]); - case Query::TYPE_CONTAINS: + case Method::Contains: $haystack = $this->decodeArrayValue($value); if ($haystack === null && \is_string($value)) { // Mirror MariaDB's default case-insensitive collation for @@ -2739,16 +2583,16 @@ protected function matches(array $row, Query $query): bool return false; - case Query::TYPE_NOT_CONTAINS: + case Method::NotContains: // SQL: NULL NOT LIKE '%x%' / JSON_CONTAINS(NULL, ...) evaluates // to NULL — null-valued rows are excluded, not matched. if ($value === null) { return false; } - return ! $this->matches($row, new Query(Query::TYPE_CONTAINS, $query->getAttribute(), $queryValues)); + return ! $this->matches($row, new Query(Method::Contains, $query->getAttribute(), $queryValues)); - case Query::TYPE_CONTAINS_ANY: + case Method::ContainsAny: // containsAny behaves like contains: array attributes match // any of the supplied needles, scalar string attributes fall // back to a case-insensitive substring search. @@ -2775,7 +2619,7 @@ protected function matches(array $row, Query $query): bool return false; - case Query::TYPE_CONTAINS_ALL: + case Method::ContainsAll: $haystack = $this->decodeArrayValue($value); if (! \is_array($haystack)) { return false; @@ -2795,18 +2639,18 @@ protected function matches(array $row, Query $query): bool return true; - case Query::TYPE_SEARCH: + case Method::Search: if (! \is_string($value)) { return false; } - $needle = (string) ($queryValues[0] ?? ''); - if ($needle === '') { + $searchNeedle = $queryValues[0] ?? ''; + if (! \is_string($searchNeedle) || $searchNeedle === '') { return false; } - return $this->matchesFulltext($value, $needle); + return $this->matchesFulltext($value, $searchNeedle); - case Query::TYPE_NOT_SEARCH: + case Method::NotSearch: // SQL: NULL NOT MATCH evaluates to NULL — null rows excluded. if ($value === null) { return false; @@ -2814,23 +2658,26 @@ protected function matches(array $row, Query $query): bool if (! \is_string($value)) { return true; } - $needle = (string) ($queryValues[0] ?? ''); - if ($needle === '') { + $notSearchNeedle = $queryValues[0] ?? ''; + if (! \is_string($notSearchNeedle) || $notSearchNeedle === '') { return true; } - return ! $this->matchesFulltext($value, $needle); + return ! $this->matchesFulltext($value, $notSearchNeedle); - case Query::TYPE_REGEX: + case Method::Regex: if (! \is_string($value)) { return false; } - $pattern = (string) ($queryValues[0] ?? ''); + $pattern = $queryValues[0] ?? ''; + if (! \is_string($pattern)) { + return false; + } return $this->matchesRegex($value, $pattern); } - throw new DatabaseException('Query method not implemented in the Memory adapter: '.$method); + throw new DatabaseException('Query method not implemented in the Memory adapter: '.$method->value); } /** @@ -2912,7 +2759,11 @@ protected function looseEquals(mixed $a, mixed $b): bool return true; } if (\is_numeric($a) && \is_numeric($b)) { - return $a + 0 === $b + 0; + // Compare numerically with `==` so cross-type pairs like + // ("3", "3.0") or (3, 3.0) match the way SQL `WHERE col = '3.0'` + // matches an int column holding 3. Strict `===` after `+0` + // splits int/float and silently misses parity. + return $a == $b; } return false; @@ -2951,7 +2802,7 @@ protected function matchesObject(mixed $value, Query $query): bool $method = $query->getMethod(); switch ($method) { - case Query::TYPE_EQUAL: + case Method::Equal: if ($haystack === null) { return false; } @@ -2963,7 +2814,7 @@ protected function matchesObject(mixed $value, Query $query): bool return false; - case Query::TYPE_NOT_EQUAL: + case Method::NotEqual: // Postgres: NOT (NULL @> x) evaluates to NULL — null/invalid // JSON rows are excluded, mirroring SQL three-valued logic. if ($haystack === null) { @@ -2977,8 +2828,8 @@ protected function matchesObject(mixed $value, Query $query): bool return true; - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: + case Method::Contains: + case Method::ContainsAny: if ($haystack === null) { return false; } @@ -2990,7 +2841,7 @@ protected function matchesObject(mixed $value, Query $query): bool return false; - case Query::TYPE_CONTAINS_ALL: + case Method::ContainsAll: if ($haystack === null) { return false; } @@ -3002,7 +2853,7 @@ protected function matchesObject(mixed $value, Query $query): bool return true; - case Query::TYPE_NOT_CONTAINS: + case Method::NotContains: // Postgres three-valued logic: NULL field excluded from negation. if ($haystack === null) { return false; @@ -3015,32 +2866,36 @@ protected function matchesObject(mixed $value, Query $query): bool return true; - case Query::TYPE_IS_NULL: + case Method::IsNull: return $value === null; - case Query::TYPE_IS_NOT_NULL: + case Method::IsNotNull: return $value !== null; } - throw new DatabaseException('Query method '.$method.' not supported for object attributes'); + throw new DatabaseException('Query method '.$method->value.' not supported for object attributes'); } - protected function decodeObjectValue(mixed $value): mixed + /** + * Return the decoded array if $value is already an array or looks like a + * JSON object/array literal; null otherwise. Mirrors decodeArrayValue and + * lets matchesObject's callers rely on a single `null === no match` guard + * rather than dispatching on raw scalar types. + * + * @return array|null + */ + protected function decodeObjectValue(mixed $value): ?array { - if ($value === null) { - return null; - } if (\is_array($value)) { return $value; } if (\is_string($value) && $value !== '' && ($value[0] === '{' || $value[0] === '[')) { $decoded = \json_decode($value, true); - if (\is_array($decoded)) { - return $decoded; - } + + return \is_array($decoded) ? $decoded : null; } - return $value; + return null; } /** @@ -3200,15 +3055,7 @@ protected function resolveNestedPath(mixed $value, string $path): mixed protected function mapAttribute(string $attribute): string { - return match ($attribute) { - '$id' => '_uid', - '$sequence' => '_id', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - '$permissions' => '_permissions', - default => $this->filter($attribute), - }; + return $this->filter(Storage::column($attribute)); } /** @@ -3219,7 +3066,7 @@ protected function mapAttribute(string $attribute): string * * @return array|null */ - protected function buildPermissionAllowSet(string $key, string $forPermission): ?array + protected function buildPermissionAllowSet(string $key, string $forPermission, int|string|null $tenant): ?array { if (! $this->authorization->getStatus()) { return null; @@ -3231,8 +3078,7 @@ protected function buildPermissionAllowSet(string $key, string $forPermission): return $allowed; } - $tenant = $this->getTenant(); - $tenantBucket = $tenant === null ? '__null__' : (string) $tenant; + $tenantBucket = $this->permissionBucket($tenant); $buckets = []; if ($this->sharedTables) { if (isset($this->permissionsByPermission[$key][$forPermission][$tenantBucket])) { @@ -3258,30 +3104,30 @@ protected function buildPermissionAllowSet(string $key, string $forPermission): /** * @param array> $rows * @param array $orderAttributes - * @param array $orderTypes + * @param array $orderTypes * @return array> */ - protected function applyOrdering(array $rows, array $orderAttributes, array $orderTypes, string $cursorDirection): array + protected function applyOrdering(array $rows, array $orderAttributes, array $orderTypes, CursorDirection $cursorDirection): array { // Random ordering must short-circuit: a non-deterministic comparator // breaks usort's transitivity invariant. Shuffle once and return. foreach ($orderTypes as $type) { - if ($type === Database::ORDER_RANDOM) { + if ($type === OrderDirection::Random) { \shuffle($rows); return $rows; } } - $reverse = $cursorDirection === Database::CURSOR_BEFORE; + $reverse = $cursorDirection === CursorDirection::Before; if (empty($orderAttributes)) { // Mirror MariaDB's clustered-index ordering when no explicit ORDER BY // is supplied — sort by the auto-incrementing _id ascending so // pagination via limit/offset is stable across calls. \usort($rows, function (array $a, array $b) use ($reverse) { - $av = $a['_id'] ?? 0; - $bv = $b['_id'] ?? 0; + $av = $a[Storage::SEQUENCE] ?? 0; + $bv = $b[Storage::SEQUENCE] ?? 0; if ($av === $bv) { return 0; } @@ -3301,11 +3147,11 @@ protected function applyOrdering(array $rows, array $orderAttributes, array $ord $directions = []; foreach ($orderAttributes as $i => $attribute) { $columns[$i] = $this->mapAttribute($attribute); - $direction = $orderTypes[$i] ?? Database::ORDER_ASC; + $direction = $orderTypes[$i] ?? OrderDirection::Asc; if ($reverse) { - $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; + $direction = $direction === OrderDirection::Asc ? OrderDirection::Desc : OrderDirection::Asc; } - $directions[$i] = $direction === Database::ORDER_ASC ? 1 : -1; + $directions[$i] = $direction === OrderDirection::Asc ? 1 : -1; } $count = \count($rows); @@ -3349,59 +3195,56 @@ protected function applyOrdering(array $rows, array $orderAttributes, array $ord /** * @param array> $rows * @param array $orderAttributes - * @param array $orderTypes + * @param array $orderTypes * @param array $cursor * @return array> */ - protected function applyCursor(array $rows, array $orderAttributes, array $orderTypes, array $cursor, string $cursorDirection): array + protected function applyCursor(array $rows, array $orderAttributes, array $orderTypes, array $cursor, CursorDirection $cursorDirection): array { if (empty($cursor)) { return $rows; } if (empty($orderAttributes)) { - $orderAttributes = ['$sequence']; - $orderTypes = [Database::ORDER_ASC]; + $orderAttributes = [Document::SEQUENCE]; + $orderTypes = [OrderDirection::Asc]; } - $reverse = $cursorDirection === Database::CURSOR_BEFORE; + $reverse = $cursorDirection === CursorDirection::Before; $resolved = []; - foreach ($orderAttributes as $i => $attribute) { - $direction = $orderTypes[$i] ?? Database::ORDER_ASC; + foreach ($orderAttributes as $index => $attribute) { + $direction = $orderTypes[$index] ?? OrderDirection::Asc; if ($reverse) { - $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; + $direction = $direction === OrderDirection::Asc ? OrderDirection::Desc : OrderDirection::Asc; } - $resolved[] = [ - 'column' => $this->mapAttribute($attribute), - 'asc' => $direction === Database::ORDER_ASC, - 'ref' => $cursor[$attribute] ?? null, - ]; + $resolved[] = new CursorBoundary($this->mapAttribute($attribute), $direction, $cursor[$attribute] ?? null); } $output = []; foreach ($rows as $row) { foreach ($resolved as $entry) { - $current = $row[$entry['column']] ?? null; - $ref = $entry['ref']; - if ($current === $ref) { + $current = $row[$entry->field] ?? null; + $reference = $entry->reference; + $ascending = $entry->direction === OrderDirection::Asc; + if ($current === $reference) { continue; } // Match applyOrdering: NULLs sort first under ASC. if ($current === null) { - if (! $entry['asc']) { + if (! $ascending) { $output[] = $row; } continue 2; } - if ($ref === null) { - if ($entry['asc']) { + if ($reference === null) { + if ($ascending) { $output[] = $row; } continue 2; } - if ($entry['asc'] ? ($current > $ref) : ($current < $ref)) { + if ($ascending ? ($current > $reference) : ($current < $reference)) { $output[] = $row; } @@ -3424,7 +3267,7 @@ protected function checkUniqueSignatures(string $key, array $newSignatures, stri foreach ($newSignatures as $indexId => $hash) { $existing = $this->uniqueIndexHashes[$key][$indexId][$hash] ?? null; if ($existing !== null && $existing !== $docKey) { - throw new UniqueException('Unique index violation'); + throw new UniqueException(UniqueException::MESSAGE); } } } @@ -3459,136 +3302,127 @@ protected function normalizeIndexValue(mixed $value): mixed /** * Apply a single Operator to a stored row value and return the new value. - * Mirrors the semantics implemented in MariaDB::getOperatorSQL — the SQL + * Mirrors the semantics implemented in MariaDB::getOperatorSql — the SQL * version uses CASE/JSON helpers; this is the in-PHP equivalent. */ protected function applyOperator(mixed $current, Operator $operator): mixed { $values = $operator->getValues(); $method = $operator->getMethod(); + $exact = BigInt::calculateOutsideNative($method, $current ?? 0, $values[0] ?? 1); + if ($exact !== null) { + $bound = $values[1] ?? null; + if ($method === OperatorType::Modulo || ! \is_numeric($bound) || (\is_float($bound) && ! \is_finite($bound))) { + return $exact; + } + + $limit = BigInt::integralValue($bound); + if ($limit === null) { + throw new OperatorException("Cannot apply {$method->value} operator: max/min limit must be a whole number, got {$bound}"); + } + + return $this->applyNumericLimit( + $current ?? 0, + $exact, + $limit, + \in_array($method, [OperatorType::Increment, OperatorType::Multiply, OperatorType::Power], true) + ); + } switch ($method) { - case Operator::TYPE_INCREMENT: - $by = $values[0] ?? 1; - $max = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - if ($max !== null) { - // Compare *remaining headroom* against $by so we never overflow PHP's int - // range. Guard: if the RESULT would exceed the max, leave it unchanged. - // Note: we must NOT short-circuit on `$base >= $max` — a negative $by moves - // the value down, so an already-over-max base can still land within bound - // (e.g. 52 + (-5) = 47 <= 50 must apply). - if (($max - $base) < $by) { - return $this->preserveNumericType($base, $base); - } - } + case OperatorType::Increment: + $byInc = $this->numericValue($values[0] ?? null, 1); + $maxInc = $this->numericValue($values[1] ?? null, null); + $baseInc = \is_numeric($current) ? $current + 0 : 0; - return $this->preserveNumericType($base, $base + $by); + return $this->applyNumericLimit($baseInc, $baseInc + $byInc, $maxInc, true); - case Operator::TYPE_DECREMENT: - $by = $values[0] ?? 1; - $min = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - if ($min !== null) { - // Guard: leave unchanged only if the RESULT would go below min. Don't - // short-circuit on `$base <= $min` — a negative $by moves the value up. - if (($base - $min) < $by) { - return $this->preserveNumericType($base, $base); - } - } + case OperatorType::Decrement: + $byDec = $this->numericValue($values[0] ?? null, 1); + $minDec = $this->numericValue($values[1] ?? null, null); + $baseDec = \is_numeric($current) ? $current + 0 : 0; - return $this->preserveNumericType($base, $base - $by); + return $this->applyNumericLimit($baseDec, $baseDec - $byDec, $minDec, false); - case Operator::TYPE_MULTIPLY: - $by = $values[0] ?? 1; - $max = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - $result = $base * $by; - if ($max !== null && $result > $max) { - return $this->preserveNumericType($base, $base); - } + case OperatorType::Multiply: + $byMul = $this->numericValue($values[0] ?? null, 1); + $maxMul = $this->numericValue($values[1] ?? null, null); + $baseMul = \is_numeric($current) ? $current + 0 : 0; - return $this->preserveNumericType($base, $result); + return $this->applyNumericLimit($baseMul, $baseMul * $byMul, $maxMul, true); - case Operator::TYPE_DIVIDE: - $by = $values[0] ?? 1; - $min = $values[1] ?? null; - if ($by == 0) { + case OperatorType::Divide: + $byDiv = $this->numericValue($values[0] ?? null, 1); + $minDiv = $this->numericValue($values[1] ?? null, null); + if ($byDiv == 0) { return $current; } - $base = \is_numeric($current) ? $current + 0 : 0; - $result = $base / $by; - if ($min !== null && $result < $min) { - return $this->preserveNumericType($base, $base); - } + $baseDiv = \is_numeric($current) ? $current + 0 : 0; - return $this->preserveNumericType($base, $result); + return $this->applyNumericLimit($baseDiv, $baseDiv / $byDiv, $minDiv, false); - case Operator::TYPE_MODULO: - $by = $values[0] ?? 1; - if ($by == 0) { + case OperatorType::Modulo: + $byMod = (int) $this->numericValue($values[0] ?? null, 1); + if ($byMod == 0) { return $current; } - $base = \is_numeric($current) ? (int) $current : 0; + $baseMod = \is_numeric($current) ? (int) $current : 0; - return $base % (int) $by; + return $baseMod % $byMod; - case Operator::TYPE_POWER: - $by = $values[0] ?? 1; - $max = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - if ($max !== null) { - // Leave the value unchanged for undefined inputs (0 to a negative power, or a - // negative base to a fractional exponent) — they produce INF/NaN, not a number. - if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) { - return $this->preserveNumericType($base, $base); - } - $result = $base ** $by; - // A result that overflows (INF) or exceeds the max also leaves the value as-is. - if (!\is_finite($result) || $result > $max) { - return $this->preserveNumericType($base, $base); + case OperatorType::Power: + $byPow = $this->numericValue($values[0] ?? null, 1) ?? 1; + $maxPow = $this->numericValue($values[1] ?? null, null); + $basePow = \is_numeric($current) ? $current + 0 : 0; + if (($basePow == 0 && $byPow < 0) || ($basePow < 0 && \floor($byPow) != $byPow)) { + if ($maxPow !== null) { + return $basePow; } - return $this->preserveNumericType($base, $result); + throw new LimitException('Value out of range'); } - // 0 to a negative power, or a negative base to a fractional exponent, is not a real - // number. Fail loudly with a clear exception rather than storing INF/NaN. - $result = $base ** $by; - if (!\is_finite($result)) { + $candidate = $basePow ** $byPow; + if (! \is_finite((float) $candidate)) { + if ($maxPow !== null) { + return $basePow; + } + throw new LimitException('Value out of range'); } - return $this->preserveNumericType($base, $result); + return $this->applyNumericLimit($basePow, $candidate, $maxPow, true); - case Operator::TYPE_STRING_CONCAT: - return ((string) ($current ?? '')).(string) ($values[0] ?? ''); + case OperatorType::StringConcat: + $appendValue = $values[0] ?? ''; - case Operator::TYPE_STRING_REPLACE: - $search = (string) ($values[0] ?? ''); - $replace = (string) ($values[1] ?? ''); + return $this->stringValue($current).$this->stringValue($appendValue); + + case OperatorType::StringReplace: + $search = $this->stringValue($values[0] ?? ''); + $replace = $this->stringValue($values[1] ?? ''); if ($current === null) { return null; } - return \str_replace($search, $replace, (string) $current); + return \str_replace($search, $replace, $this->stringValue($current)); - case Operator::TYPE_TOGGLE: + case OperatorType::Toggle: return ! (bool) $current; - case Operator::TYPE_ARRAY_APPEND: + case OperatorType::ArrayAppend: $list = $this->coerceArray($current); return [...$list, ...\array_values($values)]; - case Operator::TYPE_ARRAY_PREPEND: + case OperatorType::ArrayPrepend: $list = $this->coerceArray($current); return [...\array_values($values), ...$list]; - case Operator::TYPE_ARRAY_INSERT: + case OperatorType::ArrayInsert: $list = $this->coerceArray($current); - $index = (int) ($values[0] ?? 0); + $index = (int) $this->numericValue($values[0] ?? null, 0); $value = $values[1] ?? null; if ($index < 0) { $index = 0; @@ -3600,65 +3434,109 @@ protected function applyOperator(mixed $current, Operator $operator): mixed return $list; - case Operator::TYPE_ARRAY_REMOVE: + case OperatorType::ArrayRemove: $list = $this->coerceArray($current); $needle = $values[0] ?? null; return \array_values(\array_filter($list, fn ($item) => $item !== $needle)); - case Operator::TYPE_ARRAY_UNIQUE: + case OperatorType::ArrayUnique: $list = $this->coerceArray($current); return \array_values(\array_unique($list, SORT_REGULAR)); - case Operator::TYPE_ARRAY_INTERSECT: + case OperatorType::ArrayIntersect: $list = $this->coerceArray($current); $other = \array_values($values); return \array_values(\array_filter($list, fn ($item) => \in_array($item, $other, false))); - case Operator::TYPE_ARRAY_DIFF: + case OperatorType::ArrayDiff: $list = $this->coerceArray($current); $other = \array_values($values); return \array_values(\array_filter($list, fn ($item) => ! \in_array($item, $other, false))); - case Operator::TYPE_ARRAY_FILTER: + case OperatorType::ArrayFilter: $list = $this->coerceArray($current); - $condition = (string) ($values[0] ?? ''); + $condition = $this->stringValue($values[0] ?? ''); $compare = $values[1] ?? null; return \array_values(\array_filter($list, fn ($item) => $this->matchesArrayFilter($item, $condition, $compare))); - case Operator::TYPE_DATE_ADD_DAYS: - $days = (int) ($values[0] ?? 0); + case OperatorType::DateAddDays: + $days = (int) $this->numericValue($values[0] ?? null, 0); return $this->shiftDate($current, $days * 86400); - case Operator::TYPE_DATE_SUB_DAYS: - $days = (int) ($values[0] ?? 0); + case OperatorType::DateSubDays: + $days = (int) $this->numericValue($values[0] ?? null, 0); return $this->shiftDate($current, -$days * 86400); - case Operator::TYPE_DATE_SET_NOW: + case OperatorType::DateSetNow: return DateTime::now(); } + } + + /** + * Coerce a mixed value to int|float, falling back to $default when the + * value is not numeric. Centralises the narrow-to-numeric pattern used + * across the operator implementations. + */ + protected function numericValue(mixed $value, int|float|null $default): int|float|null + { + if (\is_int($value) || \is_float($value)) { + return $value; + } + if (\is_string($value) && \is_numeric($value)) { + return $value + 0; + } + + return $default; + } + + /** + * Coerce a mixed value to string, falling back to '' when the value is + * not stringable. Centralises the narrow-to-string pattern used across + * the string-operator implementations. + */ + protected function stringValue(mixed $value): string + { + if (\is_string($value)) { + return $value; + } + if (\is_scalar($value) || $value === null) { + return (string) $value; + } - throw new OperatorException("Invalid operator: {$method}"); + return ''; } /** - * Clamp an arithmetic result against an optional bound. + * Apply an arithmetic result unless it crosses an optional bound. * * @param bool $isUpper true = bound is a maximum, false = minimum */ - protected function applyNumericLimit(int|float $value, int|float|null $bound, bool $isUpper): int|float + protected function applyNumericLimit(mixed $original, mixed $candidate, mixed $bound, bool $isUpper): int|float|string { - if ($bound === null) { - return $value; + if (BigInt::isIntegerValue($original) && BigInt::isIntegerValue($candidate) && BigInt::isIntegerValue($bound)) { + $crossed = $isUpper + ? BigInt::compare($candidate, $bound) > 0 + : BigInt::compare($candidate, $bound) < 0; + + return $crossed ? BigInt::toNative($original) : $candidate; + } + + $numericOriginal = \is_numeric($original) ? $original + 0 : 0; + $numericCandidate = \is_numeric($candidate) ? $candidate + 0 : 0; + $numericBound = \is_numeric($bound) ? $bound + 0 : null; + + if ($numericBound !== null && (($isUpper && $numericCandidate > $numericBound) || (! $isUpper && $numericCandidate < $numericBound))) { + return $numericOriginal; } - return $isUpper ? \min($value, $bound) : \max($value, $bound); + return $this->preserveNumericType($numericOriginal, $numericCandidate); } /** @@ -3695,20 +3573,20 @@ protected function coerceArray(mixed $value): array } /** - * Mirror Operator::TYPE_ARRAY_FILTER's case-by-case predicate translation + * Mirror OperatorType::ArrayFilter's case-by-case predicate translation * (see MariaDB JSON_TABLE filter — `equal`, `greaterThan`, `isNull`, ...). */ protected function matchesArrayFilter(mixed $item, string $condition, mixed $compare): bool { return match ($condition) { - Query::TYPE_EQUAL => $item == $compare, - Query::TYPE_NOT_EQUAL => $item != $compare, - Query::TYPE_GREATER => \is_numeric($item) && \is_numeric($compare) && $item + 0 > $compare + 0, - Query::TYPE_GREATER_EQUAL => \is_numeric($item) && \is_numeric($compare) && $item + 0 >= $compare + 0, - Query::TYPE_LESSER => \is_numeric($item) && \is_numeric($compare) && $item + 0 < $compare + 0, - Query::TYPE_LESSER_EQUAL => \is_numeric($item) && \is_numeric($compare) && $item + 0 <= $compare + 0, - Query::TYPE_IS_NULL => $item === null, - Query::TYPE_IS_NOT_NULL => $item !== null, + Method::Equal->value => $item == $compare, + Method::NotEqual->value => $item != $compare, + Method::GreaterThan->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 > $compare + 0, + Method::GreaterThanEqual->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 >= $compare + 0, + Method::LessThan->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 < $compare + 0, + Method::LessThanEqual->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 <= $compare + 0, + Method::IsNull->value => $item === null, + Method::IsNotNull->value => $item !== null, default => true, }; } @@ -3723,10 +3601,11 @@ protected function shiftDate(mixed $current, int $seconds): ?string if ($current === null) { return null; } + $stringValue = $this->stringValue($current); try { - $base = new \DateTime((string) $current); + $base = new \DateTime($stringValue); } catch (\Throwable) { - return $current === '' ? null : (string) $current; + return $stringValue === '' ? null : $stringValue; } $base->modify(($seconds >= 0 ? '+' : '').$seconds.' seconds'); @@ -3734,19 +3613,19 @@ protected function shiftDate(mixed $current, int $seconds): ?string } /** - * Filter out any Operator-typed values from $attrs and apply them against + * Filter out any Operator-typed values from $attributes and apply them against * the stored row, returning the remaining (regular) attributes plus the * operator-derived assignments. The split mirrors how MariaDB's UPDATE * separates operator SQL fragments from bound parameters. * - * @param array $attrs Incoming attributes (mix of operators and scalars) + * @param array $attributes Incoming attributes (mix of operators and scalars) * @param array $row Stored row (post-filter on rowToDocument) * @return array Regular attributes ready for write */ - protected function applyOperators(array $attrs, array $row): array + protected function applyOperators(array $attributes, array $row): array { $result = []; - foreach ($attrs as $attribute => $value) { + foreach ($attributes as $attribute => $value) { if (Operator::isOperator($value)) { /** @var Operator $value */ $current = $row[$this->filter($attribute)] ?? null; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index fdc09ed68a..ffec5e8434 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2,35 +2,62 @@ namespace Utopia\Database\Adapter; +use DateTime as NativeDateTime; +use DateTimeZone; use Exception; +use MongoDB\BSON\Int64; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use stdClass; +use Swoole\Coroutine; +use Throwable; use Utopia\Database\Adapter; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; use Utopia\Database\Change; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Authorization as AuthorizationException; -use Utopia\Database\Exception\Conflict as ConflictException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; -use Utopia\Database\Exception\Relationship as RelationshipException; -use Utopia\Database\Exception\Restricted as RestrictedException; +use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Type as TypeException; +use Utopia\Database\Exception\Unconfirmed as UnconfirmedException; use Utopia\Database\Exception\Unique as UniqueException; +use Utopia\Database\Hook\Mongo\Permission as PermissionHook; +use Utopia\Database\Hook\Mongo\Read; +use Utopia\Database\Hook\Mongo\Tenant as TenantHook; +use Utopia\Database\Index; use Utopia\Database\Operator; +use Utopia\Database\OperatorType; +use Utopia\Database\PermissionType; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Storage; +use Utopia\Database\Validator\BigInt; use Utopia\Mongo\Client; use Utopia\Mongo\Exception as MongoException; - -class Mongo extends Adapter +use Utopia\Mongo\UnsentException; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; + +class Mongo extends Adapter implements Feature\Casting, Feature\Connection, Feature\Relationships, Feature\Schemaless, Feature\Timeouts, Feature\Upserts { + use Timeout; + /** * @var array */ @@ -53,30 +80,74 @@ class Mongo extends Adapter '$nor', '$exists', '$elemMatch', - '$exists' + '$all', ]; protected Client $client; /** - * Default batch size for cursor operations + * @var list + */ + protected array $readHooks = []; + + private const int DEFAULT_BATCH_SIZE = 1000; + + /** + * The collation of the `_uid` index: a lookup or upsert by id must use it to match what the + * index treats as the same id. + */ + private const array UID_COLLATION = ['locale' => 'en', 'strength' => 1]; + + /** + * How many times a commit whose result is unknown is sent again after the first attempt. + */ + private const int COMMIT_RETRIES = 3; + + /** + * Microseconds to wait before each commit retry, multiplied by the retry number. */ - private const DEFAULT_BATCH_SIZE = 1000; + private const int COMMIT_RETRY_SLEEP = 50_000; /** - * Transaction/session state for MongoDB transactions - * @var array|null $session + * The write concern a commit retry must carry, per the MongoDB transactions specification. */ - private ?array $session = null; // Store session array from startSession + private const array COMMIT_RETRY_WRITE_CONCERN = ['w' => 'majority', 'wtimeout' => 10_000]; + + /** + * @var array|null + */ + private ?array $session = null; + + #[\Override] protected int $inTransaction = 0; - protected bool $supportForAttributes = true; + + protected bool $schemaless = false; + + private const array PREFIX_SWAPPED_KEYS = ['permissions', 'createdAt', 'updatedAt', 'collection']; + + /** + * Every BSON type the driver writes but null. The deprecated undefined, dbPointer and symbol types are never written. + */ + private const array NON_NULL_BSON_TYPES = [ + 'double', + 'string', + 'object', + 'array', + 'binData', + 'objectId', + 'bool', + 'date', + 'regex', + 'javascript', + 'int', + 'timestamp', + 'long', + 'decimal', + 'minKey', + 'maxKey', + ]; /** - * Constructor. - * - * Set connection and settings - * - * @param Client $client * @throws MongoException */ public function __construct(Client $client) @@ -85,140 +156,191 @@ public function __construct(Client $client) $this->client->connect(); } - public function getHostname(): string + #[\Override] + public function hostname(): string { return $this->client->getHost(); } /** - * Returns the current Mongo client - * @return mixed + * The wire protocol has no connection id, so the client's object id names the connection: unique only within + * the process and only while the client lives. */ - public function getDriver(): mixed + #[\Override] + public function id(): string + { + return (string) \spl_object_id($this->client); + } + + #[\Override] + public function getDriver(): Client { return $this->client; } - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + /** + * @return array + */ + #[\Override] + public function capabilities(): array { - if (!$this->getSupportForTimeouts()) { - return; - } + return array_merge(parent::capabilities(), [ + Capability::Objects, + Capability::IndexFulltext, + Capability::IndexTtl, + Capability::Caching, + Capability::Operators, + Capability::TransactionRetries, + ]); + } + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { $this->timeout = $milliseconds; } - public function clearTimeout(string $event): void + #[\Override] + public function clearTimeout(Event $event = Event::All): void { - parent::clearTimeout($event); - $this->timeout = 0; } + #[\Override] + public function setSchemaless(bool $schemaless): static + { + $this->schemaless = $schemaless; + + return $this; + } + + #[\Override] + public function isSchemaless(): bool + { + return $this->schemaless; + } + + #[\Override] + public function supports(Capability $capability): bool + { + if ($capability === Capability::DefinedAttributes) { + return ! $this->schemaless; + } + + return parent::supports($capability); + } + + protected function syncWriteHooks(): void + { + } + + protected function syncReadHooks(): void + { + $this->readHooks = [new PermissionHook($this->authorization)]; + } + /** - * @template T - * @param callable(): T $callback - * @return T - * @throws \Throwable + * @param array $filters + * @return array */ - public function withTransaction(callable $callback): mixed + protected function applyTenantFilter(array $filters, string $collection): array { - // If the database is not a replica set, we can't use transactions - if (!$this->client->isReplicaSet()) { - return $callback(); - } + $tenantFilter = new TenantHook( + $this->sharedTables, + $this->getTenantFilters(...), + ); - // MongoDB doesn't support nested transactions/savepoints. - // If already in a transaction, just run the callback directly. - if ($this->inTransaction > 0) { - return $callback(); - } + return $tenantFilter->applyFilters($filters, $collection); + } - // upsert + $setOnInsert hits WriteConflict (E112) under txn snapshot isolation. - if ($this->skipDuplicates) { - return $callback(); + /** + * @param array $filters + * @return array + */ + protected function applyReadFilters(array $filters, string $collection, PermissionType $forPermission): array + { + $filters = $this->applyTenantFilter($filters, $collection); + + $this->syncReadHooks(); + foreach ($this->readHooks as $hook) { + $filters = $hook->applyFilters($filters, $collection, $forPermission); } - $sleep = 50_000; // 50 milliseconds - $retries = 2; + return $filters; + } - for ($attempts = 0; $attempts <= $retries; $attempts++) { - try { - $this->startTransaction(); - $result = $callback(); - $this->commitTransaction(); - return $result; - } catch (\Throwable $action) { - try { - $this->rollbackTransaction(); - } catch (\Throwable) { - // Throw the original exception, not the rollback one - // Since if it's a duplicate key error, the rollback will fail, - // and we want to throw the original exception. - } finally { - // Ensure state is cleaned up even if rollback fails - if ($this->session) { - try { - $this->client->endSessions([$this->session]); - } catch (\Throwable $endSessionError) { - // Ignore errors when ending session during error cleanup - } - } - $this->inTransaction = 0; - $this->session = null; - } + /** + * @throws Exception + * @throws MongoException + */ + #[\Override] + public function ping(): bool + { + /** @var \stdClass|array|int $result */ + $result = $this->getClient()->query([ + 'ping' => 1, + 'skipReadConcern' => true, + ]); - if ( - $action instanceof DuplicateException || - $action instanceof RestrictedException || - $action instanceof AuthorizationException || - $action instanceof RelationshipException || - $action instanceof ConflictException || - $action instanceof LimitException || - $action instanceof TimeoutException - ) { - throw $action; - } + if ($result instanceof \stdClass && isset($result->ok)) { + return (bool) $result->ok; + } - if ($attempts < $retries) { - \usleep($sleep * ($attempts + 1)); - continue; - } + return false; + } - throw $action; - } - } + #[\Override] + public function reconnect(): void + { + $this->client->connect(); + } - throw new TransactionException('Failed to execute transaction'); + /** + * @throws Exception + */ + protected function getClient(): Client + { + return $this->client; } + /** + * Start a new database transaction or increment the nesting counter. A standalone server has no transactions. + * + * @throws DatabaseException If the transaction cannot be started. + */ + #[\Override] public function startTransaction(): bool { - // If the database is not a replica set, we can't use transactions - if (!$this->client->isReplicaSet()) { + if (! $this->client->isReplicaSet()) { return true; } try { - if ($this->inTransaction === 0) { - if (!$this->session) { - $this->session = $this->client->startSession(); // Get session array - $this->client->startTransaction($this->session); // Start the transaction - } + if ($this->inTransaction === 0 && ! $this->session) { + $this->session = $this->client->startSession(); + $this->client->startTransaction($this->session); } $this->inTransaction++; + return true; - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->session = null; $this->inTransaction = 0; - throw new DatabaseException('Failed to start transaction: ' . $e->getMessage(), $e->getCode(), $e); + throw new DatabaseException('Failed to start transaction: '.$e->getMessage(), $e->getCode(), $e); } } + /** + * Commit the current database transaction or decrement the nesting counter. + * + * @throws UnconfirmedException If the commit was sent but its result could not be confirmed. + * @throws DatabaseException If the transaction cannot be committed, with an `Exception\Transaction` cause when + * the server reports it aborted. + */ + #[\Override] public function commitTransaction(): bool { - // If the database is not a replica set, we can't use transactions - if (!$this->client->isReplicaSet()) { + if (! $this->client->isReplicaSet()) { return true; } @@ -227,280 +349,546 @@ public function commitTransaction(): bool return false; } $this->inTransaction--; - if ($this->inTransaction === 0) { - if (!$this->session) { - return false; - } - try { - $result = $this->client->commitTransaction($this->session); - } catch (MongoException $e) { - // If there's no active transaction, it may have been auto-aborted due to an error. - // This is not necessarily a failure, just return success since the transaction was already terminated. - $e = $this->processException($e); - if ($e instanceof TransactionException) { - $this->client->endSessions([$this->session]); - $this->session = null; - $this->inTransaction = 0; // Reset counter when transaction is already terminated - return true; - } - throw $e; - } catch (\Throwable $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } finally { - if ($this->session) { - $this->client->endSessions([$this->session]); - } - $this->session = null; - } - + if ($this->inTransaction > 0) { return true; } - return true; - } catch (\Throwable $e) { - // Ensure cleanup on any failure - try { - $this->client->endSessions([$this->session]); - } catch (\Throwable $endSessionError) { - // Ignore errors when ending session during error cleanup - } - $this->session = null; - $this->inTransaction = 0; - throw new DatabaseException('Failed to commit transaction: ' . $e->getMessage(), $e->getCode(), $e); - } - } - - public function rollbackTransaction(): bool - { - // If the database is not a replica set, we can't use transactions - if (!$this->client->isReplicaSet()) { - return true; - } - - try { - if ($this->inTransaction === 0) { + if (! $this->session) { return false; } - $this->inTransaction--; - if ($this->inTransaction === 0) { - if (!$this->session) { - return false; - } - - try { - $this->client->abortTransaction($this->session); - } catch (\Throwable $e) { - $e = $this->processException($e); - - if ($e instanceof TransactionException) { - // If there's no active transaction, it may have been auto-aborted due to an error. - // Just return success since the transaction was already terminated. - return true; - } - - throw $e; - } finally { - $this->client->endSessions([$this->session]); - $this->session = null; - } - return true; - } - return true; - } catch (\Throwable $e) { try { - $this->client->endSessions([$this->session]); - } catch (\Throwable) { - // Ignore errors when ending session during error cleanup + $this->commit($this->session); + } finally { + $this->endSession(); } - $this->session = null; + + return true; + } catch (Throwable $error) { + $this->endSession(); $this->inTransaction = 0; - throw new DatabaseException('Failed to rollback transaction: ' . $e->getMessage(), $e->getCode(), $e); + if ($error instanceof UnconfirmedException) { + throw $error; + } + + throw new DatabaseException('Failed to commit transaction: '.$error->getMessage(), $error->getCode(), $error); } } /** - * Helper to add transaction/session context to command options if in transaction - * Includes defensive check to ensure session is valid + * Commit the session's transaction. When the result of the commit is unknown, only the commit is sent again. * - * @param array $options - * @return array + * @param array $session + * + * @throws TransactionException If the server reports the transaction aborted, so nothing of it is stored. + * @throws UnconfirmedException If the commit was sent but its result could not be confirmed. + * @throws Throwable */ - private function getTransactionOptions(array $options = []): array + private function commit(array $session): void { - if ($this->inTransaction > 0 && $this->session !== null) { - // Pass the session array directly - the client will handle the transaction state internally - $options['session'] = $this->session; + try { + $this->client->commitTransaction($session); + } catch (Throwable $error) { + if ($this->isUnknownCommitResult($error)) { + $this->retryCommit($session, $error); + + return; + } + + if (! $error instanceof MongoException) { + throw new DatabaseException($error->getMessage(), $error->getCode(), $error); + } + + throw $this->processException($error); } - return $options; } - /** - * Create a safe MongoDB regex pattern by escaping special characters + * Send the commit again, up to COMMIT_RETRIES times after the first attempt, with a majority write concern so a + * commit that already applied is reported as applied. A retry that was never sent is sent again. * - * @param string $value The user input to escape - * @param string $pattern The pattern template (e.g., ".*%s.*" for contains) - * @return Regex - * @throws DatabaseException + * @param array $session + * + * @throws TransactionException If the server reports the transaction aborted, so nothing of it is stored. + * @throws UnconfirmedException If the commit still cannot be confirmed. */ - private function createSafeRegex(string $value, string $pattern = '%s', string $flags = 'i'): Regex + private function retryCommit(array $session, Throwable $unknown): void { - $escaped = preg_quote($value, '/'); + for ($retry = 1; $retry <= self::COMMIT_RETRIES; $retry++) { + $this->pause(self::COMMIT_RETRY_SLEEP * $retry); - // Validate that the pattern doesn't contain injection vectors - if (preg_match('/\$[a-z]+/i', $escaped)) { - throw new DatabaseException('Invalid regex pattern: potential injection detected'); - } + try { + $this->client->commitTransaction($session, ['writeConcern' => self::COMMIT_RETRY_WRITE_CONCERN]); - $finalPattern = sprintf($pattern, $escaped); + return; + } catch (Throwable $error) { + if ($error instanceof UnsentException || $this->isUnknownCommitResult($error)) { + continue; + } - return new Regex($finalPattern, $flags); - } + if ($this->isAbortedCommit($error)) { + throw new TransactionException('The transaction was aborted while its commit was retried', previous: $error); + } - /** - * Ping Database - * - * @return bool - * @throws Exception - * @throws MongoException - */ - public function ping(): bool - { - return $this->getClient()->query([ - 'ping' => 1, - 'skipReadConcern' => true - ])->ok ?? false; - } + break; + } + } - public function reconnect(): void - { - $this->client->connect(); + throw new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed', previous: $unknown); } - /** - * Create Database - * - * @param string $name - * - * @return bool - */ - public function create(string $name): bool + private function pause(int $microseconds): void { - return true; - } + if (\extension_loaded('swoole') && Coroutine::getCid() > 0) { + Coroutine::sleep($microseconds / 1_000_000); - /** - * Check if database exists - * Optionally check if collection exists in database - * - * @param string $database database name - * @param string|null $collection (optional) collection name - * - * @return bool - * @throws Exception - */ - public function exists(string $database, ?string $collection = null): bool - { - if (!\is_null($collection)) { - $collection = $this->getNamespace() . "_" . $collection; - try { - // Use listCollections command with filter for O(1) lookup - $result = $this->getClient()->query([ - 'listCollections' => 1, - 'filter' => ['name' => $collection] - ]); - - return !empty($result->cursor->firstBatch); - } catch (\Exception $e) { - return false; - } + return; } - return $this->getClient()->selectDatabase() != null; + \usleep($microseconds); } /** - * List Databases - * - * @return array - * @throws Exception + * Whether the commit reached the server but its result is unknown: the commit may have applied, so running the + * transaction again could apply it twice. A commit that was never sent, or that the server labels transient, + * applied nothing. */ - public function list(): array + private function isUnknownCommitResult(Throwable $error): bool { - $list = []; + if (! $error instanceof MongoException || $error instanceof UnsentException) { + return false; + } - foreach ((array)$this->getClient()->listDatabaseNames() as $value) { - $list[] = $value; + $labels = $error->getErrorLabels(); + if (\in_array(Client::TRANSIENT_TRANSACTION_ERROR, $labels, true)) { + return false; } - return $list; + return \in_array(Client::UNKNOWN_TRANSACTION_COMMIT_RESULT, $labels, true) + || $error->isNetworkError() + || $this->client->isUnknownTransactionCommitResult($error); } - /** - * Delete Database - * - * @param string $name - * - * @return bool - * @throws Exception - */ - public function delete(string $name): bool + private function isAbortedCommit(Throwable $error): bool { - $this->getClient()->dropDatabase([], $name); + return $error instanceof MongoException + && ( + \in_array(Client::TRANSIENT_TRANSACTION_ERROR, $error->getErrorLabels(), true) + || $this->processException($error) instanceof TransactionException + ); + } - return true; + private function endSession(): void + { + if ($this->session !== null) { + try { + $this->client->endSessions([$this->session]); + } catch (Throwable) { + // Best effort: a dropped connection fails this, and that must not replace the outcome. + } + } + $this->session = null; } /** - * Create Collection + * Roll back the current database transaction or decrement the nesting counter. A transaction the server already + * aborted counts as rolled back. * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool - * @throws Exception + * @throws DatabaseException If the rollback fails. */ - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + #[\Override] + public function rollbackTransaction(): bool { - $id = $this->getNamespace() . '_' . $this->filter($name); - - // In shared-tables mode or for metadata, the physical collection may - // already exist for another tenant. Return early to avoid a - // "Collection Exists" exception from the client. - if (!$this->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) { + if (! $this->client->isReplicaSet()) { return true; } - // Returns an array/object with the result document try { - $options = $this->getTransactionOptions(); - $this->getClient()->createCollection($id, $options); - } catch (MongoException $e) { - $e = $this->processException($e); - if ($e instanceof DuplicateException) { - if ($this->getSharedTables() || $name === Database::METADATA) { - return true; + if ($this->inTransaction === 0) { + return false; + } + $this->inTransaction--; + if ($this->inTransaction > 0) { + return true; + } + if (! $this->session) { + return false; + } + + try { + $this->client->abortTransaction($this->session); + } catch (Throwable $e) { + $e = $this->processException($e); + if (! $e instanceof TransactionException) { + throw $e; } - throw $e; + } finally { + $this->endSession(); } - // Client throws code-0 "Collection Exists" when its pre-check - // finds the collection. In shared-tables/metadata context this - // is a no-op; otherwise re-throw as DuplicateException so - // Database::createCollection() can run orphan reconciliation. - if ($e->getCode() === 0 && stripos($e->getMessage(), 'Collection Exists') !== false) { - if ($this->getSharedTables() || $name === Database::METADATA) { - return true; + + return true; + } catch (Throwable $e) { + $this->endSession(); + $this->inTransaction = 0; + + throw new DatabaseException('Failed to rollback transaction: '.$e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Run the callback in a transaction, retrying an attempt that failed transiently up to twice. Without savepoints + * a call nested in an open transaction runs the callback in it, and a standalone server runs it without one. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @throws Throwable + */ + #[\Override] + public function withTransaction(callable $callback): mixed + { + if (! $this->client->isReplicaSet() || $this->inTransaction > 0) { + return $callback(); + } + + // An upsert with $setOnInsert hits WriteConflict (112) under the transaction's snapshot isolation. + if ($this->isIgnoringDuplicates()) { + return $callback(); + } + + $sleep = 50_000; + $retries = 2; + + for ($attempts = 0; $attempts <= $retries; $attempts++) { + try { + $this->startTransaction(); + $result = $callback(); + $this->commitTransaction(); + + return $result; + } catch (Throwable $action) { + try { + $this->rollbackTransaction(); + } catch (Throwable) { + // The attempt's failure is the one retried or thrown. + } finally { + $this->endSession(); + $this->inTransaction = 0; + } + + if (! parent::isRetryable($action)) { + throw $action; + } + + if ($attempts < $retries) { + \usleep($sleep * ($attempts + 1)); + + continue; } - throw new DuplicateException('Collection already exists', $e->getCode(), $e); + + throw $action; + } + } + + throw new TransactionException('Transaction retry loop exited unexpectedly'); + } + + /** + * A standalone server has no transactions, so withTransaction() runs the callback once and retries nothing. The + * failure is classified first, so a failure that is never retried needs no round trip to a server that may be + * gone. + */ + #[\Override] + public function isRetryable(Throwable $failure): bool + { + return parent::isRetryable($failure) && $this->client->isReplicaSet(); + } + + /** + * A MongoDB error is transient when the server labels it so, when it is a network error, when the command was + * never sent, or when the adapter maps it to an aborted transaction. + */ + #[\Override] + protected function isTransient(Throwable $error): bool + { + if ( + $error instanceof MongoException + && ( + $error instanceof UnsentException + || $error->isTransientError() + || $this->processException($error) instanceof TransactionException + ) + ) { + return true; + } + + return parent::isTransient($error); + } + + #[\Override] + public function create(string $name): bool + { + return true; + } + + /** + * Moves every collection into the new database with `renameCollection`, then drops the emptied one. A failure + * part way moves the collections already moved back, newest first, and is thrown. A sharded cluster cannot move + * a collection between databases, so it refuses the rename, as do shared tables, whose database other tenants + * share. The client stays bound to the database it was built for: address the renamed one with a client built + * for it. + * + * @throws DatabaseException + */ + #[\Override] + public function update(string $name, string $new): bool + { + if ($this->hasSharedTables()) { + throw new DatabaseException('Cannot rename a database while shared tables are enabled'); + } + + $name = $this->filter($name); + $new = $this->filter($new); + $client = $this->getClient(); + + /** @var stdClass $hello */ + $hello = $client->query(['hello' => 1], 'admin'); + if (($hello->msg ?? null) === 'isdbgrid') { + throw new DatabaseException('Renaming a database is not supported on a sharded MongoDB cluster'); + } + + $databases = $this->getDatabaseNames(); + + if (! \in_array($name, $databases, true)) { + throw new NotFoundException('Database not found'); + } + + if (\in_array($new, $databases, true)) { + throw new DuplicateException('Database already exists'); + } + + $moved = []; + try { + foreach ($this->getCollectionNames($name) as $collection) { + $client->query(['renameCollection' => "{$name}.{$collection}", 'to' => "{$new}.{$collection}"], 'admin'); + $moved[] = $collection; + } + } catch (Throwable $error) { + foreach (\array_reverse($moved) as $collection) { + $client->query(['renameCollection' => "{$new}.{$collection}", 'to' => "{$name}.{$collection}"], 'admin'); + } + + throw $error instanceof MongoException ? $this->processException($error) : $error; + } + + $client->dropDatabase([], $name); + + return true; + } + + /** + * The collections of a database a rename moves: every one but the server's own. + * + * @return list + * + * @throws DatabaseException When the server pages the listing, which a rename cannot move in one pass + */ + private function getCollectionNames(string $database): array + { + /** @var stdClass $listed */ + $listed = $this->getClient()->query(['listCollections' => 1, 'nameOnly' => true], $database); + /** @var stdClass $cursor */ + $cursor = $listed->cursor; + if (! empty($cursor->id)) { + throw new DatabaseException('Database has more collections than one listing returns, so it cannot be renamed'); + } + + /** @var array $collections */ + $collections = $cursor->firstBatch ?? []; + $names = []; + foreach ($collections as $collection) { + $collectionName = $collection->name ?? null; + if (\is_string($collectionName) && ! \str_starts_with($collectionName, 'system.')) { + $names[] = $collectionName; + } + } + + return $names; + } + + /** + * @return list + */ + private function getDatabaseNames(): array + { + /** @var stdClass $listed */ + $listed = $this->getClient()->listDatabaseNames(); + /** @var array $databases */ + $databases = $listed->databases ?? []; + + $names = []; + foreach ($databases as $database) { + $databaseName = $database->name ?? null; + if (\is_string($databaseName)) { + $names[] = $databaseName; + } + } + + return $names; + } + + /** + * MongoDB creates a database on its first write, so only a database holding data is listed and exists. + * + * @throws Exception + */ + #[\Override] + public function exists(string $database): bool + { + return \in_array($this->filter($database), $this->getDatabaseNames(), true); + } + + /** + * An empty database name asks the database the client was built for. + */ + #[\Override] + public function collectionExists(string $database, string $collection): bool + { + $database = $this->filter($database); + + try { + /** @var \stdClass $result */ + $result = $this->getClient()->query([ + 'listCollections' => 1, + 'filter' => ['name' => $this->getNamespace().'_'.$this->filter($collection)], + ], $database === '' ? null : $database); + + /** @var \stdClass $cursor */ + $cursor = $result->cursor; + /** @var array $firstBatch */ + $firstBatch = $cursor->firstBatch; + + return ! empty($firstBatch); + } catch (Exception) { + return false; + } + } + + /** + * @return array + * + * @throws Exception + */ + #[\Override] + public function list(): array + { + /** @var array $list */ + $list = []; + + /** @var \stdClass $databaseNames */ + $databaseNames = $this->getClient()->listDatabaseNames(); + /** @var array $databaseNamesArray */ + $databaseNamesArray = (array) $databaseNames; + foreach ($databaseNamesArray as $value) { + $list[] = $value; + } + + return $list; + } + + /** + * @throws Exception + */ + #[\Override] + public function delete(string $name): bool + { + $this->getClient()->dropDatabase([], $this->filter($name)); + + return true; + } + + /** + * @param list $attributes + * @param list $indexes + * + * @throws Exception + */ + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool + { + $id = $this->getNamespace().'_'.$this->filter($collection); + $shared = $this->hasSharedTables() || $collection === Database::METADATA; + + if (! $this->inTransaction && $shared && $this->collectionExists($this->getDatabase(), $collection)) { + return $this->adoptCollection($id, $attributes, $indexes); + } + + try { + $options = $this->getTransactionOptions(); + $this->getClient()->createCollection($id, $options); + } catch (MongoException $error) { + $error = \str_contains($error->getMessage(), 'Collection Exists') + ? new DuplicateException('Collection already exists', previous: $error) + : $this->processException($error); + if ($error instanceof DuplicateException && $shared) { + return $this->adoptCollection($id, $attributes, $indexes, $error); } - throw $e; + throw $error; + } + + if ($shared) { + return $this->createCollectionIndexes($id, $attributes, $indexes); + } + + try { + $indexed = $this->createCollectionIndexes($id, $attributes, $indexes); + } catch (Throwable $error) { + $this->dropCreatedCollection($id); + + throw $error; + } + + if (! $indexed) { + $this->dropCreatedCollection($id); + } + + return $indexed; + } + + /** + * Give a shared or metadata collection another creator made the indexes it is declared with, then report it as + * existing, so the caller treats it as a collection it did not create and never drops it. + * + * @param list $attributes + * @param list $indexes + * @return false When an index could not be created + * + * @throws DuplicateException Once the indexes exist + */ + private function adoptCollection(string $id, array $attributes, array $indexes, ?DuplicateException $exists = null): bool + { + if (! $this->createCollectionIndexes($id, $attributes, $indexes)) { + return false; } + throw $exists ?? new DuplicateException('Collection already exists'); + } + + /** + * @param list $attributes + * @param list $indexes + * @return bool False when an index could not be created + * + * @throws Exception + */ + private function createCollectionIndexes(string $id, array $attributes, array $indexes): bool + { $internalIndex = [ [ - 'key' => ['_uid' => $this->getOrder(Database::ORDER_ASC)], - 'name' => '_uid', + 'key' => [Storage::UID => $this->getOrder(OrderDirection::Asc)], + 'name' => Storage::UID, 'unique' => true, 'collation' => [ 'locale' => 'en', @@ -508,22 +896,22 @@ public function createCollection(string $name, array $attributes = [], array $in ], ], [ - 'key' => ['_createdAt' => $this->getOrder(Database::ORDER_ASC)], - 'name' => '_createdAt', + 'key' => [Storage::CREATED_AT => $this->getOrder(OrderDirection::Asc)], + 'name' => Storage::CREATED_AT, ], [ - 'key' => ['_updatedAt' => $this->getOrder(Database::ORDER_ASC)], - 'name' => '_updatedAt', + 'key' => [Storage::UPDATED_AT => $this->getOrder(OrderDirection::Asc)], + 'name' => Storage::UPDATED_AT, ], [ - 'key' => ['_permissions' => $this->getOrder(Database::ORDER_ASC)], - 'name' => '_permissions', - ] + 'key' => [Storage::PERMISSIONS => $this->getOrder(OrderDirection::Asc)], + 'name' => Storage::PERMISSIONS, + ], ]; if ($this->sharedTables) { foreach ($internalIndex as &$index) { - $index['key'] = array_merge(['_tenant' => $this->getOrder(Database::ORDER_ASC)], $index['key']); + $index['key'] = array_merge([Storage::TENANT => $this->getOrder(OrderDirection::Asc)], $index['key']); } unset($index); } @@ -531,121 +919,92 @@ public function createCollection(string $name, array $attributes = [], array $in try { $options = $this->getTransactionOptions(); $indexesCreated = $this->client->createIndexes($id, $internalIndex, $options); - } catch (\Exception $e) { - throw $this->processException($e); + } catch (Exception $error) { + throw $this->indexCreationError($error); } - if (!$indexesCreated) { + if (! $indexesCreated) { return false; } - // Since attributes are not used by this adapter - // Only act when $indexes is provided - - if (!empty($indexes)) { + if (! empty($indexes)) { /** * Each new index has format ['key' => [$attribute => $order], 'name' => $name, 'unique' => $unique] */ $newIndexes = []; - $collectionAttributes = $attributes; - - // using $i and $j as counters to distinguish from $key - foreach ($indexes as $i => $index) { - + foreach ($indexes as $indexPosition => $index) { $key = []; $unique = false; - $attributes = $index->getAttribute('attributes'); - $orders = $index->getAttribute('orders'); + $indexType = $index->type; - // If sharedTables, always add _tenant as the first key if ($this->shouldAddTenantToIndex($index)) { - $key['_tenant'] = $this->getOrder(Database::ORDER_ASC); + $key[Storage::TENANT] = $this->getOrder(OrderDirection::Asc); } - foreach ($attributes as $j => $attribute) { + foreach ($index->attributes as $attributePosition => $attribute) { $attribute = $this->filter($this->getInternalKeyForAttribute($attribute)); - switch ($index->getAttribute('type')) { - case Database::INDEX_KEY: - $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); + switch ($indexType) { + case IndexType::Key: + case IndexType::Ttl: + $order = $this->getOrder($index->orders[$attributePosition] ?? OrderDirection::Asc); break; - case Database::INDEX_FULLTEXT: - // MongoDB fulltext index is just 'text' - // Not using Database::INDEX_KEY for clarity + case IndexType::Fulltext: $order = 'text'; break; - case Database::INDEX_UNIQUE: - $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); + case IndexType::Unique: + $order = $this->getOrder($index->orders[$attributePosition] ?? OrderDirection::Asc); $unique = true; break; - case Database::INDEX_TTL: - $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); - break; default: - // index not supported return false; } $key[$attribute] = $order; } - $newIndexes[$i] = [ + $newIndexes[$indexPosition] = [ 'key' => $key, - 'name' => $this->filter($index->getId()), - 'unique' => $unique + 'name' => $this->filter($index->key), + 'unique' => $unique, ]; - if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) { - $newIndexes[$i]['default_language'] = 'none'; + if ($indexType === IndexType::Fulltext) { + $newIndexes[$indexPosition]['default_language'] = 'none'; } - // Handle TTL indexes - if ($index->getAttribute('type') === Database::INDEX_TTL) { - $ttl = $index->getAttribute('ttl', 0); - if ($ttl > 0) { - $newIndexes[$i]['expireAfterSeconds'] = $ttl; - } + if ($indexType === IndexType::Ttl && $index->ttl > 0) { + $newIndexes[$indexPosition]['expireAfterSeconds'] = $index->ttl; } - // Add partial filter for indexes to avoid indexing null values - if (in_array($index->getAttribute('type'), [ - Database::INDEX_UNIQUE, - Database::INDEX_KEY - ])) { - $partialFilter = []; - foreach ($attributes as $attr) { - // Find the matching attribute in collectionAttributes to get its type - $attrType = $this->getMongoTypeCode(null); - foreach ($collectionAttributes as $collectionAttr) { - if ($collectionAttr->getId() === $attr) { - $attrType = $this->getMongoTypeCode($collectionAttr->getAttribute('type')); + if (in_array($indexType, [IndexType::Unique, IndexType::Key])) { + $fields = []; + foreach ($index->attributes as $indexedAttribute) { + $attributeType = null; + foreach ($attributes as $collectionAttribute) { + if ($collectionAttribute->key === $indexedAttribute) { + $attributeType = $collectionAttribute->type; break; } } - $attr = $this->filter($this->getInternalKeyForAttribute($attr)); - - // Use both $exists: true and $type to exclude nulls and ensure correct type - $partialFilter[$attr] = [ - '$exists' => true, - '$type' => $attrType - ]; + $fields[$this->filter($this->getInternalKeyForAttribute($indexedAttribute))] = $attributeType; } - if (!empty($partialFilter)) { - $newIndexes[$i]['partialFilterExpression'] = $partialFilter; + if (! empty($fields)) { + $newIndexes[$indexPosition]['partialFilterExpression'] = $this->getPartialFilterExpression($indexType, $fields); } } } try { $options = $this->getTransactionOptions(); - $indexesCreated = $this->getClient()->createIndexes($id, $newIndexes, $options); - } catch (\Exception $e) { - throw $this->processException($e); + $indexesCreated = $this->getClient()->createIndexes($id, \array_values($newIndexes), $options); + } catch (Exception $error) { + throw $this->indexCreationError($error); } - if (!$indexesCreated) { + if (! $indexesCreated) { return false; } } @@ -654,18 +1013,53 @@ public function createCollection(string $name, array $attributes = [], array $in } /** - * List Collections - * + * An index the collection's indexes conflict with, such as one under the same name with other options (code + * 85), is an index error of the create, not the collection already existing. + */ + private function indexCreationError(Exception $error): Throwable + { + $mapped = $this->processException($error); + + return $mapped instanceof DuplicateException + ? new IndexException($mapped->getMessage(), previous: $error) + : $mapped; + } + + /** + * Drop a collection this call created but could not finish. Inside a transaction the collection is left to the + * transaction's abort, since MongoDB runs no drop in a multi-document transaction and one outside it would not + * see the uncommitted collection. + */ + private function dropCreatedCollection(string $id): void + { + if ($this->inTransaction > 0) { + return; + } + + try { + $this->getClient()->dropCollection($id); + } catch (Throwable) { + // Best effort: the creation's own failure is what the caller needs. + } + } + + /** * @return array + * * @throws Exception */ - public function listCollections(): array + protected function listCollections(): array { + /** @var array $list */ $list = []; // Note: listCollections is a metadata operation that should not run in transactions // to avoid transaction conflicts and readConcern issues - foreach ((array)$this->getClient()->listCollectionNames() as $value) { + /** @var \stdClass $collectionNames */ + $collectionNames = $this->getClient()->listCollectionNames(); + /** @var array $collectionNamesArray */ + $collectionNamesArray = (array) $collectionNames; + foreach ($collectionNamesArray as $value) { $list[] = $value; } @@ -673,138 +1067,97 @@ public function listCollections(): array } /** - * Get Collection Size on disk - * @param string $collection - * @return int - * @throws DatabaseException + * @throws Exception */ - public function getSizeOfCollectionOnDisk(string $collection): int + #[\Override] + public function deleteCollection(string $collection): bool { - return $this->getSizeOfCollection($collection); + $id = $this->getNamespace().'_'.$this->filter($collection); + + return (bool) $this->getClient()->dropCollection($id); + } + + #[\Override] + public function analyzeCollection(string $collection): bool + { + return false; + } + + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool + { + return true; } /** - * Get Collection Size of raw data - * @param string $collection - * @return int + * @param list $attributes + * * @throws DatabaseException */ - public function getSizeOfCollection(string $collection): int + #[\Override] + public function createAttributes(string $collection, array $attributes): bool { - $namespace = $this->getNamespace(); - $collection = $this->filter($collection); - $collection = $namespace . '_' . $collection; + return true; + } - $command = [ - 'collStats' => $collection, - 'scale' => 1 - ]; - - try { - $result = $this->getClient()->query($command); - if (is_object($result)) { - return $result->totalSize; - } else { - throw new DatabaseException('No size found'); - } - } catch (Exception $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); - } - } - - /** - * Delete Collection - * - * @param string $id - * @return bool - * @throws Exception - */ - public function deleteCollection(string $id): bool - { - $id = $this->getNamespace() . '_' . $this->filter($id); - return (!!$this->getClient()->dropCollection($id)); - } - - /** - * Analyze a collection updating it's metadata on the database engine - * - * @param string $collection - * @return bool - */ - public function analyzeCollection(string $collection): bool - { - return false; - } - - /** - * Create Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @return bool - */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool { - return true; - } + if ($attribute->key !== $key) { + return $this->renameAttribute($collection, $key, $attribute->key); + } - /** - * Create Attributes - * - * @param string $collection - * @param array> $attributes - * @return bool - * @throws DatabaseException - */ - public function createAttributes(string $collection, array $attributes): bool - { return true; } /** - * Delete Attribute - * - * @param string $collection - * @param string $id - * - * @return bool * @throws DatabaseException * @throws MongoException */ - public function deleteAttribute(string $collection, string $id): bool + #[\Override] + public function deleteAttribute(string $collection, string $key): bool { - $collection = $this->getNamespace() . '_' . $this->filter($collection); + $collection = $this->getNamespace().'_'.$this->filter($collection); $this->getClient()->update( $collection, [], - ['$unset' => [$id => '']], + ['$unset' => [$this->escapeMongoFieldName($this->getInternalKeyForAttribute($key)) => '']], multi: true ); return true; } + #[\Override] + public function getSchemaAttributes(string $collection): array + { + return []; + } + + #[\Override] + public function getSchemaIndexes(string $collection): array + { + return []; + } + + #[\Override] + public function getColumnType(Attribute $attribute): ?string + { + return null; + } + /** - * Rename Attribute. - * - * @param string $collection - * @param string $id - * @param string $name - * @return bool * @throws DatabaseException * @throws MongoException */ + #[\Override] public function renameAttribute(string $collection, string $id, string $name): bool { - $collection = $this->getNamespace() . '_' . $this->filter($collection); + $collection = $this->getNamespace().'_'.$this->filter($collection); - $from = $this->filter($this->getInternalKeyForAttribute($id)); - $to = $this->filter($this->getInternalKeyForAttribute($name)); + $from = $this->escapeMongoFieldName($this->getInternalKeyForAttribute($id)); + $to = $this->escapeMongoFieldName($this->getInternalKeyForAttribute($name)); $options = $this->getTransactionOptions(); $this->getClient()->update( @@ -819,254 +1172,210 @@ public function renameAttribute(string $collection, string $id, string $name): b } /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $id - * @param string $twoWayKey - * @return bool + * Create a relationship between collections. No-op for MongoDB since relationships are virtual. */ - public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool { return true; } /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @param string|null $newKey - * @param string|null $newTwoWayKey - * @return bool * @throws DatabaseException * @throws MongoException */ - public function updateRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side, - ?string $newKey = null, - ?string $newTwoWayKey = null - ): bool { - $collectionName = $this->getNamespace() . '_' . $this->filter($collection); - $relatedCollectionName = $this->getNamespace() . '_' . $this->filter($relatedCollection); - - $escapedKey = $this->escapeMongoFieldName($key); - $escapedNewKey = !\is_null($newKey) ? $this->escapeMongoFieldName($newKey) : null; - $escapedTwoWayKey = $this->escapeMongoFieldName($twoWayKey); - $escapedNewTwoWayKey = !\is_null($newTwoWayKey) ? $this->escapeMongoFieldName($newTwoWayKey) : null; + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + { + $collectionName = $this->getNamespace().'_'.$this->filter($collection); + $relatedCollectionName = $this->getNamespace().'_'.$this->filter($relationship->relatedCollection); + $key = $relationship->key ?? ''; + $twoWayKey = $relationship->twoWayKey ?? ''; + $newKey = $update->key; + $newTwoWayKey = $update->twoWayKey; + $twoWay = $update->twoWay ?? $relationship->twoWay; $renameKey = [ '$rename' => [ - $escapedKey => $escapedNewKey, - ] + $this->escapeMongoFieldName($key) => $newKey === null ? null : $this->escapeMongoFieldName($newKey), + ], ]; $renameTwoWayKey = [ '$rename' => [ - $escapedTwoWayKey => $escapedNewTwoWayKey, - ] + $this->escapeMongoFieldName($twoWayKey) => $newTwoWayKey === null ? null : $this->escapeMongoFieldName($newTwoWayKey), + ], ]; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if (!\is_null($newKey) && $key !== $newKey) { + switch ($relationship->type) { + case RelationshipType::OneToOne: + if (($twoWay || $side === RelationshipSide::Parent) && $newKey !== null && $key !== $newKey) { $this->getClient()->update($collectionName, updates: $renameKey, multi: true); } - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + if (($twoWay || $side === RelationshipSide::Child) && $newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); } break; - case Database::RELATION_ONE_TO_MANY: - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { - $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { + $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); + } + } elseif ($newKey !== null && $key !== $newKey) { + $this->getClient()->update($collectionName, updates: $renameKey, multi: true); } break; - case Database::RELATION_MANY_TO_ONE: - if (!\is_null($newKey) && $key !== $newKey) { + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Child) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { + $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); + } + } elseif ($newKey !== null && $key !== $newKey) { $this->getClient()->update($collectionName, updates: $renameKey, multi: true); } break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collectionDoc = $this->getDocument($metadataCollection, $collection); - $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection); - - if ($collectionDoc->isEmpty() || $relatedCollectionDoc->isEmpty()) { - throw new DatabaseException('Collection or related collection not found'); - } - - $junction = $side === Database::RELATION_SIDE_PARENT - ? $this->getNamespace() . '_' . $this->filter('_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence()) - : $this->getNamespace() . '_' . $this->filter('_' . $relatedCollectionDoc->getSequence() . '_' . $collectionDoc->getSequence()); + case RelationshipType::ManyToMany: + $junction = $this->getJunctionName($collection, $relationship->relatedCollection, $side); - if (!\is_null($newKey) && $key !== $newKey) { + if ($newKey !== null && $key !== $newKey) { $this->getClient()->update($junction, updates: $renameKey, multi: true); } - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $this->getClient()->update($junction, updates: $renameTwoWayKey, multi: true); } break; - default: - throw new DatabaseException('Invalid relationship type'); } return true; } /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @return bool * @throws MongoException * @throws Exception */ - public function deleteRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side - ): bool { - $collectionName = $this->getNamespace() . '_' . $this->filter($collection); - $relatedCollectionName = $this->getNamespace() . '_' . $this->filter($relatedCollection); - $escapedKey = $this->escapeMongoFieldName($key); - $escapedTwoWayKey = $this->escapeMongoFieldName($twoWayKey); - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + $collectionName = $this->getNamespace().'_'.$this->filter($collection); + $relatedCollectionName = $this->getNamespace().'_'.$this->filter($relationship->relatedCollection); + $escapedKey = $this->escapeMongoFieldName($relationship->key ?? ''); + $escapedTwoWayKey = $this->escapeMongoFieldName($relationship->twoWayKey ?? ''); + + switch ($relationship->type) { + case RelationshipType::OneToOne: + if ($side === RelationshipSide::Parent) { $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); - if ($twoWay) { + if ($relationship->twoWay) { $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); } - } elseif ($side === Database::RELATION_SIDE_CHILD) { + } else { $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); - if ($twoWay) { + if ($relationship->twoWay) { $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); } } break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); } else { $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); } break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Parent) { $this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true); } else { $this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true); } break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collectionDoc = $this->getDocument($metadataCollection, $collection); - $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection); + case RelationshipType::ManyToMany: + $this->getClient()->dropCollection($this->getJunctionName($collection, $relationship->relatedCollection, $side)); + break; + } - if ($collectionDoc->isEmpty() || $relatedCollectionDoc->isEmpty()) { - throw new DatabaseException('Collection or related collection not found'); - } + return true; + } - $junction = $side === Database::RELATION_SIDE_PARENT - ? $this->getNamespace() . '_' . $this->filter('_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence()) - : $this->getNamespace() . '_' . $this->filter('_' . $relatedCollectionDoc->getSequence() . '_' . $collectionDoc->getSequence()); + /** + * The namespaced junction collection of a many-to-many relationship, named after the parent's sequence first. + * + * @throws DatabaseException + */ + private function getJunctionName(string $collection, string $relatedCollection, RelationshipSide $side): string + { + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collectionDocument = $this->getDocument($metadataCollection, $collection); + $relatedCollectionDocument = $this->getDocument($metadataCollection, $relatedCollection); - $this->getClient()->dropCollection($junction); - break; - default: - throw new DatabaseException('Invalid relationship type'); + if ($collectionDocument->isEmpty() || $relatedCollectionDocument->isEmpty()) { + throw new DatabaseException('Collection or related collection not found'); } - return true; + return $side === RelationshipSide::Parent + ? $this->getNamespace().'_'.$this->filter('_'.$collectionDocument->getSequence().'_'.$relatedCollectionDocument->getSequence()) + : $this->getNamespace().'_'.$this->filter('_'.$relatedCollectionDocument->getSequence().'_'.$collectionDocument->getSequence()); } /** - * Create Index + * @param array $indexAttributeTypes + * @param array $collation * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - * @param array $collation - * @param int $ttl - * @return bool * @throws Exception */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - $id = $this->filter($id); + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool + { + $name = $this->getNamespace().'_'.$this->filter($collection); + $id = $this->filter($index->key); + $type = $index->type; + $indexedAttributes = $index->attributes; + $attributes = $indexedAttributes; + $ttl = $index->ttl; + /** @var array $indexes */ $indexes = []; $options = []; $indexes['name'] = $id; - // If sharedTables, always add _tenant as the first key + /** @var array $indexKey */ + $indexKey = []; + if ($this->shouldAddTenantToIndex($type)) { - $indexes['key']['_tenant'] = $this->getOrder(Database::ORDER_ASC); + $indexKey[Storage::TENANT] = $this->getOrder(OrderDirection::Asc); } - // Types are keyed by attribute name, which the loop below replaces with the internal key - $bsonTypes = \array_map(fn (string $attribute) => $this->getMongoTypeCode($indexAttributeTypes[$attribute] ?? null), $attributes); - - foreach ($attributes as $i => $attribute) { - - if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === Database::VAR_OBJECT) { + foreach ($attributes as $position => $attribute) { + if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === ColumnType::Object->value) { $dottedAttributes = \explode('.', $attribute); - $expandedAttributes = array_map(fn ($attr) => $this->filter($attr), $dottedAttributes); - $attributes[$i] = implode('.', $expandedAttributes); + $expandedAttributes = array_map(fn (string $part): string => $this->filter($part), $dottedAttributes); + $attributes[$position] = implode('.', $expandedAttributes); } else { - $attributes[$i] = $this->filter($this->getInternalKeyForAttribute($attribute)); + $attributes[$position] = $this->filter($this->getInternalKeyForAttribute($attribute)); } - $orderType = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC)); - $indexes['key'][$attributes[$i]] = $orderType; + $orderType = $this->getOrder($index->orders[$position] ?? OrderDirection::Asc); + $indexKey[$attributes[$position]] = $orderType; switch ($type) { - case Database::INDEX_KEY: + case IndexType::Key: break; - case Database::INDEX_FULLTEXT: - $indexes['key'][$attributes[$i]] = 'text'; + case IndexType::Fulltext: + $indexKey[$attributes[$position]] = 'text'; break; - case Database::INDEX_UNIQUE: + case IndexType::Unique: $indexes['unique'] = true; break; - case Database::INDEX_TTL: + case IndexType::Ttl: break; default: return false; } } - /** - * Collation - * 1. Moved under $indexes. - * 2. Updated format. - * 3. Avoid adding collation to fulltext index - */ - if (!empty($collation) && - $type !== Database::INDEX_FULLTEXT) { + $indexes['key'] = $indexKey; + + if (! empty($collation) && + $type !== IndexType::Fulltext) { $indexes['collation'] = [ 'locale' => 'en', 'strength' => 1, @@ -1078,23 +1387,21 @@ public function createIndex(string $collection, string $id, string $type, array * Set to 'none' to disable stop words (words like 'other', 'the', 'a', etc.) * This ensures all words are indexed and searchable */ - if ($type === Database::INDEX_FULLTEXT) { + if ($type === IndexType::Fulltext) { $indexes['default_language'] = 'none'; } - // Handle TTL indexes - if ($type === Database::INDEX_TTL && $ttl > 0) { + if ($type === IndexType::Ttl && $ttl > 0) { $indexes['expireAfterSeconds'] = $ttl; } - // Add partial filter for indexes to avoid indexing null values - if (in_array($type, [Database::INDEX_UNIQUE, Database::INDEX_KEY])) { - $partialFilter = []; - foreach ($attributes as $i => $attr) { - $partialFilter[$attr] = ['$exists' => true, '$type' => $bsonTypes[$i]]; + if (in_array($type, [IndexType::Unique, IndexType::Key])) { + $fields = []; + foreach ($attributes as $position => $filteredAttribute) { + $fields[$filteredAttribute] = self::indexedColumnType($indexAttributeTypes[$indexedAttributes[$position]] ?? ''); } - if (!empty($partialFilter)) { - $indexes['partialFilterExpression'] = $partialFilter; + if (! empty($fields)) { + $indexes['partialFilterExpression'] = $this->getPartialFilterExpression($type, $fields); } } try { @@ -1103,7 +1410,7 @@ public function createIndex(string $collection, string $id, string $type, array // Wait for unique index to be fully built before returning // MongoDB builds indexes asynchronously, so we need to wait for completion // to ensure unique constraints are enforced immediately - if ($type === Database::INDEX_UNIQUE) { + if ($type === IndexType::Unique) { $maxRetries = 10; $retryCount = 0; $baseDelay = 50000; // 50ms @@ -1111,34 +1418,39 @@ public function createIndex(string $collection, string $id, string $type, array while ($retryCount < $maxRetries) { try { + /** @var \stdClass $indexList */ $indexList = $this->client->query([ - 'listIndexes' => $name + 'listIndexes' => $name, ]); - if (isset($indexList->cursor->firstBatch)) { - foreach ($indexList->cursor->firstBatch as $existingIndex) { + /** @var \stdClass $indexListCursor */ + $indexListCursor = $indexList->cursor; + if (isset($indexListCursor->firstBatch)) { + /** @var array $firstBatch */ + $firstBatch = $indexListCursor->firstBatch; + foreach ($firstBatch as $existingIndex) { $indexArray = $this->client->toArray($existingIndex); if ( (isset($indexArray['name']) && $indexArray['name'] === $id) && - (!isset($indexArray['buildState']) || $indexArray['buildState'] === 'ready') + (! isset($indexArray['buildState']) || $indexArray['buildState'] === 'ready') ) { return $result; } } } - } catch (\Exception $e) { + } catch (Exception $error) { if ($retryCount >= $maxRetries - 1) { throw new DatabaseException( - 'Timeout waiting for index creation: ' . $e->getMessage(), - $e->getCode(), - $e + 'Timeout waiting for index creation: '.$error->getMessage(), + $error->getCode(), + $error ); } } $delay = \min($baseDelay * (2 ** $retryCount), $maxDelay); - \usleep((int)$delay); + \usleep((int) $delay); $retryCount++; } @@ -1146,127 +1458,101 @@ public function createIndex(string $collection, string $id, string $type, array } return $result; - } catch (\Exception $e) { - // Existing documents violate the new unique index, whatever index the message names - if ($e->getCode() === 11000 || $e->getCode() === 11001) { - throw new UniqueException('Unique index violation', $e->getCode(), $e); + } catch (Exception $error) { + if ($error->getCode() === 11000 || $error->getCode() === 11001) { + throw new UniqueException(UniqueException::MESSAGE, $error->getCode(), $error); } - throw $this->processException($e); + throw $this->processException($error); } } /** - * Rename Index. - * - * @param string $collection - * @param string $old - * @param string $new - * - * @return bool * @throws Exception */ + #[\Override] + public function deleteIndex(string $collection, string $key): bool + { + $name = $this->getNamespace().'_'.$this->filter($collection); + $id = $this->filter($key); + $this->getClient()->dropIndexes($name, [$id]); + + return true; + } + + /** + * @throws Exception + */ + #[\Override] public function renameIndex(string $collection, string $old, string $new): bool { $collection = $this->filter($collection); - $metadataCollection = new Document(['$id' => Database::METADATA]); + $metadataCollection = new Document([Document::ID => Database::METADATA]); $collectionDocument = $this->getDocument($metadataCollection, $collection); $old = $this->filter($old); $new = $this->filter($new); - $indexes = json_decode($collectionDocument['indexes'], true); $index = null; - - foreach ($indexes as $node) { - if (($node['$id'] ?? $node['key'] ?? '') === $old) { - $index = $node; + foreach (self::collectionIndexes($collectionDocument) as $candidate) { + if ($candidate->key === $old) { + $index = $candidate; break; } } - // Extract attribute types from the collection document $indexAttributeTypes = []; - if (isset($collectionDocument['attributes'])) { - $attributes = json_decode($collectionDocument['attributes'], true); - if ($attributes && $index) { - // Map index attributes to their types - foreach ($index['attributes'] as $attrName) { - foreach ($attributes as $attr) { - if ($attr['key'] === $attrName) { - $indexAttributeTypes[$attrName] = $attr['type']; - break; - } + if ($index !== null) { + $attributes = self::collectionAttributes($collectionDocument); + foreach ($index->attributes as $indexed) { + foreach ($attributes as $attribute) { + if ($attribute->key === $indexed) { + $indexAttributeTypes[$indexed] = $attribute->type->value; + break; } } } } try { - if (!$index) { - throw new DatabaseException('Index not found: ' . $old); + if ($index === null) { + throw new DatabaseException('Index not found: '.$old); } - $deletedindex = $this->deleteIndex($collection, $old); - $createdindex = $this->createIndex($collection, $new, $index['type'], $index['attributes'], $index['lengths'] ?? [], $index['orders'] ?? [], $indexAttributeTypes, [], $index['ttl'] ?? 0); - } catch (\Exception $e) { + $deleted = $this->deleteIndex($collection, $old); + $created = $this->createIndex($collection, $index->withKey($new), $indexAttributeTypes); + } catch (Exception $e) { throw $this->processException($e); } - if ($deletedindex && $createdindex) { - return true; - } - - return false; - } - - /** - * Delete Index - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws Exception - */ - public function deleteIndex(string $collection, string $id): bool - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - $id = $this->filter($id); - $this->getClient()->dropIndexes($name, [$id]); - - return true; + return $deleted && $created; } /** - * Get Document + * @param Query[] $queries * - * @param Document $collection - * @param string $id - * @param Query[] $queries - * @param bool $forUpdate - * @return Document * @throws DatabaseException */ + #[\Override] public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - - $filters = ['_uid' => $id]; - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); - } + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); + $filters = [Storage::UID => $id]; + $filters = $this->applyTenantFilter($filters, $collection->getId()); $options = $this->getTransactionOptions(); $selections = $this->getAttributeSelections($queries); - $hasProjection = !empty($selections) && !\in_array('*', $selections); + $hasProjection = ! empty($selections) && ! \in_array('*', $selections); if ($hasProjection) { $options['projection'] = $this->getAttributeProjection($selections); } try { - $result = $this->client->find($name, $filters, $options)->cursor->firstBatch; + $findResponse = $this->client->find($name, $filters, $options); + /** @var \stdClass $findCursor */ + $findCursor = $findResponse->cursor; + /** @var array $result */ + $result = $findCursor->firstBatch; } catch (MongoException $e) { throw $this->processException($e); } @@ -1275,13 +1561,14 @@ public function getDocument(Document $collection, string $id, array $queries = [ return new Document([]); } + /** @var array|null $resultArray */ $resultArray = $this->client->toArray($result[0]); - $result = $this->replaceChars('_', '$', $resultArray); - $document = new Document($result); - $document = $this->castingAfter($collection, $document); + $result = $this->replaceCharacters('_', '$', $resultArray ?? []); + $document = Document::fromStorage($result); + $document = $this->castRead($this->getReadCasts($collection), $this->supports(Capability::DefinedAttributes), $document); // Ensure missing relationship attributes are set to null (MongoDB doesn't store null fields) - if (!$hasProjection) { + if (! $hasProjection) { $this->ensureRelationshipDefaults($collection, $document); } @@ -1289,35 +1576,30 @@ public function getDocument(Document $collection, string $id, array $queries = [ } /** - * Create Document - * - * @param Document $collection - * @param Document $document - * - * @return Document * @throws Exception */ + #[\Override] public function createDocument(Document $collection, Document $document): Document { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $this->syncWriteHooks(); - $sequence = $document->getSequence(); + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); - $document->removeAttribute('$sequence'); + $sequence = $document->getSequence(); - if ($this->sharedTables) { - $document->setAttribute('$tenant', $this->getTenant()); - } + $document->removeAttribute(Document::SEQUENCE); - $record = $this->replaceChars('$', '_', (array)$document); + /** @var array $documentArray */ + $documentArray = (array) $document; + $record = $this->replaceCharacters('$', '_', $documentArray); + $record = $this->decorateRow($record, $document); - // Insert manual id if set - if (!empty($sequence)) { - $record['_id'] = $sequence; + if (! empty($sequence)) { + $record[Storage::SEQUENCE] = $sequence; } $options = $this->getTransactionOptions(); $result = $this->insertDocument($name, $this->removeNullKeys($record), $options); - $result = $this->replaceChars('_', '$', $result); + $result = $this->replaceCharacters('_', '$', $result); // in order to keep the original object refrence. foreach ($result as $key => $value) { $document->setAttribute($key, $value); @@ -1327,397 +1609,176 @@ public function createDocument(Document $collection, Document $document): Docume } /** - * Returns the document after casting from - * @param Document $collection - * @param Document $document - * @return Document + * @param array $documents + * @return array + * + * @throws DuplicateException + * @throws DatabaseException */ - public function castingAfter(Document $collection, Document $document): Document + #[\Override] + public function createDocuments(Document $collection, array $documents): array { - if (!$this->getSupportForInternalCasting()) { - return $document; - } + $this->syncWriteHooks(); - if ($document->isEmpty()) { - return $document; - } + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); - $attributes = $collection->getAttribute('attributes', []); + $options = $this->getTransactionOptions(); + $records = []; + $hasSequence = null; + $documents = \array_values(\array_map(fn ($doc) => clone $doc, $documents)); - $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); + foreach ($documents as $document) { + $sequence = $document->getSequence(); - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; - $value = $document->getAttribute($key); - if (is_null($value)) { - continue; + if ($hasSequence === null) { + $hasSequence = ! empty($sequence); + } elseif ($hasSequence == empty($sequence)) { + throw new DatabaseException('All documents must have an sequence if one is set'); } - // Operators are resolved by the database (aggregation pipeline); skip casting - if (Operator::isOperator($value)) { - continue; + /** @var array $documentArr */ + $documentArr = (array) $document; + $record = $this->replaceCharacters('$', '_', $documentArr); + $record = $this->decorateRow($record, $document); + + if (! empty($sequence)) { + $record[Storage::SEQUENCE] = $sequence; } - if ($array) { - if (is_string($value)) { - $decoded = json_decode($value, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new DatabaseException('Failed to decode JSON for attribute ' . $key . ': ' . json_last_error_msg()); - } - $value = $decoded; - } - } else { - $value = [$value]; + $records[] = $record; + } + + // insertMany aborts the txn on any duplicate; upsert + $setOnInsert no-ops instead. + if ($this->isIgnoringDuplicates()) { + if (empty($records)) { + return []; } - foreach ($value as $index => $node) { - switch ($type) { - case Database::VAR_INTEGER: - case Database::VAR_BIGINT: - $node = (int)$node; - break; - case Database::VAR_DATETIME: - $node = $this->convertUTCDateToString($node); - break; - case Database::VAR_OBJECT: - // Convert stdClass objects to arrays for object attributes - if (is_object($node) && get_class($node) === stdClass::class) { - $node = $this->convertStdClassToArray($node); - } - break; - default: - break; + $provided = []; + $sequences = []; + $updates = []; + foreach ($records as $index => $record) { + if (isset($record[Storage::SEQUENCE])) { + $provided[] = $record[Storage::SEQUENCE]; + } else { + $record[Storage::SEQUENCE] = $this->client->createUuid(); } - $value[$index] = $node; + $sequences[$index] = $record[Storage::SEQUENCE]; + + $filter = [Storage::UID => $record[Storage::UID] ?? '']; + if ($this->sharedTables) { + $filter[Storage::TENANT] = $record[Storage::TENANT] ?? $this->getTenant(); + } + + // Filter fields can't reappear in $setOnInsert (mongo path-conflict error). + $setOnInsert = $record; + unset($setOnInsert[Storage::UID], $setOnInsert[Storage::TENANT]); + + $updates[] = [ + 'q' => $filter, + 'u' => $this->client->toObject(['$setOnInsert' => $setOnInsert]), + 'upsert' => true, + 'multi' => false, + 'collation' => self::UID_COLLATION, + ]; } - $document->setAttribute($key, ($array) ? $value : $value[0]); - } - if (!$this->getSupportForAttributes()) { - foreach ($document->getArrayCopy() as $key => $value) { - // mongodb results out a stdclass for objects - if (is_object($value) && get_class($value) === stdClass::class) { - $document->setAttribute($key, $this->convertStdClassToArray($value)); - } elseif ($value instanceof UTCDateTime) { - $document->setAttribute($key, $this->convertUTCDateToString($value)); + $stored = $provided === [] ? [] : $this->storedSequences($name, $provided, $options); + + try { + $this->client->query(\array_merge(['update' => $name, 'updates' => $updates], $options)); + } catch (MongoException $e) { + throw $this->processException($e); + } + + $inserted = \array_diff_key($this->storedSequences($name, \array_values($sequences), $options), $stored); + + $created = []; + foreach ($sequences as $index => $sequence) { + $key = $this->stringifyIdentifier($sequence); + if (isset($inserted[$key])) { + unset($inserted[$key]); + $created[] = $documents[$index]; } } - } - return $document; - } - private function convertStdClassToArray(mixed $value): mixed - { - if (is_object($value) && get_class($value) === stdClass::class) { - $properties = get_object_vars($value); + return $created; + } - return $properties === [] ? $value : array_map($this->convertStdClassToArray(...), $properties); + try { + $documents = $this->client->insertMany($name, $records, $options); + } catch (MongoException $e) { + throw $this->processException($e); } - if (is_array($value)) { - return array_map( - fn ($v) => $this->convertStdClassToArray($v), - $value - ); + foreach ($documents as $index => $document) { + /** @var array $toArrayResult */ + $toArrayResult = $this->client->toArray($document) ?? []; + $documents[$index] = $this->replaceCharacters('_', '$', $toArrayResult); + $documents[$index] = new Document($documents[$index]); } - return $value; + return $documents; } /** - * Returns the document after casting to - * @param Document $collection - * @param Document $document - * @return Document - * @throws Exception + * @throws DuplicateException + * @throws DatabaseException */ - public function castingBefore(Document $collection, Document $document): Document + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { - if (!$this->getSupportForInternalCasting()) { - return $document; - } + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); - if ($document->isEmpty()) { - return $document; - } - - $attributes = $collection->getAttribute('attributes', []); - - $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); + $record = $document->getArrayCopy(); + $record = $this->replaceCharacters('$', '_', $record); - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; + $filters = [Storage::UID => $id]; + $filters = $this->applyTenantFilter($filters, $collection->getId()); - $value = $document->getAttribute($key); - if (is_null($value)) { - continue; - } + try { + unset($record[Storage::SEQUENCE]); // Don't update _id - // Operators are resolved by the database (aggregation pipeline); skip casting - if (Operator::isOperator($value)) { - continue; - } + $options = $this->getTransactionOptions(); - if ($array) { - if (is_string($value)) { - $decoded = json_decode($value, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new DatabaseException('Failed to decode JSON for attribute ' . $key . ': ' . json_last_error_msg()); - } - $value = $decoded; - } + $pipeline = $this->buildOperatorPipeline($record); + if ($pipeline !== null) { + $updated = $this->updateWithPipeline($name, $filters, $pipeline, $options); } else { - $value = [$value]; + $updateQuery = [ + '$set' => $record, + ]; + $updated = $this->client->update($name, $filters, $updateQuery, $options); } - - foreach ($value as $index => $node) { - switch ($type) { - case Database::VAR_DATETIME: - if (!($node instanceof UTCDateTime)) { - try { - $node = new UTCDateTime(new \DateTime($node)); - } catch (\Throwable $e) { - throw new StructureException('Invalid datetime value for attribute "' . $key . '": ' . $e->getMessage()); - } - } - break; - case Database::VAR_OBJECT: - $node = json_decode($node); - break; - default: - break; - } - $value[$index] = $node; - } - $document->setAttribute($key, ($array) ? $value : $value[0]); - } - $indexes = $collection->getAttribute('indexes'); - $ttlIndexes = array_filter($indexes, fn ($index) => $index->getAttribute('type') === Database::INDEX_TTL); - - if (!$this->getSupportForAttributes()) { - foreach ($document->getArrayCopy() as $key => $value) { - if (in_array($this->getInternalKeyForAttribute($key), Database::INTERNAL_ATTRIBUTE_KEYS)) { - continue; - } - if (is_string($value) && (in_array($key, $ttlIndexes) || $this->isExtendedISODatetime($value))) { - try { - $newValue = new UTCDateTime(new \DateTime($value)); - $document->setAttribute($key, $newValue); - } catch (\Throwable $th) { - // skip -> a valid string - } - } - } - } - - return $document; - } - - /** - * Create Documents in batches - * - * @param Document $collection - * @param array $documents - * - * @return array - * - * @throws DuplicateException - * @throws DatabaseException - */ - public function createDocuments(Document $collection, array $documents): array - { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - - $options = $this->getTransactionOptions(); - $records = []; - $hasSequence = null; - $documents = \array_map(fn ($doc) => clone $doc, $documents); - - foreach ($documents as $document) { - $sequence = $document->getSequence(); - - if ($hasSequence === null) { - $hasSequence = !empty($sequence); - } elseif ($hasSequence == empty($sequence)) { - throw new DatabaseException('All documents must have an sequence if one is set'); - } - - $record = $this->replaceChars('$', '_', (array)$document); - - if (!empty($sequence)) { - $record['_id'] = $sequence; - } - - $records[] = $record; - } - - // insertMany aborts the txn on any duplicate; upsert + $setOnInsert no-ops instead. - if ($this->skipDuplicates) { - if (empty($records)) { - return []; - } - - $operations = []; - foreach ($records as $record) { - $filter = ['_uid' => $record['_uid'] ?? '']; - if ($this->sharedTables) { - $filter['_tenant'] = $record['_tenant'] ?? $this->getTenant(); - } - - // Filter fields can't reappear in $setOnInsert (mongo path-conflict error). - $setOnInsert = $record; - unset($setOnInsert['_uid'], $setOnInsert['_tenant']); - - if (empty($setOnInsert)) { - continue; - } - - $operations[] = [ - 'filter' => $filter, - 'update' => ['$setOnInsert' => $setOnInsert], - ]; - } - - try { - $this->client->upsert($name, $operations, $options); - } catch (MongoException $e) { - throw $this->processException($e); - } - - return $documents; - } - - try { - $documents = $this->client->insertMany($name, $records, $options); - } catch (MongoException $e) { - throw $this->processException($e); - } - - foreach ($documents as $index => $document) { - $documents[$index] = $this->replaceChars('_', '$', $this->client->toArray($document)); - $documents[$index] = new Document($documents[$index]); - } - - return $documents; - } - - /** - * - * @param string $name - * @param array $document - * @param array $options - * - * @return array - * @throws DuplicateException - * @throws Exception - */ - private function insertDocument(string $name, array $document, array $options = []): array - { - try { - $result = $this->client->insert($name, $document, $options); - $filters = []; - $filters['_uid'] = $document['_uid']; - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($name); - } - - try { - $result = $this->client->find( - $name, - $filters, - array_merge(['limit' => 1], $options) - )->cursor->firstBatch[0]; - } catch (MongoException $e) { - throw $this->processException($e); - } - - return $this->client->toArray($result); - } catch (MongoException $e) { - throw $this->processException($e); - } - } - - /** - * Update Document - * - * @param Document $collection - * @param string $id - * @param Document $document - * @param bool $skipPermissions - * @return Document - * @throws DuplicateException - * @throws DatabaseException - */ - public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document - { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - - $record = $document->getArrayCopy(); - $record = $this->replaceChars('$', '_', $record); - - $filters = []; - $filters['_uid'] = $id; - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); - } - - try { - unset($record['_id']); // Don't update _id - - $options = $this->getTransactionOptions(); - - $pipeline = $this->buildOperatorPipeline($record); - if ($pipeline !== null) { - $this->updateWithPipeline($name, $filters, $pipeline, $options); - } else { - $updateQuery = [ - '$set' => $record, - ]; - $this->client->update($name, $filters, $updateQuery, $options); - } - } catch (MongoException $e) { - throw $this->processException($e); - } + } catch (MongoException $e) { + throw $this->processException($e); + } return $document; } /** - * Update documents - * - * Updates all documents which match the given query. - * - * @param Document $collection - * @param Document $updates - * @param array $documents - * - * @return int + * @param array $documents + * @param array $skipPermissions * * @throws DatabaseException */ - public function updateDocuments(Document $collection, Document $updates, array $documents): int + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); $options = $this->getTransactionOptions(); $queries = [ - Query::equal('$sequence', \array_map(fn ($document) => $document->getSequence(), $documents)) + Query::equal(Document::SEQUENCE, \array_map(fn ($document) => $document->getSequence(), $documents)), ]; + /** @var array $filters */ $filters = $this->buildFilters($queries); - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); - } + $filters = $this->applyTenantFilter($filters, $collection->getId()); $record = $updates->getArrayCopy(); - $record = $this->replaceChars('$', '_', $record); + $record = $this->replaceCharacters('$', '_', $record); try { $pipeline = $this->buildOperatorPipeline($record); @@ -1742,72 +1803,60 @@ public function updateDocuments(Document $collection, Document $updates, array $ } /** - * Build an aggregation pipeline update from a record that may contain Operator instances. + * Build an aggregation pipeline update from a record containing operators. * - * Returns null when the record contains no operators, so the caller can fall back to a - * plain `$set` update. When operators are present, every regular value is wrapped in - * `$literal` (so it is never interpreted as an aggregation expression) and every operator - * is translated into the equivalent aggregation expression, all merged into a single - * `$set` stage. + * @param array $record + * @return array{0: array{'$set': array}}|null * - * @param array $record - * @return array>|null * @throws DatabaseException */ private function buildOperatorPipeline(array $record): ?array { $hasOperators = false; foreach ($record as $value) { - if (Operator::isOperator($value)) { + if ($value instanceof Operator) { $hasOperators = true; + break; } } - if (!$hasOperators) { + if (! $hasOperators) { return null; } $set = []; foreach ($record as $key => $value) { - if (Operator::isOperator($value)) { - $set[$key] = $this->getOperatorExpression($value, $key); - } else { - // Wrap literals so values are never parsed as aggregation expressions/field paths - $set[$key] = ['$literal' => $value]; - } + $set[$key] = $value instanceof Operator + ? $this->getOperatorExpression($value, $key) + : ['$literal' => $value]; } return [['$set' => $set]]; } /** - * Execute an aggregation pipeline update. - * - * The Mongo client's update() helper wraps the update document in toObject(), which would - * turn a pipeline (a list) into an object and break it. We therefore build the raw update - * command and send it through query(), letting BSON encode the pipeline as an array. + * @param array $filters + * @param array> $pipeline + * @param array $options * - * @param string $collection - * @param array $filters - * @param array> $pipeline - * @param array $options - * @param bool $multi - * @return int Number of matched documents * @throws MongoException */ - private function updateWithPipeline(string $collection, array $filters, array $pipeline, array $options = [], bool $multi = false): int - { + private function updateWithPipeline( + string $collection, + array $filters, + array $pipeline, + array $options = [], + bool $multi = false, + ): int { $command = [ 'update' => $collection, - 'updates' => [ - [ - 'q' => $this->client->toObject($filters), - 'u' => $pipeline, - 'multi' => $multi, - 'upsert' => false, - ], - ], + 'updates' => [[ + 'q' => $this->client->toObject($filters), + 'u' => $pipeline, + 'multi' => $multi, + 'upsert' => false, + ]], ]; if (isset($options['session'])) { @@ -1820,103 +1869,82 @@ private function updateWithPipeline(string $collection, array $filters, array $p } /** - * Execute a batch of upsert operations, supporting aggregation-pipeline updates. + * @param array, update: array}> $operations + * @param array $options * - * Mirrors the Mongo client's upsert() helper but does not wrap each update in toObject(), - * so an update may be either a classic update document or an aggregation pipeline (list). - * - * @param string $collection - * @param array, update: array}> $operations - * @param array $options - * @return int * @throws MongoException */ private function executeUpsert(string $collection, array $operations, array $options = []): int { $updates = []; - foreach ($operations as $op) { + foreach ($operations as $operation) { $updates[] = [ - 'q' => $this->client->toObject($op['filter']), - 'u' => $op['update'], + 'q' => $this->client->toObject($operation['filter']), + 'u' => $operation['update'], 'upsert' => true, 'multi' => false, ]; } - $command = \array_merge( - [ - 'update' => $collection, - 'updates' => $updates, - ], - $options - ); - - $result = $this->client->query($command); + $result = $this->client->query(\array_merge([ + 'update' => $collection, + 'updates' => $updates, + ], $options)); return \is_int($result) ? $result : 0; } /** - * Translate an Operator into a MongoDB aggregation expression for use inside a `$set` stage. - * - * @param Operator $operator - * @param string $field The (already escaped) field name the expression is assigned to - * @return mixed * @throws DatabaseException */ private function getOperatorExpression(Operator $operator, string $field): mixed { - $ref = '$' . $field; + $reference = '$'.$field; $method = $operator->getMethod(); $values = $operator->getValues(); switch ($method) { - // Numeric operators - case Operator::TYPE_INCREMENT: - $expr = ['$add' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]]; + case OperatorType::Increment: + $expression = ['$add' => [['$ifNull' => [$reference, 0]], $values[0] ?? 1]]; if (isset($values[1])) { - $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]]; + $expression = ['$cond' => [['$lte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]]; } - return $expr; - case Operator::TYPE_DECREMENT: - $expr = ['$subtract' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]]; + return $expression; + + case OperatorType::Decrement: + $expression = ['$subtract' => [['$ifNull' => [$reference, 0]], $values[0] ?? 1]]; if (isset($values[1])) { - $expr = ['$cond' => [['$gte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]]; + $expression = ['$cond' => [['$gte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]]; } - return $expr; - case Operator::TYPE_MULTIPLY: - $expr = ['$multiply' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]]; + return $expression; + + case OperatorType::Multiply: + $expression = ['$multiply' => [['$ifNull' => [$reference, 0]], $values[0] ?? 1]]; if (isset($values[1])) { - $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]]; + $expression = ['$cond' => [['$lte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]]; } - return $expr; - case Operator::TYPE_DIVIDE: - $expr = ['$divide' => [['$ifNull' => [$ref, 0]], $values[0]]]; + return $expression; + + case OperatorType::Divide: + $expression = ['$divide' => [['$ifNull' => [$reference, 0]], $values[0]]]; if (isset($values[1])) { - $expr = ['$cond' => [['$gte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]]; + $expression = ['$cond' => [['$gte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]]; } - return $expr; - case Operator::TYPE_MODULO: - return ['$mod' => [['$ifNull' => [$ref, 0]], $values[0]]]; + return $expression; + + case OperatorType::Modulo: + return ['$mod' => [['$ifNull' => [$reference, 0]], $values[0]]]; - case Operator::TYPE_POWER: - $base = ['$ifNull' => [$ref, 0]]; - $exponent = $values[0]; - $expr = ['$pow' => [$base, $exponent]]; + case OperatorType::Power: + $base = ['$ifNull' => [$reference, 0]]; + $exponent = $this->getNumericOperand($values, 0, 1, $method); + $expression = ['$pow' => [$base, $exponent]]; if (isset($values[1])) { - // Apply the power only if the result stays within the max; otherwise leave the - // value unchanged. Overflow yields Infinity, which is greater than the max, so - // it correctly stays put. - $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]]; - - // Never compute $pow for an undefined input (0 to a negative power, or a - // negative base to a fractional exponent): it yields NaN, which Mongo orders - // below every number, so a plain `<= max` check would wrongly apply it. The - // exponent is constant, so only guard the base condition it can actually trigger. + $expression = ['$cond' => [['$lte' => [$expression, $values[1]]], $expression, $base]]; $guards = []; if ($exponent < 0) { $guards[] = ['$eq' => [$base, 0]]; @@ -1924,61 +1952,58 @@ private function getOperatorExpression(Operator $operator, string $field): mixed if (\floor($exponent) != $exponent) { $guards[] = ['$lt' => [$base, 0]]; } - if (!empty($guards)) { + if (! empty($guards)) { $undefined = \count($guards) === 1 ? $guards[0] : ['$or' => $guards]; - $expr = ['$cond' => [$undefined, $base, $expr]]; + $expression = ['$cond' => [$undefined, $base, $expression]]; } } - return $expr; - // String operators - case Operator::TYPE_STRING_CONCAT: - return ['$concat' => [['$ifNull' => [$ref, '']], ['$literal' => $values[0] ?? '']]]; + return $expression; - case Operator::TYPE_STRING_REPLACE: - // An empty search is a no-op (matches SQL REPLACE semantics); MongoDB's - // $replaceAll would otherwise insert the replacement between every character. + case OperatorType::StringConcat: + return ['$concat' => [['$ifNull' => [$reference, '']], ['$literal' => $values[0] ?? '']]]; + + case OperatorType::StringReplace: if (($values[0] ?? '') === '') { - return ['$ifNull' => [$ref, '']]; + return ['$ifNull' => [$reference, '']]; } + return ['$replaceAll' => [ - 'input' => ['$ifNull' => [$ref, '']], + 'input' => ['$ifNull' => [$reference, '']], 'find' => ['$literal' => $values[0]], 'replacement' => ['$literal' => $values[1] ?? ''], ]]; - // Boolean operators - case Operator::TYPE_TOGGLE: - return ['$not' => [['$ifNull' => [$ref, false]]]]; + case OperatorType::Toggle: + return ['$not' => [['$ifNull' => [$reference, false]]]]; - // Array operators - case Operator::TYPE_ARRAY_APPEND: - return ['$concatArrays' => [['$ifNull' => [$ref, []]], ['$literal' => \array_values($values)]]]; + case OperatorType::ArrayAppend: + return ['$concatArrays' => [['$ifNull' => [$reference, []]], ['$literal' => \array_values($values)]]]; - case Operator::TYPE_ARRAY_PREPEND: - return ['$concatArrays' => [['$literal' => \array_values($values)], ['$ifNull' => [$ref, []]]]]; + case OperatorType::ArrayPrepend: + return ['$concatArrays' => [['$literal' => \array_values($values)], ['$ifNull' => [$reference, []]]]]; - case Operator::TYPE_ARRAY_INSERT: - $index = (int)($values[0] ?? 0); + case OperatorType::ArrayInsert: + $index = $this->getIntegerOperand($values, 0, 0, $method); $value = $values[1] ?? null; - $size = ['$size' => '$$arr']; - $before = ['$cond' => [['$lte' => [$index, 0]], [], ['$slice' => ['$$arr', $index]]]]; - $after = ['$cond' => [['$gte' => [$index, $size]], [], ['$slice' => ['$$arr', ['$subtract' => [$index, $size]]]]]]; + $size = ['$size' => '$$array']; + $before = ['$cond' => [['$lte' => [$index, 0]], [], ['$slice' => ['$$array', $index]]]]; + $after = ['$cond' => [['$gte' => [$index, $size]], [], ['$slice' => ['$$array', ['$subtract' => [$index, $size]]]]]]; + return ['$let' => [ - 'vars' => ['arr' => ['$ifNull' => [$ref, []]]], + 'vars' => ['array' => ['$ifNull' => [$reference, []]]], 'in' => ['$concatArrays' => [$before, ['$literal' => [$value]], $after]], ]]; - case Operator::TYPE_ARRAY_REMOVE: + case OperatorType::ArrayRemove: return ['$filter' => [ - 'input' => ['$ifNull' => [$ref, []]], + 'input' => ['$ifNull' => [$reference, []]], 'cond' => ['$ne' => ['$$this', ['$literal' => $values[0] ?? null]]], ]]; - case Operator::TYPE_ARRAY_UNIQUE: - // Preserve first-occurrence order while removing duplicates + case OperatorType::ArrayUnique: return ['$reduce' => [ - 'input' => ['$ifNull' => [$ref, []]], + 'input' => ['$ifNull' => [$reference, []]], 'initialValue' => [], 'in' => ['$cond' => [ ['$in' => ['$$this', '$$value']], @@ -1987,54 +2012,89 @@ private function getOperatorExpression(Operator $operator, string $field): mixed ]], ]]; - case Operator::TYPE_ARRAY_INTERSECT: - // Keep elements present in the given set, preserving original order + case OperatorType::ArrayIntersect: return ['$filter' => [ - 'input' => ['$ifNull' => [$ref, []]], + 'input' => ['$ifNull' => [$reference, []]], 'cond' => ['$in' => ['$$this', ['$literal' => \array_values($values)]]], ]]; - case Operator::TYPE_ARRAY_DIFF: - // Remove elements present in the given set, preserving original order + case OperatorType::ArrayDiff: return ['$filter' => [ - 'input' => ['$ifNull' => [$ref, []]], + 'input' => ['$ifNull' => [$reference, []]], 'cond' => ['$not' => [['$in' => ['$$this', ['$literal' => \array_values($values)]]]]], ]]; - case Operator::TYPE_ARRAY_FILTER: + case OperatorType::ArrayFilter: return ['$filter' => [ - 'input' => ['$ifNull' => [$ref, []]], - 'cond' => $this->getArrayFilterCondition((string)($values[0] ?? ''), $values[1] ?? null), + 'input' => ['$ifNull' => [$reference, []]], + 'cond' => $this->getArrayFilterCondition($this->getStringOperand($values, 0, '', $method), $values[1] ?? null), ]]; - // Date operators - case Operator::TYPE_DATE_ADD_DAYS: + case OperatorType::DateAddDays: return ['$dateAdd' => [ - 'startDate' => ['$ifNull' => [$ref, '$$NOW']], + 'startDate' => ['$ifNull' => [$reference, '$$NOW']], 'unit' => 'day', - 'amount' => (int)($values[0] ?? 0), + 'amount' => $this->getIntegerOperand($values, 0, 0, $method), ]]; - case Operator::TYPE_DATE_SUB_DAYS: + case OperatorType::DateSubDays: return ['$dateSubtract' => [ - 'startDate' => ['$ifNull' => [$ref, '$$NOW']], + 'startDate' => ['$ifNull' => [$reference, '$$NOW']], 'unit' => 'day', - 'amount' => (int)($values[0] ?? 0), + 'amount' => $this->getIntegerOperand($values, 0, 0, $method), ]]; - case Operator::TYPE_DATE_SET_NOW: + case OperatorType::DateSetNow: return '$$NOW'; + } + } + + /** + * @param array $values + * + * @throws DatabaseException + */ + private function getNumericOperand(array $values, int $offset, int|float $default, OperatorType $method): int|float + { + $value = $values[$offset] ?? $default; + if (! \is_int($value) && ! \is_float($value)) { + throw new DatabaseException('Invalid numeric operand for operator '.$method->value); + } + + return $value; + } - default: - throw new DatabaseException("Unsupported operator: {$method}"); + /** + * @param array $values + * + * @throws DatabaseException + */ + private function getIntegerOperand(array $values, int $offset, int $default, OperatorType $method): int + { + $value = $values[$offset] ?? $default; + if (! \is_int($value)) { + throw new DatabaseException('Invalid integer operand for operator '.$method->value); } + + return $value; } /** - * Build the aggregation condition expression used by the arrayFilter operator. + * @param array $values * - * @param string $condition - * @param mixed $compare + * @throws DatabaseException + */ + private function getStringOperand(array $values, int $offset, string $default, OperatorType $method): string + { + $value = $values[$offset] ?? $default; + if (! \is_string($value)) { + throw new DatabaseException('Invalid string operand for operator '.$method->value); + } + + return $value; + } + + /** * @return array */ private function getArrayFilterCondition(string $condition, mixed $compare): array @@ -2050,114 +2110,111 @@ private function getArrayFilterCondition(string $condition, mixed $compare): arr 'lessThanEqual' => ['$lte' => ['$$this', $value]], 'isNull' => ['$eq' => ['$$this', null]], 'isNotNull' => ['$ne' => ['$$this', null]], - default => ['$literal' => true], // unknown condition keeps every element + default => ['$literal' => true], }; } /** - * @param Document $collection - * @param string $attribute - * @param array $changes + * @throws DatabaseException + */ + #[\Override] + public function upsertDocument(Document $collection, Change $change): Document + { + return $this->upsertDocuments($collection, [$change])[0]; + } + + /** + * @param array $changes * @return array + * * @throws DatabaseException */ - public function upsertDocuments(Document $collection, string $attribute, array $changes): array + #[\Override] + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array { - if (empty($changes)) { - return $changes; + if ($changes === []) { + return []; } + $this->syncWriteHooks(); + try { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - $attribute = $this->filter($attribute); + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); + $attribute = $this->filter($increase ?? ''); $operations = []; $hasPipeline = false; foreach ($changes as $change) { - $document = $change->getNew(); - $oldDocument = $change->getOld(); + $document = $change->new; + $oldDocument = $change->old; + /** @var array $attributes */ $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document['$createdAt']; - $attributes['_updatedAt'] = $document['$updatedAt']; - $attributes['_permissions'] = $document->getPermissions(); - - if (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); - } + $attributes[Storage::UID] = $document->getId(); + $attributes[Storage::CREATED_AT] = $document[Document::CREATED_AT]; + $attributes[Storage::UPDATED_AT] = $document[Document::UPDATED_AT]; + $attributes[Storage::PERMISSIONS] = $document->getPermissions(); - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + if (! empty($document->getSequence())) { + $attributes[Storage::SEQUENCE] = $document->getSequence(); } - $record = $this->replaceChars('$', '_', $attributes); - - // Build filter for upsert - $filters = ['_uid' => $document->getId()]; + $filters = [Storage::UID => $document->getId()]; if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); + $tenant = $document->getTenant() ?? $this->getTenant(); + $attributes[Storage::TENANT] = $tenant; + $filters[Storage::TENANT] = $this->getTenantFilters($collection->getId(), [$tenant]); } - unset($record['_id']); // Don't update _id + $record = $this->replaceCharacters('$', '_', $attributes); + $record = $this->decorateRow($record, $document); + + unset($record[Storage::SEQUENCE]); // Don't update _id - // Get fields to unset for schemaless mode $unsetFields = $this->getUpsertAttributeRemovals($oldDocument, $document, $record); - if (!empty($attribute)) { - // Get the attribute value before removing it from $set + if (! empty($attribute)) { $attributeValue = $record[$attribute] ?? 0; // Remove the attribute from $set since we're incrementing it // it is requierd to mimic the behaver of SQL on duplicate key update unset($record[$attribute]); - // Also remove from unset if it was there unset($unsetFields[$attribute]); - // Increment the specific attribute and update all other fields $update = [ '$inc' => [$attribute => $attributeValue], - '$set' => $record + '$set' => $record, ]; - if (!empty($unsetFields)) { + if (! empty($unsetFields)) { $update['$unset'] = $unsetFields; } } else { $pipeline = $this->buildOperatorPipeline($record); - if ($pipeline !== null) { - // Operator-based upsert: resolve operators via an aggregation pipeline - // so they apply atomically, with $ifNull defaults on insert. $set = $pipeline[0]['$set']; - - // Generate an _id only on insert; keep the existing one on update. if (empty($document->getSequence())) { - $set['_id'] = ['$ifNull' => ['$_id', $this->client->createUuid()]]; + $set[Storage::SEQUENCE] = ['$ifNull' => ['$' . Storage::SEQUENCE, $this->client->createUuid()]]; } $update = [['$set' => $set]]; - - if (!empty($unsetFields)) { + if (! empty($unsetFields)) { $update[] = ['$unset' => \array_keys($unsetFields)]; } - $hasPipeline = true; } else { - // Update all fields $update = [ - '$set' => $record + '$set' => $record, ]; - if (!empty($unsetFields)) { + if (! empty($unsetFields)) { $update['$unset'] = $unsetFields; } - // Add UUID7 _id for new documents in upsert operations if (empty($document->getSequence())) { $update['$setOnInsert'] = [ - '_id' => $this->client->createUuid() + Storage::SEQUENCE => $this->client->createUuid(), ]; } } @@ -2172,8 +2229,6 @@ public function upsertDocuments(Document $collection, string $attribute, array $ $options = $this->getTransactionOptions(); if ($hasPipeline) { - // The client's upsert() wraps each update in toObject(), which would corrupt a - // pipeline (a list). Send the raw command so BSON encodes pipelines as arrays. $this->executeUpsert($name, $operations, $options); } else { $this->client->upsert( @@ -2186,344 +2241,153 @@ public function upsertDocuments(Document $collection, string $attribute, array $ throw $this->processException($e); } - return \array_map(fn ($change) => $change->getNew(), $changes); + return \array_map(static fn (Change $change): Document => $change->new, $changes); } /** - * Get fields to unset for schemaless upsert operations - * - * @param Document $oldDocument - * @param Document $newDocument - * @param array $record - * @return array + * @throws Exception */ - private function getUpsertAttributeRemovals(Document $oldDocument, Document $newDocument, array $record): array + #[\Override] + public function deleteDocument(Document $collection, string $id): bool { - $unsetFields = []; - - if ($this->getSupportForAttributes() || $oldDocument->isEmpty()) { - return $unsetFields; - } + $collectionId = $collection->getId(); + $name = $this->getNamespace().'_'.$this->filter($collectionId); - $oldUserAttributes = $oldDocument->getAttributes(); - $newUserAttributes = $newDocument->getAttributes(); - - $protectedFields = ['_uid', '_id', '_createdAt', '_updatedAt', '_permissions', '_tenant']; - - foreach ($oldUserAttributes as $originalKey => $originalValue) { - if (in_array($originalKey, $protectedFields) || array_key_exists($originalKey, $newUserAttributes)) { - continue; - } + $filters = [Storage::UID => $id]; + $filters = $this->applyTenantFilter($filters, $collectionId); - $transformed = $this->replaceChars('$', '_', [$originalKey => $originalValue]); - $dbKey = array_key_first($transformed); - - if ($dbKey && !array_key_exists($dbKey, $record) && !in_array($dbKey, $protectedFields)) { - $unsetFields[$dbKey] = ''; - } - } + $options = $this->getTransactionOptions(); + $result = $this->client->delete($name, $filters, 1, [], $options); - return $unsetFields; + return (bool) $result; } /** - * Get sequences for documents that were created + * @param array $sequences + * @param array $permissionIds * - * @param string $collection - * @param array $documents - * @return array * @throws DatabaseException - * @throws MongoException */ - public function getSequences(string $collection, array $documents): array + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { - $documentIds = []; - $documentTenants = []; - foreach ($documents as $document) { - if (empty($document->getSequence())) { - $documentIds[] = $document->getId(); - - if ($this->sharedTables) { - $documentTenants[] = $document->getTenant(); - } - } - } - - if (empty($documentIds)) { - return $documents; - } - - $sequences = []; - $name = $this->getNamespace() . '_' . $this->filter($collection); - - $filters = ['_uid' => ['$in' => $documentIds]]; - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection, $documentTenants); - } - try { - // Use cursor paging for large result sets - $options = [ - 'projection' => ['_uid' => 1, '_id' => 1], - 'batchSize' => self::DEFAULT_BATCH_SIZE - ]; - - $options = $this->getTransactionOptions($options); - $response = $this->client->find($name, $filters, $options); - $results = $response->cursor->firstBatch ?? []; - - // Process first batch - foreach ($results as $result) { - $sequences[$result->_uid] = (string)$result->_id; - } - - // Get cursor ID for subsequent batches - $cursorId = $response->cursor->id ?? null; + $collectionId = $collection->getId(); + $name = $this->getNamespace().'_'.$this->filter($collectionId); - // Continue fetching with getMore - while ($cursorId && $cursorId !== 0) { - $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE); - $moreResults = $moreResponse->cursor->nextBatch ?? []; - - if (empty($moreResults)) { - break; - } - - foreach ($moreResults as $result) { - $sequences[$result->_uid] = (string)$result->_id; - } - - // Update cursor ID for next iteration - $cursorId = (int)($moreResponse->cursor->id ?? 0); - } - } catch (MongoException $e) { - throw $this->processException($e); - } - - foreach ($documents as $document) { - if (isset($sequences[$document->getId()])) { - $document['$sequence'] = $sequences[$document->getId()]; - } + foreach ($sequences as $index => $sequence) { + $sequences[$index] = $sequence; } - return $documents; - } - - /** - * Increase or decrease an attribute value - * - * @param string $collection - * @param string $id - * @param string $attribute - * @param int|float $value - * @param string $updatedAt - * @param int|float|null $min - * @param int|float|null $max - * @return bool - * @throws DatabaseException - * @throws MongoException - * @throws Exception - */ - public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, string $updatedAt, int|float|null $min = null, int|float|null $max = null): bool - { - $attribute = $this->filter($attribute); - $filters = ['_uid' => $id]; - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); - } + /** @var array $filters */ + $filters = $this->buildFilters([new Query(Method::Equal, Storage::SEQUENCE, $sequences)]); + $filters = $this->applyTenantFilter($filters, $collectionId); - if ($max !== null || $min !== null) { - $filters[$attribute] = []; - if ($max !== null) { - $filters[$attribute]['$lte'] = $max; - } - if ($min !== null) { - $filters[$attribute]['$gte'] = $min; - } - } + $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); $options = $this->getTransactionOptions(); + try { - $this->client->update( - $this->getNamespace() . '_' . $this->filter($collection), - $filters, - [ - '$inc' => [$attribute => $value], - '$set' => ['_updatedAt' => $this->toMongoDatetime($updatedAt)], - ], + return $this->client->delete( + collection: $name, + filters: $filters, + limit: 0, options: $options ); } catch (MongoException $e) { throw $this->processException($e); } - - return true; } /** - * Delete Document - * - * @param string $collection - * @param string $id - * - * @return bool + * @throws DatabaseException + * @throws MongoException * @throws Exception */ - public function deleteDocument(string $collection, string $id): bool + #[\Override] + public function increaseDocumentAttribute(Document $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool { - $name = $this->getNamespace() . '_' . $this->filter($collection); + $collectionId = $collection->getId(); + $value = $this->normalizeAtomicNumber($value, 'value'); + $min = $min === null ? null : $this->normalizeAtomicNumber($min, 'minimum'); + $max = $max === null ? null : $this->normalizeAtomicNumber($max, 'maximum'); - $filters = []; - $filters['_uid'] = $id; + $attribute = $this->filter($attribute); + $current = ['$ifNull' => ['$'.$attribute, 0]]; + $filters = [Storage::UID => $id]; + $filters = $this->applyTenantFilter($filters, $collectionId); - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + $bounds = []; + if ($max !== null) { + $bounds[] = ['$lte' => [$current, $max]]; } - - $options = $this->getTransactionOptions(); - $result = $this->client->delete($name, $filters, 1, [], $options); - - return (!!$result); - } - - /** - * Delete Documents - * - * @param string $collection - * @param array $sequences - * @param array $permissionIds - * @return int - * @throws DatabaseException - */ - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int - { - $name = $this->getNamespace() . '_' . $this->filter($collection); - - foreach ($sequences as $index => $sequence) { - $sequences[$index] = $sequence; + if ($min !== null) { + $bounds[] = ['$gte' => [$current, $min]]; } - - $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_id', $sequences)]); - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection); + if ($bounds !== []) { + $filters['$expr'] = \count($bounds) === 1 ? $bounds[0] : ['$and' => $bounds]; } - $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - - $options = $this->getTransactionOptions(); + $pipeline = [['$set' => [ + $attribute => ['$add' => [$current, $value]], + Storage::UPDATED_AT => ['$literal' => $this->toMongoDatetime($updatedAt)], + ]]]; try { - return $this->client->delete( - collection: $name, - filters: $filters, - limit: 0, - options: $options + $this->updateWithPipeline( + $this->getNamespace().'_'.$this->filter($collectionId), + $filters, + $pipeline, + $this->getTransactionOptions(), ); } catch (MongoException $e) { throw $this->processException($e); } - } - /** - * Update Attribute. - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param string $newKey - * - * @return bool - */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool - { - if (!empty($newKey) && $newKey !== $id) { - return $this->renameAttribute($collection, $id, $newKey); - } return true; } - /** - * TODO Consider moving this to adapter.php - * @param string $attribute - * @return string - */ - protected function getInternalKeyForAttribute(string $attribute): string - { - return match ($attribute) { - '$id' => '_uid', - '$sequence' => '_id', - '$collection' => '_collection', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - '$deletedAt' => '_deletedAt', - '$permissions' => '_permissions', - default => $attribute - }; - } - - /** - * @return list - */ - private function permissionStrings(string $type): array + private function normalizeAtomicNumber(int|float|string $value, string $name): int|float { - $permissions = []; - foreach ($this->authorization->getRoles() as $role) { - $permissions[] = $type . '("' . $role . '")'; + if (! \is_string($value)) { + return $value; + } + if (! BigInt::fitsPhpInt($value)) { + throw new TypeException("MongoDB cannot safely apply {$name} outside the signed 64-bit integer range."); } - return $permissions; + return (int) $value; } /** - * Find Documents - * - * Find data sets using chosen queries - * - * @param Document $collection - * @param array $queries - * @param int|null $limit - * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor - * @param string $cursorDirection - * @param string $forPermission - * + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor * @return array + * * @throws Exception * @throws TimeoutException */ - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); $queries = array_map(fn ($query) => clone $query, $queries); // Escape query attribute names that contain dots and match collection attributes // (to distinguish from nested object paths like profile.level1.value) $this->escapeQueryAttributes($collection, $queries); + /** @var array $filters */ $filters = $this->buildFilters($queries); - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); - } - - // permissions - if ($this->authorization->getStatus()) { - $filters['_permissions']['$in'] = $this->permissionStrings($forPermission); - } + $filters = $this->applyReadFilters($filters, $collection->getId(), $forPermission); $options = []; - if (!\is_null($limit)) { + if (! \is_null($limit)) { $options['limit'] = $limit; } - if (!\is_null($offset)) { + if (! \is_null($offset)) { $options['skip'] = $offset; } @@ -2532,40 +2396,40 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 } $selections = $this->getAttributeSelections($queries); - $hasProjection = !empty($selections) && !\in_array('*', $selections); + $hasProjection = ! empty($selections) && ! \in_array('*', $selections); if ($hasProjection) { $options['projection'] = $this->getAttributeProjection($selections); } - // Add transaction context to options $options = $this->getTransactionOptions($options); $orFilters = []; + /** @var array $sortOptions */ + $sortOptions = []; foreach ($orderAttributes as $i => $originalAttribute) { $attribute = $this->getInternalKeyForAttribute($originalAttribute); $attribute = $this->filter($attribute); - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; $direction = $orderType; - /** Get sort direction ASC || DESC **/ - if ($cursorDirection === Database::CURSOR_BEFORE) { - $direction = ($direction === Database::ORDER_ASC) - ? Database::ORDER_DESC - : Database::ORDER_ASC; + if ($cursorDirection === CursorDirection::Before) { + $direction = ($direction === OrderDirection::Asc) + ? OrderDirection::Desc + : OrderDirection::Asc; } - $options['sort'][$attribute] = $this->getOrder($direction); + $sortOptions[$attribute] = $this->getOrder($direction); + $options['sort'] = $sortOptions; - /** Get operator sign '$lt' ? '$gt' **/ - $operator = $cursorDirection === Database::CURSOR_AFTER - ? ($orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER) - : ($orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER); + $operator = $cursorDirection === CursorDirection::After + ? ($orderType === OrderDirection::Desc ? Method::LessThan : Method::GreaterThan) + : ($orderType === OrderDirection::Desc ? Method::GreaterThan : Method::LessThan); $operator = $this->getQueryOperator($operator); - if (!empty($cursor)) { + if (! empty($cursor)) { $andConditions = []; for ($j = 0; $j < $i; $j++) { @@ -2573,17 +2437,16 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $prevAttr = $this->filter($this->getInternalKeyForAttribute($originalPrev)); $tmp = $cursor[$originalPrev]; $andConditions[] = [ - $prevAttr => $tmp + $prevAttr => $tmp, ]; } $tmp = $cursor[$originalAttribute]; - if ($originalAttribute === '$sequence') { - /** If there is only $sequence attribute in $orderAttributes skip Or And operators **/ + if ($originalAttribute === Document::SEQUENCE) { if (count($orderAttributes) === 1) { $filters[$attribute] = [ - $operator => $tmp + $operator => $tmp, ]; break; } @@ -2591,56 +2454,85 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $andConditions[] = [ $attribute => [ - $operator => $tmp - ] + $operator => $tmp, + ], ]; $orFilters[] = [ - '$and' => $andConditions + '$and' => $andConditions, ]; } } - if (!empty($orFilters)) { + if (! empty($orFilters)) { $filters['$or'] = $orFilters; } - // Translate operators and handle time filters + /** @var array $filters */ $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); $found = []; + /** @var int|null $cursorId */ $cursorId = null; try { - // Use proper cursor iteration with reasonable batch size $options['batchSize'] = self::DEFAULT_BATCH_SIZE; $response = $this->client->find($name, $filters, $options); - $results = $response->cursor->firstBatch ?? []; - // Process first batch + /** @var \stdClass $responseCursorFind */ + $responseCursorFind = $response->cursor; + /** @var array $results */ + $results = $responseCursorFind->firstBatch ?? []; foreach ($results as $result) { - $record = $this->replaceChars('_', '$', (array)$result); - $found[] = new Document($this->convertStdClassToArray($record)); + /** @var array $resultCast */ + $resultCast = (array) $result; + $record = $this->replaceCharacters('_', '$', $resultCast); + /** @var array $convertedRecord */ + $convertedRecord = $this->convertStdClassToArray($record); + $found[] = Document::fromStorage($convertedRecord); } - // Get cursor ID for subsequent batches - $cursorId = $response->cursor->id ?? null; + if (isset($responseCursorFind->id)) { + /** @var mixed $responseCursorFindId */ + $responseCursorFindId = $responseCursorFind->id; + $cursorId = \is_int($responseCursorFindId) ? $responseCursorFindId : (\is_scalar($responseCursorFindId) ? (int) $responseCursorFindId : null); + if ($cursorId === 0) { + $cursorId = null; + } + } else { + $cursorId = null; + } - // Continue fetching with getMore - while ($cursorId && $cursorId !== 0) { - $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE); - $moreResults = $moreResponse->cursor->nextBatch ?? []; + while ($cursorId !== null) { + $moreResponse = $this->client->getMore($cursorId, $name, self::DEFAULT_BATCH_SIZE); + /** @var \stdClass $moreCursorFind */ + $moreCursorFind = $moreResponse->cursor; + /** @var array $moreResults */ + $moreResults = $moreCursorFind->nextBatch ?? []; if (empty($moreResults)) { break; } foreach ($moreResults as $result) { - $record = $this->replaceChars('_', '$', (array)$result); - $found[] = new Document($this->convertStdClassToArray($record)); + /** @var array $resultCast */ + $resultCast = (array) $result; + $record = $this->replaceCharacters('_', '$', $resultCast); + /** @var array $convertedRecord */ + $convertedRecord = $this->convertStdClassToArray($record); + $found[] = Document::fromStorage($convertedRecord); } - $cursorId = (int)($moreResponse->cursor->id ?? 0); + if (isset($moreCursorFind->id)) { + /** @var mixed $moreCursorFindId */ + $moreCursorFindId = $moreCursorFind->id; + $cursorId = \is_int($moreCursorFindId) ? $moreCursorFindId : (\is_scalar($moreCursorFindId) ? (int) $moreCursorFindId : null); + if ($cursorId === 0) { + $cursorId = null; + } + } else { + $cursorId = null; + } } } catch (MongoException $e) { throw $this->processException($e); @@ -2650,20 +2542,20 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 try { $this->client->query([ 'killCursors' => $name, - 'cursors' => [(int)$cursorId] + 'cursors' => [$cursorId], ]); - } catch (\Exception $e) { + } catch (Exception $e) { // Ignore errors during cursor cleanup } } } - if ($cursorDirection === Database::CURSOR_BEFORE) { + if ($cursorDirection === CursorDirection::Before) { $found = array_reverse($found); } // Ensure missing relationship attributes are set to null (MongoDB doesn't store null fields) - if (!$hasProjection) { + if (! $hasProjection) { foreach ($found as $document) { $this->ensureRelationshipDefaults($collection, $document); } @@ -2672,110 +2564,35 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 return $found; } - - /** - * Converts Appwrite database type to MongoDB BSON type code. - * - * Numbers use the 'number' alias: an integer is stored as int32 or int64 - * depending on its value, and a float attribute can hold an integer. - * An unknown type (schemaless, internal attributes) matches every stored - * value type except null. - * - * @param string|null $appwriteType - * @return string|array - */ - private function getMongoTypeCode(?string $appwriteType): string|array - { - return match ($appwriteType) { - Database::VAR_STRING => 'string', - Database::VAR_VARCHAR => 'string', - Database::VAR_TEXT => 'string', - Database::VAR_MEDIUMTEXT => 'string', - Database::VAR_LONGTEXT => 'string', - Database::VAR_INTEGER => 'number', - Database::VAR_BIGINT => 'number', - Database::VAR_FLOAT => 'number', - Database::VAR_BOOLEAN => 'bool', - Database::VAR_DATETIME => 'date', - Database::VAR_ID => 'string', - Database::VAR_UUID7 => 'string', - null => ['string', 'number', 'bool', 'date', 'object'], - default => 'string' - }; - } - - /** - * Converts timestamp to Mongo\BSON datetime format. - * - * @param string $dt - * @return UTCDateTime - * @throws Exception - */ - private function toMongoDatetime(string $dt): UTCDateTime - { - return new UTCDateTime(new \DateTime($dt)); - } - - /** - * Recursive function to replace chars in array keys, while - * skipping any that are explicitly excluded. - * - * @param array $array - * @param string $from - * @param string $to - * @param array $exclude - * @return array - */ - private function replaceInternalIdsKeys(array $array, string $from, string $to, array $exclude = []): array - { - $result = []; - - foreach ($array as $key => $value) { - if (!in_array($key, $exclude)) { - $key = str_replace($from, $to, $key); - } - - $result[$key] = is_array($value) - ? $this->replaceInternalIdsKeys($value, $from, $to, $exclude) - : $value; - } - - return $result; - } - - /** - * Count Documents + * @param array $queries * - * @param Document $collection - * @param array $queries - * @param int|null $max - * @return int * @throws Exception */ + #[\Override] public function count(Document $collection, array $queries = [], ?int $max = null): int { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); $queries = array_map(fn ($query) => clone $query, $queries); - // Escape query attribute names that contain dots and match collection attributes $this->escapeQueryAttributes($collection, $queries); $filters = []; + $options = []; - // Build filters from queries - $filters = $this->buildFilters($queries); - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); + if (! \is_null($max) && $max > 0) { + $options['limit'] = $max; } - // Add permissions filter if authorization is enabled - if ($this->authorization->getStatus()) { - $filters['_permissions']['$in'] = $this->permissionStrings(Database::PERMISSION_READ); + if ($this->timeout) { + $options['maxTimeMS'] = $this->timeout; } + /** @var array $filters */ + $filters = $this->buildFilters($queries); + $filters = $this->applyReadFilters($filters, $collection->getId(), PermissionType::Read); + /** * Use MongoDB aggregation pipeline for accurate counting * Accuracy and Sharded Clusters @@ -2784,7 +2601,6 @@ public function count(Document $collection, array $queries = [], ?int $max = nul * To avoid these situations, on a sharded cluster, use the db.collection.aggregate() method" * https://www.mongodb.com/docs/manual/reference/command/count/#response **/ - $options = $this->getTransactionOptions(); if ($this->timeout) { @@ -2793,30 +2609,27 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $pipeline = []; - // Add match stage if filters are provided - if (!empty($filters)) { + if (! empty($filters)) { $pipeline[] = ['$match' => $this->client->toObject($filters)]; } - // Add limit stage if specified - if (!\is_null($max) && $max > 0) { + if (! \is_null($max) && $max > 0) { $pipeline[] = ['$limit' => $max]; } // Use $group and $sum when limit is specified, $count when no limit // Note: $count stage doesn't works well with $limit in the same pipeline // When limit is specified, we need to use $group + $sum to count the limited documents - if (!\is_null($max) && $max > 0) { - // When limit is specified, use $group and $sum to count limited documents + if (! \is_null($max) && $max > 0) { $pipeline[] = [ '$group' => [ - '_id' => null, - 'total' => ['$sum' => 1]] + Storage::SEQUENCE => null, + 'total' => ['$sum' => 1]], ]; } else { // When no limit is passed, use $count for better performance $pipeline[] = [ - '$count' => 'total' + '$count' => 'total', ]; } @@ -2825,75 +2638,59 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $result = $this->client->aggregate($name, $pipeline, $options); // Aggregation returns stdClass with cursor property containing firstBatch - if (isset($result->cursor) && !empty($result->cursor->firstBatch)) { - $firstResult = $result->cursor->firstBatch[0]; - - // Handle both $count and $group response formats - if (isset($firstResult->total)) { - return (int)$firstResult->total; + if (isset($result->cursor)) { + /** @var \stdClass $aggCursor */ + $aggCursor = $result->cursor; + if (! empty($aggCursor->firstBatch)) { + /** @var array $aggFirstBatch */ + $aggFirstBatch = $aggCursor->firstBatch; + /** @var \stdClass $firstResult */ + $firstResult = $aggFirstBatch[0]; + + // Handle both $count and $group response formats + if (isset($firstResult->total)) { + /** @var mixed $totalVal */ + $totalVal = $firstResult->total; + return \is_int($totalVal) ? $totalVal : (\is_numeric($totalVal) ? (int) $totalVal : 0); + } } } return 0; } catch (MongoException $e) { - $processed = $this->processException($e); - if ($processed instanceof TimeoutException) { - throw $processed; - } - - return 0; + throw $this->processException($e); } } - /** - * Sum an attribute - * - * @param Document $collection - * @param string $attribute - * @param array $queries - * @param int|null $max + * @param array $queries * - * @return int|float * @throws Exception */ - + #[\Override] public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); - // queries $queries = array_map(fn ($query) => clone $query, $queries); - $filters = $this->buildFilters($queries); - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); - } + $this->escapeQueryAttributes($collection, $queries); + $field = $this->getEscapedAttributes($collection)[$attribute] ?? $attribute; - // permissions - if ($this->authorization->getStatus()) { // skip if authorization is disabled - $filters['_permissions']['$in'] = $this->permissionStrings(Database::PERMISSION_READ); - } + /** @var array $filters */ + $filters = $this->buildFilters($queries); + $filters = $this->applyReadFilters($filters, $collection->getId(), PermissionType::Read); - // using aggregation to get sum an attribute as described in - // https://docs.mongodb.com/manual/reference/method/db.collection.aggregate/ - // Pipeline consists of stages to aggregation, so first we set $match - // that will load only documents that matches the filters provided and passes to the next stage - // then we set $limit (if $max is provided) so that only $max documents will be passed to the next stage - // finally we use $group stage to sum the provided attribute that matches the given filters and max - // We pass the $pipeline to the aggregate method, which returns a cursor, then we get - // the array of results from the cursor, and we return the total sum of the attribute $pipeline = []; - if (!empty($filters)) { + if (! empty($filters)) { $pipeline[] = ['$match' => $filters]; } - if (!empty($max)) { + if (! empty($max)) { $pipeline[] = ['$limit' => $max]; } $pipeline[] = [ '$group' => [ - '_id' => null, - 'total' => ['$sum' => '$' . $attribute], + Storage::SEQUENCE => null, + 'total' => ['$sum' => '$'.$field], ], ]; @@ -2904,1091 +2701,1076 @@ public function sum(Document $collection, string $attribute, array $queries = [] } try { - return $this->client->aggregate($name, $pipeline, $options)->cursor->firstBatch[0]->total ?? 0; + $sumResult = $this->client->aggregate($name, $pipeline, $options); + /** @var \stdClass $sumCursor */ + $sumCursor = $sumResult->cursor; + /** @var array $sumFirstBatch */ + $sumFirstBatch = $sumCursor->firstBatch; + if (empty($sumFirstBatch)) { + return 0; + } + /** @var \stdClass $sumFirstResult */ + $sumFirstResult = $sumFirstBatch[0]; + if (! isset($sumFirstResult->total)) { + return 0; + } + /** @var mixed $sumTotal */ + $sumTotal = $sumFirstResult->total; + if (\is_int($sumTotal) || \is_float($sumTotal)) { + return $sumTotal; + } + + return \is_numeric($sumTotal) ? (int) $sumTotal : 0; } catch (MongoException $e) { throw $this->processException($e); } } /** - * @return Client - * - * @throws Exception - */ - protected function getClient(): Client - { - return $this->client; - } - - /** - * Escape a field name for MongoDB storage. - * MongoDB field names cannot start with $ or contain dots. + * @param array $documents + * @return array * - * @param string $name - * @return string + * @throws DatabaseException + * @throws MongoException */ - protected function escapeMongoFieldName(string $name): string + #[\Override] + public function getSequences(Document $collection, array $documents): array { - if (\str_starts_with($name, '$')) { - $name = '_' . \substr($name, 1); - } - if (\str_contains($name, '.')) { - $name = \str_replace('.', '__dot__', $name); - } - return $name; - } + $collectionId = $collection->getId(); + $documentIds = []; + $documentTenants = []; + foreach ($documents as $document) { + if (empty($document->getSequence())) { + $documentIds[] = $document->getId(); - /** - * Escape query attribute names that contain dots and match known collection attributes. - * This distinguishes field names with dots (like 'collectionSecurity.Parent') from - * nested object paths (like 'profile.level1.value'). - * - * @param Document $collection - * @param array $queries - */ - protected function escapeQueryAttributes(Document $collection, array $queries): void - { - $attributes = $collection->getAttribute('attributes', []); - $dotAttributes = []; - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - if (\str_contains($key, '.') || \str_starts_with($key, '$')) { - $dotAttributes[$key] = $this->escapeMongoFieldName($key); + if ($this->sharedTables) { + $documentTenants[] = $document->getTenant() ?? $this->getTenant(); + } } } - if (empty($dotAttributes)) { - return; + if (empty($documentIds)) { + return $documents; } - foreach ($queries as $query) { - $attr = $query->getAttribute(); - if (isset($dotAttributes[$attr])) { - $query->setAttribute($dotAttributes[$attr]); - } - } - } + $sequences = []; + $name = $this->getNamespace().'_'.$this->filter($collectionId); - /** - * Ensure relationship attributes have default null values in MongoDB documents. - * MongoDB doesn't store null fields, so we need to add them for schema compatibility. - * - * @param Document $collection - * @param Document $document - */ - protected function ensureRelationshipDefaults(Document $collection, Document $document): void - { - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - if ($type === Database::VAR_RELATIONSHIP && !$document->offsetExists($key)) { - $options = $attribute['options'] ?? []; - $twoWay = $options['twoWay'] ?? false; - $side = $options['side'] ?? ''; - $relationType = $options['relationType'] ?? ''; - - // Determine if this relationship stores data on this collection's documents - // Only set null defaults for relationships that would have a column in SQL - $storesData = match ($relationType) { - Database::RELATION_ONE_TO_ONE => $side === Database::RELATION_SIDE_PARENT || $twoWay, - Database::RELATION_ONE_TO_MANY => $side === Database::RELATION_SIDE_CHILD, - Database::RELATION_MANY_TO_ONE => $side === Database::RELATION_SIDE_PARENT, - Database::RELATION_MANY_TO_MANY => false, - default => false, - }; + $filters = [Storage::UID => ['$in' => \array_values(\array_unique($documentIds))]]; - if ($storesData) { - $document->setAttribute($key, null); - } - } + if ($this->sharedTables) { + $filters[Storage::TENANT] = $this->getTenantFilters($collectionId, \array_values(\array_unique($documentTenants))); } - } - - /** - * Keys cannot begin with $ in MongoDB - * Convert $ prefix to _ on $id, $permissions, and $collection - * - * @param string $from - * @param string $to - * @param array $array - * @return array - */ - protected function replaceChars(string $from, string $to, array $array): array - { - $filter = [ - 'permissions', - 'createdAt', - 'updatedAt', - 'collection' - ]; - - // First pass: recursively process array values and collect keys to rename - $keysToRename = []; - foreach ($array as $k => $v) { - if (is_array($v)) { - $array[$k] = $this->replaceChars($from, $to, $v); - } - - $newKey = $k; + try { + $options = [ + 'projection' => [Storage::UID => 1, Storage::SEQUENCE => 1, Storage::TENANT => 1], + 'batchSize' => self::DEFAULT_BATCH_SIZE, + ]; - // Handle key replacement for filtered attributes - $clean_key = str_replace($from, "", $k); - if (in_array($clean_key, $filter)) { - $newKey = str_replace($from, $to, $k); - } elseif (\is_string($k) && \str_starts_with($k, $from) && !in_array($k, ['$id', '$sequence', '$tenant', '_uid', '_id', '_tenant'])) { - // Handle any other key starting with the 'from' char (e.g. user-defined $-prefixed keys) - $newKey = $to . \substr($k, \strlen($from)); + $options = $this->getTransactionOptions($options); + $response = $this->client->find($name, $filters, $options); + /** @var \stdClass $responseCursor */ + $responseCursor = $response->cursor; + /** @var array<\stdClass> $results */ + $results = $responseCursor->firstBatch ?? []; + + $this->collectSequences($results, $sequences); + + /** @var int|null $cursorId */ + $cursorId = null; + if (isset($responseCursor->id)) { + /** @var mixed $rcId */ + $rcId = $responseCursor->id; + $cursorId = \is_int($rcId) ? $rcId : (\is_scalar($rcId) ? (int) $rcId : null); + if ($cursorId === 0) { + $cursorId = null; + } } - // Handle dot escaping in MongoDB field names - if ($from === '$' && \is_string($k) && \str_contains($newKey, '.')) { - $newKey = \str_replace('.', '__dot__', $newKey); - } elseif ($from === '_' && \is_string($k) && \str_contains($k, '__dot__')) { - $newKey = \str_replace('__dot__', '.', $newKey); - } + while ($cursorId !== null) { + $moreResponse = $this->client->getMore($cursorId, $name, self::DEFAULT_BATCH_SIZE); + /** @var \stdClass $moreCursor */ + $moreCursor = $moreResponse->cursor; + /** @var array<\stdClass> $moreResults */ + $moreResults = $moreCursor->nextBatch ?? []; - if ($newKey !== $k) { - $keysToRename[$k] = $newKey; - } - } + if (empty($moreResults)) { + break; + } - foreach ($keysToRename as $oldKey => $newKey) { - $array[$newKey] = $array[$oldKey]; - unset($array[$oldKey]); - } + $this->collectSequences($moreResults, $sequences); - // Handle special attribute mappings - if ($from === '_') { - if (isset($array['_id'])) { - $array['$sequence'] = (string)$array['_id']; - unset($array['_id']); - } - if (isset($array['_uid'])) { - $array['$id'] = $array['_uid']; - unset($array['_uid']); - } - if (isset($array['_tenant'])) { - $array['$tenant'] = $array['_tenant']; - unset($array['_tenant']); - } - } elseif ($from === '$') { - if (isset($array['$id'])) { - $array['_uid'] = $array['$id']; - unset($array['$id']); - } - if (isset($array['$sequence'])) { - $array['_id'] = $array['$sequence']; - unset($array['$sequence']); - } - if (isset($array['$tenant'])) { - $array['_tenant'] = $array['$tenant']; - unset($array['$tenant']); + if (isset($moreCursor->id)) { + /** @var mixed $moreCursorIdVal */ + $moreCursorIdVal = $moreCursor->id; + $cursorId = \is_int($moreCursorIdVal) ? $moreCursorIdVal : (\is_scalar($moreCursorIdVal) ? (int) $moreCursorIdVal : null); + if ($cursorId === 0) { + $cursorId = null; + } + } else { + $cursorId = null; + } } + } catch (MongoException $e) { + throw $this->processException($e); } - return $array; - } - - /** - * @param array $queries - * @param string $separator - * @return array - * @throws Exception - */ - protected function buildFilters(array $queries, string $separator = '$and'): array - { - $filters = []; - $queries = Query::groupByType($queries)['filters']; - - foreach ($queries as $query) { - /* @var $query Query */ - if ($query->isNested()) { - if ($query->getMethod() === Query::TYPE_ELEM_MATCH) { - $filters[$separator][] = [ - $query->getAttribute() => [ - '$elemMatch' => $this->buildFilters($query->getValues(), $separator) - ] - ]; - continue; - } - - $operator = $this->getQueryOperator($query->getMethod()); - - $filters[$separator][] = $this->buildFilters($query->getValues(), $operator); - } else { - $filters[$separator][] = $this->buildFilter($query); + foreach ($documents as $document) { + $tenant = $this->sharedTables ? ($document->getTenant() ?? $this->getTenant()) : null; + $key = $this->sequenceKey($tenant, $document->getId()); + if (isset($sequences[$key])) { + $document[Document::SEQUENCE] = $sequences[$key]; } } - return $filters; + return $documents; } /** - * @param Query $query - * @return array - * @throws Exception + * Which of the given `_id`s are stored. An upsert that matched a stored document leaves the `_id` it would have + * inserted absent, so reading them back after the upserts tells which documents they inserted. + * + * @param list $sequences + * @param array $options + * @return array + * + * @throws DatabaseException */ - protected function buildFilter(Query $query): array + private function storedSequences(string $name, array $sequences, array $options): array { - // Normalize extended ISO 8601 datetime strings in query values to UTCDateTime - // so they can be correctly compared against datetime fields stored in MongoDB. - if (!$this->getSupportForAttributes() || \in_array($query->getAttribute(), ['$createdAt', '$updatedAt'], true)) { - $values = $query->getValues(); - foreach ($values as $k => $value) { - if (is_string($value) && $this->isExtendedISODatetime($value)) { - try { - $values[$k] = $this->toMongoDatetime($value); - } catch (\Throwable $th) { - // Leave value as-is if it cannot be parsed as a datetime - } - } - } - $query->setValues($values); - } - - if ($query->getAttribute() === '$id') { - $query->setAttribute('_uid'); - } elseif ($query->getAttribute() === '$sequence') { - $query->setAttribute('_id'); - $values = $query->getValues(); - foreach ($values as $k => $v) { - $values[$k] = $v; - } - $query->setValues($values); - } elseif ($query->getAttribute() === '$createdAt') { - $query->setAttribute('_createdAt'); - } elseif ($query->getAttribute() === '$updatedAt') { - $query->setAttribute('_updatedAt'); - } elseif (\str_starts_with($query->getAttribute(), '$')) { - // Escape $ prefix and dots in user-defined $-prefixed attribute names for MongoDB - $query->setAttribute($this->escapeMongoFieldName($query->getAttribute())); + try { + $response = $this->client->find($name, [Storage::SEQUENCE => ['$in' => $sequences]], \array_merge($options, [ + 'projection' => [Storage::SEQUENCE => 1], + 'batchSize' => \count($sequences) + 1, + 'singleBatch' => true, + ])); + } catch (MongoException $e) { + throw $this->processException($e); } - $attribute = $query->getAttribute(); - $operator = $this->getQueryOperator($query->getMethod()); + /** @var \stdClass $cursor */ + $cursor = $response->cursor; + /** @var array<\stdClass> $rows */ + $rows = $cursor->firstBatch ?? []; - $value = match ($query->getMethod()) { - Query::TYPE_IS_NULL, - Query::TYPE_IS_NOT_NULL => null, - Query::TYPE_EXISTS => true, - Query::TYPE_NOT_EXISTS => false, - default => $this->getQueryValue( - $query->getMethod(), - count($query->getValues()) > 1 - ? $query->getValues() - : $query->getValues()[0] - ), - }; - - $filter = []; - if ($query->isObjectAttribute() && !\str_contains($attribute, '.') && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) { - $this->handleObjectFilters($query, $filter); - return $filter; + $stored = []; + foreach ($rows as $row) { + $stored[$this->stringifyIdentifier($row->{Storage::SEQUENCE} ?? null)] = true; } - if ($operator == '$eq' && \is_array($value)) { - $filter[$attribute]['$in'] = $value; - } elseif ($operator == '$ne' && \is_array($value)) { - $filter[$attribute]['$nin'] = $value; - } elseif ($operator == '$all') { - $filter[$attribute]['$all'] = $query->getValues(); - } elseif ($operator == '$in') { - if (in_array($query->getMethod(), [Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY]) && !$query->onArray()) { - // contains support array values - if (is_array($value)) { - $filter['$or'] = array_map(function ($val) use ($attribute) { - return [ - $attribute => [ - '$regex' => $this->createSafeRegex($val, '.*%s.*', 'i') - ] - ]; - }, $value); - } else { - $filter[$attribute]['$regex'] = $this->createSafeRegex($value, '.*%s.*'); - } - } else { - $filter[$attribute]['$in'] = $query->getValues(); - } - } elseif ($operator === 'notContains') { - if (!$query->onArray()) { - $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '.*%s.*')]; - } else { - $filter[$attribute]['$nin'] = $query->getValues(); - } - } elseif ($operator == '$search') { - if ($query->getMethod() === Query::TYPE_NOT_SEARCH) { - // MongoDB doesn't support negating $text expressions directly - // Use regex as fallback for NOT search while keeping fulltext for positive search - if (empty($value)) { - // If value is not passed, don't add any filter - this will match all documents - } else { - $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '.*%s.*')]; - } - } else { - $filter['$text'][$operator] = $value; - } - } elseif ($operator === Query::TYPE_BETWEEN) { - $filter[$attribute]['$lte'] = $value[1]; - $filter[$attribute]['$gte'] = $value[0]; - } elseif ($operator === Query::TYPE_NOT_BETWEEN) { - $filter['$or'] = [ - [$attribute => ['$lt' => $value[0]]], - [$attribute => ['$gt' => $value[1]]] - ]; - } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_STARTS_WITH) { - $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '^%s')]; - } elseif ($operator === '$regex' && $query->getMethod() === Query::TYPE_NOT_ENDS_WITH) { - $filter[$attribute] = ['$not' => $this->createSafeRegex($value, '%s$')]; - } elseif ($operator === '$exists') { - foreach ($query->getValues() as $attribute) { - $filter['$or'][] = [$attribute => [$operator => $value]]; - } - } else { - $filter[$attribute][$operator] = $value; - } - - return $filter; + return $stored; } /** - * @param Query $query - * @param array $filter - * @return void + * @param array<\stdClass> $rows + * @param array $sequences */ - private function handleObjectFilters(Query $query, array &$filter): void + private function collectSequences(array $rows, array &$sequences): void { - $conditions = []; - $isNot = in_array($query->getMethod(), [Query::TYPE_NOT_CONTAINS,Query::TYPE_NOT_EQUAL]); - $values = $query->getValues(); - foreach ($values as $attribute => $value) { - $flattendQuery = $this->flattenWithDotNotation(is_string($attribute) ? $attribute : '', $value); - $flattenedObjectKey = array_key_first($flattendQuery); - $queryValue = $flattendQuery[$flattenedObjectKey]; - $queryAttribute = $query->getAttribute(); - $flattenedQueryField = array_key_first($flattendQuery); - $flattenedObjectKey = $flattenedQueryField === '' ? $queryAttribute : $queryAttribute . '.' . array_key_first($flattendQuery); - switch ($query->getMethod()) { - - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: - case Query::TYPE_CONTAINS_ALL: - case Query::TYPE_NOT_CONTAINS: { - $arrayValue = \is_array($queryValue) ? $queryValue : [$queryValue]; - $operator = $isNot ? '$nin' : '$in'; - $conditions[] = [ $flattenedObjectKey => [ $operator => $arrayValue] ]; - break; - } - - case Query::TYPE_EQUAL: - case Query::TYPE_NOT_EQUAL: { - if (\is_array($queryValue)) { - $operator = $isNot ? '$nin' : '$in'; - $conditions[] = [ $flattenedObjectKey => [ $operator => $queryValue] ]; - } else { - $operator = $isNot ? '$ne' : '$eq'; - $conditions[] = [ $flattenedObjectKey => [ $operator => $queryValue] ]; - } - - break; - } - } - } - - $logicalOperator = $isNot ? '$and' : '$or'; - if (count($conditions) && isset($filter[$logicalOperator])) { - $filter[$logicalOperator] = array_merge($filter[$logicalOperator], $conditions); - } else { - $filter[$logicalOperator] = $conditions; + foreach ($rows as $row) { + $tenant = $this->sharedTables ? ($row->{Storage::TENANT} ?? null) : null; + $key = $this->sequenceKey($tenant, $this->stringifyIdentifier($row->{Storage::UID} ?? null)); + $sequences[$key] = $this->stringifyIdentifier($row->{Storage::SEQUENCE} ?? null); } } /** - * Flatten a nested associative array into Mongo-style dot notation. - * - * @param string $key - * @param mixed $value - * @param string $prefix - * @return array + * `_uid` is unique only per tenant under shared tables, so a batch spanning tenants + * must match each row back to the document of the same tenant. */ - private function flattenWithDotNotation(string $key, mixed $value, string $prefix = ''): array + private function sequenceKey(mixed $tenant, string $id): string { - /** @var array $result */ - $result = []; - - $stack = []; - - $initialKey = $prefix === '' ? $key : $prefix . '.' . $key; - $stack[] = [$initialKey, $value]; - while (!empty($stack)) { - [$currentPath, $currentValue] = array_pop($stack); - if (is_array($currentValue) && !array_is_list($currentValue)) { - foreach ($currentValue as $nextKey => $nextValue) { - $nextKey = (string)$nextKey; - $nextPath = $currentPath === '' ? $nextKey : $currentPath . '.' . $nextKey; - $stack[] = [$nextPath, $nextValue]; - } - } else { - // leaf node - $result[$currentPath] = $currentValue; - } - } + $tenant = $tenant === null ? '' : $this->stringifyIdentifier($tenant); - return $result; + return $tenant."\0".$id; } /** - * Get Query Operator - * - * @param string $operator - * - * @return string - * @throws Exception + * Collections hold any number of attributes and documents of any width, so both caps are 0. */ - protected function getQueryOperator(string $operator): string - { - return match ($operator) { - Query::TYPE_EQUAL, - Query::TYPE_IS_NULL => '$eq', - Query::TYPE_NOT_EQUAL, - Query::TYPE_IS_NOT_NULL => '$ne', - Query::TYPE_LESSER => '$lt', - Query::TYPE_LESSER_EQUAL => '$lte', - Query::TYPE_GREATER => '$gt', - Query::TYPE_GREATER_EQUAL => '$gte', - Query::TYPE_CONTAINS => '$in', - Query::TYPE_CONTAINS_ANY => '$in', - Query::TYPE_CONTAINS_ALL => '$all', - Query::TYPE_NOT_CONTAINS => 'notContains', - Query::TYPE_SEARCH => '$search', - Query::TYPE_NOT_SEARCH => '$search', - Query::TYPE_BETWEEN => 'between', - Query::TYPE_NOT_BETWEEN => 'notBetween', - Query::TYPE_STARTS_WITH, - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_REGEX => '$regex', - Query::TYPE_OR => '$or', - Query::TYPE_AND => '$and', - Query::TYPE_EXISTS, - Query::TYPE_NOT_EXISTS => '$exists', - Query::TYPE_ELEM_MATCH => '$elemMatch', - default => throw new DatabaseException('Unknown operator:' . $operator . '. Must be one of ' . Query::TYPE_EQUAL . ', ' . Query::TYPE_NOT_EQUAL . ', ' . Query::TYPE_LESSER . ', ' . Query::TYPE_LESSER_EQUAL . ', ' . Query::TYPE_GREATER . ', ' . Query::TYPE_GREATER_EQUAL . ', ' . Query::TYPE_IS_NULL . ', ' . Query::TYPE_IS_NOT_NULL . ', ' . Query::TYPE_BETWEEN . ', ' . Query::TYPE_NOT_BETWEEN . ', ' . Query::TYPE_STARTS_WITH . ', ' . Query::TYPE_NOT_STARTS_WITH . ', ' . Query::TYPE_ENDS_WITH . ', ' . Query::TYPE_NOT_ENDS_WITH . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_NOT_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_NOT_SEARCH . ', ' . Query::TYPE_SELECT), - }; + #[\Override] + public function limits(): Limits + { + return $this->limits ??= new Limits( + string: 2147483647, + varchar: 2147483647, + integer: 4294967295, + bigInteger: Database::MAX_BIG_INT, + attributes: 0, + indexes: 64, + defaultAttributes: \count(Database::internalAttributesFor(true)), + defaultIndexes: \count(Database::INTERNAL_INDEXES), + indexLength: 1024, + uidLength: 255, + documentSize: 0, + minDateTime: new NativeDateTime('-9999-01-01 00:00:00'), + maxDateTime: new NativeDateTime(self::MAX_DATETIME), + idType: ColumnType::Uuid7, + keywords: [], + internalIndexKeys: [], + ); } - protected function getQueryValue(string $method, mixed $value): mixed + #[\Override] + public function getCountOfAttributes(Document $collection): int { - switch ($method) { - case Query::TYPE_STARTS_WITH: - $value = preg_quote($value, '/'); - return $value . '.*'; - case Query::TYPE_NOT_STARTS_WITH: - return $value; - case Query::TYPE_ENDS_WITH: - $value = preg_quote($value, '/'); - return '.*' . $value; - case Query::TYPE_NOT_ENDS_WITH: - return $value; - default: - return $value; - } + return \count(self::collectionAttributes($collection)) + $this->limits()->defaultAttributes; } - /** - * Get Mongo Order - * - * @param string $order - * - * @return int - * @throws Exception - */ - protected function getOrder(string $order): int + #[\Override] + public function getCountOfIndexes(Document $collection): int { - return match (\strtoupper($order)) { - Database::ORDER_ASC => 1, - Database::ORDER_DESC => -1, - default => throw new DatabaseException('Unknown sort order:' . $order . '. Must be one of ' . Database::ORDER_ASC . ', ' . Database::ORDER_DESC), - }; + return \count(self::collectionIndexes($collection)) + $this->limits()->defaultIndexes; } /** - * Check if tenant should be added to index - * - * @param Document|string $indexOrType Index document or index type string - * @return bool + * Estimate maximum number of bytes required to store a document in $collection. + * Byte requirement varies based on column type and size. + * Needed to satisfy MariaDB/MySQL row width limit. + * Return 0 when no restrictions apply to row width */ - protected function shouldAddTenantToIndex(Document|string $indexOrType): bool + #[\Override] + public function getAttributeWidth(Document $collection): int { - if (!$this->sharedTables) { - return false; - } - - $indexType = $indexOrType instanceof Document - ? $indexOrType->getAttribute('type') - : $indexOrType; - - return $indexType !== Database::INDEX_TTL; + return 0; } /** - * @param array $selections - * @param string $prefix - * @return mixed + * @throws DatabaseException */ - protected function getAttributeProjection(array $selections, string $prefix = ''): mixed + #[\Override] + public function getSizeOfCollection(string $collection): int { - $projection = []; + $namespace = $this->getNamespace(); + $collection = $this->filter($collection); + $collection = $namespace.'_'.$collection; - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - Database::INTERNAL_ATTRIBUTES - ); + $command = [ + 'collStats' => $collection, + 'scale' => 1, + ]; - foreach ($selections as $selection) { - // Skip internal attributes since all are selected by default - if (\in_array($selection, $internalKeys)) { - continue; + try { + /** @var \stdClass $result */ + $result = $this->getClient()->query($command); + if (isset($result->totalSize)) { + /** @var mixed $totalSizeVal */ + $totalSizeVal = $result->totalSize; + return \is_int($totalSizeVal) ? $totalSizeVal : (\is_numeric($totalSizeVal) ? (int) $totalSizeVal : 0); + } else { + throw new DatabaseException('No size found'); } - - $projection[$selection] = 1; + } catch (Exception $e) { + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } - - $projection['_uid'] = 1; - $projection['_id'] = 1; - $projection['_createdAt'] = 1; - $projection['_updatedAt'] = 1; - $projection['_permissions'] = 1; - - return $projection; } /** - * Get max STRING limit - * - * @return int + * @throws DatabaseException */ - public function getLimitForString(): int + #[\Override] + public function getSizeOfCollectionOnDisk(string $collection): int { - return 2147483647; + return $this->getSizeOfCollection($collection); } /** - * Get max VARCHAR limit - * MongoDB doesn't distinguish between string types, so using same as string limit - * - * @return int + * @param array $tenants + * @return int|string|null|array> */ - public function getMaxVarcharLength(): int - { - return 2147483647; - } + protected function getTenantFilters( + string $collection, + array $tenants = [], + ): int|string|null|array { + if (! $this->sharedTables) { + return null; + } - /** - * Get max INT limit - * - * @return int - */ - public function getLimitForInt(): int - { - // Mongo does not handle integers directly, so using MariaDB limit for now - return 4294967295; - } + /** @var array $values */ + $values = []; - /** - * Get max BIGINT limit - * - * @return int - */ - public function getLimitForBigInt(): int - { - return Database::MAX_BIG_INT; - } + if (\count($tenants) === 0) { + $tenant = $this->getTenant(); + if ($tenant !== null) { + $values[] = $tenant; + } + } else { + for ($index = 0; $index < \count($tenants); $index++) { + $values[] = $tenants[$index]; + } + } - /** - * Get maximum column limit. - * Returns 0 to indicate no limit - * - * @return int - */ - public function getLimitForAttributes(): int - { - return 0; - } + if ($collection === Database::METADATA && !empty($values)) { + // Include both tenant-specific and tenant-null documents for metadata collections + // by returning the $in filter which covers tenant documents + // (null tenant docs are accessible to all tenants for metadata) + return ['$in' => [...$values, null]]; + } - /** - * Get maximum index limit. - * https://docs.mongodb.com/manual/reference/limits/#mongodb-limit-Number-of-Indexes-per-Collection - * - * @return int - */ - public function getLimitForIndexes(): int - { - return 64; - } + if (empty($values)) { + return null; + } - public function getMinDateTime(): \DateTime - { - return new \DateTime('-9999-01-01 00:00:00'); - } + if (\count($values) === 1) { + return $values[0]; + } - /** - * Is schemas supported? - * - * @return bool - */ - public function getSupportForSchemas(): bool - { - return false; + return ['$in' => $values]; } /** - * Is index supported? - * - * @return bool + * @throws Exception */ - public function getSupportForIndex(): bool + #[\Override] + public function castBefore(Document $collection, Document $document): Document { - return true; - } + if ($document->isEmpty()) { + return $document; + } - public function getSupportForIndexArray(): bool - { - return true; - } + foreach (self::collectionAttributesWithInternal($collection) as $attribute) { + $key = $attribute->key; + $type = $attribute->type; + $array = $attribute->array; - /** - * Is internal casting supported? - * - * @return bool - */ - public function getSupportForInternalCasting(): bool - { - return true; - } + $value = $document->getAttribute($key); + if (is_null($value)) { + continue; + } - public function getSupportForUTCCasting(): bool - { - return true; - } + if (Operator::isOperator($value)) { + if ($attribute->isInteger()) { + /** @var Operator $value */ + $values = $value->getValues(); + foreach ($values as $index => $operand) { + if (! \is_string($operand) || ! BigInt::isIntegerString($operand)) { + continue; + } + if (! BigInt::fitsPhpInt($operand)) { + throw new TypeException('MongoDB cannot safely apply an integer operator outside the signed 64-bit range.'); + } + $values[$index] = (int) $operand; + } + $value->setValues($values); + } + continue; + } - public function setUTCDatetime(string $value): mixed - { - return new UTCDateTime(new \DateTime($value)); - } + if ($array) { + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new DatabaseException('Failed to decode JSON for attribute '.$key.': '.json_last_error_msg()); + } + $value = $decoded; + } + if (!\is_array($value)) { + $value = [$value]; + } + } else { + $value = [$value]; + } + /** @var array $value */ + foreach ($value as $index => $node) { + switch ($type) { + case ColumnType::Datetime: + if (! ($node instanceof UTCDateTime)) { + /** @var mixed $node */ + $nodeStr = \is_string($node) ? $node : (\is_scalar($node) ? (string) $node : ''); + if (\is_numeric($nodeStr)) { + $node = new UTCDateTime((int) $nodeStr); + } else { + $node = new UTCDateTime(new NativeDateTime($nodeStr)); + } + } + break; + case ColumnType::Object: + /** @var mixed $node */ + $nodeStr = \is_string($node) ? $node : (\is_scalar($node) ? (string) $node : ''); + $node = json_decode($nodeStr); + break; + default: + break; + } + $value[$index] = $node; + } + $document->setAttribute($key, ($array) ? $value : $value[0]); + } - /** - * Are attributes supported? - * - * @return bool - */ - public function getSupportForAttributes(): bool - { - return $this->supportForAttributes; - } + if (! $this->supports(Capability::DefinedAttributes)) { + foreach ($document->getArrayCopy() as $key => $value) { + $key = (string) $key; + if (in_array($this->getInternalKeyForAttribute($key), Database::INTERNAL_ATTRIBUTE_KEYS)) { + continue; + } + if (is_string($value) && $this->isExtendedIsoDatetime($value)) { + try { + $newValue = new UTCDateTime(new NativeDateTime($value)); + $document->setAttribute($key, $newValue); + } catch (Throwable $th) { + // skip -> a valid string + } + } + } + } - public function setSupportForAttributes(bool $support): bool - { - $this->supportForAttributes = $support; - return $this->supportForAttributes; + return $document; } - /** - * Is unique index supported? - * - * @return bool - */ - public function getSupportForUniqueIndex(): bool + #[\Override] + public function castAfter(Document $collection, array $documents): array { - return true; - } + $casts = $this->getReadCasts($collection); + $defined = $this->supports(Capability::DefinedAttributes); - /** - * Is fulltext index supported? - * - * @return bool - */ - public function getSupportForFulltextIndex(): bool - { - return true; + foreach ($documents as $index => $document) { + $documents[$index] = $this->castRead($casts, $defined, $document); + } + + return $documents; } /** - * Is fulltext Wildcard index supported? + * The key, type and array flag of every collection attribute, then of every internal attribute. * - * @return bool + * @return list */ - public function getSupportForFulltextWildcardIndex(): bool + private function getReadCasts(Document $collection): array { - return false; - } + $casts = []; + foreach (self::collectionAttributesWithInternal($collection) as $attribute) { + $casts[] = [$attribute->key, $attribute->type, $attribute->array]; + } - /** - * Does the adapter handle Query Array Contains? - * - * @return bool - */ - public function getSupportForQueryContains(): bool - { - return false; + return $casts; } /** - * Are timeouts supported? - * - * @return bool + * @param list $casts */ - public function getSupportForTimeouts(): bool + private function castRead(array $casts, bool $defined, Document $document): Document { - return true; - } + if ($document->isEmpty()) { + return $document; + } - public function getSupportForRelationships(): bool - { - return true; - } + foreach ($casts as [$key, $type, $array]) { + $stored = $document->getAttribute($key); + if (is_null($stored)) { + continue; + } - public function getSupportForUpdateLock(): bool - { - return false; - } + if (Operator::isOperator($stored)) { + continue; + } - public function getSupportForAttributeResizing(): bool - { - return false; + $value = $stored; + if ($array) { + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new DatabaseException('Failed to decode JSON for attribute '.$key.': '.json_last_error_msg()); + } + $value = $decoded; + } + if (!\is_array($value)) { + $value = [$value]; + } + } else { + $value = [$value]; + } + + /** @var array $value */ + foreach ($value as $index => $node) { + $cast = match ($type) { + ColumnType::BigInteger, ColumnType::Integer => \is_int($node) + ? $node + : ($node instanceof Int64 + ? (int) (string) $node + : (\is_numeric($node) ? (int) $node : 0)), + ColumnType::String, ColumnType::Id => \is_string($node) ? $node : (\is_scalar($node) ? (string) $node : $node), + ColumnType::Float, ColumnType::Double => \is_float($node) ? $node : (\is_numeric($node) ? (float) $node : 0.0), + ColumnType::Boolean => \is_scalar($node) ? (bool) $node : $node, + ColumnType::Datetime => $this->convertUtcDateToString($node), + ColumnType::Object => is_object($node) && get_class($node) === stdClass::class + ? $this->convertStdClassToArray($node) + : $node, + default => $node, + }; + if ($cast !== $node) { + $value[$index] = $cast; + } + } + + $value = $array ? $value : $value[0]; + if ($value !== $stored || $key === Document::PERMISSIONS) { + $document->setAttribute($key, $value); + } + } + + if (! $defined) { + foreach ($document->getArrayCopy() as $key => $value) { + // mongodb results out a stdclass for objects + if (is_object($value) && get_class($value) === stdClass::class) { + $document->setAttribute($key, $this->convertStdClassToArray($value)); + } elseif ($value instanceof UTCDateTime) { + $document->setAttribute($key, $this->convertUtcDateToString($value)); + } + } + } + + return $document; } - /** - * Are batch operations supported? - * - * @return bool - */ - public function getSupportForBatchOperations(): bool + #[\Override] + public function castDatetime(string $value): mixed { - return false; + return new UTCDateTime(new NativeDateTime($value)); } /** - * Is get connection id supported? - * - * @return bool + * MongoDB field names cannot start with $ or contain dots. */ - public function getSupportForGetConnectionId(): bool + protected function escapeMongoFieldName(string $name): string { - return false; + if (\str_starts_with($name, '$')) { + $name = '_'.\substr($name, 1); + } + if (\str_contains($name, '.')) { + $name = \str_replace('.', '__dot__', $name); + } + + return $name; } /** - * Is PCRE regex supported? + * Escape query attribute names that contain dots and match known collection attributes. + * This distinguishes field names with dots (like 'collectionSecurity.Parent') from + * nested object paths (like 'profile.level1.value'). * - * @return bool + * @param array $queries */ - public function getSupportForPCRERegex(): bool + protected function escapeQueryAttributes(Document $collection, array $queries): void { - return true; + $dotAttributes = $this->getEscapedAttributes($collection); + + if (empty($dotAttributes)) { + return; + } + + $this->escapeQueryFields($queries, $dotAttributes); } /** - * Is POSIX regex supported? - * - * @return bool + * @param array $queries + * @param array $dotAttributes */ - public function getSupportForPOSIXRegex(): bool + private function escapeQueryFields(array $queries, array $dotAttributes): void { - return false; + foreach ($queries as $query) { + if (! $query instanceof Query) { + continue; + } + + $method = $query->getMethod(); + if ($method === Method::And || $method === Method::Or) { + $this->escapeQueryFields($query->getValues(), $dotAttributes); + + continue; + } + + if ($method === Method::Exists || $method === Method::NotExists) { + $query->setValues(\array_map( + static fn (mixed $field): mixed => \is_string($field) ? $dotAttributes[$field] ?? $field : $field, + $query->getValues(), + )); + + continue; + } + + $attribute = $query->getAttribute(); + if (isset($dotAttributes[$attribute])) { + $query->setAttribute($dotAttributes[$attribute]); + } + } } /** - * Is cache fallback supported? + * The stored field name of each collection attribute whose key holds a dot or starts with `$`. * - * @return bool + * @return array */ - public function getSupportForCacheSkipOnFailure(): bool + private function getEscapedAttributes(Document $collection): array { - return false; - } + $dotAttributes = []; + foreach (self::collectionAttributes($collection) as $attribute) { + $key = $attribute->key; + if (\str_contains($key, '.') || \str_starts_with($key, '$')) { + $dotAttributes[$key] = $this->escapeMongoFieldName($key); + } + } - public function getSupportForCaching(): bool - { - return true; + return $dotAttributes; } /** - * Is hostname supported? - * - * @return bool + * Ensure relationship attributes have default null values in MongoDB documents. + * MongoDB doesn't store null fields, so we need to add them for schema compatibility. */ - public function getSupportForHostname(): bool + protected function ensureRelationshipDefaults(Document $collection, Document $document): void { - return true; + foreach (self::collectionAttributes($collection) as $attribute) { + $relationship = $attribute->relationship; + if ($relationship === null || $document->offsetExists($attribute->key)) { + continue; + } + + $parent = $attribute->side === RelationshipSide::Parent; + $storesData = match ($relationship->type) { + RelationshipType::OneToOne => $parent || $relationship->twoWay, + RelationshipType::OneToMany => ! $parent, + RelationshipType::ManyToOne => $parent, + RelationshipType::ManyToMany => false, + }; + + if ($storesData) { + $document->setAttribute($attribute->key, null); + } + } } /** - * Is get schema attributes supported? + * Keys cannot begin with $ in MongoDB + * Convert $ prefix to _ on $id, $permissions, and $collection * - * @return bool + * @param array $array A document's fields, or a nested value of one (a list keeps its keys) + * @return array */ - public function getSupportForSchemaAttributes(): bool + protected function replaceCharacters(string $from, string $to, array $array): array { - return false; - } + $keysToRename = []; + foreach ($array as $k => $v) { + if (is_array($v)) { + $array[$k] = $this->replaceCharacters($from, $to, $v); + } - public function getSupportForCastIndexArray(): bool - { - return false; - } + if (\is_int($k)) { + continue; + } - public function getSupportForUpserts(): bool - { - return true; - } + $newKey = $k; - public function getSupportForUpsertOnUniqueIndex(): bool - { - return false; - } + $clean_key = str_replace($from, '', $k); + if (in_array($clean_key, self::PREFIX_SWAPPED_KEYS)) { + $newKey = str_replace($from, $to, $k); + } elseif (\str_starts_with($k, $from) && ! in_array($k, [Document::ID, Document::SEQUENCE, Document::TENANT, Storage::UID, Storage::SEQUENCE, Storage::TENANT])) { + // Handle any other key starting with the 'from' char (e.g. user-defined $-prefixed keys) + $newKey = $to.\substr($k, \strlen($from)); + } - public function getSupportForReconnection(): bool - { - return false; - } + if ($from === '$' && \str_contains($newKey, '.')) { + $newKey = \str_replace('.', '__dot__', $newKey); + } elseif ($from === '_' && \str_contains($k, '__dot__')) { + $newKey = \str_replace('__dot__', '.', $newKey); + } - public function getSupportForBatchCreateAttributes(): bool - { - return true; - } + if ($newKey !== $k) { + $keysToRename[$k] = $newKey; + } + } - public function getSupportForObject(): bool - { - return true; - } + foreach ($keysToRename as $oldKey => $newKey) { + $array[$newKey] = $array[$oldKey]; + unset($array[$oldKey]); + } - /** - * Are object (JSON) indexes supported? - * - * @return bool - */ - public function getSupportForObjectIndexes(): bool - { - return false; + if ($from === '_') { + if (isset($array[Storage::SEQUENCE])) { + $array[Document::SEQUENCE] = $this->stringifyIdentifier($array[Storage::SEQUENCE]); + unset($array[Storage::SEQUENCE]); + } + if (isset($array[Storage::UID])) { + $array[Document::ID] = $this->stringifyIdentifier($array[Storage::UID]); + unset($array[Storage::UID]); + } + if (\array_key_exists(Storage::TENANT, $array)) { + $tenant = $array[Storage::TENANT]; + $array[Document::TENANT] = \is_int($tenant) || $tenant === null ? $tenant : $this->stringifyIdentifier($tenant); + unset($array[Storage::TENANT]); + } + } elseif ($from === '$') { + if (isset($array[Document::ID])) { + $array[Storage::UID] = $array[Document::ID]; + unset($array[Document::ID]); + } + if (isset($array[Document::SEQUENCE])) { + $array[Storage::SEQUENCE] = $array[Document::SEQUENCE]; + unset($array[Document::SEQUENCE]); + } + if (\array_key_exists(Document::TENANT, $array)) { + $array[Storage::TENANT] = $array[Document::TENANT]; + unset($array[Document::TENANT]); + } + } + + /** @var array $array */ + return $array; } - /** - * Get current attribute count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfAttributes(Document $collection): int + private function stringifyIdentifier(mixed $value): string { - $attributes = \count($collection->getAttribute('attributes') ?? []); + if (\is_string($value)) { + return $value; + } + + if (\is_scalar($value)) { + return (string) $value; + } + + if (\is_object($value) && \method_exists($value, '__toString')) { + return (string) $value; + } - return $attributes + static::getCountOfDefaultAttributes(); + return ''; } /** - * Get current index count from collection document + * @param array $queries + * @return array * - * @param Document $collection - * @return int + * @throws Exception */ - public function getCountOfIndexes(Document $collection): int + protected function buildFilters(array $queries, string $separator = '$and'): array { - $indexes = \count($collection->getAttribute('indexes') ?? []); + $filters = []; + $queries = Query::groupByType($queries)->filters; - return $indexes + static::getCountOfDefaultIndexes(); - } + foreach ($queries as $query) { + if ($query->isNested()) { + if ($query->getMethod() === Method::ElemMatch) { + /** @var array $elemMatchValues */ + $elemMatchValues = $query->getValues(); + $filters[$separator][] = [ + $query->getAttribute() => [ + '$elemMatch' => $this->buildFilters($elemMatchValues, $separator), + ], + ]; - /** - * Returns number of attributes used by default. - *p - * @return int - */ - public function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } + continue; + } - /** - * Returns number of indexes used by default. - * - * @return int - */ - public function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); + $operator = $this->getQueryOperator($query->getMethod()); + + /** @var array $nestedValues */ + $nestedValues = $query->getValues(); + $filters[$separator][] = $this->buildFilters($nestedValues, $operator); + } else { + $filters[$separator][] = $this->buildFilter($query); + } + } + + return $filters; } /** - * Get maximum width, in bytes, allowed for a SQL row - * Return 0 when no restrictions apply + * @return array * - * @return int + * @throws Exception */ - public function getDocumentSizeLimit(): int + protected function buildFilter(Query $query): array { - return 0; - } + // Normalize extended ISO 8601 datetime strings in query values to UTCDateTime + // so they can be correctly compared against datetime fields stored in MongoDB. + if (! $this->supports(Capability::DefinedAttributes) || \in_array($query->getAttribute(), [Document::CREATED_AT, Document::UPDATED_AT], true)) { + $values = $query->getValues(); + foreach ($values as $k => $value) { + if (is_string($value) && $this->isExtendedIsoDatetime($value)) { + try { + $values[$k] = $this->toMongoDatetime($value); + } catch (Throwable $th) { + // Leave value as-is if it cannot be parsed as a datetime + } + } + } + $query->setValues($values); + } + + if ($query->getAttribute() === Document::ID) { + $query->setAttribute(Storage::UID); + } elseif ($query->getAttribute() === Document::SEQUENCE) { + $query->setAttribute(Storage::SEQUENCE); + $values = $query->getValues(); + foreach ($values as $k => $v) { + $values[$k] = $v; + } + $query->setValues($values); + } elseif ($query->getAttribute() === Document::CREATED_AT) { + $query->setAttribute(Storage::CREATED_AT); + } elseif ($query->getAttribute() === Document::UPDATED_AT) { + $query->setAttribute(Storage::UPDATED_AT); + } elseif (\str_starts_with($query->getAttribute(), '$')) { + $query->setAttribute($this->escapeMongoFieldName($query->getAttribute())); + } + + $attribute = $query->getAttribute(); + $operator = $this->getQueryOperator($query->getMethod()); + + $value = match ($query->getMethod()) { + Method::IsNull, + Method::IsNotNull => null, + Method::Exists => true, + Method::NotExists => false, + default => $this->getQueryValue( + $query->getMethod(), + count($query->getValues()) > 1 + ? $query->getValues() + : $query->getValues()[0] + ), + }; + + /** @var array $filter */ + $filter = []; + if ($query->isObjectAttribute() && ! \str_contains($attribute, '.') && in_array($query->getMethod(), [Method::Equal, Method::Contains, Method::ContainsAny, Method::ContainsAll, Method::NotContains, Method::NotEqual])) { + $this->handleObjectFilters($query, $filter); - /** - * Estimate maximum number of bytes required to store a document in $collection. - * Byte requirement varies based on column type and size. - * Needed to satisfy MariaDB/MySQL row width limit. - * Return 0 when no restrictions apply to row width - * - * @param Document $collection - * @return int - */ - public function getAttributeWidth(Document $collection): int - { - return 0; - } + return $filter; + } - /** - * Is casting supported? - * - * @return bool - */ - public function getSupportForCasting(): bool - { - return false; - } + if ($operator == '$eq' && \is_array($value)) { + /** @var array $attrFilter1 */ + $attrFilter1 = []; + $attrFilter1['$in'] = $value; + $filter[$attribute] = $attrFilter1; + } elseif ($operator == '$ne' && \is_array($value)) { + /** @var array $attrFilter2 */ + $attrFilter2 = []; + $attrFilter2['$nin'] = $value; + $filter[$attribute] = $attrFilter2; + } elseif ($operator == '$all') { + /** @var array $attrFilter3 */ + $attrFilter3 = []; + $attrFilter3['$all'] = $query->getValues(); + $filter[$attribute] = $attrFilter3; + } elseif ($operator == '$in') { + if (in_array($query->getMethod(), [Method::Contains, Method::ContainsAny]) && ! $query->onArray()) { + if (is_array($value)) { + $filter['$or'] = array_map(fn ($item) => [ + $attribute => [ + '$regex' => $this->createSafeRegex( + \is_string($item) ? $item : (\is_scalar($item) ? (string) $item : ''), + '.*%s.*', + 'i' + ), + ], + ], $value); + } else { + $valueStr = \is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''); + /** @var array $attrFilter4 */ + $attrFilter4 = []; + $attrFilter4['$regex'] = $this->createSafeRegex($valueStr, '.*%s.*'); + $filter[$attribute] = $attrFilter4; + } + } else { + /** @var array $attrFilter5 */ + $attrFilter5 = []; + $attrFilter5['$in'] = $query->getValues(); + $filter[$attribute] = $attrFilter5; + } + } elseif ($operator === 'notContains') { + if (! $query->onArray()) { + $valueStr = \is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''); + $filter[$attribute] = ['$not' => $this->createSafeRegex($valueStr, '.*%s.*')]; + } else { + /** @var array $attrFilter6 */ + $attrFilter6 = []; + $attrFilter6['$nin'] = $query->getValues(); + $attrFilter6['$ne'] = null; + $filter[$attribute] = $attrFilter6; + } + } elseif ($operator == '$search') { + if ($query->getMethod() === Method::NotSearch) { + // MongoDB doesn't support negating $text expressions directly + // Use regex as fallback for NOT search while keeping fulltext for positive search + if (empty($value)) { + // If value is not passed, don't add any filter - this will match all documents + } else { + $valueStr = \is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''); + $filter[$attribute] = ['$not' => $this->createSafeRegex($valueStr, '.*%s.*')]; + } + } else { + /** @var array $textFilter */ + $textFilter = \is_array($filter['$text'] ?? null) ? $filter['$text'] : []; + $textFilter[$operator] = $value; + $filter['$text'] = $textFilter; + } + } elseif ($query->getMethod() === Method::Between) { + /** @var array $valueArray */ + $valueArray = \is_array($value) ? $value : []; + /** @var array $attrFilter7 */ + $attrFilter7 = []; + $attrFilter7['$lte'] = $valueArray[1] ?? null; + $attrFilter7['$gte'] = $valueArray[0] ?? null; + $filter[$attribute] = $attrFilter7; + } elseif ($query->getMethod() === Method::NotBetween) { + /** @var array $valueArray2 */ + $valueArray2 = \is_array($value) ? $value : []; + $filter['$or'] = [ + [$attribute => ['$lt' => $valueArray2[0] ?? null]], + [$attribute => ['$gt' => $valueArray2[1] ?? null]], + ]; + } elseif ($operator === '$regex' && $query->getMethod() === Method::NotStartsWith) { + $valueStr = \is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''); + $filter[$attribute] = ['$not' => $this->createSafeRegex($valueStr, '^%s')]; + } elseif ($operator === '$regex' && $query->getMethod() === Method::NotEndsWith) { + $valueStr = \is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''); + $filter[$attribute] = ['$not' => $this->createSafeRegex($valueStr, '%s$')]; + } elseif ($operator === '$exists') { + /** @var array $existsOr */ + $existsOr = \is_array($filter['$or'] ?? null) ? $filter['$or'] : []; + foreach ($query->getValues() as $existsAttribute) { + $existsAttrStr = \is_string($existsAttribute) ? $existsAttribute : (\is_scalar($existsAttribute) ? (string) $existsAttribute : ''); + $existsOr[] = [$existsAttrStr => [$operator => $value]]; + } + $filter['$or'] = $existsOr; + } else { + /** @var array $attrFilterDefault */ + $attrFilterDefault = \is_array($filter[$attribute] ?? null) ? $filter[$attribute] : []; + $attrFilterDefault[$operator] = $value; + $filter[$attribute] = $attrFilterDefault; + } - /** - * Is spatial attributes supported? - * - * @return bool - */ - public function getSupportForSpatialAttributes(): bool - { - return false; + return $filter; } /** - * Get Support for Null Values in Spatial Indexes - * - * @return bool + * @throws Exception */ - public function getSupportForSpatialIndexNull(): bool + protected function getQueryOperator(Method $operator): string { - return false; + return match ($operator) { + Method::Equal, + Method::IsNull => '$eq', + Method::NotEqual, + Method::IsNotNull => '$ne', + Method::LessThan => '$lt', + Method::LessThanEqual => '$lte', + Method::GreaterThan => '$gt', + Method::GreaterThanEqual => '$gte', + Method::Contains => '$in', + Method::ContainsAny => '$in', + Method::ContainsAll => '$all', + Method::NotContains => 'notContains', + Method::Search => '$search', + Method::NotSearch => '$search', + Method::Between => 'between', + Method::NotBetween => 'notBetween', + Method::StartsWith, + Method::NotStartsWith, + Method::EndsWith, + Method::NotEndsWith, + Method::Regex => '$regex', + Method::Or => '$or', + Method::And => '$and', + Method::Exists, + Method::NotExists => '$exists', + Method::ElemMatch => '$elemMatch', + default => throw new DatabaseException('Unknown operator: '.$operator->value), + }; } - /** - * Does the adapter support operators? - * - * @return bool - */ - public function getSupportForOperators(): bool + protected function getQueryValue(Method $method, mixed $value): mixed { - return true; + return match ($method) { + Method::StartsWith => '^'.preg_quote(\is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''), '/'), + Method::EndsWith => preg_quote(\is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''), '/').'$', + default => $value, + }; } /** - * Does the adapter require booleans to be converted to integers (0/1)? - * - * @return bool + * @throws Exception */ - public function getSupportForIntegerBooleans(): bool + protected function getOrder(OrderDirection $order): int { - return false; + return match ($order) { + OrderDirection::Asc => 1, + OrderDirection::Desc => -1, + OrderDirection::Random => throw new QueryException('Random order is not supported by this adapter'), + }; } - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ - - public function getSupportForBoundaryInclusiveContains(): bool + private static function indexedColumnType(string $type): ?ColumnType { - return false; + try { + return Attribute::typeFromStored($type); + } catch (StructureException) { + return null; + } } /** - * Does the adapter support order attribute in spatial indexes? - * - * @return bool + * @param Document|string $indexOrType Index document or index type string */ - public function getSupportForSpatialIndexOrder(): bool + protected function shouldAddTenantToIndex(Index|Document|string|IndexType $indexOrType): bool { - return false; - } + if (! $this->sharedTables) { + return false; + } + if ($indexOrType instanceof Index) { + $indexType = $indexOrType->type; + } elseif ($indexOrType instanceof Document) { + $rawIndexType = $indexOrType->getAttribute('type'); + $indexTypeValue = \is_string($rawIndexType) ? $rawIndexType : (\is_scalar($rawIndexType) ? (string) $rawIndexType : ''); + $indexType = IndexType::tryFrom($indexTypeValue) ?? IndexType::Key; + } elseif ($indexOrType instanceof IndexType) { + $indexType = $indexOrType; + } else { + $indexType = IndexType::tryFrom($indexOrType) ?? IndexType::Key; + } - /** - * Does the adapter support spatial axis order specification? - * - * @return bool - */ - public function getSupportForSpatialAxisOrder(): bool - { - return false; + return $indexType !== IndexType::Ttl; } /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool + * @param array $selections + * @return array */ - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + private function getAttributeProjection(array $selections): array { - return false; - } + $projection = []; - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return false; - } + $internalKeys = \array_map( + static fn (Attribute $attribute): string => $attribute->key, + Database::internalAttributesFor(true), + ); - /** - * Does the adapter support multiple fulltext indexes? - * - * @return bool - */ - public function getSupportForMultipleFulltextIndexes(): bool - { - return false; - } + foreach ($selections as $selection) { + // Skip internal attributes since all are selected by default + if (\in_array($selection, $internalKeys)) { + continue; + } - /** - * Does the adapter support identical indexes? - * - * @return bool - */ - public function getSupportForIdenticalIndexes(): bool - { - return false; - } + $projection[$selection] = 1; + } - /** - * Does the adapter support random order for queries? - * - * @return bool - */ - public function getSupportForOrderRandom(): bool - { - return false; - } + $projection[Storage::UID] = 1; + $projection[Storage::SEQUENCE] = 1; + $projection[Storage::CREATED_AT] = 1; + $projection[Storage::UPDATED_AT] = 1; + $projection[Storage::PERMISSIONS] = 1; - public function getSupportForVectors(): bool - { - return false; + return $projection; } /** - * Flattens the array. - * - * @param mixed $list * @return array */ protected function flattenArray(mixed $list): array { - if (!is_array($list)) { - // make sure the input is an array - return array($list); + if (! is_array($list)) { + return [$list]; } $newArray = []; @@ -4001,7 +3783,7 @@ protected function flattenArray(mixed $list): array } /** - * @param array|Document $target + * @param array|Document $target * @return array */ protected function removeNullKeys(array|Document $target): array @@ -4017,246 +3799,61 @@ protected function removeNullKeys(array|Document $target): array $cleaned[$key] = $value; } - return $cleaned; } - public function getKeywords(): array - { - return []; - } - - protected function processException(\Throwable $e): \Throwable + protected function processException(Throwable $e): Throwable { - // Timeout if ($e->getCode() === 50 || $e->getCode() === 262) { return new TimeoutException('Query timed out', $e->getCode(), $e); } - // Duplicate key error if ($e->getCode() === 11000 || $e->getCode() === 11001) { $index = $this->getViolatedIndex($e->getMessage()); - if ($index !== null && $index !== '_uid' && $index !== '_id_') { - return new UniqueException('Unique index violation', $e->getCode(), $e); + if ($index !== null && $index !== Storage::UID && $index !== '_id_') { + return new UniqueException(UniqueException::MESSAGE, $e->getCode(), $e); } + return new DuplicateException('Document already exists', $e->getCode(), $e); } - // Collection already exists if ($e->getCode() === 48) { return new DuplicateException('Collection already exists', $e->getCode(), $e); - } - - // Index already exists - if ($e->getCode() === 85) { - return new DuplicateException('Index already exists', $e->getCode(), $e); - } - - // No transaction - if ($e->getCode() === 251) { - return new TransactionException('No active transaction', $e->getCode(), $e); - } - - // Aborted transaction - if ($e->getCode() === 112) { - return new TransactionException('Transaction aborted', $e->getCode(), $e); - } - - // Invalid operation (MongoDB error code 14) - if ($e->getCode() === 14) { - return new TypeException('Invalid operation', $e->getCode(), $e); - } - - // Invalid $pow argument (0 raised to a negative power) — matches the SQL adapters, which - // report an undefined power as a numeric range error. - if ($e->getCode() === 28764) { - return new LimitException('Value out of range', $e->getCode(), $e); - } - - return $e; - } - - /** - * Extract the index name from a duplicate key error, e.g. - * "E11000 duplicate key error collection: db.movies index: _uid dup key: { _uid: \"movie\" }" - * resolves to "_uid". Returns null when the message cannot be parsed. - */ - protected function getViolatedIndex(string $message): ?string - { - if (\preg_match('/index:\s*(\S+)\s+dup key/', $message, $matches) !== 1) { - return null; - } - - return $matches[1]; - } - - protected function quote(string $string): string - { - return ""; - } - - /** - * @param mixed $stmt - * @return bool - */ - protected function execute(mixed $stmt): bool - { - return true; - } - - /** - * @return string - */ - public function getIdAttributeType(): string - { - return Database::VAR_UUID7; - } - - /** - * @return int - */ - public function getMaxIndexLength(): int - { - return 1024; - } - - /** - * @return int - */ - public function getMaxUIDLength(): int - { - return 255; - } - - public function getConnectionId(): string - { - return '0'; - } - - public function getInternalIndexesKeys(): array - { - return []; - } - - public function getSchemaAttributes(string $collection): array - { - return []; - } - - public function getSupportForSchemaIndexes(): bool - { - return false; - } - - public function getSchemaIndexes(string $collection): array - { - return []; - } - - /** - * @param string $collection - * @param array $tenants - * @return int|string|null|array> - */ - public function getTenantFilters( - string $collection, - array $tenants = [], - ): int|string|null|array { - $values = []; - if (!$this->sharedTables) { - return $values; - } - - if (\count($tenants) === 0) { - $values[] = $this->getTenant(); - } else { - for ($index = 0; $index < \count($tenants); $index++) { - $values[] = $tenants[$index]; - } - } - - if ($collection === Database::METADATA) { - $values[] = null; - } - - if (\count($values) === 1) { - return $values[0]; - } - - - return ['$in' => $values]; - } - - public function decodePoint(string $wkb): array - { - return []; - } - - /** - * Decode a WKB or textual LINESTRING into [[x1, y1], [x2, y2], ...] - * - * @param string $wkb - * @return float[][] Array of points, each as [x, y] - */ - public function decodeLinestring(string $wkb): array - { - return []; - } - - /** - * Decode a WKB or textual POLYGON into [[[x1, y1], [x2, y2], ...], ...] - * - * @param string $wkb - * @return float[][][] Array of rings, each ring is an array of points [x, y] - */ - public function decodePolygon(string $wkb): array - { - return []; - } + } - /** - * Get the query to check for tenant when in shared tables mode - * - * @param string $collection The collection being queried - * @param string $alias The alias of the parent collection if in a subquery - * @return string - */ - public function getTenantQuery(string $collection, string $alias = ''): string - { - return ''; - } + if ($e->getCode() === 85) { + return new DuplicateException('Index already exists', $e->getCode(), $e); + } - public function getSupportForAlterLocks(): bool - { - return false; - } + if ($e->getCode() === 251) { + return new TransactionException('No active transaction', $e->getCode(), $e); + } - public function getSupportNonUtfCharacters(): bool - { - return false; - } + if ($e->getCode() === 112) { + return new TransactionException('Transaction aborted', $e->getCode(), $e); + } - public function getSupportForTrigramIndex(): bool - { - return false; - } + if ($e->getCode() === 14) { + return new TypeException('Invalid operation', $e->getCode(), $e); + } - public function getSupportForTTLIndexes(): bool - { - return true; - } + if ($e->getCode() === 28764) { + return new LimitException('Value out of range', $e->getCode(), $e); + } - public function getSupportForTransactionRetries(): bool - { - return false; + return $e; } - public function getSupportForNestedTransactions(): bool + protected function getViolatedIndex(string $message): ?string { - return false; + if (\preg_match('/index:\s*(\S+)\s+dup key/', $message, $matches) !== 1) { + return null; + } + + return $matches[1]; } - protected function isExtendedISODatetime(string $val): bool + protected function isExtendedIsoDatetime(string $value): bool { /** * Min: @@ -4267,61 +3864,59 @@ protected function isExtendedISODatetime(string $val): bool * YYYY-MM-DDTHH:mm:ss.fffffZ (26) * YYYY-MM-DDTHH:mm:ss.fffff+HH:MM (31) */ - - $len = strlen($val); + $length = strlen($value); // absolute minimum - if ($len < 20) { + if ($length < 20) { return false; } // fixed datetime fingerprints if ( - !isset($val[19]) || - $val[4] !== '-' || - $val[7] !== '-' || - $val[10] !== 'T' || - $val[13] !== ':' || - $val[16] !== ':' + ! isset($value[19]) || + $value[4] !== '-' || + $value[7] !== '-' || + $value[10] !== 'T' || + $value[13] !== ':' || + $value[16] !== ':' ) { return false; } - // timezone detection - $hasZ = ($val[$len - 1] === 'Z'); + $hasZ = ($value[$length - 1] === 'Z'); $hasOffset = ( - $len >= 25 && - ($val[$len - 6] === '+' || $val[$len - 6] === '-') && - $val[$len - 3] === ':' + $length >= 25 && + ($value[$length - 6] === '+' || $value[$length - 6] === '-') && + $value[$length - 3] === ':' ); - if (!$hasZ && !$hasOffset) { + if (! $hasZ && ! $hasOffset) { return false; } - if ($hasOffset && $len > 31) { + if ($hasOffset && $length > 31) { return false; } - if ($hasZ && $len > 26) { + if ($hasZ && $length > 26) { return false; } $digitPositions = [ - 0,1,2,3, - 5,6, - 8,9, - 11,12, - 14,15, - 17,18 + 0, 1, 2, 3, + 5, 6, + 8, 9, + 11, 12, + 14, 15, + 17, 18, ]; - $timeEnd = $hasZ ? $len - 1 : $len - 6; + $timeEnd = $hasZ ? $length - 1 : $length - 6; // fractional seconds if ($timeEnd > 19) { - if ($val[19] !== '.' || $timeEnd < 21) { + if ($value[19] !== '.' || $timeEnd < 21) { return false; } for ($i = 20; $i < $timeEnd; $i++) { @@ -4331,13 +3926,13 @@ protected function isExtendedISODatetime(string $val): bool // timezone offset numeric digits if ($hasOffset) { - foreach ([$len - 5, $len - 4, $len - 2, $len - 1] as $i) { + foreach ([$length - 5, $length - 4, $length - 2, $length - 1] as $i) { $digitPositions[] = $i; } } foreach ($digitPositions as $i) { - if (!ctype_digit($val[$i])) { + if (! ctype_digit($value[$i])) { return false; } } @@ -4345,33 +3940,341 @@ protected function isExtendedISODatetime(string $val): bool return true; } - protected function convertUTCDateToString(mixed $node): mixed + protected function convertUtcDateToString(mixed $node): mixed { if ($node instanceof UTCDateTime) { - // Handle UTCDateTime objects $node = DateTime::format($node->toDateTime()); } elseif (is_array($node) && isset($node['$date'])) { - // Handle Extended JSON format from (array) cast - // Format: {"$date":{"$numberLong":"1760405478290"}} + // Extended JSON an (array) cast leaves: {"$date":{"$numberLong":"1760405478290"}} if (is_array($node['$date']) && isset($node['$date']['$numberLong'])) { - $milliseconds = (int)$node['$date']['$numberLong']; + /** @var mixed $numberLongVal */ + $numberLongVal = $node['$date']['$numberLong']; + $milliseconds = \is_int($numberLongVal) ? $numberLongVal : (\is_numeric($numberLongVal) ? (int) $numberLongVal : 0); $seconds = intdiv($milliseconds, 1000); $microseconds = ($milliseconds % 1000) * 1000; - $dateTime = \DateTime::createFromFormat('U.u', $seconds . '.' . str_pad((string)$microseconds, 6, '0')); + $dateTime = NativeDateTime::createFromFormat('U.u', $seconds.'.'.str_pad((string) $microseconds, 6, '0')); if ($dateTime) { - $dateTime->setTimezone(new \DateTimeZone('UTC')); + $dateTime->setTimezone(new DateTimeZone('UTC')); $node = DateTime::format($dateTime); } } } elseif (is_string($node)) { // Already a string, validate and pass through try { - new \DateTime($node); - } catch (\Exception $e) { + new NativeDateTime($node); + } catch (Exception $e) { // Invalid date string, skip } } return $node; } + + /** + * @param array $options + * @return array + */ + private function getTransactionOptions(array $options = []): array + { + if ($this->inTransaction > 0 && $this->session !== null) { + // Pass the session array directly - the client will handle the transaction state internally + $options['session'] = $this->session; + } + + return $options; + } + + /** + * @param string $pattern The pattern template (e.g., ".*%s.*" for contains) + */ + private function createSafeRegex(string $value, string $pattern = '%s', string $flags = 'i'): Regex + { + $escaped = preg_quote($value, '/'); + + $finalPattern = sprintf($pattern, $escaped); + + return new Regex($finalPattern, $flags); + } + + /** + * @param array $document + * @param array $options + * @return array + * + * @throws DuplicateException + * @throws Exception + */ + private function insertDocument(string $name, array $document, array $options = []): array + { + try { + $this->client->insert($name, $document, $options); + $filters = [Storage::UID => $document[Storage::UID]]; + if ($this->sharedTables) { + $filters[Storage::TENANT] = $document[Storage::TENANT] ?? null; + } + + try { + $findResult = $this->client->find( + $name, + $filters, + array_merge(['limit' => 1], $options) + ); + /** @var \stdClass $findResultCursor */ + $findResultCursor = $findResult->cursor; + /** @var array $firstBatch */ + $firstBatch = $findResultCursor->firstBatch; + $result = $firstBatch[0]; + } catch (MongoException $e) { + throw $this->processException($e); + } + + /** @var array $toArrayResult */ + $toArrayResult = $this->client->toArray($result) ?? []; + return $toArrayResult; + } catch (MongoException $e) { + throw $this->processException($e); + } + } + + /** + * MongoDB uses a partial index for a query only when the query implies its filter, and a filter on a value implies + * `$exists` but never `$type`. A unique index requires every field to exist with its stored type, so null values + * never collide. A key index requires only its leading field to exist, so a filter on that field, alone or with + * the following ones, can use it. + * + * @param non-empty-array $fields stored field name => attribute type (null when + * unknown), in index order + * @return array> + */ + private function getPartialFilterExpression(IndexType $type, array $fields): array + { + if ($type !== IndexType::Unique) { + return [\array_key_first($fields) => ['$exists' => true]]; + } + + $filter = []; + foreach ($fields as $field => $attributeType) { + $filter[$field] = ['$exists' => true, '$type' => $this->getMongoTypeCode($attributeType)]; + } + + return $filter; + } + + /** + * The BSON types a stored value of the column type can have. PHP integers are written as int + * or long by magnitude, and a float attribute also accepts integers. A value of unknown type + * (a schemaless or internal attribute) may have any type the driver writes but null. + * + * @return string|list + */ + private function getMongoTypeCode(?ColumnType $type): string|array + { + return match ($type) { + null => self::NON_NULL_BSON_TYPES, + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText, + ColumnType::Id, + ColumnType::Uuid7 => 'string', + ColumnType::BigInteger, + ColumnType::Integer => ['int', 'long'], + ColumnType::Float, + ColumnType::Double => ['double', 'int', 'long'], + ColumnType::Boolean => 'bool', + ColumnType::Datetime => 'date', + default => 'string' + }; + } + + /** + * @throws Exception + */ + private function toMongoDatetime(string $dt): UTCDateTime + { + return new UTCDateTime(new NativeDateTime($dt)); + } + + /** + * Recursive function to replace chars in array keys, while + * skipping any that are explicitly excluded. + * + * @param array $array + * @param array $exclude + * @return array + */ + private function replaceInternalIdsKeys(array $array, string $from, string $to, array $exclude = []): array + { + $result = []; + + foreach ($array as $key => $value) { + if (! in_array($key, $exclude)) { + $key = str_replace($from, $to, $key); + } + + if (is_array($value)) { + /** @var array $value */ + $result[$key] = $this->replaceInternalIdsKeys($value, $from, $to, $exclude); + } else { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * @param array $filter + */ + private function handleObjectFilters(Query $query, array &$filter): void + { + $conditions = []; + $isNot = in_array($query->getMethod(), [Method::NotContains, Method::NotEqual]); + $values = $query->getValues(); + foreach ($values as $attribute => $value) { + $flattendQuery = $this->flattenWithDotNotation(is_string($attribute) ? $attribute : '', $value); + $flattenedObjectKey = array_key_first($flattendQuery); + if ($flattenedObjectKey === null) { + continue; + } + $queryValue = $flattendQuery[$flattenedObjectKey]; + $queryAttribute = $query->getAttribute(); + $flattenedQueryField = array_key_first($flattendQuery); + $flattenedObjectKey = $flattenedQueryField === '' ? $queryAttribute : $queryAttribute.'.'.array_key_first($flattendQuery); + switch ($query->getMethod()) { + + case Method::Contains: + case Method::ContainsAny: + case Method::ContainsAll: + case Method::NotContains: + $arrayValue = \is_array($queryValue) ? $queryValue : [$queryValue]; + $operator = $isNot ? '$nin' : '$in'; + $conditions[] = [$flattenedObjectKey => [$operator => $arrayValue]]; + break; + + case Method::Equal: + case Method::NotEqual: + if (\is_array($queryValue)) { + $operator = $isNot ? '$nin' : '$in'; + $conditions[] = [$flattenedObjectKey => [$operator => $queryValue]]; + } else { + $operator = $isNot ? '$ne' : '$eq'; + $conditions[] = [$flattenedObjectKey => [$operator => $queryValue]]; + } + + break; + + } + } + + $logicalOperator = $isNot ? '$and' : '$or'; + if (count($conditions) && isset($filter[$logicalOperator])) { + $existingLogical = $filter[$logicalOperator]; + /** @var array $existingLogicalArr */ + $existingLogicalArr = \is_array($existingLogical) ? $existingLogical : []; + $filter[$logicalOperator] = array_merge($existingLogicalArr, $conditions); + } else { + $filter[$logicalOperator] = $conditions; + } + } + + /** + * Flatten a nested associative array into Mongo-style dot notation. + * + * @return array + */ + private function flattenWithDotNotation(string $key, mixed $value, string $prefix = ''): array + { + /** @var array $result */ + $result = []; + + /** @var array $stack */ + $stack = []; + + $initialKey = $prefix === '' ? $key : $prefix.'.'.$key; + $stack[] = [$initialKey, $value]; + while (! empty($stack)) { + $item = array_pop($stack); + /** @var array{0: string, 1: mixed} $item */ + [$currentPath, $currentValue] = $item; + if (is_array($currentValue) && ! array_is_list($currentValue)) { + foreach ($currentValue as $nextKey => $nextValue) { + $nextKeyStr = (string) $nextKey; + $nextPath = $currentPath === '' ? $nextKeyStr : $currentPath.'.'.$nextKeyStr; + $stack[] = [$nextPath, $nextValue]; + } + } else { + // leaf node + $result[$currentPath] = $currentValue; + } + } + + return $result; + } + + private function convertStdClassToArray(mixed $value): mixed + { + if (is_object($value) && get_class($value) === stdClass::class) { + $properties = get_object_vars($value); + + return $properties === [] ? $value : $this->convertStdClassValues($properties); + } + + if (is_array($value)) { + return $this->convertStdClassValues($value); + } + + return $value; + } + + /** + * @param array $values + * @return array + */ + private function convertStdClassValues(array $values): array + { + foreach ($values as $key => $value) { + if (\is_array($value) || \is_object($value)) { + $values[$key] = $this->convertStdClassToArray($value); + } + } + + return $values; + } + + /** + * Get fields to unset for schemaless upsert operations + * + * @param array $record + * @return array + */ + private function getUpsertAttributeRemovals(Document $oldDocument, Document $newDocument, array $record): array + { + $unsetFields = []; + + if ($this->supports(Capability::DefinedAttributes) || $oldDocument->isEmpty()) { + return $unsetFields; + } + + $oldUserAttributes = $oldDocument->getAttributes(); + $newUserAttributes = $newDocument->getAttributes(); + + $protectedFields = [Storage::UID, Storage::SEQUENCE, Storage::CREATED_AT, Storage::UPDATED_AT, Storage::PERMISSIONS, Storage::TENANT]; + + foreach ($oldUserAttributes as $originalKey => $originalValue) { + if (in_array($originalKey, $protectedFields) || array_key_exists($originalKey, $newUserAttributes)) { + continue; + } + + $transformed = $this->replaceCharacters('$', '_', [$originalKey => $originalValue]); + $dbKey = array_key_first($transformed); + + if ($dbKey && ! array_key_exists($dbKey, $record) && ! in_array($dbKey, $protectedFields)) { + $unsetFields[$dbKey] = ''; + } + } + + return $unsetFields; + } } diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 6a43047957..d024ade559 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -2,157 +2,100 @@ namespace Utopia\Database\Adapter; +use Exception; use PDOException; +use Utopia\Database\Adapter\SQL\Hook\Permission; +use Utopia\Database\Builder\MySQL as MySQLBuilder; +use Utopia\Database\Builder\Scoping; +use Utopia\Database\Capability; use Utopia\Database\Database; +use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Character as CharacterException; use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Operator; -use Utopia\Database\Query; +use Utopia\Database\OperatorType; +use Utopia\Database\Storage; +use Utopia\Query\Builder\JoinType; +use Utopia\Query\Builder\SQL as SQLBuilder; +use Utopia\Query\Schema\ColumnType; class MySQL extends MariaDB { /** - * Set max execution time - * @param int $milliseconds - * @param string $event - * @return void - * @throws DatabaseException + * @return array */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + #[\Override] + public function capabilities(): array { - if (!$this->getSupportForTimeouts()) { - return; - } - if ($milliseconds <= 0) { - throw new DatabaseException('Timeout must be greater than 0'); - } - - $this->timeout = $milliseconds; + $remove = [ + Capability::IndexSpatialOrder, + ]; + + return array_values(array_filter( + array_merge(parent::capabilities(), [ + Capability::SpatialAxisOrder, + Capability::IndexArrayCast, + ]), + fn (Capability $c) => ! in_array($c, $remove, true) + )); + } - $this->before($event, 'timeout', function ($sql) use ($milliseconds) { - return \preg_replace( - pattern: '/SELECT/', - replacement: "SELECT /*+ max_execution_time({$milliseconds}) */", - subject: $sql, - limit: 1 - ); - }); + #[\Override] + protected function getTimeoutStatement(int $milliseconds): string + { + return "SET SESSION MAX_EXECUTION_TIME = {$milliseconds}"; } /** - * Get size of collection on disk - * @param string $collection - * @return int * @throws DatabaseException */ + #[\Override] public function getSizeOfCollectionOnDisk(string $collection): int { $collection = $this->filter($collection); - $collection = $this->getNamespace() . '_' . $collection; + $collection = $this->getNamespace().'_'.$collection; $database = $this->getDatabase(); - $name = $database . '/' . $collection; - $permissions = $database . '/' . $collection . '_perms'; + $name = $database.'/'.$collection; + $permissions = $database.'/'.Storage::permissionsTable($collection); - $collectionSize = $this->getPDO()->prepare(" + $collectionSize = $this->prepareStatement(' SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME = :name - "); + ', Event::CollectionRead); - $permissionsSize = $this->getPDO()->prepare(" + $permissionsSize = $this->prepareStatement(' SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME = :permissions - "); + ', Event::CollectionRead); $collectionSize->bindParam(':name', $name); $permissionsSize->bindParam(':permissions', $permissions); try { - $collectionSize->execute(); - $permissionsSize->execute(); - $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn(); + $this->execute($collectionSize); + $this->execute($permissionsSize); + $collVal = $collectionSize->fetchColumn(); + $permVal = $permissionsSize->fetchColumn(); + $size = (int)(\is_numeric($collVal) ? $collVal : 0) + (int)(\is_numeric($permVal) ? $permVal : 0); } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } return $size; } - /** - * Handle distance spatial queries - * - * @param Query $query - * @param array $binds - * @param string $attribute - * @param string $type - * @param string $alias - * @param string $placeholder - * @return string - */ - protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string - { - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - - $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true; - - switch ($query->getMethod()) { - case Query::TYPE_DISTANCE_EQUAL: - $operator = '='; - break; - case Query::TYPE_DISTANCE_NOT_EQUAL: - $operator = '!='; - break; - case Query::TYPE_DISTANCE_GREATER_THAN: - $operator = '>'; - break; - case Query::TYPE_DISTANCE_LESS_THAN: - $operator = '<'; - break; - default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); - } - - if ($useMeters) { - $attr = "ST_SRID({$alias}.{$attribute}, " . Database::DEFAULT_SRID . ")"; - $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", null); - return "ST_Distance({$attr}, {$geom}, 'metre') {$operator} :{$placeholder}_1"; - } - // need to use srid 0 because of geometric distance - $attr = "ST_SRID({$alias}.{$attribute}, " . 0 . ")"; - $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", 0); - return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; - } - - public function getSupportForIndexArray(): bool - { - /** - * @link https://bugs.mysql.com/bug.php?id=111037 - */ - return true; - } - - public function getSupportForCastIndexArray(): bool - { - if (!$this->getSupportForIndexArray()) { - return false; - } - - return true; - } - - protected function processException(PDOException $e): \Exception + #[\Override] + protected function processException(PDOException $e): Exception { if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1366) { return new CharacterException('Invalid character', $e->getCode(), $e); } - // Timeout if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3024) { return new TimeoutException('Query timed out', $e->getCode(), $e); } @@ -173,139 +116,132 @@ protected function processException(PDOException $e): \Exception return parent::processException($e); } - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ - public function getSupportForBoundaryInclusiveContains(): bool + + #[\Override] + protected function dialectBuilder(): SQLBuilder&Scoping { - return false; + return new MySQLBuilder(); } - /** - * Does the adapter support order attribute in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexOrder(): bool + + #[\Override] + protected function supportsInsertReturning(): bool { return false; } /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool + * MySQL merges each permission check into the join as a semi-join, one more table for its join + * order search, which grows about tenfold with each table past ten. From this many joins every + * joined table's check stays a subquery. */ - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + private const int LARGE_JOIN = 5; + + /** + * Inside an outer join's ON clause MySQL runs a semi-joined check by scanning its materialised + * rows once per outer row, so an outer-joined table's check always stays a subquery. + */ + #[\Override] + protected function newJoinPermissionHook(string $collection, array $roles, string $type, string $documentColumn, int $joins, JoinType $joinType): Permission\Filter + { + $hook = parent::newJoinPermissionHook($collection, $roles, $type, $documentColumn, $joins, $joinType); + + return $joins >= self::LARGE_JOIN || self::isOuterJoin($joinType) ? $hook->withoutSemiJoin() : $hook; + } + + #[\Override] + protected function looksUpByTenantAlone(): bool { return true; } - /** - * Spatial type attribute - */ - public function getSpatialSQLType(string $type, bool $required): string + private static function isOuterJoin(JoinType $joinType): bool + { + return match ($joinType) { + JoinType::Left, JoinType::Right, JoinType::FullOuter => true, + default => false, + }; + } + + #[\Override] + protected function getSpatialSqlType(string $type, bool $required): string { switch ($type) { - case Database::VAR_POINT: + case ColumnType::Point->value: $type = 'POINT SRID 4326'; - if (!$this->getSupportForSpatialIndexNull()) { + if (! $this->supports(Capability::IndexSpatialNull)) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } + return $type; - case Database::VAR_LINESTRING: + case ColumnType::Linestring->value: $type = 'LINESTRING SRID 4326'; - if (!$this->getSupportForSpatialIndexNull()) { + if (! $this->supports(Capability::IndexSpatialNull)) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } - return $type; + return $type; - case Database::VAR_POLYGON: + case ColumnType::Polygon->value: $type = 'POLYGON SRID 4326'; - if (!$this->getSupportForSpatialIndexNull()) { + if (! $this->supports(Capability::IndexSpatialNull)) { if ($required) { $type .= ' NOT NULL'; } else { $type .= ' NULL'; } } + return $type; } - return ''; - } - /** - * Does the adapter support spatial axis order specification? - * - * @return bool - */ - public function getSupportForSpatialAxisOrder(): bool - { - return true; + return ''; } - public function getSupportForObjectIndexes(): bool + #[\Override] + protected function getSpatialColumnSrid(): ?int { - return false; + return Database::DEFAULT_SRID; } /** - * Get the spatial axis order specification string for MySQL * MySQL with SRID 4326 expects lat-long by default, but our data is in long-lat format - * - * @return string */ - protected function getSpatialAxisOrderSpec(): string + #[\Override] + protected function getSpatialAxisOrder(): string { return "'axis-order=long-lat'"; } - /** - * Adapter supports optional spatial attributes with existing rows. - * - * @return bool - */ - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return false; - } - - /** - * Get SQL expression for operator - * Override for MySQL-specific operator implementations - * - * @param string $column - * @param \Utopia\Database\Operator $operator - * @param array $binds - * @return ?string - */ - protected function getOperatorSQL(string $column, \Utopia\Database\Operator $operator, array &$binds): ?string + #[\Override] + protected function getOperatorSql(string $column, Operator $operator, int &$bindIndex): ?string { $quotedColumn = $this->quote($column); $method = $operator->getMethod(); $values = $operator->getValues(); switch ($method) { - case Operator::TYPE_ARRAY_APPEND: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayAppend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)"; - case Operator::TYPE_ARRAY_PREPEND: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayPrepend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; - case Operator::TYPE_ARRAY_UNIQUE: + case OperatorType::ArrayUnique: return "{$quotedColumn} = IFNULL(( SELECT JSON_ARRAYAGG(value) FROM ( @@ -315,12 +251,6 @@ protected function getOperatorSQL(string $column, \Utopia\Database\Operator $ope ), JSON_ARRAY())"; } - // For all other operators, use parent implementation - return parent::getOperatorSQL($column, $operator, $binds); - } - - public function getSupportForTTLIndexes(): bool - { - return false; + return parent::getOperatorSql($column, $operator, $bindIndex); } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 511da2b13a..4b2c66301f 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -2,44 +2,79 @@ namespace Utopia\Database\Adapter; +use Throwable; use Utopia\Database\Adapter; -use Utopia\Database\Database; +use Utopia\Database\Adapter\SQL\Wkt; +use Utopia\Database\Attribute; +use Utopia\Database\Builder\Scoping; +use Utopia\Database\Capability; +use Utopia\Database\Change; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Hook\Transform; +use Utopia\Database\Index; +use Utopia\Database\PermissionType; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Schema\Column as SchemaColumn; +use Utopia\Database\Schema\Index as SchemaIndex; +use Utopia\Database\State\Value; use Utopia\Database\Validator\Authorization; use Utopia\Pools\Pool as UtopiaPool; - -class Pool extends Adapter +use Utopia\Query\Builder; +use Utopia\Query\CursorDirection; +use Utopia\Query\Schema; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; + +/** + * Pool is a proxy: optional Feature methods are forwarded to the borrowed adapter. + * Feature support is reported by hasFeature(), not instanceof. + */ +class Pool extends Adapter implements Feature\Timeouts { + use Timeout; + + private const int FIXED_SCHEMA = -1; + /** * @var UtopiaPool */ protected UtopiaPool $pool; /** - * When a transaction is active, all delegate calls are routed through - * this pinned adapter to ensure they run on the same connection. + * @var Value|null The connection a coroutine's open transaction runs on, which that coroutine and + * the coroutines it starts use for every call until the transaction ends */ - protected ?Adapter $pinnedAdapter = null; + private ?Value $pinned = null; /** - * The timeout each event is under, held here rather than on a connection. - * - * A timeout is adapter state, not a statement: every concrete adapter - * records it and applies it to the SQL it builds afterwards, and none of - * them contacts the server to set it. Delegating the call therefore opened - * a connection for the sole purpose of writing a number onto whichever one - * answered, which the pool took back moments later - so the timeout bound - * one connection and none of its siblings, and merely building a handle - * failed outright while the backing was unreachable, reporting a database - * as down to a caller that had not yet issued a query. + * The schemaless mode this handle puts every borrowed adapter in, or null to leave each in its own. + */ + protected ?bool $schemaless = null; + + /** + * Every connection of one pool runs the same adapter, and handles are often built per + * request, so the answers are kept per pool rather than per handle. * - * @var array + * @var \WeakMap, array>|null + */ + private static ?\WeakMap $declared = null; + + /** + * @var \WeakMap, array>|null + */ + private static ?\WeakMap $features = null; + + /** + * @var \WeakMap, array>|null */ - private array $timeouts = []; + private static ?\WeakMap $definedAttributes = null; /** - * @param UtopiaPool $pool The pool to use for connections. Must contain instances of Adapter. + * @param UtopiaPool $pool The pool to use for connections. Must contain instances of Adapter. */ public function __construct(UtopiaPool $pool) { @@ -47,844 +82,1033 @@ public function __construct(UtopiaPool $pool) } /** - * Forward method calls to the internal adapter instance via the pool. - * * Required because __call() can't be used to implement abstract methods. * - * @param string $method - * @param array $args - * @return mixed + * @param array $arguments + * * @throws DatabaseException */ - public function delegate(string $method, array $args): mixed + public function delegate(string $method, array $arguments): mixed { - if ($this->pinnedAdapter !== null) { - if ($this->skipDuplicates) { - return $this->pinnedAdapter->skipDuplicates( - fn () => $this->pinnedAdapter->{$method}(...$args) - ); - } - return $this->pinnedAdapter->{$method}(...$args); - } - - return $this->pool->use(function (Adapter $adapter) use ($method, $args) { - // Run setters in case config changed since this connection was last used - $adapter->setDatabase($this->getDatabase()); - $adapter->setNamespace($this->getNamespace()); - $adapter->setSharedTables($this->getSharedTables()); - $adapter->setTenant($this->getTenant()); - $adapter->setAuthorization($this->authorization); - - $this->syncTimeouts($adapter); - $adapter->resetDebug(); - foreach ($this->getDebug() as $key => $value) { - $adapter->setDebug($key, $value); - } - $adapter->resetMetadata(); - foreach ($this->getMetadata() as $key => $value) { - $adapter->setMetadata($key, $value); - } - - if ($this->skipDuplicates) { - return $adapter->skipDuplicates( - fn () => $adapter->{$method}(...$args) - ); - } - return $adapter->{$method}(...$args); - }); - } - - public function getDriver(): mixed - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function before(string $event, string $name = '', ?callable $callback = null): static - { - $this->delegate(__FUNCTION__, \func_get_args()); - - return $this; - } - - protected function trigger(string $event, mixed $query): mixed - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->borrowAndInvoke($method, $arguments); } /** - * Zero is the value a caller's own default carries when it wants no - * timeout, so it clears the event rather than being refused. A connection - * is only ever asked for a timeout it can hold. + * @param class-string $feature + * @param array $arguments */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + protected function delegateFeature(string $feature, string $method, array $arguments): mixed { - if ($milliseconds <= 0) { - $this->clearTimeout($event); - - return; - } - - $this->timeouts[$event] = $milliseconds; - $this->timeout = $this->timeouts[Database::EVENT_ALL] ?? 0; - - $this->syncPin(); + return $this->borrowAndInvoke($method, $arguments, $feature); } /** - * Clearing one event leaves the others alone. The concrete adapters keep a - * single timeout scalar that Postgres and Mongo apply to every statement, - * so a clear forwarded verbatim would drop the timeout the caller still - * has configured for everything else. + * @param array $arguments + * @param class-string|null $feature */ - public function clearTimeout(string $event): void + protected function borrowAndInvoke(string $method, array $arguments, ?string $feature = null): mixed { - unset($this->timeouts[$event]); - $this->timeout = $this->timeouts[Database::EVENT_ALL] ?? 0; - - $this->syncPin(); - } + $pinned = $this->pin(); + if ($pinned !== null) { + $this->syncBorrowed($pinned); - /** - * The pool's own map is what a checkout replays, so a clear has to empty - * it. Inheriting the base implementation cleared almost nothing: it walks - * the events it finds in `$transformations`, and this adapter delegates - * `before()`, so its own array never holds more than `EVENT_ALL` however - * many events a caller has set a timeout for. - */ - public function clearTimeouts(): void - { - $this->timeouts = []; - $this->timeout = 0; + return $pinned->withTenant( + $this->getTenant(), + fn (): mixed => $this->invokeDelegated($pinned, $method, $arguments, $feature), + ); + } - $this->syncPin(); - } + return $this->pool->use(function (Adapter $adapter) use ($method, $arguments, $feature) { + try { + $this->syncBorrowed($adapter); - /** - * The connection this caller's open transaction is pinned to, if any. - * - * A seam: a subclass that keys the pin by coroutine rather than by object - * overrides this, and the timeout setters reach the right connection - * without knowing how the pin is held. - */ - protected function pin(): ?Adapter - { - return $this->pinnedAdapter; + return $this->invokeDelegated($adapter, $method, $arguments, $feature); + } finally { + $this->releaseBorrowed($adapter); + } + }); } /** - * A timeout changed inside a transaction has to reach the connection - * running it. Every statement left in that transaction goes to the pinned - * connection, and it will not be checked out again before the commit, so - * waiting for the next checkout would leave the rest of the body running - * under the timeout the caller just replaced. + * @param array $arguments + * @param class-string|null $feature */ - private function syncPin(): void + protected function invokeDelegated(Adapter $adapter, string $method, array $arguments, ?string $feature = null): mixed { - $pinned = $this->pin(); + if ($feature !== null && ! $adapter instanceof $feature) { + throw new DatabaseException($this->unsupportedFeatureMessage($feature)); + } - if ($pinned === null) { - return; + if ($this->isIgnoringDuplicates()) { + return $adapter->ignoreDuplicates( + fn () => $adapter->{$method}(...$arguments) + ); } - $this->syncTimeouts($pinned); + return $adapter->{$method}(...$arguments); } /** - * Put a connection into the timeout state this pool holds, as it is checked - * out. The connection outlives the handle that configured it and is handed - * on to handles that want a different timeout or none at all, so it is - * reset first: a handle carrying no timeout must not inherit one, and a - * handle carrying its own must not be left with an event the last holder - * set. - * - * The global timeout is applied last, which decides what an engine with no - * per-event timeout does with one. MariaDB and MySQL hang a hook on the - * event and are unaffected; Postgres and Mongo take `$event` and discard - * it, so every call lands on the one scalar they bound every statement by - * and the last one wins. Applying the global last means a per-event - * refinement those two cannot express is ignored there. The other order - * would let a 5s read deadline silently bound every write on the handle, - * which is the failure worth avoiding. + * @param class-string $feature */ - protected function syncTimeouts(Adapter $adapter): void - { - $adapter->clearTimeouts(); + protected function unsupportedFeatureMessage(string $feature): string + { + return match ($feature) { + Feature\Upserts::class => 'Adapter does not support upserts', + Feature\RawQuery::class => 'Adapter does not support raw queries', + Feature\QueryBuilder::class => 'Adapter does not support query builder', + Feature\Spatial::class => 'Adapter does not support spatial', + Feature\Casting::class => 'Adapter does not support casting', + Feature\Connection::class => 'Adapter does not support connections', + Feature\Relationships::class => 'Adapter does not support relationships', + Feature\Timeouts::class => 'Adapter does not support timeouts', + Feature\Schemaless::class => 'Adapter does not support schemaless', + default => 'Adapter does not support '.$feature, + }; + } + + protected function syncBorrowed(Adapter $adapter): void + { + $adapter->setDatabase($this->getDatabase()); + $adapter->setNamespace($this->getNamespace()); + $adapter->setSharedTables($this->hasSharedTables()); + $adapter->setTenant($this->getTenant()); + $adapter->setTenantPerDocument($this->isTenantPerDocument()); + $adapter->setAuthorization($this->authorization); + $adapter->setLocks($this->locks); + + if ($this->schemaless !== null && $adapter->hasFeature(Feature\Schemaless::class)) { + /** @var Adapter&Feature\Schemaless $adapter */ + $adapter->setSchemaless($this->schemaless); + } - foreach ($this->timeouts as $event => $milliseconds) { - if ($event === Database::EVENT_ALL) { - continue; + $this->syncTimeouts($adapter); + + $metadata = $this->getMetadata(); + if ($adapter->getMetadata() !== $metadata) { + $adapter->resetMetadata(); + foreach ($metadata as $key => $value) { + $adapter->setMetadata($key, $value); } + } - $adapter->setTimeout($milliseconds, $event); + if ($adapter->profiler !== $this->profiler) { + $adapter->setProfiler($this->profiler); } - if (isset($this->timeouts[Database::EVENT_ALL])) { - $adapter->setTimeout($this->timeouts[Database::EVENT_ALL]); + if ($adapter->transforms !== $this->transforms) { + $adapter->resetTransforms(); + foreach ($this->transforms as $name => $transform) { + $adapter->addTransform($name, $transform); + } } - } - public function startTransaction(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->syncWriteHooks($adapter); } - public function commitTransaction(): bool + /** + * Take back what syncBorrowed() lent the connection for one checkout. + * A subclass that checks connections out itself calls this before handing + * the connection back to the pool. + */ + protected function releaseBorrowed(Adapter $adapter): void { - return $this->delegate(__FUNCTION__, \func_get_args()); + $adapter->setProfiler(null); } - public function rollbackTransaction(): bool + #[\Override] + public function getDriver(): object { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var object $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); - public function getHostname(): string - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } /** - * Pin a single connection from the pool for the entire transaction lifecycle. - * This prevents startTransaction(), the callback, and commitTransaction() - * from running on different connections. - * - * @template T - * @param callable(): T $callback - * @return T - * @throws \Throwable + * Answered from the capabilities the pool's connections reported when first asked, except + * DefinedAttributes: it reflects the schema mode a connection is in. Once this handle has set + * that mode, every connection it borrows is put in it first, so the answer is kept per pool + * and mode; before that, a connection with a schemaless mode keeps its own and is asked every + * time, while connections without one always answer alike, so theirs is kept per pool too. */ - public function withTransaction(callable $callback): mixed + #[\Override] + public function supports(Capability $capability): bool { - // If already inside a transaction, reuse the pinned adapter - // so nested withTransaction calls use the same connection - if ($this->pinnedAdapter !== null) { - return $this->pinnedAdapter->withTransaction($callback); + if ($capability === Capability::DefinedAttributes) { + return $this->supportsDefinedAttributes(); } - return $this->pool->use(function (Adapter $adapter) use ($callback) { - $adapter->setDatabase($this->getDatabase()); - $adapter->setNamespace($this->getNamespace()); - $adapter->setSharedTables($this->getSharedTables()); - $adapter->setTenant($this->getTenant()); - $adapter->setAuthorization($this->authorization); - - $this->syncTimeouts($adapter); - $adapter->resetDebug(); - foreach ($this->getDebug() as $key => $value) { - $adapter->setDebug($key, $value); - } - $adapter->resetMetadata(); - foreach ($this->getMetadata() as $key => $value) { - $adapter->setMetadata($key, $value); - } - - $this->pinnedAdapter = $adapter; - try { - if ($this->skipDuplicates) { - return $adapter->skipDuplicates( - fn () => $adapter->withTransaction($callback) - ); - } - return $adapter->withTransaction($callback); - } finally { - $this->pinnedAdapter = null; - } - }); + return \in_array($capability, $this->capabilities(), true); } - protected function quote(string $string): string + private function supportsDefinedAttributes(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $mode = $this->schemaless; + if ($mode === null && $this->hasFeature(Feature\Schemaless::class)) { + /** @var bool $result */ + $result = $this->delegate('supports', [Capability::DefinedAttributes]); - public function ping(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + return $result; + } - public function reconnect(): void - { - $this->delegate(__FUNCTION__, \func_get_args()); - } + $mode = $mode === null ? self::FIXED_SCHEMA : (int) $mode; + $known = self::$definedAttributes[$this->pool][$mode] ?? null; + if ($known !== null) { + return $known; + } - public function create(string $name): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var bool $result */ + $result = $this->delegate('supports', [Capability::DefinedAttributes]); + self::$definedAttributes ??= new \WeakMap(); + $answers = self::$definedAttributes[$this->pool] ?? []; + $answers[$mode] = $result; + self::$definedAttributes[$this->pool] = $answers; - public function exists(string $database, ?string $collection = null): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function list(): array + /** + * @return array + */ + #[\Override] + public function capabilities(): array { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $remembered = self::$declared[$this->pool] ?? null; + if ($remembered !== null) { + return $remembered; + } - public function delete(string $name): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + self::$declared ??= new \WeakMap(); + self::$declared[$this->pool] = $result; - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function deleteCollection(string $id): bool + /** + * A feature the pool serves itself, such as timeouts it holds as state, is answered without checking a + * connection out; any other is answered by the pooled adapter. + * + * @param class-string $feature + */ + #[\Override] + public function hasFeature(string $feature): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + if ($this instanceof $feature) { + return true; + } - public function analyzeCollection(string $collection): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $known = self::$features[$this->pool][$feature] ?? null; + if ($known !== null) { + return $known; + } - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var bool $result */ + $result = $this->delegate('hasFeature', [$feature]); - public function createAttributes(string $collection, array $attributes): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + self::$features ??= new \WeakMap(); + $features = self::$features[$this->pool] ?? []; + $features[$feature] = $result; + self::$features[$this->pool] = $features; - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function deleteAttribute(string $collection, string $id): bool + #[\Override] + public function addTransform(string $name, Transform $transform): static { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $this->transforms[$name] = $transform; - public function renameAttribute(string $collection, string $old, string $new): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this; } - public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool + #[\Override] + public function removeTransform(string $name): static { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + unset($this->transforms[$name]); - public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this; } - public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + // Zero is what a caller's own default carries when it wants no timeout, + // so it clears the event rather than pinning every statement to 0. + if ($milliseconds <= 0) { + $this->clearTimeout($event); - public function renameIndex(string $collection, string $old, string $new): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + return; + } - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->setTimeoutState($milliseconds, $event); + $this->syncPinnedTimeouts(); } - public function deleteIndex(string $collection, string $id): bool + #[\Override] + public function clearTimeout(Event $event = Event::All): void { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->clearTimeoutState($event); + $this->syncPinnedTimeouts(); } - public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + /** + * A timeout is adapter state, not a statement: every concrete adapter records + * it and applies it to the SQL it builds afterwards, and none of them contacts + * the server to set it. Delegating the call therefore checked a connection out + * for the sole purpose of writing a number onto whichever one answered, so the + * timeout bound that connection and none of its siblings — and merely building + * a handle failed outright while the backing was unreachable, reporting a + * database as down to a caller that had not yet issued a query. + * + * The state is replayed onto each connection as it is borrowed + * ({@see self::syncTimeouts()}), so the only connection that needs telling now + * is one already pinned: a transaction does not check out again before its + * commit, and the rest of its body must not run under the timeout the caller + * just replaced. + */ + private function syncPinnedTimeouts(): void { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $pinned = $this->pin(); - public function createDocument(Document $collection, Document $document): Document - { - return $this->delegate(__FUNCTION__, \func_get_args()); + if ($pinned !== null) { + $this->syncTimeouts($pinned); + } } - public function createDocuments(Document $collection, array $documents): array + /** + * Which connection the calling coroutine's transaction, or the transaction of + * the coroutine that started it, has pinned, if any. Read through a seam, so a + * subclass that keeps its pins somewhere else is asked too. + */ + protected function pin(): ?Adapter { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->pinned?->get(); } - public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + /** + * How many reads the calling coroutine can run at the same time, each on a connection of its own, without + * waiting for one and while leaving an idle connection to other coroutines. One while a transaction has pinned a + * connection, which runs one statement at a time. + * + * @return int<1, max> + */ + public function getReadConcurrency(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + if ($this->pin() !== null) { + return 1; + } - public function updateDocuments(Document $collection, Document $updates, array $documents): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return \max(1, $this->getReadPool()->count() - 1); } - public function upsertDocuments(Document $collection, string $attribute, array $changes): array + /** + * The pool a read outside a transaction borrows its connection from. + * + * @return UtopiaPool + */ + protected function getReadPool(): UtopiaPool { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->pool; } - public function deleteDocument(string $collection, string $id): bool + /** + * @return Value + */ + private function pinned(): Value { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + if ($this->pinned === null) { + /** @var Value $pinned */ + $pinned = new Value(null); + $this->pinned = $pinned; + } - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->pinned; } - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + /** + * Start a database transaction via the pooled adapter. + * + * @return bool + * + * @throws DatabaseException + */ + #[\Override] + public function startTransaction(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + /** + * Commit the current database transaction via the pooled adapter. + * + * @return bool + * + * @throws DatabaseException + */ + #[\Override] + public function commitTransaction(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function count(Document $collection, array $queries = [], ?int $max = null): int + /** + * Roll back the current database transaction via the pooled adapter. + * + * @return bool + * + * @throws DatabaseException + */ + #[\Override] + public function rollbackTransaction(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSizeOfCollection(string $collection): int + #[\Override] + public function inTransaction(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->pin()?->inTransaction() ?? parent::inTransaction(); } - public function getSizeOfCollectionOnDisk(string $collection): int + public function hostname(): string { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var string $result */ + $result = $this->delegateFeature(Feature\Connection::class, __FUNCTION__, \func_get_args()); - public function getLimitForString(): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getLimitForInt(): int + /** + * Pin a single connection from the pool for the entire transaction lifecycle. + * This prevents startTransaction(), the callback, and commitTransaction() + * from running on different connections. The pin belongs to the calling + * coroutine and the coroutines it starts; other coroutines sharing the handle + * borrow connections of their own and run outside the transaction. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @throws Throwable + */ + #[\Override] + public function withTransaction(callable $callback): mixed { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $pinned = $this->pin(); + if ($pinned !== null) { + return $pinned->withTransaction($callback); + } - public function getLimitForBigInt(): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + return $this->pool->use(function (Adapter $adapter) use ($callback) { + try { + $this->syncBorrowed($adapter); - public function getSupportForUnsignedBigInt(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + return $this->pinned()->with($adapter, function () use ($adapter, $callback): mixed { + if ($this->isIgnoringDuplicates()) { + return $adapter->ignoreDuplicates( + fn () => $adapter->withTransaction($callback) + ); + } - public function getLimitForAttributes(): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $adapter->withTransaction($callback); + }); + } finally { + $this->releaseBorrowed($adapter); + } + }); } - public function getLimitForIndexes(): int + protected function syncTimeouts(Adapter $adapter): void { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + if (! $adapter->hasFeature(Feature\Timeouts::class)) { + // Setting a timeout no longer checks a connection out, so this is the + // first moment the adapter's capabilities are known. Staying silent + // here would drop a bound the caller asked for and run the statement + // unbounded; the refusal belongs where the timeout would be applied, + // not where a handle is merely being built. + if ($this->timeouts !== []) { + throw new DatabaseException($this->unsupportedFeatureMessage(Feature\Timeouts::class)); + } - public function getMaxIndexLength(): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + return; + } - public function getMaxVarcharLength(): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var Adapter&Feature\Timeouts $adapter */ + if (empty($this->timeouts)) { + $adapter->clearTimeout(); - public function getMaxUIDLength(): int - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + return; + } - public function getMinDateTime(): \DateTime - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + if (count($this->timeouts) === 1 && isset($this->timeouts[Event::All->value])) { + $adapter->setTimeout($this->timeouts[Event::All->value]); - public function getSupportForSchemas(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + return; + } - public function getSupportForAttributes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + // The concrete adapters keep one timeout scalar, which Postgres writes + // into SET statement_timeout and Mongo into maxTimeMS for every + // statement, so the last value applied is the one every statement runs + // under. Apply the per-event entries first and the global one last, or a + // per-event timeout set after the global one bounds everything. + $adapter->clearTimeout(); + foreach ($this->timeouts as $event => $milliseconds) { + if ($event === Event::All->value) { + continue; + } - public function getSupportForSchemaAttributes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $adapter->setTimeout($milliseconds, Event::from($event)); + } - public function getSupportForIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + if (isset($this->timeouts[Event::All->value])) { + $adapter->setTimeout($this->timeouts[Event::All->value]); + } } - public function getSupportForIndexArray(): bool + private function syncWriteHooks(Adapter $adapter): void { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $current = $adapter->getWriteHooks(); + if ($current === $this->writeHooks) { + return; + } - public function getSupportForCastIndexArray(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + foreach ($current as $childHook) { + $adapter->removeWriteHook($childHook::class); + } - public function getSupportForUniqueIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + foreach ($this->writeHooks as $hook) { + $adapter->addWriteHook($hook); + } } - public function getSupportForFulltextIndex(): bool + public function ping(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var bool $result */ + $result = $this->delegateFeature(Feature\Connection::class, __FUNCTION__, \func_get_args()); - public function getSupportForFulltextWildcardIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForPCRERegex(): bool + #[\Override] + public function isRetryable(Throwable $failure): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForPOSIXRegex(): bool + public function reconnect(): void { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->delegateFeature(Feature\Connection::class, __FUNCTION__, \func_get_args()); } - public function getSupportForTrigramIndex(): bool + #[\Override] + public function create(string $name): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForCasting(): bool + #[\Override] + public function update(string $name, string $new): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); - public function getSupportForQueryContains(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForTimeouts(): bool + #[\Override] + public function exists(string $database): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForRelationships(): bool + #[\Override] + public function collectionExists(string $database, string $collection): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForUpdateLock(): bool + #[\Override] + public function list(): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForBatchOperations(): bool + #[\Override] + public function delete(string $name): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForAttributeResizing(): bool + /** + * @param list $attributes + * @param list $indexes + */ + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForOperators(): bool + #[\Override] + public function deleteCollection(string $collection): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForGetConnectionId(): bool + #[\Override] + public function analyzeCollection(string $collection): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForUpserts(): bool + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForUpsertOnUniqueIndex(): bool + /** + * @param list $attributes + */ + #[\Override] + public function createAttributes(string $collection, array $attributes): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForVectors(): bool + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForCacheSkipOnFailure(): bool + #[\Override] + public function relaxAttributeRequired(string $collection, string $id): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); - public function getSupportForCaching(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForReconnection(): bool + #[\Override] + public function deleteAttribute(string $collection, string $key): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForHostname(): bool + #[\Override] + public function renameAttribute(string $collection, string $old, string $new): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForBatchCreateAttributes(): bool + public function createRelationship(string $collection, Relationship $relationship): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegateFeature(Feature\Relationships::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForSpatialAttributes(): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegateFeature(Feature\Relationships::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForSpatialIndexNull(): bool + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegateFeature(Feature\Relationships::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getCountOfAttributes(Document $collection): int + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getCountOfIndexes(Document $collection): int + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getCountOfDefaultAttributes(): int + #[\Override] + public function deleteIndex(string $collection, string $key): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getCountOfDefaultIndexes(): int + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getDocumentSizeLimit(): int + #[\Override] + public function createDocument(Document $collection, Document $document): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getAttributeWidth(Document $collection): int + #[\Override] + public function createDocuments(Document $collection, array $documents): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getKeywords(): array + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - protected function getAttributeProjection(array $selections, string $prefix): mixed + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function increaseDocumentAttribute(string $collection, string $id, string $attribute, float|int $value, string $updatedAt, float|int|null $min = null, float|int|null $max = null): bool + public function upsertDocument(Document $collection, Change $change): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegateFeature(Feature\Upserts::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getConnectionId(): string + /** + * @param array $changes + * @return array + */ + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\Upserts::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getInternalIndexesKeys(): array + #[\Override] + public function deleteDocument(Document $collection, string $id): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSchemaAttributes(string $collection): array + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForSchemaIndexes(): bool + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSchemaIndexes(string $collection): array + #[\Override] + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var float|int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getTenantQuery(string $collection, string $alias = ''): string + #[\Override] + public function count(Document $collection, array $queries = [], ?int $max = null): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - protected function execute(mixed $stmt): bool + #[\Override] + public function getSizeOfCollection(string $collection): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getIdAttributeType(): string + #[\Override] + public function getSizeOfCollectionOnDisk(string $collection): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSequences(string $collection, array $documents): array + #[\Override] + public function limits(): Limits { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + if ($this->limits === null) { + /** @var Limits $limits */ + $limits = $this->delegate(__FUNCTION__, \func_get_args()); + $this->limits = $limits; + } - public function getSupportForBoundaryInclusiveContains(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->limits; } - public function getSupportForSpatialIndexOrder(): bool + #[\Override] + public function getCountOfAttributes(Document $collection): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + #[\Override] + public function getCountOfIndexes(Document $collection): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForSpatialAxisOrder(): bool + #[\Override] + public function getAttributeWidth(Document $collection): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool + #[\Override] + public function increaseDocumentAttribute(Document $collection, string $id, string $attribute, float|int|string $value, string $updatedAt, float|int|string|null $min = null, float|int|string|null $max = null): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForMultipleFulltextIndexes(): bool + public function id(): string { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var string $result */ + $result = $this->delegateFeature(Feature\Connection::class, __FUNCTION__, \func_get_args()); - public function getSupportForIdenticalIndexes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForOrderRandom(): bool + /** + * @return list + */ + #[\Override] + public function getSchemaAttributes(string $collection): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var list $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function decodePoint(string $wkb): array + /** + * @return list + */ + #[\Override] + public function getSchemaIndexes(string $collection): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var list $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function decodeLinestring(string $wkb): array + #[\Override] + public function getSchemaIndexType(IndexType $type): IndexType { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var IndexType $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function decodePolygon(string $wkb): array + #[\Override] + public function getColumnType(Attribute $attribute): ?string { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var string|null $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForObject(): bool + #[\Override] + public function getSequences(Document $collection, array $documents): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForObjectIndexes(): bool + /** + * The well-known text does not depend on a connection, so it is built without borrowing one, whichever + * adapter the pool holds: hasFeature(Feature\Spatial::class) says whether that adapter stores geometries. + */ + public function encode(mixed $value, ColumnType $type): string { - return $this->delegate(__FUNCTION__, \func_get_args()); + return Wkt::encode($value, $type); } - public function castingBefore(Document $collection, Document $document): Document + /** + * @return array + */ + public function decode(string $value, ColumnType $type): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\Spatial::class, __FUNCTION__, \func_get_args()); + return $result; } - public function castingAfter(Document $collection, Document $document): Document + public function castBefore(Document $collection, Document $document): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegateFeature(Feature\Casting::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForInternalCasting(): bool + /** + * @param array $documents + * @return array + */ + public function castAfter(Document $collection, array $documents): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\Casting::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForUTCCasting(): bool + public function castDatetime(string $value): mixed { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->delegateFeature(Feature\Casting::class, __FUNCTION__, \func_get_args()); } - public function setUTCDatetime(string $value): mixed + /** + * Every adapter this handle borrows afterwards is put in the mode first; one without a schemaless + * mode always enforces its schema and is left as it is. + */ + public function setSchemaless(bool $schemaless): static { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $this->schemaless = $schemaless; - public function setSupportForAttributes(bool $support): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this; } - public function getSupportForIntegerBooleans(): bool + public function isSchemaless(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + if ($this->schemaless !== null && $this->hasFeature(Feature\Schemaless::class)) { + return $this->schemaless; + } + + /** @var bool $result */ + $result = $this->delegateFeature(Feature\Schemaless::class, __FUNCTION__, \func_get_args()); + + return $result; } + /** + * Set the authorization instance used for permission checks. + * + * @param Authorization $authorization The authorization instance + * @return self + */ + #[\Override] public function setAuthorization(Authorization $authorization): self { $this->authorization = $authorization; - return $this; - } - public function getSupportForAlterLocks(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this; } - public function getSupportNonUtfCharacters(): bool + /** + * @param array $bindings + * @return array + */ + public function rawQuery(string $query, array $bindings = []): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\RawQuery::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForTTLIndexes(): bool + /** + * @param array $bindings + */ + public function rawMutation(string $query, array $bindings = []): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegateFeature(Feature\RawQuery::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForTransactionRetries(): bool + public function builder(): Builder&Scoping { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Builder&Scoping $result */ + $result = $this->delegateFeature(Feature\QueryBuilder::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForNestedTransactions(): bool + public function schema(): Schema { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Schema $result */ + $result = $this->delegateFeature(Feature\QueryBuilder::class, __FUNCTION__, \func_get_args()); + return $result; } } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 04863a5fa8..7f8d5757aa 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -5,1243 +5,1108 @@ use Exception; use PDO; use PDOException; +use PDOStatement; +use Swoole\Database\PDOStatementProxy; +use Throwable; +use Utopia\Database\Adapter\SQL\Expression; +use Utopia\Database\Adapter\SQL\Hook\Permission; +use Utopia\Database\Adapter\SQL\Wkt; +use Utopia\Database\Attribute; +use Utopia\Database\Builder\Postgres as PostgresBuilder; +use Utopia\Database\Builder\Scoping; +use Utopia\Database\Capability; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Character as CharacterException; +use Utopia\Database\Exception\Contention as ContentionException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\Mismatch as MismatchException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Operator as OperatorException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Timeout as TimeoutException; -use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\ID; +use Utopia\Database\Index; use Utopia\Database\Operator; +use Utopia\Database\OperatorType; +use Utopia\Database\PDOStatement as DatabasePDOStatement; +use Utopia\Database\PermissionType; use Utopia\Database\Query; - -/** - * Differences between MariaDB and Postgres - * - * 1. Need to use CASCADE to DROP schema - * 2. Quotes are different ` vs " - * 3. DATETIME is TIMESTAMP - * 4. Full-text search is different - to_tsvector() and to_tsquery() - */ -class Postgres extends SQL +use Utopia\Database\Schema\Column as SchemaColumn; +use Utopia\Database\Schema\Index as SchemaIndex; +use Utopia\Database\Storage; +use Utopia\Database\Validator\ObjectPath; +use Utopia\Query\Builder\Condition; +use Utopia\Query\Builder\SQL as SQLBuilder; +use Utopia\Query\Builder\Statement; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Query as BaseQuery; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\PostgreSQL as PostgresSchema; + +class Postgres extends SQL implements Feature\Spatial, Feature\Timeouts { - public const MAX_IDENTIFIER_NAME = 63; + use Timeout; - /** - * @inheritDoc - */ - public function rollbackTransaction(): bool - { - if ($this->inTransaction === 0) { - return false; - } + public const int MAX_IDENTIFIER_NAME = 63; - try { - if ($this->inTransaction > 1) { - $this->getPDO()->exec('ROLLBACK TO transaction' . ($this->inTransaction - 1)); - $this->inTransaction--; - return true; - } + protected const string MIN_DATETIME = '-4713-01-01 00:00:00'; - $result = $this->getPDO()->rollBack(); - $this->inTransaction = 0; - } catch (PDOException $e) { - $this->inTransaction = 0; - throw new DatabaseException('Failed to rollback transaction: ' . $e->getMessage(), $e->getCode(), $e); - } + private const string QUOTED_IDENTIFIER = '/["\x{AB}\x{BB}\x{201C}\x{201D}\x{201E}\x{300C}\x{300D}][\s\x{A0}\x{202F}]*([^"\x{AB}\x{BB}\x{201C}\x{201D}\x{201E}\x{300C}\x{300D}]+?)[\s\x{A0}\x{202F}]*["\x{AB}\x{BB}\x{201C}\x{201D}\x{201E}\x{300C}\x{300D}]/u'; - if (!$result) { - throw new TransactionException('Failed to rollback transaction'); - } + private const string HASHED_IDENTIFIER = '/^[0-9a-f]{32}(?:_[A-Za-z0-9_-]+)?$/'; - return $result; - } + /** + * The catalog's format_type() spellings mapped onto getSqlType()'s. + * + * @var array + */ + private const array CATALOG_TYPE_SPELLINGS = [ + 'CHARACTER VARYING' => 'VARCHAR', + ' WITHOUT TIME ZONE' => '', + ', ' => ',', + ]; - protected function execute(mixed $stmt): bool + /** + * @return array + */ + #[\Override] + public function capabilities(): array { - $pdo = $this->getPDO(); - - // Choose the right SET command based on transaction state - $sql = $this->inTransaction === 0 - ? "SET statement_timeout = '{$this->timeout}ms'" - : "SET LOCAL statement_timeout = '{$this->timeout}ms'"; - - // Apply timeout - $pdo->exec($sql); - - try { - return $stmt->execute(); - } finally { - // Only reset the global timeout when not in a transaction - if ($this->inTransaction === 0) { - $pdo->exec("RESET statement_timeout"); - } - } + return array_merge(parent::capabilities(), [ + Capability::Vectors, + Capability::Objects, + Capability::IndexSpatialNull, + Capability::IndexTrigram, + Capability::IndexObject, + Capability::SchemaIntrospection, + ]); } + #[\Override] + public function id(): string + { + $result = $this->dialectBuilder()->fromNone()->selectRaw('pg_backend_pid()')->build(); + $statement = $this->prepareStatement($result->query); + if (! $statement->execute()) { + return ''; + } + $column = $statement->fetchColumn(); + return \is_scalar($column) ? (string) $column : ''; + } /** - * Returns Max Execution Time - * @param int $milliseconds - * @param string $event - * @return void * @throws DatabaseException */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + #[\Override] + public function create(string $name): bool { - if (!$this->getSupportForTimeouts()) { - return; + $name = $this->filter($name); + + if ($this->exists($name)) { + return true; } - if ($milliseconds <= 0) { - throw new DatabaseException('Timeout must be greater than 0'); + + $schema = $this->schema(); + $sql = $schema->createDatabase($name)->query; + + $dbCreation = $this->executeStatement($sql, Event::DatabaseCreate); + + foreach (['postgis', 'vector', 'pg_trgm'] as $ext) { + try { + $this->executeStatement($schema->createExtension($ext)->query, Event::DatabaseCreate); + } catch (PDOException) { + // Extension may already exist due to concurrent worker + } + } + + try { + $collation = $schema->createCollation('utf8_ci_ai', [ + 'provider' => 'icu', + 'locale' => 'und-u-ks-level1', + ], deterministic: false); + $this->executeStatement($collation->query, Event::DatabaseCreate); + } catch (PDOException) { + // Collation may already exist due to concurrent worker } - $this->timeout = $milliseconds; + return $dbCreation; } /** - * Create Database - * - * @param string $name + * A Postgres database is a schema, which renames in place with everything it holds. Shared tables refuse + * the rename: other tenants' rows share the schema. * - * @return bool * @throws DatabaseException */ - public function create(string $name): bool + #[\Override] + public function update(string $name, string $new): bool { + if ($this->hasSharedTables()) { + throw new DatabaseException('Cannot rename a database while shared tables are enabled'); + } + $name = $this->filter($name); + $new = $this->filter($new); - if ($this->exists($name)) { - return true; + if (! $this->exists($name)) { + throw new NotFoundException('Database not found'); + } + + if ($this->exists($new)) { + throw new DuplicateException('Database already exists'); } - $sql = "CREATE SCHEMA \"{$name}\""; - $sql = $this->trigger(Database::EVENT_DATABASE_CREATE, $sql); + try { + return $this->execute($this->prepareStatement("ALTER SCHEMA {$this->quote($name)} RENAME TO {$this->quote($new)}")); + } catch (PDOException $error) { + throw $this->processException($error); + } + } + + #[\Override] + public function exists(string $database): bool + { + $statement = $this->prepareStatement('SELECT "schema_name" FROM information_schema.schemata WHERE "schema_name" = ?', Event::DatabaseList); + $statement->bindValue(1, $this->filter($database)); - $dbCreation = $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->returnsRows($statement); + } - // Enable extensions - $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS postgis')->execute(); - $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS vector')->execute(); - $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS pg_trgm')->execute(); + #[\Override] + public function collectionExists(string $database, string $collection): bool + { + $statement = $this->prepareStatement('SELECT "table_name" FROM information_schema.tables WHERE "table_schema" = ? AND "table_name" = ?', Event::CollectionRead); + $statement->bindValue(1, $this->filter($database)); + $statement->bindValue(2, $this->tableName($this->getNamespace(), $collection)); - $collation = " - CREATE COLLATION IF NOT EXISTS utf8_ci_ai ( - provider = icu, - locale = 'und-u-ks-level1', - deterministic = false - ) - "; - $this->getPDO()->prepare($collation)->execute(); - return $dbCreation; + return $this->returnsRows($statement); } /** - * Delete Database + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement * - * @param string $name - * @return bool - * @throws Exception - * @throws PDOException + * @throws DatabaseException */ - public function delete(string $name): bool + private function returnsRows(object $statement): bool { - $name = $this->filter($name); - - $sql = "DROP SCHEMA IF EXISTS \"{$name}\" CASCADE"; - $sql = $this->trigger(Database::EVENT_DATABASE_DELETE, $sql); + try { + $this->execute($statement); + $rows = $statement->fetchAll(); + $statement->closeCursor(); + } catch (PDOException $error) { + throw $this->processException($error); + } - return $this->getPDO()->prepare($sql)->execute(); + return ! empty($rows); } /** - * Create Collection + * @param list $attributes + * @param list $indexes * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool * @throws DuplicateException */ - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool { $namespace = $this->getNamespace(); - $id = $this->filter($name); + $id = $this->filter($collection); + $tableRaw = $this->getTableRaw($id); + $permissionsTableRaw = $this->getTableRaw(Storage::permissionsTable($id)); - /** @var array $attributeStrings */ - $attributeStrings = []; - foreach ($attributes as $attribute) { - $attrId = $this->filter($attribute->getId()); - - $attrType = $this->getSQLType( - $attribute->getAttribute('type'), - $attribute->getAttribute('size', 0), - $attribute->getAttribute('signed', true), - $attribute->getAttribute('array', false), - $attribute->getAttribute('required', false) - ); + $schema = $this->schema(); - // Ignore relationships with virtual attributes - if ($attribute->getAttribute('type') === Database::VAR_RELATIONSHIP) { - $options = $attribute->getAttribute('options', []); - $relationType = $options['relationType'] ?? null; - $twoWay = $options['twoWay'] ?? false; - $side = $options['side'] ?? null; - - if ( - $relationType === Database::RELATION_MANY_TO_MANY - || ($relationType === Database::RELATION_ONE_TO_ONE && !$twoWay && $side === Database::RELATION_SIDE_CHILD) - || ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) - || ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) - ) { - continue; - } - } + $table = $schema->table($tableRaw); + $table->id(Storage::SEQUENCE); + $table->string(Storage::UID, 255); - $attributeStrings[] = "\"{$attrId}\" {$attrType}, "; + if ($this->sharedTables) { + $table->integer(Storage::TENANT)->nullable()->default(null); } - $sqlTenant = $this->sharedTables ? '_tenant INTEGER DEFAULT NULL,' : ''; - $collection = " - CREATE TABLE {$this->getSQLTable($id)} ( - _id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - _uid VARCHAR(255) NOT NULL, - " . $sqlTenant . " - \"_createdAt\" TIMESTAMP(3) DEFAULT NULL, - \"_updatedAt\" TIMESTAMP(3) DEFAULT NULL, - " . \implode(' ', $attributeStrings) . " - _permissions JSONB DEFAULT NULL - ); - "; + $table->datetime(Storage::CREATED_AT, 3)->nullable()->default(null); + $table->datetime(Storage::UPDATED_AT, 3)->nullable()->default(null); + + foreach ($attributes as $attribute) { + if (self::storesColumn($attribute)) { + $this->addAttributeColumn($table, $attribute); + } + } + + $table->json(Storage::PERMISSIONS)->nullable()->default(null); + $collectionResult = $table->create(); + + $indexStatements = []; if ($this->sharedTables) { - $uidIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_uid"); - $createdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_created"); - $updatedIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_updated"); - $tenantIdIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_tenant_id"); - $permissionsIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permissions"); - $collection .= " - CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai, \"_tenant\"); - CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); - CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\"); - CREATE INDEX \"{$tenantIdIndex}\" ON {$this->getSQLTable($id)} (_tenant, _id); - CREATE INDEX \"{$permissionsIndex}\" ON {$this->getSQLTable($id)} USING gin (_permissions); - "; + $uidIndex = $this->getShortKey("{$namespace}_{$this->currentTenant()}_{$id}".Storage::UID); + $createdIndex = $this->getShortKey("{$namespace}_{$this->currentTenant()}_{$id}_created"); + $updatedIndex = $this->getShortKey("{$namespace}_{$this->currentTenant()}_{$id}_updated"); + $tenantIdIndex = $this->getShortKey("{$namespace}_{$this->currentTenant()}_{$id}".Storage::INDEX_TENANT_ID); + $permissionsIndex = $this->getShortKey("{$namespace}_{$this->currentTenant()}_{$id}".Storage::PERMISSIONS); + $indexStatements[] = $schema->createIndex($tableRaw, $uidIndex, [Storage::UID, Storage::TENANT], unique: true, collations: [Storage::UID => 'utf8_ci_ai'])->query; + $indexStatements[] = $schema->createIndex($tableRaw, $createdIndex, [Storage::TENANT, Storage::CREATED_AT])->query; + $indexStatements[] = $schema->createIndex($tableRaw, $updatedIndex, [Storage::TENANT, Storage::UPDATED_AT])->query; + $indexStatements[] = $schema->createIndex($tableRaw, $tenantIdIndex, [Storage::TENANT, Storage::SEQUENCE])->query; + $indexStatements[] = $schema->createIndex($tableRaw, $permissionsIndex, [Storage::PERMISSIONS], method: 'gin')->query; } else { - $uidIndex = $this->getShortKey("{$namespace}_{$id}_uid"); + $uidIndex = $this->getShortKey("{$namespace}_{$id}".Storage::UID); $createdIndex = $this->getShortKey("{$namespace}_{$id}_created"); $updatedIndex = $this->getShortKey("{$namespace}_{$id}_updated"); - $permissionsIndex = $this->getShortKey("{$namespace}_{$id}_permissions"); - $collection .= " - CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai); - CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (\"_createdAt\"); - CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); - CREATE INDEX \"{$permissionsIndex}\" ON {$this->getSQLTable($id)} USING gin (_permissions); - "; - } - - $collection = $this->trigger(Database::EVENT_COLLECTION_CREATE, $collection); - - $permissions = " - CREATE TABLE {$this->getSQLTable($id . '_perms')} ( - _id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - _tenant INTEGER DEFAULT NULL, - _type VARCHAR(12) NOT NULL, - _permission VARCHAR(255) NOT NULL, - _document VARCHAR(255) NOT NULL - ); - "; + $permissionsIndex = $this->getShortKey("{$namespace}_{$id}".Storage::PERMISSIONS); + $indexStatements[] = $schema->createIndex($tableRaw, $uidIndex, [Storage::UID], unique: true, collations: [Storage::UID => 'utf8_ci_ai'])->query; + $indexStatements[] = $schema->createIndex($tableRaw, $createdIndex, [Storage::CREATED_AT])->query; + $indexStatements[] = $schema->createIndex($tableRaw, $updatedIndex, [Storage::UPDATED_AT])->query; + $indexStatements[] = $schema->createIndex($tableRaw, $permissionsIndex, [Storage::PERMISSIONS], method: 'gin')->query; + } + + $collectionSql = $collectionResult->query.'; '.implode('; ', $indexStatements); + + $permissionsTable = $schema->table($permissionsTableRaw); + $permissionsTable->id(Storage::SEQUENCE); + $permissionsTable->integer(Storage::TENANT)->nullable()->default(null); + $permissionsTable->string(Storage::PERMISSIONS_TYPE, 12); + $permissionsTable->string(Storage::PERMISSIONS_PERMISSION, 255); + $permissionsTable->string(Storage::PERMISSIONS_DOCUMENT, 255); + $permissionsResult = $permissionsTable->create(); + + $permissionsIndexStatements = []; if ($this->sharedTables) { - $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey"); - $permissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permission"); - $permissions .= " - CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" - ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission); - CREATE INDEX \"{$permissionIndex}\" - ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type); - "; + $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$this->currentTenant()}_{$id}_ukey"); + $permissionIndex = $this->getShortKey("{$namespace}_{$this->currentTenant()}_{$id}_permission"); + $permissionsIndexStatements[] = $schema->createIndex($permissionsTableRaw, $uniquePermissionIndex, [Storage::TENANT, Storage::PERMISSIONS_DOCUMENT, Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION], unique: true, method: 'btree')->query; + $permissionsIndexStatements[] = $schema->createIndex($permissionsTableRaw, $permissionIndex, [Storage::TENANT, Storage::PERMISSIONS_PERMISSION, Storage::PERMISSIONS_TYPE], method: 'btree')->query; } else { $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$id}_ukey"); $permissionIndex = $this->getShortKey("{$namespace}_{$id}_permission"); - $permissions .= " - CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" - ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission); - CREATE INDEX \"{$permissionIndex}\" - ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type); - "; + $permissionsIndexStatements[] = $schema->createIndex($permissionsTableRaw, $uniquePermissionIndex, [Storage::PERMISSIONS_DOCUMENT, Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION], unique: true, method: 'btree', collations: [Storage::PERMISSIONS_DOCUMENT => 'utf8_ci_ai'])->query; + $permissionsIndexStatements[] = $schema->createIndex($permissionsTableRaw, $permissionIndex, [Storage::PERMISSIONS_PERMISSION, Storage::PERMISSIONS_TYPE], method: 'btree')->query; } - $permissions = $this->trigger(Database::EVENT_COLLECTION_CREATE, $permissions); + $permissionsSql = $permissionsResult->query.'; '.implode('; ', $permissionsIndexStatements); - try { - $this->getPDO()->prepare($collection)->execute(); + $created = false; - $this->getPDO()->prepare($permissions)->execute(); + try { + $this->executeStatement($collectionSql, Event::CollectionCreate); + $created = true; + $this->executeStatement($permissionsSql, Event::CollectionCreate); foreach ($indexes as $index) { - $indexId = $this->filter($index->getId()); - $indexType = $index->getAttribute('type'); - $indexAttributes = $index->getAttribute('attributes', []); $indexAttributesWithType = []; - foreach ($indexAttributes as $indexAttribute) { + foreach ($index->attributes as $indexAttribute) { + $baseAttribute = \explode('.', $indexAttribute, 2)[0]; foreach ($attributes as $attribute) { - if ($attribute->getId() === $indexAttribute) { - $indexAttributesWithType[$indexAttribute] = $attribute->getAttribute('type'); + if ($attribute->key === $baseAttribute) { + $indexAttributesWithType[$indexAttribute] = $attribute->type->value; } } } - $indexOrders = $index->getAttribute('orders', []); - $indexTtl = $index->getAttribute('ttl', 0); - if ($indexType === Database::INDEX_SPATIAL && count($indexOrders)) { + if ($index->type === IndexType::Spatial && $index->orders !== []) { throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); } $this->createIndex( $id, - $indexId, - $indexType, - $indexAttributes, - [], - $indexOrders, + $index->withKey($this->filter($index->key)), $indexAttributesWithType, - [], - $indexTtl + event: Event::CollectionCreate, ); } - } catch (PDOException $e) { - $e = $this->processException($e); + } catch (Throwable $error) { + if ($error instanceof PDOException) { + $error = $this->processException($error); + } - if (!($e instanceof DuplicateException)) { - $this->execute($this->getPDO() - ->prepare("DROP TABLE IF EXISTS {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')};")); + if ($created && ! ($error instanceof DuplicateException)) { + $this->discardCreatedCollection($id); } - throw $e; + throw $error; } return true; } /** - * Get Collection Size on disk - * @param string $collection - * @return int + * Refresh the planner statistics of a collection's table and its permissions table. + * * @throws DatabaseException */ - public function getSizeOfCollectionOnDisk(string $collection): int + #[\Override] + public function analyzeCollection(string $collection): bool { - $collection = $this->filter($collection); - $name = $this->getSQLTable($collection); - $permissions = $this->getSQLTable($collection . '_perms'); - - $collectionSize = $this->getPDO()->prepare(" - SELECT pg_total_relation_size(:name); - "); - - $permissionsSize = $this->getPDO()->prepare(" - SELECT pg_total_relation_size(:permissions); - "); + $name = $this->filter($collection); + $schema = $this->schema(); - $collectionSize->bindParam(':name', $name); - $permissionsSize->bindParam(':permissions', $permissions); + $main = $schema->analyzeTable($this->getTableRaw($name)); + $permissions = $schema->analyzeTable($this->getTableRaw(Storage::permissionsTable($name))); try { - $this->execute($collectionSize); - $this->execute($permissionsSize); - $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn(); + return $this->executeStatement($main->query.'; '.$permissions->query, Event::CollectionUpdate); } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); + throw $this->processException($e); } - - return $size; } /** - * Get Collection Size of raw data - * @param string $collection - * @return int * @throws DatabaseException - * */ - public function getSizeOfCollection(string $collection): int + #[\Override] + public function getSizeOfCollectionOnDisk(string $collection): int { $collection = $this->filter($collection); - $name = $this->getSQLTable($collection); - $permissions = $this->getSQLTable($collection . '_perms'); + $name = $this->getTable($collection); + $permissions = $this->getTable(Storage::permissionsTable($collection)); + + $builder = $this->dialectBuilder(); - $collectionSize = $this->getPDO()->prepare(" - SELECT pg_relation_size(:name); - "); + $collectionResult = $builder->fromNone()->selectRaw('pg_total_relation_size(?)', [$name])->build(); + $permissionsResult = $builder->reset()->fromNone()->selectRaw('pg_total_relation_size(?)', [$permissions])->build(); - $permissionsSize = $this->getPDO()->prepare(" - SELECT pg_relation_size(:permissions); - "); + $collectionSize = $this->executeResult($collectionResult, Event::CollectionRead); + $permissionsSize = $this->executeResult($permissionsResult, Event::CollectionRead); - $collectionSize->bindParam(':name', $name); - $permissionsSize->bindParam(':permissions', $permissions); + foreach ($collectionResult->bindings as $i => $v) { + $collectionSize->bindValue($i + 1, $v); + } + foreach ($permissionsResult->bindings as $i => $v) { + $permissionsSize->bindValue($i + 1, $v); + } try { $this->execute($collectionSize); $this->execute($permissionsSize); - $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn(); + $collVal = $collectionSize->fetchColumn(); + $permVal = $permissionsSize->fetchColumn(); + $size = (int)(\is_numeric($collVal) ? $collVal : 0) + (int)(\is_numeric($permVal) ? $permVal : 0); } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } - return $size; + return $size; } /** - * Delete Collection - * - * @param string $id - * @return bool + * @throws DatabaseException */ - public function deleteCollection(string $id): bool + #[\Override] + public function getSizeOfCollection(string $collection): int { - $id = $this->filter($id); + $collection = $this->filter($collection); + $name = $this->getTable($collection); + $permissions = $this->getTable(Storage::permissionsTable($collection)); + + $builder = $this->dialectBuilder(); + + $collectionResult = $builder->fromNone()->selectRaw('pg_relation_size(?)', [$name])->build(); + $permissionsResult = $builder->reset()->fromNone()->selectRaw('pg_relation_size(?)', [$permissions])->build(); - $sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')}"; - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); + $collectionSize = $this->executeResult($collectionResult, Event::CollectionRead); + $permissionsSize = $this->executeResult($permissionsResult, Event::CollectionRead); + + foreach ($collectionResult->bindings as $i => $v) { + $collectionSize->bindValue($i + 1, $v); + } + foreach ($permissionsResult->bindings as $i => $v) { + $permissionsSize->bindValue($i + 1, $v); + } try { - return $this->getPDO()->prepare($sql)->execute(); + $this->execute($collectionSize); + $this->execute($permissionsSize); + $collVal = $collectionSize->fetchColumn(); + $permVal = $permissionsSize->fetchColumn(); + $size = (int)(\is_numeric($collVal) ? $collVal : 0) + (int)(\is_numeric($permVal) ? $permVal : 0); } catch (PDOException $e) { - throw $this->processException($e); + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } - } - /** - * Analyze a collection updating it's metadata on the database engine - * - * @param string $collection - * @return bool - */ - public function analyzeCollection(string $collection): bool - { - return false; + return $size; } /** - * Create Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * - * @return bool * @throws DatabaseException */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool { - // Ensure pgvector extension is installed for vector types - if ($type === Database::VAR_VECTOR) { - if ($size <= 0) { - throw new DatabaseException('Vector dimensions must be a positive integer'); - } - if ($size > Database::MAX_VECTOR_DIMENSIONS) { - throw new DatabaseException('Vector dimensions cannot exceed ' . Database::MAX_VECTOR_DIMENSIONS); - } - } + self::assertVectorDimensions($attribute); - $name = $this->filter($collection); - $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed, $array, $required); + $this->refuseSharedColumnsOfAnotherType($collection, [$attribute]); - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - ADD COLUMN \"{$id}\" {$type} - "; + $schema = $this->schema(); + $table = $schema->table($this->getTableRaw($collection)); + $this->addAttributeColumn($table, $attribute); + $result = $table->alter(); - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + // Postgres does not support LOCK= on ALTER TABLE, so no lock type appended + $sql = $result->query; try { - return $this->execute($this->getPDO() - ->prepare($sql)); - } catch (PDOException $e) { - throw $this->processException($e); + return $this->executeStatement($sql, Event::AttributeCreate); + } catch (PDOException $error) { + throw $this->processException($error); } } /** - * Delete Attribute + * @param list $attributes * - * @param string $collection - * @param string $id - * @param bool $array - * - * @return bool * @throws DatabaseException */ - public function deleteAttribute(string $collection, string $id, bool $array = false): bool + #[\Override] + public function createAttributes(string $collection, array $attributes): bool { - $name = $this->filter($collection); - $id = $this->filter($id); - - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - DROP COLUMN \"{$id}\"; - "; - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); - - try { - return $this->execute($this->getPDO() - ->prepare($sql)); - } catch (PDOException $e) { - if ($e->getCode() === "42703" && $e->errorInfo[1] === 7) { - return true; - } + $this->refuseSharedColumnsOfAnotherType($collection, $attributes); - throw $e; - } + return parent::createAttributes($collection, $attributes); } /** - * Rename Attribute + * @param array $attributes * - * @param string $collection - * @param string $old - * @param string $new - * @return bool - * @throws Exception - * @throws PDOException + * @throws MismatchException + * @throws DatabaseException */ - public function renameAttribute(string $collection, string $old, string $new): bool + private function refuseSharedColumnsOfAnotherType(string $collection, array $attributes): void { - $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); + if (! $this->sharedTables) { + return; + } - $sql = " - ALTER TABLE {$this->getSQLTable($collection)} - RENAME COLUMN \"{$old}\" TO \"{$new}\" - "; + $statement = $this->prepareStatement( + 'SELECT a.attname, format_type(a.atttypid, a.atttypmod) FROM pg_attribute a WHERE a.attrelid = to_regclass(?) AND a.attnum > 0 AND NOT a.attisdropped', + Event::CollectionRead, + ); + $statement->bindValue(1, $this->getTable($this->filter($collection))); + + try { + $this->execute($statement); + /** @var array $columns */ + $columns = $statement->fetchAll(PDO::FETCH_KEY_PAIR); + $statement->closeCursor(); + } catch (PDOException $error) { + throw $this->processException($error); + } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + foreach ($attributes as $attribute) { + $existing = $columns[$this->filter($attribute->key)] ?? null; + if ($existing === null) { + continue; + } - return $this->execute($this->getPDO() - ->prepare($sql)); + $requested = $this->getAttributeSqlType($attribute); + if ($this->canonicalColumnType($existing) !== $this->canonicalColumnType($requested)) { + throw new MismatchException('Attribute exists in the shared table with another type'); + } + } } /** - * Update Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param string|null $newKey - * @param bool $required - * @return bool * @throws Exception * @throws PDOException */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool { $name = $this->filter($collection); - $id = $this->filter($id); - $newKey = empty($newKey) ? null : $this->filter($newKey); + $id = $this->filter($key); + $newKey = $attribute->key === $key ? null : $this->filter($attribute->key); - if ($type === Database::VAR_VECTOR) { - if ($size <= 0) { - throw new DatabaseException('Vector dimensions must be a positive integer'); - } - if ($size > Database::MAX_VECTOR_DIMENSIONS) { - throw new DatabaseException('Vector dimensions cannot exceed ' . Database::MAX_VECTOR_DIMENSIONS); - } - } + self::assertVectorDimensions($attribute); - $type = $this->getSQLType( - $type, - $size, - $signed, - $array, - $required, - ); + $schema = $this->schema(); - if ($type == 'TIMESTAMP(3)') { - $type = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')"; + if (! empty($newKey) && $this->isRenamed($collection, $id, $newKey)) { + $id = $newKey; + $newKey = null; } - if (!empty($newKey) && $id !== $newKey) { + if (! empty($newKey) && $id !== $newKey) { $newKey = $this->filter($newKey); - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - RENAME COLUMN \"{$id}\" TO \"{$newKey}\" - "; + $renameTable = $schema->table($this->getTableRaw($collection)); + $renameTable->renameColumn($id, $newKey); + $renameResult = $renameTable->alter(); - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + $sql = $renameResult->query; - $result = $this->execute($this->getPDO() - ->prepare($sql)); + try { + $result = $this->executeStatement($sql, Event::AttributeUpdate); + } catch (PDOException $error) { + throw $this->processException($error); + } - if (!$result) { + if (! $result) { return false; } $id = $newKey; } - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - ALTER COLUMN \"{$id}\" TYPE {$type} - "; + $sqlType = $this->getAttributeSqlType($attribute); + $tableRaw = $this->getTableRaw($name); - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + if ($sqlType == 'TIMESTAMP(3)') { + $result = $schema->alterColumnType($tableRaw, $id, 'TIMESTAMP(3)', $this->quote($id).'::TIMESTAMP(3)'); + } else { + $result = $schema->alterColumnType($tableRaw, $id, $sqlType); + } + + $sql = $result->query; try { - $result = $this->execute($this->getPDO() - ->prepare($sql)); + $ok = $this->executeStatement($sql, Event::AttributeUpdate); + + // Postgres carries NOT NULL through ALTER COLUMN ... TYPE, so an + // attribute that stops being required keeps a constraint its + // definition no longer claims. Only the relaxing direction is + // applied: tightening would fail against rows already holding + // null, and MySQL does not tighten on update either. + if ($ok && ! $attribute->required) { + $nullable = $schema->alterColumnNullable($tableRaw, $id, true); + $ok = $this->executeStatement($nullable->query, Event::AttributeUpdate); + } - return $result; - } catch (PDOException $e) { - throw $this->processException($e); + return $ok; + } catch (PDOException $error) { + throw $this->processException($error); } } /** - * @param string $collection - * @param string $id - * @param string $type - * @param string $relatedCollection - * @param bool $twoWay - * @param string $twoWayKey - * @return bool - * @throws Exception + * @throws DatabaseException */ - public function createRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay = false, - string $id = '', - string $twoWayKey = '' - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $id = $this->filter($id); - $twoWayKey = $this->filter($twoWayKey); - $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false, false, false); - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - $sql = "ALTER TABLE {$table} ADD COLUMN \"{$id}\" {$sqlType} DEFAULT NULL;"; - - if ($twoWay) { - $sql .= "ALTER TABLE {$relatedTable} ADD COLUMN \"{$twoWayKey}\" {$sqlType} DEFAULT NULL;"; - } - break; - case Database::RELATION_ONE_TO_MANY: - $sql = "ALTER TABLE {$relatedTable} ADD COLUMN \"{$twoWayKey}\" {$sqlType} DEFAULT NULL;"; - break; - case Database::RELATION_MANY_TO_ONE: - $sql = "ALTER TABLE {$table} ADD COLUMN \"{$id}\" {$sqlType} DEFAULT NULL;"; - break; - case Database::RELATION_MANY_TO_MANY: - return true; - default: - throw new DatabaseException('Invalid relationship type'); + private static function assertVectorDimensions(Attribute $attribute): void + { + if ($attribute->type !== ColumnType::Vector) { + return; } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + $dimensions = $attribute->size ?? 0; + if ($dimensions <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } - return $this->execute($this->getPDO() - ->prepare($sql)); + if ($dimensions > Database::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed '.Database::MAX_VECTOR_DIMENSIONS); + } } - /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @param string|null $newKey - * @param string|null $newTwoWayKey - * @return bool - * @throws DatabaseException - */ - public function updateRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side, - ?string $newKey = null, - ?string $newTwoWayKey = null, - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + #[\Override] + public function relaxAttributeRequired(string $collection, string $id): bool + { + $schema = $this->schema(); + $statement = $schema->alterColumnNullable( + $this->getTableRaw($this->filter($collection)), + $this->filter($id), + true, + ); - if (!\is_null($newKey)) { - $newKey = $this->filter($newKey); - } - if (!\is_null($newTwoWayKey)) { - $newTwoWayKey = $this->filter($newTwoWayKey); + try { + return $this->executeStatement($statement->query, Event::AttributeUpdate); + } catch (PDOException $e) { + throw $this->processException($e); } + } - $sql = ''; + /** + * @throws DatabaseException + */ + #[\Override] + public function deleteAttribute(string $collection, string $key): bool + { + $schema = $this->schema(); + $table = $schema->table($this->getTableRaw($collection)); + $table->dropColumn($this->filter($key)); + $result = $table->alter(); - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($key !== $newKey) { - $sql = "ALTER TABLE {$table} RENAME COLUMN \"{$key}\" TO \"{$newKey}\";"; - } - if ($twoWay && $twoWayKey !== $newTwoWayKey) { - $sql .= "ALTER TABLE {$relatedTable} RENAME COLUMN \"{$twoWayKey}\" TO \"{$newTwoWayKey}\";"; - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - if ($twoWayKey !== $newTwoWayKey) { - $sql = "ALTER TABLE {$relatedTable} RENAME COLUMN \"{$twoWayKey}\" TO \"{$newTwoWayKey}\";"; - } - } else { - if ($key !== $newKey) { - $sql = "ALTER TABLE {$table} RENAME COLUMN \"{$key}\" TO \"{$newKey}\";"; - } - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - if ($twoWayKey !== $newTwoWayKey) { - $sql = "ALTER TABLE {$relatedTable} RENAME COLUMN \"{$twoWayKey}\" TO \"{$newTwoWayKey}\";"; - } - } else { - if ($key !== $newKey) { - $sql = "ALTER TABLE {$table} RENAME COLUMN \"{$key}\" TO \"{$newKey}\";"; - } - } - break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); + $sql = $result->query; - $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); + try { + return $this->executeStatement($sql, Event::AttributeDelete); + } catch (PDOException $e) { + if ($e->getCode() === '42703' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return true; + } - if (!\is_null($newKey)) { - $sql = "ALTER TABLE {$junction} RENAME COLUMN \"{$key}\" TO \"{$newKey}\";"; - } - if ($twoWay && !\is_null($newTwoWayKey)) { - $sql .= "ALTER TABLE {$junction} RENAME COLUMN \"{$twoWayKey}\" TO \"{$newTwoWayKey}\";"; - } - break; - default: - throw new DatabaseException('Invalid relationship type'); + throw $e; } + } - if (empty($sql)) { + /** + * @throws Exception + * @throws PDOException + */ + #[\Override] + public function renameAttribute(string $collection, string $old, string $new): bool + { + if ($this->isRenamed($collection, $old, $new)) { return true; } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + $schema = $this->schema(); + $table = $schema->table($this->getTableRaw($collection)); + $table->renameColumn($this->filter($old), $this->filter($new)); + $result = $table->alter(); + + $sql = $result->query; - return $this->execute($this->getPDO() - ->prepare($sql)); + try { + return $this->executeStatement($sql, Event::AttributeUpdate); + } catch (PDOException $e) { + throw $this->processException($e); + } } /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $key - * @param string $twoWayKey - * @param string $side - * @return bool + * @return array + * * @throws DatabaseException */ - public function deleteRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); - - $sql = ''; - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $sql = "ALTER TABLE {$table} DROP COLUMN \"{$key}\";"; - if ($twoWay) { - $sql .= "ALTER TABLE {$relatedTable} DROP COLUMN \"{$twoWayKey}\";"; - } - } elseif ($side === Database::RELATION_SIDE_CHILD) { - $sql = "ALTER TABLE {$relatedTable} DROP COLUMN \"{$twoWayKey}\";"; - if ($twoWay) { - $sql .= "ALTER TABLE {$table} DROP COLUMN \"{$key}\";"; - } - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - $sql = "ALTER TABLE {$relatedTable} DROP COLUMN \"{$twoWayKey}\";"; - } else { - $sql = "ALTER TABLE {$table} DROP COLUMN \"{$key}\";"; - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - $sql = "ALTER TABLE {$relatedTable} DROP COLUMN \"{$twoWayKey}\";"; - } else { - $sql = "ALTER TABLE {$table} DROP COLUMN \"{$key}\";"; - } - break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); - - $junction = $side === Database::RELATION_SIDE_PARENT - ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()) - : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence()); - - $perms = $side === Database::RELATION_SIDE_PARENT - ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() . '_perms') - : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence() . '_perms'); - - $sql = "DROP TABLE {$junction}; DROP TABLE {$perms}"; - break; - default: - throw new DatabaseException('Invalid relationship type'); - } + #[\Override] + protected function getColumnNames(string $collection): array + { + $statement = $this->prepareStatement( + 'SELECT a.attname FROM pg_attribute a WHERE a.attrelid = to_regclass(?) AND a.attnum > 0 AND NOT a.attisdropped', + Event::CollectionRead, + ); + $statement->bindValue(1, $this->getTable($collection)); - if (empty($sql)) { - return true; + try { + $this->execute($statement); + /** @var array $columns */ + $columns = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); - - return $this->execute($this->getPDO() - ->prepare($sql)); + return $columns; } /** - * Create Index + * @return list * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - - * @return bool + * @throws DatabaseException */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool - { - $collection = $this->filter($collection); - $id = $this->filter($id); - - foreach ($attributes as $i => $attr) { - $order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i]; - $isNestedPath = isset($indexAttributeTypes[$attr]) && \str_contains($attr, '.') && $indexAttributeTypes[$attr] === Database::VAR_OBJECT; - if ($isNestedPath) { - $attributes[$i] = $this->buildJsonbPath($attr, true) . ($order ? " {$order}" : ''); - } else { - $attr = $this->getInternalKeyForAttribute($attr); - $attr = $this->filter($attr); - - $attributes[$i] = "\"{$attr}\" {$order}"; - } - } - - $sqlType = match ($type) { - Database::INDEX_KEY, - Database::INDEX_FULLTEXT, - Database::INDEX_SPATIAL, - Database::INDEX_HNSW_EUCLIDEAN, - Database::INDEX_HNSW_COSINE, - Database::INDEX_HNSW_DOT, - Database::INDEX_OBJECT, - Database::INDEX_TRIGRAM => 'INDEX', - Database::INDEX_UNIQUE => 'UNIQUE INDEX', - default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT), - }; - - $keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); - $attributes = \implode(', ', $attributes); - - if ($this->sharedTables && \in_array($type, [Database::INDEX_KEY, Database::INDEX_UNIQUE])) { - // Add tenant as first index column for best performance - $attributes = "_tenant, {$attributes}"; - } - - $sql = "CREATE {$sqlType} \"{$keyName}\" ON {$this->getSQLTable($collection)}"; - - // Add USING clause for special index types - $sql .= match ($type) { - Database::INDEX_SPATIAL => " USING GIST ({$attributes})", - Database::INDEX_HNSW_EUCLIDEAN => " USING HNSW ({$attributes} vector_l2_ops)", - Database::INDEX_HNSW_COSINE => " USING HNSW ({$attributes} vector_cosine_ops)", - Database::INDEX_HNSW_DOT => " USING HNSW ({$attributes} vector_ip_ops)", - Database::INDEX_OBJECT => " USING GIN ({$attributes})", - Database::INDEX_TRIGRAM => - " USING GIN (" . implode(', ', array_map( - fn ($attr) => "$attr gin_trgm_ops", - array_map(fn ($attr) => trim($attr), explode(',', $attributes)) - )) . ")", - default => " ({$attributes})", - }; - - $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); + #[\Override] + public function getSchemaAttributes(string $collection): array + { + $statement = $this->prepareStatement( + 'SELECT a.attname AS name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, + CASE WHEN a.atttypid IN (1042, 1043) AND a.atttypmod > 4 THEN a.atttypmod - 4 END AS length, + NOT a.attnotnull AS nullable + FROM pg_catalog.pg_attribute a + WHERE a.attrelid = to_regclass(?) AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum', + Event::CollectionRead, + ); + $statement->bindValue(1, $this->getTable($collection)); try { - return $this->getPDO()->prepare($sql)->execute(); + $this->execute($statement); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + $statement->closeCursor(); } catch (PDOException $e) { - // Existing rows violate the new unique index. Classified here because an - // expression key (nested object path) has no columns for processException() to parse. - if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - throw new UniqueException('Unique index violation', $e->getCode(), $e); - } - throw $this->processException($e); } - } - /** - * Delete Index - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws Exception - */ - public function deleteIndex(string $collection, string $id): bool - { - $collection = $this->filter($collection); - $id = $this->filter($id); - $schemaName = $this->getDatabase(); - $keyName = $this->getShortKey("{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}"); + $columns = []; + foreach ($rows as $row) { + if (! \is_array($row) || ! \is_string($row['name'] ?? null)) { + continue; + } - $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".\"{$keyName}\""; - $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); + $type = $row['type'] ?? ''; + $length = $row['length'] ?? null; + $columns[] = new SchemaColumn( + name: $row['name'], + type: $this->canonicalColumnType(\is_string($type) ? $type : ''), + length: \is_numeric($length) ? (int) $length : null, + nullable: self::isTrue($row['nullable'] ?? false), + ); + } - return $this->execute($this->getPDO() - ->prepare($sql)); + return $columns; } /** - * Rename Index - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool - * @throws Exception - * @throws PDOException + * A fulltext index is a plain btree index here, which the catalog reports as a key. */ - public function renameIndex(string $collection, string $old, string $new): bool + #[\Override] + public function getSchemaIndexType(IndexType $type): IndexType { - $collection = $this->filter($collection); - $namespace = $this->getNamespace(); - $old = $this->filter($old); - $new = $this->filter($new); - $schema = $this->getDatabase(); - $oldIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$old}"); - $newIndexName = $this->getShortKey("{$namespace}_{$this->tenant}_{$collection}_{$new}"); - - $sql = "ALTER INDEX \"{$schema}\".\"{$oldIndexName}\" RENAME TO \"{$newIndexName}\""; - $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); - - return $this->execute($this->getPDO() - ->prepare($sql)); + return $type === IndexType::Fulltext ? IndexType::Key : $type; } /** - * Create Document + * Under shared tables every tenant keeps its own copy of an index, named after it: the current tenant's are + * reported under their keys, any other index under its physical name. * - * @param Document $collection - * @param Document $document + * @return list * - * @return Document + * @throws DatabaseException */ - public function createDocument(Document $collection, Document $document): Document - { - $collection = $collection->getId(); - $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); + #[\Override] + public function getSchemaIndexes(string $collection): array + { + $statement = $this->prepareStatement( + 'SELECT i.relname AS name, + x.indisunique AS "unique", + am.amname AS method, + (SELECT o.opcname FROM pg_catalog.pg_opclass o WHERE o.oid = x.indclass[k.position - 1]) AS operator, + pg_catalog.pg_get_indexdef(x.indexrelid, k.position, true) AS "column" + FROM pg_catalog.pg_index x + JOIN pg_catalog.pg_class i ON i.oid = x.indexrelid + JOIN pg_catalog.pg_am am ON am.oid = i.relam + CROSS JOIN LATERAL pg_catalog.generate_series(1, x.indnkeyatts) AS k(position) + WHERE x.indrelid = to_regclass(?) + ORDER BY i.relname, k.position', + Event::CollectionRead, + ); + $statement->bindValue(1, $this->getTable($collection)); - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + try { + $this->execute($statement); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + $statement->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); } - $name = $this->filter($collection); - $columns = ''; - $columnNames = ''; + $prefix = "{$this->getNamespace()}_{$this->currentTenant()}_{$this->filter($collection)}_"; - // Insert internal id if set - if (!empty($document->getSequence())) { - $bindKey = '_id'; - $columns .= "\"_id\", "; - $columnNames .= ':' . $bindKey . ', '; + $grouped = []; + foreach ($rows as $row) { + if (! \is_array($row) || ! \is_string($row['name'] ?? null)) { + continue; + } + + $name = $row['name']; + if (! isset($grouped[$name])) { + $operator = $row['operator'] ?? ''; + $grouped[$name] = [ + 'type' => match (true) { + self::isTrue($row['unique'] ?? false) => IndexType::Unique, + $operator === 'gin_trgm_ops' => IndexType::Trigram, + $operator === 'vector_l2_ops' => IndexType::HnswEuclidean, + $operator === 'vector_cosine_ops' => IndexType::HnswCosine, + $operator === 'vector_ip_ops' => IndexType::HnswDot, + ($row['method'] ?? '') === 'gin' => IndexType::Object, + ($row['method'] ?? '') === 'gist' => IndexType::Spatial, + default => IndexType::Key, + }, + 'columns' => [], + ]; + } + + $column = \is_string($row['column'] ?? null) ? $row['column'] : ''; + if (\preg_match('/^"(.*)"$/s', $column, $matches) === 1) { + $column = \str_replace('""', '"', $matches[1]); + } + $grouped[$name]['columns'][] = $column; } - $bindIndex = 0; - foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); - $bindKey = 'key_' . $bindIndex; - $columns .= "\"{$column}\", "; - $columnNames .= ':' . $bindKey . ', '; - $bindIndex++; + $indexes = []; + foreach ($grouped as $name => $index) { + $name = (string) $name; + $indexes[] = new SchemaIndex( + name: \str_starts_with($name, $prefix) ? \substr($name, \strlen($prefix)) : $name, + type: $index['type'], + columns: $index['columns'], + lengths: \array_fill(0, \count($index['columns']), null), + ); } - $sql = " - INSERT INTO {$this->getSQLTable($name)} ({$columns} \"_uid\") - VALUES ({$columnNames} :_uid) - "; + return $indexes; + } + + #[\Override] + protected function canonicalColumnType(string $type): string + { + return \strtr(\strtoupper(\trim($type)), self::CATALOG_TYPE_SPELLINGS); + } - $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql); + private static function isTrue(mixed $value): bool + { + return $value === true || $value === 't' || $value === 1 || $value === '1'; + } - $stmt = $this->getPDO()->prepare($sql); + /** + * @param array $indexAttributeTypes + * @param array $collation + */ + #[\Override] + public function createIndex( + string $collection, + Index $index, + array $indexAttributeTypes = [], + array $collation = [], + Event $event = Event::IndexCreate, + ): bool { + $collection = $this->filter($collection); + $id = $this->filter($index->key); + $type = $index->type; + + match ($type) { + IndexType::Key, + IndexType::Fulltext, + IndexType::Spatial, + IndexType::HnswEuclidean, + IndexType::HnswCosine, + IndexType::HnswDot, + IndexType::Object, + IndexType::Trigram, + IndexType::Unique => true, + default => throw new DatabaseException('Unknown index type: '.$type->value.'. Must be one of '.IndexType::Key->value.', '.IndexType::Unique->value.', '.IndexType::Fulltext->value.', '.IndexType::Spatial->value.', '.IndexType::Object->value.', '.IndexType::HnswEuclidean->value.', '.IndexType::HnswCosine->value.', '.IndexType::HnswDot->value), + }; - $stmt->bindValue(':_uid', $document->getId(), PDO::PARAM_STR); + $keyName = $this->getIndexName($collection, $id, $this->currentTenant()); + $tableRaw = $this->getTableRaw($collection); + $schema = $this->schema(); - if (!empty($document->getSequence())) { - $stmt->bindValue(':_id', $document->getSequence(), PDO::PARAM_STR); - } + $operatorClass = match ($type) { + IndexType::HnswEuclidean => 'vector_l2_ops', + IndexType::HnswCosine => 'vector_cosine_ops', + IndexType::HnswDot => 'vector_ip_ops', + IndexType::Trigram => 'gin_trgm_ops', + default => '', + }; - $attributeIndex = 0; - foreach ($attributes as $value) { - if (\is_array($value)) { - $value = \json_encode($value); - } + $columns = []; + foreach ($index->attributes as $position => $attribute) { + $isNestedPath = isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === ColumnType::Object->value; + $column = $isNestedPath + ? $this->buildJsonbPath($attribute, true) + : $this->quote($this->filter($this->getInternalKeyForAttribute($attribute))); + $order = $type === IndexType::Fulltext ? '' : ($index->orders[$position]->value ?? ''); - $bindKey = 'key_' . $attributeIndex; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; + $columns[] = $column + .($operatorClass !== '' ? ' '.$operatorClass : '') + .($order !== '' ? ' '.$order : ''); } - $permissions = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { - $permission = \str_replace('"', '', $permission); - $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $permissions[] = "('{$type}', '{$permission}', :_uid {$sqlTenant})"; - } + if ($this->sharedTables && \in_array($type, [IndexType::Key, IndexType::Unique])) { + \array_unshift($columns, $this->quote(Storage::TENANT)); } + $unique = $type === IndexType::Unique; - if (!empty($permissions)) { - $permissions = \implode(', ', $permissions); - $sqlTenant = $this->sharedTables ? ', _tenant' : ''; + $method = match ($type) { + IndexType::Spatial => 'gist', + IndexType::Object => 'gin', + IndexType::Trigram => 'gin', + IndexType::HnswEuclidean, + IndexType::HnswCosine, + IndexType::HnswDot => 'hnsw', + default => '', + }; - $queryPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$sqlTenant}) - VALUES {$permissions} - "; + $sql = $schema->createIndex( + $tableRaw, + $keyName, + [], + unique: $unique, + method: $method, + rawColumns: $columns, + )->query; - $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); - $stmtPermissions = $this->getPDO()->prepare($queryPermissions); - $stmtPermissions->bindValue(':_uid', $document->getId()); - if ($sqlTenant) { - $stmtPermissions->bindValue(':_tenant', $document->getTenant()); + try { + return $this->executeStatement($sql, $event); + } catch (PDOException $error) { + if ($error->getCode() === '23505' && isset($error->errorInfo[1]) && $error->errorInfo[1] === 7) { + throw new UniqueException(UniqueException::MESSAGE, $error->getCode(), $error); } + + throw $this->processException($error); } + } - try { - $this->execute($stmt); - $lastInsertedId = $this->getPDO()->lastInsertId(); - // Sequence can be manually set as well - $document['$sequence'] ??= $lastInsertedId; + /** + * @throws Exception + */ + #[\Override] + public function deleteIndex(string $collection, string $key): bool + { + $collection = $this->filter($collection); + $id = $this->filter($key); - if (isset($stmtPermissions)) { - $this->execute($stmtPermissions); - } - } catch (PDOException $e) { - throw $this->processException($e); - } + $keyName = $this->getIndexName($collection, $id, $this->currentTenant()); + $schemaQualifiedName = $this->getDatabase().'.'.$keyName; - return $document; + $schema = $this->schema(); + $sql = $schema->dropIndex($this->getTableRaw($collection), $schemaQualifiedName)->query; + // Add IF EXISTS since the schema builder's dropIndex does not include it + $sql = str_replace('DROP INDEX', 'DROP INDEX IF EXISTS', $sql); + + return $this->executeStatement($sql, Event::IndexDelete); } /** - * Update Document + * Reports the index renamed when the schema holds it under the new name afterwards. Under shared tables an + * index is named after the tenant that created it, so a tenant without its own copy is renamed in its metadata + * when another tenant's copy of the collection's index exists under the old or the new name. * - * - * @param Document $collection - * @param string $id - * @param Document $document - * @param bool $skipPermissions - * @return Document - * @throws DatabaseException - * @throws DuplicateException + * @throws Exception + * @throws PDOException */ - public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool { - $spatialAttributes = $this->getSpatialAttributes($collection); - $collection = $collection->getId(); - $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); - $attributes['_uid'] = $document->getId(); - $name = $this->filter($collection); - $columns = ''; - - if (!$skipPermissions) { - $newUid = $document->offsetExists('$id') ? $document->getId() : $id; + $old = $this->filter($old); + $new = $this->filter($new); + $oldIndexName = $this->getIndexName($name, $old, $this->currentTenant()); + $newIndexName = $this->getIndexName($name, $new, $this->currentTenant()); - $sql = " - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; + $schemaBuilder = $this->schema(); + $sql = $schemaBuilder->renameIndex($this->getTableRaw($name), $this->getDatabase().'.'.$oldIndexName, $newIndexName)->query; + $sql = \str_replace('ALTER INDEX', 'ALTER INDEX IF EXISTS', $sql); - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + $this->executeStatement($sql, Event::IndexRename); - $stmtRemovePermissions = $this->getPDO()->prepare($sql); - $stmtRemovePermissions->bindValue(':_uid', $id); - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $document->getTenant()); + $names = [$newIndexName]; + if ($this->sharedTables) { + foreach ($this->getCollectionCreators($collection) as $creator) { + \array_push($names, $this->getIndexName($name, $old, $creator), $this->getIndexName($name, $new, $creator)); } + } - $values = []; - $binds = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $permission) { - $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$sqlTenant})"; - $binds[":_add_{$type}_{$i}"] = $permission; - } - } + return $this->anyIndexExists($names); + } + + private function getIndexName(string $collection, string $id, int|string|null $tenant): string + { + return $this->getShortKey("{$this->getNamespace()}_{$tenant}_{$collection}_{$id}"); + } + + /** + * The tenant whose definition of a shared collection was stored first, null for a tenantless one: the one that + * created its table and the indexes declared with it, which back the same indexes of every later tenant. Empty + * when the collection has no definition, so a tenantless creator is told apart from none. + * + * @return list + * + * @throws DatabaseException + */ + private function getCollectionCreators(string $collection): array + { + $statement = $this->prepareStatement( + 'SELECT '.$this->quote(Storage::TENANT).' FROM '.$this->getTable(Database::METADATA).' WHERE '.$this->quote(Storage::UID).' = ? ORDER BY '.$this->quote(Storage::SEQUENCE).' ASC LIMIT 1', + Event::IndexRename, + ); + $statement->bindValue(1, $collection); - if (!empty($values)) { - $sqlTenant = $this->sharedTables ? ', _tenant' : ''; + try { + $this->execute($statement); + $row = $statement->fetch(PDO::FETCH_NUM); + $statement->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); + } - $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$sqlTenant}) - VALUES " . \implode(', ', $values); + if (! \is_array($row)) { + return []; + } - $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); + $tenant = $row[0] ?? null; - $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $newUid); - if ($this->sharedTables) { - $stmtAddPermissions->bindValue(':_tenant', $document->getTenant()); - } + return [\is_int($tenant) || \is_string($tenant) ? $tenant : null]; + } - foreach ($binds as $key => $permission) { - $stmtAddPermissions->bindValue($key, $permission); - } - } + /** + * @param list $names + * + * @throws DatabaseException + */ + private function anyIndexExists(array $names): bool + { + $names = \array_values(\array_unique($names)); + $placeholders = \implode(', ', \array_fill(0, \count($names), '?')); + $statement = $this->prepareStatement( + "SELECT c.relname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = ? AND c.relkind = 'i' AND c.relname IN ({$placeholders})", + Event::IndexRename, + ); + $statement->bindValue(1, $this->getDatabase()); + foreach ($names as $position => $indexName) { + $statement->bindValue($position + 2, $indexName); } - /** - * Update Attributes - */ - - $keyIndex = 0; - $operatorBinds = []; - - foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); - - // Check if this is an operator, spatial attribute, or regular attribute - if (Operator::isOperator($value)) { - $operatorSQL = $this->getOperatorSQL($column, $value, $operatorBinds); - $columns .= $operatorSQL . ','; - } elseif (\in_array($attribute, $spatialAttributes, true)) { - $bindKey = 'key_' . $keyIndex; - $columns .= "\"{$column}\" = " . $this->getSpatialGeomFromText(':' . $bindKey) . ','; - $keyIndex++; - } else { - $bindKey = 'key_' . $keyIndex; - $columns .= "\"{$column}\"" . '=:' . $bindKey . ','; - $keyIndex++; - } + try { + $this->execute($statement); + $found = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); } - $sql = " - UPDATE {$this->getSQLTable($name)} - SET " . \rtrim($columns, ',') . " - WHERE _id=:_sequence - {$this->getTenantQuery($collection)} - "; + return $found !== []; + } - $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + try { + $this->syncWriteHooks(); - $stmt = $this->getPDO()->prepare($sql); + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); + $attributes = $document->getAttributes(); + $attributes[Storage::CREATED_AT] = $document->getCreatedAt(); + $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt(); + $attributes[Storage::PERMISSIONS] = \json_encode($document->getPermissions()); - $stmt->bindValue(':_sequence', $document->getSequence()); + $name = $this->filter($collection); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } + $builder = $this->dialectBuilder()->into($this->getTableRaw($name)); - $keyIndex = 0; - foreach ($attributes as $attribute => $value) { - // Handle operators separately - if (Operator::isOperator($value)) { - continue; + $row = [Storage::UID => $document->getId()]; + if (! empty($document->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); } - // Convert spatial arrays to WKT, json_encode non-spatial arrays - if (\in_array($attribute, $spatialAttributes, true)) { - if (\is_array($value)) { - $value = $this->convertArrayToWKT($value); - } - } elseif (is_array($value)) { - $value = json_encode($value); + foreach ($spatialAttributes as $spatialColumn) { + $builder->insertColumnExpression($spatialColumn, $this->getSpatialGeometryFromText('?')); } - $bindKey = 'key_' . $keyIndex; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $keyIndex++; - } + $spatialMap = \array_fill_keys($spatialAttributes, true); - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + foreach ($attributes as $attribute => $value) { + $column = $this->filter($attribute); - try { - $this->execute($stmt); - if (isset($stmtRemovePermissions)) { - $this->execute($stmtRemovePermissions); - } - if (isset($stmtAddPermissions)) { - $this->execute($stmtAddPermissions); + if (isset($spatialMap[$attribute])) { + $row[$column] = $this->encodeSpatialWriteValue($value); + $builder->insertColumnExpression($column, $this->getSpatialGeometryFromText('?')); + } else { + if (\is_array($value)) { + $value = \json_encode($value); + } + $row[$column] = $value; + } } + + $row = $this->decorateRow($row, $document); + $builder->set($row); + $result = $builder->insert(); + $statement = $this->executeResult($result, Event::DocumentCreate); + + $this->execute($statement); + $lastInsertedId = $this->getDriver()->lastInsertId(); + $document[Document::SEQUENCE] ??= $lastInsertedId; + + $context = $this->writeContext(); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $context)); } catch (PDOException $e) { throw $this->processException($e); } @@ -1250,1430 +1115,979 @@ public function updateDocument(Document $collection, string $id, Document $docum } /** - * @param string $tableName - * @param string $columns - * @param array $batchKeys - * @param array $attributes - * @param array $bindValues - * @param string $attribute - * @param array $operators - * @return mixed + * @throws DatabaseException + * @throws DuplicateException */ - protected function getUpsertStatement( - string $tableName, - string $columns, - array $batchKeys, - array $attributes, - array $bindValues, - string $attribute = '', - array $operators = [], - ): mixed { - $getUpdateClause = function (string $attribute, bool $increment = false): string { - $attribute = $this->quote($this->filter($attribute)); - if ($increment) { - $new = "target.{$attribute} + EXCLUDED.{$attribute}"; - } else { - $new = "EXCLUDED.{$attribute}"; + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + try { + $this->syncWriteHooks(); + + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); + $attributes = $document->getAttributes(); + $attributes[Storage::CREATED_AT] = $document->getCreatedAt(); + $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt(); + $attributes[Storage::PERMISSIONS] = \json_encode($document->getPermissions()); + + $name = $this->filter($collection); + + $operators = []; + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; + } } - if ($this->sharedTables) { - return "{$attribute} = CASE WHEN target._tenant = EXCLUDED._tenant THEN {$new} ELSE target.{$attribute} END"; + $builder = $this->newBuilder($name); + $row = []; + if ($document->getId() !== $id) { + $row[Storage::UID] = $document->getId(); } - return "{$attribute} = {$new}"; - }; + $spatialMap = \array_fill_keys($spatialAttributes, true); - $operatorBinds = []; + foreach ($attributes as $attribute => $value) { + $column = $this->filter($attribute); - if (!empty($attribute)) { - // Increment specific column by its new value in place - $updateColumns = [ - $getUpdateClause($attribute, increment: true), - $getUpdateClause('_updatedAt'), - ]; - } else { - // Update all columns and apply operators - $updateColumns = []; - foreach (array_keys($attributes) as $attr) { - /** - * @var string $attr - */ - $filteredAttr = $this->filter($attr); - - // Check if this attribute has an operator - if (isset($operators[$attr])) { - $operatorSQL = $this->getOperatorSQL($filteredAttr, $operators[$attr], $operatorBinds, useTargetPrefix: true); - if ($operatorSQL !== null) { - $updateColumns[] = $operatorSQL; + if (isset($operators[$attribute])) { + $operation = $operators[$attribute]; + if ($operation instanceof Operator) { + $expression = $this->getOperatorBuilderExpression($column, $operation); + $builder->setRaw($column, $expression->sql, $expression->bindings); } + } elseif (isset($spatialMap[$attribute])) { + $builder->setRaw($column, $this->getSpatialGeometryFromText('?'), [$this->encodeSpatialWriteValue($value)]); } else { - if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) { - $updateColumns[] = $getUpdateClause($filteredAttr); + if (\is_array($value)) { + $value = \json_encode($value); } + $row[$column] = $value; } } - } - - $conflictKeys = $this->sharedTables ? '("_uid", _tenant)' : '("_uid")'; - $stmt = $this->getPDO()->prepare( - " - INSERT INTO {$this->getSQLTable($tableName)} AS target {$columns} - VALUES " . implode(', ', $batchKeys) . " - ON CONFLICT {$conflictKeys} DO UPDATE - SET " . implode(', ', $updateColumns) - ); + $builder->set($row); + $filters = [BaseQuery::equal(Storage::SEQUENCE, [$document->getSequence()])]; + $builder->filter($filters); + $result = $builder->update(); + $statement = $this->executeResult($result, Event::DocumentUpdate); - foreach ($bindValues as $key => $binding) { - $stmt->bindValue($key, $binding, $this->getPDOType($binding)); - } + $this->execute($statement); - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); + $context = $this->writeContext($skipPermissions ? [$document->getId() => true] : []); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpdate($name, $id, $document, $context)); + } catch (PDOException $e) { + throw $this->processException($e); } - return $stmt; + return $document; } /** - * Increase or decrease an attribute value - * - * @param string $collection - * @param string $id - * @param string $attribute - * @param int|float $value - * @param string $updatedAt - * @param int|float|null $min - * @param int|float|null $max - * @return bool * @throws DatabaseException */ - public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, string $updatedAt, int|float|null $min = null, int|float|null $max = null): bool + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void { - $name = $this->filter($collection); - $attribute = $this->filter($attribute); - - $sqlMax = $max !== null ? " AND \"{$attribute}\" <= :max" : ""; - $sqlMin = $min !== null ? " AND \"{$attribute}\" >= :min" : ""; - - $sql = " - UPDATE {$this->getSQLTable($name)} - SET - \"{$attribute}\" = \"{$attribute}\" + :val, - \"_updatedAt\" = :updatedAt - WHERE _uid = :_uid - {$this->getTenantQuery($collection)} - "; - - $sql .= $sqlMax . $sqlMin; + if ($milliseconds <= 0) { + throw new DatabaseException('Timeout must be greater than 0'); + } - $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); + $this->setTimeoutState($milliseconds, $event); + } - $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':_uid', $id); - $stmt->bindValue(':val', $value); - $stmt->bindValue(':updatedAt', $updatedAt); + #[\Override] + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); + } - if ($max !== null) { - $stmt->bindValue(':max', $max); - } - if ($min !== null) { - $stmt->bindValue(':min', $min); - } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } + /** + * @return array + */ + #[\Override] + public function decode(string $value, ColumnType $type): array + { + return match ($type) { + ColumnType::Point => $this->decodePoint($value), + ColumnType::Linestring => $this->decodeLinestring($value), + ColumnType::Polygon => $this->decodePolygon($value), + default => throw new DatabaseException('Unknown spatial type: '.$type->value), + }; + } - $this->execute($stmt) || throw new DatabaseException('Failed to update attribute'); - return true; + #[\Override] + public function encode(mixed $value, ColumnType $type): string + { + return Wkt::encode($value, $type); } /** - * Delete Document + * Decode a WKB or WKT POINT into a coordinate array [x, y]. * - * @param string $collection - * @param string $id + * @param string $wkb The WKB hex or WKT string + * @return array * - * @return bool + * @throws DatabaseException If the input is invalid. */ - public function deleteDocument(string $collection, string $id): bool + protected function decodePoint(string $wkb): array { - $name = $this->filter($collection); + if (str_starts_with(strtoupper($wkb), 'POINT(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, $end - $start); - $sql = " - DELETE FROM {$this->getSQLTable($name)} - WHERE _uid = :_uid - {$this->getTenantQuery($collection)} - "; + $coords = explode(' ', trim($inside)); - $sql = $this->trigger(Database::EVENT_DOCUMENT_DELETE, $sql); - $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':_uid', $id, PDO::PARAM_STR); + return [(float) $coords[0], (float) $coords[1]]; + } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); + $bin = hex2bin($wkb); + if ($bin === false) { + throw new DatabaseException('Invalid hex WKB string'); } - $sql = " - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; + if (strlen($bin) < 13) { // 1 byte endian + 4 bytes type + 8 bytes for X + throw new DatabaseException('WKB too short'); + } - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + $isLE = ord($bin[0]) === 1; - $stmtPermissions = $this->getPDO()->prepare($sql); - $stmtPermissions->bindValue(':_uid', $id); + // Type (4 bytes) + $typeBytes = substr($bin, 1, 4); + if (strlen($typeBytes) !== 4) { + throw new DatabaseException('Failed to extract type bytes from WKB'); + } - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); + $typeArr = unpack($isLE ? 'V' : 'N', $typeBytes); + if ($typeArr === false || ! isset($typeArr[1])) { + throw new DatabaseException('Failed to unpack type from WKB'); } + $type = \is_numeric($typeArr[1]) ? (int) $typeArr[1] : 0; - $deleted = false; + // Offset to coordinates (skip SRID if present) + $offset = 5 + (($type & 0x20000000) ? 4 : 0); - try { - if (!$this->execute($stmt)) { - throw new DatabaseException('Failed to delete document'); - } + if (strlen($bin) < $offset + 16) { // 16 bytes for X,Y + throw new DatabaseException('WKB too short for coordinates'); + } - $deleted = $stmt->rowCount(); + $fmt = $isLE ? 'e' : 'E'; // little vs big endian double - if (!$this->execute($stmtPermissions)) { - throw new DatabaseException('Failed to delete permissions'); - } - } catch (\Throwable $th) { - throw new DatabaseException($th->getMessage()); + $xArr = unpack($fmt, substr($bin, $offset, 8)); + if ($xArr === false || ! isset($xArr[1])) { + throw new DatabaseException('Failed to unpack X coordinate'); + } + $x = \is_numeric($xArr[1]) ? (float) $xArr[1] : 0.0; + + $yArr = unpack($fmt, substr($bin, $offset + 8, 8)); + if ($yArr === false || ! isset($yArr[1])) { + throw new DatabaseException('Failed to unpack Y coordinate'); } + $y = \is_numeric($yArr[1]) ? (float) $yArr[1] : 0.0; - return $deleted; + return [$x, $y]; } /** - * @return string + * Decode a WKB or WKT LINESTRING into an array of coordinate pairs. + * + * @param mixed $wkb The WKB binary or WKT string + * @return array> + * + * @throws DatabaseException If the input is invalid. */ - public function getConnectionId(): string + protected function decodeLinestring(mixed $wkb): array { - $stmt = $this->getPDO()->query("SELECT pg_backend_pid();"); - return $stmt->fetchColumn(); - } + $wkb = \is_string($wkb) ? $wkb : ''; + if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) { + $start = strpos($wkb, '(') + 1; + $end = strrpos($wkb, ')'); + $inside = substr($wkb, $start, (int) $end - $start); - /** - * Handle distance spatial queries - * - * @param Query $query - * @param array $binds - * @param string $attribute - * @param string $alias - * @param string $placeholder - * @return string - */ - protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string - { - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - - $meters = isset($distanceParams[2]) && $distanceParams[2] === true; - - switch ($query->getMethod()) { - case Query::TYPE_DISTANCE_EQUAL: - $operator = '='; - break; - case Query::TYPE_DISTANCE_NOT_EQUAL: - $operator = '!='; - break; - case Query::TYPE_DISTANCE_GREATER_THAN: - $operator = '>'; - break; - case Query::TYPE_DISTANCE_LESS_THAN: - $operator = '<'; - break; - default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); - } - - $within = $query->getMethod() === Query::TYPE_DISTANCE_LESS_THAN; + $points = explode(',', $inside); - if ($meters) { - $attr = "({$alias}.{$attribute}::geography)"; - $geom = "ST_SetSRID(" . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::DEFAULT_SRID . ")::geography"; - $distance = "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; + return array_map(function ($point) { + $coords = explode(' ', trim($point)); - // The GIST index is on geometry, so only a degree box around the point can narrow a geography distance - $degrees = $within && $query->getAttributeType() === Database::VAR_POINT - ? $this->getDegreesWithinMeters($distanceParams[0], (float) $distanceParams[1]) - : null; + return [(float) $coords[0], (float) $coords[1]]; + }, $points); + } - if ($degrees === null) { - return $distance; + if (ctype_xdigit($wkb)) { + $wkb = hex2bin($wkb); + if ($wkb === false) { + throw new DatabaseException('Failed to convert hex WKB to binary.'); } - - $binds[":{$placeholder}_2"] = $degrees[0]; - $binds[":{$placeholder}_3"] = $degrees[1]; - - return "{$alias}.{$attribute} && ST_Expand(" . $this->getSpatialGeomFromText(":{$placeholder}_0") . ", :{$placeholder}_2, :{$placeholder}_3) AND {$distance}"; } - // Without meters, use the original SRID (e.g., 4326) - $distance = "ST_Distance({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ") {$operator} :{$placeholder}_1"; - - // ST_DWithin can use the GIST index; ST_Distance keeps the boundary exclusive - if ($within) { - return "ST_DWithin({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ", :{$placeholder}_1) AND {$distance}"; + if (strlen($wkb) < 9) { + throw new DatabaseException('WKB too short to be a valid geometry'); } - return $distance; - } + $byteOrder = ord($wkb[0]); + if ($byteOrder === 0) { + throw new DatabaseException('Big-endian WKB not supported'); + } elseif ($byteOrder !== 1) { + throw new DatabaseException('Invalid byte order in WKB'); + } - /** - * Longitude and latitude degrees that hold every point within $meters of $point on the WGS84 spheroid. - * - * Null for lines and polygons, whose geodesic edges leave any degree box, and when the box would reach a pole or the antimeridian. - * - * @return array{0: float, 1: float}|null - */ - protected function getDegreesWithinMeters(mixed $point, float $meters): ?array - { - if (!\is_array($point) || \count($point) !== 2 || !\is_numeric($point[0] ?? null) || !\is_numeric($point[1] ?? null)) { - return null; + // Type + SRID flag + $typeField = unpack('V', substr($wkb, 1, 4)); + if ($typeField === false) { + throw new DatabaseException('Failed to unpack the type field from WKB.'); } - $longitude = (float) $point[0]; - $latitude = (float) $point[1]; + $typeField = \is_numeric($typeField[1]) ? (int) $typeField[1] : 0; + $geomType = $typeField & 0xFF; + $hasSRID = ($typeField & 0x20000000) !== 0; - // A degree of latitude spans at least 110,574 m, and a degree of longitude at least 111,319 m × cos(latitude) - $latitudeDegrees = $meters / 110574; - if (\abs($latitude) + $latitudeDegrees >= 90) { - return null; + if ($geomType !== 2) { // 2 = LINESTRING + throw new DatabaseException("Not a LINESTRING geometry type, got {$geomType}"); } - $longitudeDegrees = $meters / (111319 * \cos(\deg2rad(\abs($latitude) + $latitudeDegrees))); - if ($longitude - $longitudeDegrees <= -180 || $longitude + $longitudeDegrees >= 180) { - return null; + $offset = 5; + if ($hasSRID) { + $offset += 4; } - return [$longitudeDegrees, $latitudeDegrees]; - } - - - /** - * Handle spatial queries - * - * @param Query $query - * @param array $binds - * @param string $attribute - * @param string $alias - * @param string $placeholder - * @return string - */ - protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string - { - switch ($query->getMethod()) { - case Query::TYPE_CROSSES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_NOT_CROSSES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_DISTANCE_EQUAL: - case Query::TYPE_DISTANCE_NOT_EQUAL: - case Query::TYPE_DISTANCE_GREATER_THAN: - case Query::TYPE_DISTANCE_LESS_THAN: - return $this->handleDistanceSpatialQueries($query, $binds, $attribute, $alias, $placeholder); - case Query::TYPE_EQUAL: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_NOT_EQUAL: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_INTERSECTS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_NOT_INTERSECTS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_OVERLAPS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_NOT_OVERLAPS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_TOUCHES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_NOT_TOUCHES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_CONTAINS: - case Query::TYPE_NOT_CONTAINS: - // using st_cover instead of contains to match the boundary matching behaviour of the mariadb st_contains - // postgis st_contains excludes matching the boundary - $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return $isNot - ? "NOT ST_Covers({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")" - : "ST_Covers({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ")"; - - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: - return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; - - default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + $numPoints = unpack('V', substr($wkb, $offset, 4)); + if ($numPoints === false) { + throw new DatabaseException("Failed to unpack number of points at offset {$offset}."); } - } - /** - * Handle JSONB queries - * - * @param Query $query - * @param array $binds - * @param string $attribute - * @param string $alias - * @param string $placeholder - * @return string - */ - protected function handleObjectQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string - { - switch ($query->getMethod()) { - case Query::TYPE_EQUAL: - case Query::TYPE_NOT_EQUAL: { - $isNot = $query->getMethod() === Query::TYPE_NOT_EQUAL; - $conditions = []; - foreach ($query->getValues() as $key => $value) { - $binds[":{$placeholder}_{$key}"] = json_encode($value); - $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; - $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; - } - $separator = $isNot ? ' AND ' : ' OR '; - return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; - } + $numPoints = \is_numeric($numPoints[1]) ? (int) $numPoints[1] : 0; + $offset += 4; - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: - case Query::TYPE_CONTAINS_ALL: - case Query::TYPE_NOT_CONTAINS: { - $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; - $conditions = []; - foreach ($query->getValues() as $key => $value) { - if (count($value) === 1) { - $jsonKey = array_key_first($value); - $jsonValue = $value[$jsonKey]; - - // If scalar (e.g. "skills" => "typescript"), - // wrap it to express array containment: {"skills": ["typescript"]} - // If it's already an object/associative array (e.g. "config" => ["lang" => "en"]), - // keep as-is to express object containment. - if (!\is_array($jsonValue)) { - $value[$jsonKey] = [$jsonValue]; - } - } - $binds[":{$placeholder}_{$key}"] = json_encode($value); - $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb"; - $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment; - } - $separator = $isNot ? ' AND ' : ' OR '; - return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; + $points = []; + for ($i = 0; $i < $numPoints; $i++) { + $x = unpack('e', substr($wkb, $offset, 8)); + if ($x === false) { + throw new DatabaseException("Failed to unpack X coordinate at offset {$offset}."); } - default: - throw new DatabaseException('Query method ' . $query->getMethod() . ' not supported for object attributes'); - } - } - - /** - * Get SQL Condition - * - * @param Query $query - * @param array $binds - * @return string - * @throws Exception - */ - protected function getSQLCondition(Query $query, array &$binds, ?string $forCollection = null): string - { - $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - $isNestedObjectAttribute = $query->isObjectAttribute() && \str_contains($query->getAttribute(), '.'); - if ($isNestedObjectAttribute) { - $attribute = $this->buildJsonbPath($query->getAttribute()); - } else { - $attribute = $this->filter($query->getAttribute()); - $attribute = $this->quote($attribute); - } + $x = \is_numeric($x[1]) ? (float) $x[1] : 0.0; - $alias = $this->quote(Query::DEFAULT_ALIAS); - $placeholder = ID::unique(); + $offset += 8; - $operator = null; + $y = unpack('e', substr($wkb, $offset, 8)); + if ($y === false) { + throw new DatabaseException("Failed to unpack Y coordinate at offset {$offset}."); + } - if ($query->isSpatialAttribute()) { - return $this->handleSpatialQueries($query, $binds, $attribute, $alias, $placeholder); - } + $y = \is_numeric($y[1]) ? (float) $y[1] : 0.0; - if ($query->isObjectAttribute() && !$isNestedObjectAttribute) { - return $this->handleObjectQueries($query, $binds, $attribute, $alias, $placeholder); + $offset += 8; + $points[] = [$x, $y]; } - switch ($query->getMethod()) { - case Query::TYPE_OR: - case Query::TYPE_AND: - $conditions = []; - /* @var $q Query */ - foreach ($query->getValue() as $q) { - $conditions[] = $this->getSQLCondition($q, $binds, $forCollection); - } - - $method = strtoupper($query->getMethod()); - return empty($conditions) ? '' : ' ' . $method . ' (' . implode(' AND ', $conditions) . ')'; - - case Query::TYPE_SEARCH: - $fulltextValue = $this->getFulltextValue($query->getValue()); - if ($fulltextValue === '') { - return '0 = 1'; - } - $binds[":{$placeholder}_0"] = $fulltextValue; - return "to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0)"; - - case Query::TYPE_NOT_SEARCH: - $fulltextValue = $this->getFulltextValue($query->getValue()); - if ($fulltextValue === '') { - return '1 = 1'; - } - $binds[":{$placeholder}_0"] = $fulltextValue; - return "NOT (to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0))"; - - case Query::TYPE_VECTOR_DOT: - case Query::TYPE_VECTOR_COSINE: - case Query::TYPE_VECTOR_EUCLIDEAN: - return ''; // Handled in ORDER BY clause - - case Query::TYPE_BETWEEN: - $binds[":{$placeholder}_0"] = $query->getValues()[0]; - $binds[":{$placeholder}_1"] = $query->getValues()[1]; - return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; - - case Query::TYPE_NOT_BETWEEN: - $binds[":{$placeholder}_0"] = $query->getValues()[0]; - $binds[":{$placeholder}_1"] = $query->getValues()[1]; - return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; - - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: - return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; - - case Query::TYPE_CONTAINS_ALL: - if ($query->onArray()) { - // @> checks the array contains ALL specified values - $binds[":{$placeholder}_0"] = \json_encode($query->getValues()); - return "{$alias}.{$attribute} @> :{$placeholder}_0::jsonb"; - } - // no break - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: - case Query::TYPE_NOT_CONTAINS: - if ($query->onArray()) { - $operator = '@>'; - } - - // no break - default: - $conditions = []; - $operator = $operator ?? $this->getSQLOperator($query->getMethod()); - $isNotQuery = in_array($query->getMethod(), [ - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_NOT_CONTAINS - ]); - - foreach ($query->getValues() as $key => $value) { - $value = match ($query->getMethod()) { - Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', - Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', - Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', - Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', - default => $value - }; - - $binds[":{$placeholder}_{$key}"] = $value; - - if ($isNotQuery && $query->onArray()) { - // For array NOT queries, wrap the entire condition in NOT() - $conditions[] = "NOT ({$alias}.{$attribute} {$operator} :{$placeholder}_{$key})"; - } elseif ($isNotQuery && !$query->onArray()) { - $conditions[] = "{$alias}.{$attribute} NOT {$operator} :{$placeholder}_{$key}"; - } else { - $conditions[] = "{$alias}.{$attribute} {$operator} :{$placeholder}_{$key}"; - } - } - - $separator = $isNotQuery ? ' AND ' : ' OR '; - return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; - } + return $points; } /** - * Get the SQL expression measuring distance between a vector attribute and the query vector + * Decode a WKB or WKT POLYGON into an array of rings, each containing coordinate pairs. * - * @param Query $query - * @param array $binds - * @param string $alias - * @return string|null - * @throws DatabaseException + * @param string $wkb The WKB hex or WKT string + * @return array>> + * + * @throws DatabaseException If the input is invalid. */ - protected function getSQLVectorDistance(Query $query, array &$binds, string $alias): ?string + protected function decodePolygon(string $wkb): array { - $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - - $attribute = $this->filter($query->getAttribute()); - $attribute = $this->quote($attribute); - $alias = $this->quote($alias); - $placeholder = ID::unique(); + // POLYGON((x1,y1),(x2,y2)) + if (str_starts_with($wkb, 'POLYGON((')) { + $start = strpos($wkb, '((') + 2; + $end = strrpos($wkb, '))'); + $inside = substr($wkb, $start, $end - $start); - $values = $query->getValues(); - $vectorArray = $values[0] ?? []; - $vector = \json_encode(\array_map(\floatval(...), $vectorArray)); - $binds[":vector_{$placeholder}"] = $vector; - - return match ($query->getMethod()) { - Query::TYPE_VECTOR_DOT => "({$alias}.{$attribute} <#> :vector_{$placeholder}::vector)", - Query::TYPE_VECTOR_COSINE => "({$alias}.{$attribute} <=> :vector_{$placeholder}::vector)", - Query::TYPE_VECTOR_EUCLIDEAN => "({$alias}.{$attribute} <-> :vector_{$placeholder}::vector)", - default => null, - }; - } + $rings = \preg_split('/\)\s*,\s*\(/', $inside) ?: [$inside]; - /** - * @param string $distance - * @return string - */ - protected function getSQLReadableDistance(string $distance): string - { - return "{$distance}::text"; - } + return array_map(function ($ring) { + $points = explode(',', $ring); - /** - * Match the permission against the copy carried on the row rather than joining the - * permissions table. - * - * Both hold the same fact, written together, but a semi join has to be resolved before - * anything can be ordered, which forces the whole collection to be read whenever the - * ordering could otherwise have come from an index. Matching on the row leaves the - * planner free to cost the permission against the ordering, so a selective permission - * drives from the GIN index and a permissive one is a cheap filter over whichever index - * the ordering wanted. - * - * @param string $collection - * @param array $roles - * @param string $alias - * @param string $type - * @return string - * @throws DatabaseException - */ - protected function getSQLPermissionsCondition( - string $collection, - array $roles, - string $alias, - string $type = Database::PERMISSION_READ - ): string { - if (!\in_array($type, Database::PERMISSIONS)) { - throw new DatabaseException('Unknown permission type: ' . $type); - } - - $column = "{$this->quote($alias)}.{$this->quote('_permissions')}"; - - // Containment rather than jsonb's ?| key operator: PDO reads a lone ? as a positional - // placeholder, and doubling it to escape breaks once a named placeholder is repeated, - // which the cursor conditions do. Each role is its own @> so the index can answer them - // as a BitmapOr; jsonb_exists_any would express it in one call but is not indexable. - $permissions = \array_map( - fn ($role) => "{$column} @> {$this->getPDO()->quote(\json_encode(["{$type}(\"{$role}\")"]))}::jsonb", - $roles - ); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); - if ($permissions === []) { - return 'FALSE'; + return [(float) $coords[0], (float) $coords[1]]; + }, $points); + }, $rings); } - return '(' . \implode(' OR ', $permissions) . ')'; - } - - /** - * @param string $value - * @return string - */ - protected function getFulltextValue(string $value): string - { - $exact = str_ends_with($value, '"') && str_starts_with($value, '"'); - - /** Keep only unicode letters, numbers, underscores, and whitespace. */ - $value = preg_replace('/[^\p{L}\p{N}_\s]/u', ' ', $value) ?? ''; - $value = preg_replace('/\s+/', ' ', $value) ?? ''; - $value = trim($value); - - if (empty($value)) { - return ''; + if (preg_match('/^[0-9a-fA-F]+$/', $wkb)) { + $wkb = hex2bin($wkb); + if ($wkb === false) { + throw new DatabaseException('Invalid hex WKB'); + } } - if (!$exact) { - $value = str_replace(' ', ' or ', $value); + if (strlen($wkb) < 9) { + throw new DatabaseException('WKB too short'); } - return "'" . $value . "'"; - } + $uInt32 = 'V'; // little-endian 32-bit unsigned + $uDouble = 'd'; // little-endian double - /** - * Get SQL Type - * - * @param string $type - * @param int $size in chars - * @param bool $signed - * @param bool $array - * @param bool $required - * @return string - * @throws DatabaseException - */ - protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string - { - if ($array === true) { - return 'JSONB'; + $typeInt = unpack($uInt32, substr($wkb, 1, 4)); + if ($typeInt === false) { + throw new DatabaseException('Failed to unpack type field from WKB.'); } - switch ($type) { - case Database::VAR_ID: - return 'BIGINT'; + $typeInt = \is_numeric($typeInt[1]) ? (int) $typeInt[1] : 0; + $hasSrid = ($typeInt & 0x20000000) !== 0; + $geomType = $typeInt & 0xFF; - case Database::VAR_STRING: - // $size = $size * 4; // Convert utf8mb4 size to bytes - if ($size > $this->getMaxVarcharLength()) { - return 'TEXT'; - } + if ($geomType !== 3) { // 3 = POLYGON + throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); + } - return "VARCHAR({$size})"; + $offset = 5; + if ($hasSrid) { + $offset += 4; + } - case Database::VAR_VARCHAR: - return "VARCHAR({$size})"; + $numRings = unpack($uInt32, substr($wkb, $offset, 4)); + if ($numRings === false) { + throw new DatabaseException('Failed to unpack number of rings from WKB.'); + } - case Database::VAR_TEXT: - case Database::VAR_MEDIUMTEXT: - case Database::VAR_LONGTEXT: - return 'TEXT'; // PostgreSQL doesn't have MEDIUMTEXT/LONGTEXT, use TEXT + $numRings = \is_numeric($numRings[1]) ? (int) $numRings[1] : 0; + $offset += 4; - case Database::VAR_INTEGER: // We don't support zerofill: https://stackoverflow.com/a/5634147/2299554 + $rings = []; + for ($r = 0; $r < $numRings; $r++) { + $numPoints = unpack($uInt32, substr($wkb, $offset, 4)); + if ($numPoints === false) { + throw new DatabaseException('Failed to unpack number of points from WKB.'); + } - if ($size >= 8) { // INT = 4 bytes, BIGINT = 8 bytes - return 'BIGINT'; + $numPoints = \is_numeric($numPoints[1]) ? (int) $numPoints[1] : 0; + $offset += 4; + $points = []; + for ($i = 0; $i < $numPoints; $i++) { + $x = unpack($uDouble, substr($wkb, $offset, 8)); + if ($x === false) { + throw new DatabaseException('Failed to unpack X coordinate from WKB.'); } - return 'INTEGER'; - - case Database::VAR_BIGINT: - return 'BIGINT'; - - case Database::VAR_FLOAT: - return 'DOUBLE PRECISION'; - - case Database::VAR_BOOLEAN: - return 'BOOLEAN'; - - case Database::VAR_RELATIONSHIP: - return 'VARCHAR(255)'; - - case Database::VAR_DATETIME: - return 'TIMESTAMP(3)'; + $x = \is_numeric($x[1]) ? (float) $x[1] : 0.0; - case Database::VAR_OBJECT: - return 'JSONB'; - - case Database::VAR_POINT: - return 'GEOMETRY(POINT,' . Database::DEFAULT_SRID . ')'; - - case Database::VAR_LINESTRING: - return 'GEOMETRY(LINESTRING,' . Database::DEFAULT_SRID . ')'; - - case Database::VAR_POLYGON: - return 'GEOMETRY(POLYGON,' . Database::DEFAULT_SRID . ')'; + $y = unpack($uDouble, substr($wkb, $offset + 8, 8)); + if ($y === false) { + throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); + } - case Database::VAR_VECTOR: - return "VECTOR({$size})"; + $y = \is_numeric($y[1]) ? (float) $y[1] : 0.0; - default: - throw new DatabaseException('Unknown Type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_VARCHAR . ', ' . Database::VAR_TEXT . ', ' . Database::VAR_MEDIUMTEXT . ', ' . Database::VAR_LONGTEXT . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_BIGINT . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::VAR_OBJECT . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); + $points[] = [$x, $y]; + $offset += 16; + } + $rings[] = $points; } + + return $rings; } /** - * Get SQL schema - * - * @return string + * The LOCAL statement timeout in force in the open transaction, in milliseconds: + * 0 is the default, null is unknown after a rollback to a savepoint. */ - protected function getSQLSchema(): string + private ?int $localTimeout = 0; + + #[\Override] + public function commitTransaction(): bool { - if (!$this->getSupportForSchemas()) { - return ''; + try { + return parent::commitTransaction(); + } finally { + if ($this->inTransaction === 0) { + $this->localTimeout = 0; + } } - - return "\"{$this->getDatabase()}\"."; } - /** - * Get PDO Type - * - * @param mixed $value - * - * @return int - * @throws DatabaseException - */ - protected function getPDOType(mixed $value): int + #[\Override] + public function rollbackTransaction(): bool { - return match (\gettype($value)) { - 'string', 'double' => PDO::PARAM_STR, - 'boolean' => PDO::PARAM_BOOL, - 'integer' => PDO::PARAM_INT, - 'NULL' => PDO::PARAM_NULL, - default => throw new DatabaseException('Unknown PDO Type for ' . \gettype($value)), - }; + try { + return parent::rollbackTransaction(); + } finally { + $this->localTimeout = $this->inTransaction === 0 ? 0 : null; + } } - /** - * Get the SQL function for random ordering - * - * @return string - */ - protected function getRandomOrder(): string + #[\Override] + public function reconnect(): void { - return 'RANDOM()'; + $this->localTimeout = null; + parent::reconnect(); + $this->localTimeout = 0; } /** - * Size of POINT spatial type - * - * @return int - */ - protected function getMaxPointSize(): int + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement + */ + #[\Override] + protected function execute(mixed $statement, ?Event $event = null): bool { - // https://stackoverflow.com/questions/30455025/size-of-data-type-geographypoint-4326-in-postgis - return 32; - } + $event ??= $this->getStatementEvent($statement); + $timeout = $event === null ? $this->getTimeout() : $this->getTimeout($event); + if ($this->inTransaction > 0) { + $this->applyLocalTimeout($timeout); - /** - * Encode array - * - * @param string $value - * - * @return array - */ - protected function encodeArray(string $value): array - { - $string = substr($value, 1, -1); - if (empty($string)) { - return []; - } else { - return explode(',', $string); + return $this->executeAndProfile($statement); } - } - /** - * Decode array - * - * @param array $value - * - * @return string - */ - protected function decodeArray(array $value): string - { - if (empty($value)) { - return '{}'; - } + $this->localTimeout = 0; - foreach ($value as $index => $item) { - $value[$index] = '"' . str_replace(['"', '(', ')'], ['\"', '\(', '\)'], $item) . '"'; + if ($timeout === 0) { + return $this->executeAndProfile($statement); } - return '{' . implode(",", $value) . '}'; - } + $pdo = $this->getDriver(); + $pdo->exec("SET statement_timeout = '{$timeout}ms'"); - public function getMinDateTime(): \DateTime - { - return new \DateTime('-4713-01-01 00:00:00'); + $exception = null; + try { + return $this->executeAndProfile($statement); + } catch (Throwable $error) { + $exception = $error; + throw $error; + } finally { + try { + $pdo->exec('RESET statement_timeout'); + } catch (Throwable $error) { + if ($exception === null) { + throw $error; + } + } + } } - /** - * Is fulltext Wildcard index supported? - * - * @return bool - */ - public function getSupportForFulltextWildcardIndex(): bool + private function applyLocalTimeout(int $milliseconds): void { - return false; + if ($milliseconds === $this->localTimeout) { + return; + } + + $this->getDriver()->exec($milliseconds === 0 + ? 'SET LOCAL statement_timeout = DEFAULT' + : "SET LOCAL statement_timeout = '{$milliseconds}ms'"); + + $this->localTimeout = $milliseconds; } - /** - * Are timeouts supported? - * - * @return bool - */ - public function getSupportForTimeouts(): bool + #[\Override] + protected function insertRequiresAlias(): bool { return true; } - /** - * Does the adapter handle Query Array Overlaps? - * - * @return bool - */ - public function getSupportForJSONOverlaps(): bool + #[\Override] + protected function getConflictTenantExpression(string $column): string { - return false; - } + $quoted = $this->quote($this->filter($column)); - public function getSupportForIntegerBooleans(): bool - { - return false; // Postgres has native boolean type + return 'CASE WHEN target.'.Storage::TENANT.' = EXCLUDED.'.Storage::TENANT." THEN EXCLUDED.{$quoted} ELSE target.{$quoted} END"; } - /** - * Is get schema attributes supported? - * - * @return bool - */ - public function getSupportForSchemaAttributes(): bool + #[\Override] + protected function getConflictIncrementExpression(string $column): string { - return false; - } + $quoted = $this->quote($this->filter($column)); - public function getSupportForSchemaIndexes(): bool - { - return false; + return "target.{$quoted} + EXCLUDED.{$quoted}"; } - public function getSupportForUpserts(): bool + #[\Override] + protected function getConflictTenantIncrementExpression(string $column): string { - return true; - } + $quoted = $this->quote($this->filter($column)); - public function getSupportForUpsertOnUniqueIndex(): bool - { - return false; + return 'CASE WHEN target.'.Storage::TENANT.' = EXCLUDED.'.Storage::TENANT." THEN target.{$quoted} + EXCLUDED.{$quoted} ELSE target.{$quoted} END"; } /** - * Is vector type supported? + * Overrides the base implementation to use target-prefixed column references + * so that ON CONFLICT DO UPDATE SET expressions correctly reference the + * existing row via the target alias. * - * @return bool + * @param string $column The unquoted, filtered column name */ - public function getSupportForVectors(): bool + #[\Override] + protected function getOperatorUpsertExpression(string $column, Operator $operator): Expression { - return true; - } + $bindIndex = 0; + $fullExpression = $this->getOperatorSql($column, $operator, $bindIndex, useTargetPrefix: true); - public function getSupportForPCRERegex(): bool - { - return false; - } + if ($fullExpression === null) { + throw new DatabaseException('Operator cannot be expressed in SQL: '.$operator->getMethod()->value); + } - public function getSupportForPOSIXRegex(): bool - { - return true; - } + $quotedColumn = $this->quote($column); + $prefix = $quotedColumn.' = '; + $expression = $fullExpression; + if (str_starts_with($expression, $prefix)) { + $expression = substr($expression, strlen($prefix)); + } - public function getSupportForTrigramIndex(): bool - { - return true; - } + /** @var array $namedBindings */ + $namedBindings = []; + $method = $operator->getMethod(); + $values = $operator->getValues(); + $idx = 0; - /** - * @return string - */ - public function getLikeOperator(): string - { - return 'ILIKE'; - } + switch ($method) { + case OperatorType::Increment: + case OperatorType::Decrement: + case OperatorType::Multiply: + case OperatorType::Divide: + $namedBindings["op_{$idx}"] = $values[0] ?? 1; + $idx++; + if (isset($values[1])) { + $namedBindings["op_{$idx}"] = self::exactLimit($values[1]); + $idx++; + } + break; - /** - * @return string - */ - public function getRegexOperator(): string - { - return '~'; - } + case OperatorType::Modulo: + $namedBindings["op_{$idx}"] = $values[0] ?? 1; + $idx++; + break; - protected function processException(PDOException $e): \Exception - { - // Timeout - if ($e->getCode() === '57014' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new TimeoutException('Query timed out', $e->getCode(), $e); - } + case OperatorType::Power: + $namedBindings["op_{$idx}"] = $values[0] ?? 1; + $idx++; + if (isset($values[1])) { + $namedBindings["op_{$idx}"] = self::exactLimit($values[1]); + $idx++; + } + break; - // Duplicate table - if ($e->getCode() === '42P07' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new DuplicateException('Collection already exists', $e->getCode(), $e); - } + case OperatorType::StringConcat: + $namedBindings["op_{$idx}"] = $values[0] ?? ''; + $idx++; + break; - // Duplicate column - if ($e->getCode() === '42701' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new DuplicateException('Attribute already exists', $e->getCode(), $e); - } + case OperatorType::StringReplace: + $namedBindings["op_{$idx}"] = $values[0] ?? ''; + $idx++; + $namedBindings["op_{$idx}"] = $values[1] ?? ''; + $idx++; + break; - // Duplicate row - if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - $columns = $this->getViolatedColumns($e->getMessage()); - if ($columns !== null && $columns !== ['_uid'] && $columns !== ['_tenant', '_uid']) { - return new UniqueException('Unique index violation', $e->getCode(), $e); - } - return new DuplicateException('Document already exists', $e->getCode(), $e); - } + case OperatorType::Toggle: + // No bindings + break; - // Data is too big for column resize - if ($e->getCode() === '22001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new TruncateException('Resize would result in data truncation', $e->getCode(), $e); - } + case OperatorType::DateAddDays: + case OperatorType::DateSubDays: + $namedBindings["op_{$idx}"] = $values[0] ?? 0; + $idx++; + break; - // Numeric value out of range (overflow/underflow from operators) - if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new LimitException('Numeric value out of range', $e->getCode(), $e); - } + case OperatorType::DateSetNow: + // No bindings + break; - // Invalid argument for power function (e.g. 0 to a negative power, or a negative base to a - // fractional exponent) — matches MariaDB, which reports the same as a numeric range error. - if ($e->getCode() === '2201F' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new LimitException('Value out of range', $e->getCode(), $e); - } + case OperatorType::ArrayAppend: + case OperatorType::ArrayPrepend: + $namedBindings["op_{$idx}"] = json_encode($values); + $idx++; + break; - // Datetime field overflow - if ($e->getCode() === '22008' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new LimitException('Datetime field overflow', $e->getCode(), $e); - } + case OperatorType::ArrayRemove: + $value = $values[0] ?? null; + $namedBindings["op_{$idx}"] = json_encode($value); + $idx++; + break; - // Index row too large - if ($e->getCode() === '54000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7 && \str_contains($e->getMessage(), 'index row')) { - return new LimitException('Index row size exceeds the maximum', $e->getCode(), $e); - } + case OperatorType::ArrayUnique: + // No bindings + break; - // Unknown table - if ($e->getCode() === '42P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new NotFoundException('Collection not found', $e->getCode(), $e); - } + case OperatorType::ArrayInsert: + $namedBindings["op_{$idx}"] = $values[0] ?? 0; + $idx++; + $namedBindings["op_{$idx}"] = json_encode($values[1] ?? null); + $idx++; + break; - // Unknown column - if ($e->getCode() === "42703" && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { - return new NotFoundException('Attribute not found', $e->getCode(), $e); + case OperatorType::ArrayIntersect: + case OperatorType::ArrayDiff: + $namedBindings["op_{$idx}"] = json_encode($values); + $idx++; + break; + + case OperatorType::ArrayFilter: + $condition = $values[0] ?? 'equal'; + $filterValue = $values[1] ?? null; + $namedBindings["op_{$idx}"] = $condition; + $idx++; + $namedBindings["op_{$idx}"] = $filterValue !== null ? json_encode($filterValue) : null; + $idx++; + break; } - return $e; - } + $positionalBindings = []; + $keys = array_keys($namedBindings); + usort($keys, fn ($a, $b) => strlen($b) - strlen($a)); - /** - * Extract the violated columns from a unique violation error, e.g. - * "DETAIL: Key (_uid, _tenant)=(movie, 1) already exists." resolves to - * ['_tenant', '_uid']. Returns null when the message cannot be parsed. - * - * @return array|null - */ - protected function getViolatedColumns(string $message): ?array - { - if (\preg_match('/Key \(([^)]+)\)=/', $message, $matches) !== 1) { - return null; + $replacements = []; + foreach ($keys as $key) { + $search = ':'.$key; + $offset = 0; + while (($pos = strpos($expression, $search, $offset)) !== false) { + $replacements[] = ['pos' => $pos, 'len' => strlen($search), 'key' => $key]; + $offset = $pos + strlen($search); + } } - $columns = \array_map( - fn (string $column) => \trim($column, " \t\"'"), - \explode(',', $matches[1]) - ); + usort($replacements, fn ($a, $b) => $a['pos'] - $b['pos']); - \sort($columns); + $result = $expression; + for ($i = count($replacements) - 1; $i >= 0; $i--) { + $r = $replacements[$i]; + $result = substr_replace($result, '?', $r['pos'], $r['len']); + } - return $columns; + foreach ($replacements as $r) { + $positionalBindings[] = $namedBindings[$r['key']]; + } + + return new Expression($result, $positionalBindings); } - /** - * @param string $string - * @return string - */ - protected function quote(string $string): string + #[\Override] + protected function dialectBuilder(): SQLBuilder&Scoping { - return "\"{$string}\""; + return new PostgresBuilder(); } - /** - * Is spatial attributes supported? - * - * @return bool - */ - public function getSupportForSpatialAttributes(): bool + #[\Override] + public function schema(): PostgresSchema { - return true; + return new PostgresSchema(); } - /** - * Are object (JSONB) attributes supported? - * - * @return bool - */ - public function getSupportForObject(): bool + #[\Override] + protected function getSqlType(ColumnType $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string { - return true; + if ($array === true) { + return 'JSONB'; + } + + return match ($type) { + ColumnType::Id => 'BIGINT', + ColumnType::String => $size <= 0 || $size > $this->limits()->varchar ? 'TEXT' : "VARCHAR({$size})", + ColumnType::Varchar => "VARCHAR({$size})", + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText => 'TEXT', + ColumnType::Integer => $size >= 8 ? 'BIGINT' : 'INTEGER', + ColumnType::BigInteger => 'BIGINT', + ColumnType::Float, ColumnType::Double => 'DOUBLE PRECISION', + ColumnType::Boolean => 'BOOLEAN', + ColumnType::Relationship => 'VARCHAR(255)', + ColumnType::Datetime => 'TIMESTAMP(3)', + ColumnType::Object => 'JSONB', + ColumnType::Point => 'GEOMETRY(POINT,'.Database::DEFAULT_SRID.')', + ColumnType::Linestring => 'GEOMETRY(LINESTRING,'.Database::DEFAULT_SRID.')', + ColumnType::Polygon => 'GEOMETRY(POLYGON,'.Database::DEFAULT_SRID.')', + ColumnType::Vector => "VECTOR({$size})", + default => throw new DatabaseException('Unknown Type: '.$type->value.'. Must be one of '.ColumnType::String->value.', '.ColumnType::Varchar->value.', '.ColumnType::Text->value.', '.ColumnType::MediumText->value.', '.ColumnType::LongText->value.', '.ColumnType::Integer->value.', '.ColumnType::Double->value.', '.ColumnType::Boolean->value.', '.ColumnType::Datetime->value.', '.ColumnType::Relationship->value.', '.ColumnType::Object->value.', '.ColumnType::Point->value.', '.ColumnType::Linestring->value.', '.ColumnType::Polygon->value), + }; } /** - * Are object (JSONB) indexes supported? - * - * @return bool + * @throws DatabaseException */ - public function getSupportForObjectIndexes(): bool + #[\Override] + protected function getPdoType(mixed $value): int { - return true; + return match (\gettype($value)) { + 'string', 'double' => PDO::PARAM_STR, + 'boolean' => PDO::PARAM_BOOL, + 'integer' => PDO::PARAM_INT, + 'NULL' => PDO::PARAM_NULL, + default => throw new DatabaseException('Unknown PDO Type for '.\gettype($value)), + }; } - /** - * Does the adapter support null values in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexNull(): bool + #[\Override] + protected function getNullOrder(): OrderDirection { - return true; + return OrderDirection::Desc; } - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ - public function getSupportForBoundaryInclusiveContains(): bool + #[\Override] + protected function getVectorOrderRaw(Query $query, string $alias): ?Expression { - return true; - } + $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - /** - * Does the adapter support order attribute in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexOrder(): bool - { - return false; - } + $attribute = $this->filter($query->getAttribute()); + $attribute = $this->quote($attribute); + $quotedAlias = $this->quote($alias); - /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool - */ - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool - { - return true; + $values = $query->getValues(); + $vectorArrayRaw2 = $values[0] ?? []; + $vectorArray2 = \is_array($vectorArrayRaw2) ? $vectorArrayRaw2 : []; + $vector = \json_encode(\array_map(fn (mixed $v): float => \is_numeric($v) ? (float) $v : 0.0, $vectorArray2)); + + $expression = match ($query->getMethod()) { + Method::VectorDot => "({$quotedAlias}.{$attribute} <#> ?::vector)", + Method::VectorCosine => "({$quotedAlias}.{$attribute} <=> ?::vector)", + Method::VectorEuclidean => "({$quotedAlias}.{$attribute} <-> ?::vector)", + default => null, + }; + + if ($expression === null) { + return null; + } + + return new Expression($expression, [$vector]); } - /** - * Does the adapter support spatial axis order specification? - * - * @return bool - */ - public function getSupportForSpatialAxisOrder(): bool + #[\Override] + protected function getSqlReadableDistance(string $distance): string { - return false; + return "{$distance}::text"; } /** - * Adapter supports optional spatial attributes with existing rows. + * Match read permissions against the JSONB copy stored on each row. This + * keeps PostgreSQL free to combine the GIN permission index with ordering + * indexes instead of resolving a permissions-table semi-join first. * - * @return bool + * @param array $roles */ - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return false; - } + #[\Override] + protected function newPermissionHook(string $collection, array $roles, string $type = PermissionType::Read->value, string $documentColumn = Storage::UID): Permission\Filter + { + return new readonly class (\array_values($roles), $type, $documentColumn) extends Permission\Filter { + /** + * @param list $roles + */ + public function __construct(array $roles, string $type, string $documentColumn) + { + parent::__construct( + roles: $roles, + permissionsTable: static fn (string $table): string => $table, + type: $type, + documentColumn: $documentColumn, + quoteCharacter: '"', + ); + } - protected function getInsertKeyword(): string - { - return 'INSERT INTO'; - } + #[\Override] + public function filter(string $table): Condition + { + if (empty($this->roles)) { + return new Condition('1 = 0'); + } - protected function getInsertSuffix(string $table): string - { - if (!$this->skipDuplicates) { - return ''; - } + $parts = \explode('.', $this->documentColumn); + $parts[\array_key_last($parts)] = Storage::PERMISSIONS; + $column = \implode('.', \array_map( + static fn (string $part): string => '"'.\str_replace('"', '""', $part).'"', + $parts + )); - $conflictTarget = $this->sharedTables ? '("_uid", "_tenant")' : '("_uid")'; + $conditions = []; + $bindings = []; + foreach ($this->roles as $role) { + $conditions[] = "{$column} @> ?::jsonb"; + $bindings[] = \json_encode(["{$this->type}(\"{$role}\")"]) ?: '[]'; + } - return "ON CONFLICT {$conflictTarget} DO NOTHING"; + return new Condition('('.\implode(' OR ', $conditions).')', $bindings); + } + }; } - protected function getInsertPermissionsSuffix(): string + #[\Override] + protected function getMaxPointSize(): int { - if (!$this->skipDuplicates) { - return ''; - } - - $conflictTarget = $this->sharedTables - ? '("_type", "_permission", "_document", "_tenant")' - : '("_type", "_permission", "_document")'; - - return "ON CONFLICT {$conflictTarget} DO NOTHING"; + // https://stackoverflow.com/questions/30455025/size-of-data-type-geographypoint-4326-in-postgis + return 32; } - public function decodePoint(string $wkb): array + #[\Override] + protected function processException(PDOException $e): Exception { - if (str_starts_with(strtoupper($wkb), 'POINT(')) { - $start = strpos($wkb, '(') + 1; - $end = strrpos($wkb, ')'); - $inside = substr($wkb, $start, $end - $start); - - $coords = explode(' ', trim($inside)); - return [(float)$coords[0], (float)$coords[1]]; + if ($e->getCode() === '57014' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new TimeoutException('Query timed out', $e->getCode(), $e); } - $bin = hex2bin($wkb); - if ($bin === false) { - throw new DatabaseException('Invalid hex WKB string'); + if ($e->getCode() === '42P07' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new DuplicateException('Collection already exists', $e->getCode(), $e); } - if (strlen($bin) < 13) { // 1 byte endian + 4 bytes type + 8 bytes for X - throw new DatabaseException('WKB too short'); + if ($e->getCode() === '42701' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new DuplicateException('Attribute already exists', $e->getCode(), $e); } - $isLE = ord($bin[0]) === 1; + if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + $columns = $this->getViolatedColumns($e->getMessage()); + if ($columns !== null && $columns !== [Storage::UID] && $columns !== [Storage::TENANT, Storage::UID]) { + return new UniqueException(UniqueException::MESSAGE, $e->getCode(), $e); + } - // Type (4 bytes) - $typeBytes = substr($bin, 1, 4); - if (strlen($typeBytes) !== 4) { - throw new DatabaseException('Failed to extract type bytes from WKB'); + return new DuplicateException('Document already exists', $e->getCode(), $e); } - $typeArr = unpack($isLE ? 'V' : 'N', $typeBytes); - if ($typeArr === false || !isset($typeArr[1])) { - throw new DatabaseException('Failed to unpack type from WKB'); + if ($e->getCode() === '22001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new TruncateException('Resize would result in data truncation', $e->getCode(), $e); } - $type = $typeArr[1]; - - // Offset to coordinates (skip SRID if present) - $offset = 5 + (($type & 0x20000000) ? 4 : 0); - if (strlen($bin) < $offset + 16) { // 16 bytes for X,Y - throw new DatabaseException('WKB too short for coordinates'); + if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Numeric value out of range', $e->getCode(), $e); } - $fmt = $isLE ? 'e' : 'E'; // little vs big endian double - - // X coordinate - $xArr = unpack($fmt, substr($bin, $offset, 8)); - if ($xArr === false || !isset($xArr[1])) { - throw new DatabaseException('Failed to unpack X coordinate'); + if ($e->getCode() === '2201F' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Invalid argument for power function', $e->getCode(), $e); } - $x = (float)$xArr[1]; - // Y coordinate - $yArr = unpack($fmt, substr($bin, $offset + 8, 8)); - if ($yArr === false || !isset($yArr[1])) { - throw new DatabaseException('Failed to unpack Y coordinate'); + if ($e->getCode() === '22008' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Datetime field overflow', $e->getCode(), $e); } - $y = (float)$yArr[1]; - - return [$x, $y]; - } - - public function decodeLinestring(mixed $wkb): array - { - if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) { - $start = strpos($wkb, '(') + 1; - $end = strrpos($wkb, ')'); - $inside = substr($wkb, $start, $end - $start); - $points = explode(',', $inside); - return array_map(function ($point) { - $coords = explode(' ', trim($point)); - return [(float)$coords[0], (float)$coords[1]]; - }, $points); + if ($e->getCode() === '54000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7 && \str_contains($e->getMessage(), 'index row')) { + return new LimitException('Index row size exceeds the maximum', $e->getCode(), $e); } - if (ctype_xdigit($wkb)) { - $wkb = hex2bin($wkb); - if ($wkb === false) { - throw new DatabaseException("Failed to convert hex WKB to binary."); + if ($e->getCode() === '42P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + if ($this->isUndefinedAlias($e->getMessage())) { + return new QueryException('Query references an undefined table or alias', $e->getCode(), $e); } - } - if (strlen($wkb) < 9) { - throw new DatabaseException("WKB too short to be a valid geometry"); + return new NotFoundException('Collection not found', $e->getCode(), $e); } - $byteOrder = ord($wkb[0]); - if ($byteOrder === 0) { - throw new DatabaseException("Big-endian WKB not supported"); - } elseif ($byteOrder !== 1) { - throw new DatabaseException("Invalid byte order in WKB"); + if ($e->getCode() === '42703' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); } - // Type + SRID flag - $typeField = unpack('V', substr($wkb, 1, 4)); - if ($typeField === false) { - throw new DatabaseException('Failed to unpack the type field from WKB.'); + if ( + $e->getCode() === '42P10' + && isset($e->errorInfo[1]) + && $e->errorInfo[1] === 7 + && \str_contains($e->getMessage(), 'for SELECT DISTINCT, ORDER BY expressions must appear in select list') + ) { + return new QueryException('A distinct() query can only be ordered by a selected attribute on this database', $e->getCode(), $e); } - $typeField = $typeField[1]; - $geomType = $typeField & 0xFF; - $hasSRID = ($typeField & 0x20000000) !== 0; - - if ($geomType !== 2) { // 2 = LINESTRING - throw new DatabaseException("Not a LINESTRING geometry type, got {$geomType}"); + if ($e->getCode() === '40P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new ContentionException('Deadlock detected', $e->getCode(), $e); } - $offset = 5; - if ($hasSRID) { - $offset += 4; + if ($e->getCode() === '40001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new ContentionException('Could not serialize access due to a concurrent update', $e->getCode(), $e); } - $numPoints = unpack('V', substr($wkb, $offset, 4)); - if ($numPoints === false) { - throw new DatabaseException("Failed to unpack number of points at offset {$offset}."); + if ($e->getCode() === '55P03' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new ContentionException('Lock not available', $e->getCode(), $e); } - $numPoints = $numPoints[1]; - $offset += 4; - - $points = []; - for ($i = 0; $i < $numPoints; $i++) { - $x = unpack('e', substr($wkb, $offset, 8)); - if ($x === false) { - throw new DatabaseException("Failed to unpack X coordinate at offset {$offset}."); - } - - $x = (float) $x[1]; - - $offset += 8; - - $y = unpack('e', substr($wkb, $offset, 8)); - if ($y === false) { - throw new DatabaseException("Failed to unpack Y coordinate at offset {$offset}."); - } - - $y = (float) $y[1]; + if ($e->getCode() === '22021' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new CharacterException('Invalid character', $e->getCode(), $e); + } - $offset += 8; - $points[] = [$x, $y]; + if ($e->getCode() === '42883' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new QueryException('Query applies a function or operator the attribute type does not support', $e->getCode(), $e); } - return $points; + return $e; } - public function decodePolygon(string $wkb): array + #[\Override] + protected function processSelectException(PDOException $e, Statement $statement): Exception { - // POLYGON((x1,y1),(x2,y2)) - if (str_starts_with($wkb, 'POLYGON((')) { - $start = strpos($wkb, '((') + 2; - $end = strrpos($wkb, '))'); - $inside = substr($wkb, $start, $end - $start); - - $rings = explode('),(', $inside); - return array_map(function ($ring) { - $points = explode(',', $ring); - return array_map(function ($point) { - $coords = explode(' ', trim($point)); - return [(float)$coords[0], (float)$coords[1]]; - }, $points); - }, $rings); - } - - // Convert hex string to binary if needed - if (preg_match('/^[0-9a-fA-F]+$/', $wkb)) { - $wkb = hex2bin($wkb); - if ($wkb === false) { - throw new DatabaseException("Invalid hex WKB"); - } + if ( + $e->getCode() === '42P10' + && isset($e->errorInfo[1]) + && $e->errorInfo[1] === 7 + && \str_starts_with($statement->query, 'SELECT DISTINCT ') + ) { + return new QueryException('A distinct() query can only be ordered by a selected attribute on this database', $e->getCode(), $e); } - if (strlen($wkb) < 9) { - throw new DatabaseException("WKB too short"); - } - - $uInt32 = 'V'; // little-endian 32-bit unsigned - $uDouble = 'd'; // little-endian double + return parent::processSelectException($e, $statement); + } - $typeInt = unpack($uInt32, substr($wkb, 1, 4)); - if ($typeInt === false) { - throw new DatabaseException('Failed to unpack type field from WKB.'); + /** + * Whether a 42P01 names something other than a table of this namespace, whatever the server's + * language. A statement names a missing table with its schema and a DROP without one, but only a + * statement reports a position, so an unqualified name followed by one is an alias. + */ + protected function isUndefinedAlias(string $message): bool + { + $message = \rtrim($message); + $firstLine = \explode("\n", $message, 2)[0]; + if (\preg_match(self::QUOTED_IDENTIFIER, $firstLine, $matches) !== 1) { + return false; } - $typeInt = (int) $typeInt[1]; - $hasSrid = ($typeInt & 0x20000000) !== 0; - $geomType = $typeInt & 0xFF; + $name = $matches[1]; + $separator = \strrpos($name, '.'); + $relation = $separator === false ? $name : \substr($name, $separator + 1); - if ($geomType !== 3) { // 3 = POLYGON - throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); + if (! \str_starts_with($relation, $this->getNamespace().'_') && \preg_match(self::HASHED_IDENTIFIER, $relation) !== 1) { + return true; } - $offset = 5; - if ($hasSrid) { - $offset += 4; - } + return $separator === false && \str_contains($message, "\n"); + } - // Number of rings - $numRings = unpack($uInt32, substr($wkb, $offset, 4)); - if ($numRings === false) { - throw new DatabaseException('Failed to unpack number of rings from WKB.'); + /** + * Extract the columns named by a PostgreSQL unique-violation DETAIL line. + * + * @return list|null + */ + protected function getViolatedColumns(string $message): ?array + { + if (\preg_match('/Key \(([^)]+)\)=/', $message, $matches) !== 1) { + return null; } - $numRings = (int) $numRings[1]; - $offset += 4; - - $rings = []; - for ($r = 0; $r < $numRings; $r++) { - $numPoints = unpack($uInt32, substr($wkb, $offset, 4)); - if ($numPoints === false) { - throw new DatabaseException('Failed to unpack number of points from WKB.'); - } - - $numPoints = (int) $numPoints[1]; - $offset += 4; - $points = []; - for ($i = 0; $i < $numPoints; $i++) { - $x = unpack($uDouble, substr($wkb, $offset, 8)); - if ($x === false) { - throw new DatabaseException('Failed to unpack X coordinate from WKB.'); - } - - $x = (float) $x[1]; + $columns = \array_map( + static fn (string $column): string => \trim($column, " \t\"'"), + \explode(',', $matches[1]) + ); - $y = unpack($uDouble, substr($wkb, $offset + 8, 8)); - if ($y === false) { - throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); - } + \sort($columns); - $y = (float) $y[1]; + return $columns; + } - $points[] = [$x, $y]; - $offset += 16; - } - $rings[] = $points; - } + #[\Override] + protected function quote(string $string): string + { + return '"'.\str_replace('"', '""', $string).'"'; + } - return $rings; // array of rings, each ring is array of [x,y] + #[\Override] + protected function getIdentifierQuote(): string + { + return '"'; } /** - * Get SQL expression for operator - * - * @param string $column - * @param Operator $operator - * @param array $binds - * @param bool $useTargetPrefix - * @return ?string + * Only a stored id is skipped; a row colliding on another unique index still fails with + * Unique, as a bare ON CONFLICT DO NOTHING would skip it silently. */ - protected function getOperatorSQL(string $column, Operator $operator, array &$binds, bool $useTargetPrefix = false): ?string + #[\Override] + protected function insertOrIgnore(SQLBuilder $builder): Statement + { + $insert = $builder->insert(); + $target = \implode(', ', \array_map($this->quote(...), $this->documentKeyColumns())); + + return new Statement($insert->query.' ON CONFLICT ('.$target.') DO NOTHING', $insert->bindings); + } + + #[\Override] + protected function getOperatorSql(string $column, Operator $operator, int &$bindIndex, bool $useTargetPrefix = false): ?string { $quotedColumn = $this->quote($column); $columnRef = $useTargetPrefix ? "target.{$quotedColumn}" : $quotedColumn; @@ -2681,139 +2095,157 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi $values = $operator->getValues(); switch ($method) { - // Numeric operators - case Operator::TYPE_INCREMENT: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Increment: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN COALESCE({$columnRef}, 0) + CAST(:$bindKey AS NUMERIC) > CAST(:$maxKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) + CAST(:$bindKey AS NUMERIC) END"; } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) + :$bindKey"; - case Operator::TYPE_DECREMENT: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Decrement: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $minKey = $this->registerOperatorBind($binds, $values[1]); + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN COALESCE({$columnRef}, 0) - CAST(:$bindKey AS NUMERIC) < CAST(:$minKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) - CAST(:$bindKey AS NUMERIC) END"; } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) - :$bindKey"; - case Operator::TYPE_MULTIPLY: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Multiply: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN COALESCE({$columnRef}, 0) * CAST(:$bindKey AS NUMERIC) > CAST(:$maxKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) * CAST(:$bindKey AS NUMERIC) END"; } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) * :$bindKey"; - case Operator::TYPE_DIVIDE: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Divide: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $minKey = $this->registerOperatorBind($binds, $values[1]); + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN CAST(:$bindKey AS NUMERIC) != 0 AND COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC) < CAST(:$minKey AS NUMERIC) THEN COALESCE({$columnRef}, 0) ELSE COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC) END"; } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) / :$bindKey"; - case Operator::TYPE_MODULO: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Modulo: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = MOD(COALESCE({$columnRef}::numeric, 0), :$bindKey::numeric)"; - case Operator::TYPE_POWER: + case OperatorType::Power: $exponent = $values[0] ?? 1; - $bindKey = $this->registerOperatorBind($binds, $exponent); + if (! \is_int($exponent) && ! \is_float($exponent)) { + throw new OperatorException('Power exponent must be numeric'); + } + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); - $col = "COALESCE({$columnRef}, 0)"; - - // Leave the value unchanged only for undefined inputs, then apply the power if - // the result stays within the max. The exponent is constant, so only the - // undefined guard its value can actually trigger is emitted. PostgreSQL throws - // a hard error for 0 to a negative power and a negative base to a fractional - // exponent, so those must never reach POWER(). + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + + $columnValue = "COALESCE({$columnRef}, 0)"; $oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0; + $guards = []; - $whens = []; if ($exponent < 0) { - $whens[] = "WHEN {$col} = 0 THEN {$col}"; + $guards[] = "WHEN {$columnValue} = 0 THEN {$columnValue}"; } if (\floor($exponent) != $exponent) { - $whens[] = "WHEN {$col} < 0 THEN {$col}"; + $guards[] = "WHEN {$columnValue} < 0 THEN {$columnValue}"; } - // Cap by magnitude via logarithms so POWER() never runs on a value that would - // overflow (base^exp > max <=> exp * LN(base) > LN(max)). if ($exponent == 0) { - // Every base to the zeroth power is 1 (including 0^0), which the magnitude - // check below can't see for a base of 0. The result 1 exceeds the max when - // max < 1, i.e. LN(max) < 0 (LN also coerces the bound value numerically). - $whens[] = "WHEN LN(:$maxKey) < 0 THEN {$col}"; + $guards[] = "WHEN LN(:$maxKey) < 0 THEN {$columnValue}"; } elseif ($oddInteger) { - // An odd exponent keeps a negative base negative, and a negative result is - // always within a positive max, so only cap positive bases; negative bases - // fall through to POWER() and their (negative) result is applied. - $whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}"; + $guards[] = "WHEN {$columnValue} > 0 AND :$bindKey * LN({$columnValue}) > LN(:$maxKey) THEN {$columnValue}"; } else { - // Otherwise the result is non-negative, so its magnitude equals its value — - // cap either sign. ABS() keeps LN() defined for a negative even-power base. - $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; + $guards[] = "WHEN {$columnValue} <> 0 AND :$bindKey * LN(ABS({$columnValue})) > LN(:$maxKey) THEN {$columnValue}"; } - $whenSql = \implode(' ', $whens); - return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; + return "{$quotedColumn} = CASE ".\implode(' ', $guards)." ELSE POWER({$columnValue}, :$bindKey) END"; } + return "{$quotedColumn} = POWER(COALESCE({$columnRef}, 0), :$bindKey)"; - // String operators - case Operator::TYPE_STRING_CONCAT: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? ''); + case OperatorType::StringConcat: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CONCAT(COALESCE({$columnRef}, ''), :$bindKey)"; - case Operator::TYPE_STRING_REPLACE: - $searchKey = $this->registerOperatorBind($binds, $values[0] ?? ''); - $replaceKey = $this->registerOperatorBind($binds, $values[1] ?? ''); + case OperatorType::StringReplace: + $searchKey = "op_{$bindIndex}"; + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = REPLACE(COALESCE({$columnRef}, ''), :$searchKey, :$replaceKey)"; - // Boolean operators - case Operator::TYPE_TOGGLE: + case OperatorType::Toggle: return "{$quotedColumn} = NOT COALESCE({$columnRef}, FALSE)"; - // Array operators - case Operator::TYPE_ARRAY_APPEND: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayAppend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE({$columnRef}, '[]'::jsonb) || :$bindKey::jsonb"; - case Operator::TYPE_ARRAY_PREPEND: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayPrepend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = :$bindKey::jsonb || COALESCE({$columnRef}, '[]'::jsonb)"; - case Operator::TYPE_ARRAY_UNIQUE: + case OperatorType::ArrayUnique: return "{$quotedColumn} = COALESCE(( SELECT jsonb_agg(DISTINCT value) FROM jsonb_array_elements({$columnRef}) AS value ), '[]'::jsonb)"; - case Operator::TYPE_ARRAY_REMOVE: - $bindKey = $this->registerOperatorBind($binds, json_encode($values[0] ?? null)); + case OperatorType::ArrayRemove: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE(( SELECT jsonb_agg(value) FROM jsonb_array_elements({$columnRef}) AS value WHERE value != :$bindKey::jsonb ), '[]'::jsonb)"; - case Operator::TYPE_ARRAY_INSERT: - $indexKey = $this->registerOperatorBind($binds, $values[0] ?? 0); - $valueKey = $this->registerOperatorBind($binds, json_encode($values[1] ?? null)); + case OperatorType::ArrayInsert: + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = ( SELECT jsonb_agg(value ORDER BY idx) FROM ( @@ -2829,27 +2261,32 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) AS combined )"; - case Operator::TYPE_ARRAY_INTERSECT: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayIntersect: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE(( SELECT jsonb_agg(value) FROM jsonb_array_elements({$columnRef}) AS value WHERE value IN (SELECT jsonb_array_elements(:$bindKey::jsonb)) ), '[]'::jsonb)"; - case Operator::TYPE_ARRAY_DIFF: - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayDiff: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE(( SELECT jsonb_agg(value) FROM jsonb_array_elements({$columnRef}) AS value WHERE value NOT IN (SELECT jsonb_array_elements(:$bindKey::jsonb)) ), '[]'::jsonb)"; - case Operator::TYPE_ARRAY_FILTER: - $condition = $values[0] ?? 'equal'; - $filterValue = $values[1] ?? null; - $conditionKey = $this->registerOperatorBind($binds, $condition); - $valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue)); + case OperatorType::ArrayFilter: + $conditionKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE(( SELECT jsonb_agg(value) FROM jsonb_array_elements({$columnRef}) AS value @@ -2866,33 +2303,45 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi END ), '[]'::jsonb)"; - // Date operators - case Operator::TYPE_DATE_ADD_DAYS: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0); + case OperatorType::DateAddDays: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = {$columnRef} + (:$bindKey || ' days')::INTERVAL"; - case Operator::TYPE_DATE_SUB_DAYS: - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0); + case OperatorType::DateSubDays: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = {$columnRef} - (:$bindKey || ' days')::INTERVAL"; - case Operator::TYPE_DATE_SET_NOW: + case OperatorType::DateSetNow: return "{$quotedColumn} = NOW()"; default: - throw new OperatorException("Invalid operator: {$method}"); + throw new OperatorException('Invalid operator'); } } - public function getSupportNonUtfCharacters(): bool + #[\Override] + protected function getOperatorBuilderExpression(string $column, Operator $operator): Expression { - return false; + if ($operator->getMethod() === OperatorType::ArrayRemove) { + $result = parent::getOperatorBuilderExpression($column, $operator); + $values = $operator->getValues(); + $value = $values[0] ?? null; + if (! is_array($value)) { + return new Expression($result->sql, [json_encode($value)]); + } + + return $result; + } + + return parent::getOperatorBuilderExpression($column, $operator); } /** * Ensure index key length stays within PostgreSQL's 63 character limit. - * - * @param string $key - * @return string */ protected function getShortKey(string $key): string { @@ -2918,29 +2367,29 @@ protected function getShortKey(string $key): string return substr($hash, 0, self::MAX_IDENTIFIER_NAME); } - protected function getSQLTable(string $name): string + #[\Override] + protected function qualifyTable(string $database, string $namespace, string $name): string { - $table = "{$this->getNamespace()}_{$this->filter($name)}"; - $table = $this->getShortKey($table); - - return "{$this->quote($this->getDatabase())}.{$this->quote($table)}"; + return $database.'.'.$this->tableName($namespace, $name); } - public function getSupportForTTLIndexes(): bool + private function tableName(string $namespace, string $name): string { - return false; + return $this->getShortKey("{$namespace}_{$this->filter($name)}"); } + protected function buildJsonbPath(string $path, bool $asText = false): string { $parts = \explode('.', $path); foreach ($parts as $part) { - if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $part)) { - throw new DatabaseException('Invalid JSON key ' . $part); + if (\preg_match(ObjectPath::KEY_PATTERN, $part) !== 1) { + throw new DatabaseException('Invalid JSON key '.$part); } } if (\count($parts) === 1) { $column = $this->filter($parts[0]); + return $this->quote($column); } diff --git a/src/Database/Adapter/Profile.php b/src/Database/Adapter/Profile.php new file mode 100644 index 0000000000..5c1fab4b72 --- /dev/null +++ b/src/Database/Adapter/Profile.php @@ -0,0 +1,67 @@ + + */ + private array $supported; + + /** + * @var array + */ + private array $implemented; + + /** + * @param list $capabilities + * @param list $features + * @param (Closure(): bool)|null $definedAttributes + */ + public function __construct( + public Limits $limits, + public array $capabilities, + public array $features, + public bool $sharedTables, + public bool $migrating, + private ?Closure $definedAttributes = null, + ) { + $supported = []; + foreach ($capabilities as $capability) { + $supported[$capability->name] = true; + } + $this->supported = $supported; + $this->implemented = \array_fill_keys($features, true); + } + + public function supports(Capability $capability): bool + { + if ($capability === Capability::DefinedAttributes && $this->definedAttributes !== null) { + return ($this->definedAttributes)(); + } + + return isset($this->supported[$capability->name]); + } + + /** + * @param class-string $feature + */ + public function hasFeature(string $feature): bool + { + return isset($this->implemented[$feature]); + } +} diff --git a/src/Database/Adapter/ReadWritePool.php b/src/Database/Adapter/ReadWritePool.php new file mode 100644 index 0000000000..18016ea19e --- /dev/null +++ b/src/Database/Adapter/ReadWritePool.php @@ -0,0 +1,262 @@ + + */ + private UtopiaPool $readPool; + + private bool $sticky = true; + + private int $stickyDurationMs = 5000; + + private ?float $lastWriteTimestamp = null; + + private ?string $writePoolHostname = null; + + private bool $replicaRead = false; + + /** + * @param UtopiaPool $writePool + * @param UtopiaPool $readPool + */ + public function __construct(UtopiaPool $writePool, UtopiaPool $readPool) + { + parent::__construct($writePool); + $this->readPool = $readPool; + } + + public function setStickyDuration(int $milliseconds): static + { + $this->stickyDurationMs = $milliseconds; + + return $this; + } + + public function setSticky(bool $sticky): static + { + $this->sticky = $sticky; + + return $this; + } + + #[\Override] + public function delegate(string $method, array $arguments): mixed + { + return $this->borrowAndInvoke($method, $arguments); + } + + #[\Override] + public function withTransaction(callable $callback): mixed + { + try { + return parent::withTransaction($callback); + } finally { + $this->stick(); + } + } + + #[\Override] + public function hostname(): string + { + return $this->writePoolHostname ??= parent::hostname(); + } + + /** + * Whether the calling coroutine's latest read was served by a replica, which may lag the + * primary: what it returned must not be cached for other readers. + */ + public function servedByReplica(): bool + { + $context = $this->getCoroutineContext(); + if ($context === null) { + return $this->replicaRead; + } + + return ($context[$this->getReplicaReadKey()] ?? false) === true; + } + + /** + * @return UtopiaPool + */ + #[\Override] + protected function getReadPool(): UtopiaPool + { + return $this->isSticky() ? parent::getReadPool() : $this->readPool; + } + + /** + * @param array $arguments + * @param class-string|null $feature + */ + #[\Override] + protected function borrowAndInvoke(string $method, array $arguments, ?string $feature = null): mixed + { + if ($this->isWrite($method, $arguments)) { + $this->recordRead($method, false); + try { + return parent::borrowAndInvoke($method, $arguments, $feature); + } finally { + $this->stick(); + } + } + + if ($this->pin() !== null || $this->isSticky() || \in_array($method, self::WRITE_POOL_METADATA_METHODS, true)) { + $this->recordRead($method, false); + + return parent::borrowAndInvoke($method, $arguments, $feature); + } + + $this->recordRead($method, true); + + return $this->readPool->use(function (Adapter $adapter) use ($method, $arguments, $feature) { + try { + $this->syncBorrowed($adapter); + + return $this->invokeDelegated($adapter, $method, $arguments, $feature); + } finally { + $this->releaseBorrowed($adapter); + } + }); + } + + /** + * @param array $arguments + */ + private function isWrite(string $method, array $arguments): bool + { + if ($this->decidesWrite($method, $arguments)) { + return true; + } + + return ! \in_array($method, self::READ_METHODS, true) + && ! \in_array($method, self::METADATA_METHODS, true) + && ! \in_array($method, self::WRITE_POOL_METADATA_METHODS, true); + } + + /** + * A read whose result decides a write must see the primary: a lagging replica would select + * rows the primary has already changed, or miss rows it has already written. + * + * @param array $arguments + */ + private function decidesWrite(string $method, array $arguments): bool + { + return match ($method) { + 'getDocument' => ($arguments[3] ?? $arguments['forUpdate'] ?? false) === true, + 'find' => ($arguments[8] ?? $arguments['forPermission'] ?? PermissionType::Read) !== PermissionType::Read, + default => false, + }; + } + + private function recordRead(string $method, bool $replica): void + { + if (! \in_array($method, self::READ_METHODS, true)) { + return; + } + + $context = $this->getCoroutineContext(); + if ($context === null) { + $this->replicaRead = $replica; + + return; + } + + $context[$this->getReplicaReadKey()] = $replica; + } + + /** + * @return Context|null + */ + private function getCoroutineContext(): ?object + { + if (! \extension_loaded('swoole')) { + return null; + } + + $context = Coroutine::getContext(); + + return $context instanceof Context ? $context : null; + } + + private function getReplicaReadKey(): string + { + return self::REPLICA_READ.\spl_object_id($this); + } + + private function stick(): void + { + $this->lastWriteTimestamp = \microtime(true); + } + + private function isSticky(): bool + { + if (! $this->sticky || $this->lastWriteTimestamp === null) { + return false; + } + + $elapsed = (\microtime(true) - $this->lastWriteTimestamp) * 1000; + + return $elapsed < $this->stickyDurationMs; + } +} diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 5d61cf3044..3935445bde 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -6,6 +6,10 @@ use Redis as RedisClient; use Utopia\Database\Adapter; +use Utopia\Database\Adapter\Redis\Write; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Change; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -17,65 +21,50 @@ use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; +use Utopia\Database\Id; +use Utopia\Database\Index; use Utopia\Database\Operator; +use Utopia\Database\OperatorType; +use Utopia\Database\PermissionType; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Validator\BigInt; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; /** * Redis-backed adapter mirroring the Memory adapter's surface. * * Storage key schema (every key is prefixed with `KEY_PREFIX:`): * - * {ns} = getNamespace() - * {db} = current setDatabase() value - * {col} = collection ID + * {ns}:dbs | SET | database names + * {ns}:{db}:cols | SET | collection IDs + * {ns}:{db}:meta:{col} | HASH | schema/attrs/indexes + * {ns}:{db}:doc:{col}:{id} | STRING | JSON Document + * {ns}:{db}:idx:{col} | SET | doc IDs in collection + * {ns}:{db}:perm:{col}:{letter}:{role} | SET | doc IDs by action+role + * {ns}:{db}:perm:doc:{col}:{id} | HASH | role -> csv letters + * {ns}:{db}:grants:{col} | SET | perm keys written for the collection * - * Key | Type | Holds - * ---------------------------------------------+------+---------------------------------- - * {ns}:{db}:dbs | SET | database names - * {ns}:{db}:cols | SET | collection IDs in this db - * {ns}:{db}:meta:{col} | HASH | fields: schema, attrs, indexes, docCount, sizeBytes - * {ns}:{db}:doc:{col}:{id} | STRING | JSON-encoded Document - * {ns}:{db}:idx:{col} | SET | doc IDs in collection (for SCAN/list) - * {ns}:{db}:perm:{col}:{r|c|u|d|w}:{role} | SET | doc IDs by action+role (non-shared) - * {ns}:{db}:perm:t:{tenant}:{col}:{letter}:{role} | SET | shared-tables variant - * {ns}:{db}:perm:doc:{col}:{id} | HASH | role -> csv("read,update,delete") - * {ns}:{db}:perm:t:{tenant}:doc:{col}:{id} | HASH | shared-tables variant - * {ns}:{db}:tenants:{col}:{tenant} | SET | doc IDs filtered by tenant - * - * Transaction model: `tx()` is a single-shot wrapper that surfaces - * `\RedisException` as `TransactionException`. There is NO retry, no - * `WATCH`/`MULTI`/`EXEC`, and no automatic OCC — retrying would replay - * journal side-effects (duplicate `INCR` on sequence keys, double - * pipelined SADDs). Real OCC is a follow-up; `getSupportForTransactionRetries()` - * returns `false` so the shared trait's OCC tests stay off. Pessimistic - * update locks are intentionally unsupported. - * - * Rollback contract: `rollbackJournal()` MUST use raw `\Redis` client - * commands only — calling a public adapter method re-enters `journal()` - * and recurses infinitely. All inverses route through `rawDeleteDoc()` - * and `rawRestoreDoc()`. + * Shared-tables variants bucket on tenant under `t:{tenant}` segments. */ -class Redis extends Adapter +class Redis extends Adapter implements + Feature\Relationships, + Feature\Upserts, + Feature\Connection { public const string KEY_PREFIX = 'utopia'; public const string SEP = ':'; - /** - * Default SCAN MATCH batch size — also the variadic DEL chunk size - * used by collection purge. Aligned with the test harness teardown - * documented in Contract.md. - */ private const int SCAN_BATCH_SIZE = 500; - /** - * Maximum depth for `json_decode` when reading document payloads and - * meta-hash fields. Matches the PHP default; hoisted so the value is - * named once instead of repeated 8+ times across the file. - */ private const int JSON_DECODE_DEPTH = 512; private RedisClient $client; @@ -90,2906 +79,2718 @@ public function __construct(RedisClient $client) $this->client = $client; } - /** - * Join the supplied parts with `SEP`. Does NOT prepend `KEY_PREFIX` — - * call sites compose the prefix by passing `$this->ns()` (which is - * `'KEY_PREFIX:{namespace}:{database}'`) as the first argument. - */ - private function key(string ...$parts): string + #[\Override] + public function getDriver(): RedisClient { - return \implode(self::SEP, $parts); + return $this->client; } /** - * Build the `'KEY_PREFIX:{namespace}:{database}'` prefix shared by - * every adapter-produced key. All call sites that construct a Redis - * key MUST pass `$this->ns()` as the first argument to `key()` — - * passing the raw namespace/database produces unprefixed keys that - * collide across processes. + * @return array */ - private function ns(): string - { - return $this->nsFor($this->getNamespace(), $this->getDatabase()); + #[\Override] + public function capabilities(): array + { + return array_merge(parent::capabilities(), [ + Capability::Schemas, + Capability::IndexFulltext, + Capability::AttributeResizing, + Capability::Objects, + Capability::Operators, + Capability::OrderRandom, + Capability::DefinedAttributes, + Capability::TransactionNested, + ]); } - /** - * Variant of `ns()` that targets a specific database name within the - * current namespace. Used by `exists()` / `delete()` and similar - * cross-database operations where the Adapter's bound database is - * not the database under inspection. - */ - private function nsFor(string $namespace, string $database): string + #[\Override] + public function ping(): bool { - return self::KEY_PREFIX . self::SEP . $namespace . self::SEP . $database; + return (bool) $this->client->ping(); } - /** - * Build the namespace-only prefix `'KEY_PREFIX:{namespace}'`. - * Used for keys that are shared across all databases in a namespace, - * such as the database-registry SET (`dbs`). Unlike `ns()` this does - * NOT include the currently bound database name, so `create()`, - * `exists()`, `list()`, and `delete()` all read/write the same key - * regardless of which database is currently selected. - */ - private function nsBase(): string + #[\Override] + public function reconnect(): void { - return self::KEY_PREFIX . self::SEP . $this->getNamespace(); } - /** - * Build the document storage key. Lower-cases `$id` to match MariaDB's - * default case-insensitive UID semantics. Under shared tables every doc - * key is bucketed by tenant so two tenants can hold the same id without - * colliding — `null` tenants land under the `_` bucket alongside global - * METADATA rows. - */ - private function docKey(string $collection, string $id, int|string|null $tenant = null): string + #[\Override] + public function hostname(): string { - $id = \strtolower($id); - if (! $this->getSharedTables()) { - return $this->key($this->ns(), 'doc', $collection, $id); - } + return $this->hostname; + } - $bucket = $this->bucketFor($tenant); + #[\Override] + public function startTransaction(): bool + { + $this->journalStack[] = []; + $this->inTransaction++; - return $this->key($this->ns(), 'doc', 't', $bucket, $collection, $id); + return true; } - /** - * Build the doc-id index SET key for a collection. Tenant-scoped under - * shared tables so per-tenant `find()` / `count()` see only their own - * ids and a recreated collection does not inherit foreign ids. - */ - private function idxKey(string $collection, int|string|null $tenant = null): string + #[\Override] + public function commitTransaction(): bool { - if (! $this->getSharedTables()) { - return $this->key($this->ns(), 'idx', $collection); + if ($this->inTransaction === 0) { + return false; } - return $this->key($this->ns(), 'idx', 't', $this->bucketFor($tenant), $collection); + $frame = \array_pop($this->journalStack); + if ($frame !== null && $frame !== [] && $this->journalStack !== []) { + $outerIndex = \count($this->journalStack) - 1; + \array_push($this->journalStack[$outerIndex], ...$frame); + } + $this->inTransaction--; + + return true; } - /** - * Build the sequence counter key for a collection. Tenant-scoped under - * shared tables so each tenant gets an independent monotonic id space. - */ - private function seqKey(string $collection, int|string|null $tenant = null): string + #[\Override] + public function rollbackTransaction(): bool { - if (! $this->getSharedTables()) { - return $this->key($this->ns(), 'seq', $collection); + if ($this->inTransaction === 0) { + return false; } - return $this->key($this->ns(), 'seq', 't', $this->bucketFor($tenant), $collection); - } + try { + $this->rollbackJournal(); + $this->inTransaction--; + } catch (\Throwable $error) { + $this->inTransaction = 0; + $this->journalStack = []; - /** - * Resolve the tenant-bucket segment for shared-tables doc/idx/seq keys, - * mapping `null` to the literal `'_'` so all shared-tables keys share a - * single bucket convention. - */ - private function bucketFor(int|string|null $tenant): string - { - if ($tenant === null) { - $tenant = $this->getTenant(); + throw $error; } - return $tenant === null ? '_' : (string) $tenant; + return true; } - private function encode(Document $document): string + #[\Override] + public function create(string $name): bool { - return \json_encode( - $document->getArrayCopy(), - JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION - ); - } + $name = $this->filter($name); + $dbsKey = $this->key($this->nsBase(), 'dbs'); - private function decode(string $payload): Document - { - try { - /** @var array $data */ - $data = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new DatabaseException('Document decode failed: ' . $e->getMessage(), 0, $e); - } + $this->transaction(fn (RedisClient $client) => $client->sAdd($dbsKey, $name)); - return new Document($data); + return true; } /** - * Single-shot wrapper for journal-tracked Redis operations. Does NOT - * retry — Redis transient errors propagate as `TransactionException`. - * Retrying here would replay journal side-effects (duplicate entries, - * non-idempotent commands like `INCR` on the sequence key advancing - * twice) so we leave retry policy to call sites that can prove - * idempotency. OCC support via WATCH/MULTI/EXEC is a follow-up - * (see Contract.md). `getSupportForTransactionRetries()` returns - * `false` so the shared trait suite skips OCC-retry assertions. + * Renames every key of the database within the current namespace. A permission grant set records the + * keys it guards, so its members are moved to the new key space too. A failure part way moves the keys + * already moved back, newest first, and is thrown. Shared tables refuse the rename: other tenants' + * keys share the database. * - * @param callable(RedisClient): mixed $fn + * @throws DatabaseException */ - protected function tx(callable $fn): mixed + #[\Override] + public function update(string $name, string $new): bool { - try { - return $fn($this->client); - } catch (\RedisException $exception) { - throw new TransactionException('tx failed: ' . $exception->getMessage(), 0, $exception); + if ($this->hasSharedTables()) { + throw new DatabaseException('Cannot rename a database while shared tables are enabled'); } - } - /** - * Persist a document's permissions into the inverted role/action sets and - * the per-document role->letters HASH. The same writes are journalled so - * T56 can revert them on rollback. - * - * NOTE: opens its own `multi(\Redis::PIPELINE)` block. MUST NOT be wrapped - * inside a MULTI/EXEC: phpredis does not support nested MULTI, and - * pipelining inside a transaction would queue commands incorrectly. If - * `tx()` ever gains real WATCH/MULTI/EXEC, this method must be refactored - * to either share the outer connection's mode, take an `inMulti` flag, - * or be split into a non-pipelined variant. Same constraint applies to - * `clearPermissions()` and `getSequences()`. - */ - private function writePermissions(string $collection, string $id, Document $document): void - { - // Document keys (`doc:{col}:{id}`) and the index SET (`idx:{col}`) both - // use `\strtolower($id)`. The inverted permission SETs must follow the - // same convention so `applyPermissionFilter()` can intersect ids from - // the index SET with the perm SETs without case mismatch. - $id = \strtolower($id); + $name = $this->filter($name); + $new = $this->filter($new); + $namespace = $this->getNamespace(); + $dbsKey = $this->key($this->nsBase(), 'dbs'); - $byRole = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $role) { - $byRole[$role][] = self::actionLetter($type); - } + if ((bool) $this->client->sIsMember($dbsKey, $name) === false) { + throw new NotFoundException('Database not found'); } - if ($byRole === []) { - return; + if ((bool) $this->client->sIsMember($dbsKey, $new)) { + throw new DuplicateException('Database already exists'); } - $hashKey = $this->permDocKey($collection, $id); - $hashFields = []; - $writes = []; - foreach ($byRole as $role => $letters) { - $unique = \array_values(\array_unique($letters)); - \sort($unique); - $hashFields[$role] = \implode(',', $unique); - foreach ($unique as $letter) { - $writes[] = [$role, $letter]; - } - } + $from = $this->nsFor($namespace, $name).self::SEP; + $to = $this->nsFor($namespace, $new).self::SEP; + $grants = 'grants'.self::SEP; - // Pipeline the SADD writes so a doc with N (role,action) pairs hits - // Redis in a single round trip rather than N+1 sequential sends. - $this->client->multi(\Redis::PIPELINE); - try { - foreach ($writes as [$role, $letter]) { - $this->client->sAdd($this->permKey($collection, $letter, $role), $id); - } - $this->client->hMSet($hashKey, $hashFields); - $this->client->exec(); - } catch (\Throwable $e) { - // PIPELINE-mode discard is version-dependent across phpredis - // (no-op in 5.x, raises in some 4.x). Swallow any failure here - // so we propagate the original cause, not a teardown error. + $this->transaction(function (RedisClient $client) use ($dbsKey, $name, $new, $from, $to, $grants): void { + $moved = []; try { - $this->client->discard(); - } catch (\Throwable) { - // ignore - } - throw $e; - } + foreach ($this->scanKeys($client, $from.'*') as $key) { + if (! \str_starts_with($key, $from)) { + continue; + } - // Journal one entry per (role, letter) pair so rollback dispatches - // through the existing 'createPerm' case without a bespoke handler. - foreach ($writes as [$role, $letter]) { - $this->journal('createPerm', [ - 'collection' => $collection, - 'id' => $id, - 'role' => $role, - 'letter' => $letter, - ]); - } - } + $target = $to.\substr($key, \strlen($from)); + $this->renameKey($client, $key, $target); + $moved[$key] = $target; - /** - * Strip every permission entry for ($collection, $id) from the inverted - * sets and the per-doc HASH, recording the previous state in the journal - * so T56 can replay it on rollback. - * - * NOTE: same nested-pipeline constraint as `writePermissions()`. MUST NOT - * be wrapped inside a MULTI/EXEC. See `writePermissions()` docblock for - * the refactor checklist if `tx()` ever gains real transaction support. - */ - private function clearPermissions(string $collection, string $id): void - { - // Mirror writePermissions(): all perm-set operations key off the - // lowercased id so reads and writes stay symmetric. - $id = \strtolower($id); - $hashKey = $this->permDocKey($collection, $id); - /** @var array|false $hash */ - $hash = $this->client->hGetAll($hashKey); - if ($hash === false || $hash === []) { - return; - } + if (\str_starts_with($target, $to.$grants)) { + $this->moveGrantMembers($client, $target, $from, $to); + } + } - $removals = []; - foreach ($hash as $role => $letterCsv) { - if ($letterCsv === '') { - continue; - } - foreach (\explode(',', $letterCsv) as $letter) { - $removals[] = [$role, $letter]; - } - } + $client->sRem($dbsKey, $name); + $client->sAdd($dbsKey, $new); + } catch (\Throwable $error) { + foreach (\array_reverse($moved, true) as $key => $target) { + if (\str_starts_with($target, $to.$grants)) { + $this->moveGrantMembers($client, $target, $to, $from); + } + $this->renameKey($client, $target, $key); + } + $client->sRem($dbsKey, $new); + $client->sAdd($dbsKey, $name); - // Pipeline the SREMs and HDEL together — one round trip per call site. - $this->client->multi(\Redis::PIPELINE); - try { - foreach ($removals as [$role, $letter]) { - $this->client->sRem($this->permKey($collection, $letter, $role), $id); - } - $this->client->del($hashKey); - $this->client->exec(); - } catch (\Throwable $e) { - // PIPELINE-mode discard is version-dependent across phpredis; - // swallow the teardown error so we surface the original cause. - try { - $this->client->discard(); - } catch (\Throwable) { - // ignore + throw $error; } - throw $e; - } + }); - // Emit one 'deletePerm' per pair so rollback can replay each SADD - // and rehydrate the per-doc HASH entry independently. - foreach ($removals as [$role, $letter]) { - $this->journal('deletePerm', [ - 'collection' => $collection, - 'id' => $id, - 'role' => $role, - 'letter' => $letter, - 'previous' => $hash[$role] ?? '', - ]); - } + return true; } /** - * Restrict $ids to those visible to the current authorization context for - * the given $action. Returns $ids unchanged when authorization is off so - * privileged code paths bypass the filter. - * - * @param array $ids - * @return array + * @throws DatabaseException When the key is gone */ - private function applyPermissionFilter(string $collection, array $ids, string $action): array + private function renameKey(RedisClient $client, string $key, string $target): void { - if ($ids === []) { - return $ids; - } - if ($this->authorization->getStatus() === false) { - return $ids; - } - - $roles = $this->authorization->getRoles(); - if ($roles === []) { - return []; + if ($client->rename($key, $target) === false) { + throw new DatabaseException('Failed to move '.$key.' to '.$target); } + } - $letter = self::actionLetter($action); + /** + * @return list + */ + private function scanKeys(RedisClient $client, string $pattern): array + { $keys = []; - foreach ($roles as $role) { - $keys[] = $this->permKey($collection, $letter, $role); - } + $cursor = null; + do { + /** @var array|false $batch */ + $batch = $client->scan($cursor, $pattern, self::SCAN_BATCH_SIZE); + foreach (\is_array($batch) ? $batch : [] as $key) { + $keys[$key] = true; + } + } while ($cursor !== 0 && $cursor !== null); - if (\count($keys) === 1) { - /** @var array|false $allowed */ - $allowed = $this->client->sMembers($keys[0]); - } else { - $first = \array_shift($keys); - /** @var array|false $allowed */ - $allowed = $this->client->sUnion($first, ...$keys); - } - if ($allowed === false || $allowed === []) { - return []; + return \array_map(\strval(...), \array_keys($keys)); + } + + private function moveGrantMembers(RedisClient $client, string $grantsKey, string $from, string $to): void + { + /** @var array|false $members */ + $members = $client->sMembers($grantsKey); + if (! \is_array($members) || $members === []) { + return; } - $allowedSet = \array_flip($allowed); + $moved = \array_map( + static fn (string $member): string => \str_starts_with($member, $from) ? $to.\substr($member, \strlen($from)) : $member, + $members, + ); - return \array_values(\array_filter($ids, static fn (string $id): bool => isset($allowedSet[$id]))); + $client->del($grantsKey); + $client->sAdd($grantsKey, ...$moved); } - /** - * Translate a `Database::PERMISSION_*` action string to the single-letter - * suffix used in `{ns}:{db}:perm:{col}:{letter}:{role}` set keys. - */ - private static function actionLetter(string $action): string + #[\Override] + public function exists(string $database): bool { - return match ($action) { - Database::PERMISSION_READ => 'r', - Database::PERMISSION_CREATE => 'c', - Database::PERMISSION_UPDATE => 'u', - Database::PERMISSION_DELETE => 'd', - Database::PERMISSION_WRITE => 'w', - default => throw new DatabaseException('Unknown permission action: ' . $action), - }; + return (bool) $this->client->sIsMember($this->key($this->nsBase(), 'dbs'), $this->filter($database)); } - /** - * Resolve the tenant-bucket segment for shared-tables perm keys, mapping - * a null tenant to the literal `'_'` so all shared-tables perm keys share - * a single inversion convention. Returns null when shared tables are off. - */ - private function tenantBucket(): ?string + #[\Override] + public function collectionExists(string $database, string $collection): bool { - if (! $this->getSharedTables()) { - return null; + if (! $this->exists($database)) { + return false; } - $tenant = $this->getTenant(); - return $tenant === null ? '_' : (string) $tenant; + $collections = $this->key($this->nsFor($this->getNamespace(), $this->filter($database)), 'cols'); + + return (bool) $this->client->sIsMember($collections, $this->filter($collection)); } - /** - * Build the role/action set key, scoping by tenant under shared tables so - * cross-tenant role overlaps don't leak document ids. - */ - private function permKey(string $collection, string $letter, string $role): string + #[\Override] + public function list(): array { - $bucket = $this->tenantBucket(); - if ($bucket !== null) { - return $this->ns() . self::SEP . 'perm' . self::SEP . 't' . self::SEP . $bucket . self::SEP . $collection . self::SEP . $letter . self::SEP . $role; + $dbsKey = $this->key($this->nsBase(), 'dbs'); + /** @var array|false $names */ + $names = $this->client->sMembers($dbsKey); + if ($names === false) { + $names = []; + } + + $databases = []; + foreach ($names as $name) { + $databases[] = new Document(['name' => $name]); } - return $this->ns() . self::SEP . 'perm' . self::SEP . $collection . self::SEP . $letter . self::SEP . $role; + return $databases; } - /** - * Build the per-document role->letters HASH key for ($collection, $id), - * applying the same tenant scoping as `permKey()` so reads/writes stay - * symmetric under shared tables. - */ - private function permDocKey(string $collection, string $id): string + #[\Override] + public function delete(string $name): bool { - $bucket = $this->tenantBucket(); - if ($bucket !== null) { - return $this->ns() . self::SEP . 'perm' . self::SEP . 't' . self::SEP . $bucket . self::SEP . 'doc' . self::SEP . $collection . self::SEP . $id; - } - - return $this->ns() . self::SEP . 'perm' . self::SEP . 'doc' . self::SEP . $collection . self::SEP . $id; - } - - /** - * Append a mutation entry to the topmost journal frame. Outside a - * transaction the entry is dropped — non-transactional writes pay - * zero overhead. The `op` discriminator drives `rollbackJournal()`'s - * dispatch to raw inverse helpers. - * - * @param array $payload - */ - protected function journal(string $op, array $payload): void - { - if ($this->inTransaction === 0) { - return; - } - $this->journalStack[\count($this->journalStack) - 1][] = [ - 'op' => $op, - 'payload' => $payload, - ]; - } - - /** - * Pop the topmost journal frame and replay its inverse operations in - * reverse order. Uses raw `\Redis` client commands only — calling a - * public adapter method would re-enter `journal()` and recurse - * infinitely. New `op` discriminators must be added to the dispatch - * switch below. - */ - protected function rollbackJournal(): void - { - $frame = \array_pop($this->journalStack); - if ($frame === null) { - return; - } - - for ($i = \count($frame) - 1; $i >= 0; $i--) { - $entry = $frame[$i]; - $op = $entry['op']; - $payload = $entry['payload']; - - switch ($op) { - case 'createDoc': - /** @var string $collection */ - $collection = $payload['collection']; - /** @var string $id */ - $id = $payload['id']; - $this->rawDeleteDoc( - $collection, - $id, - isset($payload['docKey']) ? (string) $payload['docKey'] : null, - isset($payload['idxKey']) ? (string) $payload['idxKey'] : null, - isset($payload['permDocKey']) ? (string) $payload['permDocKey'] : null, - ); - break; - - case 'deleteDoc': - /** @var string $collection */ - $collection = $payload['collection']; - /** @var string $id */ - $id = $payload['id']; - /** @var string $beforePayload */ - $beforePayload = $payload['payload']; - $this->rawRestoreDoc( - $collection, - $id, - $beforePayload, - isset($payload['docKey']) ? (string) $payload['docKey'] : null, - isset($payload['idxKey']) ? (string) $payload['idxKey'] : null, - ); - break; - - case 'updateDoc': - /** @var string $collection */ - $collection = $payload['collection']; - /** @var string $id */ - $id = $payload['id']; - /** @var string $beforePayload */ - $beforePayload = $payload['payload']; - $docKey = isset($payload['docKey']) ? (string) $payload['docKey'] : $this->docKey($collection, $id); - $this->client->set($docKey, $beforePayload); - // If the update changed the id, the new key must be removed - // and the old id restored to the index set. - if (isset($payload['newId']) && \is_string($payload['newId']) && $payload['newId'] !== $id) { - $newId = $payload['newId']; - $newDocKey = isset($payload['newDocKey']) ? (string) $payload['newDocKey'] : $this->docKey($collection, $newId); - $this->client->del($newDocKey); - $idxKey = isset($payload['idxKey']) ? (string) $payload['idxKey'] : $this->idxKey($collection); - $this->client->sRem($idxKey, \strtolower($newId)); - $this->client->sAdd($idxKey, \strtolower($id)); - } - break; - - case 'createPerm': - // Inverse of writePermissions: drop the (role, letter) - // membership and the per-doc HASH entry for that role. - /** @var string $collection */ - $collection = $payload['collection']; - /** @var string $letter */ - $letter = $payload['letter']; - /** @var string $role */ - $role = $payload['role']; - /** @var string $id */ - $id = $payload['id']; - $this->client->sRem($this->permKey($collection, $letter, $role), $id); - $this->client->hDel($this->permDocKey($collection, $id), $role); - break; - - case 'deletePerm': - // Inverse of clearPermissions: restore the (role, letter) - // membership and rehydrate the per-doc HASH entry. - /** @var string $collection */ - $collection = $payload['collection']; - /** @var string $letter */ - $letter = $payload['letter']; - /** @var string $role */ - $role = $payload['role']; - /** @var string $id */ - $id = $payload['id']; - $this->client->sAdd($this->permKey($collection, $letter, $role), $id); - if (isset($payload['previous']) && \is_string($payload['previous']) && $payload['previous'] !== '') { - $this->client->hSet($this->permDocKey($collection, $id), $role, $payload['previous']); - } - break; + $name = $this->filter($name); + $namespace = $this->getNamespace(); + $dbsKey = $this->key($this->nsBase(), 'dbs'); + $colsKey = $this->key($this->nsFor($namespace, $name), 'cols'); - default: - throw new TransactionException('Unknown journal op: ' . $op); + $this->transaction(function (RedisClient $client) use ($name, $namespace, $dbsKey, $colsKey): void { + /** @var array|false $collections */ + $collections = $client->sMembers($colsKey); + if (\is_array($collections)) { + foreach ($collections as $collection) { + $this->purgeCollectionKeys($client, $namespace, $name, $collection); + } } - } - } - - /** - * Pop the topmost journal frame and, when nested, splice its entries - * onto the parent frame so an outer rollback still rewinds inner - * work. At the outermost level the frame is discarded — Wave-2 - * writes go directly to Redis (no two-phase commit), so the journal - * exists purely for rollback compensation. - */ - protected function commitJournal(): void - { - $frame = \array_pop($this->journalStack); - if ($frame === null) { - return; - } - if ($frame !== [] && $this->journalStack !== []) { - $outerIndex = \count($this->journalStack) - 1; - \array_push($this->journalStack[$outerIndex], ...$frame); - } - } - - private function rawDeleteDoc(string $collection, string $id, ?string $docKey = null, ?string $idxKey = null, ?string $permDocKey = null): void - { - // writePermissions/clearPermissions key the per-doc HASH off the - // lowercased id; lowercase here too so rollback of a mixed-case - // create id actually deletes the perm doc HASH that was written. - $lowerId = \strtolower($id); - $this->client->del($docKey ?? $this->docKey($collection, $lowerId)); - $this->client->sRem($idxKey ?? $this->idxKey($collection), $lowerId); - $this->client->del($permDocKey ?? $this->permDocKey($collection, $lowerId)); - } + $client->del($colsKey); + $client->sRem($dbsKey, $name); + }); - private function rawRestoreDoc(string $collection, string $id, string $payload, ?string $docKey = null, ?string $idxKey = null): void - { - $lowerId = \strtolower($id); - $this->client->set($docKey ?? $this->docKey($collection, $lowerId), $payload); - $this->client->sAdd($idxKey ?? $this->idxKey($collection), $lowerId); + return true; } /** - * @param array $queries - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor - * @return array + * @param list $attributes + * @param list $indexes */ - protected function evaluateQueries(string $collection, array $queries, ?int $limit, ?int $offset, array $orderAttributes, array $orderTypes, array $cursor, string $cursorDirection): array + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool { - $collectionId = $this->filter($collection); - $metaKey = $this->key($this->ns(), 'meta', $collectionId); + $id = $this->filter($collection); + $colsKey = $this->key($this->ns(), 'cols'); + $metaKey = $this->key($this->ns(), 'meta', $id); + $idxKey = $this->idxKey($id); - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + if ((bool) $this->client->exists($metaKey)) { + throw new DuplicateException('Collection already exists'); } - return $this->tx(function (RedisClient $client) use ($collectionId, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection): array { - $documents = $this->loadCollectionDocuments($client, $collectionId, Database::PERMISSION_READ); - $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); - $documents = $this->orderDocuments($documents, $orderAttributes, $orderTypes, $cursorDirection); - $documents = $this->cursorDocuments($documents, $orderAttributes, $orderTypes, $cursor, $cursorDirection); + $attributePayload = []; + foreach ($attributes as $attribute) { + $attributePayload[] = self::attributeRecord($attribute->key, $attribute); + } - if (! \is_null($offset)) { - $documents = \array_slice($documents, $offset); - } - if (! \is_null($limit)) { - $documents = \array_slice($documents, 0, $limit); - } + $indexPayload = []; + foreach ($indexes as $index) { + $indexPayload[] = [ + Document::ID => $index->key, + 'key' => $index->key, + 'type' => $index->type->value, + 'attributes' => $index->attributes, + 'lengths' => $index->lengths, + 'orders' => self::orderValues($index), + ]; + } - if ($cursorDirection === Database::CURSOR_BEFORE) { - $documents = \array_reverse($documents); - } + $schema = new Document([ + Document::ID => $id, + 'name' => $collection, + 'attributes' => $attributePayload, + 'indexes' => $indexPayload, + ]); - return $documents; + $this->transaction(function (RedisClient $client) use ($id, $colsKey, $metaKey, $idxKey, $schema, $attributePayload, $indexPayload): void { + $client->hMSet($metaKey, [ + 'schema' => \json_encode($schema->getArrayCopy(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + 'attrs' => \json_encode($attributePayload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + 'indexes' => \json_encode($indexPayload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + 'docCount' => '0', + 'sizeBytes' => '0', + ]); + $client->del($idxKey); + $client->sAdd($colsKey, $id); }); - } - - public function getDriver(): mixed - { - return 'redis'; - } - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void - { + return true; } - public function ping(): bool - { - return (bool) $this->client->ping(); + /** + * @return array + */ + private static function attributeRecord(string $id, Attribute $attribute): array + { + return [ + Document::ID => $id, + 'key' => $id, + 'type' => Attribute::storedType($attribute->type), + 'size' => $attribute->size ?? 0, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, + ]; } - public function reconnect(): void + /** + * @return list + */ + private static function orderValues(Index $index): array { + return \array_map(static fn (?OrderDirection $order): ?string => $order?->value, $index->orders); } - protected function quote(string $string): string + #[\Override] + public function deleteCollection(string $collection): bool { - return '"' . $string . '"'; - } + $id = $this->filter($collection); + $namespace = $this->getNamespace(); + $database = $this->getDatabase(); + $colsKey = $this->key($this->ns(), 'cols'); - public function getLimitForString(): int - { - return 4294967295; - } + $this->transaction(function (RedisClient $client) use ($id, $namespace, $database, $colsKey): void { + $this->purgeCollectionKeys($client, $namespace, $database, $id); + $client->sRem($colsKey, $id); + }); - public function getLimitForInt(): int - { - return 4294967295; + return true; } - public function getLimitForAttributes(): int + #[\Override] + public function analyzeCollection(string $collection): bool { - return 1017; + return false; } - public function getLimitForIndexes(): int + #[\Override] + public function getSizeOfCollection(string $collection): int { - return 64; + return $this->computeCollectionSize($collection); } - public function getMaxIndexLength(): int + #[\Override] + public function getSizeOfCollectionOnDisk(string $collection): int { - return 1024; + return $this->computeCollectionSize($collection); } - public function getMaxVarcharLength(): int + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool { - return 16381; - } + $collection = $this->filter($collection); + $id = $this->filter($attribute->key); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getMaxUIDLength(): int - { - return 255; - } + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } - public function getMinDateTime(): \DateTime - { - return new \DateTime('0001-01-01 00:00:00'); - } + $record = self::attributeRecord($id, $attribute); - public function getIdAttributeType(): string - { - // Sequence ids are sourced from `INCR`, which returns integers. - // The validator rejects string-valued sequences when this returns - // VAR_STRING, so mirror Memory's VAR_INTEGER stance. - return Database::VAR_INTEGER; - } + $this->transaction(function (RedisClient $client) use ($metaKey, $record): void { + $attributes = $this->readAttributesField($client, $metaKey); + $attributes = $this->upsertAttributeRecord($attributes, $record); + $client->hSet($metaKey, 'attrs', \json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); - public function getSupportForSchemas(): bool - { return true; } - public function getSupportForAttributes(): bool + /** + * @param list $attributes + */ + #[\Override] + public function createAttributes(string $collection, array $attributes): bool { - return true; - } + foreach ($attributes as $attribute) { + $this->createAttribute($collection, $attribute); + } - public function setSupportForAttributes(bool $support): bool - { return true; } - public function getSupportForSchemaAttributes(): bool + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool { - return false; - } + $collection = $this->filter($collection); + $id = $this->filter($key); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getSupportForSchemaIndexes(): bool - { - return false; - } + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } - public function getSupportForIndex(): bool - { - return true; - } + if ($attribute->key !== $key) { + $this->renameAttribute($collection, $id, $attribute->key); + $id = $this->filter($attribute->key); + } - public function getSupportForIndexArray(): bool - { - return false; - } + $record = self::attributeRecord($id, $attribute); - public function getSupportForCastIndexArray(): bool - { - return false; - } + $this->transaction(function (RedisClient $client) use ($metaKey, $record): void { + $attributes = $this->readAttributesField($client, $metaKey); + $attributes = $this->upsertAttributeRecord($attributes, $record); + $client->hSet($metaKey, 'attrs', \json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); - public function getSupportForUniqueIndex(): bool - { return true; } - public function getSupportForFulltextIndex(): bool + #[\Override] + public function deleteAttribute(string $collection, string $key): bool { - return true; - } + $collection = $this->filter($collection); + $id = $this->filter($key); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getSupportForFulltextWildcardIndex(): bool - { - return false; - } + if ((bool) $this->client->exists($metaKey) === false) { + return true; + } + + $this->transaction(function (RedisClient $client) use ($metaKey, $id): void { + $attributes = $this->readAttributesField($client, $metaKey); + $filtered = []; + foreach ($attributes as $attribute) { + $existingId = $this->recordIdentifier($attribute); + if ($this->filter($existingId) === $id) { + continue; + } + $filtered[] = $attribute; + } + $client->hSet($metaKey, 'attrs', \json_encode($filtered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); + + $this->dropDocumentField($collection, $id); - public function getSupportForCasting(): bool - { return true; } - public function getSupportForQueryContains(): bool + #[\Override] + public function getSchemaAttributes(string $collection): array { - return true; + return []; } - public function getSupportForTimeouts(): bool + #[\Override] + public function getSchemaIndexes(string $collection): array { - return false; + return []; } - public function getSupportForRelationships(): bool + #[\Override] + public function getColumnType(Attribute $attribute): ?string { - return true; + return null; } - public function getSupportForUpdateLock(): bool + #[\Override] + public function renameAttribute(string $collection, string $old, string $new): bool { - return false; - } + $collection = $this->filter($collection); + $old = $this->filter($old); + $new = $this->filter($new); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getSupportForBatchOperations(): bool - { - return true; - } + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } + + $this->transaction(function (RedisClient $client) use ($metaKey, $old, $new): void { + $attributes = $this->readAttributesField($client, $metaKey); + $touched = false; + foreach ($attributes as $i => $attribute) { + $existingId = $this->recordIdentifier($attribute); + if ($this->filter($existingId) !== $old) { + continue; + } + $attribute[Document::ID] = $new; + $attribute['key'] = $new; + $attributes[$i] = $attribute; + $touched = true; + } + if (! $touched) { + return; + } + $client->hSet($metaKey, 'attrs', \json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); + + $this->renameDocumentField($collection, $old, $new); - public function getSupportForAttributeResizing(): bool - { return true; } - public function getSupportForGetConnectionId(): bool + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool { - return false; - } + $relatedCollection = $relationship->relatedCollection; + $key = $relationship->key ?? ''; + $twoWayKey = $relationship->twoWayKey ?? ''; - public function getSupportForUpserts(): bool - { - return false; - } + switch ($relationship->type) { + case RelationshipType::OneToOne: + $this->registerRelationshipField($collection, $key); + if ($relationship->twoWay) { + $this->registerRelationshipField($relatedCollection, $twoWayKey); + } + break; + case RelationshipType::OneToMany: + $this->registerRelationshipField($relatedCollection, $twoWayKey); + break; + case RelationshipType::ManyToOne: + $this->registerRelationshipField($collection, $key); + break; + case RelationshipType::ManyToMany: + break; + } - public function getSupportForUpsertOnUniqueIndex(): bool - { - return false; + return true; } - public function getSupportForVectors(): bool + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool { - return false; - } + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $newKey = $update->key === null ? null : $this->filter($update->key); + $newTwoWayKey = $update->twoWayKey === null ? null : $this->filter($update->twoWayKey); + $twoWay = $update->twoWay ?? $relationship->twoWay; - public function getSupportForCacheSkipOnFailure(): bool - { - return false; - } + switch ($relationship->type) { + case RelationshipType::OneToOne: + if (($twoWay || $side === RelationshipSide::Parent) && $newKey !== null && $newKey !== $key) { + $this->renameAttribute($collection, $key, $newKey); + } + if (($twoWay || $side === RelationshipSide::Child) && $newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); + } + break; + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { + if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); + } + } elseif ($newKey !== null && $newKey !== $key) { + $this->renameAttribute($collection, $key, $newKey); + } + break; + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Child) { + if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); + } + } elseif ($newKey !== null && $newKey !== $key) { + $this->renameAttribute($collection, $key, $newKey); + } + break; + case RelationshipType::ManyToMany: + $junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side); + if ($junction !== null) { + if ($newKey !== null && $newKey !== $key) { + $this->renameAttribute($junction, $key, $newKey); + } + if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + $this->renameAttribute($junction, $twoWayKey, $newTwoWayKey); + } + } + break; + } - public function getSupportForCaching(): bool - { - // The Redis adapter is itself the store; reads hit Redis directly and - // it runs with a no-op cache, so the Database cache layer is bypassed. - return false; + return true; } - public function getSupportForReconnection(): bool + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool { - return false; - } + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $twoWay = $relationship->twoWay; - public function getSupportForHostname(): bool - { - return false; - } + switch ($relationship->type) { + case RelationshipType::OneToOne: + if ($side === RelationshipSide::Parent) { + $this->deleteAttribute($collection, $key); + if ($twoWay) { + $this->deleteAttribute($relatedCollection, $twoWayKey); + } + } else { + $this->deleteAttribute($relatedCollection, $twoWayKey); + if ($twoWay) { + $this->deleteAttribute($collection, $key); + } + } + break; + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { + $this->deleteAttribute($relatedCollection, $twoWayKey); + } else { + $this->deleteAttribute($collection, $key); + } + break; + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Parent) { + $this->deleteAttribute($collection, $key); + } else { + $this->deleteAttribute($relatedCollection, $twoWayKey); + } + break; + case RelationshipType::ManyToMany: + $junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side); + if ($junction !== null) { + $this->deleteCollection($junction); + } + break; + } - public function getSupportForBatchCreateAttributes(): bool - { return true; } - public function getSupportForSpatialAttributes(): bool + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool { - return false; - } + $collection = $this->filter($collection); + $id = $this->filter($index->key); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getSupportForObject(): bool - { - return true; - } + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } - public function getSupportForObjectIndexes(): bool - { - return false; - } + $type = $index->type->value; + $attributes = $index->attributes; + $lengths = $index->lengths; + $orders = self::orderValues($index); - public function getSupportForSpatialIndexNull(): bool - { - return false; - } + $this->transaction(function (RedisClient $client) use ($metaKey, $collection, $id, $type, $attributes, $lengths, $orders): void { + $indexes = $this->readIndexesField($client, $metaKey); - public function getSupportForOperators(): bool - { - return true; - } + foreach ($indexes as $existing) { + if (($existing[Document::ID] ?? $existing['key'] ?? null) === $id) { + throw new DuplicateException('Index already exists'); + } + } - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return false; - } + if ($type === IndexType::Unique->value && ! empty($attributes)) { + $idxKey = $this->idxKey($collection); + /** @var array|false $docIds */ + $docIds = $client->sMembers($idxKey); + if (\is_array($docIds) && $docIds !== []) { + $sharedTables = $this->hasSharedTables(); + $currentTenant = $sharedTables ? $this->getTenant() : null; + $docKeys = []; + foreach ($docIds as $docId) { + $docKeys[] = $this->docKey($collection, (string) $docId); + } + /** @var array $payloads */ + $payloads = $client->mGet($docKeys); + $seen = []; + foreach ($payloads as $payload) { + if (! \is_string($payload)) { + continue; + } + $document = $this->decode($payload); + if ($sharedTables) { + $rowTenant = $document->getAttribute(Document::TENANT); + if ($rowTenant !== $currentTenant) { + continue; + } + } + $signature = []; + $hasNull = false; + foreach ($attributes as $attribute) { + $value = $this->resolveDocumentAttribute($document, (string) $attribute); + if ($value === null) { + $hasNull = true; + break; + } + $signature[] = $this->normalizeIndexValue($value); + } + if ($hasNull) { + continue; + } + if ($sharedTables) { + \array_unshift($signature, $currentTenant); + } + $hash = \serialize($signature); + if (isset($seen[$hash])) { + throw new UniqueException(UniqueException::MESSAGE); + } + $seen[$hash] = true; + } + } + } - public function getSupportForSpatialIndexOrder(): bool - { - return false; - } + $indexes[] = [ + Document::ID => $id, + 'key' => $id, + 'type' => $type, + 'attributes' => $attributes, + 'lengths' => $lengths, + 'orders' => $orders, + ]; - public function getSupportForSpatialAxisOrder(): bool - { - return false; - } + $client->hSet($metaKey, 'indexes', \json_encode($indexes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); - public function getSupportForBoundaryInclusiveContains(): bool - { - return false; + return true; } - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + #[\Override] + public function deleteIndex(string $collection, string $key): bool { - return false; - } + $collection = $this->filter($collection); + $id = $this->filter($key); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getSupportForMultipleFulltextIndexes(): bool - { - return false; - } + if ((bool) $this->client->exists($metaKey) === false) { + return true; + } - public function getSupportForIdenticalIndexes(): bool - { - return false; - } + $this->transaction(function (RedisClient $client) use ($metaKey, $id): void { + $indexes = $this->readIndexesField($client, $metaKey); + $filtered = []; + foreach ($indexes as $index) { + if (($index[Document::ID] ?? $index['key'] ?? null) === $id) { + continue; + } + $filtered[] = $index; + } + $client->hSet($metaKey, 'indexes', \json_encode($filtered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); - public function getSupportForOrderRandom(): bool - { return true; } - /** - * Get max BIGINT limit - * - * @return int - */ - public function getLimitForBigInt(): int + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool { - return Database::MAX_BIG_INT; - } + $collection = $this->filter($collection); + $old = $this->filter($old); + $new = $this->filter($new); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getSupportForInternalCasting(): bool - { - return false; - } + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } - public function getSupportForUTCCasting(): bool - { - return false; - } + return $this->transaction(function (RedisClient $client) use ($metaKey, $old, $new): bool { + $indexes = $this->readIndexesField($client, $metaKey); + $ids = \array_map(static fn (array $index): mixed => $index[Document::ID] ?? $index['key'] ?? null, $indexes); + $position = \array_search($old, $ids, true); + if ($position === false) { + return \in_array($new, $ids, true); + } + $indexes[$position][Document::ID] = $new; + $indexes[$position]['key'] = $new; + $client->hSet($metaKey, 'indexes', \json_encode(\array_values($indexes), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); - public function getSupportForIntegerBooleans(): bool - { - return false; + return true; + }) === true; } - public function getSupportForAlterLocks(): bool + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { - return false; - } + $col = $this->filter($collection->getId()); + $payload = $this->client->get($this->docKey($col, $id)); - public function getSupportNonUtfCharacters(): bool - { - return false; - } + if ((! \is_string($payload) || $payload === '') && $this->hasSharedTables() && $col === Database::METADATA) { + $payload = $this->client->get($this->docKey($col, $id, '_')); + } - public function getSupportForTrigramIndex(): bool - { - return false; - } + if (! \is_string($payload) || $payload === '') { + return new Document([]); + } - public function getSupportForPCRERegex(): bool - { - return true; - } - - public function getSupportForPOSIXRegex(): bool - { - return false; - } - - public function getSupportForTransactionRetries(): bool - { - // The current `tx()` body is a network-error retry loop, not a - // WATCH/MULTI/EXEC OCC implementation. Reporting `false` keeps the - // shared trait's OCC-retry tests from running against semantics this - // adapter doesn't yet provide. Mirror Memory's stance until a real - // optimistic concurrency layer lands. - return false; - } - - public function getSupportForNestedTransactions(): bool - { - return true; - } + $document = $this->decode($payload); - public function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } + if ($this->hasSharedTables()) { + $rowTenant = $document->getAttribute(Document::TENANT); + $tenant = $this->getTenant(); + $allowNullTenant = $col === Database::METADATA && $rowTenant === null; + if (! $allowNullTenant && $rowTenant !== $tenant) { + return new Document([]); + } + } - public function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); - } + if ($col !== Database::METADATA) { + $document = $this->surfaceRelationshipAttributes($col, $document); + } - public function getDocumentSizeLimit(): int - { - return 0; - } + $selections = $this->extractSelections($queries); + if (! empty($selections) && ! \in_array('*', $selections, true)) { + $document = $this->projectDocument($document, $selections); + } - public function getAttributeWidth(Document $collection): int - { - return 0; + return $document; } - public function getKeywords(): array + #[\Override] + public function createDocument(Document $collection, Document $document): Document { - return []; + return $this->insertDocument($collection, $document) ?? $document; } /** - * @param array $selections + * @return Document|null The stored document, or null when ignoreDuplicates() skipped it */ - protected function getAttributeProjection(array $selections, string $prefix): mixed - { - return $selections; - } - - public function getConnectionId(): string - { - return '0'; - } - - public function getInternalIndexesKeys(): array + private function insertDocument(Document $collection, Document $document): ?Document { - return []; - } - - public function getTenantQuery(string $collection, string $alias = ''): string - { - return ''; - } + $col = $this->filter($collection->getId()); + $id = $document->getId(); + if ($id === '') { + $id = Id::unique(); + $document->setAttribute(Document::ID, $id); + } + $tenant = $document->getTenant(); + $docKey = $this->docKey($col, $id, $tenant); + $idxKey = $this->idxKey($col, $tenant); + $seqKey = $this->seqKey($col, $tenant); + $permDocKey = $this->permDocKey($col, $id, $tenant); - protected function execute(mixed $stmt): bool - { - return true; - } + return $this->transaction(function (RedisClient $redis) use ($col, $id, $document, $docKey, $idxKey, $seqKey, $permDocKey): ?Document { + if ((bool) $redis->exists($docKey)) { + if ($this->isIgnoringDuplicates()) { + $existingPayload = $redis->get($docKey); + if (\is_string($existingPayload) && $existingPayload !== '') { + $existing = $this->decode($existingPayload); + $document->setAttribute(Document::SEQUENCE, $existing->getSequence() ?? ''); + } - public function decodePoint(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Redis adapter'); - } + return null; + } + throw new DuplicateException('Document already exists'); + } - public function decodeLinestring(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Redis adapter'); - } + try { + $this->enforceUniqueIndexes($redis, $col, $document); + } catch (DuplicateException $e) { + if ($this->isIgnoringDuplicates()) { + return null; + } + throw $e; + } - public function decodePolygon(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Redis adapter'); - } + $sequence = $document->getSequence(); + if (empty($sequence)) { + $next = $redis->incr($seqKey); + $sequence = (string) $next; + } else { + $sequence = (string) $sequence; + $current = $redis->get($seqKey); + if (! \is_string($current) || (int) $sequence > (int) $current) { + $redis->set($seqKey, $sequence); + } + } + $document->setAttribute(Document::SEQUENCE, $sequence); - public function castingBefore(Document $collection, Document $document): Document - { - return $document; - } + $redis->set($docKey, $this->encode($document)); + $redis->sAdd($idxKey, \strtolower($id)); - public function castingAfter(Document $collection, Document $document): Document - { - return $document; - } + $this->writePermissions($col, $id, $document); + $this->journal('createDoc', [ + 'collection' => $col, + 'id' => $id, + 'docKey' => $docKey, + 'idxKey' => $idxKey, + 'permDocKey' => $permDocKey, + ]); - public function setUTCDatetime(string $value): mixed - { - return $value; + return $document; + }); } - /** - * Surface relationship attributes registered on the collection's meta.attrs - * as null when the document does not carry them — mirrors MariaDB selecting - * a `DEFAULT NULL` column even when no row has set it (and Memory's - * `documentToRow` null-surface pass). - * - * METADATA is exempt: relationship attributes for user collections are - * nested inside the metadata row's `attributes` payload, not stored as - * top-level keys. Surfacing nulls there would clobber that nested array. - */ - private function surfaceRelationshipAttributes(string $collection, Document $document): Document + #[\Override] + public function createDocuments(Document $collection, array $documents): array { - if ($collection === Database::METADATA) { - return $document; - } - - $metaKey = $this->key($this->ns(), 'meta', $this->filter($collection)); - $attributes = $this->readAttributesField($this->client, $metaKey); - $relationshipKeys = $this->extractRelationshipKeys($attributes); - if ($relationshipKeys === []) { - return $document; + $created = []; + foreach ($documents as $document) { + $inserted = $this->insertDocument($collection, $document); + if ($inserted !== null) { + $created[] = $inserted; + } } - return $this->surfaceRelationshipAttributesUsing($relationshipKeys, $document); + return $created; } - /** - * Loop-friendly companion to `surfaceRelationshipAttributes`. Callers that - * iterate large result sets (e.g. `find()` / `loadCollectionDocuments`) - * read meta.attrs once, derive the relationship key list via - * `extractRelationshipKeys`, and pass it here per document — avoiding N - * round trips to Redis for the same meta hash. - * - * @param array $relationshipKeys - */ - private function surfaceRelationshipAttributesUsing(array $relationshipKeys, Document $document): Document + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { - if ($relationshipKeys === []) { - return $document; - } + $col = $this->filter($collection->getId()); + $oldKey = $this->docKey($col, $id); + $idxKey = $this->idxKey($col); - $payload = $document->getArrayCopy(); - foreach ($relationshipKeys as $key) { - if (! \array_key_exists($key, $payload)) { - $document->setAttribute($key, null); + $useNullTenant = false; + if ($col === Database::METADATA && $this->hasSharedTables() && $this->getTenant() !== null) { + if ((bool) $this->client->exists($oldKey) === false) { + $oldKey = $this->docKey($col, $id, '_'); + $useNullTenant = true; } } - return $document; - } - - /** - * Extract the list of relationship attribute keys from a decoded - * meta.attrs records array. Returned as a positional list so callers can - * iterate without extra `array_keys` calls. - * - * @param array> $attributes - * @return array - */ - private function extractRelationshipKeys(array $attributes): array - { - $keys = []; - foreach ($attributes as $attribute) { - if (($attribute['type'] ?? null) !== Database::VAR_RELATIONSHIP) { - continue; + return $this->transaction(function (RedisClient $redis) use ($col, $id, $document, $skipPermissions, $oldKey, $idxKey, $useNullTenant): Document { + $existingPayload = $redis->get($oldKey); + if (! \is_string($existingPayload) || $existingPayload === '') { + throw new NotFoundException('Document not found'); } - $key = (string) ($attribute['$id'] ?? $attribute['key'] ?? ''); - if ($key === '') { - continue; + + $existing = $this->decode($existingPayload); + if ($col !== Database::METADATA) { + $existing = $this->surfaceRelationshipAttributes($col, $existing); } - $keys[] = $key; - } + $newId = $document->getId() !== '' ? $document->getId() : $id; + $newKey = $useNullTenant ? $this->docKey($col, $newId, '_') : $this->docKey($col, $newId); + $effectiveIdxKey = $useNullTenant ? $this->idxKey($col, '_') : $idxKey; - return $keys; - } + if ($newKey !== $oldKey && (bool) $redis->exists($newKey)) { + throw new DuplicateException('Document already exists'); + } - /** - * Rename a top-level field across every document in a collection. Mirrors - * Memory's `renameDocumentField`. Used by `updateRelationship` to migrate - * stored payloads when a relationship key is renamed. - * - * Schema-level (non-journalled): same convention as `createAttribute` / - * `renameAttribute` — schema mutations are not transactional and therefore - * do not register inverse entries with `journal()`. The transaction - * wrapper is used solely to surface `\RedisException` as - * `TransactionException`. - */ - private function renameDocumentField(string $collection, string $oldKey, string $newKey): void - { - $collection = $this->filter($collection); - $oldKey = $this->filter($oldKey); - $newKey = $this->filter($newKey); + $resolved = $this->applyOperators($document->getArrayCopy(), $existing->getArrayCopy()); + $merged = \array_merge($existing->getArrayCopy(), $resolved); + $merged[Document::ID] = $newId; + $mergedDocument = new Document($merged); - if ($oldKey === $newKey) { - return; - } + $this->enforceUniqueIndexes($redis, $col, $mergedDocument, $id); - $idxKey = $this->idxKey($collection); + $payload = $this->encode($mergedDocument); - $this->tx(function (RedisClient $client) use ($collection, $oldKey, $newKey, $idxKey): void { - /** @var array|false $docIds */ - $docIds = $client->sMembers($idxKey); - if (! \is_array($docIds) || $docIds === []) { - return; + if ($newId !== $id) { + $redis->del($oldKey); + $redis->sRem($effectiveIdxKey, \strtolower($id)); } + $redis->set($newKey, $payload); + $redis->sAdd($effectiveIdxKey, \strtolower($newId)); - foreach ($docIds as $docId) { - $docKey = $this->docKey($collection, $docId); - $payload = $client->get($docKey); - if (! \is_string($payload) || $payload === '') { - continue; - } + $this->journal('updateDoc', [ + 'collection' => $col, + 'id' => $id, + 'newId' => $newId, + 'payload' => $existingPayload, + 'docKey' => $oldKey, + 'newDocKey' => $newKey, + 'idxKey' => $effectiveIdxKey, + ]); - /** @var array $decoded */ - $decoded = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - if (! \array_key_exists($oldKey, $decoded)) { - continue; + if (! $skipPermissions) { + $this->clearPermissions($col, $id); + if ($newId !== $id) { + $this->clearPermissions($col, $newId); } - - $decoded[$newKey] = $decoded[$oldKey]; - unset($decoded[$oldKey]); - - $client->set( - $docKey, - \json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), - ); + $this->writePermissions($col, $newId, $mergedDocument); } + + return $mergedDocument; }); } - /** - * Remove a top-level field from every document in a collection. Mirrors - * Memory's `dropDocumentField`. Used by `deleteRelationship` to scrub - * stored payloads when a relationship column is dropped. - * - * Same non-journalled schema-op contract as `renameDocumentField`. - */ - private function dropDocumentField(string $collection, string $field): void + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int { - $collection = $this->filter($collection); - $field = $this->filter($field); - $idxKey = $this->idxKey($collection); - - $this->tx(function (RedisClient $client) use ($collection, $field, $idxKey): void { - /** @var array|false $docIds */ - $docIds = $client->sMembers($idxKey); - if (! \is_array($docIds) || $docIds === []) { - return; + if (empty($documents)) { + return 0; + } + + $changed = $updates->getAttributes(); + $hasCreatedAt = ! empty($updates->getCreatedAt()); + $hasUpdatedAt = ! empty($updates->getUpdatedAt()); + $hasPermissions = $updates->offsetExists(Document::PERMISSIONS); + if (empty($changed) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) { + return 0; + } + + $col = $this->filter($collection->getId()); + $documents = \array_values($documents); + + return $this->transaction(function (RedisClient $redis) use ($col, $documents, $updates, $changed, $hasCreatedAt, $hasUpdatedAt, $hasPermissions): int { + $docKeys = []; + foreach ($documents as $doc) { + $docKeys[] = $this->docKey($col, $doc->getId()); } - foreach ($docIds as $docId) { - $docKey = $this->docKey($collection, $docId); - $payload = $client->get($docKey); - if (! \is_string($payload) || $payload === '') { + $redis->multi(\Redis::PIPELINE); + foreach ($docKeys as $docKey) { + $redis->get($docKey); + } + $existingPayloads = $redis->exec(); + if (! \is_array($existingPayloads)) { + $existingPayloads = []; + } + + $relationshipKeys = []; + if ($col !== Database::METADATA) { + $metaKey = $this->key($this->ns(), 'meta', $col); + $attributes = $this->readAttributesField($redis, $metaKey); + $relationshipKeys = $this->extractRelationshipKeys($attributes); + } + + $writes = []; + foreach ($documents as $i => $doc) { + $existingPayload = $existingPayloads[$i] ?? false; + if (! \is_string($existingPayload) || $existingPayload === '') { continue; } - /** @var array $decoded */ - $decoded = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - if (! \array_key_exists($field, $decoded)) { - continue; + $existing = $this->decode($existingPayload); + if (! empty($relationshipKeys)) { + $existing = $this->surfaceRelationshipAttributesUsing($relationshipKeys, $existing); + } + $merged = $existing->getArrayCopy(); + $resolved = $this->applyOperators($changed, $merged); + foreach ($resolved as $attribute => $value) { + $merged[$attribute] = $value; + } + if ($hasCreatedAt) { + $merged[Document::CREATED_AT] = $updates->getCreatedAt(); + } + if ($hasUpdatedAt) { + $merged[Document::UPDATED_AT] = $updates->getUpdatedAt(); + } + if ($hasPermissions) { + $merged[Document::PERMISSIONS] = $updates->getPermissions(); } - unset($decoded[$field]); + $writes[] = new Write($doc->getId(), $docKeys[$i], $existingPayload, new Document($merged)); + } - $client->set( - $docKey, - \json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + if ($changed !== []) { + $this->enforceUniqueIndexesForDocuments( + $redis, + $col, + \array_map(static fn (Write $write): Document => $write->document, $writes), + \array_map(static fn (Write $write): string => $write->id, $writes), ); } - }); - } - /** - * Resolve the junction collection name for an M2M relationship. Mirrors - * `Database::getJunctionCollection` — the junction is named after the - * parent/child sequence pair (`_{parent}_{child}` for the parent side, - * reversed for the child side). - * - * Reads the METADATA collection's docs for both sides and extracts each - * `$sequence`. Returns null when either METADATA row is missing or has - * no sequence — callers treat that as a no-op (skip the rename). - */ - private function resolveJunctionCollection(string $collection, string $relatedCollection, string $side): ?string - { - $collectionDoc = $this->loadMetadataDocument($collection); - $relatedDoc = $this->loadMetadataDocument($relatedCollection); - if ($collectionDoc === null || $relatedDoc === null) { - return null; - } - - $collectionSequence = $collectionDoc->getSequence(); - $relatedSequence = $relatedDoc->getSequence(); - if ($collectionSequence === null || $relatedSequence === null || $collectionSequence === '' || $relatedSequence === '') { - return null; - } + foreach ($writes as $write) { + $redis->set($write->key, $this->encode($write->document)); - return $side === Database::RELATION_SIDE_PARENT - ? '_' . $collectionSequence . '_' . $relatedSequence - : '_' . $relatedSequence . '_' . $collectionSequence; - } + $this->journal('updateDoc', [ + 'collection' => $col, + 'id' => $write->id, + 'newId' => $write->id, + 'payload' => $write->payload, + 'docKey' => $write->key, + ]); - /** - * Read a single METADATA document directly from the doc key, bypassing - * the public `getDocument` path so this helper can be called from inside - * schema operations (which build a Document collection lazily). - */ - private function loadMetadataDocument(string $collection): ?Document - { - $id = $this->filter($collection); - $payload = $this->client->get($this->docKey(Database::METADATA, $id)); - // Fall back to the null-tenant METADATA row under shared tables — - // bootstrap writes the global metadata schema with $tenant=null. - if ((! \is_string($payload) || $payload === '') && $this->getSharedTables()) { - $payload = $this->client->get($this->docKey(Database::METADATA, $id, '_')); - } - if (! \is_string($payload) || $payload === '') { - return null; - } + if ($hasPermissions) { + $this->clearPermissions($col, $write->id); + $this->writePermissions($col, $write->id, $write->document); + } + } - return $this->decode($payload); + return \count($writes); + }); } - // === @architect:T20 owns: schema + collection + attribute ops === - - public function create(string $name): bool + #[\Override] + public function upsertDocument(Document $collection, Change $change): Document { - $name = $this->filter($name); - $dbsKey = $this->key($this->nsBase(), 'dbs'); - - $this->tx(fn (RedisClient $client) => $client->sAdd($dbsKey, $name)); - - return true; + return $this->upsertDocuments($collection, [$change])[0]; } - public function exists(string $database, ?string $collection = null): bool + #[\Override] + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array { - $database = $this->filter($database); - $dbsKey = $this->key($this->nsBase(), 'dbs'); - - if ((bool) $this->client->sIsMember($dbsKey, $database) === false) { - return false; + if ($changes === []) { + return []; } - if ($collection === null) { - return true; - } + $col = $this->filter($collection->getId()); + $attribute = $increase ?? ''; - $collection = $this->filter($collection); - $namespace = $this->getNamespace(); - $colsKey = $this->key($this->nsFor($namespace, $database), 'cols'); + return $this->transaction(function (RedisClient $redis) use ($col, $attribute, $changes): array { + $results = []; - return (bool) $this->client->sIsMember($colsKey, $collection); - } + $redis->multi(\Redis::PIPELINE); + foreach ($changes as $change) { + $document = $change->new; + $redis->get($this->docKey($col, $document->getId(), $document->getTenant())); + } + $existingPayloads = $redis->exec(); + if (! \is_array($existingPayloads)) { + $existingPayloads = []; + } - public function list(): array - { - $dbsKey = $this->key($this->nsBase(), 'dbs'); - /** @var array|false $names */ - $names = $this->client->sMembers($dbsKey); - if ($names === false) { - $names = []; - } + $relationshipKeys = []; + if ($col !== Database::METADATA) { + $metaKey = $this->key($this->ns(), 'meta', $col); + $attributes = $this->readAttributesField($redis, $metaKey); + $relationshipKeys = $this->extractRelationshipKeys($attributes); + } - $databases = []; - foreach ($names as $name) { - $databases[] = new Document(['name' => $name]); - } + $writes = []; + foreach ($changes as $i => $change) { + $document = $change->new; + $id = $document->getId(); + $existingPayload = $existingPayloads[$i] ?? false; - return $databases; - } + if (! \is_string($existingPayload) || $existingPayload === '') { + $writes[] = new Write($id, $this->docKey($col, $id, $document->getTenant()), null, $document, $document->getTenant()); - public function delete(string $name): bool - { - $name = $this->filter($name); - $namespace = $this->getNamespace(); - $dbsKey = $this->key($this->nsBase(), 'dbs'); - $colsKey = $this->key($this->nsFor($namespace, $name), 'cols'); + continue; + } - $this->tx(function (RedisClient $client) use ($name, $namespace, $dbsKey, $colsKey): void { - /** @var array|false $collections */ - $collections = $client->sMembers($colsKey); - if (\is_array($collections)) { - foreach ($collections as $collection) { - $this->purgeCollectionKeys($client, $namespace, $name, $collection); + $existing = $this->decode($existingPayload); + if (! empty($relationshipKeys)) { + $existing = $this->surfaceRelationshipAttributesUsing($relationshipKeys, $existing); } - } + $existingArray = $existing->getArrayCopy(); + $resolved = $this->applyOperators($document->getArrayCopy(), $existingArray); + $merged = \array_merge($existingArray, $resolved); + $merged[Document::ID] = $id; - $client->del($colsKey); - $client->sRem($dbsKey, $name); - }); + if ($attribute !== '') { + $previous = $existing->getAttribute($attribute); + $delta = $document->getAttribute($attribute); + $previousNumeric = \is_numeric($previous) ? $previous + 0 : 0; + $deltaNumeric = \is_numeric($delta) ? $delta + 0 : 0; + $merged[$attribute] = $previousNumeric + $deltaNumeric; + } - return true; - } + $writes[] = new Write($id, $this->docKey($col, $id, $document->getTenant()), $existingPayload, new Document($merged), $document->getTenant()); + } - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool - { - $id = $this->filter($name); - $colsKey = $this->key($this->ns(), 'cols'); - $metaKey = $this->key($this->ns(), 'meta', $id); - $idxKey = $this->idxKey($id); + $this->enforceUniqueIndexesInOrder($redis, $col, $writes); - if ((bool) $this->client->exists($metaKey)) { - throw new DuplicateException('Collection already exists'); - } + foreach ($writes as $write) { + $id = $write->id; + $document = $write->document; + $tenant = $write->tenant; - $attributePayload = []; - foreach ($attributes as $attribute) { - $attributePayload[] = $attribute->getArrayCopy(); - } - $indexPayload = []; - foreach ($indexes as $index) { - $indexPayload[] = $index->getArrayCopy(); - } + if ($write->payload !== null) { + $redis->set($write->key, $this->encode($document)); - $schema = new Document([ - '$id' => $id, - 'name' => $name, - 'attributes' => $attributePayload, - 'indexes' => $indexPayload, - ]); + $this->journal('updateDoc', [ + 'collection' => $col, + 'id' => $id, + 'newId' => $id, + 'payload' => $write->payload, + 'docKey' => $write->key, + ]); - $this->tx(function (RedisClient $client) use ($id, $colsKey, $metaKey, $idxKey, $schema, $attributePayload, $indexPayload): void { - $client->hMSet($metaKey, [ - 'schema' => \json_encode($schema->getArrayCopy(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - 'attrs' => \json_encode($attributePayload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - 'indexes' => \json_encode($indexPayload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - 'docCount' => '0', - 'sizeBytes' => '0', - ]); - // Reserve the doc-id index set so SCAN/list operations work even - // before the first document write. Redis cannot persist empty - // sets, so we materialise the key on first write — but we still - // delete it on collection drop to clean up any prior contents. - $client->del($idxKey); - $client->sAdd($colsKey, $id); - }); + $this->clearPermissions($col, $id, $tenant); + $this->writePermissions($col, $id, $document); - return true; - } + $results[] = $document; - public function deleteCollection(string $id): bool - { - $id = $this->filter($id); - $namespace = $this->getNamespace(); - $database = $this->getDatabase(); - $colsKey = $this->key($this->ns(), 'cols'); + continue; + } - $this->tx(function (RedisClient $client) use ($id, $namespace, $database, $colsKey): void { - $this->purgeCollectionKeys($client, $namespace, $database, $id); - $client->sRem($colsKey, $id); - }); + $idxKey = $this->idxKey($col, $tenant); + $seqKey = $this->seqKey($col, $tenant); + $sequence = $document->getSequence(); + if (empty($sequence)) { + $next = $redis->incr($seqKey); + $sequence = (string) $next; + } else { + $sequence = (string) $sequence; + $current = $redis->get($seqKey); + if (! \is_string($current) || (int) $sequence > (int) $current) { + $redis->set($seqKey, $sequence); + } + } + $document->setAttribute(Document::SEQUENCE, $sequence); - return true; - } + $resolved = $this->applyOperators($document->getArrayCopy(), []); + foreach ($resolved as $attr => $value) { + $document->setAttribute($attr, $value); + } - public function analyzeCollection(string $collection): bool - { - // Redis maintains no internal table statistics; mirrors Memory's - // behavior for adapters without a stats subsystem. - return false; - } + $redis->set($write->key, $this->encode($document)); + $redis->sAdd($idxKey, \strtolower($id)); - public function getSizeOfCollection(string $collection): int - { - return $this->computeCollectionSize($collection); - } + $this->writePermissions($col, $id, $document); + $this->journal('createDoc', [ + 'collection' => $col, + 'id' => $id, + 'docKey' => $write->key, + 'idxKey' => $idxKey, + 'permDocKey' => $this->permDocKey($col, $id, $tenant), + ]); - public function getSizeOfCollectionOnDisk(string $collection): int - { - // Redis stores the working set in memory; on-disk size mirrors - // logical size for the purposes of the size-tracking tests. - return $this->computeCollectionSize($collection); + $results[] = $document; + } + + return $results; + }); } - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + #[\Override] + public function getSequences(Document $collection, array $documents): array { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); - - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + if (empty($documents)) { + return $documents; } - $this->tx(function (RedisClient $client) use ($metaKey, $id, $type, $size, $signed, $array, $required): void { - $attrs = $this->readAttributesField($client, $metaKey); - $attrs = $this->upsertAttributeRecord($attrs, [ - '$id' => $id, - 'key' => $id, - 'type' => $type, - 'size' => $size, - 'signed' => $signed, - 'array' => $array, - 'required' => $required, - ]); - $client->hSet( - $metaKey, - 'attrs', - \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - }); + $col = $this->filter($collection->getId()); - return true; - } + $this->client->multi(\Redis::PIPELINE); + try { + $indexes = []; + foreach ($documents as $index => $doc) { + if (! empty($doc->getSequence())) { + continue; + } + $this->client->get($this->docKey($col, $doc->getId(), $doc->getTenant())); + $indexes[] = $index; + } + if ($indexes === []) { + try { + $this->client->discard(); + } catch (\Throwable) { + // PIPELINE-mode discard is version-dependent across phpredis. + } - public function createAttributes(string $collection, array $attributes): bool - { - foreach ($attributes as $attribute) { - $this->createAttribute( - $collection, - (string) $attribute['$id'], - (string) $attribute['type'], - (int) ($attribute['size'] ?? 0), - (bool) ($attribute['signed'] ?? true), - (bool) ($attribute['array'] ?? false), - (bool) ($attribute['required'] ?? false), - ); + return $documents; + } + $payloads = $this->client->exec(); + } catch (\Throwable $e) { + try { + $this->client->discard(); + } catch (\Throwable) { + // PIPELINE-mode discard is version-dependent across phpredis. + } + throw new TransactionException('Failed to load sequences: '.$e->getMessage(), 0, $e); + } + if (! \is_array($payloads)) { + return $documents; } - return true; + foreach ($indexes as $position => $index) { + $payload = $payloads[$position] ?? false; + if (! \is_string($payload) || $payload === '') { + continue; + } + $existing = $this->decode($payload); + $sequence = $existing->getSequence(); + if (! empty($sequence)) { + $documents[$index]->setAttribute(Document::SEQUENCE, (string) $sequence); + } + } + + return $documents; } - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + #[\Override] + public function deleteDocument(Document $collection, string $id): bool { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); - - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); - } + $collectionId = $this->filter($collection->getId()); + $docKey = $this->docKey($collectionId, $id); + $idxKey = $this->idxKey($collectionId); - if (! empty($newKey) && $newKey !== $id) { - $this->renameAttribute($collection, $id, $newKey); - $id = $this->filter($newKey); - } + return $this->transaction(function (RedisClient $redis) use ($collectionId, $id, $docKey, $idxKey): bool { + $payload = $redis->get($docKey); + if (! \is_string($payload) || $payload === '') { + return false; + } - $this->tx(function (RedisClient $client) use ($metaKey, $id, $type, $size, $signed, $array, $required): void { - $attrs = $this->readAttributesField($client, $metaKey); - $attrs = $this->upsertAttributeRecord($attrs, [ - '$id' => $id, - 'key' => $id, - 'type' => $type, - 'size' => $size, - 'signed' => $signed, - 'array' => $array, - 'required' => $required, + $this->journal('deleteDoc', [ + 'collection' => $collectionId, + 'id' => $id, + 'payload' => $payload, + 'docKey' => $docKey, + 'idxKey' => $idxKey, ]); - $client->hSet( - $metaKey, - 'attrs', - \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - }); - return true; + $this->clearPermissions($collectionId, $id); + $redis->del($docKey); + $redis->sRem($idxKey, \strtolower($id)); + + return true; + }); } - public function deleteAttribute(string $collection, string $id): bool + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); - - if ((bool) $this->client->exists($metaKey) === false) { - return true; + if (empty($sequences) && empty($permissionIds)) { + return 0; } - $this->tx(function (RedisClient $client) use ($metaKey, $id): void { - $attrs = $this->readAttributesField($client, $metaKey); - $filtered = []; - foreach ($attrs as $attribute) { - $existingId = (string) ($attribute['$id'] ?? $attribute['key'] ?? ''); - if ($this->filter($existingId) === $id) { + $collectionId = $this->filter($collection->getId()); + $idxKey = $this->idxKey($collectionId); + + return $this->transaction(function (RedisClient $redis) use ($collectionId, $sequences, $permissionIds, $idxKey): int { + $sequenceSet = []; + foreach ($sequences as $sequence) { + $sequenceSet[(string) $sequence] = true; + } + + $allIds = $redis->sMembers($idxKey); + if (! \is_array($allIds)) { + $allIds = []; + } + + $docKeys = []; + $redis->multi(\Redis::PIPELINE); + foreach ($allIds as $id) { + $docKey = $this->docKey($collectionId, (string) $id); + $docKeys[(string) $id] = $docKey; + $redis->get($docKey); + } + $payloads = $redis->exec(); + if (! \is_array($payloads)) { + $payloads = []; + } + + $deleted = []; + foreach ($allIds as $position => $id) { + $payload = $payloads[$position] ?? false; + if (! \is_string($payload) || $payload === '') { continue; } - $filtered[] = $attribute; + $document = $this->decode($payload); + $matchesSequence = isset($sequenceSet[(string) $document->getSequence()]); + if ($matchesSequence) { + $deleted[$document->getId()] = ['payload' => $payload, 'docKey' => $docKeys[(string) $id]]; + } } - $client->hSet( - $metaKey, - 'attrs', - \json_encode($filtered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - }); - $this->dropDocumentField($collection, $id); + foreach ($deleted as $documentId => $deleteEntry) { + $deletedDocKey = $deleteEntry['docKey']; + $this->journal('deleteDoc', [ + 'collection' => $collectionId, + 'id' => (string) $documentId, + 'payload' => $deleteEntry['payload'], + 'docKey' => $deletedDocKey, + 'idxKey' => $idxKey, + ]); + $this->clearPermissions($collectionId, (string) $documentId); + $redis->del($deletedDocKey); + $redis->sRem($idxKey, \strtolower((string) $documentId)); + } - return true; + foreach ($permissionIds as $permissionId) { + $documentId = (string) $permissionId; + if (isset($deleted[$documentId])) { + continue; + } + $this->clearPermissions($collectionId, $documentId); + } + + return \count($deleted); + }); } - public function renameAttribute(string $collection, string $old, string $new): bool + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array { - $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); - $metaKey = $this->key($this->ns(), 'meta', $collection); + $collectionId = $this->filter($collection->getId()); + $metaKey = $this->key($this->ns(), 'meta', $collectionId); if ((bool) $this->client->exists($metaKey) === false) { throw new NotFoundException('Collection not found'); } - $this->tx(function (RedisClient $client) use ($metaKey, $old, $new): void { - $attrs = $this->readAttributesField($client, $metaKey); - $touched = false; - foreach ($attrs as $i => $attribute) { - $existingId = (string) ($attribute['$id'] ?? $attribute['key'] ?? ''); - if ($this->filter($existingId) !== $old) { - continue; - } - $attribute['$id'] = $new; - $attribute['key'] = $new; - $attrs[$i] = $attribute; - $touched = true; + return $this->transaction(function (RedisClient $client) use ($collectionId, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission): array { + $documents = $this->loadCollectionDocuments($client, $collectionId, $forPermission); + $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); + $documents = $this->orderDocuments($documents, $orderAttributes, $orderTypes, $cursorDirection); + $documents = $this->cursorDocuments($documents, $orderAttributes, $orderTypes, $cursor, $cursorDirection); + + if (! \is_null($offset)) { + $documents = \array_slice($documents, $offset); } - if (! $touched) { - return; + if (! \is_null($limit)) { + $documents = \array_slice($documents, 0, $limit); } - $client->hSet( - $metaKey, - 'attrs', - \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - }); - $this->renameDocumentField($collection, $old, $new); + $selections = $this->extractSelections($queries); + if (! empty($selections)) { + $projected = []; + foreach ($documents as $document) { + $projected[] = $this->projectDocument($document, $selections); + } + $documents = $projected; + } - return true; - } + if ($cursorDirection === CursorDirection::Before) { + $documents = \array_reverse($documents); + } - public function getSchemaAttributes(string $collection): array - { - return []; + return $documents; + }); } - public function getCountOfAttributes(Document $collection): int + #[\Override] + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int { - return \count($collection->getAttribute('attributes', [])) + $this->getCountOfDefaultAttributes(); - } + $collectionId = $this->filter($collection->getId()); + $metaKey = $this->key($this->ns(), 'meta', $collectionId); - /** - * Read and decode the `attrs` JSON field on a collection meta hash. Returns - * a plain list of attribute record arrays (empty when the field is absent - * or stored empty). - * - * @return array> - */ - private function readAttributesField(RedisClient $client, string $metaKey): array - { - $raw = $client->hGet($metaKey, 'attrs'); - if (! \is_string($raw) || $raw === '') { - return []; + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); } - /** @var array> $decoded */ - $decoded = \json_decode($raw, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - return \array_values($decoded); - } - - /** - * Pre-flight unique-index check: scan the collection's existing rows for - * conflicts with `$document` against every UNIQUE index on the collection, - * mirroring Memory's `checkUniqueSignatures`. Throws DuplicateException - * on the first collision so callers don't waste a write round trip when - * MariaDB would have rejected the row. - * - * `$excludeId` lets `updateDocument` skip the document being updated. - */ - private function enforceUniqueIndexes(RedisClient $client, string $collection, Document $document, ?string $excludeId = null): void - { - $metaKey = $this->key($this->ns(), 'meta', $collection); - $indexes = $this->readIndexesField($client, $metaKey); + return $this->transaction(function (RedisClient $client) use ($collectionId, $attribute, $queries, $max): float|int { + $documents = $this->loadCollectionDocuments($client, $collectionId, PermissionType::Read); + $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); - $uniqueIndexes = []; - foreach ($indexes as $index) { - if (($index['type'] ?? '') !== Database::INDEX_UNIQUE) { - continue; - } - $attributes = $index['attributes'] ?? []; - if (empty($attributes)) { - continue; + if (! \is_null($max)) { + $documents = \array_slice($documents, 0, $max); } - $uniqueIndexes[] = $attributes; - } - - if ($uniqueIndexes === []) { - return; - } - // Build the new document's signatures up-front. Indexes that have any - // null component are treated as distinct (mirrors MariaDB's UNIQUE - // semantics — NULL never collides with another NULL). - $newSignatures = []; - $sharedTables = $this->getSharedTables(); - $tenant = $sharedTables ? ($document->getAttribute('$tenant') ?? $this->getTenant()) : null; - foreach ($uniqueIndexes as $i => $attributes) { - $signature = []; - $hasNull = false; - foreach ($attributes as $attribute) { - $value = $this->resolveDocumentAttribute($document, (string) $attribute); + $sum = 0; + $isFloat = false; + foreach ($documents as $document) { + $value = $this->resolveDocumentAttribute($document, $attribute); if ($value === null) { - $hasNull = true; - break; + continue; + } + if (\is_float($value)) { + $isFloat = true; + } + if (\is_numeric($value)) { + $sum += $value; } - $signature[] = $this->normalizeIndexValue($value); - } - if ($hasNull) { - continue; - } - if ($sharedTables) { - \array_unshift($signature, $tenant); } - $newSignatures[$i] = \serialize($signature); - } - if ($newSignatures === []) { - return; + return $isFloat ? (float) $sum : (int) $sum; + }); + } + + #[\Override] + public function count(Document $collection, array $queries = [], ?int $max = null): int + { + $collectionId = $this->filter($collection->getId()); + $metaKey = $this->key($this->ns(), 'meta', $collectionId); + + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); } - $idxKey = $this->idxKey($collection); - /** @var array $docIds */ - $docIds = $client->sMembers($idxKey); - if (empty($docIds)) { - return; + if ( + empty($queries) + && $this->authorization->getStatus() === false + && $this->hasSharedTables() === false + ) { + $idxKey = $this->idxKey($collectionId); + $cardinality = $this->client->sCard($idxKey); + if (\is_int($cardinality)) { + return $max === null ? $cardinality : \min($max, $cardinality); + } } - $excludeKey = $excludeId !== null ? \strtolower($excludeId) : null; - $docKeys = []; - foreach ($docIds as $docId) { - if ($excludeKey !== null && \strtolower((string) $docId) === $excludeKey) { - continue; + return $this->transaction(function (RedisClient $client) use ($collectionId, $queries, $max): int { + $documents = $this->loadCollectionDocuments($client, $collectionId, PermissionType::Read); + $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); + + if (! \is_null($max)) { + $documents = \array_slice($documents, 0, $max); } - $docKeys[(string) $docId] = $this->docKey($collection, (string) $docId); - } - if ($docKeys === []) { - return; - } - /** @var array $payloads */ - $payloads = $client->mGet(\array_values($docKeys)); - $position = 0; - foreach ($docKeys as $docId => $_) { - $payload = $payloads[$position++] ?? null; + return \count($documents); + }); + } + + #[\Override] + public function increaseDocumentAttribute(Document $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool + { + $collectionId = $this->filter($collection->getId()); + $docKey = $this->docKey($collectionId, $id); + + return $this->transaction(function (RedisClient $redis) use ($collectionId, $id, $attribute, $value, $updatedAt, $min, $max, $docKey): bool { + $payload = $redis->get($docKey); if (! \is_string($payload) || $payload === '') { - continue; - } - $existing = $this->decode($payload); - if ($sharedTables) { - $rowTenant = $existing->getAttribute('$tenant'); - if ($rowTenant !== $tenant) { - continue; - } + throw new NotFoundException('Document not found'); } - foreach ($newSignatures as $i => $newHash) { - $attributes = $uniqueIndexes[$i]; - $signature = []; - $hasNull = false; - foreach ($attributes as $attribute) { - $value = $this->resolveDocumentAttribute($existing, (string) $attribute); - if ($value === null) { - $hasNull = true; - break; - } - $signature[] = $this->normalizeIndexValue($value); + + $document = $this->decode($payload); + $current = $document->getAttribute($attribute); + $exact = (\is_int($current) || (\is_string($current) && BigInt::isIntegerString($current))) + && (\is_int($value) || (\is_string($value) && BigInt::isIntegerString($value))); + if ($exact) { + $current = BigInt::toNative($current); + $value = BigInt::toNative($value); + if (! \is_null($min) && BigInt::compare($current, $min) < 0) { + return true; } - if ($hasNull) { - continue; + if (! \is_null($max) && BigInt::compare($current, $max) > 0) { + return true; } - if ($sharedTables) { - \array_unshift($signature, $tenant); + $result = BigInt::add($current, $value); + } else { + $current = $this->numericOr($current, 0); + $value = $this->numericOr($value, 0); + if (! \is_null($min) && $current < $min) { + return true; } - if (\serialize($signature) === $newHash) { - throw new UniqueException('Unique index violation'); + if (! \is_null($max) && $current > $max) { + return true; } + $result = $current + $value; } - } + + $document->setAttribute($attribute, $result); + $document->setAttribute(Document::UPDATED_AT, $updatedAt); + + $redis->set($docKey, $this->encode($document)); + + $this->journal('updateDoc', [ + 'collection' => $collectionId, + 'id' => $id, + 'newId' => $id, + 'payload' => $payload, + 'docKey' => $docKey, + ]); + + return true; + }); } - /** - * Insert or replace an attribute record matched by `$id`/`key`. Returns a - * fresh list (re-indexed) so the JSON encodes as an array, never an object. - * - * @param array> $attrs - * @param array $record - * @return array> - */ - private function upsertAttributeRecord(array $attrs, array $record): array - { - $targetId = (string) ($record['$id'] ?? ''); - $replaced = false; - foreach ($attrs as $i => $existing) { - $existingId = (string) ($existing['$id'] ?? $existing['key'] ?? ''); - if ($existingId !== $targetId) { - continue; - } - $attrs[$i] = $record; - $replaced = true; - break; - } - if (! $replaced) { - $attrs[] = $record; - } + #[\Override] + public function limits(): Limits + { + return $this->limits ??= new Limits( + string: 4294967295, + varchar: 16381, + integer: 4294967295, + bigInteger: Database::MAX_BIG_INT, + attributes: 1017, + indexes: 64, + defaultAttributes: \count(Database::internalAttributesFor(true)), + defaultIndexes: \count(Database::INTERNAL_INDEXES), + indexLength: 1024, + uidLength: 255, + documentSize: 0, + minDateTime: new \DateTime('0001-01-01 00:00:00'), + maxDateTime: new \DateTime(self::MAX_DATETIME), + idType: ColumnType::Integer, + keywords: [], + internalIndexKeys: [], + ); + } - return \array_values($attrs); + #[\Override] + public function getCountOfAttributes(Document $collection): int + { + return \count(self::collectionAttributes($collection)) + $this->limits()->defaultAttributes; } - /** - * Drop every key associated with a single collection inside `{ns}:{db}`. - * Used by both deleteCollection and the cascading delete() path. Permission - * sets and document blobs are SCANned because we can't enumerate them - * without an index — the doc-id set under `idx:{col}` is authoritative for - * existing documents but permission roles vary, so we SCAN the prefix. - */ - private function purgeCollectionKeys(RedisClient $client, string $namespace, string $database, string $collection): void + #[\Override] + public function getCountOfIndexes(Document $collection): int { - $collection = $this->filter($collection); - $prefix = $this->nsFor($namespace, $database); - $metaKey = $this->key($prefix, 'meta', $collection); - $idxKey = $this->key($prefix, 'idx', $collection); - $seqKey = $this->key($prefix, 'seq', $collection); + return \count(self::collectionIndexes($collection)) + $this->limits()->defaultIndexes; + } - // Non-shared layout: walk the doc-id index for variadic DEL of every - // doc + perm-doc HASH. Cheap when the set is empty. - /** @var array|false $docIds */ - $docIds = $client->sMembers($idxKey); - if (\is_array($docIds) && $docIds !== []) { - $keys = []; - foreach ($docIds as $docId) { - $keys[] = $this->key($prefix, 'doc', $collection, $docId); - $keys[] = $this->key($prefix, 'perm', 'doc', $collection, $docId); - if (\count($keys) >= self::SCAN_BATCH_SIZE) { - $client->del(...$keys); - $keys = []; - } - } - if ($keys !== []) { - $client->del(...$keys); - } - } + #[\Override] + public function getAttributeWidth(Document $collection): int + { + return 0; + } - // Shared-tables doc/idx/seq sweep: tenants-bucketed under - // `{prefix}:doc:t:{tenant}:{col}:*`, `{prefix}:idx:t:{tenant}:{col}` - // and `{prefix}:seq:t:{tenant}:{col}`. Run unconditionally so a - // collection populated while shared-tables was on can still be - // purged after the test resets the flag back off. - $this->deleteByPattern($client, $prefix . self::SEP . 'doc' . self::SEP . 't' . self::SEP . '*' . self::SEP . $collection . self::SEP . '*'); - $this->deleteByPattern($client, $prefix . self::SEP . 'idx' . self::SEP . 't' . self::SEP . '*' . self::SEP . $collection); - $this->deleteByPattern($client, $prefix . self::SEP . 'seq' . self::SEP . 't' . self::SEP . '*' . self::SEP . $collection); + #[\Override] + public function id(): string + { + return '0'; + } - // Non-shared-tables perm-set sweep. permKey() emits this layout when - // shared tables is OFF: `{prefix}:perm:{col}:{letter}:{role}`. - $this->deleteByPattern($client, $this->key($prefix, 'perm', $collection) . self::SEP . '*'); - // Shared-tables perm sweep. permKey()/permDocKey() emit - // `{prefix}:perm:t:{tenant}:{col}:...` and - // `{prefix}:perm:t:{tenant}:doc:{col}:...` respectively. The non-shared - // pattern above does NOT match these, so without this sweep dropping a - // collection under shared tables leaves stale role/doc HASH keys - // behind — and a recreated collection inherits stale grants. - $this->deleteByPattern($client, $prefix . self::SEP . 'perm' . self::SEP . 't' . self::SEP . '*' . self::SEP . $collection . self::SEP . '*'); - $this->deleteByPattern($client, $prefix . self::SEP . 'perm' . self::SEP . 't' . self::SEP . '*' . self::SEP . 'doc' . self::SEP . $collection . self::SEP . '*'); - $this->deleteByPattern($client, $this->key($prefix, 'tenants', $collection) . self::SEP . '*'); + private function key(string ...$parts): string + { + return \implode(self::SEP, $parts); + } - $client->del($metaKey, $idxKey, $seqKey); + private function ns(): string + { + return $this->nsFor($this->getNamespace(), $this->getDatabase()); } - /** - * SCAN-and-DEL helper — MATCHes the supplied glob in batches so we don't - * block the server with a giant KEYS call. Honours the same 500-key batch - * size used by the test harness teardown. - */ - private function deleteByPattern(RedisClient $client, string $pattern): void + private function nsFor(string $namespace, string $database): string { - $cursor = null; - do { - /** @var array|false $batch */ - $batch = $client->scan($cursor, $pattern, self::SCAN_BATCH_SIZE); - if (\is_array($batch) && $batch !== []) { - $client->del(...$batch); - } - } while ($cursor !== 0 && $cursor !== null); + return self::KEY_PREFIX.self::SEP.$namespace.self::SEP.$database; } - /** - * Compute the size of a collection by summing memory used by its meta - * hash, every document blob, the doc-id index, and any permission sets. - * - * Redis `MEMORY USAGE` is used when supported (Redis 4.0+). We fall back - * to STRLEN/HLEN approximations so the adapter still produces a non-zero - * size on builds (or test doubles) where MEMORY USAGE isn't routed. - */ - private function computeCollectionSize(string $collection): int + private function nsBase(): string { - $collection = $this->filter($collection); - $metaKey = $this->key($this->ns(), 'meta', $collection); + return self::KEY_PREFIX.self::SEP.$this->getNamespace(); + } - if ((bool) $this->client->exists($metaKey) === false) { - return 0; + private function docKey(string $collection, string $id, int|string|null $tenant = null): string + { + $id = \strtolower($id); + if (! $this->hasSharedTables()) { + return $this->key($this->ns(), 'doc', $collection, $id); } - $total = $this->measureKey($metaKey); - - $idxKey = $this->idxKey($collection); - $total += $this->measureKey($idxKey); + $bucket = $this->bucketFor($tenant); - /** @var array|false $docIds */ - $docIds = $this->client->sMembers($idxKey); - if (\is_array($docIds)) { - foreach ($docIds as $docId) { - $total += $this->measureKey($this->docKey($collection, (string) $docId)); - // Route through permDocKey() so the tenant-bucketed shape is - // honoured under shared tables; otherwise the per-document - // perm HASH is missed entirely. - $total += $this->measureKey($this->permDocKey($collection, (string) $docId)); - } - } + return $this->key($this->ns(), 'doc', 't', $bucket, $collection, $id); + } - // Inverted permission SETs live under permKey()'s shape — tenant - // bucketed under shared tables, flat otherwise. Pick the matching - // SCAN prefix so both layouts contribute to the size estimate. - $bucket = $this->tenantBucket(); - if ($bucket !== null) { - $permPrefix = $this->ns() . self::SEP . 'perm' . self::SEP . 't' . self::SEP . $bucket . self::SEP . $collection . self::SEP . '*'; - } else { - $permPrefix = $this->key($this->ns(), 'perm', $collection) . self::SEP . '*'; + private function idxKey(string $collection, int|string|null $tenant = null): string + { + if (! $this->hasSharedTables()) { + return $this->key($this->ns(), 'idx', $collection); } - $cursor = null; - do { - /** @var array|false $batch */ - $batch = $this->client->scan($cursor, $permPrefix, self::SCAN_BATCH_SIZE); - if (\is_array($batch)) { - foreach ($batch as $key) { - $total += $this->measureKey($key); - } - } - } while ($cursor !== 0 && $cursor !== null); - return $total; + return $this->key($this->ns(), 'idx', 't', $this->bucketFor($tenant), $collection); } - /** - * Best-effort size probe for a single Redis key. Prefers `MEMORY USAGE` - * (returns the bytes Redis itself reports). Falls back to the encoded - * payload length when MEMORY USAGE is unavailable, so the result remains - * a stable monotonically-growing integer for size-tracking tests. - */ - private function measureKey(string $key): int + private function seqKey(string $collection, int|string|null $tenant = null): string { - try { - /** @var int|false|null $usage */ - $usage = $this->client->rawCommand('MEMORY', 'USAGE', $key); - if (\is_int($usage)) { - return $usage; - } - } catch (\Throwable) { - // Fall through to the structural fallback below. + if (! $this->hasSharedTables()) { + return $this->key($this->ns(), 'seq', $collection); } - $type = $this->client->type($key); - switch ($type) { - case RedisClient::REDIS_STRING: - $value = $this->client->get($key); + return $this->key($this->ns(), 'seq', 't', $this->bucketFor($tenant), $collection); + } - return \is_string($value) ? \strlen($value) + \strlen($key) : 0; - case RedisClient::REDIS_HASH: - $entries = $this->client->hGetAll($key); - $bytes = \strlen($key); - if (\is_array($entries)) { - foreach ($entries as $field => $value) { - $bytes += \strlen((string) $field) + \strlen((string) $value); - } - } + private function bucketFor(int|string|null $tenant): string + { + if ($tenant === null) { + $tenant = $this->getTenant(); + } - return $bytes; - case RedisClient::REDIS_SET: - $members = $this->client->sMembers($key); - $bytes = \strlen($key); - if (\is_array($members)) { - foreach ($members as $member) { - $bytes += \strlen((string) $member); - } - } + return $tenant === null ? '_' : (string) $tenant; + } - return $bytes; - default: - return 0; + private function tenantBucket(int|string|null $tenant = null): ?string + { + if (! $this->hasSharedTables()) { + return null; } - } - // === @architect:T20 end === + return $this->bucketFor($tenant); + } + private function permKey(string $collection, string $letter, string $role, int|string|null $tenant = null): string + { + $bucket = $this->tenantBucket($tenant); + if ($bucket !== null) { + return $this->ns().self::SEP.'perm'.self::SEP.'t'.self::SEP.$bucket.self::SEP.$collection.self::SEP.$letter.self::SEP.$role; + } + return $this->ns().self::SEP.'perm'.self::SEP.$collection.self::SEP.$letter.self::SEP.$role; + } + private function permDocKey(string $collection, string $id, int|string|null $tenant = null): string + { + $id = \strtolower($id); + $bucket = $this->tenantBucket($tenant); + if ($bucket !== null) { + return $this->ns().self::SEP.'perm'.self::SEP.'t'.self::SEP.$bucket.self::SEP.'doc'.self::SEP.$collection.self::SEP.$id; + } + return $this->ns().self::SEP.'perm'.self::SEP.'doc'.self::SEP.$collection.self::SEP.$id; + } - // === @architect:T30 owns: document CRUD + bulk + increase === + private function encode(Document $document): string + { + return \json_encode( + $document->getArrayCopy(), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION + ); + } - public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + private function decode(string $payload): Document { - $col = $this->filter($collection->getId()); - $payload = $this->client->get($this->docKey($col, $id)); - // Mirror Memory's METADATA fallback: under shared tables the - // bootstrap METADATA row is written with a null tenant and must - // be visible to every tenant. - if ((! \is_string($payload) || $payload === '') && $this->getSharedTables() && $col === Database::METADATA) { - $payload = $this->client->get($this->docKey($col, $id, '_')); + try { + /** @var array $data */ + $data = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new DatabaseException('Document decode failed: '.$e->getMessage(), 0, $e); } - if (! \is_string($payload) || $payload === '') { - return new Document([]); + return Document::fromStorage($data); + } + + /** + * @template T + * @param callable(RedisClient): T $callback + * @return T + */ + protected function transaction(callable $callback): mixed + { + try { + return $callback($this->client); + } catch (\RedisException $exception) { + throw new TransactionException('Redis transaction failed: '.$exception->getMessage(), 0, $exception); } + } - $document = $this->decode($payload); + private function writePermissions(string $collection, string $id, Document $document): void + { + $id = \strtolower($id); + $tenant = $document->getTenant(); - // Mirror the loadCollectionDocuments tenant filter: under shared - // tables a doc key written for tenant A must not surface for tenant - // B. Permission filtering can't catch this on the single-doc path - // because the caller already knows the id. METADATA collections - // are exempt — they intentionally serve null-tenant rows to every - // tenant. - if ($this->getSharedTables()) { - $rowTenant = $document->getAttribute('$tenant'); - $tenant = $this->getTenant(); - $allowNullTenant = $col === Database::METADATA && $rowTenant === null; - if (! $allowNullTenant && $rowTenant !== $tenant) { - return new Document([]); + $byRole = []; + foreach ([PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete] as $type) { + foreach ($document->getPermissionsByType($type) as $role) { + $byRole[(string) $role][] = $this->actionLetter($type); } } - if ($col !== Database::METADATA) { - $document = $this->surfaceRelationshipAttributes($col, $document); - } - - $selections = []; - foreach ($queries as $query) { - if ($query instanceof Query && $query->getMethod() === Query::TYPE_SELECT) { - foreach ($query->getValues() as $value) { - $selections[] = (string) $value; - } - } + if ($byRole === []) { + return; } - if (! empty($selections) && ! \in_array('*', $selections, true)) { - $projected = []; - foreach ($document->getArrayCopy() as $field => $value) { - if (\str_starts_with((string) $field, '$') || \str_starts_with((string) $field, '_')) { - $projected[$field] = $value; - - continue; - } - if (\in_array($field, $selections, true)) { - $projected[$field] = $value; - } + $hashKey = $this->permDocKey($collection, $id, $tenant); + $hashFields = []; + $writes = []; + foreach ($byRole as $role => $letters) { + $unique = \array_values(\array_unique($letters)); + \sort($unique); + $hashFields[$role] = \implode(',', $unique); + foreach ($unique as $letter) { + $writes[] = [$role, $letter, $this->permKey($collection, $letter, $role, $tenant)]; } - $document = new Document($projected); } - return $document; - } - - public function createDocument(Document $collection, Document $document): Document - { - $col = $this->filter($collection->getId()); - $id = $document->getId(); - if ($id === '') { - $id = ID::unique(); - $document->setAttribute('$id', $id); - } - $tenant = $document->getTenant(); - $docKey = $this->docKey($col, $id, $tenant); - $idxKey = $this->idxKey($col, $tenant); - $seqKey = $this->seqKey($col, $tenant); - $permDocKey = $this->permDocKey($col, $id); - - return $this->tx(function (RedisClient $redis) use ($col, $id, $document, $docKey, $idxKey, $seqKey, $permDocKey): Document { - if ((bool) $redis->exists($docKey)) { - if ($this->skipDuplicates) { - // Mirrors MariaDB's `INSERT IGNORE` and Memory's skipDuplicates path: - // duplicate primary key is silently dropped and the existing row's - // sequence is returned so the caller can still emit an onNext event. - $existingPayload = $redis->get($docKey); - if (\is_string($existingPayload) && $existingPayload !== '') { - $existing = $this->decode($existingPayload); - $document->setAttribute('$sequence', $existing->getSequence() ?? ''); - } - - return $document; - } - throw new DuplicateException('Document already exists'); + $this->client->multi(\Redis::PIPELINE); + try { + foreach ($writes as [, , $setKey]) { + $this->client->sAdd($setKey, $id); } - + $this->client->hMSet($hashKey, $hashFields); + $this->client->sAdd($this->grantsKey($this->ns(), $collection), $hashKey, ...\array_column($writes, 2)); + $this->client->exec(); + } catch (\Throwable $e) { try { - $this->enforceUniqueIndexes($redis, $col, $document); - } catch (DuplicateException $e) { - if ($this->skipDuplicates) { - return $document; - } - throw $e; - } - - $sequence = $document->getSequence(); - if (empty($sequence)) { - $next = $redis->incr($seqKey); - $sequence = (string) $next; - } else { - $sequence = (string) $sequence; - $current = $redis->get($seqKey); - if (! \is_string($current) || (int) $sequence > (int) $current) { - $redis->set($seqKey, $sequence); - } + $this->client->discard(); + } catch (\Throwable) { + // ignore } - $document->setAttribute('$sequence', $sequence); - - $redis->set($docKey, $this->encode($document)); - $redis->sAdd($idxKey, \strtolower($id)); + throw $e; + } - $this->writePermissions($col, $id, $document); - $this->journal('createDoc', [ - 'collection' => $col, + foreach ($writes as [$role, $letter, $setKey]) { + $this->journal('createPerm', [ + 'collection' => $collection, 'id' => $id, - 'docKey' => $docKey, - 'idxKey' => $idxKey, - 'permDocKey' => $permDocKey, + 'role' => $role, + 'letter' => $letter, + 'permKey' => $setKey, + 'permDocKey' => $hashKey, ]); - - return $document; - }); - } - - public function createDocuments(Document $collection, array $documents): array - { - $created = []; - foreach ($documents as $document) { - $created[] = $this->createDocument($collection, $document); } - - return $created; } - public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + private function clearPermissions(string $collection, string $id, int|string|null $tenant = null): void { - $col = $this->filter($collection->getId()); - $oldKey = $this->docKey($col, $id); - $idxKey = $this->idxKey($col); - // METADATA fallback: under shared tables the bootstrap METADATA row - // is written with a null tenant; subsequent updates from another - // tenant must still resolve to that row instead of throwing. - $useNullTenant = false; - if ($col === Database::METADATA && $this->getSharedTables() && $this->getTenant() !== null) { - if ((bool) $this->client->exists($oldKey) === false) { - $oldKey = $this->docKey($col, $id, '_'); - $useNullTenant = true; - } + $id = \strtolower($id); + $hashKey = $this->permDocKey($collection, $id, $tenant); + /** @var array|false $hash */ + $hash = $this->client->hGetAll($hashKey); + if ($hash === false || $hash === []) { + return; } - return $this->tx(function (RedisClient $redis) use ($col, $id, $document, $skipPermissions, $oldKey, $idxKey, $useNullTenant): Document { - $existingPayload = $redis->get($oldKey); - if (! \is_string($existingPayload) || $existingPayload === '') { - throw new NotFoundException('Document not found'); + $removals = []; + foreach ($hash as $role => $letterCsv) { + if ($letterCsv === '') { + continue; } - - $existing = $this->decode($existingPayload); - if ($col !== Database::METADATA) { - $existing = $this->surfaceRelationshipAttributes($col, $existing); + foreach (\explode(',', $letterCsv) as $letter) { + $removals[] = [$role, $letter, $this->permKey($collection, $letter, $role, $tenant)]; } - $newId = $document->getId() !== '' ? $document->getId() : $id; - // Stay on the null-tenant key when the existing row was located - // there; rewriting under the current tenant would split the row. - $newKey = $useNullTenant ? $this->docKey($col, $newId, '_') : $this->docKey($col, $newId); - // Idx set scoping mirrors the located row so per-tenant ids remain - // separate but the null-tenant METADATA row stays in the null - // tenant's idx set. - $effectiveIdxKey = $useNullTenant ? $this->idxKey($col, '_') : $idxKey; + } - if ($newId !== $id && (bool) $redis->exists($newKey)) { - throw new DuplicateException('Document already exists'); + $this->client->multi(\Redis::PIPELINE); + try { + foreach ($removals as [, , $setKey]) { + $this->client->sRem($setKey, $id); } - - $resolved = $this->applyOperators($document->getArrayCopy(), $existing->getArrayCopy()); - $merged = \array_merge($existing->getArrayCopy(), $resolved); - $merged['$id'] = $newId; - $mergedDocument = new Document($merged); - - $this->enforceUniqueIndexes($redis, $col, $mergedDocument, $id); - - $payload = $this->encode($mergedDocument); - - if ($newId !== $id) { - $redis->del($oldKey); - $redis->sRem($effectiveIdxKey, \strtolower($id)); + $this->client->del($hashKey); + $this->client->sRem($this->grantsKey($this->ns(), $collection), $hashKey); + $this->client->exec(); + } catch (\Throwable $e) { + try { + $this->client->discard(); + } catch (\Throwable) { + // ignore } - $redis->set($newKey, $payload); - $redis->sAdd($effectiveIdxKey, \strtolower($newId)); + throw $e; + } - $this->journal('updateDoc', [ - 'collection' => $col, + foreach ($removals as [$role, $letter, $setKey]) { + $this->journal('deletePerm', [ + 'collection' => $collection, 'id' => $id, - 'newId' => $newId, - 'payload' => $existingPayload, - 'docKey' => $oldKey, - 'newDocKey' => $newKey, - 'idxKey' => $effectiveIdxKey, + 'role' => $role, + 'letter' => $letter, + 'previous' => $hash[$role] ?? '', + 'permKey' => $setKey, + 'permDocKey' => $hashKey, ]); - - if (! $skipPermissions) { - $this->clearPermissions($col, $id); - if ($newId !== $id) { - $this->clearPermissions($col, $newId); - } - $this->writePermissions($col, $newId, $mergedDocument); - } - - return $mergedDocument; - }); + } } - public function updateDocuments(Document $collection, Document $updates, array $documents): int + /** + * @param array $ids + * @return array + */ + private function applyPermissionFilter(string $collection, array $ids, PermissionType $action): array { - if (empty($documents)) { - return 0; + if ($ids === []) { + return $ids; } - - $attrs = $updates->getAttributes(); - $hasCreatedAt = ! empty($updates->getCreatedAt()); - $hasUpdatedAt = ! empty($updates->getUpdatedAt()); - $hasPermissions = $updates->offsetExists('$permissions'); - if (empty($attrs) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) { - return 0; + if ($this->authorization->getStatus() === false) { + return $ids; } - $col = $this->filter($collection->getId()); - - // Drop any caller-provided keys: pipeline results are indexed - // sequentially, so positional iteration here MUST start at 0. - $documents = \array_values($documents); + $roles = $this->authorization->getRoles(); + if ($roles === []) { + return []; + } - return $this->tx(function (RedisClient $redis) use ($col, $documents, $updates, $attrs, $hasCreatedAt, $hasUpdatedAt, $hasPermissions): int { - // Pipeline existing-payload GETs in a single round trip — mirrors - // upsertDocuments() and avoids one synchronous round trip per - // document, which dominates wall time on bulk updates. - $docKeys = []; - foreach ($documents as $doc) { - $docKeys[] = $this->docKey($col, $doc->getId()); - } + $letter = $this->actionLetter($action); + $keys = []; + foreach ($roles as $role) { + $keys[] = $this->permKey($collection, $letter, $role); + } - $redis->multi(\Redis::PIPELINE); - foreach ($docKeys as $docKey) { - $redis->get($docKey); - } - $existingPayloads = $redis->exec(); - if (! \is_array($existingPayloads)) { - $existingPayloads = []; - } + if (\count($keys) === 1) { + /** @var array|false $allowed */ + $allowed = $this->client->sMembers($keys[0]); + } else { + $first = \array_shift($keys); + /** @var array|false $allowed */ + $allowed = $this->client->sUnion($first, ...$keys); + } + if ($allowed === false || $allowed === []) { + return []; + } - // Cache the relationship-key list once per bulk call so the - // null-surface pass is N reads of a local list, not N reads of - // meta.attrs. - $relationshipKeys = []; - if ($col !== Database::METADATA) { - $metaKey = $this->key($this->ns(), 'meta', $this->filter($col)); - $attributes = $this->readAttributesField($redis, $metaKey); - $relationshipKeys = $this->extractRelationshipKeys($attributes); - } + $allowedSet = \array_flip($allowed); - $count = 0; - foreach ($documents as $i => $doc) { - $uid = $doc->getId(); - $docKey = $docKeys[$i]; - $existingPayload = $existingPayloads[$i] ?? false; - if (! \is_string($existingPayload) || $existingPayload === '') { - continue; - } + return \array_values(\array_filter($ids, static fn (string $id): bool => isset($allowedSet[$id]))); + } - $existing = $this->decode($existingPayload); - if (! empty($relationshipKeys)) { - $existing = $this->surfaceRelationshipAttributesUsing($relationshipKeys, $existing); - } - $merged = $existing->getArrayCopy(); - $resolved = $this->applyOperators($attrs, $merged); - foreach ($resolved as $attribute => $value) { - $merged[$attribute] = $value; - } - if ($hasCreatedAt) { - $merged['$createdAt'] = $updates->getCreatedAt(); - } - if ($hasUpdatedAt) { - $merged['$updatedAt'] = $updates->getUpdatedAt(); - } - if ($hasPermissions) { - $merged['$permissions'] = $updates->getPermissions(); - } + private function actionLetter(PermissionType $action): string + { + return match ($action) { + PermissionType::Read => 'r', + PermissionType::Create => 'c', + PermissionType::Update => 'u', + PermissionType::Delete => 'd', + PermissionType::Write => 'w', + }; + } - $mergedDocument = new Document($merged); - $redis->set($docKey, $this->encode($mergedDocument)); + /** + * @param array $payload + */ + protected function journal(string $operation, array $payload): void + { + if ($this->inTransaction === 0) { + return; + } + $this->journalStack[\count($this->journalStack) - 1][] = [ + 'op' => $operation, + 'payload' => $payload, + ]; + } - $this->journal('updateDoc', [ - 'collection' => $col, - 'id' => $uid, - 'newId' => $uid, - 'payload' => $existingPayload, - 'docKey' => $docKey, - ]); + /** + * @param array $payload + */ + private function payloadString(array $payload, string $key): ?string + { + $value = $payload[$key] ?? null; - if ($hasPermissions) { - $this->clearPermissions($col, $uid); - $this->writePermissions($col, $uid, $mergedDocument); - } + return \is_string($value) ? $value : null; + } - $count++; - } + /** + * @param array $payload + */ + private function payloadStringOr(array $payload, string $key, string $default): string + { + $value = $payload[$key] ?? null; - return $count; - }); + return \is_string($value) ? $value : $default; } - public function upsertDocuments( - Document $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; - } - - $col = $this->filter($collection->getId()); + private function stringOrEmpty(mixed $value): string + { + return \is_string($value) ? $value : ''; + } - return $this->tx(function (RedisClient $redis) use ($col, $attribute, $changes): array { - $results = []; + private function numericOr(mixed $value, int|float $default): int|float + { + return \is_numeric($value) ? $value + 0 : $default; + } - // Phase 1: pipeline GETs of every doc so we know create vs update - // in a single round trip. Mirror createDocument and route every - // doc/idx/seq key through the document's own tenant so a batch - // that mixes tenants under shared tables doesn't silently - // misroute to the adapter-bound bucket. - $redis->multi(\Redis::PIPELINE); - foreach ($changes as $change) { - $document = $change->getNew(); - $redis->get($this->docKey($col, $document->getId(), $document->getTenant())); - } - $existingPayloads = $redis->exec(); - if (! \is_array($existingPayloads)) { - $existingPayloads = []; - } + private function intOr(mixed $value, int $default): int + { + return \is_numeric($value) ? (int) $value : $default; + } - // Cache the relationship-key list once per bulk call (see - // updateDocuments) so we surface nulls without re-reading - // meta.attrs per change. - $relationshipKeys = []; - if ($col !== Database::METADATA) { - $metaKey = $this->key($this->ns(), 'meta', $this->filter($col)); - $attributes = $this->readAttributesField($redis, $metaKey); - $relationshipKeys = $this->extractRelationshipKeys($attributes); - } + /** + * @param array $record + */ + private function recordIdentifier(array $record): string + { + $id = $record[Document::ID] ?? null; + if (\is_string($id)) { + return $id; + } + $key = $record['key'] ?? null; - foreach ($changes as $i => $change) { - $document = $change->getNew(); - $id = $document->getId(); - $tenant = $document->getTenant(); - $docKey = $this->docKey($col, $id, $tenant); - $idxKey = $this->idxKey($col, $tenant); - $seqKey = $this->seqKey($col, $tenant); - $existingPayload = $existingPayloads[$i] ?? false; + return \is_string($key) ? $key : ''; + } - if (\is_string($existingPayload) && $existingPayload !== '') { - $existing = $this->decode($existingPayload); - if (! empty($relationshipKeys)) { - $existing = $this->surfaceRelationshipAttributesUsing($relationshipKeys, $existing); - } - $existingArray = $existing->getArrayCopy(); - $resolved = $this->applyOperators($document->getArrayCopy(), $existingArray); - $merged = \array_merge($existingArray, $resolved); - $merged['$id'] = $id; - - if ($attribute !== '') { - $previous = $existing->getAttribute($attribute); - $delta = $document->getAttribute($attribute); - $previousNumeric = \is_numeric($previous) ? $previous + 0 : 0; - $deltaNumeric = \is_numeric($delta) ? $delta + 0 : 0; - $merged[$attribute] = $previousNumeric + $deltaNumeric; - } + protected function rollbackJournal(): void + { + $frame = \array_pop($this->journalStack); + if ($frame === null) { + return; + } - $mergedDocument = new Document($merged); - $redis->set($docKey, $this->encode($mergedDocument)); + for ($i = \count($frame) - 1; $i >= 0; $i--) { + $entry = $frame[$i]; + $operation = $entry['op']; + $payload = $entry['payload']; - $this->journal('updateDoc', [ - 'collection' => $col, - 'id' => $id, - 'newId' => $id, - 'payload' => $existingPayload, - 'docKey' => $docKey, - ]); + switch ($operation) { + case 'createDoc': + $collection = $this->payloadStringOr($payload, 'collection', ''); + $id = $this->payloadStringOr($payload, 'id', ''); + $this->rawDeleteDoc( + $collection, + $id, + $this->payloadString($payload, 'docKey'), + $this->payloadString($payload, 'idxKey'), + $this->payloadString($payload, 'permDocKey'), + ); + break; - $this->clearPermissions($col, $id); - $this->writePermissions($col, $id, $mergedDocument); + case 'deleteDoc': + $collection = $this->payloadStringOr($payload, 'collection', ''); + $id = $this->payloadStringOr($payload, 'id', ''); + $beforePayload = $this->payloadStringOr($payload, 'payload', ''); + $this->rawRestoreDoc( + $collection, + $id, + $beforePayload, + $this->payloadString($payload, 'docKey'), + $this->payloadString($payload, 'idxKey'), + ); + break; - $results[] = $mergedDocument; - } else { - // Insert path: parity with createDocument — reject writes - // that would violate a UNIQUE index before the row lands - // in the keyspace. - $this->enforceUniqueIndexes($redis, $col, $document); - - $sequence = $document->getSequence(); - if (empty($sequence)) { - $next = $redis->incr($seqKey); - $sequence = (string) $next; - } else { - $sequence = (string) $sequence; - $current = $redis->get($seqKey); - if (! \is_string($current) || (int) $sequence > (int) $current) { - $redis->set($seqKey, $sequence); + case 'updateDoc': + $collection = $this->payloadStringOr($payload, 'collection', ''); + $id = $this->payloadStringOr($payload, 'id', ''); + $beforePayload = $this->payloadStringOr($payload, 'payload', ''); + $docKey = $this->payloadString($payload, 'docKey') ?? $this->docKey($collection, $id); + $this->client->set($docKey, $beforePayload); + $newId = $this->payloadString($payload, 'newId'); + if ($newId !== null && $newId !== $id) { + $newDocKey = $this->payloadString($payload, 'newDocKey') ?? $this->docKey($collection, $newId); + if ($newDocKey !== $docKey) { + $this->client->del($newDocKey); } + $idxKey = $this->payloadString($payload, 'idxKey') ?? $this->idxKey($collection); + $this->client->sRem($idxKey, \strtolower($newId)); + $this->client->sAdd($idxKey, \strtolower($id)); } - $document->setAttribute('$sequence', $sequence); - - $resolved = $this->applyOperators($document->getArrayCopy(), []); - foreach ($resolved as $attr => $value) { - $document->setAttribute($attr, $value); - } + break; - $redis->set($docKey, $this->encode($document)); - $redis->sAdd($idxKey, \strtolower($id)); + case 'createPerm': + $collection = $this->payloadStringOr($payload, 'collection', ''); + $letter = $this->payloadStringOr($payload, 'letter', ''); + $role = $this->payloadStringOr($payload, 'role', ''); + $id = $this->payloadStringOr($payload, 'id', ''); + $setKey = $this->payloadString($payload, 'permKey') ?? $this->permKey($collection, $letter, $role); + $hashKey = $this->payloadString($payload, 'permDocKey') ?? $this->permDocKey($collection, $id); + $this->client->sRem($setKey, $id); + $this->client->hDel($hashKey, $role); + break; - $this->writePermissions($col, $id, $document); - $this->journal('createDoc', [ - 'collection' => $col, - 'id' => $id, - 'docKey' => $docKey, - 'idxKey' => $idxKey, - 'permDocKey' => $this->permDocKey($col, $id), - ]); + case 'deletePerm': + $collection = $this->payloadStringOr($payload, 'collection', ''); + $letter = $this->payloadStringOr($payload, 'letter', ''); + $role = $this->payloadStringOr($payload, 'role', ''); + $id = $this->payloadStringOr($payload, 'id', ''); + $setKey = $this->payloadString($payload, 'permKey') ?? $this->permKey($collection, $letter, $role); + $hashKey = $this->payloadString($payload, 'permDocKey') ?? $this->permDocKey($collection, $id); + $this->client->sAdd($setKey, $id); + $previous = $this->payloadString($payload, 'previous'); + if ($previous !== null && $previous !== '') { + $this->client->hSet($hashKey, $role, $previous); + } + break; - $results[] = $document; - } + default: + throw new TransactionException('Unknown journal op: '.$operation); } + } + } - return $results; - }); + private function rawDeleteDoc(string $collection, string $id, ?string $docKey = null, ?string $idxKey = null, ?string $permDocKey = null): void + { + $lowerId = \strtolower($id); + $this->client->del($docKey ?? $this->docKey($collection, $lowerId)); + $this->client->sRem($idxKey ?? $this->idxKey($collection), $lowerId); + $this->client->del($permDocKey ?? $this->permDocKey($collection, $lowerId)); } - public function getSequences(string $collection, array $documents): array + private function rawRestoreDoc(string $collection, string $id, string $payload, ?string $docKey = null, ?string $idxKey = null): void { - if (empty($documents)) { - return $documents; - } + $lowerId = \strtolower($id); + $this->client->set($docKey ?? $this->docKey($collection, $lowerId), $payload); + $this->client->sAdd($idxKey ?? $this->idxKey($collection), $lowerId); + } - $this->client->multi(\Redis::PIPELINE); - try { - $indexes = []; - foreach ($documents as $index => $doc) { - if (! empty($doc->getSequence())) { - continue; - } - $this->client->get($this->docKey($collection, $doc->getId())); - $indexes[] = $index; - } - // No work queued — discard the empty pipeline so the connection - // does not stay in MULTI mode after returning early. - if ($indexes === []) { - try { - $this->client->discard(); - } catch (\Throwable) { - // PIPELINE-mode discard is version-dependent across phpredis. - } - return $documents; - } - $payloads = $this->client->exec(); - } catch (\Throwable $e) { - try { - $this->client->discard(); - } catch (\Throwable) { - // PIPELINE-mode discard is version-dependent across phpredis. - } - throw new TransactionException('Failed to load sequences: ' . $e->getMessage(), 0, $e); - } - if (! \is_array($payloads)) { - return $documents; + /** + * @return array> + */ + private function readAttributesField(RedisClient $client, string $metaKey): array + { + $raw = $client->hGet($metaKey, 'attrs'); + if (! \is_string($raw) || $raw === '') { + return []; } + /** @var array> $decoded */ + $decoded = \json_decode($raw, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - foreach ($indexes as $position => $index) { - $payload = $payloads[$position] ?? false; - if (! \is_string($payload) || $payload === '') { - continue; - } - $existing = $this->decode($payload); - $sequence = $existing->getSequence(); - if (! empty($sequence)) { - $documents[$index]->setAttribute('$sequence', (string) $sequence); - } + return \array_values($decoded); + } + + /** + * @return array> + */ + private function readIndexesField(RedisClient $client, string $metaKey): array + { + $raw = $client->hGet($metaKey, 'indexes'); + if (! \is_string($raw) || $raw === '') { + return []; + } + $decoded = \json_decode($raw, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); + if (! \is_array($decoded)) { + return []; } - return $documents; + /** @var array> $decoded */ + return $decoded; } - public function deleteDocument(string $collection, string $id): bool + /** + * @param array> $attributes + * @param array $record + * @return array> + */ + private function upsertAttributeRecord(array $attributes, array $record): array { - $collection = $this->filter($collection); - $docKey = $this->docKey($collection, $id); - $idxKey = $this->idxKey($collection); - - return $this->tx(function (RedisClient $redis) use ($collection, $id, $docKey, $idxKey): bool { - $payload = $redis->get($docKey); - if (! \is_string($payload) || $payload === '') { - return false; + $targetId = $this->stringOrEmpty($record[Document::ID] ?? ''); + $replaced = false; + foreach ($attributes as $i => $existing) { + $existingId = $this->recordIdentifier($existing); + if ($existingId !== $targetId) { + continue; } + $attributes[$i] = $record; + $replaced = true; + break; + } + if (! $replaced) { + $attributes[] = $record; + } - $this->journal('deleteDoc', [ - 'collection' => $collection, - 'id' => $id, - 'payload' => $payload, - 'docKey' => $docKey, - 'idxKey' => $idxKey, - ]); - - $this->clearPermissions($collection, $id); - $redis->del($docKey); - $redis->sRem($idxKey, \strtolower($id)); + return \array_values($attributes); + } - return true; - }); + private function enforceUniqueIndexes(RedisClient $client, string $collection, Document $document, ?string $excludeId = null): void + { + $this->enforceUniqueIndexesForDocuments($client, $collection, [$document], $excludeId === null ? [] : [$excludeId]); } - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + /** + * Rejects a write when two of its documents share a unique value, or when one shares a + * unique value with a stored document it does not replace. + * + * @param array $documents + * @param array $replacedIds the stored id each document overwrites, by document position + */ + private function enforceUniqueIndexesForDocuments(RedisClient $client, string $collection, array $documents, array $replacedIds): void { - if (empty($sequences) && empty($permissionIds)) { - return 0; + $uniqueIndexes = $this->uniqueIndexAttributes($client, $collection); + if ($uniqueIndexes === []) { + return; } - $collection = $this->filter($collection); - $idxKey = $this->idxKey($collection); - - return $this->tx(function (RedisClient $redis) use ($collection, $sequences, $permissionIds, $idxKey): int { - $sequenceSet = []; - foreach ($sequences as $sequence) { - $sequenceSet[(string) $sequence] = true; - } - - $allIds = $redis->sMembers($idxKey); - if (! \is_array($allIds)) { - $allIds = []; - } - - $docKeys = []; - $redis->multi(\Redis::PIPELINE); - foreach ($allIds as $id) { - $docKey = $this->docKey($collection, (string) $id); - $docKeys[(string) $id] = $docKey; - $redis->get($docKey); + $sharedTables = $this->hasSharedTables(); + $claimed = []; + $replaced = []; + $tenants = []; + foreach ($documents as $position => $document) { + $tenant = $sharedTables ? ($document->getTenant() ?? $this->getTenant()) : null; + $idxKey = $this->idxKey($collection, $tenant); + $tenants[$idxKey] = $tenant; + if (isset($replacedIds[$position])) { + $replaced[$idxKey][\strtolower($replacedIds[$position])] = true; } - $payloads = $redis->exec(); - if (! \is_array($payloads)) { - $payloads = []; + foreach ($this->uniqueSignatures($document, $uniqueIndexes, $tenant) as $index => $signature) { + if (isset($claimed[$idxKey][$index][$signature])) { + throw new UniqueException(UniqueException::MESSAGE); + } + $claimed[$idxKey][$index][$signature] = true; } + } - $deleted = []; - foreach ($allIds as $position => $id) { - $payload = $payloads[$position] ?? false; - if (! \is_string($payload) || $payload === '') { - continue; - } - $document = $this->decode($payload); - $matchesSequence = isset($sequenceSet[(string) $document->getSequence()]); - if ($matchesSequence) { - $deleted[$document->getId()] = ['payload' => $payload, 'docKey' => $docKeys[(string) $id]]; + foreach ($claimed as $idxKey => $signatures) { + [$owners] = $this->storedUniqueValues($client, $collection, $idxKey, $tenants[$idxKey], $uniqueIndexes, $replaced[$idxKey] ?? []); + foreach ($owners as $index => $values) { + if (\array_intersect_key($values, $signatures[$index] ?? []) !== []) { + throw new UniqueException(UniqueException::MESSAGE); } } + } + } - foreach ($deleted as $documentId => $deleteEntry) { - $deletedDocKey = $deleteEntry['docKey']; - $this->journal('deleteDoc', [ - 'collection' => $collection, - 'id' => (string) $documentId, - 'payload' => $deleteEntry['payload'], - 'docKey' => $deletedDocKey, - 'idxKey' => $idxKey, - ]); - $this->clearPermissions($collection, (string) $documentId); - $redis->del($deletedDocKey); - $redis->sRem($idxKey, \strtolower((string) $documentId)); + /** + * Rejects an upsert batch as writing its documents one after another would: each is checked against the stored + * documents and the batch documents before it, and a document that replaces a stored one frees its id's values. + * + * @param list $writes + */ + private function enforceUniqueIndexesInOrder(RedisClient $client, string $collection, array $writes): void + { + $uniqueIndexes = $this->uniqueIndexAttributes($client, $collection); + if ($uniqueIndexes === []) { + return; + } + + $sharedTables = $this->hasSharedTables(); + $owners = []; + $held = []; + foreach ($writes as $write) { + $tenant = $sharedTables ? ($write->document->getTenant() ?? $this->getTenant()) : null; + $idxKey = $this->idxKey($collection, $tenant); + if (! isset($owners[$idxKey])) { + [$owners[$idxKey], $held[$idxKey]] = $this->storedUniqueValues($client, $collection, $idxKey, $tenant, $uniqueIndexes); } - // Permission-only cleanup for ids the caller listed but that did - // not match by sequence — mirrors Memory adapter semantics. - foreach ($permissionIds as $permissionId) { - $documentId = (string) $permissionId; - if (isset($deleted[$documentId])) { - continue; + $id = \strtolower($write->id); + $signatures = $this->uniqueSignatures($write->document, $uniqueIndexes, $tenant); + foreach ($signatures as $index => $signature) { + $owner = $owners[$idxKey][$index][$signature] ?? null; + if ($owner !== null && ($write->payload === null || $owner !== $id)) { + throw new UniqueException(UniqueException::MESSAGE); } - $this->clearPermissions($collection, $documentId); } - return \count($deleted); - }); + foreach ($held[$idxKey][$id] ?? [] as $index => $signature) { + unset($owners[$idxKey][$index][$signature]); + } + $held[$idxKey][$id] = $signatures; + foreach ($signatures as $index => $signature) { + $owners[$idxKey][$index][$signature] = $id; + } + } } - public function increaseDocumentAttribute( - string $collection, - string $id, - string $attribute, - int|float $value, - string $updatedAt, - int|float|null $min = null, - int|float|null $max = null - ): bool { - $collection = $this->filter($collection); - $docKey = $this->docKey($collection, $id); + /** + * The unique values the stored documents of one tenant's bucket hold: by index and value the lowercased id of the + * document holding it, and by that id its values. + * + * @param array> $uniqueIndexes + * @param array $skipped lowercased ids whose stored documents are left out + * @return array{array>, array>} + */ + private function storedUniqueValues(RedisClient $client, string $collection, string $idxKey, int|string|null $tenant, array $uniqueIndexes, array $skipped = []): array + { + /** @var array|false $docIds */ + $docIds = $client->sMembers($idxKey); + $ids = []; + $docKeys = []; + foreach (\is_array($docIds) ? $docIds : [] as $docId) { + $id = \strtolower((string) $docId); + if (isset($skipped[$id])) { + continue; + } + $ids[] = $id; + $docKeys[] = $this->docKey($collection, (string) $docId, $tenant); + } + if ($docKeys === []) { + return [[], []]; + } - return $this->tx(function (RedisClient $redis) use ($collection, $id, $attribute, $value, $updatedAt, $min, $max, $docKey): bool { - $payload = $redis->get($docKey); + $owners = []; + $held = []; + /** @var array|false $payloads */ + $payloads = $client->mGet($docKeys); + foreach (\is_array($payloads) ? $payloads : [] as $position => $payload) { if (! \is_string($payload) || $payload === '') { - throw new NotFoundException('Document not found'); + continue; + } + $existing = $this->decode($payload); + if ($this->hasSharedTables() && $existing->getTenant() !== $tenant) { + continue; } + $id = $ids[$position]; + $held[$id] = $this->uniqueSignatures($existing, $uniqueIndexes, $tenant); + foreach ($held[$id] as $index => $signature) { + $owners[$index][$signature] = $id; + } + } - $document = $this->decode($payload); - $current = $document->getAttribute($attribute); - $current = \is_numeric($current) ? $current + 0 : 0; + return [$owners, $held]; + } - // Mirrors MariaDB's bound semantics — silent no-op when bounds - // exclude the row. Caller has pre-adjusted bounds by $value. - if (! \is_null($min) && $current < $min) { - return true; + /** + * @return array> + */ + private function uniqueIndexAttributes(RedisClient $client, string $collection): array + { + $uniqueIndexes = []; + foreach ($this->readIndexesField($client, $this->key($this->ns(), 'meta', $collection)) as $index) { + if (($index['type'] ?? '') !== IndexType::Unique->value) { + continue; } - if (! \is_null($max) && $current > $max) { - return true; + $attributes = $index['attributes'] ?? []; + if (empty($attributes) || ! \is_array($attributes)) { + continue; } + $names = []; + foreach ($attributes as $attribute) { + if (\is_string($attribute) && $attribute !== '') { + $names[] = $attribute; + } + } + if ($names !== []) { + $uniqueIndexes[] = $names; + } + } - $document->setAttribute($attribute, $current + $value); - $document->setAttribute('$updatedAt', $updatedAt); - - $redis->set($docKey, $this->encode($document)); + return $uniqueIndexes; + } - $this->journal('updateDoc', [ - 'collection' => $collection, - 'id' => $id, - 'newId' => $id, - 'payload' => $payload, - 'docKey' => $docKey, - ]); + /** + * Signatures of the unique values a document holds, by index position. An index where the + * document holds a null is left out: nulls never collide. + * + * @param array> $uniqueIndexes + * @return array + */ + private function uniqueSignatures(Document $document, array $uniqueIndexes, int|string|null $tenant): array + { + $signatures = []; + foreach ($uniqueIndexes as $index => $attributes) { + $signature = []; + foreach ($attributes as $attribute) { + $value = $this->resolveDocumentAttribute($document, $attribute); + if ($value === null) { + continue 2; + } + $signature[] = $this->normalizeIndexValue($value); + } + if ($this->hasSharedTables()) { + \array_unshift($signature, $tenant); + } + $signatures[$index] = \serialize($signature); + } - return true; - }); + return $signatures; } - // === @architect:T30 end === + private function purgeCollectionKeys(RedisClient $client, string $namespace, string $database, string $collection): void + { + $collection = $this->filter($collection); + $prefix = $this->nsFor($namespace, $database); + $grantsKey = $this->grantsKey($prefix, $collection); + /** @var array|false $registered */ + $registered = $client->sMembers($grantsKey); + $keys = \is_array($registered) ? $registered : []; + $buckets = [null, ...$this->tenantBuckets($client, $prefix, 'idx', $collection), ...$this->tenantBuckets($client, $prefix, 'seq', $collection)]; + foreach (\array_unique($buckets) as $bucket) { + $idxKey = $this->scopedKey($prefix, 'idx', $bucket, $collection); + $keys[] = $idxKey; + $keys[] = $this->scopedKey($prefix, 'seq', $bucket, $collection); + /** @var array|false $docIds */ + $docIds = $client->sMembers($idxKey); + if (! \is_array($docIds) || $docIds === []) { + continue; + } + $permDocKeys = []; + foreach ($docIds as $docId) { + $keys[] = $this->scopedKey($prefix, 'doc', $bucket, $collection, (string) $docId); + $permDocKeys[] = $this->scopedKey($prefix, 'perm', $bucket, 'doc', $collection, (string) $docId); + } + \array_push($keys, ...$permDocKeys, ...$this->roleSetKeys($client, $prefix, $bucket, $collection, $permDocKeys)); + } - // === @architect:T40 owns: indexes + queries + counts === + $keys[] = $this->key($prefix, 'meta', $collection); + $keys[] = $grantsKey; + foreach (\array_chunk(\array_values(\array_unique($keys)), self::SCAN_BATCH_SIZE) as $batch) { + $client->del(...$batch); + } + } - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool + private function grantsKey(string $prefix, string $collection): string { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); + return $this->key($prefix, 'grants', $collection); + } - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + /** + * @param array $permDocKeys + * @return array + */ + private function roleSetKeys(RedisClient $client, string $prefix, ?string $bucket, string $collection, array $permDocKeys): array + { + if ($permDocKeys === []) { + return []; } - return $this->tx(function (RedisClient $client) use ($metaKey, $collection, $id, $type, $attributes, $lengths, $orders): bool { - $indexes = $this->readIndexesField($client, $metaKey); - - foreach ($indexes as $existing) { - if (($existing['$id'] ?? $existing['key'] ?? null) === $id) { - throw new DuplicateException('Index already exists'); - } - } + $client->multi(\Redis::PIPELINE); + foreach ($permDocKeys as $permDocKey) { + $client->hGetAll($permDocKey); + } + $grantsByDocument = $client->exec(); - // Unique-index pre-flight: scan existing documents for collisions so - // index creation fails up-front rather than silently allowing - // duplicate values to coexist under a "unique" constraint. - if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) { - $idxKey = $this->idxKey($collection); - /** @var array $docIds */ - $docIds = $client->sMembers($idxKey); - if (! empty($docIds)) { - $sharedTables = $this->getSharedTables(); - $currentTenant = $sharedTables ? $this->getTenant() : null; - // Single mGet round trip instead of N sequential GETs so - // unique-index creation on a populated collection scales - // with payload size rather than RTT count. - $docKeys = []; - foreach ($docIds as $docId) { - $docKeys[] = $this->docKey($collection, (string) $docId); - } - /** @var array $payloads */ - $payloads = $client->mGet($docKeys); - $seen = []; - foreach ($payloads as $payload) { - if (! \is_string($payload)) { - continue; - } - $document = $this->decode($payload); - // Under shared tables the inverted-index set fans - // across every tenant; only probe rows that belong - // to the active tenant so cross-tenant rows don't - // produce spurious collisions. - if ($sharedTables) { - $rowTenant = $document->getAttribute('$tenant'); - if ($rowTenant !== $currentTenant) { - continue; - } - } - $signature = []; - $hasNull = false; - foreach ($attributes as $attribute) { - $value = $this->resolveDocumentAttribute($document, (string) $attribute); - if ($value === null) { - $hasNull = true; - break; - } - $signature[] = $this->normalizeIndexValue($value); - } - if ($hasNull) { - continue; - } - if ($sharedTables) { - \array_unshift($signature, $currentTenant); - } - $hash = \serialize($signature); - if (isset($seen[$hash])) { - throw new UniqueException('Cannot create unique index: existing rows already contain duplicate values'); - } - $seen[$hash] = true; + $keys = []; + foreach (\is_array($grantsByDocument) ? $grantsByDocument : [] as $grants) { + foreach (\is_array($grants) ? $grants : [] as $role => $letters) { + foreach (\explode(',', \is_string($letters) ? $letters : '') as $letter) { + if ($letter !== '') { + $keys[] = $this->scopedKey($prefix, 'perm', $bucket, $collection, $letter, (string) $role); } } } + } + + return $keys; + } + + /** + * The registered permission keys of the collection that belong to the tenant bucket. A bucket + * never contains the separator, so one bucket's scope is never a prefix of another's. + * + * @return array + */ + private function registeredGrantKeys(RedisClient $client, string $prefix, ?string $bucket, string $collection): array + { + /** @var array|false $registered */ + $registered = $client->sMembers($this->grantsKey($prefix, $collection)); + $keys = \is_array($registered) ? $registered : []; - $indexes[] = [ - '$id' => $id, - 'key' => $id, - 'type' => $type, - 'attributes' => \array_values($attributes), - 'lengths' => \array_values($lengths), - 'orders' => \array_values($orders), - ]; + if ($bucket === null) { + return $keys; + } - $client->hSet( - $metaKey, - 'indexes', - \json_encode($indexes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); + $scope = $this->scopedKey($prefix, 'perm', $bucket).self::SEP; - return true; - }); + return \array_values(\array_filter($keys, static fn (string $key): bool => \str_starts_with($key, $scope))); } - public function deleteIndex(string $collection, string $id): bool + private function scopedKey(string $prefix, string $family, ?string $bucket, string ...$parts): string { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); + $scope = $bucket === null ? [] : ['t', $bucket]; - if ((bool) $this->client->exists($metaKey) === false) { - return true; - } + return $this->key($prefix, $family, ...$scope, ...$parts); + } - return $this->tx(function (RedisClient $client) use ($metaKey, $id): bool { - $indexes = $this->readIndexesField($client, $metaKey); - $filtered = []; - foreach ($indexes as $index) { - if (($index['$id'] ?? $index['key'] ?? null) === $id) { + /** + * Tenant buckets that hold a {family}:t:{bucket}:{collection} key. A bucket never contains the + * separator, so a key the pattern also matches for another layout is skipped. + * + * @return array + */ + private function tenantBuckets(RedisClient $client, string $prefix, string $family, string $collection): array + { + $head = $this->key($prefix, $family, 't').self::SEP; + $tail = self::SEP.$collection; + $buckets = []; + $cursor = null; + do { + /** @var array|false $batch */ + $batch = $client->scan($cursor, $head.'*'.$tail, self::SCAN_BATCH_SIZE); + foreach (\is_array($batch) ? $batch : [] as $key) { + if (! \str_starts_with($key, $head) || ! \str_ends_with($key, $tail)) { continue; } - $filtered[] = $index; + $bucket = \substr($key, \strlen($head), -\strlen($tail)); + if ($bucket !== '' && ! \str_contains($bucket, self::SEP)) { + $buckets[] = $bucket; + } } + } while ($cursor !== 0 && $cursor !== null); - $client->hSet( - $metaKey, - 'indexes', - \json_encode(\array_values($filtered), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - - return true; - }); + return $buckets; } - public function renameIndex(string $collection, string $old, string $new): bool + private function computeCollectionSize(string $collection): int { $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); - $metaKey = $this->key($this->ns(), 'meta', $collection); + $prefix = $this->ns(); + $metaKey = $this->key($prefix, 'meta', $collection); if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + return 0; } - return $this->tx(function (RedisClient $client) use ($metaKey, $old, $new): bool { - $indexes = $this->readIndexesField($client, $metaKey); - $changed = false; - foreach ($indexes as $i => $index) { - if (($index['$id'] ?? $index['key'] ?? null) === $old) { - $indexes[$i]['$id'] = $new; - $indexes[$i]['key'] = $new; - $changed = true; - break; - } - } + $bucket = $this->tenantBucket(); + $idxKey = $this->idxKey($collection); + $keys = [$metaKey, $idxKey]; - if (! $changed) { - return true; - } + /** @var array|false $docIds */ + $docIds = $this->client->sMembers($idxKey); + $permDocKeys = []; + foreach (\is_array($docIds) ? $docIds : [] as $docId) { + $keys[] = $this->docKey($collection, (string) $docId); + $permDocKeys[] = $this->permDocKey($collection, (string) $docId); + } + \array_push( + $keys, + ...$permDocKeys, + ...$this->roleSetKeys($this->client, $prefix, $bucket, $collection, $permDocKeys), + ...$this->registeredGrantKeys($this->client, $prefix, $bucket, $collection), + ); - $client->hSet( - $metaKey, - 'indexes', - \json_encode(\array_values($indexes), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); + $total = 0; + foreach (\array_unique($keys) as $key) { + $total += $this->measureKey($key); + } - return true; - }); + return $total; } - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + private function measureKey(string $key): int { - $collectionId = $this->filter($collection->getId()); - $metaKey = $this->key($this->ns(), 'meta', $collectionId); - - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + try { + /** @var int|false|null $usage */ + $usage = $this->client->rawCommand('MEMORY', 'USAGE', $key); + if (\is_int($usage)) { + return $usage; + } + } catch (\Throwable) { + // Fall through to the structural fallback below. } - return $this->tx(function (RedisClient $client) use ($collectionId, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission): array { - $documents = $this->loadCollectionDocuments($client, $collectionId, $forPermission); - $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); - $documents = $this->orderDocuments($documents, $orderAttributes, $orderTypes, $cursorDirection); - $documents = $this->cursorDocuments($documents, $orderAttributes, $orderTypes, $cursor, $cursorDirection); + $type = $this->client->type($key); + switch ($type) { + case RedisClient::REDIS_STRING: + $value = $this->client->get($key); - if (! \is_null($offset)) { - $documents = \array_slice($documents, $offset); - } - if (! \is_null($limit)) { - $documents = \array_slice($documents, 0, $limit); - } + return \is_string($value) ? \strlen($value) + \strlen($key) : 0; + case RedisClient::REDIS_HASH: + $entries = $this->client->hGetAll($key); + $bytes = \strlen($key); + if (\is_array($entries)) { + foreach ($entries as $field => $value) { + $bytes += \strlen((string) $field) + \strlen((string) $value); + } + } - $selections = $this->extractSelectionsFromQueries($queries); - if (! empty($selections)) { - $projected = []; - foreach ($documents as $document) { - $projected[] = $this->projectDocument($document, $selections); + return $bytes; + case RedisClient::REDIS_SET: + $members = $this->client->sMembers($key); + $bytes = \strlen($key); + if (\is_array($members)) { + foreach ($members as $member) { + $bytes += \strlen((string) $member); + } } - $documents = $projected; - } - if ($cursorDirection === Database::CURSOR_BEFORE) { - $documents = \array_reverse($documents); - } + return $bytes; + default: + return 0; + } + } - return $documents; + private function registerRelationshipField(string $collection, string $field): void + { + $collection = $this->filter($collection); + $field = $this->filter($field); + $metaKey = $this->key($this->ns(), 'meta', $collection); + + if ((bool) $this->client->exists($metaKey) === false) { + return; + } + + $record = [ + Document::ID => $field, + 'key' => $field, + 'type' => ColumnType::Relationship->value, + 'size' => 0, + 'signed' => true, + 'array' => false, + 'required' => false, + ]; + + $this->transaction(function (RedisClient $client) use ($metaKey, $record): void { + $attributes = $this->readAttributesField($client, $metaKey); + $attributes = $this->upsertAttributeRecord($attributes, $record); + $client->hSet($metaKey, 'attrs', \json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); }); } - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + private function renameDocumentField(string $collection, string $oldKey, string $newKey): void { - $collectionId = $this->filter($collection->getId()); - $metaKey = $this->key($this->ns(), 'meta', $collectionId); + $collection = $this->filter($collection); + $oldKey = $this->filter($oldKey); + $newKey = $this->filter($newKey); - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + if ($oldKey === $newKey) { + return; } - return $this->tx(function (RedisClient $client) use ($collectionId, $attribute, $queries, $max): float|int { - $documents = $this->loadCollectionDocuments($client, $collectionId, Database::PERMISSION_READ); - $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); + $idxKey = $this->idxKey($collection); - if (! \is_null($max)) { - $documents = \array_slice($documents, 0, $max); + $this->transaction(function (RedisClient $client) use ($collection, $oldKey, $newKey, $idxKey): void { + /** @var array|false $docIds */ + $docIds = $client->sMembers($idxKey); + if (! \is_array($docIds) || $docIds === []) { + return; } - $sum = 0; - $isFloat = false; - foreach ($documents as $document) { - $value = $this->resolveDocumentAttribute($document, $attribute); - if ($value === null) { + foreach ($docIds as $docId) { + $docKey = $this->docKey($collection, $docId); + $payload = $client->get($docKey); + if (! \is_string($payload) || $payload === '') { continue; } - if (\is_float($value)) { - $isFloat = true; - } - if (\is_numeric($value)) { - $sum += $value; + + /** @var array $decoded */ + $decoded = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); + if (! \array_key_exists($oldKey, $decoded)) { + continue; } + + $decoded[$newKey] = $decoded[$oldKey]; + unset($decoded[$oldKey]); + + $client->set( + $docKey, + \json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + ); + } + }); + } + + private function dropDocumentField(string $collection, string $field): void + { + $collection = $this->filter($collection); + $field = $this->filter($field); + $idxKey = $this->idxKey($collection); + + $this->transaction(function (RedisClient $client) use ($collection, $field, $idxKey): void { + /** @var array|false $docIds */ + $docIds = $client->sMembers($idxKey); + if (! \is_array($docIds) || $docIds === []) { + return; } - return $isFloat ? (float) $sum : (int) $sum; + foreach ($docIds as $docId) { + $docKey = $this->docKey($collection, $docId); + $payload = $client->get($docKey); + if (! \is_string($payload) || $payload === '') { + continue; + } + + /** @var array $decoded */ + $decoded = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); + if (! \array_key_exists($field, $decoded)) { + continue; + } + + unset($decoded[$field]); + + $client->set( + $docKey, + \json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + ); + } }); } - public function count(Document $collection, array $queries = [], ?int $max = null): int + private function resolveJunctionCollection(string $collection, string $relatedCollection, RelationshipSide $side): ?string { - $collectionId = $this->filter($collection->getId()); - $metaKey = $this->key($this->ns(), 'meta', $collectionId); + $collectionDoc = $this->loadMetadataDocument($collection); + $relatedDoc = $this->loadMetadataDocument($relatedCollection); + if ($collectionDoc === null || $relatedDoc === null) { + return null; + } - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + $collectionSequence = $collectionDoc->getSequence(); + $relatedSequence = $relatedDoc->getSequence(); + if ($collectionSequence === null || $relatedSequence === null || $collectionSequence === '' || $relatedSequence === '') { + return null; } - // Fast path: no query filters, authorization disabled, and shared - // tables off means the `idx:{collection}` SET cardinality matches the - // visible doc count directly. Under shared tables the SET is shared - // across tenants — `sCard` would return the union count, leaking - // cross-tenant rows — so we fall through to the slow path which - // hydrates and tenant-filters via `loadCollectionDocuments`. - // Authorization-on also requires hydration so the permission filter - // actually runs. - // TODO: this path still scans the full collection when queries are - // present — acceptable parity with Memory, but a known scaling limit - // and unsuitable for large production collections. - if ( - empty($queries) - && $this->authorization->getStatus() === false - && $this->getSharedTables() === false - ) { - $idxKey = $this->idxKey($collectionId); - $cardinality = $this->client->sCard($idxKey); - if (\is_int($cardinality)) { - return $max === null ? $cardinality : \min($max, $cardinality); - } + return $side === RelationshipSide::Parent + ? '_'.$collectionSequence.'_'.$relatedSequence + : '_'.$relatedSequence.'_'.$collectionSequence; + } + + private function loadMetadataDocument(string $collection): ?Document + { + $id = $this->filter($collection); + $payload = $this->client->get($this->docKey(Database::METADATA, $id)); + if ((! \is_string($payload) || $payload === '') && $this->hasSharedTables()) { + $payload = $this->client->get($this->docKey(Database::METADATA, $id, '_')); + } + if (! \is_string($payload) || $payload === '') { + return null; } - return $this->tx(function (RedisClient $client) use ($collectionId, $queries, $max): int { - $documents = $this->loadCollectionDocuments($client, $collectionId, Database::PERMISSION_READ); - $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); + return $this->decode($payload); + } - if (! \is_null($max)) { - $documents = \array_slice($documents, 0, $max); - } + private function surfaceRelationshipAttributes(string $collection, Document $document): Document + { + if ($collection === Database::METADATA) { + return $document; + } + + $metaKey = $this->key($this->ns(), 'meta', $this->filter($collection)); + $attributes = $this->readAttributesField($this->client, $metaKey); + $relationshipKeys = $this->extractRelationshipKeys($attributes); + if ($relationshipKeys === []) { + return $document; + } - return \count($documents); - }); + return $this->surfaceRelationshipAttributesUsing($relationshipKeys, $document); } - public function getSchemaIndexes(string $collection): array + /** + * @param array $relationshipKeys + */ + private function surfaceRelationshipAttributesUsing(array $relationshipKeys, Document $document): Document { - // Mirror Memory: Redis maintains no on-disk schema, so the adapter - // exposes no schema-level indexes. Index metadata lives on the - // collection Document and is read by Database via getCollection(). - return []; - } + if ($relationshipKeys === []) { + return $document; + } - public function getCountOfIndexes(Document $collection): int - { - return \count($collection->getAttribute('indexes', [])) + \count(Database::INTERNAL_INDEXES); + $payload = $document->getArrayCopy(); + foreach ($relationshipKeys as $key) { + if (! \array_key_exists($key, $payload)) { + $document->setAttribute($key, null); + } + } + + return $document; } /** - * Read and JSON-decode the indexes field on a collection meta hash. - * - * @return array> + * @param array> $attributes + * @return array */ - private function readIndexesField(RedisClient $client, string $metaKey): array + private function extractRelationshipKeys(array $attributes): array { - $raw = $client->hGet($metaKey, 'indexes'); - if (! \is_string($raw) || $raw === '') { - return []; - } - $decoded = \json_decode($raw, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - if (! \is_array($decoded)) { - return []; + $keys = []; + foreach ($attributes as $attribute) { + if (($attribute['type'] ?? null) !== ColumnType::Relationship->value) { + continue; + } + $key = $this->recordIdentifier($attribute); + if ($key === '') { + continue; + } + $keys[] = $key; } - /** @var array> $decoded */ - return $decoded; + return $keys; } /** - * Hydrate every document in the collection's id-set, applying tenant and - * permission filters. Returns Documents in insertion-set order. - * * @return array */ - private function loadCollectionDocuments(RedisClient $client, string $collection, string $forPermission): array + private function loadCollectionDocuments(RedisClient $client, string $collection, PermissionType $forPermission): array { $idxKey = $this->idxKey($collection); - /** @var array $ids */ + /** @var array|false $ids */ $ids = $client->sMembers($idxKey); - if (empty($ids)) { + if (! \is_array($ids) || empty($ids)) { return []; } - // Permission filter through the T50-owned hook before fetching to - // avoid round-tripping payloads we will discard anyway. if ($this->authorization->getStatus()) { $ids = $this->applyPermissionFilter($collection, $ids, $forPermission); if (empty($ids)) { @@ -3004,13 +2805,10 @@ private function loadCollectionDocuments(RedisClient $client, string $collection /** @var array $payloads */ $payloads = $client->mGet($keys); - $sharedTables = $this->getSharedTables(); + $sharedTables = $this->hasSharedTables(); $tenant = $sharedTables ? $this->getTenant() : null; $allowNullTenant = $sharedTables && $collection === Database::METADATA; - // Read meta.attrs once and cache the relationship-key list across the - // decode loop — `surfaceRelationshipAttributes` would re-read meta on - // every document otherwise. $relationshipKeys = []; if ($collection !== Database::METADATA) { $metaKey = $this->key($this->ns(), 'meta', $this->filter($collection)); @@ -3026,7 +2824,7 @@ private function loadCollectionDocuments(RedisClient $client, string $collection $document = $this->decode($payload); if ($sharedTables) { - $rowTenant = $document->getAttribute('$tenant'); + $rowTenant = $document->getAttribute(Document::TENANT); $crossTenant = $rowTenant !== $tenant && ! ($allowNullTenant && $rowTenant === null); if ($crossTenant) { @@ -3045,10 +2843,8 @@ private function loadCollectionDocuments(RedisClient $client, string $collection } /** - * Apply non-pagination query filters to the supplied documents. - * - * @param array $documents - * @param array $queries + * @param array $documents + * @param array $queries * @return array */ private function filterDocumentsByQueries(string $collection, array $documents, array $queries): array @@ -3061,14 +2857,14 @@ private function filterDocumentsByQueries(string $collection, array $documents, foreach ($queries as $query) { $method = $query->getMethod(); if (\in_array($method, [ - Query::TYPE_SELECT, - Query::TYPE_ORDER_ASC, - Query::TYPE_ORDER_DESC, - Query::TYPE_ORDER_RANDOM, - Query::TYPE_LIMIT, - Query::TYPE_OFFSET, - Query::TYPE_CURSOR_AFTER, - Query::TYPE_CURSOR_BEFORE, + Method::Select, + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, + Method::Limit, + Method::Offset, + Method::CursorAfter, + Method::CursorBefore, ], true)) { continue; } @@ -3096,15 +2892,11 @@ private function filterDocumentsByQueries(string $collection, array $documents, return $output; } - /** - * Resolve a single Query against a Document, mirroring Memory's matches() - * but operating on the Document's natural `$id`/`$tenant`/etc. layout. - */ private function matchesDocument(Document $document, Query $query): bool { $method = $query->getMethod(); - if ($method === Query::TYPE_AND) { + if ($method === Method::And) { foreach ($query->getValues() as $sub) { if (! ($sub instanceof Query) || ! $this->matchesDocument($document, $sub)) { return false; @@ -3114,7 +2906,7 @@ private function matchesDocument(Document $document, Query $query): bool return true; } - if ($method === Query::TYPE_OR) { + if ($method === Method::Or) { foreach ($query->getValues() as $sub) { if ($sub instanceof Query && $this->matchesDocument($document, $sub)) { return true; @@ -3133,8 +2925,14 @@ private function matchesDocument(Document $document, Query $query): bool } switch ($method) { - case Query::TYPE_EQUAL: + case Method::Equal: + if ($value === null) { + return false; + } foreach ($values as $candidate) { + if ($candidate === null) { + continue; + } if ($this->valuesEqual($value, $candidate)) { return true; } @@ -3142,11 +2940,14 @@ private function matchesDocument(Document $document, Query $query): bool return false; - case Query::TYPE_NOT_EQUAL: + case Method::NotEqual: if ($value === null) { return false; } foreach ($values as $candidate) { + if ($candidate === null) { + return false; + } if ($this->valuesEqual($value, $candidate)) { return false; } @@ -3154,56 +2955,56 @@ private function matchesDocument(Document $document, Query $query): bool return true; - case Query::TYPE_LESSER: + case Method::LessThan: return $value !== null && $value < $values[0]; - case Query::TYPE_LESSER_EQUAL: + case Method::LessThanEqual: return $value !== null && $value <= $values[0]; - case Query::TYPE_GREATER: + case Method::GreaterThan: return $value !== null && $value > $values[0]; - case Query::TYPE_GREATER_EQUAL: + case Method::GreaterThanEqual: return $value !== null && $value >= $values[0]; - case Query::TYPE_IS_NULL: + case Method::IsNull: return $value === null; - case Query::TYPE_IS_NOT_NULL: + case Method::IsNotNull: return $value !== null; - case Query::TYPE_BETWEEN: + case Method::Between: return $value !== null && $value >= $values[0] && $value <= $values[1]; - case Query::TYPE_NOT_BETWEEN: + case Method::NotBetween: if ($value === null) { return false; } return $value < $values[0] || $value > $values[1]; - case Query::TYPE_STARTS_WITH: - return \is_string($value) && \is_string($values[0] ?? null) && \str_starts_with($value, (string) $values[0]); + case Method::StartsWith: + return \is_string($value) && isset($values[0]) && \is_string($values[0]) && \str_starts_with($value, $values[0]); - case Query::TYPE_NOT_STARTS_WITH: + case Method::NotStartsWith: if ($value === null) { return false; } - return ! \is_string($value) || ! \is_string($values[0] ?? null) || ! \str_starts_with($value, (string) $values[0]); + return ! \is_string($value) || ! isset($values[0]) || ! \is_string($values[0]) || ! \str_starts_with($value, $values[0]); - case Query::TYPE_ENDS_WITH: - return \is_string($value) && \is_string($values[0] ?? null) && \str_ends_with($value, (string) $values[0]); + case Method::EndsWith: + return \is_string($value) && isset($values[0]) && \is_string($values[0]) && \str_ends_with($value, $values[0]); - case Query::TYPE_NOT_ENDS_WITH: + case Method::NotEndsWith: if ($value === null) { return false; } - return ! \is_string($value) || ! \is_string($values[0] ?? null) || ! \str_ends_with($value, (string) $values[0]); + return ! \is_string($value) || ! isset($values[0]) || ! \is_string($values[0]) || ! \str_ends_with($value, $values[0]); - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: + case Method::Contains: + case Method::ContainsAny: $haystack = $this->coerceArrayValue($value); if ($haystack === null && \is_string($value)) { foreach ($values as $needle) { @@ -3227,14 +3028,14 @@ private function matchesDocument(Document $document, Query $query): bool return false; - case Query::TYPE_NOT_CONTAINS: + case Method::NotContains: if ($value === null) { return false; } - return ! $this->matchesDocument($document, new Query(Query::TYPE_CONTAINS, $attribute, $values)); + return ! $this->matchesDocument($document, new Query(Method::Contains, $attribute, $values)); - case Query::TYPE_CONTAINS_ALL: + case Method::ContainsAll: $haystack = $this->coerceArrayValue($value); if (! \is_array($haystack)) { return false; @@ -3254,48 +3055,44 @@ private function matchesDocument(Document $document, Query $query): bool return true; - case Query::TYPE_SEARCH: + case Method::Search: if (! \is_string($value)) { return false; } - $needle = (string) ($values[0] ?? ''); + $needle = $this->stringOrEmpty($values[0] ?? ''); if ($needle === '') { return false; } return $this->matchesFulltextRedis($value, $needle); - case Query::TYPE_NOT_SEARCH: + case Method::NotSearch: if ($value === null) { return false; } if (! \is_string($value)) { return true; } - $needle = (string) ($values[0] ?? ''); + $needle = $this->stringOrEmpty($values[0] ?? ''); if ($needle === '') { return true; } return ! $this->matchesFulltextRedis($value, $needle); - case Query::TYPE_REGEX: + case Method::Regex: if (! \is_string($value)) { return false; } - $pattern = (string) ($values[0] ?? ''); - $delimited = '#' . \str_replace('#', '\\#', $pattern) . '#u'; + $pattern = $this->stringOrEmpty($values[0] ?? ''); + $delimited = '#'.\str_replace('#', '\\#', $pattern).'#u'; return @\preg_match($delimited, $value) === 1; } - throw new QueryException('Query method not supported by Redis adapter: ' . $method); + throw new QueryException('Query method not supported by Redis adapter: '.$method->value); } - /** - * Object-attribute query semantics — JSONB-style containment used for - * Postgres-flavoured equal/contains operators against decoded objects. - */ private function matchesDocumentObject(mixed $value, Query $query): bool { $haystack = $this->decodeObjectishValue($value); @@ -3303,7 +3100,7 @@ private function matchesDocumentObject(mixed $value, Query $query): bool $method = $query->getMethod(); switch ($method) { - case Query::TYPE_EQUAL: + case Method::Equal: if ($haystack === null) { return false; } @@ -3315,7 +3112,7 @@ private function matchesDocumentObject(mixed $value, Query $query): bool return false; - case Query::TYPE_NOT_EQUAL: + case Method::NotEqual: if ($haystack === null) { return false; } @@ -3327,8 +3124,8 @@ private function matchesDocumentObject(mixed $value, Query $query): bool return true; - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: + case Method::Contains: + case Method::ContainsAny: if ($haystack === null) { return false; } @@ -3340,7 +3137,7 @@ private function matchesDocumentObject(mixed $value, Query $query): bool return false; - case Query::TYPE_CONTAINS_ALL: + case Method::ContainsAll: if ($haystack === null) { return false; } @@ -3352,7 +3149,7 @@ private function matchesDocumentObject(mixed $value, Query $query): bool return true; - case Query::TYPE_NOT_CONTAINS: + case Method::NotContains: if ($haystack === null) { return false; } @@ -3364,41 +3161,38 @@ private function matchesDocumentObject(mixed $value, Query $query): bool return true; - case Query::TYPE_IS_NULL: + case Method::IsNull: return $value === null; - case Query::TYPE_IS_NOT_NULL: + case Method::IsNotNull: return $value !== null; } - throw new QueryException('Query method ' . $method . ' not supported for object attributes'); + throw new QueryException('Query method '.$method->value.' not supported for object attributes'); } /** - * Stable ordering across Documents. Random short-circuits via shuffle to - * preserve usort transitivity; absent attributes fall back to $sequence. - * - * @param array $documents - * @param array $orderAttributes - * @param array $orderTypes + * @param array $documents + * @param array $orderAttributes + * @param array $orderTypes * @return array */ - private function orderDocuments(array $documents, array $orderAttributes, array $orderTypes, string $cursorDirection): array + private function orderDocuments(array $documents, array $orderAttributes, array $orderTypes, CursorDirection $cursorDirection): array { foreach ($orderTypes as $type) { - if ($type === Database::ORDER_RANDOM) { + if ($type === OrderDirection::Random) { \shuffle($documents); return $documents; } } - $reverse = $cursorDirection === Database::CURSOR_BEFORE; + $reverse = $cursorDirection === CursorDirection::Before; if (empty($orderAttributes)) { \usort($documents, function (Document $a, Document $b) use ($reverse): int { - $av = $a->getAttribute('$sequence', 0); - $bv = $b->getAttribute('$sequence', 0); + $av = $a->getAttribute(Document::SEQUENCE, 0); + $bv = $b->getAttribute(Document::SEQUENCE, 0); $av = \is_numeric($av) ? $av + 0 : 0; $bv = \is_numeric($bv) ? $bv + 0 : 0; if ($av === $bv) { @@ -3413,12 +3207,12 @@ private function orderDocuments(array $documents, array $orderAttributes, array } $directions = []; - foreach ($orderAttributes as $i => $attribute) { - $direction = $orderTypes[$i] ?? Database::ORDER_ASC; + foreach ($orderAttributes as $index => $attribute) { + $direction = $orderTypes[$index] ?? OrderDirection::Asc; if ($reverse) { - $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; + $direction = $direction === OrderDirection::Asc ? OrderDirection::Desc : OrderDirection::Asc; } - $directions[$i] = $direction === Database::ORDER_ASC ? 1 : -1; + $directions[$index] = $direction === OrderDirection::Asc ? 1 : -1; } \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $directions): int { @@ -3446,62 +3240,57 @@ private function orderDocuments(array $documents, array $orderAttributes, array } /** - * Discard documents preceding the supplied cursor on the active sort. - * - * @param array $documents - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor + * @param array $documents + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor * @return array */ - private function cursorDocuments(array $documents, array $orderAttributes, array $orderTypes, array $cursor, string $cursorDirection): array + private function cursorDocuments(array $documents, array $orderAttributes, array $orderTypes, array $cursor, CursorDirection $cursorDirection): array { if (empty($cursor)) { return $documents; } if (empty($orderAttributes)) { - $orderAttributes = ['$sequence']; - $orderTypes = [Database::ORDER_ASC]; + $orderAttributes = [Document::SEQUENCE]; + $orderTypes = [OrderDirection::Asc]; } - $reverse = $cursorDirection === Database::CURSOR_BEFORE; + $reverse = $cursorDirection === CursorDirection::Before; $resolved = []; - foreach ($orderAttributes as $i => $attribute) { - $direction = $orderTypes[$i] ?? Database::ORDER_ASC; + foreach ($orderAttributes as $index => $attribute) { + $direction = $orderTypes[$index] ?? OrderDirection::Asc; if ($reverse) { - $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; + $direction = $direction === OrderDirection::Asc ? OrderDirection::Desc : OrderDirection::Asc; } - $resolved[] = [ - 'attribute' => $attribute, - 'asc' => $direction === Database::ORDER_ASC, - 'ref' => $cursor[$attribute] ?? null, - ]; + $resolved[] = new CursorBoundary($attribute, $direction, $cursor[$attribute] ?? null); } $output = []; foreach ($documents as $document) { foreach ($resolved as $entry) { - $current = $this->resolveDocumentAttribute($document, $entry['attribute']); - $ref = $entry['ref']; - if ($current === $ref) { + $current = $this->resolveDocumentAttribute($document, $entry->field); + $reference = $entry->reference; + $ascending = $entry->direction === OrderDirection::Asc; + if ($current === $reference) { continue; } if ($current === null) { - if (! $entry['asc']) { + if (! $ascending) { $output[] = $document; } continue 2; } - if ($ref === null) { - if ($entry['asc']) { + if ($reference === null) { + if ($ascending) { $output[] = $document; } continue 2; } - if ($entry['asc'] ? ($current > $ref) : ($current < $ref)) { + if ($ascending ? ($current > $reference) : ($current < $reference)) { $output[] = $document; } @@ -3512,16 +3301,8 @@ private function cursorDocuments(array $documents, array $orderAttributes, array return $output; } - /** - * Resolve a dotted attribute path on a Document, falling back to nested - * decoded JSON traversal when the head segment holds a string payload. - */ private function resolveDocumentAttribute(Document $document, string $attribute): mixed { - // Redis stores documents as raw JSON, so attribute keys keep symbols - // (`$`, `.`, etc.) verbatim. Try a direct lookup first — only when the - // literal key misses do we fall back to the filtered alias and then to - // dotted-path traversal (mirrors Memory's `resolveAttributeValue`). if ($document->offsetExists($attribute)) { return $document->getAttribute($attribute); } @@ -3550,9 +3331,6 @@ private function resolveDocumentAttribute(Document $document, string $attribute) return $this->traverseNestedPath($value, $rest); } - /** - * Walk a remaining dotted path through arrays, returning null on miss. - */ private function traverseNestedPath(mixed $value, string $path): mixed { foreach (\explode('.', $path) as $part) { @@ -3571,15 +3349,14 @@ private function traverseNestedPath(mixed $value, string $path): mixed return $value; } - /** - * Normalise a value for unique-index hashing. Booleans collapse to ints - * and numeric strings collapse to numbers so signatures match SQL casts. - */ private function normalizeIndexValue(mixed $value): mixed { if (\is_bool($value)) { return $value ? 1 : 0; } + if (\is_array($value)) { + return \json_encode($value); + } if (\is_string($value) && \is_numeric($value)) { return $value + 0; } @@ -3587,10 +3364,6 @@ private function normalizeIndexValue(mixed $value): mixed return $value; } - /** - * Equal-with-numeric-coercion mirroring Memory::looseEquals — covers the - * "1" == 1 case Database tests rely on. - */ private function valuesEqual(mixed $a, mixed $b): bool { if ($a === $b) { @@ -3604,9 +3377,6 @@ private function valuesEqual(mixed $a, mixed $b): bool } /** - * Decode a CONTAINS-target into an array if possible. Returns null when - * the value is neither an array nor a JSON-encoded array string. - * * @return array|null */ private function coerceArrayValue(mixed $value): ?array @@ -3623,9 +3393,6 @@ private function coerceArrayValue(mixed $value): ?array return null; } - /** - * Decode an object-typed attribute value for JSONB-style containment. - */ private function decodeObjectishValue(mixed $value): mixed { if ($value === null) { @@ -3647,10 +3414,6 @@ private function decodeObjectishValue(mixed $value): mixed return $value; } - /** - * Postgres `@>` JSONB containment in PHP — recursive subset semantics - * with list-element matching for array haystacks. - */ private function jsonContainment(mixed $haystack, mixed $candidate): bool { if (\is_array($haystack) && \array_is_list($haystack)) { @@ -3700,10 +3463,6 @@ private function jsonContainment(mixed $haystack, mixed $candidate): bool return false; } - /** - * Wrap `['skills' => 'typescript']` into `['skills' => ['typescript']]` - * so contains-style probes hit array entries inside the haystack. - */ private function wrapScalarObjectCandidate(mixed $candidate): mixed { if (! \is_array($candidate) || \count($candidate) !== 1) { @@ -3718,11 +3477,6 @@ private function wrapScalarObjectCandidate(mixed $candidate): mixed return [$key => [$value]]; } - /** - * Natural-language fulltext approximation: tokenise on - * whitespace/punctuation, support trailing wildcard prefix matching, and - * honour quoted phrases as case-insensitive substring probes. - */ private function matchesFulltextRedis(string $haystack, string $needle): bool { if (\preg_match('/^"(.*)"$/u', \trim($needle), $matches) === 1) { @@ -3774,18 +3528,14 @@ private function tokenizeForSearch(string $text): array } /** - * Extract user-requested attributes from any TYPE_SELECT queries. Internal - * attributes (prefixed with `$` or `_`) are always preserved — only user - * attributes are subject to projection. - * - * @param array $queries + * @param array $queries * @return array */ - private function extractSelectionsFromQueries(array $queries): array + protected function extractSelections(array $queries): array { $selections = []; foreach ($queries as $query) { - if ($query->getMethod() !== Query::TYPE_SELECT) { + if ($query->getMethod() !== Method::Select) { continue; } foreach ($query->getValues() as $value) { @@ -3799,11 +3549,7 @@ private function extractSelectionsFromQueries(array $queries): array } /** - * Project a Document down to the supplied user-attribute selection. - * `*` short-circuits projection (no filter applied). Internal attributes - * (prefixed `$` / `_`) are always retained. - * - * @param array $selections + * @param array $selections */ private function projectDocument(Document $document, array $selections): Document { @@ -3813,7 +3559,7 @@ private function projectDocument(Document $document, array $selections): Documen $projected = []; foreach ($document->getArrayCopy() as $field => $value) { - if (\is_string($field) && (\str_starts_with($field, '$') || \str_starts_with($field, '_'))) { + if (\str_starts_with($field, '$') || \str_starts_with($field, '_')) { $projected[$field] = $value; continue; @@ -3826,214 +3572,15 @@ private function projectDocument(Document $document, array $selections): Documen return new Document($projected); } - // === @architect:T40 end === - - - - - - // === @architect:T50 owns: permissions + relationships === - - public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool - { - // Redis stores documents as flexible JSON blobs, so the relationship - // "column" is registered on the collection's meta.attrs list rather - // than added as a physical schema column. Mirrors Memory's - // `registerRelationshipField` — minimal record only; the orchestrator - // writes the full options (onDelete / side / related-collection) onto - // the METADATA collection separately. The M2M junction collection - // itself is created by the wrapper via the standard createCollection - // path with explicit attributes. - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - $this->createAttribute($collection, $id, Database::VAR_RELATIONSHIP, 0, true, false, false); - if ($twoWay) { - $this->createAttribute($relatedCollection, $twoWayKey, Database::VAR_RELATIONSHIP, 0, true, false, false); - } - break; - case Database::RELATION_ONE_TO_MANY: - $this->createAttribute($relatedCollection, $twoWayKey, Database::VAR_RELATIONSHIP, 0, true, false, false); - break; - case Database::RELATION_MANY_TO_ONE: - $this->createAttribute($collection, $id, Database::VAR_RELATIONSHIP, 0, true, false, false); - break; - case Database::RELATION_MANY_TO_MANY: - // Junction columns live on the junction collection, which is - // created with explicit attributes by the wrapper. - break; - default: - throw new DatabaseException('Invalid relationship type'); - } - - return true; - } - - public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool - { - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); - $newKey = $newKey !== null ? $this->filter($newKey) : null; - $newTwoWayKey = $newTwoWayKey !== null ? $this->filter($newTwoWayKey) : null; - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($newKey !== null && $newKey !== $key) { - $this->renameAttribute($collection, $key, $newKey); - } - if ($twoWay && $newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { - $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { - $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); - } - } else { - if ($newKey !== null && $newKey !== $key) { - $this->renameAttribute($collection, $key, $newKey); - } - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { - $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); - } - } else { - if ($newKey !== null && $newKey !== $key) { - $this->renameAttribute($collection, $key, $newKey); - } - } - break; - case Database::RELATION_MANY_TO_MANY: - $junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side); - if ($junction !== null) { - if ($newKey !== null && $newKey !== $key) { - $this->renameAttribute($junction, $key, $newKey); - } - if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { - $this->renameAttribute($junction, $twoWayKey, $newTwoWayKey); - } - } - break; - default: - throw new DatabaseException('Invalid relationship type'); - } - - return true; - } - - public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool - { - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); - - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteAttribute($collection, $key); - if ($twoWay) { - $this->deleteAttribute($relatedCollection, $twoWayKey); - } - } else { - $this->deleteAttribute($relatedCollection, $twoWayKey); - if ($twoWay) { - $this->deleteAttribute($collection, $key); - } - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteAttribute($relatedCollection, $twoWayKey); - } else { - $this->deleteAttribute($collection, $key); - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteAttribute($collection, $key); - } else { - $this->deleteAttribute($relatedCollection, $twoWayKey); - } - break; - case Database::RELATION_MANY_TO_MANY: - // Junction collection is dropped by the wrapper via cleanupCollection. - break; - default: - throw new DatabaseException('Invalid relationship type'); - } - - return true; - } - - // === @architect:T50 end === - - - - - - // === @architect:T56 owns: transactions + journal === - - public function startTransaction(): bool - { - $this->journalStack[] = []; - $this->inTransaction++; - - return true; - } - - public function commitTransaction(): bool - { - if ($this->inTransaction === 0) { - return false; - } - - $this->commitJournal(); - $this->inTransaction--; - - return true; - } - - public function rollbackTransaction(): bool - { - if ($this->inTransaction === 0) { - return false; - } - - try { - $this->rollbackJournal(); - $this->inTransaction--; - } catch (\Throwable $e) { - // A failed rollback (mid-replay) leaves the transaction in an - // indeterminate state. Discard all pending journal state so the - // connection is clean for reuse. Both must be cleared together to - // preserve the count($journalStack) === inTransaction invariant: - // resetting only the counter would strand parent frames that later - // transactions merge into, growing the stack without bound. - $this->inTransaction = 0; - $this->journalStack = []; - throw $e; - } - - return true; - } - - // === @architect:T56 end === - /** - * Resolve any Operator-typed attributes against the existing document - * before persisting. Mirrors Memory::applyOperators — non-operator - * values pass through untouched. - * - * @param array $attrs Incoming attributes (may contain Operator instances) - * @param array $existing Decoded document used as the operator's "current" value + * @param array $attributes + * @param array $existing * @return array */ - protected function applyOperators(array $attrs, array $existing): array + protected function applyOperators(array $attributes, array $existing): array { $result = []; - foreach ($attrs as $attribute => $value) { + foreach ($attributes as $attribute => $value) { if (Operator::isOperator($value)) { /** @var Operator $value */ $result[$attribute] = $this->applyOperator($existing[$attribute] ?? null, $value); @@ -4046,138 +3593,122 @@ protected function applyOperators(array $attrs, array $existing): array return $result; } - /** - * Apply a single Operator to a stored value and return the new value. - * Mirrors Memory::applyOperator — the SQL adapters express the same - * semantics in CASE/JSON helpers (see MariaDB::getOperatorSQL). - */ protected function applyOperator(mixed $current, Operator $operator): mixed { $values = $operator->getValues(); $method = $operator->getMethod(); + $exact = BigInt::calculateOutsideNative($method, $current ?? 0, $values[0] ?? 1); + if ($exact !== null) { + $bound = $values[1] ?? null; + if ($method === OperatorType::Modulo || ! \is_numeric($bound) || (\is_float($bound) && ! \is_finite($bound))) { + return $exact; + } + + $limit = BigInt::integralValue($bound); + if ($limit === null) { + throw new OperatorException("Cannot apply {$method->value} operator: max/min limit must be a whole number, got {$bound}"); + } + + return $this->applyNumericLimit( + $current ?? 0, + $exact, + $limit, + \in_array($method, [OperatorType::Increment, OperatorType::Multiply, OperatorType::Power], true) + ); + } switch ($method) { - case Operator::TYPE_INCREMENT: - $by = $values[0] ?? 1; + case OperatorType::Increment: + $by = $this->numericOr($values[0] ?? 1, 1); $max = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - if ($max !== null) { - // Guard: if the RESULT would exceed the max, leave it unchanged. Comparing - // remaining headroom keeps us inside PHP's int range. Must NOT short-circuit - // on `$base >= $max` — a negative $by moves the value down, so an over-max - // base can still land within bound (e.g. 52 + (-5) = 47 <= 50 must apply). - if (($max - $base) < $by) { - return $this->preserveNumericType($base, $base); - } - } + $base = $this->numericOr($current, 0); - return $this->preserveNumericType($base, $base + $by); + return $this->applyNumericLimit($base, $base + $by, $max, true); - case Operator::TYPE_DECREMENT: - $by = $values[0] ?? 1; + case OperatorType::Decrement: + $by = $this->numericOr($values[0] ?? 1, 1); $min = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - if ($min !== null) { - // Guard: leave unchanged only if the RESULT would go below min. Don't - // short-circuit on `$base <= $min` — a negative $by moves the value up. - if (($base - $min) < $by) { - return $this->preserveNumericType($base, $base); - } - } + $base = $this->numericOr($current, 0); - return $this->preserveNumericType($base, $base - $by); + return $this->applyNumericLimit($base, $base - $by, $min, false); - case Operator::TYPE_MULTIPLY: - $by = $values[0] ?? 1; + case OperatorType::Multiply: + $by = $this->numericOr($values[0] ?? 1, 1); $max = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - $result = $base * $by; - if ($max !== null && $result > $max) { - return $this->preserveNumericType($base, $base); - } + $base = $this->numericOr($current, 0); - return $this->preserveNumericType($base, $result); + return $this->applyNumericLimit($base, $base * $by, $max, true); - case Operator::TYPE_DIVIDE: + case OperatorType::Divide: $by = $values[0] ?? 1; $min = $values[1] ?? null; - if ($by == 0) { + if (! \is_numeric($by) || $by == 0) { return $current; } - $base = \is_numeric($current) ? $current + 0 : 0; - $result = $base / $by; - if ($min !== null && $result < $min) { - return $this->preserveNumericType($base, $base); - } + $base = $this->numericOr($current, 0); - return $this->preserveNumericType($base, $result); + return $this->applyNumericLimit($base, $base / ($by + 0), $min, false); - case Operator::TYPE_MODULO: + case OperatorType::Modulo: $by = $values[0] ?? 1; - if ($by == 0) { + if (! \is_numeric($by) || $by == 0) { return $current; } $base = \is_numeric($current) ? (int) $current : 0; return $base % (int) $by; - case Operator::TYPE_POWER: - $by = $values[0] ?? 1; + case OperatorType::Power: + $by = $this->numericOr($values[0] ?? 1, 1); $max = $values[1] ?? null; - $base = \is_numeric($current) ? $current + 0 : 0; - if ($max !== null) { - // Leave the value unchanged for undefined inputs (0 to a negative power, or a - // negative base to a fractional exponent) — they produce INF/NaN, not a number. - if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) { - return $this->preserveNumericType($base, $base); - } - $result = $base ** $by; - // A result that overflows (INF) or exceeds the max also leaves the value as-is. - if (!\is_finite($result) || $result > $max) { - return $this->preserveNumericType($base, $base); + $base = $this->numericOr($current, 0); + if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) { + if (\is_numeric($max)) { + return $base; } - return $this->preserveNumericType($base, $result); + throw new LimitException('Value out of range'); } - // 0 to a negative power, or a negative base to a fractional exponent, is not a real - // number. Fail loudly with a clear exception rather than storing INF/NaN (which - // also can't be JSON-encoded, so it would otherwise surface as a raw JsonException). - $result = $base ** $by; - if (!\is_finite($result)) { + $candidate = $base ** $by; + if (! \is_finite((float) $candidate)) { + if (\is_numeric($max)) { + return $base; + } + throw new LimitException('Value out of range'); } - return $this->preserveNumericType($base, $result); + return $this->applyNumericLimit($base, $candidate, $max, true); - case Operator::TYPE_STRING_CONCAT: - return ((string) ($current ?? '')) . (string) ($values[0] ?? ''); + case OperatorType::StringConcat: + return $this->stringOrEmpty($current).$this->stringOrEmpty($values[0] ?? ''); - case Operator::TYPE_STRING_REPLACE: - $search = (string) ($values[0] ?? ''); - $replace = (string) ($values[1] ?? ''); + case OperatorType::StringReplace: + $search = $this->stringOrEmpty($values[0] ?? ''); + $replace = $this->stringOrEmpty($values[1] ?? ''); if ($current === null) { return null; } - return \str_replace($search, $replace, (string) $current); + return \str_replace($search, $replace, $this->stringOrEmpty($current)); - case Operator::TYPE_TOGGLE: + case OperatorType::Toggle: return ! (bool) $current; - case Operator::TYPE_ARRAY_APPEND: + case OperatorType::ArrayAppend: $list = $this->coerceArray($current); return [...$list, ...\array_values($values)]; - case Operator::TYPE_ARRAY_PREPEND: + case OperatorType::ArrayPrepend: $list = $this->coerceArray($current); return [...\array_values($values), ...$list]; - case Operator::TYPE_ARRAY_INSERT: + case OperatorType::ArrayInsert: $list = $this->coerceArray($current); - $index = (int) ($values[0] ?? 0); + $index = $this->intOr($values[0] ?? 0, 0); $value = $values[1] ?? null; if ($index < 0) { $index = 0; @@ -4189,67 +3720,74 @@ protected function applyOperator(mixed $current, Operator $operator): mixed return $list; - case Operator::TYPE_ARRAY_REMOVE: + case OperatorType::ArrayRemove: $list = $this->coerceArray($current); $needle = $values[0] ?? null; return \array_values(\array_filter($list, fn ($item) => $item !== $needle)); - case Operator::TYPE_ARRAY_UNIQUE: + case OperatorType::ArrayUnique: $list = $this->coerceArray($current); return \array_values(\array_unique($list, SORT_REGULAR)); - case Operator::TYPE_ARRAY_INTERSECT: + case OperatorType::ArrayIntersect: $list = $this->coerceArray($current); $other = \array_values($values); return \array_values(\array_filter($list, fn ($item) => \in_array($item, $other, false))); - case Operator::TYPE_ARRAY_DIFF: + case OperatorType::ArrayDiff: $list = $this->coerceArray($current); $other = \array_values($values); return \array_values(\array_filter($list, fn ($item) => ! \in_array($item, $other, false))); - case Operator::TYPE_ARRAY_FILTER: + case OperatorType::ArrayFilter: $list = $this->coerceArray($current); - $condition = (string) ($values[0] ?? ''); + $condition = $this->stringOrEmpty($values[0] ?? ''); $compare = $values[1] ?? null; return \array_values(\array_filter($list, fn ($item) => $this->matchesArrayFilter($item, $condition, $compare))); - case Operator::TYPE_DATE_ADD_DAYS: - $days = (int) ($values[0] ?? 0); + case OperatorType::DateAddDays: + $days = $this->intOr($values[0] ?? 0, 0); return $this->shiftDate($current, $days * 86400); - case Operator::TYPE_DATE_SUB_DAYS: - $days = (int) ($values[0] ?? 0); + case OperatorType::DateSubDays: + $days = $this->intOr($values[0] ?? 0, 0); return $this->shiftDate($current, -$days * 86400); - case Operator::TYPE_DATE_SET_NOW: + case OperatorType::DateSetNow: return DateTime::now(); } - - throw new OperatorException("Invalid operator: {$method}"); } - protected function applyNumericLimit(int|float $value, int|float|null $bound, bool $isUpper): int|float + protected function applyNumericLimit(mixed $original, mixed $candidate, mixed $bound, bool $isUpper): int|float|string { - if ($bound === null) { - return $value; + if (BigInt::isIntegerValue($original) && BigInt::isIntegerValue($candidate) && BigInt::isIntegerValue($bound)) { + $crossed = $isUpper + ? BigInt::compare($candidate, $bound) > 0 + : BigInt::compare($candidate, $bound) < 0; + + return $crossed ? BigInt::toNative($original) : BigInt::toNative($candidate); + } + + $numericOriginal = \is_numeric($original) ? $original + 0 : 0; + $numericCandidate = \is_numeric($candidate) ? $candidate + 0 : 0; + + if (\is_numeric($bound)) { + $numericBound = $bound + 0; + if (($isUpper && $numericCandidate > $numericBound) || (! $isUpper && $numericCandidate < $numericBound)) { + return $numericOriginal; + } } - return $isUpper ? \min($value, $bound) : \max($value, $bound); + return $this->preserveNumericType($numericOriginal, $numericCandidate); } - /** - * Preserve int-ness when the original value is an int — without this, - * PHP's arithmetic promotes the result to float and the Range validator - * rejects an integer column post-update. - */ protected function preserveNumericType(int|float $original, int|float $result): int|float { if (\is_int($original) && \is_float($result) && $result === (float) (int) $result) { @@ -4280,14 +3818,14 @@ protected function coerceArray(mixed $value): array protected function matchesArrayFilter(mixed $item, string $condition, mixed $compare): bool { return match ($condition) { - Query::TYPE_EQUAL => $item == $compare, - Query::TYPE_NOT_EQUAL => $item != $compare, - Query::TYPE_GREATER => \is_numeric($item) && \is_numeric($compare) && $item + 0 > $compare + 0, - Query::TYPE_GREATER_EQUAL => \is_numeric($item) && \is_numeric($compare) && $item + 0 >= $compare + 0, - Query::TYPE_LESSER => \is_numeric($item) && \is_numeric($compare) && $item + 0 < $compare + 0, - Query::TYPE_LESSER_EQUAL => \is_numeric($item) && \is_numeric($compare) && $item + 0 <= $compare + 0, - Query::TYPE_IS_NULL => $item === null, - Query::TYPE_IS_NOT_NULL => $item !== null, + Method::Equal->value => $item == $compare, + Method::NotEqual->value => $item != $compare, + Method::GreaterThan->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 > $compare + 0, + Method::GreaterThanEqual->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 >= $compare + 0, + Method::LessThan->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 < $compare + 0, + Method::LessThanEqual->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 <= $compare + 0, + Method::IsNull->value => $item === null, + Method::IsNotNull->value => $item !== null, default => true, }; } @@ -4297,12 +3835,13 @@ protected function shiftDate(mixed $current, int $seconds): ?string if ($current === null) { return null; } + $stringified = $this->stringOrEmpty($current); try { - $base = new \DateTime((string) $current); + $base = new \DateTime($stringified); } catch (\Throwable) { - return $current === '' ? null : (string) $current; + return $stringified === '' ? null : $stringified; } - $base->modify(($seconds >= 0 ? '+' : '') . $seconds . ' seconds'); + $base->modify(($seconds >= 0 ? '+' : '').$seconds.' seconds'); return DateTime::format($base); } diff --git a/src/Database/Adapter/Redis/Write.php b/src/Database/Adapter/Redis/Write.php new file mode 100644 index 0000000000..7d3487be10 --- /dev/null +++ b/src/Database/Adapter/Redis/Write.php @@ -0,0 +1,22 @@ +|null + */ + private ?\WeakMap $statementEvents = null; + + /** + * The metadata the comments ahead of every statement were last written for, when every value is + * scalar or null, so the same metadata yields the same comments. + * + * @var array|null + */ + private ?array $commentedMetadata = null; + + private string $comments = ''; + + /** + * @var \WeakMap>|null + */ + private ?\WeakMap $statementBindings = null; + + /** + * @var \WeakMap|null + */ + private ?\WeakMap $statementCollections = null; + + /** + * Accepts Utopia\Database\PDO, a PDO-compatible proxy, or a native PDO. */ - public function setFloatPrecision(int $precision): void + public function __construct(object $pdo) { - $this->floatPrecision = $precision; + $this->pdo = $pdo; } /** - * Helper to format a float value according to configured precision for binding/logging. + * @return array */ - protected function getFloatPrecision(float $value): string + #[\Override] + public function capabilities(): array { - return sprintf('%.'. $this->floatPrecision . 'F', $value); + return array_merge(parent::capabilities(), [ + Capability::Schemas, + Capability::Caching, + Capability::IndexFulltext, + Capability::IndexFulltextMultiple, + Capability::UpdateLock, + Capability::TransactionRetries, + Capability::TransactionNested, + Capability::Operators, + Capability::OrderRandom, + Capability::IndexIdentical, + Capability::AttributeResizing, + Capability::DefinedAttributes, + Capability::Joins, + Capability::Aggregations, + ]); } /** - * Build conditions threading `$name` to per-query builders so adapter - * overrides (SQLite FTS5 routing) can resolve auxiliary tables. - * - * @param array $queries - * @param array $binds + * @return DatabasePDO|PDOProxy|PDO */ - protected function getSQLConditionsForCollection(string $name, array $queries, array &$binds, string $separator = 'AND'): string + #[\Override] + public function getDriver(): object { - return $this->getSQLConditions($queries, $binds, $separator, $name); + if ($this->pdo instanceof DatabasePDO || $this->pdo instanceof PDOProxy || $this->pdo instanceof PDO) { + return $this->pdo; + } + + throw new DatabaseException('SQL adapter requires Utopia\\Database\\PDO, Swoole\\Database\\PDOProxy, or PDO'); } /** - * Constructor. - * - * Set connection and settings - * - * @param mixed $pdo + * Helper to format a float value according to configured precision for binding/logging. */ - public function __construct(mixed $pdo) + protected function getFloatPrecision(float $value): string { - $this->pdo = $pdo; + return sprintf('%.'.$this->floatPrecision.'F', $value); + } + + #[\Override] + public function hostname(): string + { + try { + if ($this->pdo instanceof DatabasePDO) { + return $this->pdo->getHostname(); + } + + return $this->hostname; + } catch (Throwable) { + return ''; + } + } + + protected function getLockType(): string + { + if ($this->supports(Capability::AlterLock) && $this->locks) { + return ',LOCK=SHARED'; + } + + return ''; } /** - * @inheritDoc + * @throws Exception + * @throws PDOException */ + #[\Override] + public function ping(): bool + { + $result = $this->dialectBuilder()->fromNone()->selectRaw('1')->build(); + + return $this->prepareStatement($result->query)->execute(); + } + + #[\Override] + public function reconnect(): void + { + $pdo = $this->getDriver(); + if ($pdo instanceof DatabasePDO) { + $pdo->reconnect(); + } + $this->inTransaction = 0; + } + + #[\Override] public function startTransaction(): bool { try { if ($this->inTransaction === 0) { try { - if ($this->getPDO()->inTransaction()) { - $this->getPDO()->rollBack(); + if ($this->getDriver()->inTransaction()) { + $this->getDriver()->rollBack(); } else { // If no active transaction, this has no effect. - $this->getPDO()->prepare('ROLLBACK')->execute(); + $this->prepareStatement('ROLLBACK')->execute(); } } catch (PDOException) { // A pooled connection can report a transaction it no longer @@ -90,13 +277,18 @@ public function startTransaction(): bool // effort; swallow it and begin a fresh transaction below. } - $this->getPDO()->beginTransaction(); + $result = $this->getDriver()->beginTransaction(); } else { - $this->getPDO()->exec('SAVEPOINT transaction' . $this->inTransaction); + $this->getDriver()->exec('SAVEPOINT transaction'.$this->inTransaction); + $result = true; } } catch (PDOException $e) { - throw new TransactionException('Failed to start transaction: ' . $e->getMessage(), $e->getCode(), $e); + throw new TransactionException('Failed to start transaction: '.$e->getMessage(), $e->getCode(), $e); + } + + if ($result !== true) { + throw new TransactionException('Failed to start transaction'); } $this->inTransaction++; @@ -104,42 +296,40 @@ public function startTransaction(): bool return true; } - /** - * @inheritDoc - */ + #[\Override] public function commitTransaction(): bool { if ($this->inTransaction === 0) { return false; } - if (!$this->getPDO()->inTransaction()) { + if (! $this->getDriver()->inTransaction()) { $this->inTransaction = 0; - return false; + + throw new TransactionException('Failed to commit transaction: the connection no longer holds the transaction'); } if ($this->inTransaction > 1) { $this->inTransaction--; + return true; } try { - $result = $this->getPDO()->commit(); + $result = $this->getDriver()->commit(); $this->inTransaction = 0; } catch (PDOException $e) { - throw new TransactionException('Failed to commit transaction: ' . $e->getMessage(), $e->getCode(), $e); + throw new TransactionException('Failed to commit transaction: '.$e->getMessage(), $e->getCode(), $e); } - if (!$result) { + if (! $result) { throw new TransactionException('Failed to commit transaction'); } return $result; } - /** - * @inheritDoc - */ + #[\Override] public function rollbackTransaction(): bool { if ($this->inTransaction === 0) { @@ -148,737 +338,981 @@ public function rollbackTransaction(): bool try { if ($this->inTransaction > 1) { - $this->getPDO()->exec('ROLLBACK TO transaction' . ($this->inTransaction - 1)); + $this->getDriver()->exec('ROLLBACK TO transaction'.($this->inTransaction - 1)); $this->inTransaction--; + $result = true; } else { - $this->getPDO()->rollBack(); + $result = $this->getDriver()->rollBack(); $this->inTransaction = 0; } } catch (PDOException $e) { $this->inTransaction = 0; - throw new DatabaseException('Failed to rollback transaction: ' . $e->getMessage(), $e->getCode(), $e); + throw new DatabaseException('Failed to rollback transaction: '.$e->getMessage(), $e->getCode(), $e); + } + + if ($result !== true) { + throw new TransactionException('Failed to rollback transaction'); } return true; } + #[\Override] + protected function abandonTransaction(): void + { + $pdo = $this->getDriver(); + if (! $pdo->inTransaction()) { + return; + } + + try { + $pdo->rollBack(); + } catch (PDOException) { + // A connection that only reports a transaction it no longer holds has nothing left to end. + } + } + /** - * Ping Database - * - * @return bool - * @throws Exception - * @throws PDOException + * @throws DatabaseException */ - public function ping(): bool + #[\Override] + public function exists(string $database): bool { - return $this->getPDO() - ->prepare("SELECT 1;") - ->execute(); + $result = $this->dialectBuilder() + ->fromTable('INFORMATION_SCHEMA.SCHEMATA') + ->selectRaw('SCHEMA_NAME') + ->filter([BaseQuery::equal('SCHEMA_NAME', [$this->filter($database)])]) + ->build(); + + return $this->returnsRows($this->executeResult($result, Event::DatabaseList)); } - public function reconnect(): void + /** + * @throws DatabaseException + */ + #[\Override] + public function collectionExists(string $database, string $collection): bool { - $this->getPDO()->reconnect(); - $this->inTransaction = 0; + $result = $this->dialectBuilder() + ->fromTable('INFORMATION_SCHEMA.TABLES') + ->selectRaw('TABLE_NAME') + ->filter([ + BaseQuery::equal('TABLE_SCHEMA', [$this->filter($database)]), + BaseQuery::equal('TABLE_NAME', ["{$this->getNamespace()}_{$this->filter($collection)}"]), + ]) + ->build(); + + return $this->returnsRows($this->executeResult($result, Event::CollectionRead)); } /** - * Check if Database exists - * Optionally check if collection exists in Database + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement * - * @param string $database - * @param string|null $collection - * @return bool * @throws DatabaseException */ - public function exists(string $database, ?string $collection = null): bool + private function returnsRows(object $statement): bool { - $database = $this->filter($database); - - if (!\is_null($collection)) { - $collection = $this->filter($collection); - $stmt = $this->getPDO()->prepare(" - SELECT TABLE_NAME - FROM INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = :schema - AND TABLE_NAME = :table - "); - $stmt->bindValue(':schema', $database, \PDO::PARAM_STR); - $stmt->bindValue(':table', "{$this->getNamespace()}_{$collection}", \PDO::PARAM_STR); - } else { - $stmt = $this->getPDO()->prepare(" - SELECT SCHEMA_NAME FROM - INFORMATION_SCHEMA.SCHEMATA - WHERE SCHEMA_NAME = :schema - "); - $stmt->bindValue(':schema', $database, \PDO::PARAM_STR); - } - try { - $stmt->execute(); - $document = $stmt->fetchAll(); - $stmt->closeCursor(); - } catch (PDOException $e) { - $e = $this->processException($e); + $this->execute($statement); + $rows = $statement->fetchAll(); + $statement->closeCursor(); + } catch (PDOException $error) { + $error = $this->processException($error); - if ($e instanceof NotFoundException) { + if ($error instanceof NotFoundException) { return false; } - throw $e; - } - - if (empty($document)) { - return false; + throw $error; } - return true; + return ! empty($rows); } /** - * List Databases - * * @return array */ + #[\Override] public function list(): array { return []; } /** - * Create Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @return bool * @throws Exception * @throws PDOException */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool + { + return $this->createAttributeWithEvent($collection, $attribute, Event::AttributeCreate); + } + + protected function createAttributeWithEvent(string $collection, Attribute $attribute, Event $event): bool { - $id = $this->quote($this->filter($id)); - $type = $this->getSQLType($type, $size, $signed, $array, $required); - $sql = "ALTER TABLE {$this->getSQLTable($collection)} ADD COLUMN {$id} {$type} {$this->getLockType()};"; - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + $schema = $this->schema(); + $table = $schema->table($this->getTableRaw($collection)); + $this->addAttributeColumn($table, $attribute); + $result = $table->alter(); + + $sql = $result->query; + $lockType = $this->getLockType(); + if (! empty($lockType)) { + $sql = rtrim($sql, ';').' '.$lockType; + } try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + return $this->executeStatement($sql, $event); + } catch (PDOException $error) { + throw $this->processException($error); } } /** - * Create Attributes + * @param list $attributes * - * @param string $collection - * @param array> $attributes - * @return bool * @throws DatabaseException */ + #[\Override] public function createAttributes(string $collection, array $attributes): bool { - $parts = []; + $schema = $this->schema(); + $table = $schema->table($this->getTableRaw($collection)); foreach ($attributes as $attribute) { - $id = $this->quote($this->filter($attribute['$id'])); - $type = $this->getSQLType( - $attribute['type'], - $attribute['size'], - $attribute['signed'] ?? true, - $attribute['array'] ?? false, - $attribute['required'] ?? false, - ); - $parts[] = "{$id} {$type}"; + $this->addAttributeColumn($table, $attribute); + } + $result = $table->alter(); + + $sql = $result->query; + $lockType = $this->getLockType(); + if (! empty($lockType)) { + $sql = rtrim($sql, ';').' '.$lockType; } - $columns = \implode(', ADD COLUMN ', $parts); + try { + return $this->executeStatement($sql, Event::AttributesCreate); + } catch (PDOException $error) { + throw $this->processException($error); + } + } + + /** + * @throws Exception + * @throws PDOException + */ + #[\Override] + public function deleteAttribute(string $collection, string $key): bool + { + $schema = $this->schema(); + $table = $schema->table($this->getTableRaw($collection)); + $table->dropColumn($this->filter($key)); + $result = $table->alter(); - $sql = "ALTER TABLE {$this->getSQLTable($collection)} ADD COLUMN {$columns} {$this->getLockType()};"; - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + $sql = $result->query; try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::AttributeDelete); } catch (PDOException $e) { throw $this->processException($e); } } /** - * Rename Attribute - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool * @throws Exception * @throws PDOException */ + #[\Override] public function renameAttribute(string $collection, string $old, string $new): bool { - $collection = $this->filter($collection); - $old = $this->quote($this->filter($old)); - $new = $this->quote($this->filter($new)); + if ($this->isRenamed($collection, $old, $new)) { + return true; + } - $sql = "ALTER TABLE {$this->getSQLTable($collection)} RENAME COLUMN {$old} TO {$new};"; + $schema = $this->schema(); + $table = $schema->table($this->getTableRaw($collection)); + $table->renameColumn($this->filter($old), $this->filter($new)); + $result = $table->alter(); - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + $sql = $result->query; try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::AttributeUpdate); } catch (PDOException $e) { throw $this->processException($e); } } /** - * Delete Attribute + * Whether an earlier rename already moved the column: under shared tables every tenant + * of a collection id renames the one physical column, so only the first rename runs. * - * @param string $collection - * @param string $id - * @param bool $array - * @return bool - * @throws Exception - * @throws PDOException + * @throws DatabaseException */ - public function deleteAttribute(string $collection, string $id, bool $array = false): bool + protected function isRenamed(string $collection, string $old, string $new): bool { - $id = $this->quote($this->filter($id)); - $sql = "ALTER TABLE {$this->getSQLTable($collection)} DROP COLUMN {$id};"; - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); + $old = $this->filter($old); + $new = $this->filter($new); - try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + if ($old === $new) { + return false; } + + $columns = $this->getColumnNames($collection); + + return ! \in_array($old, $columns, true) && \in_array($new, $columns, true); } /** - * Get Document + * The physical column names of a collection's table, empty when the table does not exist. + * + * @return array + * + * @throws DatabaseException + */ + abstract protected function getColumnNames(string $collection): array; + + /** + * @param Query[] $queries * - * @param Document $collection - * @param string $id - * @param Query[] $queries - * @param bool $forUpdate - * @return Document * @throws DatabaseException */ + #[\Override] public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { + $collectionDoc = $collection; $collection = $collection->getId(); $name = $this->filter($collection); $selections = $this->getAttributeSelections($queries); + $alias = Query::DEFAULT_ALIAS; + + // Fast path: single-row lookup by primary key with no projection and + // no joins. This is by far the most common shape (metadata fetch, + // primary cache miss, the locked read of every update); skip the + // builder pipeline and go directly to a parameterised SELECT, filtered + // by tenant and locked the way the builder's Tenant\Filter and lock + // clause do it. + if ( + empty($selections) + && ! $this->queriesHaveJoins($queries) + ) { + $tableExpr = $this->getTable($name); + $aliasQuoted = $this->quote($alias); + $uidQuoted = $this->quote(Storage::UID); + $sql = "SELECT * FROM {$tableExpr} AS {$aliasQuoted} WHERE {$this->collateDocumentId($uidQuoted)} = " . ':'.Storage::UID; + $bindings = [':'.Storage::UID => $id]; + if ($this->sharedTables) { + $tenantColumn = $aliasQuoted.'.'.Storage::TENANT; + $sql .= $name === Database::METADATA || $name === Storage::permissionsTable(Database::METADATA) + ? " AND ({$tenantColumn} IN (:".Storage::TENANT.") OR {$tenantColumn} IS NULL)" + : " AND {$tenantColumn} IN (:".Storage::TENANT.')'; + $bindings[':'.Storage::TENANT] = $this->currentTenant(); + } + if ($forUpdate && $this->supports(Capability::UpdateLock)) { + $sql .= ' FOR UPDATE'; + } + $statement = null; + $row = false; + $exception = null; + + try { + $statement = $this->prepareStatement($sql, Event::DocumentRead); + foreach ($bindings as $parameter => $value) { + $statement->bindValue($parameter, $value, $this->getPdoType($value)); + } + $this->describeStatement($statement, $bindings, $name); + $this->execute($statement); + /** @var array|false $row */ + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + $exception = $e; + } finally { + if ($statement !== null) { + try { + $statement->closeCursor(); + } catch (PDOException $e) { + $exception ??= $e; + } + } + } - $forUpdate = $forUpdate ? 'FOR UPDATE' : ''; + if ($exception !== null) { + throw $this->processException($exception); + } - $alias = Query::DEFAULT_ALIAS; + if (! is_array($row) || empty($row)) { + return new Document([]); + } - $sql = " - SELECT {$this->getAttributeProjection($selections, $alias)} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - WHERE {$this->quote($alias)}.{$this->quote('_uid')} = :_uid - {$this->getTenantQuery($collection, $alias)} - "; + $this->remapRow($row); - if ($this->getSupportForUpdateLock()) { - $sql .= " {$forUpdate}"; + return Document::fromRow($row); } - $sql = $this->trigger(Database::EVENT_DOCUMENT_READ, $sql); - - $stmt = null; - $document = []; - $exception = null; + if ($this->queriesHaveJoins($queries)) { + if ($forUpdate) { + throw new QueryException('Cannot lock a document for update when join queries are present'); + } - try { - $stmt = $this->getPDO()->prepare($sql); + $roles = $this->authorization->getRoles(); + $queries = \array_map(static fn ($query) => clone $query, $queries); + $joinTablePrefixes = $this->remapJoinQueries($queries); + $queries = $this->rewriteFullOuterJoins($queries, Method::LeftJoin); + + $builder = $this->newBuilder($name, $alias, $this->keepsUnmatchedRows($queries), unindexed: $this->unindexedJoins($collectionDoc, $queries, $joinTablePrefixes)); + $this->configureFindBuilder( + $builder, + $collectionDoc, + $queries, + $joinTablePrefixes, + false, + false, + [], + $name, + $alias, + $roles, + PermissionType::Read, + ); + $builder->filter([BaseQuery::equal($alias.'.'.Storage::UID, [$id])]); - $stmt->bindValue(':_uid', $id); + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + foreach ($joinAliases as $joinAlias) { + $builder->sortAsc($this->qualifyOrderAttribute($joinAlias.'.'.Document::SEQUENCE, $joinAliases)); + } + $builder->limit(1); + } else { + $builder = $this->newBuilder($name, $alias); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->getTenant()); + if (! \in_array('*', $selections)) { + $builder->select($this->mapSelectionsToColumns($selections, joinAliases: [])); } - $this->execute($stmt); - $document = $stmt->fetchAll(); - } catch (PDOException $e) { - $exception = $e; - } finally { - if ($stmt !== null) { - try { - $stmt->closeCursor(); - } catch (PDOException $e) { - $exception ??= $e; - } + $builder->filter([BaseQuery::equal(Storage::UID, [$id])]); + + if ($forUpdate && $this->supports(Capability::UpdateLock)) { + $builder->forUpdate(); } } - if ($exception !== null) { - throw $this->processException($exception); - } + $rows = $this->executeSelect($builder, Event::DocumentRead, $name); - if (empty($document)) { + if (empty($rows)) { return new Document([]); } - $document = $document[0]; - - if (\array_key_exists('_id', $document)) { - $document['$sequence'] = $document['_id']; - unset($document['_id']); - } - if (\array_key_exists('_uid', $document)) { - $document['$id'] = $document['_uid']; - unset($document['_uid']); - } - if (\array_key_exists('_tenant', $document)) { - $document['$tenant'] = $document['_tenant']; - unset($document['_tenant']); - } - if (\array_key_exists('_createdAt', $document)) { - $document['$createdAt'] = $document['_createdAt']; - unset($document['_createdAt']); - } - if (\array_key_exists('_updatedAt', $document)) { - $document['$updatedAt'] = $document['_updatedAt']; - unset($document['_updatedAt']); - } - if (\array_key_exists('_deletedAt', $document)) { - $document['$deletedAt'] = $document['_deletedAt']; - unset($document['_deletedAt']); - } - if (\array_key_exists('_permissions', $document)) { - $document['$permissions'] = json_decode($document['_permissions'] ?? '[]', true); - unset($document['_permissions']); - } + /** @var array $document */ + $document = $rows[0]; - return new Document($document); - } + $this->remapRow($document); - /** - * Helper method to extract spatial type attributes from collection attributes - * - * @param Document $collection - * @return array - */ - protected function getSpatialAttributes(Document $collection): array - { - $collectionAttributes = $collection->getAttribute('attributes', []); - $spatialAttributes = []; - foreach ($collectionAttributes as $attr) { - if ($attr instanceof Document) { - $attributeType = $attr->getAttribute('type'); - if (in_array($attributeType, Database::SPATIAL_TYPES)) { - $spatialAttributes[] = $attr->getId(); - } - } - } - return $spatialAttributes; + return Document::fromRow($document); } /** - * Update documents - * - * Updates all documents which match the given query. + * Under ignoreDuplicates() only the documents written are returned and handed to the write + * hooks, so a skipped document writes no permission rows for a stored one. * - * @param Document $collection - * @param Document $updates - * @param array $documents - * - * @return int + * @param array $documents + * @return array * - * @throws DatabaseException + * @throws DuplicateException + * @throws Throwable */ - public function updateDocuments(Document $collection, Document $updates, array $documents): int + #[\Override] + public function createDocuments(Document $collection, array $documents): array { if (empty($documents)) { - return 0; + return $documents; } + + $this->syncWriteHooks(); + $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); + try { + $name = $this->filter($collection); + $hasSequence = $this->batchHasSequence($documents); - $attributes = $updates->getAttributes(); + if ($this->isIgnoringDuplicates()) { + $documents = $this->firstCopies($documents); + $documents = $this->supportsInsertReturning() + ? $this->insertReturning($name, $documents, $spatialAttributes, $hasSequence) + : $this->insertThenReadBack($name, $documents, $spatialAttributes, $hasSequence); + } else { + $insert = $this->buildDocumentsInsert($name, $documents, $spatialAttributes, $hasSequence)->insert(); + $this->execute($this->executeResult($insert, Event::DocumentsCreate)); + } - if (!empty($updates->getUpdatedAt())) { - $attributes['_updatedAt'] = $updates->getUpdatedAt(); + if (! empty($documents)) { + $context = $this->writeContext(); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, $documents, $context)); + } + } catch (PDOException $e) { + throw $this->processException($e); } - if (!empty($updates->getCreatedAt())) { - $attributes['_createdAt'] = $updates->getCreatedAt(); - } + return $documents; + } - if ($updates->offsetExists('$permissions')) { - $attributes['_permissions'] = json_encode($updates->getPermissions()); - } + protected function supportsInsertReturning(): bool + { + return true; + } - if (empty($attributes)) { - return 0; + /** + * MariaDB, MySQL and SQLite cannot name the index to ignore, so they skip any unique collision. + * + * @throws DatabaseException + */ + protected function insertOrIgnore(SQLBuilder $builder): Statement + { + if (! $builder instanceof InsertOrIgnoreFeature) { + throw new DatabaseException('Insert-or-ignore is not supported on this dialect'); } - $keyIndex = 0; - $operatorBinds = []; - $columns = ''; - - foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); + return $builder->insertOrIgnore(); + } - // Check if this is an operator, spatial attribute, or regular attribute - if (Operator::isOperator($value)) { - $columns .= $this->getOperatorSQL($column, $value, $operatorBinds); - } elseif (\in_array($attribute, $spatialAttributes)) { - $columns .= "{$this->quote($column)} = " . $this->getSpatialGeomFromText(":key_{$keyIndex}"); - $keyIndex++; - } else { - $columns .= "{$this->quote($column)} = :key_{$keyIndex}"; - $keyIndex++; - } + /** + * @return list + */ + protected function documentKeyColumns(): array + { + return $this->sharedTables ? [Storage::UID, Storage::TENANT] : [Storage::UID]; + } - if ($attribute !== \array_key_last($attributes)) { - $columns .= ','; + /** + * @param array $documents + * + * @throws DatabaseException + */ + private function batchHasSequence(array $documents): bool + { + $hasSequence = null; + foreach ($documents as $document) { + if ($hasSequence === null) { + $hasSequence = ! empty($document->getSequence()); + } elseif ($hasSequence == empty($document->getSequence())) { + throw new DatabaseException('All documents must have an sequence if one is set'); } } - // Remove trailing comma if present - $columns = \rtrim($columns, ','); - - if (empty($columns)) { - return 0; - } + return $hasSequence ?? false; + } - $name = $this->filter($collection); - $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); + /** + * A single statement writes at most one copy of an id, and a later copy may be written when + * the first is skipped for another unique value; keeping only the first copy leaves no row + * whose grants could be taken from another copy. + * + * @param array $documents + * @return list + */ + private function firstCopies(array $documents): array + { + $seen = []; + $firstCopies = []; + foreach ($documents as $document) { + [$tenant, $id] = $this->documentKey($document); + if (isset($seen[$tenant][$id])) { + continue; + } + $seen[$tenant][$id] = true; + $firstCopies[] = $document; + } - $sql = " - UPDATE {$this->getSQLTable($name)} - SET {$columns} - WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") - {$this->getTenantQuery($collection)} - "; + return $firstCopies; + } - $sql = $this->trigger(Database::EVENT_DOCUMENTS_UPDATE, $sql); - $stmt = $this->getPDO()->prepare($sql); + /** + * @param array $documents + * @param list $spatialAttributes + * + * @throws DatabaseException + */ + private function buildDocumentsInsert(string $name, array $documents, array $spatialAttributes, bool $hasSequence): SQLBuilder + { + $attributeKeySet = []; + foreach (Database::INTERNAL_ATTRIBUTE_KEYS as $key) { + $attributeKeySet[$key] = true; + } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); + foreach ($documents as $document) { + foreach ($document->getAttributes() as $key => $value) { + $attributeKeySet[$key] = true; + } } - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); + $attributeKeys = \array_keys($attributeKeySet); + + if ($hasSequence) { + $attributeKeys[] = Storage::SEQUENCE; } - $keyIndex = 0; - foreach ($attributes as $attributeName => $value) { - // Skip operators as they don't need value binding - if (Operator::isOperator($value)) { - continue; - } + $builder = $this->dialectBuilder()->into($this->getTableRaw($name)); - // Convert spatial arrays to WKT, json_encode non-spatial arrays - if (\in_array($attributeName, $spatialAttributes, true)) { - if (\is_array($value)) { - $value = $this->convertArrayToWKT($value); - } - } elseif (\is_array($value)) { - $value = \json_encode($value); - } + $spatialMap = \array_fill_keys($spatialAttributes, true); - $bindKey = 'key_' . $keyIndex; - if ($this->getSupportForIntegerBooleans()) { - $value = (\is_bool($value)) ? (int)$value : $value; - } - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $keyIndex++; + foreach ($spatialAttributes as $spatialColumn) { + $builder->insertColumnExpression($spatialColumn, $this->getSpatialGeometryFromText('?')); } - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); + $intBools = $this->supports(Capability::IntegerBooleans); + + foreach ($documents as $document) { + $row = $this->buildDocumentRow($document, $attributeKeys, $spatialMap, $intBools); + $row = $this->decorateRow($row, $document); + $builder->set($row); } - try { - $stmt->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + return $builder; + } + + /** + * @param list $documents + * @param list $spatialAttributes + * @return list + * + * @throws DatabaseException + */ + private function insertReturning(string $name, array $documents, array $spatialAttributes, bool $hasSequence): array + { + $builder = $this->buildDocumentsInsert($name, $documents, $spatialAttributes, $hasSequence); + $columns = $this->documentKeyColumns(); + + if ($builder instanceof MariaDBReturning) { + $insert = $this->insertOrIgnore($builder->returning($columns)); + } else { + $insert = $this->insertOrIgnore($builder); + $quoted = \array_map($this->quote(...), $columns); + $insert = new Statement($insert->query.' RETURNING '.\implode(', ', $quoted), $insert->bindings); } - $affected = $stmt->rowCount(); + $statement = $this->executeResult($insert, Event::DocumentsCreate); + $this->execute($statement); + /** @var list> $rows */ + $rows = $statement->fetchAll(PDO::FETCH_NUM); + $statement->closeCursor(); - // Permissions logic - if ($updates->offsetExists('$permissions')) { - $removeQueries = []; - $removeBindValues = []; + $written = $this->rowKeys($rows); - $addQuery = ''; - $addBindValues = []; + return \array_values(\array_filter( + $documents, + fn (Document $document): bool => $this->hasKey($written, $document), + )); + } - foreach ($documents as $index => $document) { - if ($document->getAttribute('$skipPermissionsUpdate', false)) { - continue; - } + /** + * Without RETURNING the ids are read before the insert, which keeps a stored id out of it, + * and read back when the insert wrote fewer rows than it was sent. A row read back is taken + * as written only when it carries the document's own permissions: a row another writer + * stored meanwhile under the same id then gains no grant it does not already state. + * + * @param list $documents + * @param list $spatialAttributes + * @return list + * + * @throws DatabaseException + */ + private function insertThenReadBack(string $name, array $documents, array $spatialAttributes, bool $hasSequence): array + { + $stored = $this->rowKeys($this->readRows($name, $documents, $this->documentKeyColumns())); + $candidates = \array_values(\array_filter( + $documents, + fn (Document $document): bool => ! $this->hasKey($stored, $document), + )); + + if (empty($candidates)) { + return []; + } - $sql = " - SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; + $statement = $this->executeResult( + $this->insertOrIgnore($this->buildDocumentsInsert($name, $candidates, $spatialAttributes, $hasSequence)), + Event::DocumentsCreate, + ); + $this->execute($statement); + $written = $statement->rowCount(); + $statement->closeCursor(); - $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + if ($written === \count($candidates)) { + return $candidates; + } - $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + $permissions = []; + foreach ($this->readRows($name, $candidates, [...$this->documentKeyColumns(), Storage::PERMISSIONS]) as $row) { + $rowPermissions = \end($row); + $permissions[$this->rowTenant($row)][$this->rowId($row)] = \is_string($rowPermissions) ? \json_decode($rowPermissions, true) : null; + } - if ($this->sharedTables) { - $permissionsStmt->bindValue(':_tenant', $this->tenant); - } + return \array_values(\array_filter( + $candidates, + function (Document $document) use ($permissions): bool { + [$tenant, $id] = $this->documentKey($document); - $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(); - $permissionsStmt->closeCursor(); + return \array_key_exists($id, $permissions[$tenant] ?? []) + && $permissions[$tenant][$id] === $document->getPermissions(); + }, + )); + } - $initial = []; - foreach (Database::PERMISSIONS as $type) { - $initial[$type] = []; - } + /** + * @param list $documents + * @param list $columns + * @return list> + * + * @throws DatabaseException + */ + private function readRows(string $name, array $documents, array $columns): array + { + $ids = []; + $tenants = []; + foreach ($documents as $document) { + $ids[] = $document->getId(); + $tenant = $this->documentTenant($document); + if ($this->sharedTables && $this->tenantPerDocument && ! \in_array($tenant, $tenants, true)) { + $tenants[] = $tenant; + } + } - $permissions = \array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; - return $carry; - }, $initial); + $builder = $this->newBuilder($name, tenants: $tenants); + $builder->select($columns); + $builder->filter([BaseQuery::equal(Storage::UID, \array_values(\array_unique($ids)))]); - // Get removed Permissions - $removals = []; - foreach (Database::PERMISSIONS as $type) { - $diff = array_diff($permissions[$type], $updates->getPermissionsByType($type)); - if (!empty($diff)) { - $removals[$type] = $diff; - } - } + $statement = $this->executeResult($builder->build(), Event::DocumentRead); + $this->execute($statement); + /** @var list> $rows */ + $rows = $statement->fetchAll(PDO::FETCH_NUM); + $statement->closeCursor(); - // Build inner query to remove permissions - if (!empty($removals)) { - foreach ($removals as $type => $permissionsToRemove) { - $bindKey = '_uid_' . $index; - $removeBindKeys[] = ':_uid_' . $index; - $removeBindValues[$bindKey] = $document->getId(); - - $removeQueries[] = "( - _document = :_uid_{$index} - {$this->getTenantQuery($collection)} - AND _type = '{$type}' - AND _permission IN (" . \implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { - $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; - $removeBindKeys[] = ':' . $bindKey; - $removeBindValues[$bindKey] = $permissionsToRemove[$i]; - - return ':' . $bindKey; - }, \array_keys($permissionsToRemove))) . - ") - )"; - } - } + return $rows; + } - // Get added Permissions - $additions = []; - foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($updates->getPermissionsByType($type), $permissions[$type]); - if (!empty($diff)) { - $additions[$type] = $diff; - } - } + /** + * @param list> $rows each starting with `_uid`, then `_tenant` under shared tables + * @return array> + */ + private function rowKeys(array $rows): array + { + $keys = []; + foreach ($rows as $row) { + $keys[$this->rowTenant($row)][$this->rowId($row)] = true; + } + + return $keys; + } + + /** + * @param list $row + */ + private function rowId(array $row): string + { + $id = $row[0] ?? null; - // Build inner query to add permissions - if (!empty($additions)) { - foreach ($additions as $type => $permissionsToAdd) { - foreach ($permissionsToAdd as $i => $permission) { - $bindKey = '_uid_' . $index; - $addBindValues[$bindKey] = $document->getId(); + return \is_scalar($id) ? (string) $id : ''; + } - $bindKey = 'add_' . $type . '_' . $index . '_' . $i; - $addBindValues[$bindKey] = $permission; + /** + * @param list $row + */ + private function rowTenant(array $row): string + { + $tenant = $this->sharedTables ? ($row[1] ?? null) : null; - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; + return \is_scalar($tenant) ? (string) $tenant : ''; + } - if ($this->sharedTables) { - $addQuery .= ", :_tenant)"; - } else { - $addQuery .= ")"; - } + /** + * @param array> $keys + */ + private function hasKey(array $keys, Document $document): bool + { + [$tenant, $id] = $this->documentKey($document); - if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { - $addQuery .= ', '; - } - } - } - if ($index !== \array_key_last($documents)) { - $addQuery .= ', '; - } - } + return isset($keys[$tenant][$id]); + } + + /** + * @return array{string, string} + */ + private function documentKey(Document $document): array + { + $tenant = $this->documentTenant($document); + + return [$tenant === null ? '' : (string) $tenant, $document->getId()]; + } + + private function documentTenant(Document $document): int|string|null + { + return $this->sharedTables ? ($document->getTenant() ?? $this->currentTenant()) : null; + } + + /** + * @param array $documents + * @param array $skipPermissions + * + * @throws DatabaseException + */ + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int + { + if (empty($documents)) { + return 0; + } + + $this->syncWriteHooks(); + + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); + + $attributes = $updates->getAttributes(); + + if (! empty($updates->getUpdatedAt())) { + $attributes[Storage::UPDATED_AT] = $updates->getUpdatedAt(); + } + + if (! empty($updates->getCreatedAt())) { + $attributes[Storage::CREATED_AT] = $updates->getCreatedAt(); + } + + if ($updates->offsetExists(Document::PERMISSIONS)) { + $attributes[Storage::PERMISSIONS] = json_encode($updates->getPermissions()); + } + + if (empty($attributes)) { + return 0; + } + + $name = $this->filter($collection); + + $builder = $this->newBuilder($name); + + // Single pass over update attributes, bucketing into regular / spatial / + // operator and applying JSON / boolean conversions inline. Hoisted + // guards keep the hot path branch-light. + $spatialMap = \array_fill_keys($spatialAttributes, true); + $intBools = $this->supports(Capability::IntegerBooleans); + + $regularRow = []; + $spatialRows = []; + $operators = []; + + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; + + continue; } - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); + if (isset($spatialMap[$attribute])) { + $spatialRows[$this->filter($attribute)] = $this->encodeSpatialWriteValue($value); - $stmtRemovePermissions = $this->getPDO()->prepare(" - DELETE - FROM {$this->getSQLTable($name . '_perms')} - WHERE ({$removeQuery}) - "); + continue; + } - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); - } + $column = $this->filter($attribute); - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); - } - $stmtRemovePermissions->execute(); + if (\is_array($value)) { + $value = \json_encode($value); + } + if ($intBools && \is_bool($value)) { + $value = (int) $value; } - if (!empty($addQuery)) { - $sqlAddPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission - "; + $regularRow[$column] = $value; + } - if ($this->sharedTables) { - $sqlAddPermissions .= ', _tenant)'; - } else { - $sqlAddPermissions .= ')'; - } + if (! empty($regularRow)) { + $builder->set($regularRow); + } - $sqlAddPermissions .= " VALUES {$addQuery}"; + foreach ($spatialRows as $column => $value) { + $builder->setRaw($column, $this->getSpatialGeometryFromText('?'), [$value]); + } - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + foreach ($operators as $attribute => $operator) { + $column = $this->filter($attribute); + /** @var Operator $operator */ + $expression = $this->getOperatorBuilderExpression($column, $operator); + $builder->setRaw($column, $expression->sql, $expression->bindings); + } - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); - } + $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); + $builder->filter([BaseQuery::equal(Storage::SEQUENCE, \array_values($sequences))]); - if ($this->sharedTables) { - $stmtAddPermissions->bindValue(':_tenant', $this->tenant); - } + $result = $builder->update(); + $statement = $this->executeResult($result, Event::DocumentsUpdate); - $stmtAddPermissions->execute(); - } + try { + $this->execute($statement); + } catch (PDOException $e) { + throw $this->processException($e); } + $affected = $statement->rowCount(); + + $context = $this->writeContext($skipPermissions); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentBatchUpdate($name, $updates, $documents, $context)); + return $affected; } + /** + * @throws DatabaseException + */ + #[\Override] + public function upsertDocument(Document $collection, Change $change): Document + { + return $this->upsertDocuments($collection, [$change])[0]; + } /** - * Delete Documents - * - * @param string $collection - * @param array $sequences - * @param array $permissionIds + * @param array $changes + * @return array * - * @return int * @throws DatabaseException */ - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + #[\Override] + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array { - if (empty($sequences)) { - return 0; + if ($changes === []) { + return []; } - try { - $name = $this->filter($collection); + $this->syncWriteHooks(); - $sql = " - DELETE FROM {$this->getSQLTable($name)} - WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") - {$this->getTenantQuery($collection)} - "; + try { + $spatialAttributes = $this->getSpatialAttributes($collection); - $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); + /** @var array $attributeDefaults */ + $attributeDefaults = []; + foreach (self::collectionAttributes($collection) as $declared) { + $attributeDefaults[$declared->key] = $declared->default; + } - $stmt = $this->getPDO()->prepare($sql); + $collection = $collection->getId(); + $name = $this->filter($collection); - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); - } + $hasOperators = false; + $firstChange = $changes[0]; + $firstDoc = $firstChange->new; + $firstExtracted = Operator::extractOperators($firstDoc->getAttributes()); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); + if (! empty($firstExtracted['operators'])) { + $hasOperators = true; + } else { + foreach ($changes as $change) { + $doc = $change->new; + $extracted = Operator::extractOperators($doc->getAttributes()); + if (! empty($extracted['operators'])) { + $hasOperators = true; + break; + } + } } - if (!$stmt->execute()) { - throw new DatabaseException('Failed to delete documents'); - } + if (! $hasOperators) { + $this->executeUpsertBatch($name, $changes, $spatialAttributes, $increase ?? '', [], $attributeDefaults, false); + } else { + $groups = []; - if (!empty($permissionIds)) { - $sql = " - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _document IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($permissionIds))) . ") - {$this->getTenantQuery($collection)} - "; + foreach ($changes as $change) { + $document = $change->new; + $extracted = Operator::extractOperators($document->getAttributes()); + $operators = $extracted['operators']; - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + if (empty($operators)) { + $signature = 'no_ops'; + } else { + $parts = []; + foreach ($operators as $attribute => $operation) { + $parts[] = $attribute.':'.$operation->getMethod()->value.':'.json_encode($operation->getValues()); + } + sort($parts); + $signature = implode('|', $parts); + } - $stmtPermissions = $this->getPDO()->prepare($sql); + if (! isset($groups[$signature])) { + $groups[$signature] = [ + 'documents' => [], + 'operators' => $operators, + ]; + } - foreach ($permissionIds as $id => $value) { - $stmtPermissions->bindValue(":_id_{$id}", $value); + $groups[$signature]['documents'][] = $change; } - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); + foreach ($groups as $group) { + $this->executeUpsertBatch($name, $group['documents'], $spatialAttributes, '', $group['operators'], $attributeDefaults, true); } + } - if (!$stmtPermissions->execute()) { - throw new DatabaseException('Failed to delete permissions'); - } + $context = $this->writeContext(); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpsert($name, $changes, $context)); + } catch (PDOException $e) { + throw $this->processException($e); + } + + return \array_map(static fn (Change $change): Document => $change->new, $changes); + } + + /** + * @param array $sequences + * @param array $permissionIds + * + * @throws DatabaseException + */ + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + { + if (empty($sequences)) { + return 0; + } + + $this->syncWriteHooks(); + + try { + $name = $this->filter($collection->getId()); + + $builder = $this->newBuilder($name); + $builder->filter([BaseQuery::equal(Storage::SEQUENCE, \array_values($sequences))]); + $result = $builder->delete(); + $statement = $this->executeResult($result, Event::DocumentsDelete); + + if (! $this->execute($statement)) { + throw new DatabaseException('Failed to delete documents'); } - } catch (\Throwable $e) { + + $context = $this->writeContext(); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentDelete($name, \array_values($permissionIds), $context)); + } catch (Throwable $e) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } - return $stmt->rowCount(); + return $statement->rowCount(); } /** * Assign internal IDs for the given documents * - * @param string $collection - * @param array $documents + * @param array $documents * @return array + * * @throws DatabaseException */ - public function getSequences(string $collection, array $documents): array + #[\Override] + public function getSequences(Document $collection, array $documents): array { $documentIds = []; - $keys = []; - $binds = []; $tenants = []; + $keyedByTenant = $this->sharedTables && $this->tenantPerDocument; - foreach ($documents as $i => $document) { + foreach ($documents as $document) { if (empty($document->getSequence())) { $documentIds[] = $document->getId(); - $key = ":uid_{$i}"; - - $binds[$key] = $document->getId(); - $keys[] = $key; - - if ($this->sharedTables) { + if ($keyedByTenant) { $tenant = $document->getTenant(); - - // One placeholder per distinct tenant - if (!\in_array($tenant, $tenants, true)) { - $binds[':_tenant_'.\count($tenants)] = $tenant; + if (! \in_array($tenant, $tenants, true)) { $tenants[] = $tenant; } } @@ -889,643 +1323,1444 @@ public function getSequences(string $collection, array $documents): array return $documents; } - $placeholders = implode(',', array_values($keys)); + $builder = $this->newBuilder($collection->getId(), tenants: $tenants); + $builder->select($keyedByTenant + ? [Storage::UID, Storage::SEQUENCE, Storage::TENANT] + : [Storage::UID, Storage::SEQUENCE]); + $builder->filter([BaseQuery::equal(Storage::UID, $documentIds)]); - $sql = " - SELECT _uid, _id - FROM {$this->getSQLTable($collection)} - WHERE {$this->quote('_uid')} IN ({$placeholders}) - {$this->getTenantQuery($collection, tenantCount: \count($tenants))} - "; + $result = $builder->build(); + $statement = $this->executeResult($result, Event::DocumentRead); + $this->execute($statement); - $stmt = $this->getPDO()->prepare($sql); + $sequenceKey = static fn (mixed $tenant, mixed $id): string => (\is_scalar($tenant) ? (string) $tenant : '')."\0".(\is_scalar($id) ? (string) $id : ''); - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value); + if ($keyedByTenant) { + $sequences = []; + /** @var array $row */ + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $sequences[$sequenceKey($row[Storage::TENANT] ?? null, $row[Storage::UID] ?? null)] = $row[Storage::SEQUENCE] ?? null; + } + } else { + /** @var array $sequences */ + $sequences = $statement->fetchAll(PDO::FETCH_KEY_PAIR); } - - $stmt->execute(); - $sequences = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR); // Fetch as [documentId => sequence] - $stmt->closeCursor(); + $statement->closeCursor(); foreach ($documents as $document) { - if (isset($sequences[$document->getId()])) { - $document['$sequence'] = $sequences[$document->getId()]; + $key = $keyedByTenant ? $sequenceKey($document->getTenant(), $document->getId()) : $document->getId(); + if (isset($sequences[$key])) { + $document[Document::SEQUENCE] = $sequences[$key]; } } return $documents; } - /** - * Get max STRING limit - * - * @return int - */ - public function getLimitForString(): int - { - return 4294967295; - } + #[\Override] + public function increaseDocumentAttribute( + Document $collection, + string $id, + string $attribute, + int|float|string $value, + string $updatedAt, + int|float|string|null $min = null, + int|float|string|null $max = null + ): bool { + $name = $this->filter($collection->getId()); + $attribute = $this->filter($attribute); - /** - * Get max INT limit - * - * @return int - */ - public function getLimitForInt(): int - { - return 4294967295; - } + $builder = $this->newBuilder($name); + $builder->setRaw($attribute, 'COALESCE('.$this->quote($attribute).', 0) + ?', [$value]); + $builder->set([Storage::UPDATED_AT => $updatedAt]); - /** - * Get max BIGINT limit - * - * @return int - */ - public function getLimitForBigInt(): int - { - return Database::MAX_BIG_INT; - } + $filters = [BaseQuery::equal(Storage::UID, [$id])]; + if ($max !== null) { + $withinMaximum = BaseQuery::lessThanEqual($attribute, $max); + $filters[] = (float) $max >= 0 ? BaseQuery::or([$withinMaximum, BaseQuery::isNull($attribute)]) : $withinMaximum; + } + if ($min !== null) { + $withinMinimum = BaseQuery::greaterThanEqual($attribute, $min); + $filters[] = (float) $min <= 0 ? BaseQuery::or([$withinMinimum, BaseQuery::isNull($attribute)]) : $withinMinimum; + } + $builder->filter($filters); - /** - * Get maximum column limit. - * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema - * Can be inherited by MySQL since we utilize the InnoDB engine - * - * @return int - */ - public function getLimitForAttributes(): int - { - return 1017; - } + $result = $builder->update(); + $event = $value < 0 ? Event::DocumentDecrease : Event::DocumentIncrease; + $statement = $this->executeResult($result, $event); - /** - * Get maximum index limit. - * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema - * - * @return int - */ - public function getLimitForIndexes(): int - { - return 64; - } + try { + $this->execute($statement); + } catch (PDOException $e) { + throw $this->processException($e); + } - /** - * Is schemas supported? - * - * @return bool - */ - public function getSupportForSchemas(): bool - { return true; } - /** - * Is index supported? - * - * @return bool - */ - public function getSupportForIndex(): bool + #[\Override] + public function deleteDocument(Document $collection, string $id): bool { - return true; - } + try { + $this->syncWriteHooks(); - /** - * Are attributes supported? - * - * @return bool - */ - public function getSupportForAttributes(): bool - { - return true; - } + $name = $this->filter($collection->getId()); - /** - * Is unique index supported? - * - * @return bool - */ - public function getSupportForUniqueIndex(): bool - { - return true; - } + $builder = $this->newBuilder($name); + $filters = [BaseQuery::equal(Storage::UID, [$id])]; + $builder->filter($filters); + $result = $builder->delete(); + $statement = $this->executeResult($result, Event::DocumentDelete); - /** - * Is fulltext index supported? - * - * @return bool - */ - public function getSupportForFulltextIndex(): bool - { - return true; - } + if (! $this->execute($statement)) { + throw new DatabaseException('Failed to delete document'); + } - /** - * Are FOR UPDATE locks supported? - * - * @return bool - */ - public function getSupportForUpdateLock(): bool - { - return true; - } + $deleted = $statement->rowCount(); - /** - * Is Attribute Resizing Supported? - * - * @return bool - */ - public function getSupportForAttributeResizing(): bool - { - return true; - } + $context = $this->writeContext(); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentDelete($name, [$id], $context)); + } catch (\Throwable $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } - /** - * Are batch operations supported? - * - * @return bool - */ - public function getSupportForBatchOperations(): bool - { - return true; + return $deleted > 0; } /** - * Is get connection id supported? + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @return array * - * @return bool + * @throws DatabaseException + * @throws TimeoutException + * @throws Exception */ - public function getSupportForGetConnectionId(): bool + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array { - return true; - } + $collectionDoc = $collection; + $collection = $collection->getId(); + $name = $this->filter($collection); + $roles = $this->authorization->getRoles(); + $alias = Query::DEFAULT_ALIAS; - /** - * Is cache fallback supported? - * - * @return bool - */ - public function getSupportForCacheSkipOnFailure(): bool - { - return true; - } + // Fast path: trivial SELECT * with default ORDER BY _id and LIMIT/OFFSET. + // Triggered when there are no filters/joins/aggregations/cursor queries, + // a single default order attribute, ascending, no shared tenant, and no + // active permission filter. This is the common "list documents" case + // and bypasses Builder allocation entirely. + if ( + empty($queries) + && empty($cursor) + && ! $this->authorization->getStatus() + && ! $this->sharedTables + && (count($orderAttributes) === 1) + && ($orderAttributes[0] === Document::SEQUENCE) + && (empty($orderTypes) || ($orderTypes[0] ?? OrderDirection::Asc) === OrderDirection::Asc) + && $cursorDirection === CursorDirection::After + ) { + $internalOrder = $this->quote($this->getInternalKeyForAttribute(Document::SEQUENCE)); + $tableExpr = $this->getTable($name); + $aliasQuoted = $this->quote($alias); + $pageLimit = $limit ?? ($offset !== null && $offset > 0 ? self::UNBOUNDED_LIMIT : null); + $limitClause = $pageLimit !== null ? " LIMIT {$pageLimit}" : ''; + $offsetClause = $offset !== null && $offset > 0 ? " OFFSET {$offset}" : ($pageLimit !== null ? ' OFFSET 0' : ''); + + $sql = "SELECT * FROM {$tableExpr} AS {$aliasQuoted} ORDER BY {$internalOrder} ASC{$limitClause}{$offsetClause}"; + $statement = null; + $rows = []; + $exception = null; + + try { + $statement = $this->prepareStatement($sql, Event::DocumentFind); + $this->describeStatement($statement, [], $name); + $this->execute($statement); + /** @var array> $rows */ + $rows = $statement->fetchAll(); + } catch (PDOException $e) { + $exception = $e; + } finally { + if ($statement !== null) { + try { + $statement->closeCursor(); + } catch (PDOException $e) { + $exception ??= $e; + } + } + } - public function getSupportForCaching(): bool - { - return true; - } + if ($exception !== null) { + throw $this->processException($exception); + } - /** - * Is hostname supported? - * - * @return bool - */ - public function getSupportForHostname(): bool - { - return true; - } + $documents = []; + foreach ($rows as $row) { + $this->remapRow($row); + $documents[] = Document::fromRow($row); + } - /** - * Returns the INSERT keyword, optionally with IGNORE for duplicate handling. - * Override in adapter subclasses for DB-specific syntax. - */ - protected function getInsertKeyword(): string - { - return $this->skipDuplicates ? 'INSERT IGNORE INTO' : 'INSERT INTO'; - } + return $documents; + } - /** - * Returns a suffix appended after VALUES clause for duplicate handling. - * Override in adapter subclasses (e.g., Postgres uses ON CONFLICT DO NOTHING). - */ - protected function getInsertSuffix(string $table): string - { - return ''; - } + // Single pass partitioning: pull vector queries out for ORDER BY and + // detect aggregation/join shape in the same walk. Each Method::value + // is checked once per query rather than three times. + // Defer the defensive `clone` until we know the query path will mutate + // the Query objects (joins or aggregations-with-joins). The vast + // majority of finds take neither path and don't need a per-query + // clone allocation. + $vectorQueries = []; + $otherQueries = []; + $adapterFilterQueries = []; + $hasAggregation = false; + $hasJoins = false; + $hasDistinct = false; - /** - * Returns a suffix for the permissions INSERT statement when ignoring duplicates. - * Override in adapter subclasses for DB-specific syntax. - */ - protected function getInsertPermissionsSuffix(): string - { - return ''; - } + foreach ($queries as $query) { + $method = $query->getMethod(); - /** - * Get current attribute count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfAttributes(Document $collection): int - { - $attributes = \count($collection->getAttribute('attributes') ?? []); + if ($method->isVector()) { + $vectorQueries[] = $query; - return $attributes + $this->getCountOfDefaultAttributes(); - } + continue; + } - /** - * Get current index count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfIndexes(Document $collection): int - { - $indexes = \count($collection->getAttribute('indexes') ?? []); - return $indexes + $this->getCountOfDefaultIndexes(); - } + if ($this->isAdapterFilterQuery($query)) { + $adapterFilterQueries[] = $query; - /** - * Returns number of attributes used by default. - * - * @return int - */ - public function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } + continue; + } - /** - * Returns number of indexes used by default. - * - * @return int - */ - public function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); - } + $otherQueries[] = $query; - /** - * Get maximum width, in bytes, allowed for a SQL row - * Return 0 when no restrictions apply - * - * @return int - */ - public function getDocumentSizeLimit(): int - { - return 65535; - } + if ($method->isAggregate() || $method === Method::GroupBy) { + $hasAggregation = true; + } + if ($method->isJoin()) { + $hasJoins = true; + } + if ($method === Method::Distinct) { + $hasDistinct = true; + } + } - /** - * Estimate maximum number of bytes required to store a document in $collection. - * Byte requirement varies based on column type and size. - * Needed to satisfy MariaDB/MySQL row width limit. - * - * @param Document $collection - * @return int - * @throws DatabaseException - */ - public function getAttributeWidth(Document $collection): int - { - /** - * @link https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html - * - * `_id` bigint => 8 bytes - * `_uid` varchar(255) => 1021 (4 * 255 + 1) bytes - * `_tenant` int => 4 bytes - * `_createdAt` datetime(3) => 7 bytes - * `_updatedAt` datetime(3) => 7 bytes - * `_permissions` mediumtext => 20 - */ + $queries = $otherQueries; - $total = 1067; + if ($hasJoins) { + $queries = \array_map(static fn ($query) => clone $query, $queries); + } - $attributes = $collection->getAttributes()['attributes'] ?? []; + $joinTablePrefixes = []; + if ($hasJoins) { + $joinTablePrefixes = $this->remapJoinQueries($queries); + } + $unindexed = $this->unindexedJoins($collectionDoc, $queries, $joinTablePrefixes); + + $hasPreservingOuterJoin = false; + if ($hasJoins) { + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method === Method::RightJoin || $method === Method::FullOuterJoin) { + $hasPreservingOuterJoin = true; + break; + } + } + } - foreach ($attributes as $attribute) { - /** - * Json / Longtext - * only the pointer contributes 20 bytes - * data is stored externally - */ + if ($joinTablePrefixes !== []) { + [$orderAttributes, $cursor] = $this->qualifyJoinedOrders($orderAttributes, $cursor, $collectionDoc, $joinTablePrefixes); + } - if ($attribute['array'] ?? false) { - $total += 20; - continue; + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + $internalKeyCache = []; + $resolveInternalKey = function (string $attribute) use (&$internalKeyCache, $joinAliases): string { + return $internalKeyCache[$attribute] + ??= $this->qualifyOrderAttribute($attribute, $joinAliases); + }; + + $emulatesFullOuterJoin = $this->needsFullOuterJoinEmulation($this->dialectBuilder(), $queries); + + if ($emulatesFullOuterJoin && $hasAggregation) { + $results = $this->findFullOuterJoinAggregate( + $collectionDoc, + $queries, + $joinTablePrefixes, + $hasDistinct, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + $orderAttributes, + $orderTypes, + $limit, + $offset, + $cursor, + $cursorDirection, + $resolveInternalKey, + ); + } elseif ($emulatesFullOuterJoin) { + if ($hasDistinct) { + $this->assertDistinctOrderIsSelected($queries, $orderAttributes, $orderTypes, $joinAliases); } - switch ($attribute['type']) { - case Database::VAR_ID: - $total += 8; // BIGINT 8 bytes - break; + [$leftQueries, $rightQueries] = $this->emulateFullOuterJoin($queries, $alias); + $leftPreserving = $this->keepsUnmatchedRows($leftQueries); + + $left = $this->newBuilder($name, $alias, $leftPreserving, unindexed: $unindexed); + $leftProjected = $this->configureFindBuilder( + $left, + $collectionDoc, + $leftQueries, + $joinTablePrefixes, + $hasAggregation, + $hasDistinct, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + orderAttributes: $orderAttributes, + ); + $this->applyFullOuterJoinOrderProjection( + $left, + $collectionDoc, + $alias, + $orderAttributes, + $orderTypes, + $leftProjected, + $joinTablePrefixes, + ); + $this->applyFindCursor( + $left, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $resolveInternalKey, + nullable: true, + ); - case Database::VAR_STRING: - /** - * Text / Mediumtext / Longtext - * only the pointer contributes 20 bytes to the row size - * data is stored externally - */ + $right = $this->newBuilder($name, $alias, true, unindexed: $unindexed); + $rightProjected = $this->configureFindBuilder( + $right, + $collectionDoc, + $rightQueries, + $joinTablePrefixes, + $hasAggregation, + $hasDistinct, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + orderAttributes: $orderAttributes, + ); + $this->applyFullOuterJoinOrderProjection( + $right, + $collectionDoc, + $alias, + $orderAttributes, + $orderTypes, + $rightProjected, + $joinTablePrefixes, + ); + $this->applyFindCursor( + $right, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $resolveInternalKey, + nullable: true, + ); - $total += match (true) { - $attribute['size'] > $this->getMaxVarcharLength() => 20, - $attribute['size'] > 255 => $attribute['size'] * 4 + 2, // VARCHAR(>255) + 2 length - default => $attribute['size'] * 4 + 1, // VARCHAR(<=255) + 1 length - }; + if ($hasDistinct) { + $left->union($right); + } else { + $left->unionAll($right); + } + $this->applyFindPage($left, $orderAttributes, $orderTypes, $limit, $offset, $cursorDirection, afterUnion: true); + $results = $this->executeSelect($left, Event::DocumentFind, $name); + } else { + $bound = $hasJoins && ! $hasAggregation && ! $hasDistinct && $vectorQueries === [] && $this->boundsJoinedSort() + ? $this->boundedPage($collectionDoc, $queries, $adapterFilterQueries, $joinTablePrefixes, $orderAttributes, $orderTypes, $limit, $offset, $cursor) + : null; + + $builder = $this->newBuilder($name, $alias, $hasPreservingOuterJoin, unindexed: $unindexed); + $hasSelectionProjection = $this->configureFindBuilder( + $builder, + $collectionDoc, + $bound?->withoutSearches($queries) ?? $queries, + $joinTablePrefixes, + $hasAggregation, + $hasDistinct, + $bound?->withoutSearches($adapterFilterQueries) ?? $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + orderAttributes: $orderAttributes, + ); - break; + $vectorDistance = null; + $vectorQuery = $vectorQueries[0] ?? null; + if ($vectorQuery !== null) { + $vectorDistance = $this->getVectorOrderRaw($vectorQuery, $alias); + } - case Database::VAR_VARCHAR: - $total += match (true) { - $attribute['size'] > 255 => $attribute['size'] * 4 + 2, // VARCHAR(>255) + 2 length - default => $attribute['size'] * 4 + 1, // VARCHAR(<=255) + 1 length - }; - break; + if ($vectorDistance !== null && $vectorQuery !== null) { + $vectorAttribute = $this->quote($this->filter($vectorQuery->getAttribute())); + $builder->whereRaw($this->quote($alias).".{$vectorAttribute} IS NOT NULL"); + } - case Database::VAR_TEXT: - case Database::VAR_MEDIUMTEXT: - case Database::VAR_LONGTEXT: - $total += 20; // Pointer storage for TEXT types - break; + if (! empty($cursor) && $vectorDistance !== null && ! $hasDistinct) { + $distance = $cursor[Document::DISTANCE] ?? null; + if (! \is_numeric($distance)) { + throw new QueryException('Vector cursor is missing its distance'); + } + if (empty($orderAttributes)) { + throw new QueryException('Vector cursor requires a unique order attribute'); + } - case Database::VAR_INTEGER: - if ($attribute['size'] >= 8) { - $total += 8; // BIGINT 8 bytes - } else { - $total += 4; // INT 4 bytes - } - break; + $vectorCursor = $this->getVectorCursorCondition( + $vectorDistance, + (float) $distance, + \array_values($orderAttributes), + \array_values($orderTypes), + $cursor, + $cursorDirection, + $alias, + $resolveInternalKey, + nullable: $hasJoins, + ); + $builder->whereRaw($vectorCursor->sql, $vectorCursor->bindings); + } - case Database::VAR_BIGINT: - $total += 8; // BIGINT 8 bytes - break; + if ($vectorDistance === null || $hasDistinct) { + $this->applyFindCursor( + $builder, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $resolveInternalKey, + nullable: $hasJoins, + ); + } - case Database::VAR_FLOAT: - $total += 8; // DOUBLE 8 bytes - break; + // Vector ordering (comes first for similarity search) + if ($vectorDistance !== null && ! $hasAggregation && ! $hasDistinct) { + $vectorOrder = $vectorDistance->sql; + if (! empty($cursor) && $cursorDirection === CursorDirection::Before) { + $vectorOrder .= ' DESC'; + } + $builder->orderByRaw($vectorOrder, $vectorDistance->bindings); - case Database::VAR_BOOLEAN: - $total += 1; // TINYINT(1) 1 bytes - break; + if (! $hasSelectionProjection) { + $builder->select(['*']); + } + $builder->selectRaw( + $this->getSqlReadableDistance($vectorDistance->sql).' AS '.$this->quote(Storage::DISTANCE), + $vectorDistance->bindings + ); + } - case Database::VAR_RELATIONSHIP: - $total += Database::LENGTH_KEY * 4 + 1; // VARCHAR(<=255) - break; + if ($bound !== null) { + $this->joinFromBoundedPage( + $builder, + $collectionDoc, + $bound, + $cursor, + $cursorDirection, + $resolveInternalKey, + $name, + $alias, + $roles, + $forPermission, + ); + } - case Database::VAR_DATETIME: - /** - * 1 byte year + month - * 1 byte for the day - * 3 bytes for the hour, minute, and second - * 2 bytes miliseconds DATETIME(3) - */ - $total += 7; - break; + $this->applyFindPage($builder, $orderAttributes, $orderTypes, $limit, $offset, $cursorDirection, joinAliases: $joinAliases); + $results = $this->executeSelect($builder, Event::DocumentFind, $name); + } - case Database::VAR_OBJECT: - /** - * JSONB/JSON type - * Only the pointer contributes 20 bytes to the row size - * Data is stored externally - */ - $total += 20; - break; + $documents = []; - case Database::VAR_POINT: - $total += $this->getMaxPointSize(); - break; - case Database::VAR_LINESTRING: - case Database::VAR_POLYGON: - $total += 20; - break; + if ($hasAggregation) { + $inputs = $this->bitwiseInputs($queries); + foreach ($results as $row) { + /** @var array $row */ + $documents[] = Document::fromRow($this->bitwiseResults($row, $inputs)); + } - case Database::VAR_VECTOR: - // Each dimension is typically 4 bytes (float32) - $total += ($attribute['size'] ?? 0) * 4; - break; + return $documents; + } - default: - throw new DatabaseException('Unknown type: ' . $attribute['type']); + foreach ($results as $row) { + /** @var array $row */ + $this->remapRow($row); + $documents[] = Document::fromRow($row); + } + + if ($cursorDirection === CursorDirection::Before) { + $documents = \array_reverse($documents); + } + + return $documents; + } + + /** + * @param array $bindings + * @return array + * + * @throws DatabaseException + */ + #[\Override] + public function rawQuery(string $query, array $bindings = []): array + { + try { + $statement = $this->prepareStatement($query); + foreach ($bindings as $i => $value) { + $statement->bindValue($i + 1, $value, $this->getPdoType($value)); } + $this->execute($statement); + } catch (PDOException $e) { + throw $this->processException($e); } - return $total; + $results = $statement->fetchAll(); + $statement->closeCursor(); + + $documents = []; + foreach ($results as $row) { + /** @var array $row */ + $documents[] = Document::fromRow($row); + } + + return $documents; } /** - * Get list of keywords that cannot be used - * Refference: https://mariadb.com/kb/en/reserved-words/ + * @param array $queries * - * @return array + * @throws Exception + * @throws PDOException */ - public function getKeywords(): array + #[\Override] + public function count(Document $collection, array $queries = [], ?int $max = null): int { - return [ - 'ACCESSIBLE', - 'ADD', - 'ALL', - 'ALTER', - 'ANALYZE', - 'AND', - 'AS', - 'ASC', - 'ASENSITIVE', - 'BEFORE', - 'BETWEEN', - 'BIGINT', - 'BINARY', - 'BLOB', - 'BOTH', - 'BY', - 'CALL', - 'CASCADE', - 'CASE', - 'CHANGE', - 'CHAR', - 'CHARACTER', - 'CHECK', - 'COLLATE', - 'COLUMN', - 'CONDITION', - 'CONSTRAINT', - 'CONTINUE', - 'CONVERT', - 'CREATE', - 'CROSS', - 'CURRENT_DATE', - 'CURRENT_ROLE', - 'CURRENT_TIME', - 'CURRENT_TIMESTAMP', - 'CURRENT_USER', - 'CURSOR', - 'DATABASE', - 'DATABASES', - 'DAY_HOUR', - 'DAY_MICROSECOND', - 'DAY_MINUTE', - 'DAY_SECOND', - 'DEC', - 'DECIMAL', - 'DECLARE', - 'DEFAULT', - 'DELAYED', - 'DELETE', - 'DELETE_DOMAIN_ID', - 'DESC', - 'DESCRIBE', - 'DETERMINISTIC', - 'DISTINCT', - 'DISTINCTROW', - 'DIV', - 'DO_DOMAIN_IDS', - 'DOUBLE', - 'DROP', - 'DUAL', - 'EACH', - 'ELSE', - 'ELSEIF', - 'ENCLOSED', - 'ESCAPED', - 'EXCEPT', - 'EXISTS', - 'EXIT', - 'EXPLAIN', - 'FALSE', - 'FETCH', - 'FLOAT', - 'FLOAT4', - 'FLOAT8', - 'FOR', - 'FORCE', - 'FOREIGN', - 'FROM', - 'FULLTEXT', - 'GENERAL', - 'GRANT', - 'GROUP', - 'HAVING', - 'HIGH_PRIORITY', - 'HOUR_MICROSECOND', - 'HOUR_MINUTE', - 'HOUR_SECOND', - 'IF', - 'IGNORE', - 'IGNORE_DOMAIN_IDS', - 'IGNORE_SERVER_IDS', - 'IN', - 'INDEX', - 'INFILE', - 'INNER', - 'INOUT', - 'INSENSITIVE', - 'INSERT', - 'INT', - 'INT1', - 'INT2', - 'INT3', - 'INT4', - 'INT8', - 'INTEGER', - 'INTERSECT', - 'INTERVAL', - 'INTO', - 'IS', - 'ITERATE', - 'JOIN', - 'KEY', - 'KEYS', - 'KILL', - 'LEADING', - 'LEAVE', - 'LEFT', - 'LIKE', - 'LIMIT', - 'LINEAR', - 'LINES', - 'LOAD', - 'LOCALTIME', - 'LOCALTIMESTAMP', - 'LOCK', - 'LONG', - 'LONGBLOB', - 'LONGTEXT', - 'LOOP', - 'LOW_PRIORITY', - 'MASTER_HEARTBEAT_PERIOD', - 'MASTER_SSL_VERIFY_SERVER_CERT', - 'MATCH', - 'MAXVALUE', - 'MEDIUMBLOB', - 'MEDIUMINT', - 'MEDIUMTEXT', - 'MIDDLEINT', - 'MINUTE_MICROSECOND', - 'MINUTE_SECOND', - 'MOD', - 'MODIFIES', - 'NATURAL', - 'NOT', - 'NO_WRITE_TO_BINLOG', - 'NULL', - 'NUMERIC', - 'OFFSET', - 'ON', - 'OPTIMIZE', - 'OPTION', - 'OPTIONALLY', - 'OR', - 'ORDER', - 'OUT', - 'OUTER', - 'OUTFILE', - 'OVER', - 'PAGE_CHECKSUM', - 'PARSE_VCOL_EXPR', - 'PARTITION', - 'POSITION', - 'PRECISION', - 'PRIMARY', - 'PROCEDURE', - 'PURGE', - 'RANGE', - 'READ', - 'READS', - 'READ_WRITE', - 'REAL', - 'RECURSIVE', - 'REF_SYSTEM_ID', - 'REFERENCES', - 'REGEXP', - 'RELEASE', - 'RENAME', - 'REPEAT', - 'REPLACE', - 'REQUIRE', - 'RESIGNAL', - 'RESTRICT', - 'RETURN', - 'RETURNING', - 'REVOKE', - 'RIGHT', - 'RLIKE', - 'ROWS', - 'SCHEMA', - 'SCHEMAS', - 'SECOND_MICROSECOND', - 'SELECT', - 'SENSITIVE', - 'SEPARATOR', - 'SET', - 'SHOW', - 'SIGNAL', - 'SLOW', - 'SMALLINT', - 'SPATIAL', - 'SPECIFIC', - 'SQL', - 'SQLEXCEPTION', - 'SQLSTATE', - 'SQLWARNING', - 'SQL_BIG_RESULT', - 'SQL_CALC_FOUND_ROWS', - 'SQL_SMALL_RESULT', - 'SSL', - 'STARTING', - 'STATS_AUTO_RECALC', - 'STATS_PERSISTENT', - 'STATS_SAMPLE_PAGES', - 'STRAIGHT_JOIN', + $collectionDoc = $collection; + $collection = $collection->getId(); + $name = $this->filter($collection); + $roles = $this->authorization->getRoles(); + $alias = Query::DEFAULT_ALIAS; + + $otherQueries = []; + $hasJoins = false; + foreach ($queries as $query) { + if ($query->getMethod()->isVector()) { + continue; + } + $otherQueries[] = $query; + if ($query->getMethod()->isJoin()) { + $hasJoins = true; + } + } + + if ($hasJoins) { + $innerBuilder = $this->configureCountBuilder( + $collectionDoc, + $otherQueries, + $name, + $alias, + $roles, + $max, + ); + + return $this->executeWrappedCount($innerBuilder, $name); + } + + if ($otherQueries === []) { + return $this->countOf($this->fetchAggregate($collectionDoc, $name, $roles, 'COUNT(1)', '1', $max, Event::DocumentCount)); + } + + $otherQueries = $this->mainRowQueries($otherQueries, $collectionDoc); + $builder = $this->newBuilder($name, $alias); + + $filters = $this->compileRowFilters($builder, $otherQueries); + if ($filters !== null) { + return $this->countOf($this->fetchAggregate($collectionDoc, $name, $roles, 'COUNT(1)', '1', $max, Event::DocumentCount, $filters)); + } + + $this->applyFilters($builder, $otherQueries, $name, $alias); + + if ($this->authorization->getStatus() && $this->filtersPerDocument($collectionDoc)) { + $builder->addHook($this->newPermissionHook($name, $roles)); + } + + if ($max === null && $this->onlyNarrowsRows($otherQueries)) { + $builder->count('1', 'sum'); + + return $this->countOf($this->fetchAggregateRow($builder, Event::DocumentCount, $name)); + } + + $builder->selectRaw('1'); + if (! \is_null($max)) { + $builder->limit($max); + } + + return $this->executeWrappedCount($builder, $name); + } + + /** + * @param array $queries + * + * @throws Exception + * @throws PDOException + */ + #[\Override] + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float + { + $collectionDoc = $collection; + $collection = $collection->getId(); + $name = $this->filter($collection); + $roles = $this->authorization->getRoles(); + $alias = Query::DEFAULT_ALIAS; + + $otherQueries = []; + $hasJoins = false; + foreach ($queries as $query) { + if ($query->getMethod()->isVector()) { + continue; + } + $otherQueries[] = $query; + if ($query->getMethod()->isJoin()) { + $hasJoins = true; + } + } + + if ($hasJoins) { + $innerBuilder = $this->configureCountBuilder( + $collectionDoc, + $otherQueries, + $name, + $alias, + $roles, + $max, + $attribute, + ); + + return $this->executeWrappedSum($innerBuilder, 'sum_attr', $name); + } + + $attribute = $this->filter($attribute); + + $column = $this->quote($attribute); + + if ($otherQueries === []) { + return $this->sumOf($this->fetchAggregate($collectionDoc, $name, $roles, "SUM({$column})", $column, $max, Event::DocumentSum)); + } + + $otherQueries = $this->mainRowQueries($otherQueries, $collectionDoc); + $builder = $this->newBuilder($name, $alias); + + $filters = $this->compileRowFilters($builder, $otherQueries); + if ($filters !== null) { + return $this->sumOf($this->fetchAggregate($collectionDoc, $name, $roles, "SUM({$column})", $column, $max, Event::DocumentSum, $filters)); + } + + $this->applyFilters($builder, $otherQueries, $name, $alias); + + if ($this->authorization->getStatus() && $this->filtersPerDocument($collectionDoc)) { + $builder->addHook($this->newPermissionHook($name, $roles)); + } + + if ($max === null && $this->onlyNarrowsRows($otherQueries)) { + $builder->sum($attribute, 'sum'); + + return $this->sumOf($this->fetchAggregateRow($builder, Event::DocumentSum, $name)); + } + + $builder->select([$attribute]); + if (! \is_null($max)) { + $builder->limit($max); + } + + return $this->executeWrappedSum($builder, $attribute, $name); + } + + /** + * @param array $queries + * @param array $roles + */ + private function configureCountBuilder( + Document $collection, + array $queries, + string $name, + string $alias, + array $roles, + ?int $max, + ?string $sumAttribute = null, + ): SQLBuilder { + $queries = \array_map(static fn ($query) => clone $query, $queries); + + $adapterFilterQueries = []; + $filterQueries = []; + foreach ($queries as $query) { + if ($this->isAdapterFilterQuery($query)) { + $adapterFilterQueries[] = $query; + + continue; + } + $filterQueries[] = $query; + } + $queries = $filterQueries; + + $joinTablePrefixes = $this->remapJoinQueries($queries); + $unindexed = $this->unindexedJoins($collection, $queries, $joinTablePrefixes); + $selectRaw = $sumAttribute === null + ? '1' + : $this->qualifySumSelect($sumAttribute, $joinTablePrefixes, $collection).' AS '.$this->quote('sum_attr'); + + $hasPreservingOuterJoin = false; + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method === Method::RightJoin || $method === Method::FullOuterJoin) { + $hasPreservingOuterJoin = true; + break; + } + } + + if ($this->needsFullOuterJoinEmulation($this->dialectBuilder(), $queries)) { + [$leftQueries, $rightQueries] = $this->emulateFullOuterJoin($queries, $alias); + $leftPreserving = $this->keepsUnmatchedRows($leftQueries); + + $left = $this->newBuilder($name, $alias, $leftPreserving, unindexed: $unindexed); + $left->selectRaw($selectRaw); + $this->applyFindFilters( + $left, + $collection, + $leftQueries, + $joinTablePrefixes, + $adapterFilterQueries, + $name, + $alias, + $roles, + PermissionType::Read, + ); + + $right = $this->newBuilder($name, $alias, true, unindexed: $unindexed); + $right->selectRaw($selectRaw); + $this->applyFindFilters( + $right, + $collection, + $rightQueries, + $joinTablePrefixes, + $adapterFilterQueries, + $name, + $alias, + $roles, + PermissionType::Read, + ); + + $left->unionAll($right); + if (! \is_null($max)) { + $this->applyFindPage($left, [], [], $max, null, afterUnion: true); + } + + return $left; + } + + $builder = $this->newBuilder($name, $alias, $hasPreservingOuterJoin, unindexed: $unindexed); + $builder->selectRaw($selectRaw); + $this->applyFindFilters( + $builder, + $collection, + $queries, + $joinTablePrefixes, + $adapterFilterQueries, + $name, + $alias, + $roles, + PermissionType::Read, + ); + + if (! \is_null($max)) { + $builder->limit($max); + } + + return $builder; + } + + /** + * @param list $joinTablePrefixes + */ + private function qualifySumSelect(string $attribute, array $joinTablePrefixes, Document $collection): string + { + $aliasSet = \array_fill_keys(\array_column($joinTablePrefixes, 'alias'), true); + $aliasSet[Query::DEFAULT_ALIAS] = true; + $mainAttributes = []; + foreach (self::collectionAttributes($collection) as $declared) { + $mainAttributes[$declared->key] = true; + } + + $qualified = $this->qualifyDottedAttribute($attribute, $aliasSet, $mainAttributes); + if (! \str_contains($qualified, '.')) { + $qualified = Query::DEFAULT_ALIAS.'.'.$qualified; + } + + $dot = \strpos($qualified, '.'); + $prefix = \substr($qualified, 0, (int) $dot); + $name = \substr($qualified, (int) $dot + 1); + + return $this->quote($this->filter($prefix)).'.'.$this->quote($this->filter($name)); + } + + /** + * The row of a count() or sum(), written out instead of built: the same statement the builder + * makes, with the filters as the builder compiles them, the tenant condition newBuilder() adds + * and the permission condition of the permission hook, each from the hook itself, in the order + * the builder writes them. + * + * @param array $roles + * @return array + */ + private function fetchAggregate(Document $collection, string $name, array $roles, string $aggregate, string $column, ?int $max, Event $event, ?Condition $filters = null): array + { + $alias = Query::DEFAULT_ALIAS; + $conditions = []; + $bindings = []; + + if ($filters !== null) { + $conditions[] = $filters->expression; + \array_push($bindings, ...$filters->bindings); + } + + if ($this->sharedTables) { + $tenant = (new Tenant\Filter($this->currentTenant(), Database::METADATA, $name, quoteCharacter: $this->getIdentifierQuote()))->filter($alias); + $conditions[] = $tenant->expression; + \array_push($bindings, ...$tenant->bindings); + } + + if ($this->authorization->getStatus() && $this->filtersPerDocument($collection)) { + $permission = $this->newPermissionHook($name, $roles)->filter($alias); + $conditions[] = $permission->expression; + \array_push($bindings, ...$permission->bindings); + } + + $rows = $this->getTable($name).' AS '.$this->quote($alias); + if ($conditions !== []) { + $rows .= ' WHERE '.\implode(' AND ', $conditions); + } + + $sum = $this->quote('sum'); + if ($max === null) { + $sql = "SELECT {$aggregate} AS {$sum} FROM {$rows}"; + } else { + $sql = "SELECT {$aggregate} AS {$sum} FROM (SELECT {$column} FROM {$rows} LIMIT ?) AS {$this->quote('table_count')}"; + $bindings[] = $max; + } + + return $this->runSelect(new Statement($sql, $bindings), $event, $name)[0] ?? []; + } + + private function executeWrappedCount(SQLBuilder $innerBuilder, string $collection): int + { + $outerBuilder = $this->dialectBuilder(); + $outerBuilder->fromSub($innerBuilder, 'table_count'); + $outerBuilder->count('1', 'sum'); + + return $this->countOf($this->fetchAggregateRow($outerBuilder, Event::DocumentCount, $collection)); + } + + private function executeWrappedSum(SQLBuilder $innerBuilder, string $attribute, string $collection): int|float + { + $outerBuilder = $this->dialectBuilder(); + $outerBuilder->fromSub($innerBuilder, 'table_count'); + $outerBuilder->sum($attribute, 'sum'); + + return $this->sumOf($this->fetchAggregateRow($outerBuilder, Event::DocumentSum, $collection)); + } + + /** + * @param array $row + */ + private function countOf(array $row): int + { + $count = $row['sum'] ?? 0; + + return \is_numeric($count) ? (int) $count : 0; + } + + /** + * @param array $row + */ + private function sumOf(array $row): int|float + { + $sum = $row['sum'] ?? 0; + + if (\is_numeric($sum)) { + return \str_contains((string) $sum, '.') ? (float) $sum : (int) $sum; + } + + return 0; + } + + /** + * Copies of the queries of a count() or sum() without joins that name each dotted attribute key by + * its column, as find() does: the builder reads any other dotted name as a table and a column. + * + * @param array $queries + * @return array + */ + private function mainRowQueries(array $queries, Document $collection): array + { + $queries = \array_map(static fn (Query $query): Query => clone $query, $queries); + $this->remapDottedQueryAttributes($queries, [], $collection); + + return $queries; + } + + /** + * The queries of a count() or sum() as the builder compiles them into its WHERE clause, when + * every one only narrows the rows and the builder compiles filters on their own; null when the + * statement has to be built. + * + * @param array $queries + * + * @throws QueryException + */ + private function compileRowFilters(SQLBuilder $builder, array $queries): ?Condition + { + if (! $builder instanceof Filtering || ! $this->onlyNarrowsRows($queries)) { + return null; + } + + foreach ($queries as $query) { + if ($this->isAdapterFilterQuery($query)) { + return null; + } + } + + try { + return $builder->compileFilters(\array_values($queries)); + } catch (ValidationException|UnsupportedException $e) { + throw new QueryException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Whether every query only narrows the rows an aggregate reads, so the aggregate can read the + * table itself: anything that shapes, orders, groups or bounds the rows needs a derived table. + * + * @param array $queries + */ + private function onlyNarrowsRows(array $queries): bool + { + foreach ($queries as $query) { + $method = $query->getMethod(); + if ( + ! $method->isFilter() + && ! $method->isSpatial() + && ! $method->isJson() + && ! \in_array($method, self::ROW_CONDITION_GROUPS, true) + && ! $this->isAdapterFilterQuery($query) + ) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + private function fetchAggregateRow(SQLBuilder $builder, Event $event, string $collection): array + { + return $this->executeSelect($builder, $event, $collection)[0] ?? []; + } + + private const array ROW_CONDITION_GROUPS = [Method::And, Method::Or, Method::ContainsAll, Method::ElemMatch]; + + private const array BITWISE_AGGREGATES = [Method::BitAnd, Method::BitOr, Method::BitXor]; + + private const array COLUMN_LIST_METHODS = [Method::GroupBy, Method::Exists, Method::NotExists]; + + private const string BITWISE_INPUTS = '$inputs:'; + + /** + * Answer NULL for each bitwise aggregate that had no input values, and + * drop the input counts populationStatistics() added. + * + * @param array $row + * @param array $inputs Each bitwise aggregate, keyed by its input count, as bitwiseInputs() gives them + * @return array + */ + private function bitwiseResults(array $row, array $inputs): array + { + foreach ($inputs as $count => $aggregate) { + if (! \array_key_exists($count, $row)) { + continue; + } + + $value = $row[$count]; + unset($row[$count]); + + $name = $this->bitwiseResultName($row, $aggregate); + if ($name !== null && \is_numeric($value) && (int) $value === 0) { + $row[$name] = null; + } + } + + return $row; + } + + /** + * The column a bitwise aggregate is returned in: its alias, or for an unaliased one the name + * MariaDB and MySQL give it, the aggregate's own text (`BIT_AND(`flags`)`, qualified under a + * join). PostgreSQL names every unaliased BIT_AND `bit_and`, which does not tell two apart; it + * answers an empty set with NULL itself, so such a column is left as it is. + * + * @param array $row + */ + private function bitwiseResultName(array $row, BaseQuery $aggregate): ?string + { + $alias = $aggregate->getAlias(); + if ($alias !== '') { + return \array_key_exists($alias, $row) ? $alias : null; + } + + $function = ($aggregate->getMethod()->sqlFunction() ?? '').'('; + $quote = $this->getIdentifierQuote(); + $expressions = []; + foreach (\array_keys($row) as $name) { + if (\str_starts_with(\strtoupper($name), $function) && \str_ends_with($name, ')')) { + $expressions[\str_replace($quote, '', \substr($name, \strlen($function), -1))] = $name; + } + } + + $attribute = $aggregate->getAttribute(); + $dot = \strrpos($attribute, '.'); + $column = $this->filter($this->getInternalKeyForAttribute($dot === false ? $attribute : \substr($attribute, $dot + 1))); + $exact = $dot === false + ? [$column, Query::DEFAULT_ALIAS.'.'.$column] + : [$this->filter(\substr($attribute, 0, $dot)).'.'.$column]; + foreach ($exact as $expression) { + if (isset($expressions[$expression])) { + return $expressions[$expression]; + } + } + + foreach ($expressions as $expression => $name) { + if (\str_ends_with((string) $expression, '.'.$column)) { + return $name; + } + } + + return null; + } + + /** + * InnoDB caps a table at 1017 columns, 64 indexes and a 65535-byte row; the varchar cap is the floor of + * Postgres 16383, MySQL 16381 and MariaDB 16382; a shared table spends one byte of each index key on `_tenant`. + */ + #[\Override] + public function limits(): Limits + { + return $this->limits ??= new Limits( + string: 4294967295, + varchar: 16381, + integer: 4294967295, + bigInteger: Database::MAX_BIG_INT, + attributes: 1017, + indexes: 64, + defaultAttributes: \count(Database::internalAttributesFor(true)), + defaultIndexes: \count(Database::INTERNAL_INDEXES), + indexLength: $this->sharedTables ? 767 : 768, + uidLength: 36, + documentSize: 65535, + minDateTime: new \DateTime(static::MIN_DATETIME), + maxDateTime: new \DateTime(self::MAX_DATETIME), + idType: ColumnType::Integer, + keywords: $this->getKeywords(), + internalIndexKeys: [Storage::INDEX_PRIMARY, Storage::INDEX_CREATED_AT, Storage::INDEX_UPDATED_AT, Storage::INDEX_TENANT_ID], + ); + } + + #[\Override] + public function getCountOfAttributes(Document $collection): int + { + return \count(self::collectionAttributes($collection)) + $this->limits()->defaultAttributes; + } + + #[\Override] + public function getCountOfIndexes(Document $collection): int + { + return \count(self::collectionIndexes($collection)) + $this->limits()->defaultIndexes; + } + + /** + * Estimate maximum number of bytes required to store a document in $collection. + * Byte requirement varies based on column type and size. + * Needed to satisfy MariaDB/MySQL row width limit. + * + * @throws DatabaseException + */ + #[\Override] + public function getAttributeWidth(Document $collection): int + { + /** + * @link https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html + * + * `_id` bigint => 8 bytes + * `_uid` varchar(255) => 1021 (4 * 255 + 1) bytes + * `_tenant` int => 4 bytes + * `_createdAt` datetime(3) => 7 bytes + * `_updatedAt` datetime(3) => 7 bytes + * `_permissions` mediumtext => 20 + */ + $total = 1067; + + foreach (self::collectionAttributes($collection) as $attribute) { + $attributeSize = $attribute->size ?? 0; + + /** + * Json / Longtext + * only the pointer contributes 20 bytes + * data is stored externally + */ + if ($attribute->array) { + $total += 20; + + continue; + } + + switch ($attribute->type) { + case ColumnType::Id: + $total += 8; // BIGINT 8 bytes + break; + + case ColumnType::String: + /** + * Text / Mediumtext / Longtext + * only the pointer contributes 20 bytes to the row size + * data is stored externally + */ + $total += match (true) { + $attributeSize > $this->limits()->varchar => 20, + $attributeSize > 255 => $attributeSize * 4 + 2, + default => $attributeSize * 4 + 1, + }; + + break; + + case ColumnType::Varchar: + $total += match (true) { + $attributeSize > 255 => $attributeSize * 4 + 2, + default => $attributeSize * 4 + 1, + }; + break; + + case ColumnType::Text: + case ColumnType::MediumText: + case ColumnType::LongText: + $total += 20; // Pointer storage for TEXT types + break; + + case ColumnType::Integer: + if ($attributeSize >= 8) { + $total += 8; // BIGINT 8 bytes + } else { + $total += 4; // INT 4 bytes + } + break; + + case ColumnType::BigInteger: + $total += 8; + break; + + case ColumnType::Float: + case ColumnType::Double: + $total += 8; // DOUBLE 8 bytes + break; + + case ColumnType::Boolean: + $total += 1; // TINYINT(1) 1 bytes + break; + + case ColumnType::Relationship: + $total += Database::LENGTH_KEY * 4 + 1; // VARCHAR(<=255) + break; + + case ColumnType::Datetime: + /** + * 1 byte year + month + * 1 byte for the day + * 3 bytes for the hour, minute, and second + * 2 bytes miliseconds DATETIME(3) + */ + $total += 7; + break; + + case ColumnType::Object: + /** + * JSONB/JSON type + * Only the pointer contributes 20 bytes to the row size + * Data is stored externally + */ + $total += 20; + break; + + case ColumnType::Point: + $total += $this->getMaxPointSize(); + break; + case ColumnType::Linestring: + case ColumnType::Polygon: + $total += 20; + break; + + case ColumnType::Vector: + // Each dimension is typically 4 bytes (float32) + $total += $attributeSize * 4; + break; + + default: + throw new DatabaseException('Unknown type: '.$attribute->type->value); + } + } + + return $total; + } + + abstract protected function getMaxPointSize(): int; + + /** + * The reserved words of https://mariadb.com/kb/en/reserved-words/ + * + * @return list + */ + protected function getKeywords(): array + { + return [ + 'ACCESSIBLE', + 'ADD', + 'ALL', + 'ALTER', + 'ANALYZE', + 'AND', + 'AS', + 'ASC', + 'ASENSITIVE', + 'BEFORE', + 'BETWEEN', + 'BIGINT', + 'BINARY', + 'BLOB', + 'BOTH', + 'BY', + 'CALL', + 'CASCADE', + 'CASE', + 'CHANGE', + 'CHAR', + 'CHARACTER', + 'CHECK', + 'COLLATE', + 'COLUMN', + 'CONDITION', + 'CONSTRAINT', + 'CONTINUE', + 'CONVERT', + 'CREATE', + 'CROSS', + 'CURRENT_DATE', + 'CURRENT_ROLE', + 'CURRENT_TIME', + 'CURRENT_TIMESTAMP', + 'CURRENT_USER', + 'CURSOR', + 'DATABASE', + 'DATABASES', + 'DAY_HOUR', + 'DAY_MICROSECOND', + 'DAY_MINUTE', + 'DAY_SECOND', + 'DEC', + 'DECIMAL', + 'DECLARE', + 'DEFAULT', + 'DELAYED', + 'DELETE', + 'DELETE_DOMAIN_ID', + 'DESC', + 'DESCRIBE', + 'DETERMINISTIC', + 'DISTINCT', + 'DISTINCTROW', + 'DIV', + 'DO_DOMAIN_IDS', + 'DOUBLE', + 'DROP', + 'DUAL', + 'EACH', + 'ELSE', + 'ELSEIF', + 'ENCLOSED', + 'ESCAPED', + 'EXCEPT', + 'EXISTS', + 'EXIT', + 'EXPLAIN', + 'FALSE', + 'FETCH', + 'FLOAT', + 'FLOAT4', + 'FLOAT8', + 'FOR', + 'FORCE', + 'FOREIGN', + 'FROM', + 'FULLTEXT', + 'GENERAL', + 'GRANT', + 'GROUP', + 'HAVING', + 'HIGH_PRIORITY', + 'HOUR_MICROSECOND', + 'HOUR_MINUTE', + 'HOUR_SECOND', + 'IF', + 'IGNORE', + 'IGNORE_DOMAIN_IDS', + 'IGNORE_SERVER_IDS', + 'IN', + 'INDEX', + 'INFILE', + 'INNER', + 'INOUT', + 'INSENSITIVE', + 'INSERT', + 'INT', + 'INT1', + 'INT2', + 'INT3', + 'INT4', + 'INT8', + 'INTEGER', + 'INTERSECT', + 'INTERVAL', + 'INTO', + 'IS', + 'ITERATE', + 'JOIN', + 'KEY', + 'KEYS', + 'KILL', + 'LEADING', + 'LEAVE', + 'LEFT', + 'LIKE', + 'LIMIT', + 'LINEAR', + 'LINES', + 'LOAD', + 'LOCALTIME', + 'LOCALTIMESTAMP', + 'LOCK', + 'LONG', + 'LONGBLOB', + 'LONGTEXT', + 'LOOP', + 'LOW_PRIORITY', + 'MASTER_HEARTBEAT_PERIOD', + 'MASTER_SSL_VERIFY_SERVER_CERT', + 'MATCH', + 'MAXVALUE', + 'MEDIUMBLOB', + 'MEDIUMINT', + 'MEDIUMTEXT', + 'MIDDLEINT', + 'MINUTE_MICROSECOND', + 'MINUTE_SECOND', + 'MOD', + 'MODIFIES', + 'NATURAL', + 'NOT', + 'NO_WRITE_TO_BINLOG', + 'NULL', + 'NUMERIC', + 'OFFSET', + 'ON', + 'OPTIMIZE', + 'OPTION', + 'OPTIONALLY', + 'OR', + 'ORDER', + 'OUT', + 'OUTER', + 'OUTFILE', + 'OVER', + 'PAGE_CHECKSUM', + 'PARSE_VCOL_EXPR', + 'PARTITION', + 'POSITION', + 'PRECISION', + 'PRIMARY', + 'PROCEDURE', + 'PURGE', + 'RANGE', + 'READ', + 'READS', + 'READ_WRITE', + 'REAL', + 'RECURSIVE', + 'REF_SYSTEM_ID', + 'REFERENCES', + 'REGEXP', + 'RELEASE', + 'RENAME', + 'REPEAT', + 'REPLACE', + 'REQUIRE', + 'RESIGNAL', + 'RESTRICT', + 'RETURN', + 'RETURNING', + 'REVOKE', + 'RIGHT', + 'RLIKE', + 'ROWS', + 'SCHEMA', + 'SCHEMAS', + 'SECOND_MICROSECOND', + 'SELECT', + 'SENSITIVE', + 'SEPARATOR', + 'SET', + 'SHOW', + 'SIGNAL', + 'SLOW', + 'SMALLINT', + 'SPATIAL', + 'SPECIFIC', + 'SQL', + 'SQLEXCEPTION', + 'SQLSTATE', + 'SQLWARNING', + 'SQL_BIG_RESULT', + 'SQL_CALC_FOUND_ROWS', + 'SQL_SMALL_RESULT', + 'SSL', + 'STARTING', + 'STATS_AUTO_RECALC', + 'STATS_PERSISTENT', + 'STATS_SAMPLE_PAGES', + 'STRAIGHT_JOIN', 'TABLE', 'TERMINATED', 'THEN', @@ -1585,2027 +2820,4107 @@ public function getKeywords(): array 'SYSTEM', 'SYSTEM_TIME', 'VERSIONING', - 'WITHOUT' + 'WITHOUT', ]; } /** - * Does the adapter handle casting? + * @throws DatabaseException + */ + #[\Override] + public function analyzeCollection(string $collection): bool + { + return false; + } + + /** + * @throws Exception + * @throws PDOException + */ + #[\Override] + public function delete(string $name): bool + { + $name = $this->filter($name); + + $result = $this->schema()->dropDatabase($name); + $sql = $result->query; + + return $this->executeStatement($sql, Event::DatabaseDelete); + } + + /** + * Delete a collection and its permissions table. + * + * @throws DatabaseException + */ + #[\Override] + public function deleteCollection(string $collection): bool + { + $id = $this->filter($collection); + + $schema = $this->schema(); + $main = $schema->table($this->getTableRaw($id))->drop(); + $permissions = $schema->table($this->getTableRaw(Storage::permissionsTable($id)))->dropIfExists(); + + try { + return $this->executeStatement($main->query.'; '.$permissions->query, Event::CollectionDelete); + } catch (PDOException $e) { + $error = $this->processException($e); + if ($error instanceof NotFoundException && $this->inTransaction === 0) { + $this->executeStatement($permissions->query, Event::CollectionDelete); + } + + throw $error; + } + } + + /** + * Drop the tables a failed createCollection() created. A drop that fails too is logged, so the caller still + * receives the error that failed the create. + */ + protected function discardCreatedCollection(string $id): void + { + try { + $this->dropCreatedCollection($id); + } catch (Throwable $error) { + Console::error("Failed to rollback collection '{$id}': ".$error->getMessage()); + } + } + + protected function dropCreatedCollection(string $id): void + { + $schema = $this->schema(); + $main = $schema->table($this->getTableRaw($id))->dropIfExists(); + $permissions = $schema->table($this->getTableRaw(Storage::permissionsTable($id)))->dropIfExists(); + + $this->executeStatement($main->query.'; '.$permissions->query, Event::CollectionCreate); + } + + /** + * Create a relationship between collections by adding foreign key columns. + * + * @throws DatabaseException + */ + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool + { + $name = $this->filter($collection); + $relatedName = $this->filter($relationship->relatedCollection); + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + + $schema = $this->schema(); + $addColumn = function (string $tableName, string $columnId) use ($schema): string { + $table = $schema->table($this->getTableRaw($tableName)); + $table->string($columnId, 255)->nullable()->default(null); + $result = $table->alter(); + + return $result->query; + }; + + $sql = match ($relationship->type) { + RelationshipType::OneToOne => $addColumn($name, $key) . ';' . ($relationship->twoWay ? $addColumn($relatedName, $twoWayKey) . ';' : ''), + RelationshipType::OneToMany => $addColumn($relatedName, $twoWayKey) . ';', + RelationshipType::ManyToOne => $addColumn($name, $key) . ';', + RelationshipType::ManyToMany => null, + }; + + if ($sql === null) { + return true; + } + + return $this->executeStatement($sql, Event::AttributeCreate); + } + + /** + * Rename the foreign key columns of a relationship. + * + * @throws DatabaseException + */ + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + { + $name = $this->filter($collection); + $relatedName = $this->filter($relationship->relatedCollection); + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $twoWay = $update->twoWay ?? $relationship->twoWay; + $newKey = $update->key === null ? null : $this->filter($update->key); + $newTwoWayKey = $update->twoWayKey === null ? null : $this->filter($update->twoWayKey); + + $schema = $this->schema(); + $renameColumn = function (string $tableName, string $from, string $to) use ($schema): string { + $table = $schema->table($this->getTableRaw($tableName)); + $table->renameColumn($from, $to); + $result = $table->alter(); + + return $result->query; + }; + + $sql = ''; + + switch ($relationship->type) { + case RelationshipType::OneToOne: + if (($twoWay || $side === RelationshipSide::Parent) && $newKey !== null && $key !== $newKey) { + $sql = $renameColumn($name, $key, $newKey) . ';'; + } + if (($twoWay || $side === RelationshipSide::Child) && $newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { + $sql .= $renameColumn($relatedName, $twoWayKey, $newTwoWayKey) . ';'; + } + break; + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { + $sql = $renameColumn($relatedName, $twoWayKey, $newTwoWayKey) . ';'; + } + } elseif ($newKey !== null && $key !== $newKey) { + $sql = $renameColumn($name, $key, $newKey) . ';'; + } + break; + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Child) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { + $sql = $renameColumn($relatedName, $twoWayKey, $newTwoWayKey) . ';'; + } + } elseif ($newKey !== null && $key !== $newKey) { + $sql = $renameColumn($name, $key, $newKey) . ';'; + } + break; + case RelationshipType::ManyToMany: + $junctionName = $this->getJunctionName($collection, $relationship->relatedCollection, $side); + + if ($newKey !== null && $key !== $newKey) { + $sql = $renameColumn($junctionName, $key, $newKey) . ';'; + } + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { + $sql .= $renameColumn($junctionName, $twoWayKey, $newTwoWayKey) . ';'; + } + break; + } + + if ($sql === '') { + return true; + } + + return $this->executeStatement($sql, Event::AttributeUpdate); + } + + /** + * Drop the foreign key columns of a relationship, or its junction tables. + * + * @throws DatabaseException + */ + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + $name = $this->filter($collection); + $relatedName = $this->filter($relationship->relatedCollection); + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $twoWay = $relationship->twoWay; + + $schema = $this->schema(); + $dropColumn = function (string $tableName, string $columnId) use ($schema): string { + $table = $schema->table($this->getTableRaw($tableName)); + $table->dropColumn($columnId); + $result = $table->alter(); + + return $result->query; + }; + + $sql = ''; + + switch ($relationship->type) { + case RelationshipType::OneToOne: + if ($side === RelationshipSide::Parent) { + $sql = $dropColumn($name, $key) . ';'; + if ($twoWay) { + $sql .= $dropColumn($relatedName, $twoWayKey) . ';'; + } + } else { + $sql = $dropColumn($relatedName, $twoWayKey) . ';'; + if ($twoWay) { + $sql .= $dropColumn($name, $key) . ';'; + } + } + break; + case RelationshipType::OneToMany: + $sql = $side === RelationshipSide::Parent + ? $dropColumn($relatedName, $twoWayKey) . ';' + : $dropColumn($name, $key) . ';'; + break; + case RelationshipType::ManyToOne: + $sql = $side === RelationshipSide::Parent + ? $dropColumn($name, $key) . ';' + : $dropColumn($relatedName, $twoWayKey) . ';'; + break; + case RelationshipType::ManyToMany: + $junctionName = $this->getJunctionName($collection, $relationship->relatedCollection, $side); + + $junctionResult = $schema->table($this->getTableRaw($junctionName))->drop(); + $permissionsResult = $schema->table($this->getTableRaw(Storage::permissionsTable($junctionName)))->drop(); + + $sql = $junctionResult->query . '; ' . $permissionsResult->query; + break; + } + + return $this->executeStatement($sql, Event::AttributeDelete); + } + + /** + * The junction collection of a many-to-many relationship, named after the parent's sequence first. + */ + protected function getJunctionName(string $collection, string $relatedCollection, RelationshipSide $side): string + { + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collectionDocument = $this->getDocument($metadataCollection, $collection); + $relatedCollectionDocument = $this->getDocument($metadataCollection, $relatedCollection); + + return $side === RelationshipSide::Parent + ? '_' . $collectionDocument->getSequence() . '_' . $relatedCollectionDocument->getSequence() + : '_' . $relatedCollectionDocument->getSequence() . '_' . $collectionDocument->getSequence(); + } + + /** + * @var array + */ + private const array COLUMN_TYPE_SPELLINGS = [ + '/\s+/' => ' ', + '/ (NOT )?NULL$/' => '', + '/^(POINT|LINESTRING|POLYGON)\b.*$/' => '$1', + '/\b(TINYINT|SMALLINT|MEDIUMINT|INT|INTEGER|BIGINT)\(\d+\)/' => '$1', + ]; + + #[\Override] + public function getColumnType(Attribute $attribute): ?string + { + $type = $this->getAttributeSqlType($attribute); + + return $type === '' ? null : $this->canonicalColumnType($type); + } + + /** + * One spelling for a native type, whether the adapter wrote it or the engine's catalog reports it: engines report + * integer display widths (int(11)), spatial types without their SRID or nullability, and MariaDB's JSON as + * LONGTEXT. + */ + protected function canonicalColumnType(string $type): string + { + $canonical = \preg_replace( + \array_keys(self::COLUMN_TYPE_SPELLINGS), + \array_values(self::COLUMN_TYPE_SPELLINGS), + \strtoupper(\trim($type)), + ) ?? $type; + + return $canonical === 'JSON' ? 'LONGTEXT' : $canonical; + } + + protected function getSqlType(ColumnType $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string + { + if (in_array($type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + return $this->getSpatialSqlType($type->value, $required); + } + if ($array === true) { + return 'JSON'; + } + + if ($type === ColumnType::String) { + if ($size > 16777215) { + return 'LONGTEXT'; + } + if ($size > 65535) { + return 'MEDIUMTEXT'; + } + if ($size > $this->limits()->varchar) { + return 'TEXT'; + } + + return "VARCHAR({$size})"; + } + + if ($type === ColumnType::Varchar) { + $this->assertVarcharSize($size); + + return "VARCHAR({$size})"; + } + + if (\in_array($type, [ColumnType::Integer, ColumnType::BigInteger], true)) { + $suffix = $signed ? '' : ' UNSIGNED'; + + return ($type === ColumnType::Integer && $size < 8 ? 'INT' : 'BIGINT') . $suffix; + } + + if ($type === ColumnType::Float || $type === ColumnType::Double) { + return 'DOUBLE' . ($signed ? '' : ' UNSIGNED'); + } + + return match ($type) { + ColumnType::Id => 'BIGINT UNSIGNED', + ColumnType::Text => 'TEXT', + ColumnType::MediumText => 'MEDIUMTEXT', + ColumnType::LongText => 'LONGTEXT', + ColumnType::Boolean => 'TINYINT(1)', + ColumnType::Relationship => 'VARCHAR(255)', + ColumnType::Datetime => 'DATETIME(3)', + default => throw new DatabaseException('Unknown type: ' . $type->value . '. Must be one of ' . ColumnType::String->value . ', ' . ColumnType::Varchar->value . ', ' . ColumnType::Text->value . ', ' . ColumnType::MediumText->value . ', ' . ColumnType::LongText->value . ', ' . ColumnType::Integer->value . ', ' . ColumnType::Double->value . ', ' . ColumnType::Boolean->value . ', ' . ColumnType::Datetime->value . ', ' . ColumnType::Relationship->value . ', ' . ColumnType::Point->value . ', ' . ColumnType::Linestring->value . ', ' . ColumnType::Polygon->value), + }; + } + + protected function getSpatialSqlType(string $type, bool $required): string + { + $srid = $this->getSpatialColumnSrid(); + $modifier = $srid === null ? '' : "({$srid})"; + $nullability = ''; + + if (! $this->supports(Capability::IndexSpatialNull)) { + if ($required) { + $nullability = ' NOT NULL'; + } else { + $nullability = ' NULL'; + } + } + + return match ($type) { + ColumnType::Point->value => "POINT{$modifier}{$nullability}", + ColumnType::Linestring->value => "LINESTRING{$modifier}{$nullability}", + ColumnType::Polygon->value => "POLYGON{$modifier}{$nullability}", + default => '', + }; + } + + protected function getSpatialGeometryFromText(string $wktPlaceholder, ?int $srid = null): string + { + $srid = $srid ?? Database::DEFAULT_SRID; + $geomFromText = "ST_GeomFromText({$wktPlaceholder}, {$srid}"; + + if ($this->supports(Capability::SpatialAxisOrder)) { + $geomFromText .= ', '.$this->getSpatialAxisOrder(); + } + + $geomFromText .= ')'; + + return $geomFromText; + } + + protected function getSpatialAxisOrder(): string + { + return "'axis-order=long-lat'"; + } + + /** + * @param array $geometry + * + * @throws DatabaseException + */ + protected function convertArrayToWkt(array $geometry): string + { + if ($geometry === [] || ! \array_is_list($geometry)) { + throw new DatabaseException('Unrecognized geometry array format'); + } + + // point [x, y] + if (count($geometry) === 2 && is_numeric($geometry[0]) && is_numeric($geometry[1])) { + return "POINT({$geometry[0]} {$geometry[1]})"; + } + + // linestring [[x1, y1], [x2, y2], ...] + if (is_array($geometry[0]) && count($geometry[0]) === 2 && is_numeric($geometry[0][0])) { + $points = []; + foreach ($geometry as $point) { + if (! is_array($point) || count($point) !== 2 || ! is_numeric($point[0]) || ! is_numeric($point[1])) { + throw new DatabaseException('Invalid point format in geometry array'); + } + $points[] = "{$point[0]} {$point[1]}"; + } + + return 'LINESTRING('.implode(', ', $points).')'; + } + + // polygon [[[x1, y1], [x2, y2], ...], ...] + if (is_array($geometry[0]) && is_array($geometry[0][0]) && count($geometry[0][0]) === 2) { + $rings = []; + foreach ($geometry as $ring) { + if (! is_array($ring)) { + throw new DatabaseException('Invalid ring format in polygon geometry'); + } + $points = []; + foreach ($ring as $point) { + if (! is_array($point) || count($point) !== 2 || ! is_numeric($point[0]) || ! is_numeric($point[1])) { + throw new DatabaseException('Invalid point format in polygon ring'); + } + $points[] = "{$point[0]} {$point[1]}"; + } + $rings[] = '('.implode(', ', $points).')'; + } + + return 'POLYGON('.implode(', ', $rings).')'; + } + + throw new DatabaseException('Unrecognized geometry array format'); + } + + /** + * @throws DatabaseException + */ + final protected function getTable(string $name): string + { + return \implode('.', \array_map($this->quote(...), \explode('.', $this->getTableRaw($name), 2))); + } + + /** + * Get an unquoted qualified table name (the builder handles quoting). Override qualifyTable() to change + * it, which a builder's from() resolves a collection through too. + * + * @throws DatabaseException + */ + final protected function getTableRaw(string $name): string + { + return $this->qualifyTable($this->getDatabase(), $this->getNamespace(), $name); + } + + /** + * The unquoted name the table is stored under in the database and namespace given. + * + * @throws DatabaseException + */ + protected function qualifyTable(string $database, string $namespace, string $name): string + { + return $database.'.'.$namespace.'_'.$this->filter($name); + } + + /** + * A query builder in the adapter's dialect, scoped with scope(). Its from() takes a collection id, as + * Database::from() hands it on: it reads the collection's table, maps document attributes to columns and + * applies no permissions. A subclass that returns its own builder uses ScopesCollections and scopes it the + * same way. + * + * Under shared tables it keeps every statement to the tenant selected when the builder was handed out: + * the main table and every table joined through the builder's join methods (Tenant\Raw). Not kept to the + * tenant: SQL the caller writes, builders that did not come from builder() (subqueries, unions, lateral + * joins), a dialect's multi-table updates and deletes, and a builder that names no collection: one that + * selects no table, inserts into() a table or reads one fromTable(), as the adapter's own statements do. + */ + #[\Override] + final public function builder(): SQLBuilder&Scoping + { + return $this->dialectBuilder()->scope($this->scope()); + } + + /** + * A builder in the adapter's dialect with no scope yet. builder() scopes it, and the adapter's own + * statements scope it with the tenants they read, or use it as it is where they name no collection. + */ + abstract protected function dialectBuilder(): SQLBuilder&Scoping; + + #[\Override] + public function schema(): MySQLSchema|PostgresSchema + { + return new MySQLSchema(); + } + + /** + * What a builder()'s from() reads a collection through, with the database, namespace, tenant and table + * sharing in effect now. + */ + protected function scope(): Scope + { + return new RawScope( + $this->tables(), + $this->attributes(), + $this->sharedTables, + $this->currentTenant(), + $this->getIdentifierQuote(), + ); + } + + /** + * Applies tenant filtering whenever shared tables are enabled, so that a query made + * with no tenant selected matches no tenant's rows rather than every tenant's. + * + * @param list $tenants Tenants this query spans, for the reads that cross + * tenants deliberately; defaults to the selected tenant + * @param list $unindexed The read's join aliases unindexedJoins() names + * + * @throws DatabaseException + */ + protected function newBuilder(string $table, string $alias = '', bool $allowNullTenant = false, array $tenants = [], array $unindexed = []): SQLBuilder&Scoping + { + return $this->dialectBuilder() + ->scope($this->filterScope($tenants === [] ? $this->currentTenant() : $tenants, $allowNullTenant, $unindexed)) + ->from($table, $alias); + } + + /** + * @param int|string|null|list $tenants + * @param list $unindexed + */ + private function filterScope(int|string|null|array $tenants, bool $allowNullTenant = false, array $unindexed = []): FilterScope + { + return new FilterScope( + $this->tables(), + $this->attributes(), + $this->sharedTables, + $tenants, + $this->getIdentifierQuote(), + $allowNullTenant, + $unindexed, + ); + } + + /** + * @return Closure(string): string + */ + private function tables(): Closure + { + $database = $this->getDatabase(); + $namespace = $this->getNamespace(); + + return fn (string $collection): string => $this->qualifyTable($database, $namespace, $collection); + } + + private function attributes(): AttributeMap + { + return $this->attributeMap ??= new AttributeMap(Storage::attributeMap()); + } + + #[\Override] + public function rawMutation(string $query, array $bindings = []): int + { + try { + $statement = $this->prepareStatement($query); + foreach ($bindings as $i => $value) { + $statement->bindValue($i + 1, $value, $this->getPdoType($value)); + } + $this->execute($statement); + } catch (PDOException $e) { + throw $this->processException($e); + } + + $count = $statement->rowCount(); + $statement->closeCursor(); + + return $count; + } + + protected function getIdentifierQuote(): string + { + return '`'; + } + + /** + * The expression a raw lookup compares a document id column through, so it + * can use the engine's unique index on that column. + */ + protected function collateDocumentId(string $column): string + { + return $column; + } + + /** + * @param array $roles + */ + protected function newPermissionHook(string $collection, array $roles, string $type = PermissionType::Read->value, string $documentColumn = Storage::UID): Permission\Filter + { + return new Permission\Filter( + roles: \array_values($roles), + permissionsTable: fn (string $table) => $this->getTableRaw(Storage::permissionsTable($collection)), + type: $type, + documentColumn: $documentColumn, + permissionDocumentColumn: Storage::PERMISSIONS_DOCUMENT, + permissionRoleColumn: Storage::PERMISSIONS_PERMISSION, + permissionTypeColumn: Storage::PERMISSIONS_TYPE, + subqueryFilter: $this->sharedTables + ? new Tenant\Filter( + $this->currentTenant(), + Database::METADATA, + Storage::permissionsTable($collection), + quoteCharacter: $this->getIdentifierQuote(), + ) + : null, + quoteCharacter: $this->getIdentifierQuote(), + ); + } + + /** + * @param array $roles + */ + protected function newJoinPermissionHook(string $collection, array $roles, string $type, string $documentColumn, int $joins, JoinType $joinType): Permission\Filter + { + return $this->newPermissionHook($collection, $roles, $type, $documentColumn); + } + + /** + * Keeps the write hook this adapter owns registered: Tenancy, while shared tables are active. It stores each + * row's tenant from the document being written, or the adapter's when the document names none, so it is needed + * in per-document mode too, where there is no adapter tenant at all. + * + * Permissions is deliberately not here, and this does not restore it. It is + * registered once by whoever builds the Database, so a handle constructed + * without it never writes a `_perms` row -- the row itself looks correct, + * its `_permissions` JSON intact, and only the side table the permission + * filter joins is empty. Do not read this method as a safety net for that. + */ + protected function syncWriteHooks(): void + { + $registered = $this->getTenantHook() !== null; + if ($registered === $this->sharedTables) { + return; + } + + if ($this->sharedTables) { + $this->addWriteHook(new Tenancy()); + } else { + $this->removeWriteHook(Tenancy::class); + } + } + + /** + * The context this adapter's write hooks write their own rows through. + * + * @param array $skipPermissions Ids of the documents whose permissions the write keeps + */ + protected function writeContext(array $skipPermissions = []): WriteContext + { + return new WriteContext( + builder: fn (): SQLBuilder&Scoping => $this->dialectBuilder()->scope($this->filterScope($this->currentTenant())), + rawTable: $this->getTableRaw(...), + prepare: fn (Statement $statement, Event $event): object => $this->executeResult($statement, $event), + execute: fn (object $statement): bool => $this->execute($statement), + decorateRow: $this->decorateRow(...), + ignoreDuplicates: $this->isIgnoringDuplicates(), + skipPermissions: $skipPermissions, + ); + } + + /** + * Execute a Statement through the transformation system with positional bindings. + * + * Prepares the SQL statement and binds positional parameters from the Statement. + * Does NOT call execute() - the caller is responsible for that. + * + * @param string $collection The collection the statement reads or writes, for the profiler + * @return PDOStatement|DatabasePDOStatement|PDOStatementProxy + */ + protected function executeResult(Statement $result, ?Event $event = null, string $collection = ''): object + { + $prepared = $this->prepareStatement($result->query, $event); + $this->describeStatement($prepared, $result->bindings, $collection); + foreach ($result->bindings as $i => $value) { + if (\is_bool($value) && $this->supports(Capability::IntegerBooleans)) { + $value = (int) $value; + } + if (\is_float($value)) { + $prepared->bindValue($i + 1, $this->getFloatPrecision($value), PDO::PARAM_STR); + } else { + $prepared->bindValue($i + 1, $value, $this->getPdoType($value)); + } + } + + return $prepared; + } + + /** + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement + */ + protected function execute(mixed $statement, ?Event $event = null): bool + { + return $this->executeAndProfile($statement); + } + + /** + * Run a prepared statement and hand it to the profiler when one is attached. + * + * Subclasses that wrap execute() with engine-specific timeout handling call + * this instead of $statement->execute(), so the statement is still counted. * - * @return bool + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement */ - public function getSupportForCasting(): bool + protected function executeAndProfile(mixed $statement): bool { - return true; + if ($this->profiler === null || ! $this->profiler->isEnabled()) { + return $statement->execute(); + } + + $start = \microtime(true); + $result = $statement->execute(); + $this->profiler->log( + $statement instanceof DatabasePDOStatement ? $statement->getQueryString() : ($statement->queryString ?? ''), + $this->statementBindings[$statement] ?? [], + (\microtime(true) - $start) * 1000, + $this->statementCollections[$statement] ?? '', + $this->getStatementEvent($statement)->value ?? '', + ); + + return $result; } - public function getSupportForNumericCasting(): bool + /** + * Keep the values bound to a statement and the collection it runs on for the profiler, while + * one is recording. + * + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement + * @param array $bindings + */ + protected function describeStatement(object $statement, array $bindings, string $collection): void { - return false; + if ($this->profiler === null || ! $this->profiler->isEnabled()) { + return; + } + + $this->statementBindings ??= new \WeakMap(); + $this->statementBindings[$statement] = $bindings; + $this->statementCollections ??= new \WeakMap(); + $this->statementCollections[$statement] = $collection; + } + + /** + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement + */ + protected function getStatementEvent(object $statement): ?Event + { + if ($this->statementEvents === null) { + return null; + } + + return $this->statementEvents[$statement] ?? null; + } + + /** + * @return PDOStatement|DatabasePDOStatement|PDOStatementProxy + */ + protected function prepareStatement(string $sql, ?Event $event = null): object + { + $sql = $this->comments().$sql; + + if ($event !== null) { + $sql = $this->transformQuery($event, $sql); + } + + $statement = $this->getDriver()->prepare($sql); + if (! $statement instanceof DatabasePDOStatement && ! $statement instanceof PDOStatementProxy && ! $statement instanceof PDOStatement) { + throw new DatabaseException('Failed to prepare SQL statement'); + } + + if ($event !== null) { + $this->statementEvents ??= new \WeakMap(); + $this->statementEvents[$statement] = $event; + } + + return $statement; + } + + private function comments(): string + { + if ($this->commentedMetadata === $this->metadata) { + return $this->comments; + } + + $comments = ''; + $scalar = true; + foreach ($this->metadata as $key => $value) { + $comments .= '/* '.$this->commentText($key).': '.$this->commentText($value).' */'."\n"; + $scalar = $scalar && ($value === null || \is_scalar($value)); + } + + if ($scalar) { + $this->commentedMetadata = $this->metadata; + $this->comments = $comments; + } + + return $comments; + } + + private function commentText(mixed $value): string + { + $text = match (true) { + \is_scalar($value), $value instanceof \Stringable => (string) $value, + default => \json_encode( + $value, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR, + ) ?: \get_debug_type($value), + }; + + if (\preg_match('/[^\x20-\x7E]/', $text) !== 0) { + $text = \preg_replace('/[\p{Cc}\p{Zl}\p{Zp}]/u', ' ', \mb_scrub($text, 'UTF-8')) ?? ''; + } + + return \str_replace(['/*', '*/'], ['/ *', '* /'], $text); + } + + protected function executeStatement(string $sql, Event $event): bool + { + return $this->execute($this->prepareStatement($sql, $event)); + } + + private function transformQuery(Event $event, string $sql): string + { + foreach ($this->transforms as $transform) { + $sql = $transform->transform($event, $sql); + } + + return $sql; } + /** + * Builds an INSERT ... ON CONFLICT/DUPLICATE KEY UPDATE statement via the + * query builder, handling spatial columns, shared-table tenant guards, + * increment attributes, and operator expressions. + * + * @param string $name The filtered collection name + * @param array $changes The changes to upsert + * @param list $spatialAttributes Spatial column names + * @param string $attribute Increment attribute name (empty if none) + * @param array $operators Operator map keyed by attribute name + * @param array $attributeDefaults Attribute default values + * + * @throws DatabaseException + */ + protected function executeUpsertBatch( + string $name, + array $changes, + array $spatialAttributes, + string $attribute, + array $operators, + array $attributeDefaults, + bool $hasOperators + ): void { + $builder = $this->dialectBuilder()->into($this->getTableRaw($name)); + + foreach ($spatialAttributes as $spatialColumn) { + $builder->insertColumnExpression($spatialColumn, $this->getSpatialGeometryFromText('?')); + } + + if ($this->insertRequiresAlias()) { + $builder->insertAs('target'); + } + + $allColumnNames = []; + $documentsData = []; + + foreach ($changes as $change) { + $document = $change->new; + + if ($hasOperators) { + $extracted = Operator::extractOperators($document->getAttributes()); + $currentRegularAttributes = $extracted['updates']; + $extractedOperators = $extracted['operators']; + + if ($change->old->isEmpty() && ! empty($extractedOperators)) { + foreach ($extractedOperators as $operatorKey => $operator) { + $default = $attributeDefaults[$operatorKey] ?? null; + $currentRegularAttributes[$operatorKey] = $this->applyOperatorToValue($operator, $default); + } + } + + $currentRegularAttributes[Storage::UID] = $document->getId(); + $currentRegularAttributes[Storage::CREATED_AT] = $document->getCreatedAt() ? $document->getCreatedAt() : null; + $currentRegularAttributes[Storage::UPDATED_AT] = $document->getUpdatedAt() ? $document->getUpdatedAt() : null; + } else { + $currentRegularAttributes = $document->getAttributes(); + $currentRegularAttributes[Storage::UID] = $document->getId(); + $currentRegularAttributes[Storage::CREATED_AT] = $document->getCreatedAt() ? DateTime::setTimezone($document->getCreatedAt()) : null; + $currentRegularAttributes[Storage::UPDATED_AT] = $document->getUpdatedAt() ? DateTime::setTimezone($document->getUpdatedAt()) : null; + } + + $currentRegularAttributes[Storage::PERMISSIONS] = \json_encode($document->getPermissions()); + + if (! empty($document->getSequence())) { + $currentRegularAttributes[Storage::SEQUENCE] = $document->getSequence(); + } + + $currentRegularAttributes = $this->decorateRow($currentRegularAttributes, $document); + + foreach (\array_keys($currentRegularAttributes) as $column) { + $allColumnNames[$column] = true; + } + + $documentsData[] = $currentRegularAttributes; + } + + foreach (\array_keys($operators) as $column) { + $allColumnNames[$column] = true; + } + + $allColumnNames = \array_keys($allColumnNames); + \sort($allColumnNames); + + $spatialMap = \array_fill_keys($spatialAttributes, true); + $integerBooleans = $this->supports(Capability::IntegerBooleans); + + foreach ($documentsData as $values) { + $row = []; + foreach ($allColumnNames as $key) { + $value = $values[$key] ?? null; + if (isset($spatialMap[$key])) { + $value = $this->encodeSpatialWriteValue($value); + } elseif (\is_array($value)) { + $value = \json_encode($value); + } + if ($integerBooleans && ! isset($spatialMap[$key])) { + $value = (\is_bool($value)) ? (int) $value : $value; + } + $row[$key] = $value; + } + $builder->set($row); + } + + $conflictKeys = $this->sharedTables ? [Storage::UID, Storage::TENANT] : [Storage::UID]; + + $skipColumns = [Storage::UID, Storage::SEQUENCE, Storage::CREATED_AT, Storage::TENANT]; + + if (! empty($attribute)) { + $updateColumns = [$this->filter($attribute), Storage::UPDATED_AT]; + } else { + $updateColumns = \array_values(\array_filter( + $allColumnNames, + static fn (int|string $column): bool => ! \in_array($column, $skipColumns) + )); + } + + $builder->onConflict($conflictKeys, $updateColumns); + + // conflictSetRaw() takes the column names given to onConflict(); the expression methods quote their own. + if (! empty($attribute)) { + $incrementColumn = $this->filter($attribute); + if ($this->sharedTables) { + $builder->conflictSetRaw($incrementColumn, $this->getConflictTenantIncrementExpression($incrementColumn)); + $builder->conflictSetRaw(Storage::UPDATED_AT, $this->getConflictTenantExpression(Storage::UPDATED_AT)); + } else { + $builder->conflictSetRaw($incrementColumn, $this->getConflictIncrementExpression($incrementColumn)); + } + } elseif (! empty($operators)) { + foreach ($allColumnNames as $column) { + if (\in_array($column, $skipColumns)) { + continue; + } + if (isset($operators[$column])) { + $filteredColumn = $this->filter($column); + $expression = $this->getOperatorUpsertExpression($filteredColumn, $operators[$column]); + $builder->conflictSetRaw($column, $expression->sql, $expression->bindings); + } elseif ($this->sharedTables) { + $builder->conflictSetRaw($column, $this->getConflictTenantExpression($column)); + } + } + } elseif ($this->sharedTables) { + foreach ($updateColumns as $column) { + $builder->conflictSetRaw($column, $this->getConflictTenantExpression($column)); + } + } + + if (! $builder instanceof UpsertFeature) { + throw new DatabaseException('Upserts are not supported on this dialect'); + } + + $result = $builder->upsert(); + $statement = $this->executeResult($result, Event::DocumentsUpsert); + $this->execute($statement); + $statement->closeCursor(); + } /** - * Does the adapter handle Query Array Contains? + * Converts user-facing attribute names (like $id, $sequence) to internal + * database column names (like _uid, _id) and ensures internal columns + * are always included. + * + * An `alias.*` selection stands for the joined columns $joinSelections lists under that alias. * - * @return bool + * @param array $selections + * @param array $joinAliases + * @param array> $joinSelections The selections `alias.*` makes under each join alias + */ + private function applySelectionProjection( + SQLBuilder $builder, + array $selections, + bool $includeInternal = true, + array $joinAliases = [], + array $joinSelections = [], + ): void { + $expanded = []; + foreach ($selections as $selection) { + if (\str_ends_with($selection, '.*') && isset($joinSelections[\substr($selection, 0, -2)])) { + \array_push($expanded, ...$joinSelections[\substr($selection, 0, -2)]); + } else { + $expanded[] = $selection; + } + } + + $mapped = $this->mapSelectionsToColumns(\array_values(\array_unique($expanded)), $includeInternal, $joinAliases); + $simple = []; + foreach ($mapped as $column) { + if (\str_contains($column, ' AS ')) { + $builder->selectRaw($column); + } else { + $simple[] = $column; + } + } + if ($simple !== []) { + $builder->select($simple); + } + } + + /** + * @param array $queries + * @param list $joinTablePrefixes */ - public function getSupportForQueryContains(): bool + private function remapDottedQueryAttributes(array $queries, array $joinTablePrefixes, Document $collection): void { - return true; + $aliasSet = \array_fill_keys(\array_column($joinTablePrefixes, 'alias'), true); + $aliasSet[Query::DEFAULT_ALIAS] = true; + $mainAttributes = []; + foreach (self::collectionAttributes($collection) as $attribute) { + $mainAttributes[$attribute->key] = true; + } + + foreach ($queries as $query) { + $this->remapDottedQuery($query, $aliasSet, $mainAttributes); + } + } + + /** + * @param array $aliasSet + * @param array $mainAttributes + */ + private function remapDottedQuery(BaseQuery $query, array $aliasSet, array $mainAttributes): void + { + $method = $query->getMethod(); + if ($method === Method::Select) { + return; + } + + if ($method->isJoin()) { + foreach ($query->getJoinOnQueries() as $onQuery) { + if ($onQuery->getMethod() !== Method::On) { + $this->remapDottedQuery($onQuery, $aliasSet, $mainAttributes); + } + } + + return; + } + + if ($query->isNested()) { + foreach ($query->getValues() as $child) { + if ($child instanceof BaseQuery) { + $this->remapDottedQuery($child, $aliasSet, $mainAttributes); + } + } + + return; + } + + if (\in_array($method, self::COLUMN_LIST_METHODS, true)) { + $values = $query->getValues(); + $changed = false; + foreach ($values as $i => $column) { + if (! \is_string($column) || ! \str_contains($column, '.')) { + continue; + } + $values[$i] = $this->qualifyDottedAttribute($column, $aliasSet, $mainAttributes); + $changed = true; + } + if ($changed) { + $query->setValues($values); + } + + return; + } + + $attribute = $query->getAttribute(); + if ($attribute === '' || $attribute === '*' || ! \str_contains($attribute, '.')) { + return; + } + + $query->setAttribute($this->qualifyDottedAttribute($attribute, $aliasSet, $mainAttributes)); + } + + /** + * @param array $aliasSet + * @param array $mainAttributes + */ + private function qualifyDottedAttribute(string $attribute, array $aliasSet, array $mainAttributes): string + { + if (isset($mainAttributes[$attribute])) { + return $this->filter($this->getInternalKeyForAttribute($attribute)); + } + + $dot = \strpos($attribute, '.'); + if ($dot === false) { + return $this->filter($this->getInternalKeyForAttribute($attribute)); + } + + $prefix = \substr($attribute, 0, $dot); + if (isset($aliasSet[$prefix])) { + $name = \substr($attribute, $dot + 1); + + return $this->filter($prefix).'.'.$this->filter($this->getInternalKeyForAttribute($name)); + } + + return $attribute; + } + + /** + * @param array $selections + * @param array $joinAliases + * @return array + */ + protected function mapSelectionsToColumns(array $selections, bool $includeInternal = true, array $joinAliases = []): array + { + $internalKeys = [ + Document::ID, + Document::SEQUENCE, + Document::PERMISSIONS, + Document::CREATED_AT, + Document::UPDATED_AT, + ]; + + $explicitInternals = []; + foreach ($selections as $selection) { + if (\in_array($selection, $internalKeys, true)) { + $explicitInternals[] = $selection; + } + } + + $selections = \array_values(\array_diff($selections, [...$internalKeys, Document::COLLECTION])); + + if ($includeInternal) { + foreach ($internalKeys as $internalKey) { + $selections[] = $this->getInternalKeyForAttribute($internalKey); + } + } else { + foreach (\array_values(\array_unique($explicitInternals)) as $internalKey) { + $selections[] = $this->getInternalKeyForAttribute($internalKey); + } + } + + $aliasSet = \array_fill_keys($joinAliases, true); + $quote = $this->getIdentifierQuote(); + $columns = []; + foreach ($selections as $selection) { + $dot = \strpos($selection, '.'); + if ($dot !== false) { + $prefix = \substr($selection, 0, $dot); + if (isset($aliasSet[$prefix])) { + $name = \substr($selection, $dot + 1); + $internal = $this->filter($this->getInternalKeyForAttribute($name)); + $qualified = $quote.$this->filter($prefix).$quote.'.'.$quote.$internal.$quote; + $output = $prefix.'.'.$internal; + $columns[] = $qualified.' AS '.$quote.$output.$quote; + + continue; + } + } + $columns[] = $this->filter($selection); + } + + return $columns; } /** - * Does the adapter handle array Overlaps? + * The projection of a join without a select or with `*`: every column of the main table, and under each + * join alias the joined collection's `$id` and the attributes the Database layer handed over for it. A + * joined table's internal columns are returned only when a select names them or when the read orders by + * them, so that every row it returns can be passed back as its cursor. * - * @return bool + * @param list $joinTablePrefixes + * @param array $additions Selections next to `*` and order attributes; those under a join alias are projected too */ - abstract public function getSupportForJSONOverlaps(): bool; + private function applyJoinProjection(SQLBuilder $builder, Document $collection, array $joinTablePrefixes, string $alias, array $additions = []): void + { + $builder->select([$this->filter($alias).'.*']); + + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + $aliasSet = \array_fill_keys($joinAliases, true); + $selections = \array_merge(...\array_values($this->joinSelections($collection, $joinTablePrefixes))); + foreach ($additions as $addition) { + $dot = \strpos($addition, '.'); + if ($dot !== false && isset($aliasSet[\substr($addition, 0, $dot)])) { + $selections[] = $addition; + } + } + + $this->applySelectionProjection( + $builder, + $selections, + includeInternal: false, + joinAliases: $joinAliases, + joinSelections: $this->joinWildcardSelections($collection, $joinTablePrefixes), + ); + } - public function getSupportForIndexArray(): bool + /** + * What a read without a select returns under each join alias: the joined collection's `$id` and + * the attributes the Database layer handed over for it. + * + * @param list $joinTablePrefixes + * @return array> + */ + private function joinSelections(Document $collection, array $joinTablePrefixes): array { - return true; + $joinAttributes = $collection->getAttribute(Database::JOIN_ATTRIBUTES, []); + $selections = []; + foreach ($joinTablePrefixes as $join) { + $selections[$join->alias] ??= []; + $selections[$join->alias][] = $join->alias.'.'.Document::ID; + + $attributes = \is_array($joinAttributes) ? ($joinAttributes[$join->table] ?? []) : []; + foreach (\is_array($attributes) ? $attributes : [] as $attribute) { + if (\is_string($attribute) && $attribute !== '') { + $selections[$join->alias][] = $join->alias.'.'.$attribute; + } + } + } + + return $selections; } - public function getSupportForCastIndexArray(): bool + /** + * What `alias.*` selects under each join alias: what a read without a select returns there, and the joined + * collection's internal attributes a direct read of it returns. + * + * @param list $joinTablePrefixes + * @return array> + */ + private function joinWildcardSelections(Document $collection, array $joinTablePrefixes): array { - return false; + $selections = $this->joinSelections($collection, $joinTablePrefixes); + foreach ($selections as $joinAlias => $columns) { + foreach (self::JOINED_ROW_INTERNALS as $internal) { + $columns[] = $joinAlias.'.'.$internal; + } + $selections[$joinAlias] = $columns; + } + + return $selections; } - public function getSupportForRelationships(): bool + /** + * @throws DatabaseException + */ + protected function addAttributeColumn(Table $table, Attribute $attribute): Column { - return true; + return $this->addTableColumn($table, $attribute->key, $attribute->type, $attribute->size ?? 0, $attribute->signed, $attribute->array, $attribute->required); + } + + protected function getAttributeSqlType(Attribute $attribute): string + { + return $this->getSqlType($attribute->type, $attribute->size ?? 0, $attribute->signed, $attribute->array, $attribute->required); + } + + /** + * A relationship stores a column on the side that holds the foreign key: never for many-to-many, which uses a + * junction table. + */ + protected static function storesColumn(Attribute $attribute): bool + { + $relationship = $attribute->relationship; + if ($relationship === null) { + return true; + } + + $parent = $attribute->side === RelationshipSide::Parent; + + return match ($relationship->type) { + RelationshipType::OneToOne => $parent || $relationship->twoWay, + RelationshipType::OneToMany => ! $parent, + RelationshipType::ManyToOne => $parent, + RelationshipType::ManyToMany => false, + }; } - public function getSupportForReconnection(): bool - { - return true; - } + /** + * Map Database type constants to Schema Table column definitions. + * + * @throws DatabaseException + */ + protected function addTableColumn( + Table $table, + string $id, + ColumnType $type, + int $size, + bool $signed = true, + bool $array = false, + bool $required = false + ): Column { + $filteredId = $this->filter($id); + + if (\in_array($type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + $column = $this->addSpatialColumn($table, $filteredId, $type); + if (! $required || $this->supports(Capability::IndexSpatialNull)) { + $column->nullable(); + } + + return $column; + } + + if ($array) { + return $table->json($filteredId)->nullable(); + } + + if ($type === ColumnType::Varchar) { + $this->assertVarcharSize($size); + } + + $column = match ($type) { + ColumnType::String => match (true) { + $size > 16777215 => $table->longText($filteredId), + $size > 65535 => $table->mediumText($filteredId), + $size > $this->limits()->varchar => $table->text($filteredId), + $size <= 0 => $table->text($filteredId), + default => $table->string($filteredId, $size), + }, + ColumnType::Integer => $size >= 8 + ? $table->bigInteger($filteredId) + : $table->integer($filteredId), + ColumnType::BigInteger => $table->bigInteger($filteredId), + ColumnType::Float, ColumnType::Double => $table->float($filteredId), + ColumnType::Boolean => $table->boolean($filteredId), + ColumnType::Datetime => $table->datetime($filteredId, 3), + ColumnType::Relationship => $table->string($filteredId, 255), + ColumnType::Id => $table->bigInteger($filteredId), + ColumnType::Varchar => $table->string($filteredId, $size), + ColumnType::Text => $table->text($filteredId), + ColumnType::MediumText => $table->mediumText($filteredId), + ColumnType::LongText => $table->longText($filteredId), + ColumnType::Object => $table->json($filteredId), + ColumnType::Vector => $this->addVectorColumn($table, $filteredId, $size), + default => throw new DatabaseException('Unknown type: '.$type->value), + }; + + if (! $signed && \in_array($type, [ColumnType::Integer, ColumnType::BigInteger, ColumnType::Float, ColumnType::Double], true)) { + $column->unsigned(); + } + + if ($type === ColumnType::Id) { + $column->unsigned(); + } + + // Non-spatial columns are nullable by default to match existing behavior + $column->nullable(); - public function getSupportForBatchCreateAttributes(): bool - { - return true; + return $column; } /** - * Are spatial attributes supported? - * - * @return bool - */ - public function getSupportForSpatialAttributes(): bool + * @throws DatabaseException + */ + protected function assertVarcharSize(int $size): void { - return false; + if ($size <= 0) { + throw new DatabaseException('VARCHAR size ' . $size . ' is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.'); + } + if ($size > $this->limits()->varchar) { + throw new DatabaseException('VARCHAR size ' . $size . ' exceeds maximum varchar length ' . $this->limits()->varchar . '. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.'); + } } /** - * Does the adapter support null values in spatial indexes? - * - * @return bool + * @throws DatabaseException */ - public function getSupportForSpatialIndexNull(): bool + private function addSpatialColumn(Table $table, string $name, ColumnType $type): Column { - return false; + $srid = $this->getSpatialColumnSrid(); + if ($srid === null) { + return $table->addColumn($name, $type); + } + + return match ($type) { + ColumnType::Point => $table->point($name, $srid), + ColumnType::Linestring => $table->linestring($name, $srid), + ColumnType::Polygon => $table->polygon($name, $srid), + default => throw new DatabaseException('Unknown spatial type: '.$type->value), + }; } /** - * Does the adapter support operators? - * - * @return bool + * SRID written into spatial column definitions, or null for a dialect that cannot declare one on a column. */ - public function getSupportForOperators(): bool + protected function getSpatialColumnSrid(): ?int { - return true; + return Database::DEFAULT_SRID; } - /** - * Does the adapter support order attribute in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexOrder(): bool + private function addVectorColumn(Table $table, string $name, int $size): Column { - return false; - } + if (! $table instanceof PostgresTable) { + throw new DatabaseException('Vector columns are only supported on PostgreSQL'); + } - /** - * Is internal casting supported? - * - * @return bool - */ - public function getSupportForInternalCasting(): bool - { - return false; + return $table->vector($name, $size); } /** - * Does the adapter support multiple fulltext indexes? - * - * @return bool + * @param array $queries */ - public function getSupportForMultipleFulltextIndexes(): bool + private function queriesHaveJoins(array $queries): bool { - return true; - } + foreach ($queries as $query) { + if ($query->getMethod()->isJoin()) { + return true; + } + } - /** - * Does the adapter support identical indexes? - * - * @return bool - */ - public function getSupportForIdenticalIndexes(): bool - { - return true; + return false; } /** - * Does the adapter support random order for queries? + * @param array $queries + * @return list * - * @return bool + * @throws QueryException */ - public function getSupportForOrderRandom(): bool + private function remapJoinQueries(array &$queries): array { - return true; - } + $joinTablePrefixes = []; + $this->assertJoinAliases($queries); - public function getSupportForUTCCasting(): bool - { - return false; - } + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } - public function setUTCDatetime(string $value): mixed - { - return $value; - } + $joinTable = $query->getAttribute(); + $query->setAttribute($this->getTableRaw($this->filter($joinTable))); - public function castingBefore(Document $collection, Document $document): Document - { - return $document; - } + $joinAlias = $query->getAlias(); + if ($query->isNestedJoin()) { + $query->setValues($this->remapJoinOnQueries($query, Query::DEFAULT_ALIAS, $joinAlias)); + } - public function castingAfter(Document $collection, Document $document): Document - { - return $document; + $joinTablePrefixes[] = new JoinAlias($joinTable, $joinAlias); + } + + return $joinTablePrefixes; } /** - * Does the adapter support spatial axis order specification? + * @param array $queries * - * @return bool + * @throws QueryException when a join alias is invalid or declared more than once */ - public function getSupportForSpatialAxisOrder(): bool + private function assertJoinAliases(array $queries): void { - return false; + $declared = []; + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } + + $alias = $query->getAlias(); + $invalid = JoinValidator::describeInvalidAlias($alias); + if ($invalid !== null) { + throw new QueryException($invalid); + } + + $key = \strtolower($alias); + if (isset($declared[$key])) { + throw new QueryException("Join alias \"{$alias}\" is declared more than once"); + } + $declared[$key] = true; + } } /** - * Is vector type supported? - * - * @return bool + * @return list */ - public function getSupportForVectors(): bool + private function remapJoinOnQueries(BaseQuery $query, string $mainAlias, string $joinAlias): array { - return false; + $values = []; + foreach ($query->getJoinOnQueries() as $onQuery) { + $values[] = $this->remapJoinOnQuery($onQuery, $mainAlias, $joinAlias); + } + + return $values; } - /** - * Generate ST_GeomFromText call with proper SRID and axis order support - * - * @param string $wktPlaceholder - * @param int|null $srid - * @return string - */ - protected function getSpatialGeomFromText(string $wktPlaceholder, ?int $srid = null): string + private function remapJoinOnQuery(BaseQuery $onQuery, string $mainAlias, string $joinAlias): BaseQuery { - $srid = $srid ?? Database::DEFAULT_SRID; - $geomFromText = "ST_GeomFromText({$wktPlaceholder}, {$srid}"; + if ($onQuery->getMethod() !== Method::On) { + return $onQuery; + } - if ($this->getSupportForSpatialAxisOrder()) { - $geomFromText .= ", " . $this->getSpatialAxisOrderSpec(); + $values = $onQuery->getValues(); + $left = $values[0] ?? null; + $right = $values[2] ?? null; + if (! \is_string($left) || $left === '' || ! \is_string($right) || $right === '') { + throw new QueryException('Join ON requires left and right columns'); } - $geomFromText .= ")"; + $values[0] = $this->qualifyJoinColumn($left, $mainAlias); + $values[2] = $this->qualifyJoinColumn($right, $joinAlias); + $onQuery->setValues($values); - return $geomFromText; + return $onQuery; } /** - * Get the spatial axis order specification string - * - * @return string + * @param array $queries */ - protected function getSpatialAxisOrderSpec(): string + private function needsFullOuterJoinEmulation(SQLBuilder $builder, array $queries): bool { - return "'axis-order=long-lat'"; - } + if ($builder instanceof FullOuterJoinsFeature) { + return false; + } - /** - * @param string $tableName - * @param string $columns - * @param array $batchKeys - * @param array $bindValues - * @param array $attributes - * @param string $attribute - * @param array $operators - * @return mixed - */ - abstract protected function getUpsertStatement( - string $tableName, - string $columns, - array $batchKeys, - array $attributes, - array $bindValues, - string $attribute = '', - array $operators = [] - ): mixed; + foreach ($queries as $query) { + if ($query->getMethod() === Method::FullOuterJoin) { + return true; + } + } - /** - * Get the SQL expression measuring distance between a vector attribute and the query vector - * - * @param Query $query - * @param array $binds - * @param string $alias - * @return string|null - */ - protected function getSQLVectorDistance(Query $query, array &$binds, string $alias): ?string - { - return null; + return false; } /** - * Render a vector distance expression in a form safe to read back into PHP + * Split a query set with a full outer join the engine cannot run into the two halves of a flat + * UNION ALL, every table staying at the top level of FROM where later ON and WHERE conditions + * reach it. The first half runs the full outer join as a left join and keeps every row holding a + * main-side row; the second runs it as a right join and keeps only the joined table's unmatched rows. + * + * A later right join runs in both halves, so its unmatched rows are kept by one half only: the one + * whose rows alone decide what the right join matches — the first when its ON reaches a table joined + * before the full outer join, the second when it reaches the full outer joined table. A chain neither + * half can decide alone, or with a second full outer join, is rejected. * - * A distance is undefined for a zero vector and can overflow for a large one, so the - * expression can evaluate to NaN or infinity. Those cannot survive the trip into a PHP - * float, so the value is carried as text and interpreted during hydration. + * @param array $queries With the join columns remapJoinQueries() qualified + * @return array{0: array, 1: array} * - * @param string $distance - * @return string + * @throws QueryException */ - protected function getSQLReadableDistance(string $distance): string + private function emulateFullOuterJoin(array $queries, string $alias): array { - return $distance; - } + $fullJoinAlias = null; + $joinedAliases = [$alias]; + $mainSideAliases = []; + $reach = []; + $firstHalfRows = self::UNPAIRED_MAIN_ROWS | self::PAIRED_ROWS; + $secondHalfRows = self::PAIRED_ROWS | self::UNPAIRED_JOINED_ROWS; + $presentRows = $firstHalfRows | $secondHalfRows; + $nextRows = self::UNPAIRED_JOINED_ROWS << 1; + $firstHalfExclusions = []; + $secondHalfInclusions = []; - /** - * @param string $value - * @return string - */ - protected function getFulltextValue(string $value): string - { - $exact = str_ends_with($value, '"') && str_starts_with($value, '"'); + foreach ($queries as $query) { + $method = $query->getMethod(); + if (! $method->isJoin()) { + continue; + } - /** Keep only unicode letters, numbers, underscores, and whitespace. */ - $value = preg_replace('/[^\p{L}\p{N}_\s]/u', ' ', $value) ?? ''; - $value = preg_replace('/\s+/', ' ', $value) ?? ''; - $value = trim($value); + $joinAlias = $query->getAlias(); - if (empty($value)) { - return ''; + if ($method === Method::FullOuterJoin) { + if ($fullJoinAlias !== null) { + throw new QueryException('A query can hold only one full outer join on this database'); + } + + $fullJoinAlias = $joinAlias; + $mainSideAliases = $joinedAliases; + foreach ($mainSideAliases as $mainSideAlias) { + $reach[$mainSideAlias] = self::UNPAIRED_MAIN_ROWS | self::PAIRED_ROWS; + } + $reach[$joinAlias] = self::PAIRED_ROWS | self::UNPAIRED_JOINED_ROWS; + $joinedAliases[] = $joinAlias; + + continue; + } + + if ($fullJoinAlias === null) { + $joinedAliases[] = $joinAlias; + + continue; + } + + $rows = $presentRows; + foreach ($this->joinConditionAliases($query) as $conditionAlias) { + $rows &= $reach[$conditionAlias] ?? $presentRows; + } + + if ($method === Method::RightJoin) { + $unmatchedRows = $nextRows; + $nextRows <<= 1; + + if (($rows & ~$firstHalfRows) === 0) { + $firstHalfRows |= $unmatchedRows; + } elseif (($rows & ~$secondHalfRows) === 0) { + $secondHalfRows |= $unmatchedRows; + $firstHalfExclusions[] = $this->anyOf([ + ...\array_map(static fn (string $joined): BaseQuery => BaseQuery::isNotNull($joined.'.'.Storage::UID), $joinedAliases), + BaseQuery::isNull($joinAlias.'.'.Storage::UID), + ]); + $between = \array_slice($joinedAliases, \count($mainSideAliases) + 1); + $secondHalfInclusions[] = $this->allOf([ + ...\array_map(static fn (string $joined): BaseQuery => BaseQuery::isNull($joined.'.'.Storage::UID), $between), + BaseQuery::isNotNull($joinAlias.'.'.Storage::UID), + ]); + } else { + throw new QueryException('A right join after a full outer join has to join on a table joined before it, or on the full outer joined table'); + } + + $rows |= $unmatchedRows; + $presentRows |= $unmatchedRows; + } elseif ($method === Method::CrossJoin || $method === Method::NaturalJoin) { + $rows = $presentRows; + } + + $reach[$joinAlias] = $rows; + $joinedAliases[] = $joinAlias; } - if ($exact) { - $value = '"' . $value . '"'; - } else { - /** Prepend wildcard by default on the back. */ - $value .= '*'; + if ($fullJoinAlias === null) { + throw new DatabaseException('The query holds no full outer join to emulate'); } - return $value; - } + $firstHalf = $this->rewriteFullOuterJoins($queries, Method::LeftJoin); + \array_push($firstHalf, ...$firstHalfExclusions); - /** - * Get SQL Operator - * - * @param string $method - * @return string - * @throws Exception - */ - protected function getSQLOperator(string $method): string - { - switch ($method) { - case Query::TYPE_EQUAL: - return '='; - case Query::TYPE_NOT_EQUAL: - return '!='; - case Query::TYPE_LESSER: - return '<'; - case Query::TYPE_LESSER_EQUAL: - return '<='; - case Query::TYPE_GREATER: - return '>'; - case Query::TYPE_GREATER_EQUAL: - return '>='; - case Query::TYPE_IS_NULL: - return 'IS NULL'; - case Query::TYPE_IS_NOT_NULL: - return 'IS NOT NULL'; - case Query::TYPE_STARTS_WITH: - case Query::TYPE_ENDS_WITH: - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: - case Query::TYPE_CONTAINS_ALL: - case Query::TYPE_NOT_STARTS_WITH: - case Query::TYPE_NOT_ENDS_WITH: - case Query::TYPE_NOT_CONTAINS: - return $this->getLikeOperator(); - case Query::TYPE_REGEX: - return $this->getRegexOperator(); - case Query::TYPE_VECTOR_DOT: - case Query::TYPE_VECTOR_COSINE: - case Query::TYPE_VECTOR_EUCLIDEAN: - throw new DatabaseException('Vector queries are not supported by this database'); - case Query::TYPE_EXISTS: - case Query::TYPE_NOT_EXISTS: - throw new DatabaseException('Exists queries are not supported by this database'); - default: - throw new DatabaseException('Unknown method: ' . $method); - } - } - - abstract protected function getSQLType( - string $type, - int $size, - bool $signed = true, - bool $array = false, - bool $required = false - ): string; + $secondHalf = $this->rewriteFullOuterJoins($queries, Method::RightJoin); + foreach ($mainSideAliases as $mainSideAlias) { + $secondHalf[] = BaseQuery::isNull($mainSideAlias.'.'.Storage::UID); + } + $secondHalf[] = $this->anyOf([ + BaseQuery::isNotNull($fullJoinAlias.'.'.Storage::UID), + ...$secondHalfInclusions, + ]); - /** - * @throws DatabaseException For unknown type values. - */ - public function getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string - { - return $this->getSQLType($type, $size, $signed, $array, $required); + return [$firstHalf, $secondHalf]; } /** - * Get SQL Index Type + * The aliases whose columns a join's ON compares, other than the join's own. * - * @param string $type - * @return string - * @throws Exception + * @return list */ - protected function getSQLIndexType(string $type): string + private function joinConditionAliases(BaseQuery $join): array { - return match ($type) { - Database::INDEX_KEY => 'INDEX', - Database::INDEX_UNIQUE => 'UNIQUE INDEX', - Database::INDEX_FULLTEXT => 'FULLTEXT INDEX', - default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT), - }; - } + $method = $join->getMethod(); + if ($method === Method::CrossJoin || $method === Method::NaturalJoin) { + return []; + } - /** - * Get SQL condition for permissions - * - * @param string $collection - * @param array $roles - * @param string $alias - * @param string $type - * @return string - * @throws DatabaseException - */ - protected function getSQLPermissionsCondition( - string $collection, - array $roles, - string $alias, - string $type = Database::PERMISSION_READ - ): string { - if (!\in_array($type, Database::PERMISSIONS)) { - throw new DatabaseException('Unknown permission type: ' . $type); + $columns = []; + foreach ($join->getJoinOnQueries() as $condition) { + if ($condition->getMethod() === Method::On) { + $values = $condition->getValues(); + $columns[] = $values[0] ?? null; + $columns[] = $values[2] ?? null; + } } - $roles = \array_map(fn ($role) => $this->getPDO()->quote($role), $roles); - $roles = \implode(', ', $roles); + $joinAlias = $join->getAlias(); + $aliases = []; + foreach ($columns as $column) { + if (! \is_string($column)) { + continue; + } + + $dot = \strpos($column, '.'); + if ($dot === false) { + continue; + } + + $conditionAlias = \substr($column, 0, $dot); + if ($conditionAlias !== $joinAlias) { + $aliases[] = $conditionAlias; + } + } - return "{$this->quote($alias)}.{$this->quote('_uid')} IN ( - SELECT _document - FROM {$this->getSQLTable($collection . '_perms')} - WHERE _permission IN ({$roles}) - AND _type = '{$type}' - {$this->getTenantQuery($collection)} - )"; + return $aliases; } /** - * Get SQL table - * - * @param string $name - * @return string - * @throws DatabaseException + * @param non-empty-list $conditions */ - protected function getSQLTable(string $name): string + private function anyOf(array $conditions): BaseQuery { - return "{$this->quote($this->getDatabase())}.{$this->quote($this->getNamespace() . '_' .$this->filter($name))}"; + return \count($conditions) === 1 ? $conditions[0] : BaseQuery::or($conditions); } /** - * Generate SQL expression for operator - * Each adapter must implement operators specific to their SQL dialect - * - * @param string $column - * @param Operator $operator - * @param array $binds - * @return string|null Returns null if operator can't be expressed in SQL + * @param non-empty-list $conditions */ - abstract protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string; + private function allOf(array $conditions): BaseQuery + { + return \count($conditions) === 1 ? $conditions[0] : BaseQuery::and($conditions); + } /** - * Register an operator bind value and return its placeholder name (without leading colon). - * Lets getOperatorSQL() capture a parameter's value as it emits the placeholder, so SQL and - * binds can never drift out of sync. The placeholder is unique by construction (ID::unique()), - * so no shared counter needs to be threaded between callers. - * - * @param array $binds + * @param array $queries */ - protected function registerOperatorBind(array &$binds, mixed $value): string + private function keepsUnmatchedRows(array $queries): bool { - $key = ID::unique(); - $binds[":{$key}"] = $value; - return $key; + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method === Method::RightJoin || $method === Method::FullOuterJoin) { + return true; + } + } + + return false; } /** - * Apply an operator to a value (used for new documents with only operators). - * This method applies the operator logic in PHP to compute what the SQL would compute. - * - * @param Operator $operator - * @param mixed $value The current value (typically the attribute default) - * @return mixed The result after applying the operator + * @param array $queries + * @return array */ - protected function applyOperatorToValue(Operator $operator, mixed $value): mixed + private function rewriteFullOuterJoins(array $queries, Method $replacement): array { - $method = $operator->getMethod(); - $values = $operator->getValues(); + $rewritten = []; + foreach ($queries as $query) { + $clone = clone $query; + if ($clone->getMethod() === Method::FullOuterJoin) { + $clone->setMethod($replacement); + } + $rewritten[] = $clone; + } - switch ($method) { - // Numeric operators - case Operator::TYPE_INCREMENT: - return ($value ?? 0) + ($values[0] ?? 1); + return $rewritten; + } - case Operator::TYPE_DECREMENT: - return ($value ?? 0) - ($values[0] ?? 1); + /** + * @param array $queries + * @param list $joinTablePrefixes + * @param array $adapterFilterQueries + * @param array $roles + * @param array $orderAttributes The attributes the read orders by + */ + private function configureFindBuilder( + SQLBuilder $builder, + Document $collection, + array $queries, + array $joinTablePrefixes, + bool $hasAggregation, + bool $hasDistinct, + array $adapterFilterQueries, + string $name, + string $alias, + array $roles, + PermissionType $forPermission, + bool $qualifyCollidingGroups = true, + array $orderAttributes = [], + ): bool { + $hasSelectionProjection = false; + if (! $hasAggregation) { + $selections = []; + foreach ($queries as $query) { + if ($query->getMethod() === Method::Select) { + foreach ($query->getValues() as $value) { + /** @var string $value */ + $selections[] = $value; + } + } + } + if (! empty($selections) && ! \in_array('*', $selections)) { + $this->applySelectionProjection( + $builder, + $selections, + includeInternal: ! $hasDistinct, + joinAliases: \array_column($joinTablePrefixes, 'alias'), + joinSelections: $this->joinWildcardSelections($collection, $joinTablePrefixes), + ); + // The projection replaces the select; forwarded as well, the builder would compile the caller's + // raw attribute names whenever the projection holds only aliased joined columns. + $queries = \array_values(\array_filter($queries, static fn (BaseQuery $query): bool => $query->getMethod() !== Method::Select)); + $hasSelectionProjection = true; + } elseif (! empty($joinTablePrefixes)) { + $this->applyJoinProjection( + $builder, + $collection, + $joinTablePrefixes, + $alias, + $hasDistinct ? $selections : [...$selections, ...$orderAttributes], + ); + $hasSelectionProjection = true; + } + } - case Operator::TYPE_MULTIPLY: - return ($value ?? 0) * ($values[0] ?? 1); + if ($hasAggregation && ! empty($joinTablePrefixes)) { + $mainAttributes = []; + foreach ([...Database::internalAttributesFor(true), ...self::collectionAttributes($collection)] as $attribute) { + $mainAttributes[$attribute->key] = true; + } - case Operator::TYPE_DIVIDE: - $divisor = $values[0] ?? 1; - return (float)$divisor !== 0.0 ? ($value ?? 0) / $divisor : ($value ?? 0); + $joinAttributes = $collection->getAttribute(Database::JOIN_ATTRIBUTES, []); + $declared = []; + foreach ($joinTablePrefixes as $join) { + $keys = \is_array($joinAttributes) ? ($joinAttributes[$join->table] ?? null) : null; + $declared[$join->alias] = \is_array($keys) ? \array_flip(\array_filter($keys, \is_string(...))) : null; + } - case Operator::TYPE_MODULO: - $divisor = $values[0] ?? 1; - return (float)$divisor !== 0.0 ? ($value ?? 0) % $divisor : ($value ?? 0); + $qualify = function (string $attribute) use ($mainAttributes, $declared): string { + if ( + $attribute === '*' + || $attribute === '' + || \is_numeric($attribute) + || \str_contains($attribute, '.') + || isset($mainAttributes[$attribute]) + ) { + return $attribute; + } - case Operator::TYPE_POWER: - return pow($value ?? 0, $values[0] ?? 1); + $aliases = []; + foreach ($declared as $alias => $attributes) { + if ($attributes === null || isset($attributes[$attribute])) { + $aliases[] = $alias; + } + } - // Array operators - case Operator::TYPE_ARRAY_APPEND: - return array_merge($value ?? [], $values); + if (\count($aliases) > 1) { + throw new QueryException('Attribute "'.$attribute.'" is ambiguous across joins; qualify it with a join alias'); + } - case Operator::TYPE_ARRAY_PREPEND: - return array_merge($values, $value ?? []); + if ($aliases === []) { + throw new QueryException('Attribute not found in schema: '.$attribute); + } - case Operator::TYPE_ARRAY_INSERT: - $arr = $value ?? []; - $index = $values[0] ?? 0; - $item = $values[1] ?? null; - array_splice($arr, $index, 0, [$item]); - return $arr; + return $aliases[0].'.'.$this->getInternalKeyForAttribute($attribute); + }; + + // The builder leaves a name that is also an aggregate alias unqualified, so an aggregate reads a main + // attribute qualified: a joined column of the same name would otherwise make it ambiguous. + foreach ($queries as $query) { + if ($query->getMethod()->isAggregate()) { + $attribute = $query->getAttribute(); + $query->setAttribute(isset($mainAttributes[$attribute]) ? $alias.'.'.$attribute : $qualify($attribute)); + } elseif ($query->getMethod() === Method::GroupBy) { + $query->setValues(\array_map( + static fn (mixed $column): mixed => \is_string($column) ? $qualify($column) : $column, + $query->getValues(), + )); + } + } + } - case Operator::TYPE_ARRAY_REMOVE: - $arr = $value ?? []; - $toRemove = $values[0] ?? null; - if (is_array($toRemove)) { - return array_values(array_diff($arr, $toRemove)); + if ($hasAggregation) { + // An aggregation returns only its groups and aggregates: a select the validators accept names a group, + // which is selected below, or a wildcard, so no select reaches the statement. + $queries = \array_values(\array_filter($queries, static fn (BaseQuery $query): bool => $query->getMethod() !== Method::Select)); + + foreach ($queries as $query) { + if ($query->getMethod() === Method::GroupBy) { + // Each group is selected as the GROUP BY clause names it once applyFindFilters() maps it. + $columns = clone $query; + $this->remapDottedQueryAttributes([$columns], $joinTablePrefixes, $collection); + /** @var array $groupCols */ + $groupCols = $columns->getValues(); + /** @var array $groups */ + $groups = $query->getValues(); + $qualified = $qualifyCollidingGroups ? $this->qualifiedGroupNames($groups) : []; + $plain = []; + foreach ($groupCols as $index => $col) { + if (! isset($qualified[$index])) { + $plain[] = \str_contains($col, '.') ? $col : $this->filter($this->getInternalKeyForAttribute($col)); + } + } + if ($plain !== []) { + $builder->select($plain); + } + foreach ($qualified as $index => $group) { + [$table, $column] = \explode('.', $groupCols[$index], 2); + $builder->select($this->quote($this->filter($table)).'.'.$this->quote($this->filter($column)).' AS '.$this->quote($group)); + } } - return array_values(array_diff($arr, [$toRemove])); + } + } - case Operator::TYPE_ARRAY_UNIQUE: - return array_values(array_unique($value ?? [])); + $this->applyFindFilters( + $builder, + $collection, + $queries, + $joinTablePrefixes, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + ); + + return $hasSelectionProjection; + } - case Operator::TYPE_ARRAY_INTERSECT: - return array_values(array_intersect($value ?? [], $values)); + /** + * @param array $queries + */ + private function applyFilters(SQLBuilder $builder, array $queries, string $name, string $alias): void + { + $builderQueries = []; + $adapterFilters = []; + foreach ($queries as $query) { + if ($this->isAdapterFilterQuery($query)) { + $adapterFilters[] = $this->compileAdapterFilter($query, $name, $alias); - case Operator::TYPE_ARRAY_DIFF: - return array_values(array_diff($value ?? [], $values)); + continue; + } + $builderQueries[] = $query; + } - case Operator::TYPE_ARRAY_FILTER: - return $value ?? []; + $builder->filter($builderQueries); - // String operators - case Operator::TYPE_STRING_CONCAT: - return ($value ?? '') . ($values[0] ?? ''); + foreach ($adapterFilters as $filter) { + if ($filter !== null) { + $builder->whereRaw($filter->sql, $filter->bindings); + } + } + } - case Operator::TYPE_STRING_REPLACE: - $search = $values[0] ?? ''; - $replace = $values[1] ?? ''; - return str_replace($search, $replace, $value ?? ''); + /** + * @param array $queries + * @param list $joinTablePrefixes + * @param array $adapterFilterQueries + * @param array $roles + */ + private function applyFindFilters( + SQLBuilder $builder, + Document $collection, + array $queries, + array $joinTablePrefixes, + array $adapterFilterQueries, + string $name, + string $alias, + array $roles, + PermissionType $forPermission, + ): void { + $queries = $this->populationStatistics($queries); + $adapterFilterQueries = \array_map(static fn (Query $query): Query => clone $query, $adapterFilterQueries); + $this->remapDottedQueryAttributes([...$queries, ...$adapterFilterQueries], $joinTablePrefixes, $collection); + $builder->filter($queries); + + foreach ($adapterFilterQueries as $query) { + $compiled = $this->compileAdapterFilter($query, $name, $alias, $joinTablePrefixes); + if ($compiled !== null) { + $builder->whereRaw($compiled->sql, $compiled->bindings); + } + } - // Boolean operators - case Operator::TYPE_TOGGLE: - return !($value ?? false); + $chain = Join\Chain::fromQueries($queries); + $preserving = $chain->hasPreservingOuterJoin(); - // Date operators - case Operator::TYPE_DATE_ADD_DAYS: - case Operator::TYPE_DATE_SUB_DAYS: - // For NULL dates, operators return NULL - return $value; + if ($this->sharedTables && $preserving) { + $tenantFilter = new Tenant\Filter($this->currentTenant(), quoteCharacter: $this->getIdentifierQuote()); + $tenantConditions = []; + foreach ($joinTablePrefixes as $join) { + $tenantConditions[$join->alias] = $tenantFilter->joined($join->alias); + } + $builder->addHook(new Join\OuterChain($chain, $tenantConditions, $this->getIdentifierQuote())); + } - case Operator::TYPE_DATE_SET_NOW: - return DateTime::now(); + if ($this->authorization->getStatus()) { + $hasJoins = ! empty($joinTablePrefixes); + $granted = $hasJoins && $collection->getAttribute(Database::COLLECTION_GRANTED, false) === true; + $permissionConditions = []; + if (! $granted && $this->filtersPerDocument($collection)) { + $docCol = $hasJoins ? $alias.'.'.Storage::UID : Storage::UID; + $permissionHook = $this->newPermissionHook($name, $roles, $forPermission->value, $docCol); + if ($preserving) { + $permissionConditions[$alias] = $permissionHook->filter($alias); + $permissionHook = new Permission\AllowNullUid( + $permissionHook, + $docCol, + $this->getIdentifierQuote(), + ); + } + $builder->addHook($permissionHook); + } - default: - return $value; - } - } + $joinDocumentSecurity = $collection->getAttribute(Database::JOIN_DOCUMENT_SECURITY, []); + /** @var array $joinDocumentSecurity */ + $joinDocumentSecurity = \is_array($joinDocumentSecurity) ? $joinDocumentSecurity : []; - /** - * Returns the current PDO object - * @return mixed - * @deprecated Use getDriver() instead - */ - protected function getPDO(): mixed - { - return $this->pdo; - } + foreach ($joinTablePrefixes as $join) { + if ($this->joinDocumentSecurityEnabled($joinDocumentSecurity, $join->table) === false) { + continue; + } - /** - * Returns the current PDO object - * @return mixed - */ - public function getDriver(): mixed - { - return $this->pdo; - } + $permissionHook = $this->newJoinPermissionHook( + $this->filter($join->table), + $roles, + $forPermission->value, + $join->alias.'.'.Storage::UID, + \count($joinTablePrefixes), + $chain->type($join->alias), + ); + if ($preserving) { + $permissionConditions[$join->alias] = $permissionHook->filter($join->alias); + } + $builder->addHook(new Permission\Join( + $permissionHook, + $join->alias, + $this->getIdentifierQuote(), + $preserving, + )); + } - /** - * Get PDO Type - * - * @param mixed $value - * @return int - * @throws Exception - */ - abstract protected function getPDOType(mixed $value): int; + if ($permissionConditions !== []) { + $builder->addHook(new Permission\OuterJoin($alias, $permissionConditions, $this->getIdentifierQuote())); + $builder->addHook(new Join\OuterChain($chain, $permissionConditions, $this->getIdentifierQuote())); + } + } + } /** - * Get the SQL function for random ordering + * Rewrite the two ambiguous statistical aggregates to their explicit + * population forms, and count the inputs of every bitwise aggregate. * - * @return string - */ - abstract protected function getRandomOrder(): string; - - /** - * Returns default PDO configuration + * Bare `STDDEV` and `VARIANCE` are not portable: MySQL and MariaDB read + * both as the population statistic, PostgreSQL reads both as the sample + * one, so the same query answered 67.0238 on one engine and 77.3985 on + * the other. `STDDEV_POP` and `VAR_POP` mean the population statistic on + * every engine this adapter targets, so emitting them explicitly fixes + * the contract at population - which is what MySQL and MariaDB already + * returned, and what the ClickHouse builder already chose. Callers who + * want the sample statistic ask for it by name with stddevSamp() or + * varSamp(), which were always unambiguous. * - * @return array + * Over no input values MySQL and MariaDB answer `BIT_AND` with every bit + * set and `BIT_OR` / `BIT_XOR` with zero, where PostgreSQL answers NULL + * as every engine does for each aggregate but count. The input count + * lets bitwiseResults() answer NULL on every engine. + * + * @param array $queries + * @return array */ - public static function getPDOAttributes(): array + private function populationStatistics(array $queries): array { - return [ - \PDO::ATTR_TIMEOUT => 3, // Specifies the timeout duration in seconds. Takes a value of type int. - \PDO::ATTR_PERSISTENT => true, // Create a persistent connection - \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, // Fetch a result row as an associative array. - \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, // PDO will throw a PDOException on errors - \PDO::ATTR_EMULATE_PREPARES => true, // Emulate prepared statements - \PDO::ATTR_STRINGIFY_FETCHES => true // Returns all fetched data as Strings - ]; - } + foreach ($queries as $index => $query) { + $method = match ($query->getMethod()) { + Method::Stddev => Method::StddevPop, + Method::Variance => Method::VarPop, + default => null, + }; + + if ($method !== null) { + $queries[$index] = (clone $query)->setMethod($method); + } + } - public function getHostname(): string - { - try { - return $this->pdo->getHostname(); - } catch (\Throwable) { - return ''; + foreach ($this->bitwiseInputs($queries) as $count => $aggregate) { + $queries[] = Query::count($aggregate->getAttribute(), $count); } - } - /** - * @return int - */ - public function getMaxVarcharLength(): int - { - return 16381; // Floor value for Postgres:16383 | MySQL:16381 | MariaDB:16382 + return $queries; } /** - * Size of POINT spatial type + * Each bitwise aggregate, keyed by the alias of the input count populationStatistics() + * adds for it: `$inputs:` for the n-th of them. The name stays short because PostgreSQL + * truncates an identifier to 63 bytes, and a truncated count named another aggregate's alias. * - * @return int - */ - abstract protected function getMaxPointSize(): int; - /** - * @return string + * @param array $queries + * @return array */ - public function getIdAttributeType(): string + private function bitwiseInputs(array $queries): array { - return Database::VAR_INTEGER; + $inputs = []; + foreach ($queries as $query) { + if (\in_array($query->getMethod(), self::BITWISE_AGGREGATES, true)) { + $inputs[self::BITWISE_INPUTS.\count($inputs)] = $query; + } + } + + return $inputs; } - /** - * @return int - */ - public function getMaxIndexLength(): int + private function filtersPerDocument(Document $collection): bool { - /** - * $tenant int = 1 - */ - return $this->sharedTables ? 767 : 768; + return (bool) $collection->getAttribute('documentSecurity', false) + || $collection->getId() === Database::METADATA; } /** - * @return int + * @param array $joinDocumentSecurity */ - public function getMaxUIDLength(): int + private function joinDocumentSecurityEnabled(array $joinDocumentSecurity, string $table): bool { - return 36; - } + foreach ($this->joinDocumentSecurityLookupKeys($table) as $key) { + if (\array_key_exists($key, $joinDocumentSecurity)) { + return (bool) $joinDocumentSecurity[$key]; + } + } - /** - * @param Query $query - * @param array $binds - * @param ?string $forCollection Filtered collection id (for FTS5 routing). - * @return string - * @throws Exception - */ - abstract protected function getSQLCondition(Query $query, array &$binds, ?string $forCollection = null): string; + if ($joinDocumentSecurity === []) { + return true; + } - /** - * @param array $queries - * @param array $binds - * @param string $separator - * @param ?string $forCollection See {@see getSQLCondition}. - * @return string - * @throws Exception - */ - public function getSQLConditions(array $queries, array &$binds, string $separator = 'AND', ?string $forCollection = null): string - { - $conditions = []; - foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { + $candidates = $this->joinDocumentSecurityLookupKeys($table); + foreach ($joinDocumentSecurity as $key => $enabled) { + $key = (string) $key; + if ($key === '') { continue; } - if ($query->isNested()) { - $conditions[] = $this->getSQLConditions($query->getValues(), $binds, $query->getMethod(), $forCollection); - } else { - $conditions[] = $this->getSQLCondition($query, $binds, $forCollection); + if (\array_intersect($candidates, $this->joinDocumentSecurityLookupKeys($key)) !== []) { + return (bool) $enabled; } } - $tmp = implode(' ' . $separator . ' ', $conditions); - return empty($tmp) ? '' : '(' . $tmp . ')'; + return true; } /** - * @return string + * @return list */ - public function getLikeOperator(): string + private function joinDocumentSecurityLookupKeys(string $table): array { - return 'LIKE'; + $filtered = $this->filter($table); + $keys = [$table, $filtered]; + $qualified = $this->getTableRaw($filtered); + $keys[] = $qualified; + + $dot = \strrpos($qualified, '.'); + if ($dot !== false) { + $keys[] = \substr($qualified, $dot + 1); + } + + return \array_values(\array_unique($keys)); } /** - * @return string + * @param array $orderAttributes + * @param array $orderTypes + * @param list $joinTablePrefixes */ - public function getRegexOperator(): string - { - return 'REGEXP'; - } - - public function getInternalIndexesKeys(): array - { - return []; - } - - public function getSchemaAttributes(string $collection): array - { - return []; - } - - public function getSchemaIndexes(string $collection): array - { - return []; - } - - public function getSupportForSchemaIndexes(): bool - { - return false; - } - - public function getTenantQuery( - string $collection, - string $alias = '', - int $tenantCount = 0, - string $condition = 'AND' - ): string { - if (!$this->sharedTables) { - return ''; + private function applyFullOuterJoinOrderProjection( + SQLBuilder $builder, + Document $collection, + string $alias, + array $orderAttributes, + array $orderTypes, + bool $hasSelectionProjection, + array $joinTablePrefixes = [], + ): void { + $hasOrderColumns = false; + foreach (\array_keys($orderAttributes) as $i) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; + if ($orderType !== OrderDirection::Random) { + $hasOrderColumns = true; + break; + } } - $dot = ''; - if ($alias !== '') { - $dot = '.'; - $alias = $this->quote($alias); + if (! $hasOrderColumns) { + return; } - $bindings = []; - if ($tenantCount === 0) { - $bindings[] = ':_tenant'; - } else { - for ($index = 0; $index < $tenantCount; $index++) { - $bindings[] = ":_tenant_{$index}"; + if (! $hasSelectionProjection) { + if (empty($joinTablePrefixes)) { + $builder->select(['*']); + } else { + $this->applyJoinProjection($builder, $collection, $joinTablePrefixes, $alias); } } - $bindings = \implode(',', $bindings); - $orIsNull = ''; - if ($collection === Database::METADATA) { - $orIsNull = " OR {$alias}{$dot}_tenant IS NULL"; - } + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + foreach ($orderAttributes as $i => $attribute) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; + if ($orderType === OrderDirection::Random) { + continue; + } - return "{$condition} ({$alias}{$dot}_tenant IN ({$bindings}) {$orIsNull})"; + $expression = $this->quoteOrderColumn($this->qualifyOrderAttribute($attribute, $joinAliases), $alias); + $builder->selectRaw($expression.' AS '.$this->quote(self::FOJ_ORDER_ALIAS_PREFIX.$i)); + } } /** - * Get the SQL projection given the selected attributes - * - * @param array $selections - * @param string $prefix - * @return mixed - * @throws Exception + * Quote an order key from qualifyOrderAttribute() as a table-qualified column: a join-qualified key + * keeps its join alias, any other key belongs to the main table. */ - protected function getAttributeProjection(array $selections, string $prefix): mixed + private function quoteOrderColumn(string $key, string $alias): string { - if (empty($selections) || \in_array('*', $selections)) { - return "{$this->quote($prefix)}.*"; + $dot = \strpos($key, '.'); + if ($dot === false) { + return $this->quote($alias).'.'.$this->quote($key); } - // Handle specific selections with spatial conversion where needed - $internalKeys = [ - '$id', - '$sequence', - '$permissions', - '$createdAt', - '$updatedAt', - ]; + return $this->quote(\substr($key, 0, $dot)).'.'.$this->quote(\substr($key, $dot + 1)); + } - $hasDeletedAt = \in_array('$deletedAt', $selections); + /** + * Aggregate an emulated full outer join once, over the rows of both halves. Each half keeps its own + * joins, filters, tenant and permission conditions and projects the columns the aggregation reads; + * their UNION ALL is read as one derived table, and the aggregates, groups, having, distinct(), order + * and page run over it through the projection and fetch a native full outer join goes through. + * + * @param array $queries With the join columns remapJoinQueries() qualified + * @param list $joinTablePrefixes + * @param array $adapterFilterQueries + * @param array $roles + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @param callable(string): string $resolveInternalKey + * @return array> + * + * @throws DatabaseException + */ + private function findFullOuterJoinAggregate( + Document $collection, + array $queries, + array $joinTablePrefixes, + bool $hasDistinct, + array $adapterFilterQueries, + string $name, + string $alias, + array $roles, + PermissionType $forPermission, + array $orderAttributes, + array $orderTypes, + ?int $limit, + ?int $offset, + array $cursor, + CursorDirection $cursorDirection, + callable $resolveInternalKey, + ): array { + $aggregationQueries = []; + $rowQueries = []; + $aggregateAliases = []; + foreach ($queries as $query) { + $method = $query->getMethod(); + if (! $this->shapesAggregatedRows($method)) { + $rowQueries[] = $query; - $selections = \array_diff($selections, [...$internalKeys, '$deletedAt', '$collection']); + continue; + } - foreach ($internalKeys as $internalKey) { - $selections[] = $this->getInternalKeyForAttribute($internalKey); + $aggregationQueries[] = $query; + if ($method->isAggregate() && $query->getAlias() !== '') { + $aggregateAliases[$query->getAlias()] = true; + } } - if ($hasDeletedAt) { - $selections[] = $this->getInternalKeyForAttribute('$deletedAt'); + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + $aggregation = $this->dialectBuilder(); + // The halves carry every tenant and permission condition of the read. The aggregation reads only + // their rows, so it takes none of the permission filters configureFindBuilder() gives a builder + // that reads the tables. + $this->authorization->skip(fn (): bool => $this->configureFindBuilder( + $aggregation, + $collection, + $aggregationQueries, + $joinTablePrefixes, + true, + $hasDistinct, + [], + $name, + $alias, + $roles, + $forPermission, + qualifyCollidingGroups: false, + )); + $this->applyFindPage($aggregation, $orderAttributes, $orderTypes, $limit, $offset, $cursorDirection, joinAliases: $joinAliases); + $columns = $this->fullOuterJoinColumns($aggregationQueries, $orderAttributes, $orderTypes, $joinAliases, $aggregateAliases, $alias); + + [$leftQueries, $rightQueries] = $this->emulateFullOuterJoin($rowQueries, $alias); + $leftPreserving = $this->keepsUnmatchedRows($leftQueries); + $halves = []; + $unindexed = $this->unindexedJoins($collection, $rowQueries, $joinTablePrefixes); + foreach ([[$leftQueries, $leftPreserving], [$rightQueries, true]] as [$halfQueries, $preservingOuter]) { + $half = $this->newBuilder($name, $alias, $preservingOuter, unindexed: $unindexed); + if ($columns === []) { + $half->selectRaw('1'); + } + foreach ($columns as $source => $column) { + $half->selectRaw($this->quoteOrderColumn($source, $alias).' AS '.$this->quote($column)); + } + $this->applyFindFilters($half, $collection, $halfQueries, $joinTablePrefixes, $adapterFilterQueries, $name, $alias, $roles, $forPermission); + $this->applyFindCursor($half, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $resolveInternalKey); + $halves[] = $half; } - $projections = []; - foreach ($selections as $selection) { - $filteredSelection = $this->filter($selection); - $quotedSelection = $this->quote($filteredSelection); - $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; + [$left, $right] = $halves; + $left->unionAll($right); + $aggregation->fromSub($left, self::FOJ_ROWS_ALIAS); + $aggregation->addHook(new AttributeMap($this->fullOuterJoinColumnSpellings($columns, $aggregateAliases, $alias))); + + $qualifiedGroups = []; + foreach ($aggregationQueries as $query) { + if ($query->getMethod() === Method::GroupBy) { + /** @var array $groups */ + $groups = $query->getValues(); + foreach ($this->qualifiedGroupNames($groups) as $group) { + $dot = (int) \strpos($group, '.'); + $qualifiedGroups[\substr($group, 0, $dot).'.'.$this->getInternalKeyForAttribute(\substr($group, $dot + 1))] = $group; + } + } } - return \implode(',', $projections); + return $this->fullOuterJoinResultNames($this->executeSelect($aggregation, Event::DocumentFind, $name), $columns, $qualifiedGroups); } - protected function getInternalKeyForAttribute(string $attribute): string + private function shapesAggregatedRows(Method $method): bool { - return match ($attribute) { - '$id' => '_uid', - '$sequence' => '_id', - '$collection' => '_collection', - '$tenant' => '_tenant', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - '$deletedAt' => '_deletedAt', - '$permissions' => '_permissions', - default => $attribute + return $method->isAggregate() || match ($method) { + Method::GroupBy, Method::Having, Method::Select, Method::Distinct => true, + default => false, }; } - protected function escapeWildcards(string $value): string + /** + * The columns an aggregation over an emulated full outer join reads — aggregated attributes, groups, + * having conditions and order attributes — keyed by table-qualified column, each with the column both + * halves project it as. A select reads none: configureFindBuilder() leaves it out of an aggregation. + * + * @param array $queries The aggregation's queries, as configureFindBuilder() left them + * @param array $orderAttributes + * @param array $orderTypes + * @param array $joinAliases + * @param array $aggregateAliases + * @return array + */ + private function fullOuterJoinColumns(array $queries, array $orderAttributes, array $orderTypes, array $joinAliases, array $aggregateAliases, string $alias): array { - $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; + $references = []; + while ($queries !== []) { + $query = \array_shift($queries); + $method = $query->getMethod(); + + if ($method->isNested()) { + foreach ($query->getValues() as $condition) { + if ($condition instanceof BaseQuery) { + $queries[] = $condition; + } + } + } elseif ($method === Method::GroupBy) { + foreach ($query->getValues() as $column) { + if (\is_string($column)) { + $references[] = $column; + } + } + } elseif ($method !== Method::Select) { + $references[] = $query->getAttribute(); + } + } - foreach ($wildcards as $wildcard) { - $value = \str_replace($wildcard, "\\$wildcard", $value); + foreach ($orderAttributes as $i => $attribute) { + if (($orderTypes[$i] ?? OrderDirection::Asc) !== OrderDirection::Random) { + $references[] = $this->qualifyOrderAttribute($attribute, $joinAliases); + } } - return $value; - } + $columns = []; + foreach ($references as $reference) { + if ($reference === '' || $reference === '*' || \is_numeric($reference) || isset($aggregateAliases[$reference])) { + continue; + } - protected function processException(PDOException $e): \Exception - { - return $e; + $dot = \strpos($reference, '.'); + $source = $dot === false + ? $alias.'.'.$this->getInternalKeyForAttribute($reference) + : \substr($reference, 0, $dot).'.'.$this->getInternalKeyForAttribute(\substr($reference, $dot + 1)); + $columns[$source] ??= self::FOJ_COLUMN_PREFIX.\count($columns); + } + + return $columns; } /** - * @param mixed $stmt - * @return bool + * Every spelling the aggregation's queries can give a projected column — table-qualified or, on the + * main table, bare; by internal or public name — resolved to the derived column that holds it. An + * aggregate alias keeps naming its aggregate. + * + * @param array $columns + * @param array $aggregateAliases + * @return array */ - protected function execute(mixed $stmt): bool + private function fullOuterJoinColumnSpellings(array $columns, array $aggregateAliases, string $alias): array { - return $stmt->execute(); + $spellings = []; + foreach ($columns as $source => $column) { + [$table, $name] = \explode('.', $source, 2); + $candidates = [$source, $table.'.'.Storage::attribute($name)]; + if ($table === $alias) { + $candidates[] = $name; + $candidates[] = Storage::attribute($name); + } + + foreach ($candidates as $spelling) { + if (! isset($aggregateAliases[$spelling])) { + $spellings[$spelling] = self::FOJ_ROWS_ALIAS.'.'.$column; + } + } + } + + return $spellings; } /** - * Create Documents in batches - * - * @param Document $collection - * @param array $documents + * Name each result column the way the single statement names it: a derived column after the column + * it holds, an expression over derived columns after the same expression over the columns they hold. * - * @return array + * A joined group that qualifiedGroupNames() names by its alias comes last under that name, where the + * single statement selects it. * - * @throws DuplicateException - * @throws \Throwable + * @param array> $rows + * @param array $columns + * @param array $qualifiedGroups The name of each joined group qualified by its alias, by the column it holds + * @return array> */ - public function createDocuments(Document $collection, array $documents): array + private function fullOuterJoinResultNames(array $rows, array $columns, array $qualifiedGroups = []): array { - if (empty($documents)) { - return $documents; + $names = []; + $qualified = []; + $expressions = []; + foreach ($columns as $source => $column) { + [$table, $name] = \explode('.', $source, 2); + $names[$column] = $name; + if (isset($qualifiedGroups[$source])) { + $qualified[$column] = $qualifiedGroups[$source]; + } + $expressions[$this->quote(self::FOJ_ROWS_ALIAS).'.'.$this->quote($column)] = $this->quote($table).'.'.$this->quote($name); } - $spatialAttributes = $this->getSpatialAttributes($collection); - $collection = $collection->getId(); - try { - $name = $this->filter($collection); - - $attributeKeys = Database::INTERNAL_ATTRIBUTE_KEYS; - - $hasSequence = null; - foreach ($documents as $document) { - $attributes = $document->getAttributes(); - $attributeKeys = [...$attributeKeys, ...\array_keys($attributes)]; + foreach ($rows as $index => $row) { + $named = []; + $trailing = []; + foreach ($row as $key => $value) { + $key = (string) $key; + if (isset($qualified[$key])) { + $trailing[$qualified[$key]] = $value; - if ($hasSequence === null) { - $hasSequence = !empty($document->getSequence()); - } elseif ($hasSequence == empty($document->getSequence())) { - throw new DatabaseException('All documents must have an sequence if one is set'); + continue; } + $named[$names[$key] ?? \strtr($key, $expressions)] = $value; } + $rows[$index] = [...$named, ...$trailing]; + } + + return $rows; + } - $attributeKeys = array_unique($attributeKeys); + /** + * The groups returned under their qualified name (`alias.attribute`), by position: a joined group + * whose column name another group of the query is also returned under. Every other group keeps + * the column name the engine gives it, so a joined group alone under its name stays reachable + * by that bare name, and the main collection's group keeps it when both are grouped. + * + * @param array $groups + * @return array + */ + private function qualifiedGroupNames(array $groups): array + { + $names = []; + foreach ($groups as $index => $group) { + $dot = \strrpos($group, '.'); + $names[$index] = $this->filter($this->getInternalKeyForAttribute($dot === false ? $group : \substr($group, $dot + 1))); + } - if ($hasSequence) { - $attributeKeys[] = '_id'; + $counts = \array_count_values($names); + $qualified = []; + foreach ($groups as $index => $group) { + if ($counts[$names[$index]] > 1 && \str_contains($group, '.')) { + $qualified[$index] = $group; } + } - if ($this->sharedTables) { - $attributeKeys[] = '_tenant'; + return $qualified; + } + + /** + * distinct() over an emulated full outer join removes a row both halves return with UNION, which + * compares every projected column, the order columns among them. A single statement compares the + * selected columns only, so ordering by an attribute the selection leaves out has no emulation. + * Without a select every row carries each table's `$id`, so no order column can tell two rows apart + * that the selection would not. + * + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + * @param array $joinAliases + * + * @throws QueryException + */ + private function assertDistinctOrderIsSelected(array $queries, array $orderAttributes, array $orderTypes, array $joinAliases): void + { + $selected = []; + foreach ($queries as $query) { + if ($query->getMethod() !== Method::Select) { + continue; } - $columns = []; - foreach ($attributeKeys as $key => $attribute) { - $columns[$key] = $this->quote($this->filter($attribute)); + foreach ($query->getValues() as $value) { + if ($value === '*') { + return; + } + if (\is_string($value)) { + $selected[$this->qualifyOrderAttribute($value, $joinAliases)] = true; + } } + } - $columns = '(' . \implode(', ', $columns) . ')'; + if ($selected === []) { + return; + } - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $permissions = []; - $bindValuesPermissions = []; + foreach ($orderAttributes as $i => $attribute) { + if (($orderTypes[$i] ?? OrderDirection::Asc) === OrderDirection::Random) { + continue; + } - foreach ($documents as $index => $document) { - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); + if (! isset($selected[$this->qualifyOrderAttribute($attribute, $joinAliases)])) { + throw new QueryException("A distinct() query over a full outer join can only be ordered by a selected attribute on this database, and {$attribute} is not selected"); + } + } + } - if (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); - } + /** + * With $nullable, a cursor value may be null and each comparison keeps the engine's own null placement: an + * equal prefix on null is IS NULL, and nulls come after every value in a direction that sorts them last. + * + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @param callable(string): string $resolveInternalKey + */ + private function applyFindCursor( + SQLBuilder $builder, + array $orderAttributes, + array $orderTypes, + array $cursor, + CursorDirection $cursorDirection, + callable $resolveInternalKey, + bool $nullable = false, + ): void { + if ($cursor === []) { + return; + } - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); - } + $cursorConditions = $this->cursorConditions($orderAttributes, $orderTypes, $cursor, $cursorDirection, $resolveInternalKey, $nullable); - $bindKeys = []; + if ($cursorConditions === []) { + return; + } - foreach ($attributeKeys as $key) { - $value = $attributes[$key] ?? null; - if (\is_array($value)) { - $value = \json_encode($value); - } - if (in_array($key, $spatialAttributes)) { - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); - } else { - if ($this->getSupportForIntegerBooleans()) { - $value = (\is_bool($value)) ? (int)$value : $value; - } - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - } - $bindValues[$bindKey] = $value; - $bindIndex++; - } + $builder->filter([$this->anyOf($cursorConditions)]); + } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + /** + * One condition per order position: the rows equal to the cursor before it and after the cursor in it. A row + * follows the cursor when it meets any of them. + * + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @param callable(string): string $resolveInternalKey + * @return list + */ + private function cursorConditions( + array $orderAttributes, + array $orderTypes, + array $cursor, + CursorDirection $cursorDirection, + callable $resolveInternalKey, + bool $nullable, + ): array { + $cursorConditions = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { - $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; - $permission = \str_replace('"', '', $permission); - $permission = "('{$type}', '{$permission}', :_uid_{$index} {$tenantBind})"; - $permissions[] = $permission; - $bindValuesPermissions[":_uid_{$index}"] = $document->getId(); - if ($this->sharedTables) { - $bindValuesPermissions[":_tenant_{$index}"] = $document->getTenant(); - } - } - } + foreach ($orderAttributes as $i => $originalAttribute) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; + if ($orderType === OrderDirection::Random) { + continue; } - $batchKeys = \implode(', ', $batchKeys); - - $stmt = $this->getPDO()->prepare(" - {$this->getInsertKeyword()} {$this->getSQLTable($name)} {$columns} - VALUES {$batchKeys} - {$this->getInsertSuffix($name)} - "); + $direction = $orderType; - foreach ($bindValues as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); + if ($cursorDirection === CursorDirection::Before) { + $direction = ($direction === OrderDirection::Asc) + ? OrderDirection::Desc + : OrderDirection::Asc; } - $this->execute($stmt); + $internalAttr = $resolveInternalKey($originalAttribute); - if (!empty($permissions)) { - $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $permissions = \implode(', ', $permissions); + if (! $nullable && count($orderAttributes) === 1 && $i === 0 && $originalAttribute === Document::SEQUENCE) { + /** @var bool|float|int|string $cursorVal */ + $cursorVal = $cursor[$originalAttribute]; + if ($direction === OrderDirection::Desc) { + $cursorConditions[] = BaseQuery::lessThan($internalAttr, $cursorVal); + } else { + $cursorConditions[] = BaseQuery::greaterThan($internalAttr, $cursorVal); + } + break; + } - $sqlPermissions = " - {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) - VALUES {$permissions} - {$this->getInsertPermissionsSuffix()} - "; + $andConditions = []; - $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); + for ($j = 0; $j < $i; $j++) { + $andConditions[] = $this->cursorEquality($orderAttributes[$j], $cursor, $resolveInternalKey, $nullable); + } - foreach ($bindValuesPermissions as $key => $value) { - $stmtPermissions->bindValue($key, $value, $this->getPDOType($value)); + if ($nullable) { + /** @var bool|float|int|string|null $nullableValue */ + $nullableValue = $cursor[$originalAttribute]; + $comparison = $this->nullableCursorComparison($internalAttr, $nullableValue, $direction); + if ($comparison === null) { + continue; + } + $andConditions[] = $comparison; + } else { + /** @var bool|float|int|string $cursorAttrVal */ + $cursorAttrVal = $cursor[$originalAttribute]; + if ($direction === OrderDirection::Desc) { + $andConditions[] = BaseQuery::lessThan($internalAttr, $cursorAttrVal); + } else { + $andConditions[] = BaseQuery::greaterThan($internalAttr, $cursorAttrVal); } - - $this->execute($stmtPermissions); } - } catch (PDOException $e) { - throw $this->processException($e); + $cursorConditions[] = $this->allOf($andConditions); } - return $documents; + return $cursorConditions; } /** - * @param Document $collection - * @param string $attribute - * @param array $changes - * @return array - * @throws DatabaseException + * @param array $cursor + * @param callable(string): string $resolveInternalKey */ - public function upsertDocuments( - Document $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; + private function cursorEquality(string $attribute, array $cursor, callable $resolveInternalKey, bool $nullable): BaseQuery + { + $column = $resolveInternalKey($attribute); + if ($nullable && $cursor[$attribute] === null) { + return BaseQuery::isNull($column); } - try { - $spatialAttributes = $this->getSpatialAttributes($collection); - - $attributeDefaults = []; - foreach ($collection->getAttribute('attributes', []) as $attr) { - $attributeDefaults[$attr['$id']] = $attr['default'] ?? null; - } - - $collection = $collection->getId(); - $name = $this->filter($collection); - - $hasOperators = false; - $firstChange = $changes[0]; - $firstDoc = $firstChange->getNew(); - $firstExtracted = Operator::extractOperators($firstDoc->getAttributes()); - - if (!empty($firstExtracted['operators'])) { - $hasOperators = true; - } else { - foreach ($changes as $change) { - $doc = $change->getNew(); - $extracted = Operator::extractOperators($doc->getAttributes()); - if (!empty($extracted['operators'])) { - $hasOperators = true; - break; - } - } - } - if (!$hasOperators) { - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $allColumnNames = []; - $documentsData = []; + /** @var array|bool|float|int|string|null> $values */ + $values = [$cursor[$attribute]]; - foreach ($changes as $change) { - $document = $change->getNew(); - $currentRegularAttributes = $document->getAttributes(); + return BaseQuery::equal($column, $values); + } - $currentRegularAttributes['_uid'] = $document->getId(); - $currentRegularAttributes['_createdAt'] = $document->getCreatedAt() ? DateTime::setTimezone($document->getCreatedAt()) : null; - $currentRegularAttributes['_updatedAt'] = $document->getUpdatedAt() ? DateTime::setTimezone($document->getUpdatedAt()) : null; - $currentRegularAttributes['_permissions'] = \json_encode($document->getPermissions()); + /** + * Whether a read whose rows are its left-joined rows picks the main rows its page can reach before it joins + * them (boundedPage()). An engine that cannot read an order over two tables from an index sorts the whole join + * before the limit otherwise. + */ + protected function boundsJoinedSort(): bool + { + return false; + } - if (!empty($document->getSequence())) { - $currentRegularAttributes['_id'] = $document->getSequence(); - } + /** + * Whether the engine looks a joined table's rows up by an equality on the leading `_tenant` of an index + * alone, once per row the join pairs, when no index serves the join. + */ + protected function looksUpByTenantAlone(): bool + { + return false; + } - if ($this->sharedTables) { - $currentRegularAttributes['_tenant'] = $document->getTenant(); - } + /** + * The aliases of the joins no index of their collection serves: no ON equality of the read reaches a column + * one of its key or unique indexes leads with, nor an internal column every collection indexes. Under shared + * tables every other index of such a table leads with `_tenant`. A join whose collection the Database layer + * described no indexes of is taken as served. + * + * @param array $queries With the join columns remapJoinQueries() qualified + * @param list $joinTablePrefixes + * @return list + */ + private function unindexedJoins(Document $collection, array $queries, array $joinTablePrefixes): array + { + if (! $this->sharedTables || $joinTablePrefixes === [] || ! $this->looksUpByTenantAlone()) { + return []; + } - foreach (\array_keys($currentRegularAttributes) as $colName) { - $allColumnNames[$colName] = true; - } + $joinIndexed = $collection->getAttribute(Database::JOIN_INDEXED, []); + if (! \is_array($joinIndexed)) { + return []; + } - $documentsData[] = ['regularAttributes' => $currentRegularAttributes]; - } + $bound = $this->joinEqualityColumns($queries); + $internal = \array_map(\strtolower(...), [Storage::UID, Storage::SEQUENCE, Storage::CREATED_AT, Storage::UPDATED_AT]); - $allColumnNames = \array_keys($allColumnNames); - \sort($allColumnNames); + $unindexed = []; + foreach ($joinTablePrefixes as $join) { + $leads = $joinIndexed[$join->table] ?? null; + if (! \is_array($leads)) { + continue; + } - $columnsArray = []; - foreach ($allColumnNames as $attr) { - $columnsArray[] = "{$this->quote($this->filter($attr))}"; + $indexed = $internal; + foreach ($leads as $lead) { + if (\is_string($lead)) { + $indexed[] = \strtolower($this->getInternalKeyForAttribute($lead)); } - $columns = '(' . \implode(', ', $columnsArray) . ')'; - - foreach ($documentsData as $docData) { - $currentRegularAttributes = $docData['regularAttributes']; - $bindKeys = []; + } - foreach ($allColumnNames as $attributeKey) { - $attrValue = $currentRegularAttributes[$attributeKey] ?? null; + if (\array_intersect($bound[$join->alias] ?? [], $indexed) === []) { + $unindexed[] = $join->alias; + } + } - if (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); - } + return $unindexed; + } - if (in_array($attributeKey, $spatialAttributes) && $attrValue !== null) { - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); - } else { - if ($this->getSupportForIntegerBooleans()) { - $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; - } - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - } - $bindValues[$bindKey] = $attrValue; - $bindIndex++; - } + /** + * The columns each join's own ON compares for equality with another alias's column, by the join's alias. A later + * join's ON never serves an earlier join's table, which a left join reads before the later ON is bound. + * + * @param array $queries With the join columns remapJoinQueries() qualified + * @return array> + */ + private function joinEqualityColumns(array $queries): array + { + $columns = []; + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + $alias = $query->getAlias(); + foreach ($query->getJoinOnQueries() as $on) { + $values = $on->getValues(); + if ($on->getMethod() !== Method::On || ($values[1] ?? null) !== '=' || ! \is_string($values[0] ?? null) || ! \is_string($values[2] ?? null)) { + continue; } - $regularAttributes = []; - foreach ($allColumnNames as $colName) { - $regularAttributes[$colName] = null; - } - foreach ($documentsData[0]['regularAttributes'] as $key => $value) { - $regularAttributes[$key] = $value; + $left = \explode('.', $values[0], 2); + $right = \explode('.', $values[2], 2); + if (\count($left) !== 2 || \count($right) !== 2 || $left[0] === $right[0]) { + continue; } - $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $regularAttributes, $bindValues, $attribute, []); - $stmt->execute(); - $stmt->closeCursor(); - } else { - $groups = []; - - foreach ($changes as $change) { - $document = $change->getNew(); - $extracted = Operator::extractOperators($document->getAttributes()); - $operators = $extracted['operators']; - - if (empty($operators)) { - $signature = 'no_ops'; - } else { - $parts = []; - foreach ($operators as $attr => $op) { - $parts[] = $attr . ':' . $op->getMethod() . ':' . json_encode($op->getValues()); - } - sort($parts); - $signature = implode('|', $parts); - } - - if (!isset($groups[$signature])) { - $groups[$signature] = [ - 'documents' => [], - 'operators' => $operators - ]; + foreach ([$left, $right] as [$side, $column]) { + if ($side === $alias) { + $columns[$alias][] = \strtolower($column); } - - $groups[$signature]['documents'][] = $change; } + } + } - foreach ($groups as $group) { - $groupChanges = $group['documents']; - $operators = $group['operators']; - - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $allColumnNames = []; - $documentsData = []; - - foreach ($groupChanges as $change) { - $document = $change->getNew(); - $attributes = $document->getAttributes(); - - $extracted = Operator::extractOperators($attributes); - $currentRegularAttributes = $extracted['updates']; - $extractedOperators = $extracted['operators']; - - // For new documents, apply operators to attribute defaults - if ($change->getOld()->isEmpty() && !empty($extractedOperators)) { - foreach ($extractedOperators as $operatorKey => $operator) { - $default = $attributeDefaults[$operatorKey] ?? null; - $currentRegularAttributes[$operatorKey] = $this->applyOperatorToValue($operator, $default); - } - } - - $currentRegularAttributes['_uid'] = $document->getId(); - $currentRegularAttributes['_createdAt'] = $document->getCreatedAt() ? $document->getCreatedAt() : null; - $currentRegularAttributes['_updatedAt'] = $document->getUpdatedAt() ? $document->getUpdatedAt() : null; - $currentRegularAttributes['_permissions'] = \json_encode($document->getPermissions()); + return $columns; + } - if (!empty($document->getSequence())) { - $currentRegularAttributes['_id'] = $document->getSequence(); - } + /** + * A read ordered by main attributes up to a unique one, then by joined ones, returns every joined row of one main + * document together. Without inner joins and without conditions on joined attributes, every main document it + * matches gives at least one row, so its page of `limit` rows after `offset` rows (and after the cursor) comes + * from the first `offset + limit` main documents in that order after the cursor's own, plus the cursor's own. + * A search on main attributes only keeps or drops main documents, so it joins the main conditions. + * + * @param array $queries + * @param array $adapterFilterQueries + * @param list $joinTablePrefixes + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + */ + private function boundedPage( + Document $collection, + array $queries, + array $adapterFilterQueries, + array $joinTablePrefixes, + array $orderAttributes, + array $orderTypes, + ?int $limit, + ?int $offset, + array $cursor, + ): ?BoundedPage { + if ($limit === null) { + return null; + } - if ($this->sharedTables) { - $currentRegularAttributes['_tenant'] = $document->getTenant(); - } + $reach = (int) $offset + ($cursor === [] ? 0 : 1); + if ($limit > PHP_INT_MAX - $reach) { + return null; + } - foreach (\array_keys($currentRegularAttributes) as $colName) { - $allColumnNames[$colName] = true; - } + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + $mainOrder = $this->mainOrderPrefix($orderAttributes, $orderTypes, $joinAliases); + if ($mainOrder === null) { + return null; + } - $documentsData[] = ['regularAttributes' => $currentRegularAttributes]; - } + $conditions = []; + $searches = []; + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method === Method::Select) { + continue; + } - foreach (\array_keys($operators) as $colName) { - $allColumnNames[$colName] = true; - } + if ($method->isJoin()) { + if ($method !== Method::LeftJoin) { + return null; + } - $allColumnNames = \array_keys($allColumnNames); - \sort($allColumnNames); + continue; + } - $columnsArray = []; - foreach ($allColumnNames as $attr) { - $columnsArray[] = "{$this->quote($this->filter($attr))}"; - } - $columns = '(' . \implode(', ', $columnsArray) . ')'; - - foreach ($documentsData as $docData) { - $currentRegularAttributes = $docData['regularAttributes']; - $bindKeys = []; - - foreach ($allColumnNames as $attributeKey) { - $attrValue = $currentRegularAttributes[$attributeKey] ?? null; - - if (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); - } - - if (in_array($attributeKey, $spatialAttributes) && $attrValue !== null) { - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); - } else { - if ($this->getSupportForIntegerBooleans()) { - $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; - } - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; - } - $bindValues[$bindKey] = $attrValue; - $bindIndex++; - } + if ($this->isMainSearch($query, $joinAliases)) { + $searches[] = $query; + } elseif (! $this->isMainRowCondition($query, $joinAliases)) { + return null; + } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; - } + $conditions[] = clone $query; + } - $regularAttributes = []; - foreach ($allColumnNames as $colName) { - $regularAttributes[$colName] = null; - } - foreach ($documentsData[0]['regularAttributes'] as $key => $value) { - $regularAttributes[$key] = $value; - } + $adapterConditions = []; + foreach ($adapterFilterQueries as $query) { + if (! $this->isMainSearch($query, $joinAliases)) { + return null; + } - $stmt = $this->getUpsertStatement( - $name, - $columns, - $batchKeys, - $regularAttributes, - $bindValues, - '', - $operators - ); + $adapterConditions[] = clone $query; + $searches[] = $query; + } - $stmt->execute(); - $stmt->closeCursor(); - } - } + $this->remapDottedQueryAttributes($conditions, $joinTablePrefixes, $collection); - $removeQueries = []; - $removeBindValues = []; - $addQueries = []; - $addBindValues = []; + [$mainAttributes, $mainTypes] = $mainOrder; - foreach ($changes as $index => $change) { - $old = $change->getOld(); - $document = $change->getNew(); + return new BoundedPage( + orderAttributes: $mainAttributes, + orderTypes: $mainTypes, + rows: $limit + $reach, + conditions: $conditions, + adapterConditions: $adapterConditions, + searches: $searches, + ); + } - $current = []; - foreach (Database::PERMISSIONS as $type) { - $current[$type] = $old->getPermissionsByType($type); - } + /** + * The read joins from a derived table of the main rows its page can come from (an index can serve its order up + * to its limit), so the join and its sort only see their rows. The derived table holds every main row the page + * needs, so the rows, their order and the page are those of the read without it. The searches run there only: a + * derived table has no fulltext index. + * + * @param array $cursor + * @param callable(string): string $resolveInternalKey + * @param array $roles + */ + private function joinFromBoundedPage( + SQLBuilder $builder, + Document $collection, + BoundedPage $bound, + array $cursor, + CursorDirection $cursorDirection, + callable $resolveInternalKey, + string $name, + string $alias, + array $roles, + PermissionType $forPermission, + ): void { + $page = $this->newBuilder($name, $alias); + $page->select([$alias.'.*']); + $page->filter($bound->conditions); + $this->applyFilters($page, $bound->adapterConditions, $name, $alias); + + if ( + $this->authorization->getStatus() + && $collection->getAttribute(Database::COLLECTION_GRANTED, false) !== true + && $this->filtersPerDocument($collection) + ) { + $page->addHook($this->newPermissionHook($name, $roles, $forPermission->value, $alias.'.'.Storage::UID)); + } - foreach (Database::PERMISSIONS as $type) { - $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); - if (!empty($toRemove)) { - $removeQueries[] = "( - _document = :_uid_{$index} - " . ($this->sharedTables ? " AND _tenant = :_tenant_{$index}" : '') . " - AND _type = '{$type}' - AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") - )"; - $removeBindValues[":_uid_{$index}"] = $document->getId(); - if ($this->sharedTables) { - $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - foreach ($toRemove as $i => $perm) { - $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; - } - } - } + if ($cursor !== []) { + $conditions = $this->cursorConditions($bound->orderAttributes, $bound->orderTypes, $cursor, $cursorDirection, $resolveInternalKey, nullable: true); + $equalities = []; + foreach ($bound->orderAttributes as $attribute) { + $equalities[] = $this->cursorEquality($attribute, $cursor, $resolveInternalKey, nullable: true); + } + $conditions[] = $this->allOf($equalities); + $page->filter([$this->anyOf($conditions)]); + } - foreach (Database::PERMISSIONS as $type) { - $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); + $this->applyFindPage($page, $bound->orderAttributes, $bound->orderTypes, $bound->rows, null, $cursorDirection); - foreach ($toAdd as $i => $permission) { - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + $builder->fromSub($page, $alias); + } - if ($this->sharedTables) { - $addQuery .= ", :_tenant_{$index}"; - } + /** + * @param array $joinAliases + */ + private function isMainSearch(BaseQuery $query, array $joinAliases): bool + { + $method = $query->getMethod(); - $addQuery .= ")"; - $addQueries[] = $addQuery; - $addBindValues[":_uid_{$index}"] = $document->getId(); - $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + return ($method === Method::Search || $method === Method::NotSearch) + && $this->joinAliasOf($query->getAttribute(), $joinAliases) === null; + } - if ($this->sharedTables) { - $addBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - } - } + /** + * The leading main attributes of an order, when they hold a unique one and joined attributes follow them: the + * order a read returns the joined rows of one main document together in. + * + * @param array $orderAttributes + * @param array $orderTypes + * @param array $joinAliases + * @return array{non-empty-list, list}|null + */ + private function mainOrderPrefix(array $orderAttributes, array $orderTypes, array $joinAliases): ?array + { + $attributes = []; + $types = []; + $unique = false; + $joined = false; + foreach (\array_values($orderAttributes) as $position => $attribute) { + $type = $orderTypes[$position] ?? OrderDirection::Asc; + if ($type === OrderDirection::Random) { + return null; } - - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); - $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); - } - $stmtRemovePermissions->execute(); + if ($this->joinAliasOf($attribute, $joinAliases) !== null) { + $joined = true; + break; } + $attributes[] = $attribute; + $types[] = $type; + $unique = $unique || $attribute === Document::SEQUENCE || $attribute === Document::ID; + } - if (!empty($addQueries)) { - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; - if ($this->sharedTables) { - $sqlAddPermissions .= ", _tenant"; - } - $sqlAddPermissions .= ") VALUES " . \implode(', ', $addQueries); - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); - } - $stmtAddPermissions->execute(); - } - } catch (PDOException $e) { - throw $this->processException($e); + if (! $unique || ! $joined || $attributes === []) { + return null; } - return \array_map(fn ($change) => $change->getNew(), $changes); + return [$attributes, $types]; } /** - * Build geometry WKT string from array input for spatial queries + * Whether a condition reads only main attributes, so it keeps or drops a main document with all its joined rows. * - * @param array $geometry - * @return string - * @throws DatabaseException + * @param array $joinAliases */ - protected function convertArrayToWKT(array $geometry): string + private function isMainRowCondition(BaseQuery $query, array $joinAliases): bool { - // point [x, y] - if (count($geometry) === 2 && is_numeric($geometry[0]) && is_numeric($geometry[1])) { - return "POINT({$geometry[0]} {$geometry[1]})"; + $method = $query->getMethod(); + if ( + $method === Method::Search + || $method === Method::NotSearch + || (! $method->isFilter() + && ! $method->isSpatial() + && ! $method->isJson() + && ! \in_array($method, self::ROW_CONDITION_GROUPS, true)) + ) { + return false; } - // linestring [[x1, y1], [x2, y2], ...] - if (is_array($geometry[0]) && count($geometry[0]) === 2 && is_numeric($geometry[0][0])) { - $points = []; - foreach ($geometry as $point) { - if (!is_array($point) || count($point) !== 2 || !is_numeric($point[0]) || !is_numeric($point[1])) { - throw new DatabaseException('Invalid point format in geometry array'); - } - $points[] = "{$point[0]} {$point[1]}"; - } - return 'LINESTRING(' . implode(', ', $points) . ')'; + if ($this->joinAliasOf($query->getAttribute(), $joinAliases) !== null) { + return false; } - // polygon [[[x1, y1], [x2, y2], ...], ...] - if (is_array($geometry[0]) && is_array($geometry[0][0]) && count($geometry[0][0]) === 2) { - $rings = []; - foreach ($geometry as $ring) { - if (!is_array($ring)) { - throw new DatabaseException('Invalid ring format in polygon geometry'); - } - $points = []; - foreach ($ring as $point) { - if (!is_array($point) || count($point) !== 2 || !is_numeric($point[0]) || !is_numeric($point[1])) { - throw new DatabaseException('Invalid point format in polygon ring'); - } - $points[] = "{$point[0]} {$point[1]}"; - } - $rings[] = '(' . implode(', ', $points) . ')'; + foreach ($query->getValues() as $value) { + if ($value instanceof BaseQuery && ! $this->isMainRowCondition($value, $joinAliases)) { + return false; + } + if ( + \is_string($value) + && \in_array($method, [Method::Exists, Method::NotExists], true) + && $this->joinAliasOf($value, $joinAliases) !== null + ) { + return false; } - return 'POLYGON(' . implode(', ', $rings) . ')'; } - throw new DatabaseException('Unrecognized geometry array format'); + return true; } /** - * Find Documents - * - * @param Document $collection - * @param array $queries - * @param int|null $limit - * @param int|null $offset - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor - * @param string $cursorDirection - * @param string $forPermission - * @return array - * @throws DatabaseException - * @throws TimeoutException - * @throws Exception + * @param array $joinAliases */ - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + private function joinAliasOf(string $attribute, array $joinAliases): ?string { - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = $this->authorization->getRoles(); - $where = []; - $orders = []; - $alias = Query::DEFAULT_ALIAS; - $binds = []; - - $queries = array_map(fn ($query) => clone $query, $queries); - - // Extract vector queries for ORDER BY - $vectorQueries = []; - $otherQueries = []; - foreach ($queries as $query) { - if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $vectorQueries[] = $query; - } else { - $otherQueries[] = $query; - } + $dot = \strpos($attribute, '.'); + if ($dot === false) { + return null; } - $queries = $otherQueries; + $prefix = \substr($attribute, 0, $dot); - $cursorWhere = []; + return \in_array($prefix, $joinAliases, true) ? $prefix : null; + } - foreach ($orderAttributes as $i => $originalAttribute) { - $orderType = $orderTypes[$i] ?? Database::ORDER_ASC; + /** + * The rows after a cursor value in one order position, or null when no row can follow it there: a null the + * direction sorts last is followed only by rows tied on it, which a later position decides. + * + * @param bool|float|int|string|null $value + */ + private function nullableCursorComparison(string $attribute, mixed $value, OrderDirection $direction): ?BaseQuery + { + $nullsFirst = $direction === $this->getNullOrder(); - // Handle random ordering - if ($orderType === Database::ORDER_RANDOM) { - $orders[] = $this->getRandomOrder(); - continue; - } + if ($value === null) { + return $nullsFirst ? BaseQuery::isNotNull($attribute) : null; + } - $attribute = $this->getInternalKeyForAttribute($originalAttribute); - $attribute = $this->filter($attribute); + $comparison = $direction === OrderDirection::Desc + ? BaseQuery::lessThan($attribute, $value) + : BaseQuery::greaterThan($attribute, $value); - $orderType = $this->filter($orderType); - $direction = $orderType; + return $nullsFirst ? $comparison : BaseQuery::or([$comparison, BaseQuery::isNull($attribute)]); + } - if ($cursorDirection === Database::CURSOR_BEFORE) { - $direction = ($direction === Database::ORDER_ASC) - ? Database::ORDER_DESC - : Database::ORDER_ASC; - } + /** + * @param array $orderAttributes + * @param array $orderTypes + * @param array $joinAliases + */ + private function applyFindPage( + SQLBuilder $builder, + array $orderAttributes, + array $orderTypes, + ?int $limit, + ?int $offset, + CursorDirection $cursorDirection = CursorDirection::After, + bool $afterUnion = false, + array $joinAliases = [], + ): void { + if ($limit === null && $offset !== null) { + $limit = self::UNBOUNDED_LIMIT; + } - $orders[] = "{$this->quote($attribute)} {$direction}"; + if ($afterUnion) { + $quote = $this->getIdentifierQuote(); + $builder->afterBuild(function (Statement $result) use ( + $orderAttributes, + $orderTypes, + $limit, + $offset, + $cursorDirection, + $quote, + ): Statement { + $sql = $result->query; + $bindings = $result->bindings; + + $orderParts = []; + foreach (\array_keys($orderAttributes) as $i) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; + if ($orderType === OrderDirection::Random) { + $orderParts[] = $this->dialectBuilder()->compileOrder(BaseQuery::orderRandom()); + $sql = 'SELECT * FROM ('.$result->query.') AS '.$quote.self::FOJ_ROWS_ALIAS.$quote; + + continue; + } - // Build pagination WHERE clause only if we have a cursor - if (!empty($cursor)) { - // Special case: No tie breaks. only 1 attribute and it's a unique primary key - if (count($orderAttributes) === 1 && $i === 0 && $originalAttribute === '$sequence') { - $operator = ($direction === Database::ORDER_DESC) - ? Query::TYPE_LESSER - : Query::TYPE_GREATER; + $direction = $orderType; + if ($cursorDirection === CursorDirection::Before) { + $direction = ($direction === OrderDirection::Asc) + ? OrderDirection::Desc + : OrderDirection::Asc; + } - $bindName = ":cursor_pk"; - $binds[$bindName] = $cursor[$originalAttribute]; + $orderParts[] = $quote.self::FOJ_ORDER_ALIAS_PREFIX.$i.$quote.($direction === OrderDirection::Desc ? ' DESC' : ' ASC'); + } - $cursorWhere[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; - break; + if ($orderParts !== []) { + $sql .= ' ORDER BY '.\implode(', ', $orderParts); + } + if (! \is_null($limit)) { + $sql .= ' LIMIT ?'; + $bindings[] = $limit; + } + if (! \is_null($offset)) { + $sql .= ' OFFSET ?'; + $bindings[] = $offset; } - $conditions = []; + return new Statement($sql, $bindings, $result->readOnly); + }); - // Add equality conditions for previous attributes - for ($j = 0; $j < $i; $j++) { - $prevOriginal = $orderAttributes[$j]; - $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); + return; + } - $bindName = ":cursor_{$j}"; - $binds[$bindName] = $cursor[$prevOriginal]; + foreach ($orderAttributes as $i => $originalAttribute) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; - $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; - } + if ($orderType === OrderDirection::Random) { + $builder->sortRandom(); - // Add comparison for current attribute - $operator = ($direction === Database::ORDER_DESC) - ? Query::TYPE_LESSER - : Query::TYPE_GREATER; + continue; + } - $bindName = ":cursor_{$i}"; - $binds[$bindName] = $cursor[$originalAttribute]; + $internalAttr = $this->qualifyOrderAttribute($originalAttribute, $joinAliases); + $direction = $orderType; - $conditions[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; + if ($cursorDirection === CursorDirection::Before) { + $direction = ($direction === OrderDirection::Asc) + ? OrderDirection::Desc + : OrderDirection::Asc; + } - $cursorWhere[] = '(' . implode(' AND ', $conditions) . ')'; + if ($direction === OrderDirection::Desc) { + $builder->sortDesc($internalAttr); + } else { + $builder->sortAsc($internalAttr); } } - if (!empty($cursorWhere)) { - $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; + if (! \is_null($limit)) { + $builder->limit($limit); + } + if (! \is_null($offset)) { + $builder->offset($offset); } + } - $conditions = $this->getSQLConditionsForCollection($name, $queries, $binds); - if (!empty($conditions)) { - $where[] = $conditions; + /** + * @return array> + */ + private function executeSelect(SQLBuilder $builder, Event $event, string $collection = ''): array + { + try { + $result = $builder->build(); + } catch (ValidationException|UnsupportedException $e) { + throw new QueryException($e->getMessage(), $e->getCode(), $e); } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + return $this->runSelect($result, $event, $collection); + } + + /** + * @return array> + * + * @throws Exception + */ + private function runSelect(Statement $result, Event $event, string $collection): array + { + $statement = null; + $results = []; + $exception = null; + try { + $statement = $this->executeResult($result, $event, $collection); + $this->execute($statement); + /** @var array> $results */ + $results = $statement->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + $exception = $e; + } finally { + if ($statement !== null) { + try { + $statement->closeCursor(); + } catch (PDOException $e) { + $exception ??= $e; + } + } } - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + if ($exception !== null) { + throw $this->processSelectException($exception, $result); } - $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + return $results; + } - $vectorDistances = []; - foreach ($vectorQueries as $query) { - $vectorDistance = $this->getSQLVectorDistance($query, $binds, $alias); - if ($vectorDistance) { - $vectorDistances[] = $vectorDistance; - } + private function qualifyJoinColumn(string $column, string $defaultAlias): string + { + $dot = \strpos($column, '.'); + if ($dot === false) { + return $defaultAlias.'.'.$this->getInternalKeyForAttribute($column); } - if (!empty($vectorDistances)) { - // Vector orders should come first for similarity search - $orders = \array_merge($vectorDistances, $orders); - } + $prefix = \substr($column, 0, $dot); + $name = \substr($column, $dot + 1); - $sqlOrder = !empty($orders) ? 'ORDER BY ' . implode(', ', $orders) : ''; + return $prefix.'.'.$this->getInternalKeyForAttribute($name); + } - $sqlLimit = ''; - if (! \is_null($limit)) { - $binds[':limit'] = $limit; - $sqlLimit = 'LIMIT :limit'; + /** + * An order names a bare attribute the main collection does not declare by the one join whose + * collection declares it, as an aggregate or a group does, and the cursor value under that name + * follows it. A name several joins declare is refused rather than read from one of them. + * + * @param array $orderAttributes + * @param array $cursor + * @param list $joinTablePrefixes + * @return array{array, array} + * + * @throws QueryException + */ + private function qualifyJoinedOrders(array $orderAttributes, array $cursor, Document $collection, array $joinTablePrefixes): array + { + $main = []; + foreach ([...Database::internalAttributesFor(true), ...self::collectionAttributes($collection)] as $attribute) { + $main[$attribute->key] = true; } - if (! \is_null($offset)) { - $binds[':offset'] = $offset; - $sqlLimit .= ' OFFSET :offset'; + $joinAttributes = $collection->getAttribute(Database::JOIN_ATTRIBUTES, []); + $declared = []; + foreach ($joinTablePrefixes as $join) { + $keys = \is_array($joinAttributes) ? ($joinAttributes[$join->table] ?? []) : []; + foreach (\is_array($keys) ? $keys : [] as $key) { + if (\is_string($key)) { + $declared[$key][] = $join->alias; + } + } } - $selections = $this->getAttributeSelections($queries); + foreach ($orderAttributes as $index => $attribute) { + if (\str_contains($attribute, '.') || isset($main[$attribute]) || ! isset($declared[$attribute])) { + continue; + } - $projection = $this->getAttributeProjection($selections, $alias); + $aliases = \array_values(\array_unique($declared[$attribute])); + if (\count($aliases) > 1) { + throw new QueryException('Attribute "'.$attribute.'" is ambiguous across joins; qualify it with a join alias'); + } - if (!empty($vectorDistances)) { - $readable = $this->getSQLReadableDistance($vectorDistances[0]); - $projection .= ", {$readable} AS {$this->quote(static::VECTOR_DISTANCE_COLUMN)}"; + $qualified = $aliases[0].'.'.$attribute; + $orderAttributes[$index] = $qualified; + if (\array_key_exists($attribute, $cursor) && ! \array_key_exists($qualified, $cursor)) { + $cursor[$qualified] = $cursor[$attribute]; + } } - $sql = " - SELECT {$projection} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$sqlOrder} - {$sqlLimit}; - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); + return [$orderAttributes, $cursor]; + } - try { - $stmt = $this->getPDO()->prepare($sql); + /** + * @param array $joinAliases + */ + private function qualifyOrderAttribute(string $attribute, array $joinAliases = []): string + { + $dot = \strpos($attribute, '.'); + if ($dot !== false) { + $prefix = \substr($attribute, 0, $dot); + if (\in_array($prefix, $joinAliases, true)) { + $name = \substr($attribute, $dot + 1); - foreach ($binds as $key => $value) { - if (gettype($value) === 'double') { - $stmt->bindValue($key, $this->getFloatPrecision($value), \PDO::PARAM_STR); - } else { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } + return $this->filter($prefix).'.'.$this->filter($this->getInternalKeyForAttribute($name)); } - - $this->execute($stmt); - } catch (PDOException $e) { - throw $this->processException($e); } - $results = $stmt->fetchAll(); - $stmt->closeCursor(); + return $this->filter($this->getInternalKeyForAttribute($attribute)); + } + + /** + * @param array $row + */ + private function remapRow(array &$row): void + { + foreach (\array_keys($row) as $key) { + if (\is_int($key)) { + unset($row[$key]); + continue; + } + if (\str_starts_with($key, self::FOJ_ORDER_ALIAS_PREFIX)) { + unset($row[$key]); - foreach ($results as $index => $document) { - if (\array_key_exists('_uid', $document)) { - $results[$index]['$id'] = $document['_uid']; - unset($results[$index]['_uid']); + continue; } - if (\array_key_exists('_id', $document)) { - $results[$index]['$sequence'] = $document['_id']; - unset($results[$index]['_id']); + if (! \str_contains($key, '.')) { + continue; } - if (\array_key_exists('_tenant', $document)) { - $results[$index]['$tenant'] = $document['_tenant']; - unset($results[$index]['_tenant']); + $separator = \strrpos($key, '.'); + if (! \is_int($separator)) { + continue; } - if (\array_key_exists('_createdAt', $document)) { - $results[$index]['$createdAt'] = $document['_createdAt']; - unset($results[$index]['_createdAt']); + $prefix = \substr($key, 0, $separator); + $bare = \trim(\substr($key, $separator + 1), '`"'); + $public = Storage::attribute($bare); + $dotted = $prefix.'.'.$public; + + if ($prefix === Query::DEFAULT_ALIAS && $bare !== '' && ! \array_key_exists($bare, $row)) { + $row[$bare] = $row[$key]; } - if (\array_key_exists('_updatedAt', $document)) { - $results[$index]['$updatedAt'] = $document['_updatedAt']; - unset($results[$index]['_updatedAt']); + + $value = $row[$key]; + if ($value !== null && ($bare === Storage::PERMISSIONS || $public === Document::PERMISSIONS)) { + $value = \json_decode(\is_string($value) ? $value : '[]', true); } - if (\array_key_exists('_permissions', $document)) { - $results[$index]['$permissions'] = \json_decode($document['_permissions'] ?? '[]', true); - unset($results[$index]['_permissions']); + if (! \array_key_exists($dotted, $row) || $key === $dotted) { + $row[$dotted] = $value; } - if (\array_key_exists(static::VECTOR_DISTANCE_COLUMN, $document)) { - $value = $document[static::VECTOR_DISTANCE_COLUMN]; - $results[$index][Database::VECTOR_DISTANCE] = \is_numeric($value) ? (float)$value : null; - unset($results[$index][static::VECTOR_DISTANCE_COLUMN]); + if ($key !== $dotted) { + unset($row[$key]); } - - $results[$index] = new Document($results[$index]); } - if ($cursorDirection === Database::CURSOR_BEFORE) { - $results = \array_reverse($results); + foreach (Storage::columnMap() as $internal => $public) { + if ($internal === Storage::PERMISSIONS || $internal === Storage::DISTANCE) { + continue; + } + if (\array_key_exists($internal, $row)) { + $row[$public] = $row[$internal]; + unset($row[$internal]); + } + } + if (\array_key_exists(Storage::PERMISSIONS, $row)) { + $row[Document::PERMISSIONS] = \json_decode(\is_string($row[Storage::PERMISSIONS]) ? $row[Storage::PERMISSIONS] : '[]', true); + unset($row[Storage::PERMISSIONS]); + } + if (\array_key_exists(Storage::DISTANCE, $row)) { + $distance = $row[Storage::DISTANCE]; + $row[Document::DISTANCE] = \is_numeric($distance) ? (float) $distance : null; + unset($row[Storage::DISTANCE]); } - - return $results; } /** - * Count Documents + * Converts internal attributes ($id, $createdAt, etc.) to their column names + * and encodes arrays as JSON. Spatial attributes are included with their raw + * value (the caller must handle ST_GeomFromText wrapping separately). * - * @param Document $collection - * @param array $queries - * @param int|null $max - * @return int - * @throws Exception - * @throws PDOException + * @param list $attributeKeys + * @param array $spatialMap Pre-built lookup map; the caller + * hoists this out of the per-document loop so we don't allocate it + * per row in batch inserts. + * @return array */ - public function count(Document $collection, array $queries = [], ?int $max = null): int + protected function buildDocumentRow(Document $document, array $attributeKeys, array $spatialMap = [], ?bool $intBools = null): array { - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = $this->authorization->getRoles(); - $binds = []; - $where = []; - $alias = Query::DEFAULT_ALIAS; + $attributes = $document->getAttributes(); + $row = [ + Storage::UID => $document->getId(), + Storage::CREATED_AT => $document->getCreatedAt(), + Storage::UPDATED_AT => $document->getUpdatedAt(), + Storage::PERMISSIONS => \json_encode($document->getPermissions()), + ]; - $limit = ''; - if (! \is_null($max)) { - $binds[':limit'] = $max; - $limit = 'LIMIT :limit'; + if (! empty($document->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); } - $queries = array_map(fn ($query) => clone $query, $queries); + $intBools ??= $this->supports(Capability::IntegerBooleans); - $otherQueries = []; - foreach ($queries as $query) { - if (!in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $otherQueries[] = $query; + foreach ($attributeKeys as $key) { + if (isset($row[$key])) { + continue; + } + $value = $attributes[$key] ?? null; + if (isset($spatialMap[$key])) { + $value = $this->encodeSpatialWriteValue($value); + } elseif (\is_array($value)) { + $value = \json_encode($value); } + if ($intBools && ! isset($spatialMap[$key])) { + $value = (\is_bool($value)) ? (int) $value : $value; + } + $row[$key] = $value; } - $conditions = $this->getSQLConditionsForCollection($name, $otherQueries, $binds); - if (!empty($conditions)) { - $where[] = $conditions; - } + return $row; + } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + /** + * @return list + */ + protected function getSpatialAttributes(Document $collection): array + { + $spatialAttributes = []; + foreach (self::collectionAttributes($collection) as $attribute) { + if ($attribute->isSpatial()) { + $spatialAttributes[] = $attribute->key; + } } - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + return $spatialAttributes; + } + + protected function encodeSpatialWriteValue(mixed $value): mixed + { + if (\is_array($value)) { + return $this->convertArrayToWkt($value); } - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; + return $value; + } + + /** + * @return string|null Returns null if operator can't be expressed in SQL + */ + abstract protected function getOperatorSql(string $column, Operator $operator, int &$bindIndex): ?string; - if (empty($limit)) { - $sql = " - SELECT COUNT(1) as sum - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - "; - } else { - $sql = " - SELECT COUNT(1) as sum FROM ( - SELECT 1 - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$limit} - ) table_count - "; - } + /** + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement + */ + protected function bindOperatorParameters(object $statement, Operator $operator, int &$bindIndex): void + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + case OperatorType::Increment: + case OperatorType::Decrement: + case OperatorType::Multiply: + case OperatorType::Divide: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value)); + $bindIndex++; + + if (isset($values[1])) { + $limitKey = "op_{$bindIndex}"; + $limit = self::exactLimit($values[1]); + $statement->bindValue(':'.$limitKey, $limit, $this->getPdoType($limit)); + $bindIndex++; + } + break; + + case OperatorType::Modulo: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value)); + $bindIndex++; + break; + + case OperatorType::Power: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value)); + $bindIndex++; + + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $limit = self::exactLimit($values[1]); + $statement->bindValue(':'.$maxKey, $limit, $this->getPdoType($limit)); + $bindIndex++; + } + break; - $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); + case OperatorType::StringConcat: + $value = $values[0] ?? ''; + $bindKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$bindKey, $value, PDO::PARAM_STR); + $bindIndex++; + break; - $stmt = $this->getPDO()->prepare($sql); + case OperatorType::StringReplace: + $search = $values[0] ?? ''; + $replace = $values[1] ?? ''; + $searchKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$searchKey, $search, PDO::PARAM_STR); + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$replaceKey, $replace, PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::DateAddDays: + case OperatorType::DateSubDays: + $days = $values[0] ?? 0; + $bindKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$bindKey, $days, PDO::PARAM_INT); + $bindIndex++; + break; + + case OperatorType::ArrayAppend: + case OperatorType::ArrayPrepend: + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException('Array size '.\count($values).' exceeds maximum allowed size of '.Operator::MAX_ARRAY_OPERATOR_SIZE.' for array operations'); + } - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$bindKey, $arrayValue, PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::ArrayRemove: + $value = $values[0] ?? null; + $bindKey = "op_{$bindIndex}"; + if (is_array($value)) { + $value = json_encode($value); + } + $statement->bindValue(':'.$bindKey, $value, $this->getPdoType($value)); + $bindIndex++; + break; - try { - $this->execute($stmt); - } catch (PDOException $e) { - throw $this->processException($e); - } + case OperatorType::ArrayInsert: + $index = $values[0] ?? 0; + $value = $values[1] ?? null; + $indexKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$indexKey, $index, PDO::PARAM_INT); + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$valueKey, json_encode($value), PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::ArrayIntersect: + case OperatorType::ArrayDiff: + if (\count($values) > Operator::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException('Array size '.\count($values).' exceeds maximum allowed size of '.Operator::MAX_ARRAY_OPERATOR_SIZE.' for array operations'); + } - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $statement->bindValue(':'.$bindKey, $arrayValue, PDO::PARAM_STR); + $bindIndex++; + break; } - - return $result['sum'] ?? 0; } /** - * Sum an Attribute + * Get the operator expression and positional bindings for use with the query builder's setRaw(). * - * @param Document $collection - * @param string $attribute - * @param array $queries - * @param int|null $max - * @return int|float - * @throws Exception - * @throws PDOException + * Calls getOperatorSql() to get the expression with named bindings, strips the + * column assignment prefix, and converts named :op_N bindings to positional ? placeholders. + * + * @param string $column The unquoted column name + * + * @throws DatabaseException */ - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float + protected function getOperatorBuilderExpression(string $column, Operator $operator): Expression { - $collection = $collection->getId(); - $name = $this->filter($collection); - $attribute = $this->filter($attribute); - $roles = $this->authorization->getRoles(); - $where = []; - $alias = Query::DEFAULT_ALIAS; - $binds = []; - - $limit = ''; - if (! \is_null($max)) { - $binds[':limit'] = $max; - $limit = 'LIMIT :limit'; - } + $bindIndex = 0; + $fullExpression = $this->getOperatorSql($column, $operator, $bindIndex); - $queries = array_map(fn ($query) => clone $query, $queries); - - $otherQueries = []; - foreach ($queries as $query) { - if (!in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $otherQueries[] = $query; - } + if ($fullExpression === null) { + throw new DatabaseException('Operator cannot be expressed in SQL: '.$operator->getMethod()->value); } - $conditions = $this->getSQLConditionsForCollection($name, $otherQueries, $binds); - if (!empty($conditions)) { - $where[] = $conditions; + $quotedColumn = $this->quote($column); + $prefix = $quotedColumn.' = '; + $expression = $fullExpression; + if (str_starts_with($expression, $prefix)) { + $expression = substr($expression, strlen($prefix)); } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); - } + /** @var array $namedBindings */ + $namedBindings = []; + $method = $operator->getMethod(); + $values = $operator->getValues(); + $idx = 0; - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + switch ($method) { + case OperatorType::Increment: + case OperatorType::Decrement: + case OperatorType::Multiply: + case OperatorType::Divide: + $namedBindings["op_{$idx}"] = $values[0] ?? 1; + $idx++; + if (isset($values[1])) { + $namedBindings["op_{$idx}"] = self::exactLimit($values[1]); + $idx++; + } + break; + + case OperatorType::Modulo: + $namedBindings["op_{$idx}"] = $values[0] ?? 1; + $idx++; + break; + + case OperatorType::Power: + $namedBindings["op_{$idx}"] = $values[0] ?? 1; + $idx++; + if (isset($values[1])) { + $namedBindings["op_{$idx}"] = self::exactLimit($values[1]); + $idx++; + } + break; + + case OperatorType::StringConcat: + $namedBindings["op_{$idx}"] = $values[0] ?? ''; + $idx++; + break; + + case OperatorType::StringReplace: + $namedBindings["op_{$idx}"] = $values[0] ?? ''; + $idx++; + $namedBindings["op_{$idx}"] = $values[1] ?? ''; + $idx++; + break; + + case OperatorType::Toggle: + // No bindings + break; + + case OperatorType::DateAddDays: + case OperatorType::DateSubDays: + $namedBindings["op_{$idx}"] = $values[0] ?? 0; + $idx++; + break; + + case OperatorType::DateSetNow: + // No bindings + break; + + case OperatorType::ArrayAppend: + case OperatorType::ArrayPrepend: + $namedBindings["op_{$idx}"] = json_encode($values); + $idx++; + break; + + case OperatorType::ArrayRemove: + $value = $values[0] ?? null; + $namedBindings["op_{$idx}"] = is_array($value) ? json_encode($value) : $value; + $idx++; + break; + + case OperatorType::ArrayUnique: + // No bindings + break; + + case OperatorType::ArrayInsert: + $namedBindings["op_{$idx}"] = $values[0] ?? 0; + $idx++; + $namedBindings["op_{$idx}"] = json_encode($values[1] ?? null); + $idx++; + break; + + case OperatorType::ArrayIntersect: + case OperatorType::ArrayDiff: + $namedBindings["op_{$idx}"] = json_encode($values); + $idx++; + break; + + case OperatorType::ArrayFilter: + $condition = $values[0] ?? 'equal'; + $filterValue = $values[1] ?? null; + $namedBindings["op_{$idx}"] = $condition; + $idx++; + $namedBindings["op_{$idx}"] = $filterValue !== null ? json_encode($filterValue) : null; + $idx++; + break; } - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; - - if (empty($limit)) { - $sql = " - SELECT SUM({$this->quote($attribute)}) as sum - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - "; - } else { - $sql = " - SELECT SUM({$this->quote($attribute)}) as sum FROM ( - SELECT {$this->quote($attribute)} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$limit} - ) table_count - "; + // Process longest keys first to avoid partial replacement (e.g., :op_10 vs :op_1) + $positionalBindings = []; + $keys = array_keys($namedBindings); + usort($keys, fn ($a, $b) => strlen($b) - strlen($a)); + + $replacements = []; + foreach ($keys as $key) { + $search = ':'.$key; + $offset = 0; + while (($pos = strpos($expression, $search, $offset)) !== false) { + $replacements[] = ['pos' => $pos, 'len' => strlen($search), 'key' => $key]; + $offset = $pos + strlen($search); + } } + usort($replacements, fn ($a, $b) => $a['pos'] - $b['pos']); - $sql = $this->trigger(Database::EVENT_DOCUMENT_SUM, $sql); - - $stmt = $this->getPDO()->prepare($sql); - - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); - } - - try { - $this->execute($stmt); - } catch (PDOException $e) { - throw $this->processException($e); + // Replace from right to left to preserve positions + $result = $expression; + for ($i = count($replacements) - 1; $i >= 0; $i--) { + $r = $replacements[$i]; + $result = substr_replace($result, '?', $r['pos'], $r['len']); } - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; + foreach ($replacements as $r) { + $positionalBindings[] = $namedBindings[$r['key']]; } - return $result['sum'] ?? 0; + return new Expression($result, $positionalBindings); } - public function getSpatialTypeFromWKT(string $wkt): string + /** + * By default this delegates to getOperatorBuilderExpression(). Adapters + * that need to reference the existing row differently in upsert context + * (e.g. Postgres using target.col) should override this method. + * + * @param string $column The unquoted, filtered column name + */ + protected function getOperatorUpsertExpression(string $column, Operator $operator): Expression { - $wkt = trim($wkt); - $pos = strpos($wkt, '('); - if ($pos === false) { - throw new DatabaseException("Invalid spatial type"); - } - return strtolower(trim(substr($wkt, 0, $pos))); + return $this->getOperatorBuilderExpression($column, $operator); } - public function decodePoint(string $wkb): array + /** + * Apply an operator to a value (used for new documents with only operators). + * This method applies the operator logic in PHP to compute what the SQL would compute. + * + * @param mixed $value The current value (typically the attribute default) + */ + protected function applyOperatorToValue(Operator $operator, mixed $value): mixed { - if (str_starts_with(strtoupper($wkb), 'POINT(')) { - $start = strpos($wkb, '(') + 1; - $end = strrpos($wkb, ')'); - $inside = substr($wkb, $start, $end - $start); - $coords = explode(' ', trim($inside)); - return [(float)$coords[0], (float)$coords[1]]; - } - - /** - * [0..3] SRID (4 bytes, little-endian) - * [4] Byte order (1 = little-endian, 0 = big-endian) - * [5..8] Geometry type (with SRID flag bit) - * [9..] Geometry payload (coordinates, etc.) - */ + $method = $operator->getMethod(); + $values = $operator->getValues(); + $exact = BigInt::calculateOutsideNative($method, $value ?? 0, $values[0] ?? 1); + if ($exact !== null) { + $bound = self::exactLimit($values[1] ?? null); + if (BigInt::isIntegerValue($bound)) { + $upper = \in_array($method, [OperatorType::Increment, OperatorType::Multiply, OperatorType::Power], true); + if (($upper && BigInt::compare($exact, $bound) > 0) + || (! $upper && $method !== OperatorType::Modulo && BigInt::compare($exact, $bound) < 0)) { + return BigInt::toNative($value ?? 0); + } + } - if (strlen($wkb) < 25) { - throw new DatabaseException('Invalid WKB: too short for POINT'); + return $exact; } - // 4 bytes SRID first → skip to byteOrder at offset 4 - $byteOrder = ord($wkb[4]); - $littleEndian = ($byteOrder === 1); + $numVal = is_numeric($value) ? $value + 0 : 0; + $firstValue = count($values) > 0 ? $values[0] : null; + $numOp = is_numeric($firstValue) ? $firstValue + 0 : 1; + /** @var array $arrVal */ + $arrVal = is_array($value) ? $value : []; + + $result = match ($method) { + OperatorType::Increment => $numVal + $numOp, + OperatorType::Decrement => $numVal - $numOp, + OperatorType::Multiply => $numVal * $numOp, + OperatorType::Divide => $numOp != 0 ? $numVal / $numOp : $numVal, + OperatorType::Modulo => $numOp != 0 ? (int) $numVal % (int) $numOp : (int) $numVal, + OperatorType::Power => pow($numVal, $numOp), + OperatorType::ArrayAppend => array_merge($arrVal, $values), + OperatorType::ArrayPrepend => array_merge($values, $arrVal), + OperatorType::ArrayInsert => (function () use ($arrVal, $values) { + $arr = $arrVal; + $insertIdxRaw = count($values) > 0 ? $values[0] : 0; + $insertIdx = \is_numeric($insertIdxRaw) ? (int) $insertIdxRaw : 0; + array_splice($arr, $insertIdx, 0, [count($values) > 1 ? $values[1] : null]); - if (!$littleEndian) { - throw new DatabaseException('Only little-endian WKB supported'); - } + return $arr; + })(), + OperatorType::ArrayRemove => (function () use ($arrVal, $values) { + $arr = self::stringifyList($arrVal); + $toRemove = $values[0] ?? null; + $remove = \is_array($toRemove) ? self::stringifyList($toRemove) : [self::stringify($toRemove)]; + + return array_values(array_diff($arr, $remove)); + })(), + OperatorType::ArrayUnique => array_values(array_unique(self::stringifyList($arrVal))), + OperatorType::ArrayIntersect => array_values(array_intersect(self::stringifyList($arrVal), self::stringifyList($values))), + OperatorType::ArrayDiff => array_values(array_diff(self::stringifyList($arrVal), self::stringifyList($values))), + OperatorType::ArrayFilter => self::filterArray($arrVal, $values[0] ?? null, $values[1] ?? null), + OperatorType::StringConcat => (\is_scalar($value) ? (string) $value : '') . (count($values) > 0 && \is_scalar($values[0]) ? (string) $values[0] : ''), + OperatorType::StringReplace => str_replace(count($values) > 0 && \is_scalar($values[0]) ? (string) $values[0] : '', count($values) > 1 && \is_scalar($values[1]) ? (string) $values[1] : '', \is_scalar($value) ? (string) $value : ''), + OperatorType::Toggle => ! ($value ?? false), + OperatorType::DateAddDays => self::shiftDays($value, \is_numeric($firstValue) ? (int) $firstValue : 0), + OperatorType::DateSubDays => self::shiftDays($value, \is_numeric($firstValue) ? -(int) $firstValue : 0), + OperatorType::DateSetNow => DateTime::now(), + }; - // After SRID (4) + byteOrder (1) + type (4) = 9 bytes - $coordsBin = substr($wkb, 9, 16); - if (strlen($coordsBin) !== 16) { - throw new DatabaseException('Invalid WKB: missing coordinate bytes'); - } + return self::keepWithinBound($method, $numVal, $result, $values[1] ?? null); + } - // Unpack two doubles - $coords = unpack('d2', $coordsBin); - if ($coords === false || !isset($coords[1], $coords[2])) { - throw new DatabaseException('Invalid WKB: failed to unpack coordinates'); + protected static function exactLimit(mixed $limit): mixed + { + if (! \is_float($limit) || ! \is_finite($limit)) { + return $limit; } - return [(float)$coords[1], (float)$coords[2]]; + return BigInt::integralValue($limit) ?? $limit; } - public function decodeLinestring(string $wkb): array + private static function keepWithinBound(OperatorType $method, int|float $current, mixed $result, mixed $bound): mixed { - if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) { - $start = strpos($wkb, '(') + 1; - $end = strrpos($wkb, ')'); - $inside = substr($wkb, $start, $end - $start); - - $points = explode(',', $inside); - return array_map(function ($point) { - $coords = explode(' ', trim($point)); - return [(float)$coords[0], (float)$coords[1]]; - }, $points); + if (! \is_numeric($bound) || (! \is_int($result) && ! \is_float($result))) { + return $result; } - // Skip 1 byte (endianness) + 4 bytes (type) + 4 bytes (SRID) - $offset = 9; + $limit = \is_float($bound) && \is_finite($bound) ? (BigInt::integralValue($bound) ?? $bound) : $bound; + $comparison = \is_int($result) && BigInt::isIntegerValue($limit) + ? BigInt::compare($result, $limit) + : $result <=> (\is_string($limit) ? (float) $limit : $limit); - // Number of points (4 bytes little-endian) - $numPointsArr = unpack('V', substr($wkb, $offset, 4)); - if ($numPointsArr === false || !isset($numPointsArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack number of points'); - } + $crossed = match ($method) { + OperatorType::Increment, OperatorType::Multiply, OperatorType::Power => \is_nan((float) $result) || $comparison > 0, + OperatorType::Decrement, OperatorType::Divide => $comparison < 0, + default => false, + }; - $numPoints = $numPointsArr[1]; - $offset += 4; + return $crossed ? $current : $result; + } - $points = []; - for ($i = 0; $i < $numPoints; $i++) { - $xArr = unpack('d', substr($wkb, $offset, 8)); - $yArr = unpack('d', substr($wkb, $offset + 8, 8)); + /** + * @param array $items + * @return list + */ + private static function filterArray(array $items, mixed $condition, mixed $compare): array + { + return \array_values(\array_filter($items, static fn (mixed $item): bool => match ($condition) { + Method::Equal->value => $item == $compare, + Method::NotEqual->value => $item != $compare, + Method::GreaterThan->value => \is_numeric($compare) && \is_numeric($item) && $item + 0 > $compare + 0, + Method::GreaterThanEqual->value => \is_numeric($compare) && \is_numeric($item) && $item + 0 >= $compare + 0, + Method::LessThan->value => \is_numeric($compare) && \is_numeric($item) && $item + 0 < $compare + 0, + Method::LessThanEqual->value => \is_numeric($compare) && \is_numeric($item) && $item + 0 <= $compare + 0, + Method::IsNull->value => $item === null, + Method::IsNotNull->value => $item !== null, + default => true, + })); + } - if ($xArr === false || !isset($xArr[1]) || $yArr === false || !isset($yArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack point coordinates'); - } + private static function shiftDays(mixed $value, int $days): mixed + { + if (! \is_string($value) || $value === '') { + return $value; + } - $points[] = [(float)$xArr[1], (float)$yArr[1]]; - $offset += 16; + try { + $date = new \DateTime($value); + } catch (Throwable) { + return $value; } - return $points; + $date->setTimezone(new \DateTimeZone(\date_default_timezone_get())); + $date->modify(\sprintf('%+d days', $days)); + + return DateTime::format($date); } - public function decodePolygon(string $wkb): array + /** + * @param array $values + * @return list + */ + private static function stringifyList(array $values): array { - // POLYGON((x1,y1),(x2,y2)) - if (str_starts_with($wkb, 'POLYGON((')) { - $start = strpos($wkb, '((') + 2; - $end = strrpos($wkb, '))'); - $inside = substr($wkb, $start, $end - $start); - - $rings = explode('),(', $inside); - return array_map(function ($ring) { - $points = explode(',', $ring); - return array_map(function ($point) { - $coords = explode(' ', trim($point)); - return [(float)$coords[0], (float)$coords[1]]; - }, $points); - }, $rings); + $out = []; + foreach ($values as $value) { + $out[] = self::stringify($value); } - // Convert HEX string to binary if needed - if (str_starts_with($wkb, '0x') || ctype_xdigit($wkb)) { - $wkb = hex2bin(str_starts_with($wkb, '0x') ? substr($wkb, 2) : $wkb); - if ($wkb === false) { - throw new DatabaseException('Invalid hex WKB'); - } - } + return $out; + } - if (strlen($wkb) < 21) { - throw new DatabaseException('WKB too short to be a POLYGON'); + private static function stringify(mixed $value): string + { + if (\is_string($value)) { + return $value; } - - // MySQL SRID-aware WKB layout: 4 bytes SRID prefix - $offset = 4; - - $byteOrder = ord($wkb[$offset]); - if ($byteOrder !== 1) { - throw new DatabaseException('Only little-endian WKB supported'); + if (\is_scalar($value) || $value === null) { + return (string) $value; } - $offset += 1; - $typeArr = unpack('V', substr($wkb, $offset, 4)); - if ($typeArr === false || !isset($typeArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack geometry type'); - } + return \get_debug_type($value); + } - $type = $typeArr[1]; - $hasSRID = ($type & 0x20000000) === 0x20000000; - $geomType = $type & 0xFF; - $offset += 4; + protected function quote(string $string): string + { + return '`'.\str_replace('`', '``', $string).'`'; + } - if ($geomType !== 3) { // 3 = POLYGON - throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); - } + /** + * Whether the adapter requires an alias on INSERT for conflict resolution. + * + * PostgreSQL needs INSERT INTO table AS target so that the ON CONFLICT + * clause can reference the existing row via target.column. MariaDB does + * not need this because it uses VALUES(column) syntax. + */ + protected function insertRequiresAlias(): bool + { + return false; + } - // Skip SRID in type flag if present - if ($hasSRID) { - $offset += 4; - } + /** + * Get the conflict-resolution expression for a regular column in shared-tables mode. + * + * The returned expression is used as the RHS of "col = " in the + * ON CONFLICT / ON DUPLICATE KEY UPDATE clause. It must conditionally update + * the column only when the tenant matches. + * + * @param string $column The unquoted column name + * @return string The raw SQL expression (with positional ? placeholders if needed) + */ + abstract protected function getConflictTenantExpression(string $column): string; - $numRingsArr = unpack('V', substr($wkb, $offset, 4)); + /** + * Get the conflict-resolution expression for an increment column. + * + * Returns the RHS expression that adds the incoming value to the existing + * column value (e.g. col + VALUES(col) for MariaDB, target.col + EXCLUDED.col + * for Postgres). + * + * @param string $column The unquoted column name + * @return string The raw SQL expression + */ + abstract protected function getConflictIncrementExpression(string $column): string; - if ($numRingsArr === false || !isset($numRingsArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack number of rings'); - } + /** + * Get the conflict-resolution expression for an increment column in shared-tables mode. + * + * Like getConflictTenantExpression but the "new value" is the existing column + * value plus the incoming value. + * + * @param string $column The unquoted column name + * @return string The raw SQL expression + */ + abstract protected function getConflictTenantIncrementExpression(string $column): string; + + /** + * @throws Exception + */ + protected function getPdoType(mixed $value): int + { + return match (gettype($value)) { + 'string', 'double' => \PDO::PARAM_STR, + 'integer', 'boolean' => \PDO::PARAM_INT, + 'NULL' => \PDO::PARAM_NULL, + default => throw new DatabaseException('Unknown PDO Type for ' . \gettype($value)), + }; + } - $numRings = $numRingsArr[1]; - $offset += 4; + /** + * The direction in which this engine sorts null before every other value. + */ + protected function getNullOrder(): OrderDirection + { + return OrderDirection::Asc; + } - $rings = []; + /** + * Get vector distance ORDER BY expression with positional bindings. + * + * Returns null when vectors are unsupported. Subclasses that support vectors + * should override this to return the expression string with `?` placeholders + * and the matching binding values. + * + */ + protected function getVectorOrderRaw(Query $query, string $alias): ?Expression + { + return null; + } - for ($r = 0; $r < $numRings; $r++) { - $numPointsArr = unpack('V', substr($wkb, $offset, 4)); + /** + * @param list $orderAttributes + * @param list $orderTypes + * @param array $cursor + * @param callable(string): string $resolveInternalKey + */ + private function getVectorCursorCondition( + Expression $vector, + float $distance, + array $orderAttributes, + array $orderTypes, + array $cursor, + CursorDirection $cursorDirection, + string $alias, + callable $resolveInternalKey, + bool $nullable = false, + ): Expression { + $distance = \json_encode($distance, JSON_THROW_ON_ERROR); + $distanceOperator = $cursorDirection === CursorDirection::Before ? '<' : '>'; + $clauses = ["({$vector->sql}) {$distanceOperator} ?"]; + $bindings = []; + \array_push($bindings, ...$vector->bindings); + $bindings[] = $distance; - if ($numPointsArr === false || !isset($numPointsArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack number of points'); + foreach ($orderAttributes as $index => $attribute) { + if (! \array_key_exists($attribute, $cursor)) { + throw new QueryException("Vector cursor is missing order attribute '{$attribute}'"); } - $numPoints = $numPointsArr[1]; - $offset += 4; - $ring = []; + $parts = ["({$vector->sql}) = ?"]; + $clauseBindings = []; + \array_push($clauseBindings, ...$vector->bindings); + $clauseBindings[] = $distance; - for ($p = 0; $p < $numPoints; $p++) { - $xArr = unpack('d', substr($wkb, $offset, 8)); - if ($xArr === false) { - throw new DatabaseException('Failed to unpack X coordinate from WKB.'); + for ($previous = 0; $previous < $index; $previous++) { + $previousAttribute = $orderAttributes[$previous]; + if (! \array_key_exists($previousAttribute, $cursor)) { + throw new QueryException("Vector cursor is missing order attribute '{$previousAttribute}'"); } - $x = (float) $xArr[1]; + $previousColumn = $this->quoteOrderColumn($resolveInternalKey($previousAttribute), $alias); + if ($nullable && $cursor[$previousAttribute] === null) { + $parts[] = "{$previousColumn} IS NULL"; - $yArr = unpack('d', substr($wkb, $offset + 8, 8)); - if ($yArr === false) { - throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); + continue; } + $parts[] = "{$previousColumn} = ?"; + $clauseBindings[] = $cursor[$previousAttribute]; + } - $y = (float) $yArr[1]; - - $ring[] = [$x, $y]; - $offset += 16; + $direction = $orderTypes[$index] ?? OrderDirection::Asc; + if ($cursorDirection === CursorDirection::Before) { + $direction = $direction === OrderDirection::Asc + ? OrderDirection::Desc + : OrderDirection::Asc; + } + $operator = $direction === OrderDirection::Desc ? '<' : '>'; + $column = $this->quoteOrderColumn($resolveInternalKey($attribute), $alias); + if ($nullable && $cursor[$attribute] === null) { + if ($direction !== $this->getNullOrder()) { + continue; + } + $parts[] = "{$column} IS NOT NULL"; + } elseif ($nullable && $direction !== $this->getNullOrder()) { + $parts[] = "COALESCE({$column} {$operator} ?, TRUE)"; + $clauseBindings[] = $cursor[$attribute]; + } else { + $parts[] = "{$column} {$operator} ?"; + $clauseBindings[] = $cursor[$attribute]; } + $clauses[] = '('.\implode(' AND ', $parts).')'; + \array_push($bindings, ...$clauseBindings); + } + + return new Expression( + '('.\implode(' OR ', $clauses).')', + $bindings, + ); + } + + /** + * Render a vector distance expression in a form safe to hydrate as a PHP float. + */ + protected function getSqlReadableDistance(string $distance): string + { + return $distance; + } + + #[\Override] + protected function escapeWildcards(string $value): string + { + $wildcards = ['\\', '%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; - $rings[] = $ring; + foreach ($wildcards as $wildcard) { + $value = \str_replace($wildcard, "\\$wildcard", $value); } - return $rings; + return $value; } - public function setSupportForAttributes(bool $support): bool + protected function processException(PDOException $e): Exception { - return true; + return $e; } - public function getSupportForAlterLocks(): bool + /** + * A driver error the adapter maps to a lock conflict is transient too, even when it reached the transaction + * without being mapped. + */ + #[\Override] + protected function isTransient(Throwable $error): bool { - return false; + return parent::isTransient($error) + || ($error instanceof PDOException && $this->processException($error) instanceof ContentionException); } - public function getLockType(): string + protected function processSelectException(PDOException $e, Statement $statement): Exception { - if ($this->getSupportForAlterLocks() && $this->alterLocks) { - return ',LOCK=SHARED'; + return $this->processException($e); + } + + /** + * Quote a search attribute, keeping join-qualified paths on the join alias. + * + * @return array{0: string, 1: string} + */ + protected function quoteSearchAttribute(string $attribute, string $alias): array + { + $dot = \strpos($attribute, '.'); + if ($dot !== false) { + $prefix = \substr($attribute, 0, $dot); + $name = \substr($attribute, $dot + 1); + + return [ + $this->quote($this->filter($prefix)), + $this->quote($this->filter($this->getInternalKeyForAttribute($name))), + ]; } - return ''; + return [ + $this->quote($alias), + $this->quote($this->filter($this->getInternalKeyForAttribute($attribute))), + ]; } - public function getSupportForTransactionRetries(): bool + /** + * Whether `$query` should bypass the upstream Builder pipeline and be + * compiled by the adapter directly via {@see compileAdapterFilter()}. + * + * Used by adapters whose query semantics aren't expressible through the + * Builder's typed methods — e.g. SQLite's FTS5 search needs an + * `IN (SELECT rowid FROM ...)` subquery that requires the + * collection name and metadata. + */ + protected function isAdapterFilterQuery(Query $query): bool { - return true; + return false; } - public function getSupportForNestedTransactions(): bool + /** + * Compile an adapter-specific filter to a raw WHERE expression with + * positional bindings. Called for queries flagged by + * {@see isAdapterFilterQuery()}. Returning null skips emission. + * + * @param list $joins + */ + protected function compileAdapterFilter(Query $query, string $collection, string $alias, array $joins = []): ?Expression { - return true; + return null; } } diff --git a/src/Database/Adapter/SQL/BoundedPage.php b/src/Database/Adapter/SQL/BoundedPage.php new file mode 100644 index 0000000000..59d5b463c8 --- /dev/null +++ b/src/Database/Adapter/SQL/BoundedPage.php @@ -0,0 +1,46 @@ + $orderAttributes + * @param list $orderTypes + * @param list $conditions The read's conditions on main attributes, its searches included + * @param list $adapterConditions The read's searches the adapter compiles itself + * @param list $searches The read's own searches, which only the page applies + */ + public function __construct( + public array $orderAttributes, + public array $orderTypes, + public int $rows, + public array $conditions, + public array $adapterConditions, + public array $searches, + ) { + } + + /** + * @template T of BaseQuery + * + * @param array $queries + * @return array + */ + public function withoutSearches(array $queries): array + { + if ($this->searches === []) { + return $queries; + } + + return \array_values(\array_filter($queries, fn (BaseQuery $query): bool => ! \in_array($query, $this->searches, true))); + } +} diff --git a/src/Database/Adapter/SQL/Expression.php b/src/Database/Adapter/SQL/Expression.php new file mode 100644 index 0000000000..bc27fabcc6 --- /dev/null +++ b/src/Database/Adapter/SQL/Expression.php @@ -0,0 +1,15 @@ + $bindings + */ + public function __construct( + public string $sql, + public array $bindings = [], + ) { + } +} diff --git a/src/Database/Adapter/SQL/Hook/Column/AllowNull.php b/src/Database/Adapter/SQL/Hook/Column/AllowNull.php new file mode 100644 index 0000000000..8e8339fd09 --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Column/AllowNull.php @@ -0,0 +1,52 @@ +filter->filter($table), $this->column, $this->quoteCharacter); + } + + public static function wrap(Condition $condition, string $column, string $quoteCharacter = '`'): Condition + { + if (! \preg_match(self::IDENTIFIER_PATTERN, $column)) { + throw new DatabaseException('Invalid column name: '.$column); + } + + return new Condition( + '('.$condition->expression.' OR '.self::quote($column, $quoteCharacter).' IS NULL)', + $condition->bindings, + ); + } + + public static function quote(string $identifier, string $quoteCharacter = '`'): string + { + $parts = \explode('.', $identifier); + $quoted = \array_map( + fn (string $part): string => $quoteCharacter.\str_replace($quoteCharacter, $quoteCharacter.$quoteCharacter, $part).$quoteCharacter, + $parts, + ); + + return \implode('.', $quoted); + } +} diff --git a/src/Database/Adapter/SQL/Hook/Join/Chain.php b/src/Database/Adapter/SQL/Hook/Join/Chain.php new file mode 100644 index 0000000000..f80422015d --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Join/Chain.php @@ -0,0 +1,95 @@ + $joins Each joined table's alias and how it is joined, in order + */ + public function __construct( + private array $joins = [], + ) { + } + + /** + * @param array $queries The read's queries; joins are keyed by alias, as the builder + * hands them to join filters + */ + public static function fromQueries(array $queries): self + { + $joins = []; + foreach ($queries as $query) { + $type = match ($query->getMethod()) { + Method::Join => JoinType::Inner, + Method::LeftJoin => JoinType::Left, + Method::RightJoin => JoinType::Right, + Method::FullOuterJoin => JoinType::FullOuter, + Method::CrossJoin => JoinType::Cross, + Method::NaturalJoin => JoinType::Natural, + default => null, + }; + + if ($type !== null) { + $joins[$query->getAlias()] = $type; + } + } + + return new self($joins); + } + + /** + * How $alias is joined; a table the read does not join counts as joined inner. + */ + public function type(string $alias): JoinType + { + return $this->joins[$alias] ?? JoinType::Inner; + } + + public function has(JoinType $type): bool + { + return \in_array($type, $this->joins, true); + } + + /** + * Whether a right or full outer join can leave a table missing from a row, so that a condition + * in WHERE has to let such rows through. + */ + public function hasPreservingOuterJoin(): bool + { + return $this->has(JoinType::Right) || $this->has(JoinType::FullOuter); + } + + /** + * The tables joined before $alias whose conditions sit in WHERE: the ones joined right, full + * outer or cross. A table joined inner or left already meets its conditions in its own ON. + * + * @return list + */ + public function preceding(string $alias): array + { + $preceding = []; + foreach ($this->joins as $joined => $type) { + if ($joined === $alias) { + return $preceding; + } + + if ($type !== JoinType::Inner && $type !== JoinType::Left) { + $preceding[] = $joined; + } + } + + return []; + } +} diff --git a/src/Database/Adapter/SQL/Hook/Join/OuterChain.php b/src/Database/Adapter/SQL/Hook/Join/OuterChain.php new file mode 100644 index 0000000000..84c1ca3b7d --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Join/OuterChain.php @@ -0,0 +1,61 @@ + $conditions The condition each joined table's rows must meet, by alias + */ + public function __construct( + private Chain $chain, + private array $conditions, + private string $quoteCharacter = '`', + ) { + } + + #[\Override] + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + if ($joinType !== JoinType::Right && $joinType !== JoinType::FullOuter) { + return null; + } + + $expressions = []; + $bindings = []; + foreach ($this->chain->preceding($table) as $alias) { + if (! isset($this->conditions[$alias])) { + continue; + } + + $condition = AllowNull::wrap($this->conditions[$alias], $alias.'.'.Storage::UID, $this->quoteCharacter); + $expressions[] = $condition->expression; + \array_push($bindings, ...$condition->bindings); + } + + if ($expressions === []) { + return null; + } + + return new JoinCondition(new Condition(\implode(' AND ', $expressions), $bindings), Placement::On); + } +} diff --git a/src/Database/Adapter/SQL/Hook/Permission/AllowNullUid.php b/src/Database/Adapter/SQL/Hook/Permission/AllowNullUid.php new file mode 100644 index 0000000000..dba7fead01 --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Permission/AllowNullUid.php @@ -0,0 +1,26 @@ +inner = new AllowNull($filter, $documentColumn, $quoteCharacter); + } + + #[\Override] + public function filter(string $table): Condition + { + return $this->inner->filter($table); + } +} diff --git a/src/Database/Adapter/SQL/Hook/Permission/Filter.php b/src/Database/Adapter/SQL/Hook/Permission/Filter.php new file mode 100644 index 0000000000..24d7bf96bc --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Permission/Filter.php @@ -0,0 +1,143 @@ + $roles + * @param Closure(string): string $permissionsTable Receives the base table name, returns the permissions table name + * @param list|null $columns Column names to check permissions for. NULL rows (wildcard) are always included. + * @param FilterHook|null $subqueryFilter Optional filter applied inside the permissions subquery (e.g. tenant filtering) + * @param bool $semiJoin Whether the engine may merge the subquery into the outer query as a semi-join; when not, it carries MySQL's NO_SEMIJOIN hint, a comment to engines without optimizer hints + */ + public function __construct( + protected array $roles, + protected Closure $permissionsTable, + protected string $type = PermissionType::Read->value, + protected ?array $columns = null, + protected string $documentColumn = 'id', + protected string $permissionDocumentColumn = 'document_id', + protected string $permissionRoleColumn = 'role', + protected string $permissionTypeColumn = 'type', + protected string $scopeColumn = 'column', + protected ?FilterHook $subqueryFilter = null, + protected string $quoteCharacter = '`', + protected bool $semiJoin = true, + ) { + foreach ([$documentColumn, $permissionDocumentColumn, $permissionRoleColumn, $permissionTypeColumn, $scopeColumn] as $column) { + if (! \preg_match(self::IDENTIFIER_PATTERN, $column)) { + throw new InvalidArgumentException('Invalid column name: '.$column); + } + } + $this->documentCollation = ''; + } + + /** + * @return Condition A condition with an IN subquery against the permissions table + * @throws DatabaseException If the permissions table name is invalid + */ + #[\Override] + public function filter(string $table): Condition + { + if (empty($this->roles)) { + return new Condition('1 = 0'); + } + + $permTable = ($this->permissionsTable)($table); + + if (! \preg_match(self::QUOTED_IDENTIFIER_PATTERN, $permTable)) { + throw new DatabaseException('Invalid permissions table name: '.$permTable); + } + + $quotedPermTable = AllowNull::quote($permTable, $this->quoteCharacter); + $quotedDocumentColumn = AllowNull::quote($this->documentColumn, $this->quoteCharacter); + + $rolePlaceholders = \implode(', ', \array_fill(0, \count($this->roles), '?')); + + $columnClause = ''; + $columnBindings = []; + + if ($this->columns !== null) { + if (empty($this->columns)) { + $columnClause = " AND {$this->scopeColumn} IS NULL"; + } else { + $colPlaceholders = \implode(', ', \array_fill(0, \count($this->columns), '?')); + $columnClause = " AND ({$this->scopeColumn} IS NULL OR {$this->scopeColumn} IN ({$colPlaceholders}))"; + $columnBindings = $this->columns; + } + } + + $subFilterClause = ''; + $subFilterBindings = []; + if ($this->subqueryFilter !== null) { + $subCondition = $this->subqueryFilter->filter($permTable); + $subFilterClause = ' AND '.$subCondition->expression; + $subFilterBindings = $subCondition->bindings; + } + + $hint = $this->semiJoin ? '' : self::NO_SEMIJOIN; + + return new Condition( + "{$quotedDocumentColumn}{$this->documentCollation} IN (SELECT {$hint}{$this->permissionDocumentColumn} FROM {$quotedPermTable} WHERE {$this->permissionRoleColumn} IN ({$rolePlaceholders}) AND {$this->permissionTypeColumn} = ?{$columnClause}{$subFilterClause})", + [...$this->roles, $this->type, ...$columnBindings, ...$subFilterBindings], + ); + } + + /** + * Compare the document column in the collation of the index that serves it. + * + * @throws InvalidArgumentException If the collation name is invalid + */ + public function collate(string $collation): static + { + if (! \preg_match(self::COLLATION_PATTERN, $collation)) { + throw new InvalidArgumentException('Invalid collation name: '.$collation); + } + + return clone($this, ['documentCollation' => ' COLLATE '.$collation]); + } + + public function withoutSemiJoin(): static + { + return clone($this, ['semiJoin' => false]); + } + + /** + * Per-join-table permission checks are applied via separate Permission\Join hooks + * registered by the SQL adapter for each joined table. This hook only handles the + * primary table's WHERE clause, so filterJoin returns null. + */ + #[\Override] + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + return null; + } +} diff --git a/src/Database/Adapter/SQL/Hook/Permission/Join.php b/src/Database/Adapter/SQL/Hook/Permission/Join.php new file mode 100644 index 0000000000..e08539f6cd --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Permission/Join.php @@ -0,0 +1,57 @@ +alias) { + return null; + } + + $placement = match ($joinType) { + JoinType::Left, JoinType::Inner => Placement::On, + default => Placement::Where, + }; + + $condition = $this->filter->filter($table); + if ($placement === Placement::Where && ($joinType === JoinType::FullOuter || $this->preservingOuterJoin)) { + $condition = AllowNull::wrap( + $condition, + $this->alias.'.'.Storage::UID, + $this->quoteCharacter, + ); + } + + return new JoinCondition($condition, $placement); + } +} diff --git a/src/Database/Adapter/SQL/Hook/Permission/OuterJoin.php b/src/Database/Adapter/SQL/Hook/Permission/OuterJoin.php new file mode 100644 index 0000000000..188f9d77d0 --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Permission/OuterJoin.php @@ -0,0 +1,69 @@ + $conditions The permission condition of each table read per + * document, by alias, the main table's included + */ + public function __construct( + private string $source, + private array $conditions, + private string $quoteCharacter = '`', + ) { + } + + #[\Override] + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + if ($joinType !== JoinType::Right && $joinType !== JoinType::FullOuter) { + return null; + } + + $conditions = []; + if (isset($this->conditions[$this->source])) { + $conditions[] = AllowNull::wrap( + $this->conditions[$this->source], + $this->source.'.'.Storage::UID, + $this->quoteCharacter, + ); + } + if (isset($this->conditions[$table])) { + $conditions[] = $this->conditions[$table]; + } + + if ($conditions === []) { + return null; + } + + $expressions = []; + $bindings = []; + foreach ($conditions as $condition) { + $expressions[] = $condition->expression; + \array_push($bindings, ...$condition->bindings); + } + + return new JoinCondition(new Condition(\implode(' AND ', $expressions), $bindings), Placement::On); + } +} diff --git a/src/Database/Adapter/SQL/Hook/Tenant/Filter.php b/src/Database/Adapter/SQL/Hook/Tenant/Filter.php new file mode 100644 index 0000000000..75bf9693e8 --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Tenant/Filter.php @@ -0,0 +1,150 @@ + + */ + private array $tenants; + + /** + * @param int|string|null|list $tenant The selected tenant, a list of them for a + * query that spans tenants, or null when none + * is selected: a shared table then matches no + * tenant's rows rather than every tenant's + * @param string $metadataCollection The metadata collection name; metadata tables allow NULL tenants + * @param string $collection The actual collection/table name being queried (not the alias) + * @param string $allowNullColumn When set, rows where this column is NULL also pass: the rows an + * outer join produced without a main-table match. It must be a + * NOT NULL column such as `_uid`, never `_tenant`, or a stored row + * that has no tenant would pass as if it were missing + * @param string $quoteCharacter The adapter's identifier quote: tables are named quoted with it, as the + * builder declares them + * @param list $unindexed Join aliases no index serves the join of: an inner or left join matches + * their tenant as a range, so the engine cannot look their rows up by the + * tenant alone once per row it pairs them with + */ + public function __construct( + int|string|null|array $tenant, + private string $metadataCollection = '', + private string $collection = '', + private string $allowNullColumn = '', + private string $quoteCharacter = '`', + private array $unindexed = [], + ) { + if (! \is_array($tenant)) { + $tenant = [$tenant]; + } + + $this->tenants = $tenant === [] ? [null] : $tenant; + } + + private function placeholders(): string + { + return \implode(', ', \array_fill(0, \count($this->tenants), '?')); + } + + #[\Override] + public function filter(string $table): Condition + { + $prefix = (! \str_contains($table, '.') && ! \str_contains($table, $this->quoteCharacter)) + ? AllowNull::quote($table, $this->quoteCharacter).'.' + : ''; + + $name = $this->collection !== '' ? $this->collection : $table; + + // A metadata row may be tenantless -- a shared pool creates its system + // collections once, with no tenant, so every tenant on the pool reads + // the one definition. Its permission rows carry the document's tenant, + // so they are tenantless too, and the side table has to be recognised + // as metadata or a write holding a project's tenant filters them out: + // the rows are matched for neither read nor delete, and revoking a + // permission on a shared definition silently does nothing. + $isMetadata = ! empty($this->metadataCollection) + && ($name === $this->metadataCollection + || $name === Storage::permissionsTable($this->metadataCollection)); + + $placeholders = $this->placeholders(); + + if ($isMetadata) { + $condition = new Condition("({$prefix}".Storage::TENANT." IN ({$placeholders}) OR {$prefix}".Storage::TENANT." IS NULL)", $this->tenants); + } else { + $condition = new Condition("{$prefix}".Storage::TENANT." IN ({$placeholders})", $this->tenants); + } + + if ($this->allowNullColumn === '') { + return $condition; + } + + return AllowNull::wrap($condition, $this->allowNullColumn, $this->quoteCharacter); + } + + /** + * A condition in ON only limits what the joined table matches; one in WHERE runs after every + * join. When the query has a join that keeps unmatched rows - the main table is then relaxed + * through $allowNullColumn - a table filtered in WHERE may be missing from a row, and only a + * missing row may pass, never a stored row without a tenant: `_uid` is NOT NULL. + */ + #[\Override] + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + $placement = match ($joinType) { + JoinType::Left, JoinType::Inner => Placement::On, + default => Placement::Where, + }; + + $condition = $placement === Placement::On && \in_array($table, $this->unindexed, true) + ? $this->range($table) + : $this->joined($table); + + if ($placement === Placement::Where && ($joinType === JoinType::FullOuter || $this->allowNullColumn !== '')) { + $condition = AllowNull::wrap( + $condition, + $table.'.'.Storage::UID, + $this->quoteCharacter, + ); + } + + return new JoinCondition($condition, $placement); + } + + /** + * The tenant condition of a joined table, before an outer join places or relaxes it. + */ + public function joined(string $table): Condition + { + $column = AllowNull::quote($table, $this->quoteCharacter).'.'.Storage::TENANT; + + return new Condition("{$column} IN ({$this->placeholders()})", $this->tenants); + } + + /** + * MySQL turns an equality on a single tenant into a lookup on the leading `_tenant` of every index, and + * prefers it to reading the table once for a join no index serves, though it reads the tenant's whole + * table again for every row the join pairs. A range it reads once. + */ + private function range(string $table): Condition + { + if (\count($this->tenants) !== 1) { + return $this->joined($table); + } + + $column = AllowNull::quote($table, $this->quoteCharacter).'.'.Storage::TENANT; + + return new Condition("({$column} >= ? AND {$column} <= ?)", [$this->tenants[0], $this->tenants[0]]); + } +} diff --git a/src/Database/Adapter/SQL/Hook/Tenant/OuterJoin.php b/src/Database/Adapter/SQL/Hook/Tenant/OuterJoin.php new file mode 100644 index 0000000000..b146307cc3 --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Tenant/OuterJoin.php @@ -0,0 +1,50 @@ +filter->filter($this->source); + $joined = $this->filter->joined($table); + + return new JoinCondition( + new Condition( + $source->expression.' AND '.$joined->expression, + [...$source->bindings, ...$joined->bindings], + ), + Placement::On, + ); + } +} diff --git a/src/Database/Adapter/SQL/Hook/Tenant/Raw.php b/src/Database/Adapter/SQL/Hook/Tenant/Raw.php new file mode 100644 index 0000000000..8690bb092d --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Tenant/Raw.php @@ -0,0 +1,147 @@ + The statement's joins so far, by the name the builder hands join filters + */ + private array $joins = []; + + /** + * @param string $table The main table as the builder names it until the caller renames it + * @param bool $metadata Whether the main table holds definitions a shared pool keeps once, with + * no tenant, for every tenant to read + */ + public function __construct( + private readonly int|string|null $tenant, + private readonly string $table, + private readonly bool $metadata, + private readonly string $quoteCharacter, + ) { + } + + public function reset(): void + { + $this->joins = []; + } + + #[\Override] + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + $this->joins[$table] = $joinType; + + if ($joinType === JoinType::Inner || $joinType === JoinType::Left) { + return new JoinCondition($this->joined($table), Placement::On); + } + + return new JoinCondition($this->allowMissing($this->joined($table), $table), Placement::Where); + } + + /** + * @throws QueryException When the statement has no table, or renames the main table a right or full outer join pairs with + */ + #[\Override] + public function filter(string $table): Condition + { + $preserving = (new Chain($this->joins))->hasPreservingOuterJoin(); + $this->reset(); + + if ($table === '') { + throw new QueryException('A query builder statement without a table cannot be kept to the selected tenant under shared tables'); + } + + if ($preserving && $table !== $this->table) { + throw new QueryException("A right or full outer join needs the main table named '{$this->table}', as Database::from() names it, under shared tables"); + } + + $condition = $this->main($table); + + return $preserving ? $this->allowMissing($condition, $table) : $condition; + } + + /** + * What a right or full outer join needs in its ON: the main table's condition, its own, and those + * of the tables joined before it whose conditions sit in WHERE, each letting through rows an + * earlier outer join left without its table. The join pairs rows before WHERE runs, so a row + * whose only match lies in another tenant would be paired with it and then dropped: it would + * vanish instead of coming back unmatched, and what the tenant reads would depend on another + * tenant's rows. + */ + public function outerJoin(string $table, JoinType $joinType): Condition + { + $chain = new Chain($this->joins); + $earlier = []; + foreach ($chain->preceding($table) as $alias) { + $earlier[$alias] = $this->joined($alias); + } + + $conditions = [$this->allowMissing($this->main($this->table), $this->table), $this->joined($table)]; + $preceding = (new OuterChain($chain, $earlier, $this->quoteCharacter))->filterJoin($table, $joinType); + if ($preceding !== null) { + $conditions[] = $preceding->condition; + } + + $expressions = []; + $bindings = []; + foreach ($conditions as $condition) { + $expressions[] = $condition->expression; + \array_push($bindings, ...$condition->bindings); + } + + return new Condition(\implode(' AND ', $expressions), $bindings); + } + + private function main(string $table): Condition + { + $column = $this->column($table); + + if ($this->metadata) { + return new Condition("({$column} IN (?) OR {$column} IS NULL)", [$this->tenant]); + } + + return new Condition("{$column} IN (?)", [$this->tenant]); + } + + private function joined(string $table): Condition + { + return new Condition("{$this->column($table)} IN (?)", [$this->tenant]); + } + + private function allowMissing(Condition $condition, string $table): Condition + { + return AllowNull::wrap($condition, $table.'.'.Storage::UID, $this->quoteCharacter); + } + + private function column(string $table): string + { + return AllowNull::quote($table, $this->quoteCharacter).'.'.Storage::TENANT; + } +} diff --git a/src/Database/Adapter/SQL/Hook/Tenant/RawOuterJoin.php b/src/Database/Adapter/SQL/Hook/Tenant/RawOuterJoin.php new file mode 100644 index 0000000000..05edf7a400 --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/Tenant/RawOuterJoin.php @@ -0,0 +1,32 @@ +filter->outerJoin($table, $joinType), Placement::On); + } +} diff --git a/src/Database/Adapter/SQL/Hook/WriteContext.php b/src/Database/Adapter/SQL/Hook/WriteContext.php new file mode 100644 index 0000000000..7d9c7ac659 --- /dev/null +++ b/src/Database/Adapter/SQL/Hook/WriteContext.php @@ -0,0 +1,89 @@ +, Document): array $decorateRow + * @param array $skipPermissions Ids of the documents whose permissions the write keeps + */ + public function __construct( + private Closure $builder, + private Closure $rawTable, + private Closure $prepare, + private Closure $execute, + private Closure $decorateRow, + private bool $ignoreDuplicates, + private array $skipPermissions = [], + ) { + } + + #[\Override] + public function builder(): Builder&Scoping + { + return ($this->builder)(); + } + + #[\Override] + public function rawTable(string $table): string + { + return ($this->rawTable)($table); + } + + #[\Override] + public function run(Statement $statement, Event $event): bool + { + return ($this->execute)(($this->prepare)($statement, $event)); + } + + #[\Override] + public function fetch(Statement $statement, Event $event): array + { + $prepared = ($this->prepare)($statement, $event); + ($this->execute)($prepared); + /** @var list> $rows */ + $rows = $prepared->fetchAll(); + $prepared->closeCursor(); + + return $rows; + } + + #[\Override] + public function decorateRow(array $row, Document $document): array + { + return ($this->decorateRow)($row, $document); + } + + #[\Override] + public function skipPermissions(Document $document): bool + { + return isset($this->skipPermissions[$document->getId()]); + } + + #[\Override] + public function ignoreDuplicates(): bool + { + return $this->ignoreDuplicates; + } +} diff --git a/src/Database/Adapter/SQL/JoinAlias.php b/src/Database/Adapter/SQL/JoinAlias.php new file mode 100644 index 0000000000..b2d6552725 --- /dev/null +++ b/src/Database/Adapter/SQL/JoinAlias.php @@ -0,0 +1,12 @@ + $tenants The selected tenant, or the tenants a read spans + * @param bool $allowNullTenant Whether rows an outer join produced without a main-table match pass + * @param list $unindexed The read's join aliases unindexedJoins() names + */ + public function __construct( + private Closure $table, + private AttributeMap $attributes, + private bool $sharedTables, + private int|string|null|array $tenants, + private string $quoteCharacter, + private bool $allowNullTenant = false, + private array $unindexed = [], + ) { + } + + #[\Override] + public function table(string $collection): string + { + return ($this->table)($collection); + } + + /** + * The adapter's reads join tables by the names they are stored under. + */ + #[\Override] + public function joinTable(string $table): string + { + return $table; + } + + #[\Override] + public function bind(Builder $builder, string $collection, string $table, string $alias): array + { + $builder->addHook($this->attributes); + + if (! $this->sharedTables) { + return []; + } + + $source = $alias !== '' ? $alias : $collection; + $filter = new Tenant\Filter( + $this->tenants, + Database::METADATA, + $collection, + $this->allowNullTenant ? $source.'.'.Storage::UID : '', + $this->quoteCharacter, + $this->unindexed, + ); + + $builder + ->addHook($filter) + ->addHook(new Tenant\OuterJoin($filter, $source)); + + return []; + } +} diff --git a/src/Database/Adapter/SQL/Scope/Raw.php b/src/Database/Adapter/SQL/Scope/Raw.php new file mode 100644 index 0000000000..a3e66c1570 --- /dev/null +++ b/src/Database/Adapter/SQL/Scope/Raw.php @@ -0,0 +1,69 @@ +table)($collection); + } + + #[\Override] + public function joinTable(string $table): string + { + return $this->table($table); + } + + #[\Override] + public function bind(Builder $builder, string $collection, string $table, string $alias): array + { + $builder->addHook($this->attributes); + + if (! $this->sharedTables) { + return []; + } + + $tenants = new Tenant\Raw( + $this->tenant, + $alias !== '' ? $alias : $table, + $table === $this->table(Database::METADATA) || $table === $this->table(Storage::permissionsTable(Database::METADATA)), + $this->quoteCharacter, + ); + + $builder + ->addHook($tenants) + ->addHook(new Tenant\RawOuterJoin($tenants)); + + return [$tenants->reset(...)]; + } +} diff --git a/src/Database/Adapter/SQL/Wkt.php b/src/Database/Adapter/SQL/Wkt.php new file mode 100644 index 0000000000..2afd71f8d0 --- /dev/null +++ b/src/Database/Adapter/SQL/Wkt.php @@ -0,0 +1,75 @@ +value); + if (! $validator->isValid($value)) { + throw new StructureException($validator->getDescription()); + } + + /** @var array $value */ + switch ($type) { + case ColumnType::Point: + /** @var array{0: float|int, 1: float|int} $value */ + return "POINT({$value[0]} {$value[1]})"; + + case ColumnType::Linestring: + /** @var array $value */ + return 'LINESTRING('.self::points($value).')'; + + case ColumnType::Polygon: + $singleRing = \is_array($value[0] ?? null) + && \count($value[0]) === 2 + && \is_numeric($value[0][0] ?? null) + && \is_numeric($value[0][1] ?? null); + + if ($singleRing) { + $value = [$value]; + } + + $rings = []; + /** @var array> $value */ + foreach ($value as $ring) { + $rings[] = '('.self::points($ring).')'; + } + + return 'POLYGON('.\implode(', ', $rings).')'; + + default: + throw new DatabaseException('Unknown spatial type: '.$type->value); + } + } + + /** + * @param array $points + */ + private static function points(array $points): string + { + $coordinates = []; + foreach ($points as $point) { + $coordinates[] = "{$point[0]} {$point[1]}"; + } + + return \implode(', ', $coordinates); + } +} diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 8e1e358299..f198125b20 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -3,22 +3,56 @@ namespace Utopia\Database\Adapter; use Exception; +use Override; use PDO; use PDOException; +use PDOStatement; +use Swoole\Database\PDOProxy; +use Swoole\Database\PDOStatementProxy; +use Throwable; +use Utopia\Database\Adapter\SQL\Expression; +use Utopia\Database\Adapter\SQL\Hook\Permission; +use Utopia\Database\Adapter\SQL\JoinAlias; +use Utopia\Database\Attribute; +use Utopia\Database\Builder\Scoping; +use Utopia\Database\Builder\SQLite as SQLiteBuilder; +use Utopia\Database\Capability; +use Utopia\Database\Change; use Utopia\Database\Database; +use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Contention as ContentionException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Operator as OperatorException; -use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\ID; +use Utopia\Database\Index; use Utopia\Database\Operator; +use Utopia\Database\OperatorType; +use Utopia\Database\PDO as DatabasePDO; +use Utopia\Database\PDOStatement as DatabasePDOStatement; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Schema\Column as SchemaColumn; +use Utopia\Database\Schema\Index as SchemaIndex; +use Utopia\Database\Storage; +use Utopia\Database\Validator\BigInt; +use Utopia\Query\Builder\SQL as SQLBuilder; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\Query as BaseQuery; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; /** * Main differences from MariaDB and MySQL: @@ -34,23 +68,53 @@ * 9. MODIFY COLUMN is not supported * 10. Can't rename an index directly */ -class SQLite extends MariaDB +class SQLite extends SQL { /** Suffix appended to every FTS5 virtual table name created by this adapter. */ - private const FTS_TABLE_SUFFIX = '_fts'; + private const string FTS_TABLE_SUFFIX = '_fts'; /** AFTER INSERT trigger suffix on the parent collection. */ - private const FTS_TRIGGER_INSERT = 'ai'; + private const string FTS_TRIGGER_INSERT = 'ai'; /** AFTER DELETE trigger suffix on the parent collection. */ - private const FTS_TRIGGER_DELETE = 'ad'; + private const string FTS_TRIGGER_DELETE = 'ad'; /** AFTER UPDATE trigger suffix on the parent collection. */ - private const FTS_TRIGGER_UPDATE = 'au'; + private const string FTS_TRIGGER_UPDATE = 'au'; + + private const string INDEX_1 = '_index_1'; + + private const string INDEX_2 = '_index_2'; + + /** + * Reject patterns over this size to bound ReDoS exposure — the UDF runs + * once per candidate row, so a pathological pattern is amplified by + * table cardinality. + */ + private const int REGEXP_MAX_PATTERN_LENGTH = 512; /** - * Per-collection attribute → FTS5 table memo. Populated in one pass - * so multi-attribute SEARCH batches don't issue PRAGMA per attribute. + * Cap on cached delimited patterns. Long-lived adapters processing many + * distinct user patterns would otherwise grow this map without bound. + */ + private const int REGEXP_PATTERN_CACHE_LIMIT = 256; + + private const array MISSING_AGGREGATES = [ + Method::Stddev, + Method::StddevPop, + Method::StddevSamp, + Method::Variance, + Method::VarPop, + Method::VarSamp, + Method::BitAnd, + Method::BitOr, + Method::BitXor, + ]; + + /** + * Attribute → FTS5 table memo per FTS table prefix, which names the + * tenant under sharedTables. Populated in one pass so multi-attribute + * SEARCH batches don't issue PRAGMA per attribute. * * @var array> */ @@ -58,56 +122,101 @@ class SQLite extends MariaDB /** * When enabled, the adapter reports MariaDB-shaped column metadata, - * advertises MariaDB-only capabilities (upserts, attribute resizing, - * PCRE regex via the registered UDF), and declares schema-internal + * advertises MariaDB-only capabilities (attribute resizing), and declares schema-internal * columns (e.g. `_tenant`) using MariaDB-style types so callers that * inspect INFORMATION_SCHEMA-style results behave identically across * both adapters. Off by default — vanilla SQLite stays vanilla. */ protected bool $emulateMySQL = false; - /** - * Whether the REGEXP UDF actually wired up. Pool/proxy PDOs may not - * expose sqliteCreateFunction. - */ - private bool $pcreRegistered = false; - - public function __construct(mixed $pdo) + public function __construct(object $pdo) { parent::__construct($pdo); $this->registerUserFunctions(); } + #[Override] + public function reconnect(): void + { + parent::reconnect(); + + $this->registerUserFunctions(); + } + /** - * Toggle MariaDB/MySQL emulation. See $emulateMySQL for what this - * actually changes. + * SQLite has no server-side connection id, so the handle's object id names the connection: unique only within + * the process and only while the handle lives. */ - public function setEmulateMySQL(bool $emulate): static + #[\Override] + public function id(): string { - $this->emulateMySQL = $emulate; + return (string) \spl_object_id($this->getDriver()); + } - return $this; + /** + * Prepare a statement and reject drivers that return false or another + * non-statement value before a method is called on it. + * + * @return PDOStatement|DatabasePDOStatement|PDOStatementProxy + */ + private function prepare( + string $query, + string $message = 'Failed to prepare SQLite statement', + ?Event $event = null, + ): object { + try { + return parent::prepareStatement($query, $event); + } catch (DatabaseException $error) { + if ($error->getMessage() !== 'Failed to prepare SQL statement') { + throw $error; + } + + throw new DatabaseException($message, $error->getCode(), $error); + } } - public function getEmulateMySQL(): bool + /** + * @return array + */ + #[\Override] + public function capabilities(): array { - return $this->emulateMySQL; + $remove = [ + Capability::Schemas, + Capability::UpdateLock, + Capability::UpsertOnUniqueIndex, + ]; + + if (! $this->emulateMySQL) { + $remove[] = Capability::AttributeResizing; + } + + return array_merge( + array_values(array_filter( + parent::capabilities(), + fn (Capability $c) => ! in_array($c, $remove, true) + )), + [ + Capability::IntegerBooleans, + Capability::SchemaIntrospection, + ] + ); } - public function setTenant(int|string|null $tenant): bool + #[\Override] + public function setTenant(int|string|null $tenant): static { - $changed = $this->tenant !== $tenant; - $result = parent::setTenant($tenant); + $changed = $this->currentTenant() !== $tenant; + parent::setTenant($tenant); if ($changed) { - // Invalidate after the parent setter so a validation failure - // doesn't leave us with a cleared cache against the prior tenant. $this->ftsTableCache = []; } - return $result; + return $this; } + #[\Override] public function setNamespace(string $namespace): static { // Invalidate after the parent setter so a thrown validation @@ -118,29 +227,30 @@ public function setNamespace(string $namespace): static return $this; } - public function setSharedTables(bool $sharedTables): bool + #[\Override] + public function setSharedTables(bool $sharedTables): static { $changed = $this->sharedTables !== $sharedTables; - $result = parent::setSharedTables($sharedTables); + parent::setSharedTables($sharedTables); if ($changed) { $this->ftsTableCache = []; } - return $result; + return $this; } - /** - * Reject patterns over this size to bound ReDoS exposure — the UDF runs - * once per candidate row, so a pathological pattern is amplified by - * table cardinality. - */ - private const REGEXP_MAX_PATTERN_LENGTH = 512; + #[Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array + { + foreach ($queries as $query) { + $method = $query->getMethod(); + if (\in_array($method, self::MISSING_AGGREGATES, true)) { + throw new QueryException('Aggregate '.$method->value.' is not supported by this adapter'); + } + } - /** - * Cap on cached delimited patterns. Long-lived adapters processing many - * distinct user patterns would otherwise grow this map without bound. - */ - private const REGEXP_PATTERN_CACHE_LIMIT = 256; + return parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); + } /** * Register a preg_match-backed REGEXP UDF so the inherited REGEXP @@ -180,15 +290,27 @@ private function registerUserFunctions(): void }; try { - $this->getPDO()->sqliteCreateFunction('REGEXP', $pcre, 2); - $this->pcreRegistered = true; + $pdo = $this->getDriver(); + + if ($pdo instanceof DatabasePDO) { + $pdo->__call('createFunction', ['REGEXP', $pcre, 2]); + } elseif (\method_exists($pdo, 'createFunction')) { + $pdo->createFunction('REGEXP', $pcre, 2); + } } catch (\Throwable) { } } /** - * @inheritDoc - * + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement + */ + #[\Override] + protected function execute(mixed $statement, ?Event $event = null): bool + { + return $this->executeAndProfile($statement); + } + + /** * SQLite serialises writers through a single file lock. PDO's default * `BEGIN` is `DEFERRED`, which acquires the writer lock lazily on the * first write — if two transactions both started as readers and try to @@ -197,29 +319,30 @@ private function registerUserFunctions(): void * `BEGIN IMMEDIATE` reserves the writer slot up-front so concurrent * writers queue behind it under busy_timeout instead. */ + #[\Override] public function startTransaction(): bool { try { if ($this->inTransaction === 0) { - if ($this->getPDO()->inTransaction()) { - $this->getPDO() + if ($this->getDriver()->inTransaction()) { + $this ->prepare('ROLLBACK') ->execute(); } - $result = $this->getPDO() + $result = $this ->prepare('BEGIN IMMEDIATE') ->execute(); } else { - $result = $this->getPDO() - ->prepare('SAVEPOINT transaction' . $this->inTransaction) + $result = $this + ->prepare('SAVEPOINT transaction'.$this->inTransaction) ->execute(); } } catch (PDOException $e) { - throw new TransactionException('Failed to start transaction: ' . $e->getMessage(), $e->getCode(), $e); + throw new TransactionException('Failed to start transaction: '.$e->getMessage(), $e->getCode(), $e); } - if (!$result) { + if (! $result) { throw new TransactionException('Failed to start transaction'); } @@ -229,14 +352,13 @@ public function startTransaction(): bool } /** - * @inheritDoc - * * Overrides the inherited PDO-driven commit because startTransaction * issues a raw `BEGIN IMMEDIATE` (rather than PDO::beginTransaction), * so PDO's internal in-transaction flag is never set and PDO::commit() * would throw "no active transaction". Mirrors that with a raw COMMIT * and SAVEPOINT release for nested levels. */ + #[\Override] public function commitTransaction(): bool { if ($this->inTransaction === 0) { @@ -245,14 +367,14 @@ public function commitTransaction(): bool try { if ($this->inTransaction > 1) { - $result = $this->getPDO() + $result = $this ->prepare('RELEASE SAVEPOINT transaction' . ($this->inTransaction - 1)) ->execute(); $this->inTransaction--; return $result; } - $result = $this->getPDO() + $result = $this ->prepare('COMMIT') ->execute(); $this->inTransaction = 0; @@ -264,11 +386,10 @@ public function commitTransaction(): bool } /** - * @inheritDoc - * * Counterpart to commitTransaction — uses a raw ROLLBACK for the same * reason (raw BEGIN IMMEDIATE bypasses PDO's transaction tracking). */ + #[\Override] public function rollbackTransaction(): bool { if ($this->inTransaction === 0) { @@ -277,12 +398,12 @@ public function rollbackTransaction(): bool try { if ($this->inTransaction > 1) { - $this->getPDO() + $this ->prepare('ROLLBACK TO transaction' . ($this->inTransaction - 1)) ->execute(); $this->inTransaction--; } else { - $this->getPDO() + $this ->prepare('ROLLBACK') ->execute(); $this->inTransaction = 0; @@ -296,207 +417,216 @@ public function rollbackTransaction(): bool } /** - * Check if Database exists - * Optionally check if collection exists in Database - * - * @param string $database - * @param string|null $collection - * @return bool - * @throws DatabaseException + * @throws Exception + * @throws PDOException + */ + #[\Override] + public function create(string $name): bool + { + return true; + } + + /** + * SQLite keeps no database name in storage, so no database is reported to exist, unlike every other adapter: + * the tables of the file are reached under any name. Ask collectionExists() for what a database holds. */ - public function exists(string $database, ?string $collection = null): bool + #[Override] + public function exists(string $database): bool { - $database = $this->filter($database); + return false; + } - if (\is_null($collection)) { - return false; - } + /** + * SQLite keeps no database name in storage: every name addresses the same tables, so a rename moves nothing + * and succeeds without checking either name, unlike every other adapter, which refuses a missing source or an + * existing target. + */ + #[\Override] + public function update(string $name, string $new): bool + { + return true; + } + /** + * @throws DatabaseException + */ + #[Override] + public function collectionExists(string $database, string $collection): bool + { $collection = $this->filter($collection); $sql = " - SELECT name FROM sqlite_master + SELECT name FROM sqlite_master WHERE type='table' AND name = :table "; - $sql = $this->trigger(Database::EVENT_DATABASE_CREATE, $sql); + $statement = $this->prepare($sql, 'Failed to prepare collection existence query', Event::CollectionRead); - $stmt = $this->getPDO()->prepare($sql); + $statement->bindValue(':table', "{$this->getNamespace()}_{$collection}", PDO::PARAM_STR); - $stmt->bindValue(':table', "{$this->getNamespace()}_{$collection}", PDO::PARAM_STR); + $this->execute($statement); - $stmt->execute(); + $document = $statement->fetchAll(); + $statement->closeCursor(); + if (! empty($document)) { + /** @var array $firstDoc */ + $firstDoc = $document[0]; + $docName = $firstDoc['name'] ?? ''; - $document = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($document)) { - $document = $document[0]; + return (\is_string($docName) ? $docName : '') === "{$this->getNamespace()}_{$collection}"; } - return (($document['name'] ?? '') === "{$this->getNamespace()}_{$collection}"); - } - - /** - * Create Database - * - * @param string $name - * @return bool - * @throws Exception - * @throws PDOException - */ - public function create(string $name): bool - { - return true; + return false; } /** - * Delete Database - * - * @param string $name - * @return bool * @throws Exception * @throws PDOException */ + #[\Override] public function delete(string $name): bool { return true; } /** - * Create Collection + * @param list $attributes + * @param list $indexes * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool * @throws Exception * @throws PDOException */ - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool { - $id = $this->filter($name); + $id = $this->filter($collection); /** @var array $attributeStrings */ $attributeStrings = []; foreach ($attributes as $key => $attribute) { - $attrId = $this->filter($attribute->getId()); - - $attrType = $this->getSQLType( - $attribute->getAttribute('type'), - $attribute->getAttribute('size', 0), - $attribute->getAttribute('signed', true), - $attribute->getAttribute('array', false), - $attribute->getAttribute('required', false) - ); - - $attributeStrings[$key] = "`{$attrId}` {$attrType}, "; + $attributeStrings[$key] = '`'.$this->filter($attribute->key).'` '.$this->getAttributeSqlType($attribute).', '; } - // SQLite stores integers regardless of declared type, but - // testSchemaAttributes asserts the columnType reads back as - // `int(11) unsigned` to match MariaDB. Quote the declaration so - // PRAGMA table_info echoes the exact string under emulation; - // otherwise use INTEGER, the affinity-correct vanilla form. + // Under MySQL emulation the tenant column reads back with the type MariaDB reports: SQLite keeps a quoted + // declaration verbatim, where INTEGER is the affinity-correct form otherwise. $tenantType = $this->emulateMySQL ? '"INT(11) UNSIGNED"' : 'INTEGER'; - $tenantQuery = $this->sharedTables ? "`_tenant` {$tenantType} DEFAULT NULL," : ''; + $tenantQuery = $this->sharedTables ? "{$this->quote(Storage::TENANT)} {$tenantType} DEFAULT NULL," : ''; - $collection = " - CREATE TABLE {$this->getSQLTable($id)} ( - `_id` INTEGER PRIMARY KEY AUTOINCREMENT, - `_uid` VARCHAR(36) NOT NULL, + $table = " + CREATE TABLE {$this->getTable($id)} ( + {$this->quote(Storage::SEQUENCE)} INTEGER PRIMARY KEY AUTOINCREMENT, + {$this->quote(Storage::UID)} VARCHAR(36) NOT NULL, {$tenantQuery} - `_createdAt` DATETIME(3) DEFAULT NULL, - `_updatedAt` DATETIME(3) DEFAULT NULL, - `_permissions` MEDIUMTEXT DEFAULT NULL".(!empty($attributes) ? ',' : '')." - " . \substr(\implode(' ', $attributeStrings), 0, -2) . " + {$this->quote(Storage::CREATED_AT)} DATETIME(3) DEFAULT NULL, + {$this->quote(Storage::UPDATED_AT)} DATETIME(3) DEFAULT NULL, + {$this->quote(Storage::PERMISSIONS)} MEDIUMTEXT DEFAULT NULL".(! empty($attributes) ? ',' : '').' + '.\substr(\implode(' ', $attributeStrings), 0, -2).' ) - "; - - $collection = $this->trigger(Database::EVENT_COLLECTION_CREATE, $collection); + '; $permissions = " - CREATE TABLE {$this->getSQLTable($id . '_perms')} ( - `_id` INTEGER PRIMARY KEY AUTOINCREMENT, + CREATE TABLE {$this->getTable(Storage::permissionsTable($id))} ( + {$this->quote(Storage::SEQUENCE)} INTEGER PRIMARY KEY AUTOINCREMENT, {$tenantQuery} - `_type` VARCHAR(12) NOT NULL, - `_permission` VARCHAR(255) NOT NULL, - `_document` VARCHAR(255) NOT NULL + {$this->quote(Storage::PERMISSIONS_TYPE)} VARCHAR(12) NOT NULL, + {$this->quote(Storage::PERMISSIONS_PERMISSION)} VARCHAR(255) NOT NULL, + {$this->quote(Storage::PERMISSIONS_DOCUMENT)} VARCHAR(255) NOT NULL ) "; - $permissions = $this->trigger(Database::EVENT_COLLECTION_CREATE, $permissions); + $created = false; try { - $this->getPDO() - ->prepare($collection) - ->execute(); + $this->execute($this->prepare($table, event: Event::CollectionCreate)); + $created = true; - $this->getPDO() - ->prepare($permissions) - ->execute(); + $this->execute($this->prepare($permissions, event: Event::CollectionCreate)); - $this->createIndex($id, '_index1', Database::INDEX_UNIQUE, ['_uid'], [], []); - $this->createIndex($id, '_created_at', Database::INDEX_KEY, [ '_createdAt'], [], []); - $this->createIndex($id, '_updated_at', Database::INDEX_KEY, [ '_updatedAt'], [], []); + $this->createIndex($id, Index::unique(key: Storage::INDEX_1, attributes: [Storage::UID]), event: Event::CollectionCreate); + $this->createIndex($id, Index::key(key: Storage::INDEX_CREATED_AT, attributes: [Storage::CREATED_AT]), event: Event::CollectionCreate); + $this->createIndex($id, Index::key(key: Storage::INDEX_UPDATED_AT, attributes: [Storage::UPDATED_AT]), event: Event::CollectionCreate); - $this->createIndex("{$id}_perms", '_index_1', Database::INDEX_UNIQUE, ['_document', '_type', '_permission'], [], []); - $this->createIndex("{$id}_perms", '_index_2', Database::INDEX_KEY, ['_permission', '_type'], [], []); + $this->createIndex(Storage::permissionsTable($id), Index::unique(key: self::INDEX_1, attributes: [Storage::PERMISSIONS_DOCUMENT, Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION]), event: Event::CollectionCreate); + $this->createIndex(Storage::permissionsTable($id), Index::key(key: self::INDEX_2, attributes: [Storage::PERMISSIONS_PERMISSION, Storage::PERMISSIONS_TYPE]), event: Event::CollectionCreate); if ($this->sharedTables) { - $this->createIndex($id, '_tenant_id', Database::INDEX_KEY, [ '_id'], [], []); + $this->createIndex($id, Index::key(key: Storage::INDEX_TENANT_ID, attributes: [Storage::SEQUENCE]), event: Event::CollectionCreate); } foreach ($indexes as $index) { - $indexId = $this->filter($index->getId()); - $indexType = $index->getAttribute('type'); - $indexAttributes = $index->getAttribute('attributes', []); - $indexLengths = $index->getAttribute('lengths', []); - $indexOrders = $index->getAttribute('orders', []); - $indexTtl = $index->getAttribute('ttl', 0); + $this->createIndex($id, $index->withKey($this->filter($index->key)), event: Event::CollectionCreate); + } + } catch (Throwable $error) { + if ($error instanceof PDOException) { + $error = $this->processException($error); + } + + if ($created && ! $error instanceof DuplicateException) { + $this->discardCreatedCollection($id); + } + + throw $error; + } + + return true; + } + + /** + * Record planner statistics (sqlite_stat1) for a collection's table and its permissions table. + * + * @throws DatabaseException + */ + #[\Override] + public function analyzeCollection(string $collection): bool + { + $name = $this->filter($collection); - $this->createIndex($id, $indexId, $indexType, $indexAttributes, $indexLengths, $indexOrders, [], [], $indexTtl); + try { + foreach ([$name, Storage::permissionsTable($name)] as $table) { + $this->executeStatement('ANALYZE '.$this->getTable($table), Event::CollectionUpdate); } } catch (PDOException $e) { throw $this->processException($e); } + return true; } - /** - * Get Collection Size of raw data - * @param string $collection - * @return int * @throws DatabaseException - * */ + #[\Override] public function getSizeOfCollection(string $collection): int { $collection = $this->filter($collection); $namespace = $this->getNamespace(); $name = $namespace . '_' . $collection; - $permissions = $namespace . '_' . $collection . '_perms'; + $permissions = $namespace . '_' . Storage::permissionsTable($collection); $ftsPrefix = $this->getFulltextTablePrefix($collection); // FTS5 storage lives in `_data|_idx|_docsize|_config` // shadow tables; sum (pgsize - unused) over all of them. $ftsPattern = $this->escapeLikePattern($ftsPrefix) . '%' . $this->escapeLikePattern(self::FTS_TABLE_SUFFIX) . '%'; - $stmt = $this->getPDO()->prepare(" + $statement = $this->prepare(" SELECT COALESCE(SUM(\"pgsize\" - \"unused\"), 0) FROM \"dbstat\" WHERE name = :name OR name = :perms OR name LIKE :fts_pattern ESCAPE '\\'; - "); + ", event: Event::CollectionRead); - $stmt->bindParam(':name', $name); - $stmt->bindParam(':perms', $permissions); - $stmt->bindParam(':fts_pattern', $ftsPattern); + $statement->bindParam(':name', $name); + $statement->bindParam(':perms', $permissions); + $statement->bindParam(':fts_pattern', $ftsPattern); try { - $stmt->execute(); - $size = (int) $stmt->fetchColumn(); - $stmt->closeCursor(); + $this->execute($statement); + $result = $statement->fetchColumn(); + if (! \is_int($result) && (! \is_string($result) || ! \is_numeric($result))) { + throw new DatabaseException('Failed to get collection size: invalid database result'); + } + $size = (int) $result; + $statement->closeCursor(); } catch (PDOException $e) { throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); } @@ -505,83 +635,57 @@ public function getSizeOfCollection(string $collection): int } /** - * Get Collection Size on disk - * @param string $collection - * @return int * @throws DatabaseException */ + #[\Override] public function getSizeOfCollectionOnDisk(string $collection): int { return $this->getSizeOfCollection($collection); } + #[Override] + protected function dropCreatedCollection(string $id): void + { + $this->deleteCollection($id); + } + /** - * Delete Collection - * @param string $id - * @return bool * @throws Exception * @throws PDOException */ - public function deleteCollection(string $id): bool + #[\Override] + public function deleteCollection(string $collection): bool { - $id = $this->filter($id); + $id = $this->filter($collection); // FTS5 shadow tables don't drop with the parent. foreach ($this->findFulltextTables($id) as $ftsTable) { $sql = "DROP TABLE IF EXISTS `{$ftsTable}`"; - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); - $this->getPDO()->prepare($sql)->execute(); + $this->execute($this->prepare($sql, event: Event::CollectionDelete)); } - $sql = "DROP TABLE IF EXISTS {$this->getSQLTable($id)}"; - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); + $sql = "DROP TABLE IF EXISTS {$this->getTable($id)}"; - $this->getPDO() - ->prepare($sql) - ->execute(); + $this->execute($this->prepare($sql, event: Event::CollectionDelete)); - $sql = "DROP TABLE IF EXISTS {$this->getSQLTable($id . '_perms')}"; - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); + $sql = "DROP TABLE IF EXISTS {$this->getTable(Storage::permissionsTable($id))}"; - $this->getPDO() - ->prepare($sql) - ->execute(); + $this->execute($this->prepare($sql, event: Event::CollectionDelete)); - unset($this->ftsTableCache[$id]); + unset($this->ftsTableCache[$this->getFulltextTablePrefix($id)]); return true; } /** - * Analyze a collection updating it's metadata on the database engine - * - * @param string $collection - * @return bool - */ - public function analyzeCollection(string $collection): bool - { - return false; - } - - /** - * Update Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @param string|null $newKey - * @param bool $required - * @return bool * @throws Exception * @throws PDOException */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool { - if (!empty($newKey) && $newKey !== $id) { - return $this->renameAttribute($collection, $id, $newKey); + if ($attribute->key !== $key) { + return $this->renameAttribute($collection, $key, $attribute->key); } // SQLite is dynamically typed — `ALTER TABLE ... MODIFY COLUMN` is @@ -590,32 +694,33 @@ public function updateAttribute(string $collection, string $id, string $type, in // raise the same TruncateException MariaDB throws. Off- // emulation the declared size is metadata-only, so skip the // scan and let the rename branch (if any) handle the rest. - if ($this->emulateMySQL && $type === Database::VAR_STRING && $size > 0 && !$array) { + $size = $attribute->size ?? 0; + if ($this->emulateMySQL && $attribute->type === ColumnType::String && $size > 0 && ! $attribute->array) { $name = $this->filter($collection); - $column = $this->filter($id); + $column = $this->filter($attribute->key); // Under shared tables the underlying table is shared across // tenants; scoping the scan by `_tenant` keeps tenant A's // resize from being blocked (and tenant A's metadata from // leaking) by an oversized value owned by tenant B. - $tenantClause = $this->sharedTables ? ' AND `_tenant` = :_tenant' : ''; - $sql = "SELECT 1 FROM {$this->getSQLTable($name)} WHERE LENGTH(`{$column}`) > :max{$tenantClause} LIMIT 1"; + $tenantClause = $this->sharedTables ? ' AND '.$this->quote(Storage::TENANT).' = :'.Storage::TENANT : ''; + $sql = "SELECT 1 FROM {$this->getTable($name)} WHERE LENGTH(`{$column}`) > :max{$tenantClause} LIMIT 1"; - $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':max', $size, PDO::PARAM_INT); + $statement = $this->prepare($sql, event: Event::AttributeUpdate); + $statement->bindValue(':max', $size, PDO::PARAM_INT); if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant, \is_int($this->tenant) ? PDO::PARAM_INT : PDO::PARAM_STR); + $statement->bindValue(':'.Storage::TENANT, $this->currentTenant(), \is_int($this->currentTenant()) ? PDO::PARAM_INT : PDO::PARAM_STR); } try { - $stmt->execute(); - $exceeds = $stmt->fetchColumn() !== false; + $this->execute($statement); + $exceeds = $statement->fetchColumn() !== false; } finally { - $stmt->closeCursor(); + $statement->closeCursor(); } if ($exceeds) { - throw new TruncateException("Attribute '{$id}' has values exceeding new size {$size}"); + throw new TruncateException("Attribute '{$attribute->key}' has values exceeding new size {$size}"); } } @@ -623,49 +728,40 @@ public function updateAttribute(string $collection, string $id, string $type, in } /** - * Delete Attribute - * - * @param string $collection - * @param string $id - * @param bool $array - * @return bool * @throws Exception * @throws PDOException */ - public function deleteAttribute(string $collection, string $id, bool $array = false): bool + #[\Override] + public function deleteAttribute(string $collection, string $key): bool { $name = $this->filter($collection); - $id = $this->filter($id); - $metadataCollection = new Document(['$id' => Database::METADATA]); + $id = $this->filter($key); + $metadataCollection = new Document([Document::ID => Database::METADATA]); $collection = $this->getDocument($metadataCollection, $name); if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); } - $indexes = $collection->getAttribute('indexes', []); - if (\is_string($indexes)) { - $indexes = \json_decode($indexes, true) ?? []; - } - - foreach ($indexes as $index) { - $attributes = $index['attributes']; - if ($attributes === [$id]) { - $this->deleteIndex($name, $index['$id']); - } elseif (\in_array($id, $attributes)) { - $this->deleteIndex($name, $index['$id']); - $this->createIndex($name, $index['$id'], $index['type'], \array_diff($attributes, [$id]), $index['lengths'], $index['orders']); + foreach (self::collectionIndexes($collection) as $index) { + if ($index->attributes === [$id]) { + $this->deleteIndex($name, $index->key, Event::AttributeDelete); + } elseif (\in_array($id, $index->attributes, true)) { + $this->deleteIndex($name, $index->key, Event::AttributeDelete); + $this->createIndex($name, Index::fromArray([ + 'key' => $index->key, + 'type' => $index->type, + 'attributes' => \array_values(\array_filter($index->attributes, fn (string $attribute): bool => $attribute !== $id)), + 'lengths' => $index->lengths, + 'orders' => $index->orders, + ]), event: Event::AttributeDelete); } } - $sql = "ALTER TABLE {$this->getSQLTable($name)} DROP COLUMN `{$id}`"; - - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); + $sql = "ALTER TABLE {$this->getTable($name)} DROP COLUMN `{$id}`"; try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->execute($this->prepare($sql, event: Event::AttributeDelete)); } catch (PDOException $e) { if (str_contains($e->getMessage(), 'no such column')) { return true; @@ -676,101 +772,48 @@ public function deleteAttribute(string $collection, string $id, bool $array = fa } /** - * Rename Index - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool - * @throws Exception - * @throws PDOException - */ - public function renameIndex(string $collection, string $old, string $new): bool - { - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $old = $this->filter($old); - $new = $this->filter($new); - $indexes = $collection->getAttribute('indexes', []); - if (\is_string($indexes)) { - $indexes = \json_decode($indexes, true) ?? []; - } - $index = null; - - foreach ($indexes as $node) { - if ($node['key'] === $old) { - $index = $node; - break; - } - } - - if ($index - && $this->deleteIndex($collection->getId(), $old) - && $this->createIndex( - $collection->getId(), - $new, - $index['type'], - $index['attributes'], - $index['lengths'], - $index['orders'], - )) { - return true; - } - - return false; - } - - /** - * Create Index + * @param array $indexAttributeTypes + * @param array $collation * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - * @return bool * @throws Exception * @throws PDOException */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool - { + #[\Override] + public function createIndex( + string $collection, + Index $index, + array $indexAttributeTypes = [], + array $collation = [], + Event $event = Event::IndexCreate, + ): bool { $name = $this->filter($collection); - $id = $this->filter($id); + $id = $this->filter($index->key); + $type = $index->type; + $attributes = $index->attributes; - if ($type === Database::INDEX_FULLTEXT) { - return $this->createFulltextIndex($name, $id, $attributes); + if ($type === IndexType::Fulltext) { + return $this->createFulltextIndex($name, $id, $attributes, $event); } // Workaround for no support for CREATE INDEX IF NOT EXISTS - $stmt = $this->getPDO()->prepare(" + $statement = $this->prepare(" SELECT name FROM sqlite_master WHERE type='index' AND name=:_index; - "); - $stmt->bindValue(':_index', "{$this->getNamespace()}_{$this->getTenantSegment()}_{$name}_{$id}"); - $stmt->execute(); - $index = $stmt->fetch(); - if (!empty($index)) { + ", event: $event); + $statement->bindValue(':_index', "{$this->getNamespace()}_{$this->getTenantSegment()}_{$name}_{$id}"); + $this->execute($statement); + $existingIndex = $statement->fetch(); + if (! empty($existingIndex)) { return true; } - $sql = $this->getSQLIndex($name, $id, $type, $attributes); - - $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); + $sql = $this->getSqlIndex($name, $id, $type, $attributes); try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + return $this->execute($this->prepare($sql, event: $event)); + } catch (PDOException $error) { + throw $this->processException($error); } } @@ -781,7 +824,7 @@ public function createIndex(string $collection, string $id, string $type, array * @param array $attributes * @throws PDOException */ - protected function createFulltextIndex(string $collection, string $id, array $attributes): bool + protected function createFulltextIndex(string $collection, string $id, array $attributes, Event $event = Event::IndexCreate): bool { if (empty($attributes)) { throw new DatabaseException('Fulltext index requires at least one attribute'); @@ -791,15 +834,15 @@ protected function createFulltextIndex(string $collection, string $id, array $at $ftsTable = $this->getFulltextTableName($collection, $attributes); $parentTable = "{$this->getNamespace()}_{$collection}"; - $stmt = $this->getPDO()->prepare(" + $statement = $this->prepare(" SELECT name FROM sqlite_master WHERE type='table' AND name=:_table; - "); - $stmt->bindValue(':_table', $ftsTable); - $stmt->execute(); - $exists = !empty($stmt->fetch()); - $stmt->closeCursor(); + ", event: $event); + $statement->bindValue(':_table', $ftsTable); + $this->execute($statement); + $exists = !empty($statement->fetch()); + $statement->closeCursor(); if ($exists) { return true; } @@ -815,47 +858,46 @@ protected function createFulltextIndex(string $collection, string $id, array $at // literal — otherwise tenant A's vtable accumulates tenant B's // tokenized content. The same applies to the initial backfill. $tenantLiteral = $this->sharedTables ? $this->getTenantSqlLiteral() : null; - $insertWhen = $tenantLiteral !== null ? " WHEN NEW.`_tenant` IS {$tenantLiteral}" : ''; - $deleteWhen = $tenantLiteral !== null ? " WHEN OLD.`_tenant` IS {$tenantLiteral}" : ''; + $insertWhen = $tenantLiteral !== null ? " WHEN NEW.{$this->quote(Storage::TENANT)} IS {$tenantLiteral}" : ''; + $deleteWhen = $tenantLiteral !== null ? " WHEN OLD.{$this->quote(Storage::TENANT)} IS {$tenantLiteral}" : ''; $updateWhen = $tenantLiteral !== null - ? " WHEN OLD.`_tenant` IS {$tenantLiteral} OR NEW.`_tenant` IS {$tenantLiteral}" + ? " WHEN OLD.{$this->quote(Storage::TENANT)} IS {$tenantLiteral} OR NEW.{$this->quote(Storage::TENANT)} IS {$tenantLiteral}" : ''; - $backfillWhere = $tenantLiteral !== null ? " WHERE `_tenant` IS {$tenantLiteral}" : ''; + $backfillWhere = $tenantLiteral !== null ? " WHERE {$this->quote(Storage::TENANT)} IS {$tenantLiteral}" : ''; $this->startTransaction(); try { - $createSql = "CREATE VIRTUAL TABLE `{$ftsTable}` USING fts5({$ftsColumnList}, content=\"{$parentTable}\", content_rowid=\"_id\")"; - $createSql = $this->trigger(Database::EVENT_INDEX_CREATE, $createSql); - $this->getPDO()->prepare($createSql)->execute(); + $createSql = "CREATE VIRTUAL TABLE `{$ftsTable}` USING fts5({$ftsColumnList}, content=\"{$parentTable}\", content_rowid=\"".Storage::SEQUENCE.'")'; + $this->execute($this->prepare($createSql, event: $event)); $insertSuffix = self::FTS_TRIGGER_INSERT; $insertTrigger = " CREATE TRIGGER `{$ftsTable}_{$insertSuffix}` AFTER INSERT ON `{$parentTable}`{$insertWhen} BEGIN - INSERT INTO `{$ftsTable}` (rowid, {$columnList}) VALUES (NEW.`_id`, {$newColumnList}); + INSERT INTO `{$ftsTable}` (rowid, {$columnList}) VALUES (NEW.{$this->quote(Storage::SEQUENCE)}, {$newColumnList}); END "; - $this->getPDO()->prepare($insertTrigger)->execute(); + $this->execute($this->prepare($insertTrigger, event: $event)); $deleteSuffix = self::FTS_TRIGGER_DELETE; $deleteTrigger = " CREATE TRIGGER `{$ftsTable}_{$deleteSuffix}` AFTER DELETE ON `{$parentTable}`{$deleteWhen} BEGIN - INSERT INTO `{$ftsTable}` (`{$ftsTable}`, rowid, {$columnList}) VALUES ('delete', OLD.`_id`, {$oldColumnList}); + INSERT INTO `{$ftsTable}` (`{$ftsTable}`, rowid, {$columnList}) VALUES ('delete', OLD.{$this->quote(Storage::SEQUENCE)}, {$oldColumnList}); END "; - $this->getPDO()->prepare($deleteTrigger)->execute(); + $this->execute($this->prepare($deleteTrigger, event: $event)); $updateSuffix = self::FTS_TRIGGER_UPDATE; // OF : skip re-tokenise when only timestamps/permissions change. $updateTrigger = " CREATE TRIGGER `{$ftsTable}_{$updateSuffix}` AFTER UPDATE OF {$columnList} ON `{$parentTable}`{$updateWhen} BEGIN - INSERT INTO `{$ftsTable}` (`{$ftsTable}`, rowid, {$columnList}) VALUES ('delete', OLD.`_id`, {$oldColumnList}); - INSERT INTO `{$ftsTable}` (rowid, {$columnList}) VALUES (NEW.`_id`, {$newColumnList}); + INSERT INTO `{$ftsTable}` (`{$ftsTable}`, rowid, {$columnList}) VALUES ('delete', OLD.{$this->quote(Storage::SEQUENCE)}, {$oldColumnList}); + INSERT INTO `{$ftsTable}` (rowid, {$columnList}) VALUES (NEW.{$this->quote(Storage::SEQUENCE)}, {$newColumnList}); END "; - $this->getPDO()->prepare($updateTrigger)->execute(); + $this->execute($this->prepare($updateTrigger, event: $event)); - $backfill = "INSERT INTO `{$ftsTable}` (rowid, {$columnList}) SELECT `_id`, {$columnList} FROM `{$parentTable}`{$backfillWhere}"; - $this->getPDO()->prepare($backfill)->execute(); + $backfill = "INSERT INTO `{$ftsTable}` (rowid, {$columnList}) SELECT {$this->quote(Storage::SEQUENCE)}, {$columnList} FROM `{$parentTable}`{$backfillWhere}"; + $this->execute($this->prepare($backfill, event: $event)); $this->commitTransaction(); } catch (\Throwable $e) { @@ -869,7 +911,7 @@ protected function createFulltextIndex(string $collection, string $id, array $at throw $e; } - unset($this->ftsTableCache[$collection]); + unset($this->ftsTableCache[$this->getFulltextTablePrefix($collection)]); return true; } @@ -882,10 +924,9 @@ protected function createFulltextIndex(string $collection, string $id, array $at */ protected function getFulltextTableName(string $collection, array|string $attributes): string { - $attrs = \is_array($attributes) ? $attributes : [$attributes]; - $attrs = \array_map(fn (string $attr) => $this->filter($attr), $attrs); - \sort($attrs); - $key = \substr(\hash('sha1', \implode("\0", $attrs)), 0, 16); + $names = \array_map($this->filter(...), \is_array($attributes) ? $attributes : [$attributes]); + \sort($names); + $key = \substr(\hash('sha1', \implode("\0", $names)), 0, 16); return $this->getFulltextTablePrefix($collection) . $key . self::FTS_TABLE_SUFFIX; } @@ -909,7 +950,7 @@ protected function getFulltextTablePrefix(string $collection): string */ private function getTenantSegment(): string { - return $this->filter((string) ($this->tenant ?? '')); + return $this->filter((string) ($this->currentTenant() ?? '')); } /** @@ -918,58 +959,59 @@ private function getTenantSegment(): string */ private function getTenantSqlLiteral(): string { - if ($this->tenant === null) { + if ($this->currentTenant() === null) { return 'NULL'; } - if (\is_int($this->tenant)) { - return (string) $this->tenant; + if (\is_int($this->currentTenant())) { + return (string) $this->currentTenant(); } - return $this->getPDO()->quote((string) $this->tenant); + $pdo = $this->getDriver(); + $quoted = $pdo instanceof PDOProxy + ? $pdo->__call('quote', [(string) $this->currentTenant()]) + : $pdo->quote((string) $this->currentTenant()); + if (! \is_string($quoted)) { + throw new DatabaseException('Failed to quote SQLite tenant'); + } + + return $quoted; } /** - * Delete Index - * - * @param string $collection - * @param string $id - * @return bool * @throws Exception * @throws PDOException */ - public function deleteIndex(string $collection, string $id): bool + #[\Override] + public function deleteIndex(string $collection, string $key, Event $event = Event::IndexDelete): bool { $name = $this->filter($collection); - $id = $this->filter($id); + $id = $this->filter($key); // If a regular SQLite index with this id exists, take the normal // DROP INDEX path. Otherwise the index is either an FTS5 virtual // table (whose name is keyed off attributes, not the id) or // already absent — try the FTS5 path before erroring. $regularIndex = "{$this->getNamespace()}_{$this->getTenantSegment()}_{$name}_{$id}"; - $stmt = $this->getPDO()->prepare(" + $statement = $this->prepare(" SELECT name FROM sqlite_master WHERE type='index' AND name=:_index - "); - $stmt->bindValue(':_index', $regularIndex); - $stmt->execute(); - $hasRegular = $stmt->fetchColumn() !== false; + ", event: $event); + $statement->bindValue(':_index', $regularIndex); + $this->execute($statement); + $hasRegular = $statement->fetchColumn() !== false; // Free the read cursor before issuing DDL — SQLite holds a SHARED // lock on the database while a statement has unfetched rows, and // any subsequent DROP INDEX / ALTER TABLE under emulated prepares // will trip "database table is locked". - $stmt->closeCursor(); + $statement->closeCursor(); - if (!$hasRegular && $this->dropFulltextIndexById($name, $id)) { + if (! $hasRegular && $this->dropFulltextIndexById($name, $id, $event)) { return true; } $sql = "DROP INDEX `{$regularIndex}`"; - $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->execute($this->prepare($sql, event: $event)); } catch (PDOException $e) { if (str_contains($e->getMessage(), 'no such index')) { return true; @@ -979,11 +1021,44 @@ public function deleteIndex(string $collection, string $id): bool } } + /** + * @throws Exception + * @throws PDOException + */ + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + + if ($collection->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + $old = $this->filter($old); + $new = $this->filter($new); + $index = null; + foreach (self::collectionIndexes($collection) as $stored) { + if ($stored->key === $old) { + $index = $stored; + break; + } + } + + if ($index !== null + && $this->deleteIndex($collection->getId(), $old, Event::IndexRename) + && $this->createIndex($collection->getId(), $index->withKey($new), event: Event::IndexRename)) { + return true; + } + + return false; + } + /** * Drop the FTS5 vtable backing index `$id` on `$collection`. Returns * false when no FTS5 table exists; throws when ambiguous. */ - protected function dropFulltextIndexById(string $collection, string $id): bool + protected function dropFulltextIndexById(string $collection, string $id, Event $event = Event::IndexDelete): bool { $tables = $this->findFulltextTables($collection); @@ -1021,11 +1096,10 @@ protected function dropFulltextIndexById(string $collection, string $id): bool $this->startTransaction(); try { foreach ($triggerSuffixes as $suffix) { - $this->getPDO()->prepare("DROP TRIGGER IF EXISTS `{$ftsTable}_{$suffix}`")->execute(); + $this->execute($this->prepare("DROP TRIGGER IF EXISTS `{$ftsTable}_{$suffix}`", event: $event)); } $sql = "DROP TABLE IF EXISTS `{$ftsTable}`"; - $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); - $this->getPDO()->prepare($sql)->execute(); + $this->execute($this->prepare($sql, event: $event)); $this->commitTransaction(); } catch (\Throwable $e) { try { @@ -1035,12 +1109,11 @@ protected function dropFulltextIndexById(string $collection, string $id): bool throw $e; } - unset($this->ftsTableCache[$collection]); + unset($this->ftsTableCache[$this->getFulltextTablePrefix($collection)]); return true; } - /** * Resolve the FTS5 table for index `$id` via metadata. Returns null * when metadata doesn't reach a candidate. @@ -1048,83 +1121,57 @@ protected function dropFulltextIndexById(string $collection, string $id): bool * @param array $candidates */ protected function resolveFulltextTableById(string $collection, string $id, array $candidates): ?string + { + $table = $this->getFulltextTablesByIndexId($collection)[$this->filter($id)] ?? null; + + return \in_array($table, $candidates, true) ? $table : null; + } + + /** + * @return array The FTS5 table of each fulltext index in the stored metadata, by index id + */ + private function getFulltextTablesByIndexId(string $collection): array { try { - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collectionDoc = $this->getDocument($metadataCollection, $collection); + $metadata = $this->getDocument(new Document([Document::ID => Database::METADATA]), $collection); } catch (NotFoundException) { // Metadata not yet seeded (collection drop during bootstrap). - // Anything else surfaces — masking PDO errors here would silently - // fall through to the single-candidate drop path and tear down - // the wrong table. - return null; - } - - if ($collectionDoc->isEmpty()) { - return null; + return []; } - $indexes = $collectionDoc->getAttribute('indexes', []); - $filteredId = $this->filter($id); - - foreach ($indexes as $index) { - $indexId = $index instanceof Document - ? $index->getId() - : (\is_array($index) ? ($index['$id'] ?? null) : null); - - if ($indexId === null) { + $tables = []; + foreach (self::collectionIndexes($metadata) as $index) { + if ($index->type !== IndexType::Fulltext) { continue; } - if ($this->filter((string) $indexId) !== $filteredId) { - continue; - } - - $type = $index instanceof Document - ? $index->getAttribute('type') - : ($index['type'] ?? null); - - if ($type !== Database::INDEX_FULLTEXT) { - return null; - } - - $attributes = $index instanceof Document - ? $index->getAttribute('attributes', []) - : ($index['attributes'] ?? []); - - $internal = \array_map( - fn (string $a) => $this->getInternalKeyForAttribute($a), - (array) $attributes - ); - $candidate = $this->getFulltextTableName($collection, $internal); - return \in_array($candidate, $candidates, true) ? $candidate : null; + $internal = \array_map($this->getInternalKeyForAttribute(...), $index->attributes); + $tables[$this->filter($index->key)] = $this->getFulltextTableName($collection, $internal); } - return null; + return $tables; } /** - * Every FTS5 vtable on `$collection`. - * * @return array */ protected function findFulltextTables(string $collection): array { // ESCAPE '\\' so the literal `_` separators in the prefix don't // act as LIKE wildcards (e.g. `db_users_` matching `db_usersA_`). - $stmt = $this->getPDO()->prepare(" + $statement = $this->prepare(" SELECT name FROM sqlite_master WHERE type='table' AND name LIKE :_prefix ESCAPE '\\' AND name LIKE :_suffix ESCAPE '\\' "); - $stmt->bindValue(':_prefix', $this->escapeLikePattern($this->getFulltextTablePrefix($collection)) . '%'); - $stmt->bindValue(':_suffix', '%' . $this->escapeLikePattern(self::FTS_TABLE_SUFFIX)); - $stmt->execute(); - $tables = $stmt->fetchAll(PDO::FETCH_COLUMN); - $stmt->closeCursor(); + $statement->bindValue(':_prefix', $this->escapeLikePattern($this->getFulltextTablePrefix($collection)) . '%'); + $statement->bindValue(':_suffix', '%' . $this->escapeLikePattern(self::FTS_TABLE_SUFFIX)); + $statement->execute(); + $tables = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); - return $tables; + return \array_map(fn (mixed $t): string => \is_string($t) ? $t : '', $tables); } /** @@ -1138,276 +1185,130 @@ private function escapeLikePattern(string $value): string } /** - * Create Document - * - * @param Document $collection - * @param Document $document - * @return Document * @throws Exception * @throws PDOException * @throws DuplicateException */ + #[\Override] public function createDocument(Document $collection, Document $document): Document { - $collection = $collection->getId(); - $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); + try { + $this->syncWriteHooks(); - if ($this->sharedTables) { - $attributes['_tenant'] = $this->tenant; - } + $collection = $collection->getId(); + $attributes = $document->getAttributes(); + $attributes[Storage::CREATED_AT] = $document->getCreatedAt(); + $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt(); + $attributes[Storage::PERMISSIONS] = json_encode($document->getPermissions()); - $name = $this->filter($collection); - $columns = ['_uid']; - $values = ['_uid']; + $name = $this->filter($collection); - /** - * Insert Attributes - */ - $bindIndex = 0; - foreach ($attributes as $attribute => $value) { // Parse statement - $column = $this->filter($attribute); - $values[] = 'value_' . $bindIndex; - $columns[] = "`{$column}`"; - $bindIndex++; - } + $builder = $this->dialectBuilder()->into($this->getTableRaw($name)); + $row = [Storage::UID => $document->getId()]; - // Insert manual id if set - if (!empty($document->getSequence())) { - $values[] = '_id'; - $columns[] = "_id"; - } + if (! empty($document->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); + } - $sql = " - INSERT INTO `{$this->getNamespace()}_{$name}` (".\implode(', ', $columns).") - VALUES (:".\implode(', :', $values)."); - "; + foreach ($attributes as $attr => $value) { + $column = $this->filter($attr); - $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql); + if (is_array($value)) { + $value = json_encode($value); + } + $value = (is_bool($value)) ? (int) $value : $value; + $row[$column] = $value; + } - $stmt = $this->getPDO()->prepare($sql); + $row = $this->decorateRow($row, $document); + $builder->set($row); + $result = $builder->insert(); + $statement = $this->executeResult($result, Event::DocumentCreate); - $stmt->bindValue(':_uid', $document->getId(), PDO::PARAM_STR); + $this->execute($statement); - // Bind internal id if set - if (!empty($document->getSequence())) { - $stmt->bindValue(':_id', $document->getSequence(), PDO::PARAM_STR); - } + $document[Document::SEQUENCE] = $this->getDriver()->lastInsertId(); - $attributeIndex = 0; - foreach ($attributes as $attribute => $value) { - if (is_array($value)) { // arrays & objects should be saved as strings - $value = json_encode($value); + if (empty($document[Document::SEQUENCE])) { + throw new DatabaseException('Error creating document empty "'.Document::SEQUENCE.'"'); } - $bindKey = 'value_' . $attributeIndex; - $attribute = $this->filter($attribute); - $value = (is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; - } - - $permissions = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { - $permission = \str_replace('"', '', $permission); - $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}' {$tenantQuery})"; - } - } - - if (!empty($permissions)) { - $tenantQuery = $this->sharedTables ? ', _tenant' : ''; - - $queryPermissions = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _document {$tenantQuery}) - VALUES " . \implode(', ', $permissions); - - $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); - - $stmtPermissions = $this->getPDO()->prepare($queryPermissions); - - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); - } - } - - try { - $stmt->execute(); - - $document['$sequence'] = (int) $this->getPDO()->lastInsertId(); - - if (isset($stmtPermissions)) { - $stmtPermissions->execute(); - } + $context = $this->writeContext(); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $context)); } catch (PDOException $e) { throw $this->processException($e); } - return $document; } /** - * Update Document - * - * @param Document $collection - * @param string $id - * @param Document $document - * @param bool $skipPermissions - * @return Document * @throws Exception * @throws PDOException * @throws DuplicateException */ + #[\Override] public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { - $spatialAttributes = $this->getSpatialAttributes($collection); - $collection = $collection->getId(); - $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); - $attributes['_uid'] = $document->getId(); - - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); - } - - $name = $this->filter($collection); - $columns = ''; - - if (!$skipPermissions) { - $newUid = $document->offsetExists('$id') ? $document->getId() : $id; - - $sql = " - DELETE FROM `{$this->getNamespace()}_{$name}_perms` - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); - - $stmtRemovePermissions = $this->getPDO()->prepare($sql); - $stmtRemovePermissions->bindValue(':_uid', $id); - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $document->getTenant()); - } - - $values = []; - $binds = []; - foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $permission) { - $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i} {$tenantQuery})"; - $binds[":_add_{$type}_{$i}"] = $permission; - } - } - - if (!empty($values)) { - $tenantQuery = $this->sharedTables ? ', _tenant' : ''; - - $sql = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission {$tenantQuery}) - VALUES " . \implode(', ', $values); + try { + $this->syncWriteHooks(); - $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); + $attributes = $document->getAttributes(); + $attributes[Storage::CREATED_AT] = $document->getCreatedAt(); + $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt(); + $attributes[Storage::PERMISSIONS] = json_encode($document->getPermissions()); - $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $newUid); - if ($this->sharedTables) { - $stmtAddPermissions->bindValue(":_tenant", $document->getTenant()); - } + $name = $this->filter($collection); - foreach ($binds as $key => $permission) { - $stmtAddPermissions->bindValue($key, $permission); + $operators = []; + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; } } - } - - /** - * Update Attributes - */ - $keyIndex = 0; - $operatorBinds = []; - - foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); - // Check if this is an operator, spatial attribute, or regular attribute - if (Operator::isOperator($value)) { - $operatorSQL = $this->getOperatorSQL($column, $value, $operatorBinds); - $columns .= $operatorSQL; - } elseif ($this->getSupportForSpatialAttributes() && \in_array($attribute, $spatialAttributes, true)) { - $bindKey = 'key_' . $keyIndex; - $columns .= "`{$column}` = " . $this->getSpatialGeomFromText(':' . $bindKey); - $keyIndex++; - } else { - $bindKey = 'key_' . $keyIndex; - $columns .= "`{$column}`" . '=:' . $bindKey; - $keyIndex++; + $builder = $this->newBuilder($name); + $regularRow = []; + if ($document->getId() !== $id) { + $regularRow[Storage::UID] = $document->getId(); } - $columns .= ','; - } - - // Remove trailing comma - $columns = rtrim($columns, ','); - - $sql = " - UPDATE `{$this->getNamespace()}_{$name}` - SET {$columns} - WHERE _uid = :_existingUid - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); + foreach ($attributes as $attribute => $value) { + $column = $this->filter($attribute); - $stmt = $this->getPDO()->prepare($sql); - - $stmt->bindValue(':_existingUid', $id); - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - // Bind values for non-operator attributes and operator parameters - $keyIndex = 0; - foreach ($attributes as $attribute => $value) { - // Handle operators separately - if (Operator::isOperator($value)) { - continue; - } - - // Convert spatial arrays to WKT, json_encode non-spatial arrays - if (\in_array($attribute, $spatialAttributes, true)) { - if (\is_array($value)) { - $value = $this->convertArrayToWKT($value); + if (isset($operators[$attribute])) { + $operation = $operators[$attribute]; + if ($operation instanceof Operator) { + $expression = $this->getOperatorBuilderExpression($column, $operation); + $builder->setRaw($column, $expression->sql, $expression->bindings); + } + } elseif ($this instanceof Feature\Spatial && \in_array($attribute, $spatialAttributes, true)) { + if (\is_array($value)) { + $value = $this->convertArrayToWkt($value); + } + $value = (is_bool($value)) ? (int) $value : $value; + $builder->setRaw($column, $this->getSpatialGeometryFromText('?'), [$value]); + } else { + if (is_array($value)) { + $value = json_encode($value); + } + $value = (is_bool($value)) ? (int) $value : $value; + $regularRow[$column] = $value; } - } elseif (is_array($value)) { // arrays & objects should be saved as strings - $value = json_encode($value); } - $bindKey = 'key_' . $keyIndex; - $value = (is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $keyIndex++; - } + $builder->set($regularRow); + $filters = [BaseQuery::equal(Storage::UID, [$id])]; + $builder->filter($filters); + $result = $builder->update(); + $statement = $this->executeResult($result, Event::DocumentUpdate); - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + $this->execute($statement); - try { - $stmt->execute(); - if (isset($stmtRemovePermissions)) { - $stmtRemovePermissions->execute(); - } - if (isset($stmtAddPermissions)) { - $stmtAddPermissions->execute(); - } + $context = $this->writeContext($skipPermissions ? [$document->getId() => true] : []); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpdate($name, $id, $document, $context)); } catch (PDOException $e) { throw $this->processException($e); } @@ -1415,298 +1316,13 @@ public function updateDocument(Document $collection, string $id, Document $docum return $document; } - - - /** - * Is schemas supported? - * - * @return bool - */ - public function getSupportForSchemas(): bool - { - return false; - } - - public function getSupportForQueryContains(): bool - { - return false; - } - - /** - * Is fulltext index supported? - * - * @return bool - */ - public function getSupportForFulltextIndex(): bool - { - return true; - } - - /** - * Is fulltext Wildcard index supported? - * - * @return bool - */ - public function getSupportForFulltextWildcardIndex(): bool - { - // FTS5's unicode61 tokenizer strips characters like `@` and `.` - // before indexing, so a search for "al@ba.io" applied as a prefix - // wildcard ("al ba io*") matches a doc containing "al@ba.io" the - // same way the non-wildcard branch does. The upstream test gates - // its expectations on this flag and the false branch matches - // SQLite's actual tokenisation behaviour; flagging as true would - // claim a behavioural distinction we don't deliver. - return false; - } - - /** - * Are timeouts supported? - * - * @return bool - */ - public function getSupportForTimeouts(): bool - { - // The adapter does no per-query timeout enforcement and therefore - // can't translate a tripped budget into Utopia\Database\Exception\Timeout - // the way MariaDB/Postgres do. Stay false rather than mislead callers - // that rely on Database::setTimeout() actually firing. - return false; - } - - public function getSupportForRelationships(): bool - { - return true; - } - - public function getSupportForUpdateLock(): bool - { - // SQLite has no row-level locking. The parser accepts FOR UPDATE - // as syntactic sugar but the planner still escalates to a - // RESERVED/EXCLUSIVE lock on the database, which deadlocks - // subsequent DDL like DROP INDEX inside the same transaction. - // Stay false so the SELECT path doesn't append the clause. - return false; - } - - /** - * Is attribute resizing supported? - * - * @return bool - */ - public function getSupportForAttributeResizing(): bool - { - // SQLite is dynamically typed with no MODIFY COLUMN. When - // emulating MySQL, updateAttribute scans the column on - // resize-down and raises TruncateException to match MariaDB's - // contract. Off-emulation, declared sizes are metadata-only. - return $this->emulateMySQL; - } - - /** - * Is get connection id supported? - * - * @return bool - */ - public function getSupportForGetConnectionId(): bool - { - return false; - } - /** - * Is get schema attributes supported? + * The keywords of https://www.sqlite.org/lang_keywords.html * - * @return bool + * @return list */ - public function getSupportForSchemaAttributes(): bool - { - return true; - } - - public function getSupportForSchemaIndexes(): bool - { - return true; - } - - /** - * Is upsert supported? - * - * @return bool - */ - public function getSupportForUpserts(): bool - { - // ON CONFLICT DO UPDATE is native SQLite, not MariaDB emulation. - return true; - } - - /** - * SQLite has no JSON_OVERLAPS — fall back to the LIKE-based default - * inherited from MariaDB::getSQLCondition for CONTAINS queries on arrays. - */ - public function getSupportForJSONOverlaps(): bool - { - return false; - } - - public function getSupportForUpsertOnUniqueIndex(): bool - { - return false; - } - - /** - * Is hostname supported? - * - * @return bool - */ - public function getSupportForHostname(): bool - { - return false; - } - - /** - * Is batch create attributes supported? - * - * @return bool - */ - public function getSupportForBatchCreateAttributes(): bool - { - return true; - } - - public function getSupportForSpatialAttributes(): bool - { - return false; // SQLite doesn't have native spatial support - } - - public function getSupportForObject(): bool - { - return false; - } - - public function getSupportForUnsignedBigInt(): bool - { - return false; - } - - /** - * Are object (JSON) indexes supported? - * - * @return bool - */ - public function getSupportForObjectIndexes(): bool - { - return false; - } - - public function getSupportForSpatialIndexNull(): bool - { - return false; // SQLite doesn't have native spatial support - } - - /** - * Override getSpatialGeomFromText to return placeholder unchanged for SQLite - * SQLite does not support ST_GeomFromText, so we return the raw placeholder - * - * @param string $wktPlaceholder - * @param int|null $srid - * @return string - */ - protected function getSpatialGeomFromText(string $wktPlaceholder, ?int $srid = null): string - { - return $wktPlaceholder; - } - - /** - * Get SQL Index Type - * - * @param string $type - * @return string - * @throws Exception - */ - protected function getSQLIndexType(string $type): string - { - switch ($type) { - case Database::INDEX_KEY: - return 'INDEX'; - - case Database::INDEX_UNIQUE: - return 'UNIQUE INDEX'; - - case Database::INDEX_FULLTEXT: - // Fulltext is handled via FTS5 virtual tables in - // createFulltextIndex; reaching this codepath means a - // caller bypassed that route and would emit invalid SQL. - throw new DatabaseException('Fulltext indexes use createFulltextIndex(), not getSQLIndexType'); - - default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT); - } - } - - /** - * Get SQL Index - * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @return string - * @throws Exception - */ - protected function getSQLIndex(string $collection, string $id, string $type, array $attributes): string - { - $postfix = ''; - - switch ($type) { - case Database::INDEX_KEY: - $type = 'INDEX'; - break; - - case Database::INDEX_UNIQUE: - $type = 'UNIQUE INDEX'; - $postfix = 'COLLATE NOCASE'; - - break; - - default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT); - } - - $attributes = \array_map(fn ($attribute) => $this->getInternalKeyForAttribute($attribute), $attributes); - - foreach ($attributes as $key => $attribute) { - $attribute = $this->filter($attribute); - - $attributes[$key] = "`{$attribute}` {$postfix}"; - } - - $key = "`{$this->getNamespace()}_{$this->getTenantSegment()}_{$collection}_{$id}`"; - $attributes = implode(', ', $attributes); - - if ($this->sharedTables) { - $attributes = "`_tenant` {$postfix}, {$attributes}"; - } - - return "CREATE {$type} {$key} ON `{$this->getNamespace()}_{$collection}` ({$attributes})"; - } - - /** - * Get SQL table - * - * @param string $name - * @return string - */ - protected function getSQLTable(string $name): string - { - return $this->quote("{$this->getNamespace()}_{$this->filter($name)}"); - } - - /** - * Get list of keywords that cannot be used - * Refference: https://www.sqlite.org/lang_keywords.html - * - * @return array - */ - public function getKeywords(): array + #[\Override] + protected function getKeywords(): array { return [ 'ABORT', @@ -1859,23 +1475,246 @@ public function getKeywords(): array ]; } - protected function processException(PDOException $e): \Exception + #[\Override] + protected function dialectBuilder(): SQLBuilder&Scoping { - // Timeout - if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3024) { - return new TimeoutException('Query timed out', $e->getCode(), $e); + return new SQLiteBuilder(); + } + + #[Override] + protected function collateDocumentId(string $column): string + { + return $column.' COLLATE '.SQLiteBuilder::COLLATION; + } + + /** + * @param array $roles + */ + #[Override] + protected function newPermissionHook(string $collection, array $roles, string $type = PermissionType::Read->value, string $documentColumn = Storage::UID): Permission\Filter + { + return parent::newPermissionHook($collection, $roles, $type, $documentColumn)->collate(SQLiteBuilder::COLLATION); + } + + #[\Override] + protected function getSqlType(ColumnType $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string + { + if (in_array($type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + return ''; + } + if ($array === true) { + return 'JSON'; } - // Table/index already exists (SQLITE_ERROR with "already exists" message) + if ($type === ColumnType::String) { + if ($size > 16777215) { + return 'LONGTEXT'; + } + if ($size > 65535) { + return 'MEDIUMTEXT'; + } + if ($size > $this->limits()->varchar) { + return 'TEXT'; + } + + return "VARCHAR({$size})"; + } + + if ($type === ColumnType::Varchar) { + $this->assertVarcharSize($size); + + return "VARCHAR({$size})"; + } + + if (\in_array($type, [ColumnType::Integer, ColumnType::BigInteger], true)) { + $suffix = $signed ? '' : ' UNSIGNED'; + + return ($type === ColumnType::Integer && $size < 8 ? 'INT' : 'BIGINT').$suffix; + } + + if ($type === ColumnType::Float || $type === ColumnType::Double) { + return 'DOUBLE'.($signed ? '' : ' UNSIGNED'); + } + + return match ($type) { + ColumnType::Id => 'BIGINT UNSIGNED', + ColumnType::Text => 'TEXT', + ColumnType::MediumText => 'MEDIUMTEXT', + ColumnType::LongText => 'LONGTEXT', + ColumnType::Boolean => 'TINYINT(1)', + ColumnType::Relationship => 'VARCHAR(255)', + ColumnType::Datetime => 'DATETIME(3)', + default => throw new DatabaseException('Unknown type: '.$type->value.'. Must be one of '.ColumnType::String->value.', '.ColumnType::Varchar->value.', '.ColumnType::Text->value.', '.ColumnType::MediumText->value.', '.ColumnType::LongText->value.', '.ColumnType::Integer->value.', '.ColumnType::Double->value.', '.ColumnType::Boolean->value.', '.ColumnType::Datetime->value.', '.ColumnType::Relationship->value), + }; + } + + #[\Override] + protected function getMaxPointSize(): int + { + return 0; + } + + /** + * SQLite does not support ST_GeomFromText, so we return the raw placeholder + */ + #[\Override] + protected function getSpatialGeometryFromText(string $wktPlaceholder, ?int $srid = null): string + { + return $wktPlaceholder; + } + + /** + * @param array $attributes + * + * @throws Exception + */ + protected function getSqlIndex(string $collection, string $id, IndexType $type, array $attributes): string + { + [$sqlType, $postfix] = match ($type) { + IndexType::Key => ['INDEX', ''], + IndexType::Unique => ['UNIQUE INDEX', 'COLLATE '.SQLiteBuilder::COLLATION], + default => throw new DatabaseException('Unknown index type: '.$type->value.'. Must be one of '.IndexType::Key->value.', '.IndexType::Unique->value.', '.IndexType::Fulltext->value), + }; + + $attributes = \array_map($this->getInternalKeyForAttribute(...), $attributes); + + foreach ($attributes as $key => $attribute) { + $attribute = $this->filter($attribute); + + $attributes[$key] = "`{$attribute}` {$postfix}"; + } + + $key = "`{$this->getNamespace()}_{$this->getTenantSegment()}_{$collection}_{$id}`"; + $attributes = implode(', ', $attributes); + + if ($this->sharedTables) { + $attributes = "{$this->quote(Storage::TENANT)}, {$attributes}"; + } + + return "CREATE {$sqlType} {$key} ON `{$this->getNamespace()}_{$collection}` ({$attributes})"; + } + + /** + * SQLite doesn't use database-qualified table names. + */ + #[\Override] + protected function qualifyTable(string $database, string $namespace, string $name): string + { + return $namespace.'_'.$this->filter($name); + } + + /** + * SQLite must be compiled with -DSQLITE_ENABLE_MATH_FUNCTIONS + */ + private function getSupportForMathFunctions(): bool + { + static $available = null; + + if ($available !== null) { + return (bool) $available; + } + + try { + $pdo = $this->getDriver(); + $statement = $pdo instanceof PDOProxy + ? $pdo->__call('query', ['SELECT POWER(2, 3) as test']) + : $pdo->query('SELECT POWER(2, 3) as test'); + if (! $statement instanceof PDOStatement && ! $statement instanceof PDOStatementProxy) { + $available = false; + + return false; + } + $result = $statement->fetch(); + /** @var array|false $result */ + $testVal = \is_array($result) ? ($result['test'] ?? null) : null; + $available = ($testVal == 8); + + return $available; + } catch (PDOException $e) { + $available = false; + + return false; + } + } + + #[\Override] + protected function isAdapterFilterQuery(Query $query): bool + { + $method = $query->getMethod(); + + return $method === Method::Search || $method === Method::NotSearch; + } + + /** + * Compile a Search/NotSearch query into FTS5 SQL with positional bindings. + * Falls back to a LIKE expression when no FTS5 table covers the attribute. + * + * @param list $joins + */ + #[\Override] + protected function compileAdapterFilter(Query $query, string $collection, string $alias, array $joins = []): ?Expression + { + $method = $query->getMethod(); + if ($method !== Method::Search && $method !== Method::NotSearch) { + return null; + } + + $rawAttribute = $query->getAttribute(); + [$quotedAlias, $quotedAttribute] = $this->quoteSearchAttribute($rawAttribute, $alias); + + $rawValue = ''; + $queryValue = $query->getValue(); + if (\is_scalar($queryValue)) { + $rawValue = (string) $queryValue; + } + $ftsValue = $this->getFts5Value($rawValue); + + if ($ftsValue === '') { + return new Expression( + $method === Method::Search ? '1 = 0' : '1 = 1', + [], + ); + } + + $ftsTable = $this->findSearchFulltextTable($rawAttribute, $collection, $joins); + + if ($ftsTable === null) { + $likeExpr = "{$quotedAlias}.{$quotedAttribute} LIKE ? ESCAPE '\\'"; + $likeBinding = '%' . $this->escapeWildcards($rawValue) . '%'; + + return new Expression( + $method === Method::Search ? $likeExpr : "NOT ({$likeExpr})", + [$likeBinding], + ); + } + + $subquery = "{$quotedAlias}.{$this->quote(Storage::SEQUENCE)} IN (SELECT rowid FROM `{$ftsTable}` WHERE `{$ftsTable}` MATCH ?)"; + + return new Expression( + $method === Method::Search ? $subquery : "NOT ({$subquery})", + [$ftsValue], + ); + } + + #[\Override] + protected function processException(PDOException $e): Exception + { if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'already exists') !== false) { return new DuplicateException('Collection already exists', $e->getCode(), $e); } - // Table not found (SQLITE_ERROR with "no such table" message) if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'no such table') !== false) { return new NotFoundException('Collection not found', $e->getCode(), $e); } + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'no such column') !== false) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); + } + + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'duplicate column name') !== false) { + return new DuplicateException('Attribute already exists', $e->getCode(), $e); + } + // Duplicate - SQLite uses various error codes for constraint violations: // - Error code 19 is SQLITE_CONSTRAINT (includes UNIQUE violations) // - Error code 1 is also used for some duplicate cases @@ -1892,27 +1731,27 @@ protected function processException(PDOException $e): \Exception stripos($message, 'duplicate') !== false ) { $columns = $this->getViolatedColumns($message); - if ($columns !== null && $columns !== ['_uid'] && $columns !== ['_tenant', '_uid']) { - return new UniqueException('Unique index violation', $e->getCode(), $e); + if ($columns !== null && $columns !== [Storage::UID] && $columns !== [Storage::TENANT, Storage::UID]) { + return new UniqueException(UniqueException::MESSAGE, $e->getCode(), $e); } + return new DuplicateException('Document already exists', $e->getCode(), $e); } } - // String or BLOB exceeds size limit if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 18) { return new LimitException('Value too large', $e->getCode(), $e); } + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 5) { + return new ContentionException('Database is locked', $e->getCode(), $e); + } + return $e; } /** - * Extract the violated columns from a constraint error, e.g. - * "UNIQUE constraint failed: movies._tenant, movies._uid" resolves to - * ['_tenant', '_uid']. Returns null when the message cannot be parsed. - * - * @return array|null + * @return list|null */ protected function getViolatedColumns(string $message): ?array { @@ -1934,87 +1773,99 @@ protected function getViolatedColumns(string $message): ?array return $columns; } - public function getSupportForSpatialIndexOrder(): bool - { - return false; - } - public function getSupportForBoundaryInclusiveContains(): bool - { - return false; - } - /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? + * Toggle, DateSetNow and ArrayUnique bind nothing: getOperatorSql() writes them without placeholders and + * advances $bindIndex itself. ArrayFilter binds its comparison value only. * - * @return bool + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $statement */ - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + #[Override] + protected function bindOperatorParameters(object $statement, Operator $operator, int &$bindIndex): void { - return false; - } + $method = $operator->getMethod(); - /** - * Does the adapter support spatial axis order specification? - * - * @return bool - */ - public function getSupportForSpatialAxisOrder(): bool - { - return false; - } + if (in_array($method, [OperatorType::Toggle, OperatorType::DateSetNow, OperatorType::ArrayUnique])) { + return; + } - /** - * Adapter supports optional spatial attributes with existing rows. - * - * @return bool - */ - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return true; - } + if ($method === OperatorType::ArrayFilter) { + $values = $operator->getValues(); + if (! empty($values) && count($values) >= 2) { + $filterType = $values[0]; + $filterValue = $values[1]; - /** - * Get the SQL function for random ordering - * - * @return string - */ - protected function getRandomOrder(): string - { - return 'RANDOM()'; + $comparisonTypes = ['equal', 'notEqual', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual']; + if (in_array($filterType, $comparisonTypes)) { + $bindKey = "op_{$bindIndex}"; + $value = (is_bool($filterValue)) ? (int) $filterValue : $filterValue; + $statement->bindValue(":{$bindKey}", $value, $this->getPdoType($value)); + $bindIndex++; + } + } + + return; + } + + parent::bindOperatorParameters($statement, $operator, $bindIndex); } - /** - * Check if SQLite math functions (like POWER) are available - * SQLite must be compiled with -DSQLITE_ENABLE_MATH_FUNCTIONS - * - * @return bool - */ - private function getSupportForMathFunctions(): bool + #[\Override] + protected function getOperatorBuilderExpression(string $column, Operator $operator): Expression { - static $available = null; + if ($operator->getMethod() === OperatorType::ArrayFilter) { + $bindIndex = 0; + $fullExpression = $this->getOperatorSql($column, $operator, $bindIndex); - if ($available !== null) { - return $available; - } + if ($fullExpression === null) { + throw new DatabaseException('Operator cannot be expressed in SQL: '.$operator->getMethod()->value); + } - try { - // Test if POWER function exists by attempting to use it - $stmt = $this->getPDO()->query('SELECT POWER(2, 3) as test'); - $result = $stmt->fetch(); - $available = ($result['test'] == 8); - return $available; - } catch (PDOException $e) { - // Function doesn't exist - $available = false; - return false; + $quotedColumn = $this->quote($column); + $prefix = $quotedColumn.' = '; + $expression = $fullExpression; + if (str_starts_with($expression, $prefix)) { + $expression = substr($expression, strlen($prefix)); + } + + // SQLite ArrayFilter only uses one binding (the filter value), not the condition string + $values = $operator->getValues(); + $namedBindings = []; + if (count($values) >= 2) { + $filterType = $values[0]; + $comparisonTypes = ['equal', 'notEqual', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual']; + if (in_array($filterType, $comparisonTypes)) { + $namedBindings['op_0'] = $values[1]; + } + } + + $positionalBindings = []; + $replacements = []; + foreach (array_keys($namedBindings) as $key) { + $search = ':'.$key; + $offset = 0; + while (($pos = strpos($expression, $search, $offset)) !== false) { + $replacements[] = ['pos' => $pos, 'len' => strlen($search), 'key' => $key]; + $offset = $pos + strlen($search); + } + } + usort($replacements, fn ($a, $b) => $a['pos'] - $b['pos']); + $result = $expression; + for ($i = count($replacements) - 1; $i >= 0; $i--) { + $r = $replacements[$i]; + $result = substr_replace($result, '?', $r['pos'], $r['len']); + } + foreach ($replacements as $r) { + $positionalBindings[] = $namedBindings[$r['key']] ?? null; + } + + return new Expression($result, $positionalBindings); } + + return parent::getOperatorBuilderExpression($column, $operator); } /** - * Get SQL expression for operator - * * IMPORTANT: SQLite JSON Limitations - * ----------------------------------- * Array operators using json_each() and json_group_array() have type conversion behavior: * - Numbers are preserved but may lose precision (e.g., 1.0 becomes 1) * - Booleans become integers (true→1, false→0) @@ -2023,152 +1874,149 @@ private function getSupportForMathFunctions(): bool * * This is inherent to SQLite's JSON implementation and affects: ARRAY_APPEND, ARRAY_PREPEND, * ARRAY_UNIQUE, ARRAY_INTERSECT, ARRAY_DIFF, ARRAY_INSERT, and ARRAY_REMOVE. - * - * @param string $column - * @param Operator $operator - * @param array $binds - * @return ?string */ - protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string + #[\Override] + protected function getOperatorSql(string $column, Operator $operator, int &$bindIndex): ?string { $quotedColumn = $this->quote($column); $method = $operator->getMethod(); + $values = $operator->getValues(); switch ($method) { - // Numeric operators - case Operator::TYPE_INCREMENT: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Increment: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) + :$bindKey > :$maxKey THEN COALESCE({$quotedColumn}, 0) + WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) + :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) + :$bindKey"; - case Operator::TYPE_DECREMENT: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Decrement: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $minKey = $this->registerOperatorBind($binds, $values[1]); + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) - :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0) + WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) - :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) - :$bindKey"; - case Operator::TYPE_MULTIPLY: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Multiply: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE - WHEN COALESCE({$quotedColumn}, 0) * :$bindKey > :$maxKey THEN COALESCE({$quotedColumn}, 0) + WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0) + WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) * :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) * :$bindKey"; - case Operator::TYPE_DIVIDE: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Divide: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $minKey = $this->registerOperatorBind($binds, $values[1]); + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0) ELSE COALESCE({$quotedColumn}, 0) / :$bindKey END"; } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) / :$bindKey"; - case Operator::TYPE_MODULO: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1); + case OperatorType::Modulo: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) % :$bindKey"; - case Operator::TYPE_POWER: - if (!$this->getSupportForMathFunctions()) { + case OperatorType::Power: + if (! $this->getSupportForMathFunctions()) { throw new DatabaseException( - 'SQLite POWER operator requires math functions. ' . + 'SQLite POWER operator requires math functions. '. 'Compile SQLite with -DSQLITE_ENABLE_MATH_FUNCTIONS or use multiply operators instead.' ); } - $values = $operator->getValues(); $exponent = $values[0] ?? 1; - $bindKey = $this->registerOperatorBind($binds, $exponent); + if (! \is_int($exponent) && ! \is_float($exponent)) { + throw new OperatorException('Power exponent must be numeric'); + } + $bindKey = "op_{$bindIndex}"; + $bindIndex++; if (isset($values[1])) { - $maxKey = $this->registerOperatorBind($binds, $values[1]); - $col = "COALESCE({$quotedColumn}, 0)"; + $maxKey = "op_{$bindIndex}"; + $bindIndex++; - // Leave the value unchanged only for undefined inputs, then apply the power if - // the result stays within the max. The exponent is constant, so only the - // undefined guard its value can actually trigger is emitted. + $columnValue = "COALESCE({$quotedColumn}, 0)"; $oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0; + $guards = []; - $whens = []; if ($exponent < 0) { - // 0 to a negative power is undefined. - $whens[] = "WHEN {$col} = 0 THEN {$col}"; + $guards[] = "WHEN {$columnValue} = 0 THEN {$columnValue}"; } if (\floor($exponent) != $exponent) { - // A negative base to a fractional exponent is not a real number. - $whens[] = "WHEN {$col} < 0 THEN {$col}"; + $guards[] = "WHEN {$columnValue} < 0 THEN {$columnValue}"; } - // Cap by magnitude via logarithms so POWER() never runs on a value that would - // overflow (base^exp > max <=> exp * LN(base) > LN(max)). if ($exponent == 0) { - // Every base to the zeroth power is 1 (including 0^0), which the magnitude - // check below can't see for a base of 0. The result 1 exceeds the max when - // max < 1, i.e. LN(max) < 0 (LN also coerces the bound value numerically). - $whens[] = "WHEN LN(:$maxKey) < 0 THEN {$col}"; + $guards[] = "WHEN LN(:$maxKey) < 0 THEN {$columnValue}"; } elseif ($oddInteger) { - // An odd exponent keeps a negative base negative, and a negative result is - // always within a positive max, so only cap positive bases; negative bases - // fall through to POWER() and their (negative) result is applied. - $whens[] = "WHEN {$col} > 0 AND :$bindKey * LN({$col}) > LN(:$maxKey) THEN {$col}"; + $guards[] = "WHEN {$columnValue} > 0 AND :$bindKey * LN({$columnValue}) > LN(:$maxKey) THEN {$columnValue}"; } else { - // Otherwise the result is non-negative, so its magnitude equals its value — - // cap either sign. ABS() keeps LN() defined for a negative even-power base. - $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LN(ABS({$col})) > LN(:$maxKey) THEN {$col}"; + $guards[] = "WHEN {$columnValue} <> 0 AND :$bindKey * LN(ABS({$columnValue})) > LN(:$maxKey) THEN {$columnValue}"; } - $whenSql = \implode(' ', $whens); - return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END"; + return "{$quotedColumn} = CASE ".\implode(' ', $guards)." ELSE POWER({$columnValue}, :$bindKey) END"; } + return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; - // String operators - case Operator::TYPE_STRING_CONCAT: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? ''); + case OperatorType::StringConcat: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL({$quotedColumn}, '') || :$bindKey"; - case Operator::TYPE_STRING_REPLACE: - $values = $operator->getValues(); - $searchKey = $this->registerOperatorBind($binds, $values[0] ?? ''); - $replaceKey = $this->registerOperatorBind($binds, $values[1] ?? ''); + case OperatorType::StringReplace: + $searchKey = "op_{$bindIndex}"; + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = REPLACE({$quotedColumn}, :$searchKey, :$replaceKey)"; - // Boolean operators - case Operator::TYPE_TOGGLE: - // SQLite: toggle boolean (0 or 1), treat NULL as 0 + case OperatorType::Toggle: return "{$quotedColumn} = CASE WHEN COALESCE({$quotedColumn}, 0) = 0 THEN 1 ELSE 0 END"; - // Array operators - case Operator::TYPE_ARRAY_APPEND: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); - // SQLite: merge arrays by using json_group_array on extracted elements - // We use json_each to extract elements from both arrays and combine them + case OperatorType::ArrayAppend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = ( SELECT json_group_array(value) FROM ( @@ -2178,10 +2026,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) )"; - case Operator::TYPE_ARRAY_PREPEND: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); - // SQLite: prepend by extracting and recombining with new elements first + case OperatorType::ArrayPrepend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = ( SELECT json_group_array(value) FROM ( @@ -2191,36 +2039,29 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) )"; - case Operator::TYPE_ARRAY_UNIQUE: - // SQLite: get distinct values from JSON array + case OperatorType::ArrayUnique: return "{$quotedColumn} = ( SELECT json_group_array(DISTINCT value) FROM json_each(IFNULL({$quotedColumn}, '[]')) )"; - case Operator::TYPE_ARRAY_REMOVE: - $values = $operator->getValues(); - $removeValue = $values[0] ?? null; - // Cast scalars to string so the value binds as PDO::PARAM_STR, preserving the - // pre-refactor behavior (it was bound with an explicit PARAM_STR). Without the - // cast, getPDOType() would bind a number as PARAM_INT. Do not drop it. - $removeValue = is_array($removeValue) ? json_encode($removeValue) : (string)$removeValue; - $bindKey = $this->registerOperatorBind($binds, $removeValue); - // SQLite: remove specific value from array + case OperatorType::ArrayRemove: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + $removed = \is_float($values[0] ?? null) ? "CAST(:$bindKey AS REAL)" : ":$bindKey"; + return "{$quotedColumn} = ( SELECT json_group_array(value) FROM json_each(IFNULL({$quotedColumn}, '[]')) - WHERE value != :$bindKey + WHERE value != {$removed} )"; - case Operator::TYPE_ARRAY_INSERT: - $values = $operator->getValues(); - $indexKey = $this->registerOperatorBind($binds, $values[0] ?? 0); - $valueKey = $this->registerOperatorBind($binds, json_encode($values[1] ?? null)); - // SQLite: Insert element at specific index by: - // 1. Take elements before index (0 to index-1) - // 2. Add new element - // 3. Take elements from index to end + case OperatorType::ArrayInsert: + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + // The bound value is JSON-encoded by parent, json() parses it back to a value, // then we wrap it in json_array() and extract to get the same format as json_each() return "{$quotedColumn} = ( @@ -2246,30 +2087,29 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) )"; - case Operator::TYPE_ARRAY_INTERSECT: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); - // SQLite: keep only values that exist in both arrays + case OperatorType::ArrayIntersect: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = ( SELECT json_group_array(value) FROM json_each(IFNULL({$quotedColumn}, '[]')) WHERE value IN (SELECT value FROM json_each(:$bindKey)) )"; - case Operator::TYPE_ARRAY_DIFF: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); - // SQLite: remove values that exist in the comparison array + case OperatorType::ArrayDiff: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = ( SELECT json_group_array(value) FROM json_each(IFNULL({$quotedColumn}, '[]')) WHERE value NOT IN (SELECT value FROM json_each(:$bindKey)) )"; - case Operator::TYPE_ARRAY_FILTER: + case OperatorType::ArrayFilter: $values = $operator->getValues(); if (empty($values)) { - // No filter criteria, return array unchanged return "{$quotedColumn} = {$quotedColumn}"; } @@ -2302,9 +2142,8 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi return "{$quotedColumn} = {$quotedColumn}"; } - $filterValue = $values[1]; - $filterValue = (is_bool($filterValue)) ? (int)$filterValue : $filterValue; - $bindKey = $this->registerOperatorBind($binds, $filterValue); + $bindKey = "op_{$bindIndex}"; + $bindIndex++; $operator = match ($filterType) { 'equal' => '=', @@ -2313,7 +2152,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi 'greaterThanEqual' => '>=', 'lessThan' => '<', 'lessThanEqual' => '<=', - default => throw new OperatorException('Unsupported filter type: ' . $filterType), + default => throw new OperatorException('Unsupported filter type: '.(\is_scalar($filterType) ? (string) $filterType : 'unknown')), }; // For numeric comparisons, cast to REAL; for equal/notEqual, use text comparison @@ -2337,50 +2176,173 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi return "{$quotedColumn} = {$quotedColumn}"; } - // Date operators // no break - case Operator::TYPE_DATE_ADD_DAYS: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0); + case OperatorType::DateAddDays: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; return "{$quotedColumn} = datetime({$quotedColumn}, :$bindKey || ' days')"; - case Operator::TYPE_DATE_SUB_DAYS: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0); + case OperatorType::DateSubDays: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; return "{$quotedColumn} = datetime({$quotedColumn}, '-' || abs(:$bindKey) || ' days')"; - case Operator::TYPE_DATE_SET_NOW: + case OperatorType::DateSetNow: return "{$quotedColumn} = datetime('now')"; default: - // Fall back to parent implementation for other operators - return parent::getOperatorSQL($column, $operator, $binds); + return null; } } + #[\Override] + protected function getConflictTenantExpression(string $column): string + { + $quoted = $this->quote($this->filter($column)); + + return 'CASE WHEN '.Storage::TENANT.' = excluded.'.Storage::TENANT." THEN excluded.{$quoted} ELSE {$quoted} END"; + } + + #[\Override] + protected function getConflictIncrementExpression(string $column): string + { + $quoted = $this->quote($this->filter($column)); + + return "{$quoted} + excluded.{$quoted}"; + } + + #[\Override] + protected function getConflictTenantIncrementExpression(string $column): string + { + $quoted = $this->quote($this->filter($column)); + + return 'CASE WHEN '.Storage::TENANT.' = excluded.'.Storage::TENANT." THEN {$quoted} + excluded.{$quoted} ELSE {$quoted} END"; + } + /** - * Override getUpsertStatement to use SQLite's ON CONFLICT syntax instead of MariaDB's ON DUPLICATE KEY UPDATE + * Override executeUpsertBatch because SQLite uses ON CONFLICT syntax which + * is not supported by the MySQL query builder that SQLite inherits. * - * @param string $tableName - * @param string $columns - * @param array $batchKeys - * @param array $attributes - * @param array $bindValues - * @param string $attribute - * @param array $operators - * @return mixed + * @param string $name The filtered collection name + * @param array $changes The changes to upsert + * @param array $spatialAttributes Spatial column names + * @param string $attribute Increment attribute name (empty if none) + * @param array $operators Operator map keyed by attribute name + * @param array $attributeDefaults Attribute default values + * + * @throws DatabaseException */ - public function getUpsertStatement( - string $tableName, - string $columns, - array $batchKeys, - array $attributes, - array $bindValues, - string $attribute = '', - array $operators = [], - ): mixed { + #[\Override] + protected function executeUpsertBatch( + string $name, + array $changes, + array $spatialAttributes, + string $attribute, + array $operators, + array $attributeDefaults, + bool $hasOperators + ): void { + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $allColumnNames = []; + $documentsData = []; + + foreach ($changes as $change) { + $document = $change->new; + + if ($hasOperators) { + $extracted = Operator::extractOperators($document->getAttributes()); + $currentRegularAttributes = $extracted['updates']; + $extractedOperators = $extracted['operators']; + + if ($change->old->isEmpty() && ! empty($extractedOperators)) { + foreach ($extractedOperators as $operatorKey => $operator) { + $default = $attributeDefaults[$operatorKey] ?? null; + $value = $this->applyOperatorToValue($operator, $default); + if ($operator->getMethod()->isNumeric() && \is_string($value) && ! BigInt::fitsPhpInt($value)) { + throw new LimitException('Value out of range'); + } + $currentRegularAttributes[$operatorKey] = $value; + } + } + + $currentRegularAttributes[Storage::UID] = $document->getId(); + $currentRegularAttributes[Storage::CREATED_AT] = $document->getCreatedAt() ? $document->getCreatedAt() : null; + $currentRegularAttributes[Storage::UPDATED_AT] = $document->getUpdatedAt() ? $document->getUpdatedAt() : null; + } else { + $currentRegularAttributes = $document->getAttributes(); + $currentRegularAttributes[Storage::UID] = $document->getId(); + $currentRegularAttributes[Storage::CREATED_AT] = $document->getCreatedAt() ? DatabaseDateTime::setTimezone($document->getCreatedAt()) : null; + $currentRegularAttributes[Storage::UPDATED_AT] = $document->getUpdatedAt() ? DatabaseDateTime::setTimezone($document->getUpdatedAt()) : null; + } + + $currentRegularAttributes[Storage::PERMISSIONS] = \json_encode($document->getPermissions()); + + if (! empty($document->getSequence())) { + $currentRegularAttributes[Storage::SEQUENCE] = $document->getSequence(); + } + + $currentRegularAttributes = $this->decorateRow($currentRegularAttributes, $document); + + foreach (\array_keys($currentRegularAttributes) as $colName) { + $allColumnNames[$colName] = true; + } + + $documentsData[] = ['regularAttributes' => $currentRegularAttributes]; + } + + foreach (\array_keys($operators) as $colName) { + $allColumnNames[$colName] = true; + } + + $allColumnNames = \array_keys($allColumnNames); + \sort($allColumnNames); + + $columnsArray = []; + foreach ($allColumnNames as $attr) { + $columnsArray[] = "{$this->quote($this->filter($attr))}"; + } + $columns = '('.\implode(', ', $columnsArray).')'; + + foreach ($documentsData as $docData) { + $currentRegularAttributes = $docData['regularAttributes']; + $bindKeys = []; + + foreach ($allColumnNames as $attributeKey) { + $attrValue = $currentRegularAttributes[$attributeKey] ?? null; + + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } + + if (in_array($attributeKey, $spatialAttributes) && $attrValue !== null) { + $bindKey = 'key_'.$bindIndex; + $bindKeys[] = $this->getSpatialGeometryFromText(':'.$bindKey); + } else { + if ($this->supports(Capability::IntegerBooleans)) { + $attrValue = (\is_bool($attrValue)) ? (int) $attrValue : $attrValue; + } + $bindKey = 'key_'.$bindIndex; + $bindKeys[] = ':'.$bindKey; + } + $bindValues[$bindKey] = $attrValue; + $bindIndex++; + } + + $batchKeys[] = '('.\implode(', ', $bindKeys).')'; + } + + $regularAttributes = []; + foreach ($allColumnNames as $colName) { + $regularAttributes[$colName] = null; + } + foreach ($documentsData[0]['regularAttributes'] as $key => $value) { + $regularAttributes[$key] = $value; + } + $getUpdateClause = function (string $attribute, bool $increment = false): string { $attribute = $this->quote($this->filter($attribute)); if ($increment) { @@ -2390,110 +2352,67 @@ public function getUpsertStatement( } if ($this->sharedTables) { - return "{$attribute} = CASE WHEN _tenant = excluded._tenant THEN {$new} ELSE {$attribute} END"; + return "{$attribute} = CASE WHEN ".Storage::TENANT.' = excluded.'.Storage::TENANT." THEN {$new} ELSE {$attribute} END"; } return "{$attribute} = {$new}"; }; $updateColumns = []; - $operatorBinds = []; + $bindIndex = 0; - if (!empty($attribute)) { - // Increment specific column by its new value in place + if (! empty($attribute)) { $updateColumns = [ $getUpdateClause($attribute, increment: true), - $getUpdateClause('_updatedAt'), + $getUpdateClause(Storage::UPDATED_AT), ]; } else { - // Update all columns, handling operators separately - foreach (\array_keys($attributes) as $attr) { - /** - * @var string $attr - */ + foreach (\array_keys($regularAttributes) as $attr) { + /** @var string $attr */ $filteredAttr = $this->filter($attr); - // Check if this attribute has an operator if (isset($operators[$attr])) { - $operatorSQL = $this->getOperatorSQL($filteredAttr, $operators[$attr], $operatorBinds); + $operatorSQL = $this->getOperatorSql($filteredAttr, $operators[$attr], $bindIndex); if ($operatorSQL !== null) { $updateColumns[] = $operatorSQL; } } else { - if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) { + if (! in_array($attr, [Storage::UID, Storage::SEQUENCE, Storage::CREATED_AT, Storage::TENANT])) { $updateColumns[] = $getUpdateClause($filteredAttr); } } } } - // getSQLIndex prepends `_tenant` to every index column list + // getSqlIndex prepends `_tenant` to every index column list // under shared tables, so the actual UNIQUE on the documents // table is (_tenant, _uid). SQLite's ON CONFLICT clause needs // the same column order to match a UNIQUE constraint. - $conflictKeys = $this->sharedTables ? '(_tenant, _uid)' : '(_uid)'; + $conflictKeys = $this->sharedTables + ? '('.Storage::TENANT.', '.Storage::UID.')' + : '('.Storage::UID.')'; - $stmt = $this->getPDO()->prepare( - " - INSERT INTO {$this->getSQLTable($tableName)} {$columns} - VALUES " . \implode(', ', $batchKeys) . " + $statement = $this->prepare( + "INSERT INTO {$this->getTable($name)} {$columns} + VALUES ".\implode(', ', $batchKeys)." ON CONFLICT {$conflictKeys} DO UPDATE - SET " . \implode(', ', $updateColumns) + SET ".\implode(', ', $updateColumns), + event: Event::DocumentsUpsert ); - // Bind regular attribute values foreach ($bindValues as $key => $binding) { - $stmt->bindValue($key, $binding, $this->getPDOType($binding)); + $statement->bindValue($key, $binding, $this->getPdoType($binding)); } - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); + $opIndexForBinding = 0; + foreach (array_keys($regularAttributes) as $attr) { + if (isset($operators[$attr])) { + $this->bindOperatorParameters($statement, $operators[$attr], $opIndexForBinding); + } } - return $stmt; - } - - public function getSupportForAlterLocks(): bool - { - return false; - } - - public function getSupportNonUtfCharacters(): bool - { - return false; - } - - /** - * Is PCRE regex supported? - * SQLite does not have native REGEXP support - it requires compile-time option or user-defined function - * - * @return bool - */ - public function getSupportForPCRERegex(): bool - { - return $this->pcreRegistered; - } - - /** - * Is POSIX regex supported? - * SQLite does not have native REGEXP support - it requires compile-time option or user-defined function - * - * @return bool - */ - public function getSupportForPOSIXRegex(): bool - { - // The PHP-implemented REGEXP UDF runs preg_match (PCRE), not POSIX. - return false; - } - - public function getSupportForTTLIndexes(): bool - { - return false; - } - - protected function getInsertKeyword(): string - { - return $this->skipDuplicates ? 'INSERT OR IGNORE INTO' : 'INSERT INTO'; + $this->execute($statement); + $statement->closeCursor(); } /** @@ -2501,8 +2420,9 @@ protected function getInsertKeyword(): string * shared SQL implementation that joins many ADD COLUMN clauses with * commas doesn't parse here. Loop over createAttribute instead. * - * @param array> $attributes + * @param list $attributes */ + #[\Override] public function createAttributes(string $collection, array $attributes): bool { // The flag advertises atomic batch creation. SQLite has no @@ -2512,15 +2432,7 @@ public function createAttributes(string $collection, array $attributes): bool $this->startTransaction(); try { foreach ($attributes as $attribute) { - $this->createAttribute( - $collection, - $attribute['$id'], - $attribute['type'], - $attribute['size'] ?? 0, - $attribute['signed'] ?? true, - $attribute['array'] ?? false, - $attribute['required'] ?? false, - ); + $this->createAttributeWithEvent($collection, $attribute, Event::AttributesCreate); } $this->commitTransaction(); } catch (\Throwable $e) { @@ -2535,648 +2447,345 @@ public function createAttributes(string $collection, array $attributes): bool } /** - * MariaDB::createRelationship concatenates multiple ALTER TABLE - * statements with `;` and runs them through a single prepare/execute, - * which only works because MySQL accepts multi-statement queries. - * SQLite's PDO driver runs the first statement and silently drops the - * rest, so re-implement the dispatch with one statement per call. + * SQL::createRelationship concatenates multiple ALTER TABLE statements + * with `;` and runs them through a single prepare/execute, which only + * works because MySQL accepts multi-statement queries. SQLite's PDO + * driver runs the first statement and silently drops the rest, so + * re-implement the dispatch with one statement per call. */ - public function createRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay = false, - string $id = '', - string $twoWayKey = '' - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $id = $this->filter($id); - $twoWayKey = $this->filter($twoWayKey); - $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false, false, false); - - $statements = match ($type) { - Database::RELATION_ONE_TO_ONE => $twoWay + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool + { + $table = $this->getTable($this->filter($collection)); + $relatedTable = $this->getTable($this->filter($relationship->relatedCollection)); + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $sqlType = $this->getSqlType(ColumnType::Relationship, 0, false, false, false); + + $statements = match ($relationship->type) { + RelationshipType::OneToOne => $relationship->twoWay ? [ - "ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL", + "ALTER TABLE {$table} ADD COLUMN `{$key}` {$sqlType} DEFAULT NULL", "ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL", ] - : ["ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL"], - Database::RELATION_ONE_TO_MANY => ["ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL"], - Database::RELATION_MANY_TO_ONE => ["ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL"], - Database::RELATION_MANY_TO_MANY => [], - default => throw new DatabaseException('Invalid relationship type'), + : ["ALTER TABLE {$table} ADD COLUMN `{$key}` {$sqlType} DEFAULT NULL"], + RelationshipType::OneToMany => ["ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL"], + RelationshipType::ManyToOne => ["ALTER TABLE {$table} ADD COLUMN `{$key}` {$sqlType} DEFAULT NULL"], + RelationshipType::ManyToMany => [], }; - foreach ($statements as $stmt) { - $stmt = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $stmt); - $this->getPDO()->prepare($stmt)->execute(); + foreach ($statements as $statement) { + $this->execute($this->prepare($statement, event: Event::AttributeCreate)); } return true; } - public function updateRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side, - ?string $newKey = null, - ?string $newTwoWayKey = null, - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); - - if (!\is_null($newKey)) { - $newKey = $this->filter($newKey); - } - if (!\is_null($newTwoWayKey)) { - $newTwoWayKey = $this->filter($newTwoWayKey); - } + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + { + $table = $this->getTable($this->filter($collection)); + $relatedTable = $this->getTable($this->filter($relationship->relatedCollection)); + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $twoWay = $update->twoWay ?? $relationship->twoWay; + $newKey = $update->key === null ? null : $this->filter($update->key); + $newTwoWayKey = $update->twoWayKey === null ? null : $this->filter($update->twoWayKey); $statements = []; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if (!\is_null($newKey) && $key !== $newKey) { + switch ($relationship->type) { + case RelationshipType::OneToOne: + if (($twoWay || $side === RelationshipSide::Parent) && $newKey !== null && $key !== $newKey) { $statements[] = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`"; } - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + if (($twoWay || $side === RelationshipSide::Child) && $newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $statements[] = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`"; } break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - if (!\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $statements[] = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`"; } - } else { - if (!\is_null($newKey) && $key !== $newKey) { - $statements[] = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`"; - } + } elseif ($newKey !== null && $key !== $newKey) { + $statements[] = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`"; } break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - if (!\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Child) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $statements[] = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`"; } - } else { - if (!\is_null($newKey) && $key !== $newKey) { - $statements[] = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`"; - } + } elseif ($newKey !== null && $key !== $newKey) { + $statements[] = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`"; } break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); - - $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); + case RelationshipType::ManyToMany: + $junction = $this->getTable($this->getJunctionName($collection, $relationship->relatedCollection, $side)); - if (!\is_null($newKey)) { + if ($newKey !== null && $key !== $newKey) { $statements[] = "ALTER TABLE {$junction} RENAME COLUMN `{$key}` TO `{$newKey}`"; } - if ($twoWay && !\is_null($newTwoWayKey)) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $statements[] = "ALTER TABLE {$junction} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`"; } break; - default: - throw new DatabaseException('Invalid relationship type'); } - foreach ($statements as $stmt) { - $stmt = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $stmt); - $this->getPDO()->prepare($stmt)->execute(); + foreach ($statements as $statement) { + $this->execute($this->prepare($statement, event: Event::AttributeUpdate)); } return true; } - public function deleteRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side - ): bool { - $name = $this->filter($collection); - $relatedName = $this->filter($relatedCollection); - $table = $this->getSQLTable($name); - $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + $table = $this->getTable($this->filter($collection)); + $relatedTable = $this->getTable($this->filter($relationship->relatedCollection)); + $key = $this->filter($relationship->key ?? ''); + $twoWayKey = $this->filter($relationship->twoWayKey ?? ''); + $twoWay = $relationship->twoWay; $statements = []; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + switch ($relationship->type) { + case RelationshipType::OneToOne: + if ($side === RelationshipSide::Parent) { $statements[] = "ALTER TABLE {$table} DROP COLUMN `{$key}`"; if ($twoWay) { $statements[] = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`"; } - } elseif ($side === Database::RELATION_SIDE_CHILD) { + } else { $statements[] = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`"; if ($twoWay) { $statements[] = "ALTER TABLE {$table} DROP COLUMN `{$key}`"; } } break; - case Database::RELATION_ONE_TO_MANY: - $statements[] = $side === Database::RELATION_SIDE_PARENT + case RelationshipType::OneToMany: + $statements[] = $side === RelationshipSide::Parent ? "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`" : "ALTER TABLE {$table} DROP COLUMN `{$key}`"; break; - case Database::RELATION_MANY_TO_ONE: - $statements[] = $side === Database::RELATION_SIDE_PARENT + case RelationshipType::ManyToOne: + $statements[] = $side === RelationshipSide::Parent ? "ALTER TABLE {$table} DROP COLUMN `{$key}`" : "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`"; break; - case Database::RELATION_MANY_TO_MANY: - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collection = $this->getDocument($metadataCollection, $collection); - $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); + case RelationshipType::ManyToMany: + $junctionBase = $this->getJunctionName($collection, $relationship->relatedCollection, $side); - $junctionBase = $side === Database::RELATION_SIDE_PARENT - ? '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() - : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); - - $statements[] = "DROP TABLE {$this->getSQLTable($junctionBase)}"; - $statements[] = "DROP TABLE {$this->getSQLTable($junctionBase . '_perms')}"; + $statements[] = "DROP TABLE {$this->getTable($junctionBase)}"; + $statements[] = "DROP TABLE {$this->getTable(Storage::permissionsTable($junctionBase))}"; break; - default: - throw new DatabaseException('Invalid relationship type'); } - foreach ($statements as $stmt) { - $stmt = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $stmt); - $this->getPDO()->prepare($stmt)->execute(); + foreach ($statements as $statement) { + $this->execute($this->prepare($statement, event: Event::AttributeDelete)); } return true; } /** - * Introspect a collection's columns via PRAGMA table_info instead of - * MariaDB's INFORMATION_SCHEMA.COLUMNS, which doesn't exist in SQLite. - * Returned shape matches the MariaDB result enough that - * Database::analyzeCollection() doesn't have to special-case the - * adapter. - * - * @return array + * @return list */ + #[\Override] public function getSchemaAttributes(string $collection): array { $table = "{$this->getNamespace()}_{$this->filter($collection)}"; - $stmt = $this->getPDO()->prepare("PRAGMA table_info(`{$table}`)"); - $stmt->execute(); - $rows = $stmt->fetchAll(); - $stmt->closeCursor(); + $statement = $this->prepare("PRAGMA table_info(`{$table}`)", event: Event::CollectionRead); + $this->execute($statement); + $rows = $statement->fetchAll(); + $statement->closeCursor(); - $results = []; + $columns = []; foreach ($rows as $row) { - $rawType = (string) ($row['type'] ?? ''); - $parsed = $this->parseSqliteColumnType($rawType); + if (! \is_array($row) || ! \is_scalar($row['name'] ?? null)) { + continue; + } - $results[] = new Document([ - '$id' => $row['name'], - 'columnDefault' => $row['dflt_value'] ?? null, - 'isNullable' => empty($row['notnull']) ? 'YES' : 'NO', - 'dataType' => $parsed['dataType'], - 'characterMaximumLength' => $parsed['characterMaximumLength'], - 'numericPrecision' => $parsed['numericPrecision'], - 'numericScale' => $parsed['numericScale'], - 'datetimePrecision' => $parsed['datetimePrecision'], - 'columnType' => \strtolower($rawType), - 'columnKey' => !empty($row['pk']) ? 'PRI' : '', - 'extra' => '', - ]); + $type = \is_scalar($row['type'] ?? null) ? (string) $row['type'] : ''; + $columns[] = new SchemaColumn( + name: (string) $row['name'], + type: $this->canonicalColumnType($type), + length: $this->getCharacterLength($type), + nullable: empty($row['notnull']), + ); } - return $results; + return $columns; + } + + /** + * @return array + */ + #[\Override] + protected function getColumnNames(string $collection): array + { + return \array_map( + static fn (SchemaColumn $column): string => $column->name, + $this->getSchemaAttributes($collection), + ); } /** - * Introspect a collection's indexes via PRAGMA index_list + - * PRAGMA index_info. Returns one Document per index with a `columns` - * array, matching the grouped shape MariaDB::getSchemaIndexes returns - * so Database::createIndex can compare `columns` against the requested - * attributes without special-casing the adapter. + * Index names are global in SQLite, so each carries the namespace, tenant and collection; an index is reported + * under its key, the current tenant's copy before another's. PRAGMA index_list misses the FTS5 tables fulltext + * indexes are kept in, so those are added. * - * @return array + * @return list */ + #[\Override] public function getSchemaIndexes(string $collection): array { - $table = "{$this->getNamespace()}_{$this->filter($collection)}"; - - $stmt = $this->getPDO()->prepare("PRAGMA index_list(`{$table}`)"); - $stmt->execute(); - $indexes = $stmt->fetchAll(); - $stmt->closeCursor(); + $filtered = $this->filter($collection); + $table = "{$this->getNamespace()}_{$filtered}"; + $own = "{$this->getNamespace()}_{$this->getTenantSegment()}_{$filtered}_"; + $anyTenant = '/^'.\preg_quote($this->getNamespace(), '/').'_[A-Za-z0-9_-]*?_'.\preg_quote($filtered, '/').'_(.+)$/'; - $results = []; - foreach ($indexes as $index) { - $name = $index['name']; - $unique = !empty($index['unique']); + $statement = $this->prepare("PRAGMA index_list(`{$table}`)", event: Event::CollectionRead); + $this->execute($statement); + $rows = $statement->fetchAll(); + $statement->closeCursor(); - $colStmt = $this->getPDO()->prepare("PRAGMA index_info(`{$name}`)"); - $colStmt->execute(); - $cols = $colStmt->fetchAll(); - $colStmt->closeCursor(); - - \usort($cols, fn ($a, $b) => ((int) $a['seqno']) <=> ((int) $b['seqno'])); - - $columns = []; - $lengths = []; - foreach ($cols as $col) { - $columns[] = $col['name']; - $lengths[] = null; + $indexes = []; + foreach ($rows as $row) { + if (! \is_array($row)) { + continue; } + $name = \is_scalar($row['name'] ?? null) ? (string) $row['name'] : ''; - $results[] = new Document([ - '$id' => $name, - 'indexName' => $name, - 'indexType' => 'BTREE', - 'nonUnique' => $unique ? 0 : 1, - 'columns' => $columns, - 'lengths' => $lengths, - ]); - } + $owned = \str_starts_with($name, $own); + $key = match (true) { + $owned => \substr($name, \strlen($own)), + \preg_match($anyTenant, $name, $matches) === 1 => $matches[1], + default => $name, + }; + if (! $owned && isset($indexes[$key])) { + continue; + } - // PRAGMA index_list misses FTS5 vtables. - foreach ($this->getFulltextSchemaIndexes($collection) as $entry) { - $results[] = new Document($entry); + $columns = $this->getIndexColumns($name); + $indexes[$key] = new SchemaIndex( + name: $key, + type: empty($row['unique']) ? IndexType::Key : IndexType::Unique, + columns: $columns, + lengths: \array_fill(0, \count($columns), null), + ); } - return $results; + return [...\array_values($indexes), ...$this->getFulltextSchemaIndexes($collection)]; } /** - * Schema-index entries for FTS5 fulltext tables on `$collection`. - * Maps each back to a metadata index id when possible. - * - * Each entry has keys: - * - `$id`: string - * - `indexName`: string - * - `indexType`: string - * - `nonUnique`: int - * - `columns`: array - * - `lengths`: array - * - * @return array> + * @return list */ - protected function getFulltextSchemaIndexes(string $collection): array + private function getIndexColumns(string $index): array { - $tables = $this->findFulltextTables($collection); - - if (empty($tables)) { - return []; - } - - $hashToId = []; - try { - $metadataCollection = new Document(['$id' => Database::METADATA]); - $collectionDoc = $this->getDocument($metadataCollection, $collection); - if (!$collectionDoc->isEmpty()) { - foreach ($collectionDoc->getAttribute('indexes', []) as $index) { - $indexId = $index instanceof Document - ? $index->getId() - : (\is_array($index) ? ($index['$id'] ?? null) : null); - $type = $index instanceof Document - ? $index->getAttribute('type') - : (\is_array($index) ? ($index['type'] ?? null) : null); - - if ($indexId === null || $type !== Database::INDEX_FULLTEXT) { - continue; - } - - $attributes = $index instanceof Document - ? $index->getAttribute('attributes', []) - : ($index['attributes'] ?? []); - - $internal = \array_map( - fn (string $a) => $this->getInternalKeyForAttribute($a), - (array) $attributes - ); - $hashToId[$this->getFulltextTableName($collection, $internal)] = $this->filter((string) $indexId); - } - } - } catch (\Throwable) { - } - - $entries = []; - foreach ($tables as $ftsTable) { - $info = $this->getPDO()->prepare("PRAGMA table_info(`{$ftsTable}`)"); - $info->execute(); - $cols = $info->fetchAll(PDO::FETCH_ASSOC); - $info->closeCursor(); + $statement = $this->prepare("PRAGMA index_info(`{$index}`)", event: Event::CollectionRead); + $this->execute($statement); + $rows = $statement->fetchAll(); + $statement->closeCursor(); - $columns = []; - foreach ($cols as $col) { - $name = (string) ($col['name'] ?? ''); - if ($name === '') { - continue; - } - $columns[] = $name; + $columns = []; + foreach ($rows as $row) { + if (! \is_array($row)) { + continue; } - - $id = $hashToId[$ftsTable] ?? $ftsTable; - - $entries[] = [ - '$id' => $id, - 'indexName' => $id, - 'indexType' => 'FULLTEXT', - 'nonUnique' => 1, - 'columns' => $columns, - 'lengths' => \array_fill(0, \count($columns), null), - ]; + $position = \is_scalar($row['seqno'] ?? null) ? (int) $row['seqno'] : \count($columns); + $columns[$position] = \is_scalar($row['name'] ?? null) ? (string) $row['name'] : ''; } + \ksort($columns); - return $entries; + return \array_values($columns); } /** - * Parse a SQLite type declaration like `VARCHAR(36)` into the column-info - * shape exposed by getSchemaAttributes. Mirrors what MariaDB returns from - * INFORMATION_SCHEMA.COLUMNS so callers don't have to special-case the - * adapter — TEXT family types report their MariaDB byte ceilings, - * VARCHAR/CHAR thread the parenthesised size into characterMaximumLength, - * DATETIME's parenthesised value routes to datetimePrecision, and - * integer types fall back to MariaDB's default precision values. + * The FTS5 tables of $collection's fulltext indexes, each under the key of the index it serves when known. * - * @return array{ - * dataType: string, - * characterMaximumLength: ?string, - * numericPrecision: ?string, - * numericScale: ?string, - * datetimePrecision: ?string, - * } + * @return list */ - private function parseSqliteColumnType(string $declaration): array + protected function getFulltextSchemaIndexes(string $collection): array { - $declaration = \trim(\preg_replace('/\s+/', ' ', $declaration) ?? ''); - - $base = $declaration; - $argument = null; - $secondArgument = null; - if (\preg_match('/^([A-Za-z]+)\s*\((\d+)(?:\s*,\s*(\d+))?\s*\)/', $declaration, $matches) === 1) { - $base = $matches[1]; - $argument = (int) $matches[2]; - if (isset($matches[3]) && $matches[3] !== '') { - $secondArgument = (int) $matches[3]; - } - } + $tables = $this->findFulltextTables($collection); - $dataType = \strtolower($base); - // SQLite spells INT and INTEGER interchangeably for declared types. - // Under emulation, canonicalise to MariaDB's reported `int` so - // getSchemaAttributes matches that adapter's contract; otherwise - // keep the verbatim form the user declared. - if ($this->emulateMySQL && $dataType === 'integer') { - $dataType = 'int'; + if (empty($tables)) { + return []; } - $result = [ - 'dataType' => $dataType, - 'characterMaximumLength' => null, - 'numericPrecision' => null, - 'numericScale' => null, - 'datetimePrecision' => null, - ]; - - // VARCHAR / CHAR / DATETIME(n) / DECIMAL(p,s) length+precision - // come straight from the declaration — that's true for vanilla - // SQLite too. The MariaDB byte ceilings (TEXT/MEDIUMTEXT/etc.) - // and the integer/float precision defaults are MariaDB-specific - // INFORMATION_SCHEMA conventions, so report them only under - // emulation. - switch ($dataType) { - case 'varchar': - case 'char': - if ($argument !== null) { - $result['characterMaximumLength'] = (string) $argument; - } - break; + $keys = \array_flip($this->getFulltextTablesByIndexId($collection)); - case 'datetime': - case 'timestamp': - case 'time': - if ($argument !== null) { - $result['datetimePrecision'] = (string) $argument; - } - break; + $indexes = []; + foreach ($tables as $fulltextTable) { + $statement = $this->prepare("PRAGMA table_info(`{$fulltextTable}`)"); + $statement->execute(); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + $statement->closeCursor(); - case 'decimal': - case 'numeric': - if ($argument !== null) { - $result['numericPrecision'] = (string) $argument; - } - if ($secondArgument !== null) { - $result['numericScale'] = (string) $secondArgument; - } elseif ($this->emulateMySQL && $argument !== null) { - $result['numericScale'] = '0'; + $columns = []; + foreach ($rows as $row) { + if (! \is_array($row) || ! \is_scalar($row['name'] ?? null) || $row['name'] === '') { + continue; } - break; - } - - /** - * MariaDB byte ceilings for TEXT-family types, mirrored so PRAGMA-based - * introspection produces the same characterMaximumLength values that - * INFORMATION_SCHEMA.COLUMNS would on MariaDB. - */ - - if ($this->emulateMySQL) { - switch ($dataType) { - case 'text': - $result['characterMaximumLength'] = '' . Database::MAX_TEXT_BYTES; - break; - - case 'mediumtext': - $result['characterMaximumLength'] = '' . Database::MAX_MEDIUMTEXT_BYTES; - break; - - case 'longtext': - case 'json': - $result['characterMaximumLength'] = '' . Database::MAX_LONGTEXT_BYTES; - break; - - case 'tinyint': - $result['numericPrecision'] = '3'; - break; - - case 'smallint': - $result['numericPrecision'] = '5'; - break; - - case 'mediumint': - $result['numericPrecision'] = '7'; - break; - - case 'int': - case 'integer': - $result['numericPrecision'] = '10'; - break; - - case 'bigint': - $result['numericPrecision'] = '19'; - break; - - case 'decimal': - case 'numeric': - if ($result['numericPrecision'] === null) { - $result['numericPrecision'] = '10'; - } - break; - - case 'float': - $result['numericPrecision'] = '12'; - break; - - case 'double': - $result['numericPrecision'] = '22'; - break; + $columns[] = (string) $row['name']; } + + $indexes[] = new SchemaIndex( + name: (string) ($keys[$fulltextTable] ?? $fulltextTable), + type: IndexType::Fulltext, + columns: $columns, + lengths: \array_fill(0, \count($columns), null), + ); } - return $result; + return $indexes; } /** - * SQLite has no MATCH ... AGAINST. Route SEARCH/NOT_SEARCH through the - * collection's FTS5 virtual table; for LIKE-using comparisons append - * an explicit ESCAPE clause because SQLite — unlike MariaDB — does - * not honour `\` as a default escape and the inherited - * escapeWildcards() emits backslash escapes on every wildcard. - * Everything else falls through to the MariaDB implementation. + * The character length of a declared type: the size of a VARCHAR or CHAR, and under MySQL emulation the byte + * ceilings MariaDB reports for the TEXT family. */ - protected function getSQLCondition(Query $query, array &$binds, ?string $forCollection = null): string + private function getCharacterLength(string $declaration): ?int { - $method = $query->getMethod(); - - $likeMethods = [ - Query::TYPE_STARTS_WITH, - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_CONTAINS, - Query::TYPE_CONTAINS_ANY, - Query::TYPE_NOT_CONTAINS, - ]; - - if (\in_array($method, $likeMethods, true)) { - // Array CONTAINS via json_each — exact element match without - // LIKE substring false positives (`%2%` matching `[12, 200]`). - $arrayContainsMethods = [ - Query::TYPE_CONTAINS, - Query::TYPE_CONTAINS_ANY, - Query::TYPE_NOT_CONTAINS, - ]; - if ($query->onArray() && \in_array($method, $arrayContainsMethods, true)) { - return $this->buildArrayContainsCondition($query, $binds); - } - - return $this->getLikeCondition($query, $binds); - } - - if ($method !== Query::TYPE_SEARCH && $method !== Query::TYPE_NOT_SEARCH) { - return parent::getSQLCondition($query, $binds, $forCollection); - } - - $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - $attribute = $this->filter($query->getAttribute()); - $alias = $this->quote(Query::DEFAULT_ALIAS); - $placeholder = ID::unique(); - - $rawValue = (string) $query->getValue(); - $ftsValue = $this->getFTS5Value($rawValue); - - if ($ftsValue === '') { - // Empty term — FTS5 syntax-errors on the empty string. - return $method === Query::TYPE_SEARCH ? '1 = 0' : '1 = 1'; + if (\preg_match('/^\s*(VARCHAR|CHAR)\s*\(\s*(\d+)\s*\)/i', $declaration, $matches) === 1) { + return (int) $matches[2]; } - $ftsTable = $forCollection === null - ? null - : $this->findFulltextTableForAttribute($forCollection, $attribute); - - if ($ftsTable === null) { - // LIKE on the raw value — the FTS5-formatted form embeds - // `OR`/`*` that LIKE would treat as literal. - return $this->buildSearchLikeFallback($attribute, $rawValue, $alias, $placeholder, $method, $binds); + if (! $this->emulateMySQL) { + return null; } - $binds[":{$placeholder}_0"] = $ftsValue; - - $subquery = "{$alias}.`_id` IN (SELECT rowid FROM `{$ftsTable}` WHERE `{$ftsTable}` MATCH :{$placeholder}_0)"; - - return $method === Query::TYPE_SEARCH ? $subquery : "NOT ({$subquery})"; - } - - /** - * SEARCH fallback to LIKE when no FTS5 table covers the attribute. - * - * @param array $binds - */ - private function buildSearchLikeFallback( - string $attribute, - string $value, - string $alias, - string $placeholder, - string $method, - array &$binds, - ): string { - $binds[":{$placeholder}_0"] = '%' . $this->escapeWildcards($value) . '%'; - $sql = "{$alias}.{$this->quote($attribute)} LIKE :{$placeholder}_0 ESCAPE '\\'"; - - return $method === Query::TYPE_SEARCH ? $sql : "NOT ({$sql})"; + return match (\strtolower(\trim($declaration))) { + 'text' => Database::MAX_TEXT_BYTES, + 'mediumtext' => Database::MAX_MEDIUMTEXT_BYTES, + 'longtext', 'json' => Database::MAX_LONGTEXT_BYTES, + default => null, + }; } /** - * Array CONTAINS / CONTAINS_ANY / NOT_CONTAINS via json_each. Exact - * element match — avoids the LIKE substring false positives where - * `%2%` matches `[12, 200]` and `%"apple"%` matches `["pineapple"]`. - * - * @param array $binds + * @param list $joins */ - private function buildArrayContainsCondition(Query $query, array &$binds): string + private function findSearchFulltextTable(string $attribute, string $collection, array $joins): ?string { - $method = $query->getMethod(); - $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - - $attribute = $this->quote($this->filter($query->getAttribute())); - $alias = $this->quote(Query::DEFAULT_ALIAS); - $placeholder = ID::unique(); - - $values = $query->getValues(); - if (empty($values)) { - return ''; + $dot = \strpos($attribute, '.'); + if ($dot === false) { + return $this->findFulltextTableForAttribute($collection, $attribute); } - $params = []; - foreach ($values as $key => $value) { - $param = ":{$placeholder}_{$key}"; - $binds[$param] = $value; - $params[] = $param; + $prefix = \substr($attribute, 0, $dot); + foreach ($joins as $join) { + if ($join->alias === $prefix) { + return $this->findFulltextTableForAttribute($join->table, \substr($attribute, $dot + 1)); + } } - $expression = "EXISTS (SELECT 1 FROM json_each({$alias}.{$attribute}) WHERE value IN (" - . \implode(', ', $params) - . '))'; - - return $method === Query::TYPE_NOT_CONTAINS ? "NOT {$expression}" : $expression; + return null; } /** @@ -3186,11 +2795,19 @@ private function buildArrayContainsCondition(Query $query, array &$binds): strin */ protected function findFulltextTableForAttribute(string $collection, string $attribute): ?string { - if (!\array_key_exists($collection, $this->ftsTableCache)) { - $this->ftsTableCache[$collection] = $this->buildFulltextAttributeMap($collection); + $dot = \strpos($attribute, '.'); + if ($dot !== false) { + return null; + } + + $attribute = $this->filter($this->getInternalKeyForAttribute($attribute)); + + $prefix = $this->getFulltextTablePrefix($collection); + if (!\array_key_exists($prefix, $this->ftsTableCache)) { + $this->ftsTableCache[$prefix] = $this->buildFulltextAttributeMap($collection); } - return $this->ftsTableCache[$collection][$attribute] ?? null; + return $this->ftsTableCache[$prefix][$attribute] ?? null; } /** @@ -3200,11 +2817,14 @@ private function buildFulltextAttributeMap(string $collection): array { $map = []; foreach ($this->findFulltextTables($collection) as $table) { - $info = $this->getPDO()->prepare("PRAGMA table_info(`{$table}`)"); + $info = $this->prepare("PRAGMA table_info(`{$table}`)"); $info->execute(); $cols = $info->fetchAll(PDO::FETCH_ASSOC); $info->closeCursor(); foreach ($cols as $col) { + if (! \is_array($col)) { + continue; + } $name = $col['name'] ?? null; if (\is_string($name) && $name !== '') { $map[$name] = $table; @@ -3215,52 +2835,11 @@ private function buildFulltextAttributeMap(string $collection): array return $map; } - /** - * Compile STARTS_WITH / ENDS_WITH / CONTAINS (and NOT variants) into - * LIKE with an explicit ESCAPE clause — SQLite needs it to honour - * the backslash escapes escapeWildcards() inserts. - * - * @param array $binds - */ - protected function getLikeCondition(Query $query, array &$binds): string - { - $method = $query->getMethod(); - $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - - $attribute = $this->quote($this->filter($query->getAttribute())); - $alias = $this->quote(Query::DEFAULT_ALIAS); - $placeholder = ID::unique(); - - $isNotQuery = \in_array($method, [ - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_NOT_CONTAINS, - ], true); - - $conditions = []; - foreach ($query->getValues() as $key => $value) { - $bound = match ($method) { - Query::TYPE_STARTS_WITH, Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', - Query::TYPE_ENDS_WITH, Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), - Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS => '%' . $this->escapeWildcards($value) . '%', - default => $value, - }; - - $binds[":{$placeholder}_{$key}"] = $bound; - $operator = $isNotQuery ? 'NOT LIKE' : 'LIKE'; - $conditions[] = "{$alias}.{$attribute} {$operator} :{$placeholder}_{$key} ESCAPE '\\'"; - } - - $separator = $isNotQuery ? ' AND ' : ' OR '; - - return empty($conditions) ? '' : '(' . \implode($separator, $conditions) . ')'; - } - /** * Format a SEARCH term as MariaDB BOOLEAN MODE: OR-joined tokens with * the trailing token prefix-matched. Empty when no token survives. */ - protected function getFTS5Value(string $value): string + protected function getFts5Value(string $value): string { // Balanced wrapping `"..."` triggers exact-phrase mode. $exact = \strlen($value) >= 2 @@ -3289,7 +2868,7 @@ protected function getFTS5Value(string $value): string return $token; }, $tokens); $last = \array_pop($tokens); - if ($last !== null && !\str_starts_with($last, '"')) { + if (! \str_starts_with($last, '"')) { $last .= '*'; } $tokens[] = $last; diff --git a/src/Database/Adapter/Timeout.php b/src/Database/Adapter/Timeout.php new file mode 100644 index 0000000000..3c95120311 --- /dev/null +++ b/src/Database/Adapter/Timeout.php @@ -0,0 +1,46 @@ + + */ + protected array $timeouts = []; + + public function getTimeout(Event $event = Event::All): int + { + return $this->timeouts[$event->value] + ?? $this->timeouts[Event::All->value] + ?? $this->timeout; + } + + protected function setTimeoutState(int $milliseconds, Event $event): void + { + $this->timeouts[$event->value] = $milliseconds; + + if ($event === Event::All) { + $this->timeout = $milliseconds; + } + } + + protected function clearTimeoutState(Event $event): void + { + if ($event === Event::All) { + $this->timeouts = []; + $this->timeout = 0; + + return; + } + + unset($this->timeouts[$event->value]); + } +} diff --git a/src/Database/Attribute.php b/src/Database/Attribute.php new file mode 100644 index 0000000000..dfe0bb0b49 --- /dev/null +++ b/src/Database/Attribute.php @@ -0,0 +1,694 @@ + + */ + public const array TYPES = [ + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText, + ColumnType::Integer, + ColumnType::BigInteger, + ColumnType::Float, + ColumnType::Double, + ColumnType::Boolean, + ColumnType::Datetime, + ColumnType::Id, + ColumnType::Relationship, + ColumnType::Object, + ColumnType::Point, + ColumnType::Linestring, + ColumnType::Polygon, + ColumnType::Vector, + ]; + + private const string STORED_BIG_INTEGER = 'bigint'; + + private const string KEY = 'key'; + + private const string TYPE = 'type'; + + private const string SIZE = 'size'; + + private const string REQUIRED = 'required'; + + private const string DEFAULT = 'default'; + + private const string SIGNED = 'signed'; + + private const string ARRAY = 'array'; + + private const string FORMAT = 'format'; + + private const string FORMAT_OPTIONS = 'formatOptions'; + + private const string FILTERS = 'filters'; + + private const string OPTIONS = 'options'; + + /** + * @param list $filters + */ + private function __construct( + public string $key, + public ColumnType $type, + public ?int $size, + public bool $required, + public mixed $default, + public bool $signed, + public bool $array, + public ?Format $format, + public array $filters, + public ?Relationship $relationship, + public ?RelationshipSide $side, + ) { + } + + /** + * @param list $filters + */ + public static function string(string $key, int $size = Database::LENGTH_KEY, bool $required = false, mixed $default = null, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::String, $size, $required, $default, true, $array, $format, $filters); + } + + /** + * @param list $filters + */ + public static function varchar(string $key, int $size = Database::LENGTH_KEY, bool $required = false, mixed $default = null, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::Varchar, $size, $required, $default, true, $array, $format, $filters); + } + + /** + * @param list $filters + */ + public static function text(string $key, ?int $size = null, bool $required = false, mixed $default = null, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::Text, $size, $required, $default, true, $array, $format, $filters); + } + + /** + * @param list $filters + */ + public static function mediumText(string $key, ?int $size = null, bool $required = false, mixed $default = null, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::MediumText, $size, $required, $default, true, $array, $format, $filters); + } + + /** + * @param list $filters + */ + public static function longText(string $key, ?int $size = null, bool $required = false, mixed $default = null, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::LongText, $size, $required, $default, true, $array, $format, $filters); + } + + /** + * @param int|list|null $default + * @param list $filters + */ + public static function integer(string $key, bool $required = false, int|array|null $default = null, bool $signed = true, bool $array = false, IntegerWidth $width = IntegerWidth::Bits32, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::Integer, $width->size(), $required, $default, $signed, $array, $format, $filters); + } + + /** + * @param int|string|list|null $default + * @param list $filters + */ + public static function bigInteger(string $key, bool $required = false, int|string|array|null $default = null, bool $signed = true, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::BigInteger, null, $required, $default, $signed, $array, $format, $filters); + } + + /** + * @param float|int|list|null $default + * @param list $filters + */ + public static function float(string $key, bool $required = false, float|int|array|null $default = null, bool $signed = true, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::Float, null, $required, $default, $signed, $array, $format, $filters); + } + + /** + * @param float|int|list|null $default + * @param list $filters + */ + public static function double(string $key, bool $required = false, float|int|array|null $default = null, bool $signed = true, bool $array = false, ?Format $format = null, array $filters = []): self + { + return self::scalar($key, ColumnType::Double, null, $required, $default, $signed, $array, $format, $filters); + } + + /** + * @param bool|list|null $default + * @param list $filters + */ + public static function boolean(string $key, bool $required = false, bool|array|null $default = null, bool $array = false, array $filters = []): self + { + return self::scalar($key, ColumnType::Boolean, null, $required, $default, true, $array, null, $filters); + } + + /** + * @param string|list|null $default + */ + public static function datetime(string $key, bool $required = false, string|array|null $default = null, bool $array = false): self + { + return self::scalar($key, ColumnType::Datetime, null, $required, $default, false, $array, null, []); + } + + /** + * @param array|null $default + */ + public static function point(string $key, bool $required = false, ?array $default = null): self + { + return self::scalar($key, ColumnType::Point, null, $required, $default, true, false, null, []); + } + + /** + * @param array|null $default + */ + public static function lineString(string $key, bool $required = false, ?array $default = null): self + { + return self::scalar($key, ColumnType::Linestring, null, $required, $default, true, false, null, []); + } + + /** + * @param array|null $default + */ + public static function polygon(string $key, bool $required = false, ?array $default = null): self + { + return self::scalar($key, ColumnType::Polygon, null, $required, $default, true, false, null, []); + } + + /** + * @param list|null $default + */ + public static function vector(string $key, int $dimensions, bool $required = false, ?array $default = null): self + { + return self::scalar($key, ColumnType::Vector, $dimensions, $required, $default, true, false, null, []); + } + + /** + * @param array|null $default + */ + public static function object(string $key, bool $required = false, ?array $default = null): self + { + return self::scalar($key, ColumnType::Object, null, $required, $default, true, false, null, []); + } + + public static function id(string $key, bool $required = false, int|string|null $default = null, bool $array = false): self + { + return self::scalar($key, ColumnType::Id, null, $required, $default, true, $array, null, []); + } + + /** + * @throws Structure + */ + public static function relationship(string $key, Relationship $relationship, RelationshipSide $side): self + { + if ($relationship->key === null) { + $relationship = $relationship->apply(new RelationshipUpdate(key: $key)); + } elseif ($relationship->key !== $key) { + throw new Structure('Relationship key "'.$relationship->key.'" does not match attribute key "'.$key.'"'); + } + + return self::normalised($key, ColumnType::Relationship, null, false, null, true, false, null, [], $relationship, $side); + } + + /** + * @throws Structure + * @throws RelationshipException + */ + public static function fromDocument(Document $document): self + { + $key = $document->getAttribute(self::KEY); + + return self::hydrate( + \is_string($key) ? $key : $document->getId(), + $document->getAttribute(self::TYPE), + $document->getAttribute(self::SIZE), + $document->getAttribute(self::REQUIRED), + $document->getAttribute(self::DEFAULT), + $document->getAttribute(self::SIGNED), + $document->getAttribute(self::ARRAY), + $document->getAttribute(self::FORMAT), + $document->getAttribute(self::FORMAT_OPTIONS), + $document->getAttribute(self::FILTERS), + $document->getAttribute(self::OPTIONS), + ); + } + + /** + * @param array $data + * + * @throws Structure + */ + public static function fromArray(array $data): self + { + $key = $data[self::KEY] ?? $data[Document::ID] ?? ''; + + return self::hydrate( + \is_string($key) ? $key : '', + $data[self::TYPE] ?? null, + $data[self::SIZE] ?? null, + $data[self::REQUIRED] ?? null, + $data[self::DEFAULT] ?? null, + $data[self::SIGNED] ?? null, + $data[self::ARRAY] ?? null, + $data[self::FORMAT] ?? null, + $data[self::FORMAT_OPTIONS] ?? null, + $data[self::FILTERS] ?? null, + $data[self::OPTIONS] ?? null, + ); + } + + public function toDocument(): Document + { + $data = [ + Document::ID => $this->key, + self::KEY => $this->key, + self::TYPE => self::storedType($this->type), + self::SIZE => $this->size ?? 0, + self::REQUIRED => $this->required, + self::DEFAULT => $this->default, + self::SIGNED => $this->signed, + self::ARRAY => $this->array, + self::FORMAT => $this->format?->name, + self::FORMAT_OPTIONS => $this->format === null ? [] : $this->format->options, + self::FILTERS => $this->filters, + ]; + + if ($this->relationship !== null && $this->side !== null) { + $data[self::OPTIONS] = $this->relationship->toOptions($this->side); + } + + return new Document($data); + } + + /** + * @throws Structure + */ + public function apply(AttributeUpdate $update): self + { + $type = $update->type ?? $this->type; + if ($type !== $this->type) { + if ($type === ColumnType::Relationship || $this->type === ColumnType::Relationship) { + throw new Structure('A relationship attribute cannot change type; use updateRelationship()'); + } + self::assertType($type); + } + + $key = $update->key ?? $this->key; + $relationship = $this->relationship; + if ($relationship !== null && $relationship->key !== $key) { + $relationship = $relationship->apply(new RelationshipUpdate(key: $key)); + } + + $filters = match (true) { + $update->filters !== null => Filter::names($update->filters), + $type !== $this->type => self::withoutTypeFilter($this->type, $this->filters), + default => $this->filters, + }; + + return self::normalised( + $key, + $type, + $update->size ?? $this->size, + $update->required ?? $this->required, + $update->changesDefault() ? $update->default : $this->default, + $update->signed ?? $this->signed, + $update->array ?? $this->array, + $update->format instanceof Unchanged ? $this->format : $update->format, + $filters, + $relationship, + $this->side, + ); + } + + /** + * @param list $filters + */ + public function withFilters(array $filters): self + { + return clone($this, ['filters' => self::withTypeFilter($this->type, Filter::names($filters))]); + } + + public function width(): ?IntegerWidth + { + return $this->type === ColumnType::Integer ? IntegerWidth::fromSize($this->size) : null; + } + + public function resolvedSize(): int + { + if ($this->size !== null && $this->size > 0) { + return $this->size; + } + + return match ($this->type) { + ColumnType::Text => Database::MAX_TEXT_BYTES, + ColumnType::MediumText => Database::MAX_MEDIUMTEXT_BYTES, + ColumnType::LongText => Database::MAX_LONGTEXT_BYTES, + default => 0, + }; + } + + public function isSpatial(): bool + { + return match ($this->type) { + ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon => true, + default => false, + }; + } + + public function isNumeric(): bool + { + return match ($this->type) { + ColumnType::Integer, ColumnType::BigInteger, ColumnType::Float, ColumnType::Double => true, + default => false, + }; + } + + public function isInteger(): bool + { + return $this->type === ColumnType::Integer || $this->type === ColumnType::BigInteger; + } + + public function bounds(): ?NumericBounds + { + return match ($this->type) { + ColumnType::Integer => $this->width() === IntegerWidth::Bits64 + ? new NumericBounds($this->signed ? \PHP_INT_MIN : 0, Database::MAX_BIG_INT) + : new NumericBounds($this->signed ? Database::MIN_INT : 0, Database::MAX_INT), + ColumnType::BigInteger => new NumericBounds( + $this->signed ? \PHP_INT_MIN : 0, + $this->signed ? Database::MAX_BIG_INT : BigInt::UNSIGNED_MAX, + ), + ColumnType::Float, ColumnType::Double => new NumericBounds( + $this->signed ? -Database::MAX_DOUBLE : 0, + Database::MAX_DOUBLE, + ), + default => null, + }; + } + + public static function isRelationship(Document $attribute): bool + { + $type = $attribute->getAttribute(self::TYPE); + + return $type === ColumnType::Relationship->value || $type === ColumnType::Relationship; + } + + /** + * @throws Structure + */ + public static function typeFromStored(string $type): ColumnType + { + $columnType = $type === self::STORED_BIG_INTEGER ? ColumnType::BigInteger : ColumnType::tryFrom($type); + if ($columnType === null) { + throw new Structure('Unknown attribute type: '.$type); + } + + self::assertType($columnType); + + return $columnType; + } + + public static function storedType(ColumnType $type): string + { + return $type === ColumnType::BigInteger ? self::STORED_BIG_INTEGER : $type->value; + } + + /** + * @return list + */ + public static function availableTypes(Profile $profile): array + { + $objects = $profile->supports(Capability::Objects); + $spatial = $profile->hasFeature(Feature\Spatial::class); + $vectors = $profile->supports(Capability::Vectors); + + $types = []; + foreach (self::TYPES as $type) { + $available = match ($type) { + ColumnType::Object => $objects, + ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon => $spatial, + ColumnType::Vector => $vectors, + default => true, + }; + if ($available) { + $types[] = $type; + } + } + + return $types; + } + + /** + * @param list $filters + */ + private static function scalar(string $key, ColumnType $type, ?int $size, bool $required, mixed $default, bool $signed, bool $array, ?Format $format, array $filters): self + { + return self::normalised($key, $type, $size, $required, $default, $signed, $array, $format, $filters, null, null); + } + + /** + * @param list $filters + */ + private static function normalised( + string $key, + ColumnType $type, + ?int $size, + bool $required, + mixed $default, + bool $signed, + bool $array, + ?Format $format, + array $filters, + ?Relationship $relationship, + ?RelationshipSide $side, + ): self { + return new self( + $key, + $type, + self::normalisedSize($type, $size), + $required, + $default, + self::normalisedSigned($type, $signed), + self::normalisedArray($type, $array), + $format, + self::withTypeFilter($type, Filter::names($filters)), + $relationship, + $side, + ); + } + + private static function normalisedSize(ColumnType $type, ?int $size): ?int + { + return match ($type) { + ColumnType::String, ColumnType::Varchar, ColumnType::Text, ColumnType::MediumText, ColumnType::LongText, ColumnType::Vector => $size === 0 ? null : $size, + ColumnType::Integer => IntegerWidth::fromSize($size)->size(), + default => null, + }; + } + + private static function normalisedSigned(ColumnType $type, bool $signed): bool + { + return match ($type) { + ColumnType::Integer, ColumnType::BigInteger, ColumnType::Float, ColumnType::Double => $signed, + ColumnType::Datetime => false, + default => true, + }; + } + + private static function normalisedArray(ColumnType $type, bool $array): bool + { + return match ($type) { + ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon, ColumnType::Vector, ColumnType::Object, ColumnType::Relationship => false, + default => $array, + }; + } + + private static function typeFilter(ColumnType $type): ?Filter + { + return match ($type) { + ColumnType::Datetime => Filter::Datetime, + ColumnType::Point => Filter::Point, + ColumnType::Linestring => Filter::LineString, + ColumnType::Polygon => Filter::Polygon, + ColumnType::Vector => Filter::Vector, + ColumnType::Object => Filter::Object, + default => null, + }; + } + + /** + * @param list $filters + * @return list + */ + private static function withTypeFilter(ColumnType $type, array $filters): array + { + $filter = self::typeFilter($type)?->value; + if ($filter === null || \in_array($filter, $filters, true)) { + return $filters; + } + + return [$filter, ...$filters]; + } + + /** + * @param list $filters + * @return list + */ + private static function withoutTypeFilter(ColumnType $type, array $filters): array + { + $filter = self::typeFilter($type)?->value; + if ($filter === null) { + return $filters; + } + + return \array_values(\array_filter($filters, static fn (string $name): bool => $name !== $filter)); + } + + /** + * @throws Structure + */ + private static function assertType(ColumnType $type): void + { + if (! \in_array($type, self::TYPES, true)) { + throw new Structure('Unknown attribute type: '.$type->value); + } + } + + /** + * @throws Structure + */ + private static function resolveType(mixed $type): ColumnType + { + if (\is_string($type)) { + return self::typeFromStored($type); + } + + if ($type instanceof ColumnType) { + self::assertType($type); + + return $type; + } + + throw new Structure('Attribute type must be a string, '.\get_debug_type($type).' given'); + } + + /** + * @throws Structure + * @throws RelationshipException + */ + private static function hydrate( + string $key, + mixed $type, + mixed $size, + mixed $required, + mixed $default, + mixed $signed, + mixed $array, + mixed $format, + mixed $formatOptions, + mixed $filters, + mixed $options, + ): self { + $columnType = self::resolveType($type); + + $relationship = null; + $side = null; + if ($columnType === ColumnType::Relationship) { + [$relationship, $side] = self::hydrateRelationship($key, $options); + } + + return self::normalised( + $key, + $columnType, + self::storedSize($size), + (bool) ($required ?? false), + $default, + (bool) ($signed ?? true), + (bool) ($array ?? false), + self::hydrateFormat($format, $formatOptions), + self::hydrateFilters($filters), + $relationship, + $side, + ); + } + + private static function storedSize(mixed $size): ?int + { + $size = \is_numeric($size) ? (int) $size : null; + + return $size === 0 ? null : $size; + } + + private static function hydrateFormat(mixed $format, mixed $options): ?Format + { + if (! \is_string($format) || $format === '') { + return null; + } + + if ($options instanceof Document) { + $options = $options->getArrayCopy(); + } + + /** @var array $options */ + $options = \is_array($options) ? $options : []; + + return new Format($format, $options); + } + + /** + * @return list + */ + private static function hydrateFilters(mixed $filters): array + { + /** @var list */ + return \is_array($filters) ? \array_values($filters) : []; + } + + /** + * @return array{Relationship, RelationshipSide} + * + * @throws Structure + * @throws RelationshipException + */ + private static function hydrateRelationship(string $key, mixed $options): array + { + if ($options instanceof Document) { + $options = $options->getArrayCopy(); + } + + if (! \is_array($options)) { + throw new Structure('Relationship attribute "'.$key.'" has no relationship options'); + } + + $side = $options[Relationship::SIDE] ?? RelationshipSide::Parent->value; + $side = $side instanceof RelationshipSide ? $side : RelationshipSide::tryFrom(\is_string($side) ? $side : ''); + if ($side === null) { + throw new Structure('Relationship attribute "'.$key.'" has an unknown side'); + } + + unset($options[Relationship::SIDE]); + $options[self::KEY] = $key; + + /** @var array $options */ + return [Relationship::fromArray($options), $side]; + } +} diff --git a/src/Database/AttributeUpdate.php b/src/Database/AttributeUpdate.php new file mode 100644 index 0000000000..784ad43d18 --- /dev/null +++ b/src/Database/AttributeUpdate.php @@ -0,0 +1,47 @@ +|null $filters + */ + public function __construct( + public ?ColumnType $type = null, + public ?int $size = null, + public ?bool $required = null, + public mixed $default = Unchanged::Value, + public ?bool $signed = null, + public ?bool $array = null, + public Format|Unchanged|null $format = Unchanged::Value, + public ?array $filters = null, + public ?string $key = null, + ) { + } + + public function changesDefault(): bool + { + return $this->default !== Unchanged::Value; + } + + public function changesFormat(): bool + { + return $this->format !== Unchanged::Value; + } + + public function isEmpty(): bool + { + return $this->type === null + && $this->size === null + && $this->required === null + && ! $this->changesDefault() + && $this->signed === null + && $this->array === null + && ! $this->changesFormat() + && $this->filters === null + && $this->key === null; + } +} diff --git a/src/Database/Builder/CompilesFilters.php b/src/Database/Builder/CompilesFilters.php new file mode 100644 index 0000000000..23e4f26a8d --- /dev/null +++ b/src/Database/Builder/CompilesFilters.php @@ -0,0 +1,30 @@ + $filters + */ + public function compileFilters(array $filters): Condition + { + $this->bindings = []; + $this->resolvedAttributeCache = []; + + $expressions = []; + foreach ($filters as $filter) { + $expressions[] = $this->compileFilter(clone $filter); + } + + return new Condition(\implode(' AND ', $expressions), $this->getBindingValues()); + } +} diff --git a/src/Database/Builder/Filtering.php b/src/Database/Builder/Filtering.php new file mode 100644 index 0000000000..85dd8cc066 --- /dev/null +++ b/src/Database/Builder/Filtering.php @@ -0,0 +1,19 @@ + $filters + */ + public function compileFilters(array $filters): Condition; +} diff --git a/src/Database/Builder/MariaDB.php b/src/Database/Builder/MariaDB.php new file mode 100644 index 0000000000..8381a0692c --- /dev/null +++ b/src/Database/Builder/MariaDB.php @@ -0,0 +1,16 @@ +requireUnbound("update from table '{$table}'"); + + return parent::updateFrom($table, $alias); + } + + /** + * @throws QueryException On a builder that read a collection, whose tenant scope does not reach the second table + */ + #[\Override] + public function deleteUsing(string $table, string $condition, mixed ...$bindings): static + { + $this->requireUnbound("delete using table '{$table}'"); + + return parent::deleteUsing($table, $condition, ...$bindings); + } + + /** + * @throws QueryException + */ + #[\Override] + public function compileFilter(Query $query): string + { + $attribute = $query->getAttribute(); + + if ($query->getAttributeType() === ColumnType::Object->value && \str_contains($attribute, '.')) { + $path = new ObjectPath(); + if (! $path->isValid($attribute)) { + throw new QueryException('Invalid object path "'.$attribute.'": '.$path->getDescription()); + } + } + + return parent::compileFilter($query); + } + + /** + * ST_Distance() cannot be served by the GIST index, so distanceLessThan() leads with a predicate that can: ST_DWithin() + * on the geometry, or, for a distance in meters from a point to a point column, a degree box around the point. The + * exact ST_Distance() check follows in every case and keeps the boundary exclusive. + */ + #[\Override] + protected function compileSpatialFilter(Method $method, string $attribute, Query $query): string + { + if ($method !== Method::DistanceLessThan) { + return parent::compileSpatialFilter($method, $attribute, $query); + } + + /** @var array{0: string|array, 1: float, 2: bool} $tuple */ + $tuple = $query->getValues()[0]; + $filter = SpatialDistanceFilter::fromTuple($tuple); + $wkt = \is_array($filter->geometry) ? $this->geometryToWkt($filter->geometry) : $filter->geometry; + $geometry = 'ST_GeomFromText(?, '.Database::DEFAULT_SRID.')'; + + if (! $filter->meters) { + $this->addBinding($wkt); + $this->addBinding($filter->distance); + $this->addBinding($wkt); + $this->addBinding($filter->distance); + + return '(ST_DWithin('.$attribute.', '.$geometry.', ?) AND ST_Distance('.$attribute.', '.$geometry.') < ?)'; + } + + $distance = 'ST_Distance(('.$attribute.'::geography), ST_SetSRID(ST_GeomFromText(?), '.Database::DEFAULT_SRID.')::geography) < ?'; + $degrees = $query->getAttributeType() === ColumnType::Point->value + ? self::degreesWithinMeters($filter->geometry, $filter->distance) + : null; + + if ($degrees === null) { + $this->addBinding($wkt); + $this->addBinding($filter->distance); + + return $distance; + } + + $this->addBinding($wkt); + $this->addBinding($degrees[0]); + $this->addBinding($degrees[1]); + $this->addBinding($wkt); + $this->addBinding($filter->distance); + + return '('.$attribute.' && ST_Expand('.$geometry.', ?, ?) AND '.$distance.')'; + } + + /** + * The longitude and latitude degrees that hold every point within $meters of $point on the WGS84 spheroid, where a + * degree of latitude spans at least 110,574 m and a degree of longitude at least 111,319 m × cos(latitude). + * + * Null for a line or polygon, whose geodesic edges leave any degree box, for a distance that is not finite, and + * when the box would reach a pole or the antimeridian. + * + * @param string|array $point + * @return array{0: float, 1: float}|null + */ + private static function degreesWithinMeters(string|array $point, float $meters): ?array + { + if (! \is_finite($meters) || ! \is_array($point) || \count($point) !== 2 || ! \is_numeric($point[0] ?? null) || ! \is_numeric($point[1] ?? null)) { + return null; + } + + $longitude = (float) $point[0]; + $latitude = (float) $point[1]; + + $latitudeDegrees = $meters / self::METERS_PER_LATITUDE_DEGREE; + if (\abs($latitude) + $latitudeDegrees >= 90) { + return null; + } + + $longitudeDegrees = $meters / (self::METERS_PER_EQUATORIAL_LONGITUDE_DEGREE * \cos(\deg2rad(\abs($latitude) + $latitudeDegrees))); + if ($longitude - $longitudeDegrees <= -180 || $longitude + $longitudeDegrees >= 180) { + return null; + } + + return [$longitudeDegrees, $latitudeDegrees]; + } +} diff --git a/src/Database/Builder/PreparesSearchTerms.php b/src/Database/Builder/PreparesSearchTerms.php new file mode 100644 index 0000000000..f8d0909dba --- /dev/null +++ b/src/Database/Builder/PreparesSearchTerms.php @@ -0,0 +1,42 @@ + $values + */ + #[\Override] + protected function compileSearchExpression(string $attribute, array $values, bool $not): string + { + $term = $values[0] ?? ''; + + return parent::compileSearchExpression($attribute, [$this->prepareSearchTerm(\is_string($term) ? $term : '')], $not); + } + + private function prepareSearchTerm(string $term): string + { + $exact = \str_starts_with($term, self::SEARCH_QUOTE) && \str_ends_with($term, self::SEARCH_QUOTE); + + $words = \preg_replace(self::SEARCH_SEPARATORS, ' ', $term) ?? ''; + $words = \trim(\preg_replace(self::SEARCH_WHITESPACE, ' ', $words) ?? ''); + + if ($exact && $words !== '') { + return self::SEARCH_QUOTE.$words.self::SEARCH_QUOTE; + } + + return $words; + } +} diff --git a/src/Database/Builder/RefusesJoinedWrites.php b/src/Database/Builder/RefusesJoinedWrites.php new file mode 100644 index 0000000000..1a97d4ee44 --- /dev/null +++ b/src/Database/Builder/RefusesJoinedWrites.php @@ -0,0 +1,36 @@ +requireUnbound("update joining table '{$table}'"); + + return parent::updateJoin($table, $left, $right, $alias); + } + + /** + * @throws QueryException On a builder that read a collection + */ + #[\Override] + public function deleteJoin(string $alias, string $table, string $left, string $right): static + { + $this->requireUnbound("delete joining table '{$table}'"); + + return parent::deleteJoin($alias, $table, $left, $right); + } +} diff --git a/src/Database/Builder/SQLite.php b/src/Database/Builder/SQLite.php new file mode 100644 index 0000000000..eed4b08c84 --- /dev/null +++ b/src/Database/Builder/SQLite.php @@ -0,0 +1,240 @@ +']; + + private const string ESCAPE = " ESCAPE '\\'"; + + private const string ELEMENT_MATCH = "EXISTS (SELECT 1 FROM json_each(%s) WHERE json_each.value = json_extract(?, '$'))"; + + #[\Override] + public function compileJoin(Query $query): string + { + $sql = parent::compileJoin($query); + + foreach ($this->onComparisons($query->getJoinOnQueries()) as [$left, $operator, $right]) { + if (! \in_array($operator, self::EQUALITY_OPERATORS, true) || (! $this->isCollated($left) && ! $this->isCollated($right))) { + continue; + } + + $comparison = ' '.$operator.' '.$this->resolveAndWrap($right); + $wrappedLeft = $this->resolveAndWrap($left); + $sql = \str_replace($wrappedLeft.$comparison, $wrappedLeft.self::COLLATE.$comparison, $sql); + } + + return $sql; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileIn(string $attribute, array $values, ?string $column = null): string + { + return parent::compileIn($this->collate($attribute, $column), $values, $column); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileNotIn(string $attribute, array $values, ?string $column = null): string + { + return parent::compileNotIn($this->collate($attribute, $column), $values, $column); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileRegex(string $attribute, array $values, ?string $column = null): string + { + $this->addBinding($values[0], $column); + + return $attribute.' REGEXP ?'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not, ?string $column = null): string + { + return parent::compileLike($attribute, $values, $prefix, $suffix, $not, $column).self::ESCAPE; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileContains(string $attribute, array $values, ?string $column = null): string + { + $predicates = $this->compileSubstrings($attribute, $values, false, $column); + + return \count($predicates) === 1 ? $predicates[0] : '('.\implode(' OR ', $predicates).')'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileContainsAll(string $attribute, array $values, ?string $column = null): string + { + return '('.\implode(' AND ', $this->compileSubstrings($attribute, $values, false, $column)).')'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileNotContains(string $attribute, array $values, ?string $column = null): string + { + $predicates = $this->compileSubstrings($attribute, $values, true, $column); + + return \count($predicates) === 1 ? $predicates[0] : '('.\implode(' AND ', $predicates).')'; + } + + #[\Override] + protected function compileArrayFilter(Method $method, string $attribute, Query $query): string + { + if ($method === Method::NotContains) { + return $this->compileNotContaining($attribute, $this->compileJsonOverlapsExpression($attribute, [$query->getValues()])); + } + + return parent::compileArrayFilter($method, $attribute, $query); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileJsonContainsExpression(string $attribute, array $values, bool $not): string + { + $expression = '('.\implode(' AND ', $this->compileElementMatches($attribute, $values[0])).')'; + + return $not ? $this->compileNotContaining($attribute, $expression) : $expression; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileJsonOverlapsExpression(string $attribute, array $values): string + { + return '('.\implode(' OR ', $this->compileElementMatches($attribute, $values[0])).')'; + } + + private function collate(string $attribute, ?string $column): string + { + return $column !== null && $this->isCollated($column) ? $attribute.self::COLLATE : $attribute; + } + + private function isCollated(string $column): bool + { + $resolved = $this->resolveAttribute($column); + $separator = \strrpos($resolved, '.'); + $name = $separator === false ? $resolved : \substr($resolved, $separator + 1); + + return \in_array($name, self::COLLATED_COLUMNS, true); + } + + /** + * @param array $queries + * @return list + */ + private function onComparisons(array $queries): array + { + $comparisons = []; + foreach ($queries as $query) { + if (! $query instanceof Query) { + continue; + } + + $comparisons = match ($query->getMethod()) { + Method::On => [...$comparisons, ...$this->comparison($query->getValues())], + Method::And, Method::Or => [...$comparisons, ...$this->onComparisons($query->getValues())], + default => $comparisons, + }; + } + + return $comparisons; + } + + /** + * @param array $values + * @return list + */ + private function comparison(array $values): array + { + [$left, $operator, $right] = [$values[0] ?? null, $values[1] ?? null, $values[2] ?? null]; + if (! \is_string($left) || ! \is_string($operator) || ! \is_string($right) || $left === '' || $right === '') { + return []; + } + + return [[$left, $operator, $right]]; + } + + private function compileNotContaining(string $attribute, string $expression): string + { + return '('.$attribute.' IS NOT NULL AND NOT '.$expression.')'; + } + + /** + * @return list + */ + private function compileElementMatches(string $attribute, mixed $needles): array + { + $matches = []; + foreach ((array) $needles as $needle) { + try { + $this->addBinding(\json_encode($needle, JSON_THROW_ON_ERROR)); + } catch (JsonException $exception) { + throw new ValidationException('Invalid JSON payload: '.$exception->getMessage()); + } + $matches[] = \sprintf(self::ELEMENT_MATCH, $attribute); + } + + return $matches; + } + + /** + * @param array $values + * @return list + */ + private function compileSubstrings(string $attribute, array $values, bool $not, ?string $column): array + { + return \array_map( + fn (mixed $value): string => $this->compileLike($attribute, [$value], '%', '%', $not, $column), + \array_values($values), + ); + } +} diff --git a/src/Database/Builder/Scope.php b/src/Database/Builder/Scope.php new file mode 100644 index 0000000000..75c6166e89 --- /dev/null +++ b/src/Database/Builder/Scope.php @@ -0,0 +1,32 @@ + What the builder runs before each statement it builds, which reset() does not + * clear + */ + public function bind(Builder $builder, string $collection, string $table, string $alias): array; +} diff --git a/src/Database/Builder/ScopesCollections.php b/src/Database/Builder/ScopesCollections.php new file mode 100644 index 0000000000..dc02edc379 --- /dev/null +++ b/src/Database/Builder/ScopesCollections.php @@ -0,0 +1,128 @@ + + */ + private array $beforeEachBuild = []; + + #[\Override] + public function scope(Scope $scope): static + { + $this->requireUnbound('take another scope'); + $this->scope = $scope; + + return $this; + } + + #[\Override] + public function isScoped(): bool + { + return $this->scope !== null; + } + + #[\Override] + public function from(string $table = '', string $alias = ''): static + { + if ($this->scope === null || $table === '') { + return parent::from($table, $alias); + } + + if ($this->boundCollection !== null) { + if ($table !== $this->boundCollection || $alias !== $this->boundAlias) { + throw new QueryException("The builder reads collection '{$this->boundCollection}'".($this->boundAlias !== '' ? " as '{$this->boundAlias}'" : '').", not '{$table}'".($alias !== '' ? " as '{$alias}'" : '').': start another builder'); + } + + return parent::from($this->scope->table($table), $alias); + } + + $stored = $this->scope->table($table); + parent::from($stored, $alias); + + $this->boundCollection = $table; + $this->boundAlias = $alias; + $this->beforeEachBuild = $this->scope->bind($this, $table, $stored, $alias); + + return $this; + } + + #[\Override] + public function fromTable(string $table, string $alias = ''): static + { + $this->requireUnbound("read table '{$table}'"); + + return parent::from($table, $alias); + } + + #[\Override] + public function into(string $table): static + { + $this->requireUnbound("insert into table '{$table}'"); + + return parent::into($table); + } + + #[\Override] + public function build(): Statement + { + if ($this->scope === null || $this->boundCollection === null) { + return parent::build(); + } + + foreach ($this->beforeEachBuild as $callback) { + $callback(); + } + + $written = $this->pendingQueries; + $this->pendingQueries = \array_map($this->scopeJoin(...), $written); + + try { + return parent::build(); + } finally { + $this->pendingQueries = $written; + } + } + + /** + * @throws QueryException Once from() has read a collection through the scope + */ + private function requireUnbound(string $action): void + { + if ($this->boundCollection !== null) { + throw new QueryException("The builder reads collection '{$this->boundCollection}' and cannot {$action}: start another builder"); + } + } + + private function scopeJoin(Query $query): Query + { + if ($this->scope === null || ! $query->getMethod()->isJoin()) { + return $query; + } + + $table = $this->scope->joinTable($query->getAttribute()); + + return $table === $query->getAttribute() ? $query : new Query($query->getMethod(), $table, $query->getValues(), $query->getAlias()); + } +} diff --git a/src/Database/Builder/Scoping.php b/src/Database/Builder/Scoping.php new file mode 100644 index 0000000000..fc2f5d17da --- /dev/null +++ b/src/Database/Builder/Scoping.php @@ -0,0 +1,24 @@ +|null */ + private static ?WeakMap $caches = null; + + public static function kept(Cache $cache): bool + { + return isset(self::caches()[$cache]); + } + + public static function remember(Cache $cache): void + { + self::caches()[$cache] = true; + } + + /** + * @return WeakMap + */ + private static function caches(): WeakMap + { + return self::$caches ??= new WeakMap(); + } +} diff --git a/src/Database/Cache/Invalidator.php b/src/Database/Cache/Invalidator.php new file mode 100644 index 0000000000..52d6a79e02 --- /dev/null +++ b/src/Database/Cache/Invalidator.php @@ -0,0 +1,212 @@ +invalidate($event, $data, $this->scope); + } + + public function invalidate(Event $event, mixed $data, Scope $scope): void + { + $tokens = $this->tokens($event, $data, $scope); + $this->block($tokens); + $this->activate($tokens, $scope->writerTimeout); + } + + /** + * With $tenantPerDocument, each document's collections are keyed under the tenant it is stored + * under: its own, or the scope's when it has none. + * + * @return array Tokens by the collection key they invalidate + */ + public function tokens(Event $event, mixed $data, ?Scope $scope = null, bool $tenantPerDocument = false): array + { + if (! $this->isMutation($event)) { + return []; + } + + $scope ??= $this->scope; + if (! $tenantPerDocument) { + return $this->scopedTokens($event, $data, $scope); + } + + $scopes = []; + $targets = []; + foreach (\is_array($data) ? $data : [$data] as $target) { + $tenant = $target instanceof Document ? $target->getTenant() ?? $scope->tenant : $scope->tenant; + $key = \serialize($tenant); + $scopes[$key] ??= $scope->withTenant($tenant); + $targets[$key][] = $target; + } + + $tokens = []; + foreach ($scopes as $key => $tenantScope) { + $tokens += $this->scopedTokens($event, $targets[$key], $tenantScope); + } + + return $tokens; + } + + /** + * @param array $tokens + */ + public function block(array $tokens): void + { + foreach ($tokens as $key => $token) { + $this->queryCache->blockCollection($key, $token); + } + } + + /** + * @param array $tokens + * @param int $writerTimeout The writer timeout of the database the tokens were created through + */ + public function activate(array $tokens, int $writerTimeout): void + { + $failure = null; + foreach ($tokens as $key => $token) { + try { + $this->queryCache->activateCollection($key, $token, $writerTimeout); + } catch (Throwable $error) { + $failure ??= $error; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + public function isMutation(Event $event): bool + { + return \in_array($event, [ + Event::CollectionCreate, + Event::CollectionUpdate, + Event::CollectionDelete, + Event::AttributeCreate, + Event::AttributesCreate, + Event::AttributeUpdate, + Event::AttributeRename, + Event::AttributeDelete, + Event::IndexCreate, + Event::IndexesCreate, + Event::IndexRename, + Event::IndexDelete, + Event::DocumentPurge, + Event::DocumentCreate, + Event::DocumentsCreate, + Event::DocumentUpdate, + Event::DocumentsUpdate, + Event::DocumentUpsert, + Event::DocumentsUpsert, + Event::DocumentDelete, + Event::DocumentsDelete, + Event::DocumentIncrease, + Event::DocumentDecrease, + Event::PermissionsCreate, + Event::PermissionsDelete, + ], true); + } + + /** + * Only an attribute event's relationship options name a related collection: a written + * document's own `options` attribute is data. + */ + private function isAttributeMutation(Event $event): bool + { + return \in_array($event, [ + Event::AttributeCreate, + Event::AttributesCreate, + Event::AttributeUpdate, + Event::AttributeRename, + Event::AttributeDelete, + ], true); + } + + /** + * @return array + */ + private function scopedTokens(Event $event, mixed $data, Scope $scope): array + { + $tokens = []; + foreach (\array_keys($this->extractCollections($event, $data)) as $collection) { + $tokens[$this->queryCache->getCollectionKey($scope, (string) $collection)] = $this->queryCache->createToken(); + } + + return $tokens; + } + + /** + * @return array + */ + private function extractCollections(Event $event, mixed $data): array + { + $collections = []; + + if (\is_array($data)) { + foreach ($data as $item) { + foreach ($this->extractCollections($event, $item) as $collection => $present) { + $collections[$collection] = $present; + } + } + + return $collections; + } + + if ($data instanceof Document) { + if (\in_array($event, [ + Event::CollectionCreate, + Event::CollectionUpdate, + Event::CollectionDelete, + ], true)) { + $collection = $data->getId(); + } else { + $collection = $data->getCollection(); + if ($collection === Database::METADATA) { + $collection = $data->getId(); + } + } + + if ($collection !== '') { + $collections[$collection] = true; + } + + if (! $this->isAttributeMutation($event) || ! Attribute::isRelationship($data)) { + return $collections; + } + + $related = Attribute::fromDocument($data)->relationship?->relatedCollection; + if ($related !== null) { + $collections[$related] = true; + } + + return $collections; + } + + if (\is_string($data) && $data !== '') { + $collections[$data] = true; + } + + return $collections; + } +} diff --git a/src/Database/Cache/Owners.php b/src/Database/Cache/Owners.php new file mode 100644 index 0000000000..88ab65a786 --- /dev/null +++ b/src/Database/Cache/Owners.php @@ -0,0 +1,67 @@ +getOwnersKey($key); + if ($this->cache->save($owners, $token, $token) === false) { + return false; + } + + if ($this->isField($owners, $token)) { + return true; + } + + return $this->cache->save($this->getOwnerKey($key, $token), $token) !== false; + } + + public function find(string $key, string $token): Registration + { + $owners = $this->getOwnersKey($key); + + return $this->isField($owners, $token) + ? new Registration($owners, $token) + : new Registration($this->getOwnerKey($key, $token)); + } + + private function isField(string $owners, string $token): bool + { + if (Fields::kept($this->cache)) { + return true; + } + + if (! \in_array($token, $this->cache->list($owners), true)) { + return false; + } + + Fields::remember($this->cache); + + return true; + } + + private function getOwnersKey(string $key): string + { + return $key.'#owners'; + } + + private function getOwnerKey(string $key, string $token): string + { + return $key.'#owner:'.$token; + } +} diff --git a/src/Database/Cache/Query.php b/src/Database/Cache/Query.php new file mode 100644 index 0000000000..6f962c3dbc --- /dev/null +++ b/src/Database/Cache/Query.php @@ -0,0 +1,439 @@ + */ + private array $regions = []; + + /** + * @param int $slots Results a collection scope keeps at most; queries sharing a slot evict each other + */ + public function __construct( + private readonly Cache $cache, + private readonly int $slots = self::SLOTS, + ) { + if ($slots < 1) { + throw new InvalidArgumentException('A query cache needs at least one slot'); + } + } + + public function setRegion(string $collection, Region $region): static + { + $this->regions[$collection] = $region; + + return $this; + } + + public function getRegion(string $collection): Region + { + return $this->regions[$collection] ?? new Region(); + } + + public function getCollectionKey(Scope $scope, string $collection): string + { + $scopeHash = \md5(\serialize([ + 'hostname' => $scope->hostname, + 'database' => $scope->database, + 'namespace' => $scope->namespace, + 'tenant' => $scope->tenant, + 'collection' => $collection, + ])); + + return "{$scope->name}:qcache:{$collection}:{$scopeHash}"; + } + + /** + * Resolve a query's entry in the collection's current epoch; null while the + * collection's region is disabled or a write to it is in progress. + * + * @param array $queries + * + * @phpstan-impure + */ + public function getEntry(Scope $scope, string $collection, array $queries, string $context = ''): ?Entry + { + if (! $this->getRegion($collection)->enabled) { + return null; + } + + $key = $this->getCollectionKey($scope, $collection); + $epoch = $this->getEpoch($key, $collection, $scope->writerTimeout); + if ($epoch === null) { + return null; + } + + $field = \md5(\serialize([ + 'queries' => $queries, + 'context' => $context, + ])); + + $slot = (string) ((int) \hexdec(\substr($field, 0, 8)) % $this->slots); + + return new Entry($key, $collection, $field, $epoch, $slot); + } + + /** + * @return array|null + * + * @phpstan-impure + */ + public function get(Entry $entry): ?array + { + /** @var mixed $data */ + $data = $this->cache->load($entry->key, $this->getRegion($entry->collection)->ttl, $entry->slot); + + if ($data === false || $data === null) { + return null; + } + + if ( + ! \is_array($data) + || ($data['version'] ?? null) !== self::VERSION + || ! \is_array($data['documents'] ?? null) + ) { + $this->purgeLoadedEntry($entry); + + return null; + } + + if (($data['epoch'] ?? null) !== $entry->epoch || ($data['field'] ?? null) !== $entry->field) { + return null; + } + + $documents = []; + foreach ($data['documents'] as $item) { + if ($item instanceof Document) { + $documents[] = $item; + continue; + } + + if (! \is_array($item)) { + $this->purgeLoadedEntry($entry); + + return null; + } + + $typed = []; + foreach ($item as $key => $value) { + if (\is_string($key)) { + $typed[$key] = $value; + } + } + $documents[] = Document::fromStorage($typed); + } + + return $documents; + } + + public function getGeneration(Entry $entry): string + { + return $this->cache->getGeneration($entry->key); + } + + /** + * @param array $results + */ + public function set(Entry $entry, array $results, string $generation): bool + { + $data = []; + foreach ($results as $result) { + if (! $result instanceof Document) { + return false; + } + + $data[] = $result->getArrayCopy(); + } + + return $this->cache->saveWithLease($entry->key, [ + 'version' => self::VERSION, + 'epoch' => $entry->epoch, + 'field' => $entry->field, + 'documents' => $data, + ], $entry->slot, $generation) !== false; + } + + public function invalidateCollection(Scope $scope, string $collection): void + { + $key = $this->getCollectionKey($scope, $collection); + $token = $this->createToken(); + $this->blockCollection($key, $token); + $this->activateCollection($key, $token, $scope->writerTimeout); + } + + /** + * A write's token, which records when it was created so a later activation can tell an abandoned write. + */ + public function createToken(): string + { + return \time().self::TOKEN_SEPARATOR.\bin2hex(\random_bytes(16)); + } + + /** + * Publish a shared tombstone before a mutation starts. The previous epoch's results stay until + * a fill of their slot replaces them; the purge of one absent field only advances the lease. + */ + public function blockCollection(string $key, string $token): void + { + if (! (new Owners($this->cache))->register($key, $token)) { + throw new RuntimeException("Failed to register query cache owner for '{$key}'"); + } + + if ($this->cache->save($this->getEpochKey($key), self::BLOCKED_PREFIX.$token.self::SEPARATOR.\time()) === false) { + throw new RuntimeException("Failed to block query cache epoch for '{$key}'"); + } + + $this->cache->purge($this->getStartedKey($key)); + $this->cache->purge($key, self::BLOCK_FIELD); + } + + /** + * Replace this mutation's shared tombstone with a fresh usable epoch once no + * other mutation of the collection is in progress. + * + * @param int $writerTimeout Seconds after which another writer's registration is treated as abandoned + */ + public function activateCollection(string $key, string $token, int $writerTimeout): void + { + $registration = (new Owners($this->cache))->find($key, $token); + $owner = $this->cache->load($registration->key, self::PERMANENT, $registration->field); + if ($owner !== false && $owner !== null && $owner !== $token) { + throw new RuntimeException("Invalid query cache owner for '{$key}'"); + } + $owned = $owner === $token; + if ($owned && ! $this->cache->purge($registration->key, $registration->field)) { + $owner = $this->cache->load($registration->key, self::PERMANENT, $registration->field); + if ($owner !== false && $owner !== null) { + throw new RuntimeException("Failed to release query cache owner for '{$key}'"); + } + $owned = false; + } + + $epochKey = $this->getEpochKey($key); + $startedKey = $this->getStartedKey($key); + $finishedKey = $this->getFinishedKey($key); + $started = $this->cache->getGeneration($startedKey); + $finished = $this->cache->getGeneration($finishedKey); + $current = $this->cache->load($epochKey, self::PERMANENT); + $ours = $this->isTombstoneOf($current, $token); + + if ($started === $finished) { + if (! $owned && ($ours || ! $this->isTombstone($current))) { + $this->publish($key, $finished); + } + + return; + } + + if (! $owned && ! $ours) { + if ($this->isActive($current)) { + $this->publish($key, $started); + } + + return; + } + + $this->cache->purge($finishedKey); + $nextStarted = $this->cache->getGeneration($startedKey); + $nextFinished = $this->cache->getGeneration($finishedKey); + + if ($nextStarted === $nextFinished) { + $this->publish($key, $nextFinished); + + return; + } + + if ($nextFinished === $finished && $this->isTombstoneOf($this->cache->load($epochKey, self::PERMANENT), $token)) { + throw new RuntimeException("Failed to finish query cache invalidation for '{$key}'"); + } + + if ($registration->field !== '' && $this->releaseAbandonedOwners($registration->key, $writerTimeout)) { + $this->publish($key, $nextStarted); + } + } + + public function flush(): void + { + if (! $this->cache->flush()) { + throw new RuntimeException('Failed to flush query cache'); + } + } + + /** + * Epochs never expire in the cache, so one cannot vanish under a transaction that + * outlives the region TTL. An active epoch carries the started generation it was + * published at: while the started generation still equals it, no mutation has + * begun since, so a reader needs one generation read. The initial epoch needs a + * results hash no write has purged, since results filled under it outlive the block. + */ + private function getEpoch(string $key, string $collection, int $writerTimeout): ?string + { + $value = $this->cache->load($this->getEpochKey($key), self::PERMANENT); + + if ($value === false || $value === null) { + return $this->cache->getGeneration($key) === self::NEVER_STARTED + ? self::INITIAL_EPOCH + : null; + } + + if (! \is_string($value) || $value === '') { + return null; + } + + $separator = \strrpos($value, self::SEPARATOR); + if ($separator === false) { + return null; + } + $marker = \substr($value, 0, $separator); + $stamp = \substr($value, $separator + 1); + + if (\str_starts_with($value, self::BLOCKED_PREFIX)) { + return $this->getLapsedEpoch($key, $collection, $value, (int) $stamp, $writerTimeout); + } + + if (! \str_starts_with($value, self::ACTIVE_PREFIX)) { + return null; + } + + $started = $this->cache->getGeneration($this->getStartedKey($key)); + if ($started !== $stamp && $started !== $this->cache->getGeneration($this->getFinishedKey($key))) { + return null; + } + + return $marker; + } + + /** + * A tombstone lapses after the region TTL once no write is counted in flight, and after the writer + * timeout while one is, since its writer may have died before activating. The lapsed epoch belongs + * to this tombstone and the finished generation, so nothing filled before the block, or before a + * later activation, is served under it. + */ + private function getLapsedEpoch(string $key, string $collection, string $tombstone, int $stamp, int $writerTimeout): ?string + { + $now = \time(); + $ttl = $this->getRegion($collection)->ttl; + if ($stamp + \min($ttl, $writerTimeout) > $now) { + return null; + } + + $started = $this->cache->getGeneration($this->getStartedKey($key)); + $finished = $this->cache->getGeneration($this->getFinishedKey($key)); + if ($stamp + ($started === $finished ? $ttl : $writerTimeout) > $now) { + return null; + } + + return self::LAPSED_PREFIX.\substr($tombstone, \strlen(self::BLOCKED_PREFIX)).self::SEPARATOR.$finished; + } + + /** + * Release every other writer still registered when all of them are older than the writer timeout. + * A token without a creation time counts as live. + */ + private function releaseAbandonedOwners(string $owners, int $writerTimeout): bool + { + $now = \time(); + $abandoned = []; + foreach ($this->cache->list($owners) as $token) { + $created = $this->getTokenTime($token); + if ($created === null || $created + $writerTimeout > $now) { + return false; + } + + $abandoned[] = $token; + } + + foreach ($abandoned as $token) { + $this->cache->purge($owners, $token); + } + + return true; + } + + private function getTokenTime(string $token): ?int + { + $separator = \strpos($token, self::TOKEN_SEPARATOR); + $time = $separator === false ? '' : \substr($token, 0, $separator); + + return \ctype_digit($time) ? (int) $time : null; + } + + private function isActive(mixed $value): bool + { + return \is_string($value) && \str_starts_with($value, self::ACTIVE_PREFIX); + } + + private function isTombstone(mixed $value): bool + { + return \is_string($value) && \str_starts_with($value, self::BLOCKED_PREFIX); + } + + private function isTombstoneOf(mixed $value, string $token): bool + { + return \is_string($value) && \str_starts_with($value, self::BLOCKED_PREFIX.$token.self::SEPARATOR); + } + + private function publish(string $key, string $finished): void + { + $epoch = self::ACTIVE_PREFIX.\bin2hex(\random_bytes(16)).self::SEPARATOR.$finished; + + if ($this->cache->save($this->getEpochKey($key), $epoch) === false) { + throw new RuntimeException("Failed to activate query cache for '{$key}'"); + } + } + + private function getEpochKey(string $key): string + { + return $key.'#epoch'; + } + + private function getFinishedKey(string $key): string + { + return $key.'#finished'; + } + + private function getStartedKey(string $key): string + { + return $key.'#started'; + } + + private function purgeLoadedEntry(Entry $entry): void + { + if (! $this->cache->purge($entry->key, $entry->slot)) { + throw new RuntimeException("Failed to purge invalid query cache entry '{$entry->key}'"); + } + } +} diff --git a/src/Database/Cache/Region.php b/src/Database/Cache/Region.php new file mode 100644 index 0000000000..bcbb7b26c0 --- /dev/null +++ b/src/Database/Cache/Region.php @@ -0,0 +1,12 @@ +hostname, + database: $this->database, + namespace: $this->namespace, + tenant: $tenant, + name: $this->name, + writerTimeout: $this->writerTimeout, + ); + } +} diff --git a/src/Database/Capability.php b/src/Database/Capability.php new file mode 100644 index 0000000000..c9eefdae68 --- /dev/null +++ b/src/Database/Capability.php @@ -0,0 +1,51 @@ +old; - } - - public function setOld(Document $old): void - { - $this->old = $old; - } - - public function getNew(): Document - { - return $this->new; - } - - public function setNew(Document $new): void - { - $this->new = $new; - } } diff --git a/src/Database/Collection.php b/src/Database/Collection.php new file mode 100644 index 0000000000..593c9a6969 --- /dev/null +++ b/src/Database/Collection.php @@ -0,0 +1,436 @@ +|null */ + private ?array $attributeModels = null; + + private mixed $attributeSource = null; + + /** @var list|null */ + private ?array $attributesWithInternal = null; + + /** @var list|null */ + private ?array $internalSource = null; + + /** @var list|null */ + private ?array $indexModels = null; + + private mixed $indexSource = null; + + private ?string $fingerprint = null; + + private mixed $fingerprintPermissions = null; + + private mixed $fingerprintDocumentSecurity = null; + + /** + * @param array $input + */ + private function __construct(array $input = []) + { + parent::__construct($input); + } + + /** + * @param list $attributes + * @param list $indexes + * @param list|null $permissions null grants the default create-any permission on creation; [] grants none + * @param array $metadata only keys the metadata collection stores; createCollection() refuses + * any other key with Exception\Structure + * + * @throws StructureException + */ + public static function create( + string $id, + string $name = '', + array $attributes = [], + array $indexes = [], + ?array $permissions = null, + bool $documentSecurity = true, + array $metadata = [], + ): self { + $reserved = \array_values(\array_intersect(self::CORE_KEYS, \array_keys($metadata))); + if ($reserved !== []) { + throw new StructureException('Collection metadata must not set '.\implode(', ', $reserved).'; pass them as arguments'); + } + + $data = [ + self::ID => $id, + self::NAME => $name !== '' ? $name : $id, + self::ATTRIBUTES => \array_map(static fn (Attribute $attribute): Document => $attribute->toDocument(), $attributes), + self::INDEXES => \array_map(static fn (Index $index): Document => $index->toDocument(), $indexes), + self::DOCUMENT_SECURITY => $documentSecurity, + ]; + + if ($permissions !== null) { + $data[self::PERMISSIONS] = $permissions; + } + + return new self(\array_merge($data, $metadata)); + } + + /** + * @param array $data + * + * @throws StructureException + * @throws IndexException + */ + #[\Override] + public static function fromArray(array $data): self + { + $id = $data[self::ID] ?? ''; + $data[self::ID] = $id = \is_string($id) ? $id : ''; + + $name = $data[self::NAME] ?? ''; + $data[self::NAME] = \is_string($name) && $name !== '' ? $name : $id; + + if (\array_key_exists(self::PERMISSIONS, $data) && ! \is_array($data[self::PERMISSIONS])) { + unset($data[self::PERMISSIONS]); + } + + $data[self::DOCUMENT_SECURITY] = (bool) ($data[self::DOCUMENT_SECURITY] ?? true); + $data[self::ATTRIBUTES] = self::attributeDocuments($data[self::ATTRIBUTES] ?? []); + $data[self::INDEXES] = self::indexDocuments($data[self::INDEXES] ?? []); + + return new self($data); + } + + /** + * The collection a stored definition describes; a definition that already is one is returned as it is, so its + * hydrated attributes and indexes are kept. + * + * @throws StructureException + * @throws IndexException + */ + public static function fromDocument(Document $document): self + { + return $document instanceof self ? $document : self::fromArray($document->getArrayCopy()); + } + + public function toDocument(): Document + { + $document = new Document(); + $document->exchangeArray(\iterator_to_array(clone $this)); + + return $document; + } + + /** + * @return list + * + * @throws StructureException + * @throws RelationshipException + */ + public function attributes(): array + { + $source = $this->getAttribute(self::ATTRIBUTES); + if ($this->attributeModels !== null && $source === $this->attributeSource) { + return $this->attributeModels; + } + + $stored = self::storedList($source) + ?? throw new StructureException('Collection attributes must be a list of attribute documents'); + + $models = []; + foreach ($stored as $attribute) { + $models[] = match (true) { + $attribute instanceof Document => Attribute::fromDocument($attribute), + \is_array($attribute) => Attribute::fromDocument(new Document(self::stringKeyed($attribute))), + default => throw new StructureException('Collection attributes must be attribute documents'), + }; + } + + $this->fingerprint = null; + $this->attributesWithInternal = null; + $this->attributeSource = $source; + + return $this->attributeModels = $models; + } + + /** + * The declared attributes followed by $internal, built at most once per schema state so per-document passes + * over the whole schema do not rebuild the list. + * + * @internal + * + * @param list $internal + * @return list + * + * @throws StructureException + */ + public function attributesWith(array $internal): array + { + $attributes = $this->attributes(); + if ($this->attributesWithInternal !== null && $internal === $this->internalSource) { + return $this->attributesWithInternal; + } + + $this->internalSource = $internal; + + return $this->attributesWithInternal = [...$attributes, ...$internal]; + } + + /** + * @return list + * + * @throws IndexException + */ + public function indexes(): array + { + $source = $this->getAttribute(self::INDEXES); + if ($this->indexModels !== null && $source === $this->indexSource) { + return $this->indexModels; + } + + $stored = self::storedList($source) + ?? throw new IndexException('Collection indexes must be a list of index documents'); + + $models = []; + foreach ($stored as $index) { + $models[] = match (true) { + $index instanceof Document => Index::fromDocument($index), + \is_array($index) => Index::fromArray(self::stringKeyed($index)), + default => throw new IndexException('Collection indexes must be index documents'), + }; + } + + $this->fingerprint = null; + $this->indexSource = $source; + + return $this->indexModels = $models; + } + + /** + * A hash of everything a query against this collection is validated and cached by: its attributes, indexes, + * permissions and document security. Computed at most once per schema state. + * + * @internal + * + * @throws StructureException + * @throws IndexException + */ + public function fingerprint(): string + { + $attributes = $this->attributes(); + $indexes = $this->indexes(); + $permissions = $this->getAttribute(self::PERMISSIONS); + $documentSecurity = $this->getAttribute(self::DOCUMENT_SECURITY); + + if ( + $this->fingerprint !== null + && $permissions === $this->fingerprintPermissions + && $documentSecurity === $this->fingerprintDocumentSecurity + ) { + return $this->fingerprint; + } + + $this->fingerprintPermissions = $permissions; + $this->fingerprintDocumentSecurity = $documentSecurity; + + return $this->fingerprint = \hash('xxh128', \serialize([ + $attributes, + $indexes, + $this->getPermissions(), + $this->documentSecurity(), + ])); + } + + public function name(): string + { + $name = $this->getAttribute(self::NAME); + + return \is_string($name) && $name !== '' ? $name : $this->getId(); + } + + public function documentSecurity(): bool + { + return (bool) $this->getAttribute(self::DOCUMENT_SECURITY, true); + } + + /** + * @return list|null + */ + public function declaredPermissions(): ?array + { + return $this->offsetExists(self::PERMISSIONS) ? $this->getPermissions() : null; + } + + #[\Override] + public function __clone() + { + $attributes = $this->attributeModels !== null && $this->getAttribute(self::ATTRIBUTES) === $this->attributeSource + ? $this->attributeModels + : null; + $indexes = $this->indexModels !== null && $this->getAttribute(self::INDEXES) === $this->indexSource + ? $this->indexModels + : null; + $fingerprint = $attributes !== null && $indexes !== null ? $this->fingerprint : null; + + parent::__clone(); + + $this->forget(self::ATTRIBUTES); + $this->forget(self::INDEXES); + if ($attributes !== null) { + $this->attributeModels = $attributes; + $this->attributeSource = $this->getAttribute(self::ATTRIBUTES); + } + if ($indexes !== null) { + $this->indexModels = $indexes; + $this->indexSource = $this->getAttribute(self::INDEXES); + } + $this->fingerprint = $fingerprint; + } + + #[\Override] + public function offsetSet(mixed $key, mixed $value): void + { + parent::offsetSet($key, $value); + $this->forget($key); + } + + #[\Override] + public function offsetUnset(mixed $key): void + { + parent::offsetUnset($key); + $this->forget($key); + } + + /** + * @param array|object $array + * @return array + */ + #[\Override] + public function exchangeArray(array|object $array): array + { + $previous = parent::exchangeArray($array); + + if (($previous[self::ATTRIBUTES] ?? null) !== $this->getAttribute(self::ATTRIBUTES)) { + $this->forget(self::ATTRIBUTES); + } + if (($previous[self::INDEXES] ?? null) !== $this->getAttribute(self::INDEXES)) { + $this->forget(self::INDEXES); + } + + return $previous; + } + + private function forget(mixed $key): void + { + if ($key === self::ATTRIBUTES) { + $this->attributeModels = null; + $this->attributeSource = null; + $this->fingerprint = null; + } elseif ($key === self::INDEXES) { + $this->indexModels = null; + $this->indexSource = null; + $this->fingerprint = null; + } + } + + /** + * The stored list as written, or decoded from the JSON a raw metadata row holds; null when it is neither. + * + * @return array|null + */ + private static function storedList(mixed $source): ?array + { + if ($source === null) { + return []; + } + + if (\is_string($source)) { + $source = \json_decode($source, true); + } + + return \is_array($source) ? $source : null; + } + + /** + * @return list|string + * + * @throws StructureException + */ + private static function attributeDocuments(mixed $attributes): array|string + { + if (\is_string($attributes)) { + return $attributes; + } + + if (! \is_array($attributes)) { + return []; + } + + $documents = []; + foreach ($attributes as $attribute) { + $documents[] = match (true) { + $attribute instanceof Attribute => $attribute->toDocument(), + $attribute instanceof Document => $attribute, + \is_array($attribute) => new Document(self::stringKeyed($attribute)), + default => throw new StructureException('Collection attributes must be attribute documents'), + }; + } + + return $documents; + } + + /** + * @return list|string + * + * @throws IndexException + */ + private static function indexDocuments(mixed $indexes): array|string + { + if (\is_string($indexes)) { + return $indexes; + } + + if (! \is_array($indexes)) { + return []; + } + + $documents = []; + foreach ($indexes as $index) { + $documents[] = match (true) { + $index instanceof Index => $index->toDocument(), + $index instanceof Document => $index, + \is_array($index) => new Document(self::stringKeyed($index)), + default => throw new IndexException('Collection indexes must be index documents'), + }; + } + + return $documents; + } + + /** + * @param array $data + * @return array + */ + private static function stringKeyed(array $data): array + { + $keyed = []; + foreach ($data as $key => $value) { + if (\is_string($key)) { + $keyed[$key] = $value; + } + } + + return $keyed; + } +} diff --git a/src/Database/CollectionUpdate.php b/src/Database/CollectionUpdate.php new file mode 100644 index 0000000000..c870943908 --- /dev/null +++ b/src/Database/CollectionUpdate.php @@ -0,0 +1,15 @@ +|null $permissions + */ + public function __construct( + public ?array $permissions = null, + public ?bool $documentSecurity = null, + ) { + } +} diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 474d10a7f6..267480e61a 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -2,26 +2,139 @@ namespace Utopia\Database; +use PDOException; use Swoole\Database\DetectsLostConnections; +use Throwable; +/** + * Provides utilities for detecting lost database connections. + */ class Connection { /** + * MySQL and MariaDB error numbers of a connection the server or the network dropped. + */ + private const array MYSQL_ERRORS = [ + 'ER_SERVER_SHUTDOWN' => 1053, + 'CR_CONNECTION_ERROR' => 2002, + 'CR_SERVER_GONE_ERROR' => 2006, + 'CR_SERVER_LOST' => 2013, + 'ER_CLIENT_INTERACTION_TIMEOUT' => 4031, + ]; + + /** + * PostgreSQL SQLSTATEs of a session the server ended. + */ + private const array POSTGRES_STATES = [ + 'admin_shutdown' => '57P01', + 'crash_shutdown' => '57P02', + 'cannot_connect_now' => '57P03', + 'database_dropped' => '57P04', + 'idle_session_timeout' => '57P05', + ]; + + private const string CONNECTION_EXCEPTION_CLASS = '08'; + + /** + * Swoole 6.2's DetectsLostConnections::ERROR_MESSAGES, plus the messages only this + * library knows, so detection does not depend on Swoole's library being loaded. + * * @var array */ protected static array $errors = [ - 'Max connect timeout reached' + 'server has gone away', + 'Server has gone away', + 'no connection to the server', + 'Lost connection', + 'is dead or not enabled', + 'Error while sending', + 'decryption failed or bad record mac', + 'server closed the connection unexpectedly', + 'SSL connection has been closed unexpectedly', + 'Error writing data to the connection', + 'Resource deadlock avoided', + 'Transaction() on null', + 'child connection forced to terminate due to client_idle_limit', + 'query_wait_timeout', + 'reset by peer', + 'Physical connection is not usable', + 'TCP Provider: Error code 0x68', + 'ORA-03113', + 'ORA-03114', + 'Packets out of order. Expected', + 'Adaptive Server connection failed', + 'Communication link failure', + 'connection is no longer usable', + 'Login timeout expired', + 'SQLSTATE[HY000] [2002] Connection refused', + 'running with the --read-only option so it cannot execute this statement', + 'The connection is broken and recovery is not possible. The connection is marked by the client driver as unrecoverable. No attempt was made to restore the connection.', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Name or service not known', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo for', + 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: EOF detected', + 'SSL error: unexpected eof', + 'SQLSTATE[HY000] [2002] Connection timed out', + 'SSL: Connection timed out', + 'SQLSTATE[HY000]: General error: 1105 The last transaction was aborted due to Seamless Scaling. Please retry.', + 'Temporary failure in name resolution', + 'SQLSTATE[08S01]: Communication link failure', + 'SQLSTATE[08006] [7] could not connect to server: Connection refused Is the server running on host', + 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: No route to host', + 'The client was disconnected by the server because of inactivity. See wait_timeout and interactive_timeout for configuring this behavior.', + 'SQLSTATE[08006] [7] could not translate host name', + 'TCP Provider: Error code 0x274C', + 'SQLSTATE[HY000] [2002] No such file or directory', + 'SSL: Operation timed out', + 'Reason: Server is in script upgrade mode. Only administrator can connect at this time.', + 'Unknown $curl_error_code: 77', + 'SSL: Handshake timed out', + 'SQLSTATE[08006] [7] SSL error: sslv3 alert unexpected message', + 'SQLSTATE[08006] [7] unrecognized SSL error code:', + 'SQLSTATE[HY000] [1045] Access denied for user', + 'SQLSTATE[HY000] [2002] No connection could be made because the target machine actively refused it', + 'SQLSTATE[HY000] [2002] A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond', + 'SQLSTATE[HY000] [2002] Network is unreachable', + 'SQLSTATE[HY000] [2002] The requested address is not valid in its context', + 'SQLSTATE[HY000] [2002] A socket operation was attempted to an unreachable network', + 'SQLSTATE[HY000] [2002] Operation now in progress', + 'SQLSTATE[HY000] [2002] Operation in progress', + 'SQLSTATE[HY000]: General error: 3989', + 'went away', + 'No such file or directory', + 'server is shutting down', + 'failed to connect to', + 'Channel connection is closed', + 'Connection lost', + 'Broken pipe', + 'SQLSTATE[25006]: Read only sql transaction: 7', + 'vtgate connection error: no healthy endpoints', + 'primary is not serving, there may be a reparent operation in progress', + 'current keyspace is being resharded', + 'no healthy tablet available', + 'transaction pool connection limit exceeded', + 'SSL operation failed with code 5', + 'timed out', + 'Error reading result', + 'Max connect timeout reached', ]; /** * Check if the given throwable was caused by a database connection error. * - * @param \Throwable $e + * The driver's error code decides first; messages are the fallback for errors that + * carry none, such as those raised by the network layer or a connection pool. + * + * @param Throwable $e The exception to inspect * @return bool */ - public static function hasError(\Throwable $e): bool + public static function hasError(Throwable $e): bool { - if (DetectsLostConnections::causedByLostConnection($e)) { + if ($e instanceof PDOException && self::hasLostConnectionCode($e)) { + return true; + } + + if (\class_exists(DetectsLostConnections::class) && DetectsLostConnections::causedByLostConnection($e)) { return true; } @@ -34,4 +147,15 @@ public static function hasError(\Throwable $e): bool return false; } + + private static function hasLostConnectionCode(PDOException $e): bool + { + $state = $e->errorInfo[0] ?? null; + if (\is_string($state) && \str_starts_with($state, self::CONNECTION_EXCEPTION_CLASS)) { + return true; + } + + return \in_array($state, self::POSTGRES_STATES, true) + || \in_array($e->errorInfo[1] ?? null, self::MYSQL_ERRORS, true); + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 2b7ac04676..aa4f3fd5d6 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2,467 +2,280 @@ namespace Utopia\Database; +use DateTime as NativeDateTime; +use DateTimeZone; use Exception; use Swoole\Coroutine; use Throwable; use Utopia\Cache\Cache; use Utopia\Console; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\Profile; +use Utopia\Database\Cache\Invalidator; +use Utopia\Database\Cache\Query as ResultCache; +use Utopia\Database\Cache\Scope; +use Utopia\Database\Event\Domain; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; +use Utopia\Database\Exception\Character as CharacterException; use Utopia\Database\Exception\Conflict as ConflictException; use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Order as OrderException; use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Refused as RefusedException; use Utopia\Database\Exception\Relationship as RelationshipException; use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Exception\Type as TypeException; -use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Validator\Attribute as AttributeValidator; +use Utopia\Database\Exception\Unconfirmed as UnconfirmedException; +use Utopia\Database\Filter\Codec; +use Utopia\Database\Filter\Registry; +use Utopia\Database\Hook\Lifecycle; +use Utopia\Database\Hook\Named; +use Utopia\Database\Hook\Relationships; +use Utopia\Database\Hook\Selective; +use Utopia\Database\Hook\Transform; +use Utopia\Database\State\Snapshot; +use Utopia\Database\State\Value; use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Authorization\Input; -use Utopia\Database\Validator\BigInt as BigIntValidator; -use Utopia\Database\Validator\Index as IndexValidator; -use Utopia\Database\Validator\IndexDependency as IndexDependencyValidator; -use Utopia\Database\Validator\PartialStructure; -use Utopia\Database\Validator\Permissions; -use Utopia\Database\Validator\Queries\Document as DocumentValidator; -use Utopia\Database\Validator\Queries\Documents as DocumentsValidator; -use Utopia\Database\Validator\Spatial; -use Utopia\Database\Validator\Structure; +use Utopia\Database\Validator\BigInt; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; class Database { - // Simple Types - public const VAR_STRING = 'string'; - public const VAR_INTEGER = 'integer'; - public const VAR_BIGINT = 'bigint'; - public const VAR_FLOAT = 'double'; - public const VAR_BOOLEAN = 'boolean'; - public const VAR_DATETIME = 'datetime'; - - public const VAR_VARCHAR = 'varchar'; - public const VAR_TEXT = 'text'; - public const VAR_MEDIUMTEXT = 'mediumtext'; - public const VAR_LONGTEXT = 'longtext'; - - // ID types - public const VAR_ID = 'id'; - public const VAR_UUID7 = 'uuid7'; - - // object type - public const VAR_OBJECT = 'object'; - - // Vector types - public const VAR_VECTOR = 'vector'; - - // Vector query result key - public const VECTOR_DISTANCE = '$distance'; - - // Relationship Types - public const VAR_RELATIONSHIP = 'relationship'; - - // Spatial Types - public const VAR_POINT = 'point'; - public const VAR_LINESTRING = 'linestring'; - public const VAR_POLYGON = 'polygon'; - - // All string types - public const STRING_TYPES = [ - self::VAR_STRING, - self::VAR_VARCHAR, - self::VAR_TEXT, - self::VAR_MEDIUMTEXT, - self::VAR_LONGTEXT, - ]; + use Trait\Attributes; + use Trait\Collections; + use Trait\Databases; + use Trait\Documents; + use Trait\Indexes; + use Trait\Relationships; + use Trait\Transactions; - // All spatial types - public const SPATIAL_TYPES = [ - self::VAR_POINT, - self::VAR_LINESTRING, - self::VAR_POLYGON - ]; + public const int MAX_INT = 2147483647; - // All types which requires filters - public const ATTRIBUTE_FILTER_TYPES = [ - ...self::SPATIAL_TYPES, - self::VAR_VECTOR, - self::VAR_OBJECT, - self::VAR_DATETIME - ]; + public const int MAX_BIG_INT = PHP_INT_MAX; - // Index Types - public const INDEX_KEY = 'key'; - public const INDEX_FULLTEXT = 'fulltext'; - public const INDEX_UNIQUE = 'unique'; - public const INDEX_SPATIAL = 'spatial'; - public const INDEX_OBJECT = 'object'; - public const INDEX_HNSW_EUCLIDEAN = 'hnsw_euclidean'; - public const INDEX_HNSW_COSINE = 'hnsw_cosine'; - public const INDEX_HNSW_DOT = 'hnsw_dot'; - public const INDEX_TRIGRAM = 'trigram'; - public const INDEX_TTL = 'ttl'; - - // Max limits - public const MAX_INT = 2147483647; - public const MAX_BIG_INT = PHP_INT_MAX; - public const MAX_DOUBLE = PHP_FLOAT_MAX; - public const MAX_VECTOR_DIMENSIONS = 16000; - public const MAX_ARRAY_INDEX_LENGTH = 255; - public const MAX_UID_DEFAULT_LENGTH = 36; - - // Maximum byte capacity for TEXT - public const MAX_TEXT_BYTES = 65535; - public const MAX_MEDIUMTEXT_BYTES = 16777215; - public const MAX_LONGTEXT_BYTES = 4294967295; - - // Min limits - public const MIN_INT = -2147483648; - - // Global SRID for geographic coordinates (WGS84) - public const DEFAULT_SRID = 4326; - public const EARTH_RADIUS = 6371000; - - // Relation Types - public const RELATION_ONE_TO_ONE = 'oneToOne'; - public const RELATION_ONE_TO_MANY = 'oneToMany'; - public const RELATION_MANY_TO_ONE = 'manyToOne'; - public const RELATION_MANY_TO_MANY = 'manyToMany'; - - // Relation Actions - public const RELATION_MUTATE_CASCADE = 'cascade'; - public const RELATION_MUTATE_RESTRICT = 'restrict'; - public const RELATION_MUTATE_SET_NULL = 'setNull'; - - // Relation Sides - public const RELATION_SIDE_PARENT = 'parent'; - public const RELATION_SIDE_CHILD = 'child'; - - public const RELATION_MAX_DEPTH = 3; - - // Orders - public const ORDER_ASC = 'ASC'; - public const ORDER_DESC = 'DESC'; - public const ORDER_RANDOM = 'RANDOM'; - - // Permissions - public const PERMISSION_CREATE = 'create'; - public const PERMISSION_READ = 'read'; - public const PERMISSION_UPDATE = 'update'; - public const PERMISSION_DELETE = 'delete'; - - // Aggregate permissions - public const PERMISSION_WRITE = 'write'; - - public const PERMISSIONS = [ - self::PERMISSION_CREATE, - self::PERMISSION_READ, - self::PERMISSION_UPDATE, - self::PERMISSION_DELETE, - ]; + public const float MAX_DOUBLE = PHP_FLOAT_MAX; + + public const int MAX_VECTOR_DIMENSIONS = 16000; + + public const string VECTOR_DISTANCE = Document::DISTANCE; + + public const int MAX_ARRAY_INDEX_LENGTH = 255; + + public const int MAX_UID_DEFAULT_LENGTH = 36; + + public const int MAX_TEXT_BYTES = 65535; + + public const int MAX_MEDIUMTEXT_BYTES = 16777215; + + public const int MAX_LONGTEXT_BYTES = 4294967295; + + public const int MIN_INT = -2147483648; + + public const int DEFAULT_SRID = 4326; + + public const int EARTH_RADIUS = 6371000; + + public const int RELATION_MAX_DEPTH = 3; + + public const int RELATION_QUERY_CHUNK_SIZE = 5000; - // Collections - public const METADATA = '_metadata'; - - // Cursor - public const CURSOR_BEFORE = 'before'; - public const CURSOR_AFTER = 'after'; - - // Lengths - public const LENGTH_KEY = 255; - - // Cache - public const TTL = 60 * 60 * 24; // 24 hours - - // Cache "Not Found" results - private const CACHE_EMPTY_MARKER = '$empty'; - - // Events - public const EVENT_ALL = '*'; - - public const EVENT_DATABASE_LIST = 'database_list'; - public const EVENT_DATABASE_CREATE = 'database_create'; - public const EVENT_DATABASE_DELETE = 'database_delete'; - - public const EVENT_COLLECTION_LIST = 'collection_list'; - public const EVENT_COLLECTION_CREATE = 'collection_create'; - public const EVENT_COLLECTION_UPDATE = 'collection_update'; - public const EVENT_COLLECTION_READ = 'collection_read'; - public const EVENT_COLLECTION_DELETE = 'collection_delete'; - - public const EVENT_DOCUMENT_FIND = 'document_find'; - public const EVENT_DOCUMENT_PURGE = 'document_purge'; - public const EVENT_DOCUMENT_CREATE = 'document_create'; - public const EVENT_DOCUMENTS_CREATE = 'documents_create'; - public const EVENT_DOCUMENT_READ = 'document_read'; - public const EVENT_DOCUMENT_UPDATE = 'document_update'; - public const EVENT_DOCUMENTS_UPDATE = 'documents_update'; - public const EVENT_DOCUMENTS_UPSERT = 'documents_upsert'; - public const EVENT_DOCUMENT_DELETE = 'document_delete'; - public const EVENT_DOCUMENTS_DELETE = 'documents_delete'; - public const EVENT_DOCUMENT_COUNT = 'document_count'; - public const EVENT_DOCUMENT_SUM = 'document_sum'; - public const EVENT_DOCUMENT_INCREASE = 'document_increase'; - public const EVENT_DOCUMENT_DECREASE = 'document_decrease'; - - public const EVENT_PERMISSIONS_CREATE = 'permissions_create'; - public const EVENT_PERMISSIONS_READ = 'permissions_read'; - public const EVENT_PERMISSIONS_DELETE = 'permissions_delete'; - - public const EVENT_ATTRIBUTE_CREATE = 'attribute_create'; - public const EVENT_ATTRIBUTES_CREATE = 'attributes_create'; - public const EVENT_ATTRIBUTE_UPDATE = 'attribute_update'; - public const EVENT_ATTRIBUTE_DELETE = 'attribute_delete'; - - public const EVENT_INDEX_RENAME = 'index_rename'; - public const EVENT_INDEX_CREATE = 'index_create'; - public const EVENT_INDEX_DELETE = 'index_delete'; - - public const INSERT_BATCH_SIZE = 1_000; - public const DELETE_BATCH_SIZE = 1_000; + public const string METADATA = '_metadata'; + + public const int LENGTH_KEY = 255; + + public const int TTL = 60 * 60 * 24; + + private const string CACHE_EMPTY_MARKER = '$empty'; /** - * List of Internal attributes + * Failures that fail the same way on every attempt, so withRetries() rethrows them at once: every typed failure + * of this library except Transaction (and Contention), which another attempt can clear. Mismatch and Unique are + * Duplicates. Unconfirmed is listed because another attempt could write twice. * - * @var array> + * @var list> + */ + private const array DETERMINISTIC_FAILURES = [ + AuthorizationException::class, + CharacterException::class, + ConflictException::class, + DependencyException::class, + DuplicateException::class, + IndexException::class, + LimitException::class, + NotFoundException::class, + OperatorException::class, + OrderException::class, + QueryException::class, + RefusedException::class, + RelationshipException::class, + RestrictedException::class, + StructureException::class, + TimeoutException::class, + TruncateException::class, + TypeException::class, + UnconfirmedException::class, + ]; + + public const int BATCH_SIZE = 1_000; + + public const int CURSOR_BATCH_SIZE = 100; + + /** + * @var list */ - public const INTERNAL_ATTRIBUTES = [ - [ - '$id' => '$id', - 'type' => self::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$sequence', - 'type' => self::VAR_ID, - 'size' => 0, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$collection', - 'type' => self::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$tenant', - 'type' => self::VAR_ID, - 'size' => 0, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'] - ], - [ - '$id' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'] - ], - [ - '$id' => '$permissions', - 'type' => Database::VAR_STRING, - 'size' => 1_000_000, - 'signed' => true, - 'required' => false, - 'default' => [], - 'array' => false, - 'filters' => ['json'] - ], + public const array DEFAULT_FILTERS = [ + Filter::Json->value, + Filter::Datetime->value, + ColumnType::Point->value, + ColumnType::Linestring->value, + ColumnType::Polygon->value, + ColumnType::Vector->value, + ColumnType::Object->value, ]; - public const INTERNAL_ATTRIBUTE_KEYS = [ - '_uid', - '_createdAt', - '_updatedAt', - '_permissions', + public const array INTERNAL_ATTRIBUTE_KEYS = [ + Storage::UID, + Storage::CREATED_AT, + Storage::UPDATED_AT, + Storage::PERMISSIONS, ]; - public const INTERNAL_INDEXES = [ - '_id', - '_uid', - '_createdAt', - '_updatedAt', - '_permissions_id', - '_permissions', + public const array INTERNAL_INDEXES = [ + Storage::SEQUENCE, + Storage::UID, + Storage::CREATED_AT, + Storage::UPDATED_AT, + Storage::INDEX_PERMISSIONS_ID, + Storage::PERMISSIONS, ]; /** - * Parent Collection - * Defines the structure for both system and custom collections - * - * @var array + * The optional adapter features a profile records. */ - protected const COLLECTION = [ - '$id' => self::METADATA, - '$collection' => self::METADATA, - 'name' => 'collections', - 'attributes' => [ - [ - '$id' => 'name', - 'key' => 'name', - 'type' => self::VAR_STRING, - 'size' => 256, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'attributes', - 'key' => 'attributes', - 'type' => self::VAR_STRING, - 'size' => 1000000, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => 'indexes', - 'key' => 'indexes', - 'type' => self::VAR_STRING, - 'size' => 1000000, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => 'documentSecurity', - 'key' => 'documentSecurity', - 'type' => self::VAR_BOOLEAN, - 'size' => 0, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [] - ] - ], - 'indexes' => [], + private const array FEATURES = [ + Feature\Casting::class, + Feature\Connection::class, + Feature\QueryBuilder::class, + Feature\RawQuery::class, + Feature\Relationships::class, + Feature\Schemaless::class, + Feature\Spatial::class, + Feature\Timeouts::class, + Feature\Upserts::class, ]; - protected Adapter $adapter; + private const string COLLECTION_NAME = Collection::NAME; - protected Cache $cache; + private const string COLLECTION_ATTRIBUTES = Collection::ATTRIBUTES; + + private const string COLLECTION_INDEXES = Collection::INDEXES; - protected string $cacheName = 'default'; + private const string COLLECTION_DOCUMENT_SECURITY = Collection::DOCUMENT_SECURITY; + + private const string INDEX_ATTRIBUTES = 'attributes'; /** - * @var array + * Keys of a collection definition that createCollection() sets itself rather than carrying over as metadata. */ - protected static array $filters = []; + private const array COLLECTION_RESERVED_KEYS = [ + Document::ID => true, + Document::SEQUENCE => true, + Document::COLLECTION => true, + Document::TENANT => true, + Document::CREATED_AT => true, + Document::UPDATED_AT => true, + Document::PERMISSIONS => true, + self::COLLECTION_NAME => true, + self::COLLECTION_ATTRIBUTES => true, + self::COLLECTION_INDEXES => true, + self::COLLECTION_DOCUMENT_SECURITY => true, + ]; - protected static bool $defaultFiltersRegistered = false; + protected Adapter $adapter; - protected static int $filtersVersion = 0; + protected Cache $cache; - /** - * @var array>|null - */ - private static ?array $tenantlessInternalAttributes = null; + protected string $cacheName = Scope::NAME; /** * @var array */ - protected array $instanceFilters = []; + protected static array $filters = []; + + protected static bool $defaultFiltersRegistered = false; /** - * @var array + * @var array> */ - private array $filterSignatures = []; + private static array $internalAttributes = []; - private string $filterSignaturesEncoded = ''; + private static ?Collection $definition = null; - private int $filterSignaturesVersion = -1; + protected ?Registry $codecs = null; /** - * @var array + * @var array */ - private array $filterSignaturesSource = []; + protected array $lifecycleHooks = []; /** - * @var array> + * @var array */ - protected array $listeners = [ - '*' => [], - ]; + protected array $decorators = []; - /** - * Array in which the keys are the names of database listeners that - * should be skipped when dispatching events. null $silentListeners - * will skip all listeners. - * - * @var ?array - */ - protected ?array $silentListeners = []; + /** @var Value|null Whether every lifecycle hook is silenced. */ + private ?Value $silenced = null; - protected ?\DateTime $timestamp = null; + /** @var Value>|null Names of the silenced lifecycle hooks. */ + private ?Value $silencedListeners = null; - protected bool $resolveRelationships = true; + /** @var array> Pending query-cache tombstones by coroutine id. */ + protected array $queryCacheMutations = []; - protected bool $checkRelationshipsExist = true; + /** @var array> Pending document-cache tombstones by coroutine id. */ + protected array $documentCacheMutations = []; - protected int $relationshipFetchDepth = 0; + /** @var Value|null */ + private ?Value $requestTimestamp = null; - protected bool $inBatchRelationshipPopulation = false; + /** @var Value|null */ + private ?Value $filtering = null; - protected bool $filter = true; + /** @var Value|null>|null */ + private ?Value $filterExclusions = null; - /** - * @var array|null - */ - protected ?array $disabledFilters = []; + /** @var Value|null */ + private ?Value $validation = null; - protected bool $validate = true; + /** @var Value|null */ + private ?Value $datePreservation = null; - protected bool $dropUnknownAttributes = false; + /** @var Value|null */ + private ?Value $sequencePreservation = null; - protected bool $preserveDates = false; + /** @var Value|null */ + private ?Value $ignoringDuplicates = null; - protected bool $skipDuplicates = false; + protected ?Relationships $relationshipHook = null; - protected bool $preserveSequence = false; + protected bool $dropUnknownAttributes = false; protected int $maxQueryValues = 5000; protected bool $migrating = false; + private ?Profile $profile = null; + /** * List of collections that should be treated as globally accessible * @@ -470,133 +283,122 @@ class Database */ protected array $globalCollections = []; - /** - * Stack of collection IDs when creating or updating related documents - * @var array - */ - protected array $relationshipWriteStack = []; - - /** - * @var array - */ - protected array $relationshipFetchStack = []; - - /** - * @var array - */ - protected array $relationshipDeleteStack = []; - /** * Type mapping for collections to custom document classes + * * @var array> */ protected array $documentTypes = []; + protected ?ResultCache $queryCache = null; + + protected ?Invalidator $invalidator = null; + + protected ?Profiler $profiler = null; - /** - * @var Authorization - */ private Authorization $authorization; /** - * @param Adapter $adapter - * @param Cache $cache - * @param array $filters + * @param list $filters Filters of this handle, which take precedence over the ones addFilter() registers + * + * @throws DuplicateException When a filter is named after a built-in one */ public function __construct( Adapter $adapter, Cache $cache, - array $filters = [] + array $filters = [], ) { $this->adapter = $adapter; $this->cache = $cache; - foreach ($filters as $name => $callbacks) { - $filters[$name]['signature'] = self::computeCallableSignature($callbacks['encode']) - . ':' . self::computeCallableSignature($callbacks['decode']); + if ($filters !== []) { + $this->codecs = new Registry(); + foreach ($filters as $codec) { + $this->codecs->register($codec); + } } - $this->instanceFilters = $filters; $this->setAuthorization(new Authorization()); + $this->documentTypes[self::METADATA] = Collection::class; self::registerDefaultFilters(); } - /** - * Registers the built-in filters on first touch of the registry, so an - * explicit addFilter() always wins regardless of whether it ran before or - * after the first instance. The flag is set first: addFilter() calls back - * into this, and the guard is what terminates that recursion. - */ - private static function registerDefaultFilters(): void + protected static function registerDefaultFilters(): void { if (self::$defaultFiltersRegistered) { return; } - self::$defaultFiltersRegistered = true; self::addFilter( - 'json', + Filter::Json->value, /** - * @param mixed $value * @return mixed */ static function (mixed $value) { $value = ($value instanceof Document) ? $value->getArrayCopy() : $value; - if (!is_array($value) && !$value instanceof \stdClass) { + if (! is_array($value) && ! $value instanceof \stdClass) { return $value; } return json_encode($value); }, /** - * @param mixed $value * @return mixed + * * @throws Exception */ - static function (mixed $value) { - if (!is_string($value)) { + static function (mixed $value, mixed $document = null, mixed $database = null, string $attribute = '') { + if (! is_string($value)) { return $value; } - $value = json_decode($value, true) ?? []; + $decoded = json_decode($value, true) ?? []; + if (! is_array($decoded)) { + return $decoded; + } - if (array_key_exists('$id', $value)) { - return new Document($value); - } else { - $value = array_map(static function ($item) { - if (is_array($item) && array_key_exists('$id', $item)) { // if `$id` exists, create a Document instance - return new Document($item); - } - return $item; - }, $value); + /** @var array $decoded */ + if (array_key_exists(Document::ID, $decoded)) { + return Document::fromStorage($decoded); } - return $value; + $decoded = array_map(static function ($item) { + if (! is_array($item) || ! array_key_exists(Document::ID, $item)) { + return $item; + } + /** @var array $item */ + + return Document::fromStorage($item); + }, $decoded); + + return $decoded; } ); self::addFilter( - 'datetime', + Filter::Datetime->value, /** - * @param mixed $value * @return mixed */ static function (mixed $value) { if (is_null($value)) { return; } + if (! is_string($value)) { + return $value; + } try { - $value = new \DateTime($value); - $value->setTimezone(new \DateTimeZone(date_default_timezone_get())); + $value = new NativeDateTime($value); + $value->setTimezone(new DateTimeZone(date_default_timezone_get())); + return DateTime::format($value); - } catch (\Throwable) { + } catch (Throwable) { return $value; } }, /** - * @param string|null $value * @return string|null */ static function (?string $value) { @@ -605,153 +407,166 @@ static function (?string $value) { ); self::addFilter( - Database::VAR_POINT, + ColumnType::Point->value, /** - * @param mixed $value - * @param Document $document - * @param Database $database + * An invalid geometry is returned as given, for the structure validator to reject. + * * @return mixed */ static function (mixed $value, Document $document, Database $database) { - if (!is_array($value)) { + if (! is_array($value) || ! $database->adapter->hasFeature(Feature\Spatial::class)) { return $value; } + /** @var Adapter&Feature\Spatial $adapter */ + $adapter = $database->adapter; + try { - return $database->encodeSpatialData($value, Database::VAR_POINT); - } catch (\Throwable) { + return $adapter->encode($value, ColumnType::Point); + } catch (StructureException) { return $value; } }, /** - * @param string|null $value - * @param Document $document - * @param Database $database * @return array|null */ static function (?string $value, Document $document, Database $database) { if ($value === null) { return null; } - return $database->adapter->decodePoint($value); + if ($database->adapter->hasFeature(Feature\Spatial::class)) { + /** @var Adapter&Feature\Spatial $adapter */ + $adapter = $database->adapter; + + return $adapter->decode($value, ColumnType::Point); + } + + return null; } ); self::addFilter( - Database::VAR_LINESTRING, + ColumnType::Linestring->value, /** - * @param mixed $value - * @param Document $document - * @param Database $database + * An invalid geometry is returned as given, for the structure validator to reject. + * * @return mixed */ static function (mixed $value, Document $document, Database $database) { - if (!is_array($value)) { + if (! is_array($value) || ! $database->adapter->hasFeature(Feature\Spatial::class)) { return $value; } + /** @var Adapter&Feature\Spatial $adapter */ + $adapter = $database->adapter; + try { - return $database->encodeSpatialData($value, Database::VAR_LINESTRING); - } catch (\Throwable) { + return $adapter->encode($value, ColumnType::Linestring); + } catch (StructureException) { return $value; } }, /** - * @param string|null $value - * @param Document $document - * @param Database $database * @return array|null */ static function (?string $value, Document $document, Database $database) { if (is_null($value)) { return null; } - return $database->adapter->decodeLinestring($value); + if ($database->adapter->hasFeature(Feature\Spatial::class)) { + /** @var Adapter&Feature\Spatial $adapter */ + $adapter = $database->adapter; + + return $adapter->decode($value, ColumnType::Linestring); + } + + return null; } ); self::addFilter( - Database::VAR_POLYGON, + ColumnType::Polygon->value, /** - * @param mixed $value - * @param Document $document - * @param Database $database + * An invalid geometry is returned as given, for the structure validator to reject. + * * @return mixed */ static function (mixed $value, Document $document, Database $database) { - if (!is_array($value)) { + if (! is_array($value) || ! $database->adapter->hasFeature(Feature\Spatial::class)) { return $value; } + /** @var Adapter&Feature\Spatial $adapter */ + $adapter = $database->adapter; + try { - return $database->encodeSpatialData($value, Database::VAR_POLYGON); - } catch (\Throwable) { + return $adapter->encode($value, ColumnType::Polygon); + } catch (StructureException) { return $value; } }, /** - * @param string|null $value - * @param Document $document - * @param Database $database * @return array|null */ static function (?string $value, Document $document, Database $database) { if (is_null($value)) { return null; } - return $database->adapter->decodePolygon($value); + if ($database->adapter->hasFeature(Feature\Spatial::class)) { + /** @var Adapter&Feature\Spatial $adapter */ + $adapter = $database->adapter; + + return $adapter->decode($value, ColumnType::Polygon); + } + + return null; } ); self::addFilter( - Database::VAR_VECTOR, + ColumnType::Vector->value, /** - * @param mixed $value * @return mixed */ static function (mixed $value) { - if (!\is_array($value)) { + if (! \is_array($value)) { return $value; } - if (!\array_is_list($value)) { + if (! \array_is_list($value)) { return $value; } foreach ($value as $item) { - if (!\is_int($item) && !\is_float($item)) { + if (! \is_int($item) && ! \is_float($item)) { return $value; } } - return \json_encode(\array_map(\floatval(...), $value)); + /** @var array $value */ + return \json_encode(\array_map(fn (int|float $v): float => (float) $v, $value)); }, /** - * @param string|null $value - * @return mixed + * @return array|null */ static function (?string $value) { if (is_null($value)) { return null; } - if (!is_string($value)) { - return $value; - } - $decoded = json_decode($value, true); - return is_array($decoded) ? $decoded : $value; + $decoded = self::decodeObject($value); + + return is_array($decoded) || $decoded instanceof \stdClass ? $decoded : $value; } ); self::addFilter( - Database::VAR_OBJECT, + ColumnType::Object->value, /** - * @param mixed $value * @return mixed */ static function (mixed $value) { - if (!\is_array($value) && !$value instanceof \stdClass) { + if (! \is_array($value) && ! $value instanceof \stdClass) { return $value; } return \json_encode($value); }, /** - * @param mixed $value * @return array|null */ static function (mixed $value) { @@ -759,7 +574,7 @@ static function (mixed $value) { return; } // can be non string in case of mongodb as it stores the value as object - if (!is_string($value)) { + if (! is_string($value)) { return $value; } $decoded = self::decodeObject($value); @@ -817,333 +632,317 @@ private static function valuesEqual(mixed $value, mixed $old): bool } /** - * Add listener to events - * Passing a null $callback will remove the listener - * - * @param string $event - * @param string $name - * @param ?callable $callback - * @return static + * @throws DatabaseException */ - public function on(string $event, string $name, ?callable $callback): static + public function setDatabase(string $name): static { - if (empty($callback)) { - unset($this->listeners[$event][$name]); - return $this; - } - - if (!isset($this->listeners[$event])) { - $this->listeners[$event] = []; - } - $this->listeners[$event][$name] = $callback; + $this->adapter->setDatabase($name); return $this; } /** - * Add a transformation to be applied to a query string before an event occurs - * - * @param string $event - * @param string $name - * @param ?callable $callback - * @return $this + * @throws DatabaseException */ - public function before(string $event, string $name, ?callable $callback): static + public function getDatabase(): string { - $this->adapter->before($event, $name, $callback); - - return $this; + return $this->adapter->getDatabase(); } /** - * Silent event generation for calls inside the callback + * @return $this * - * @template T - * @param callable(): T $callback - * @param array|null $listeners List of listeners to silence; if null, all listeners will be silenced - * @return T + * @throws DatabaseException */ - public function silent(callable $callback, ?array $listeners = null): mixed + public function setNamespace(string $namespace): static { - $previous = $this->silentListeners; + $this->adapter->setNamespace($namespace); - if (is_null($listeners)) { - $this->silentListeners = null; - } else { - $silentListeners = []; - foreach ($listeners as $listener) { - $silentListeners[$listener] = true; - } - $this->silentListeners = $silentListeners; - } + return $this; + } - try { - return $callback(); - } finally { - $this->silentListeners = $previous; - } + public function getNamespace(): string + { + return $this->adapter->getNamespace(); } /** - * Get getConnection Id - * - * @return string - * @throws Exception + * The type of the internal sequence: integer on SQL, uuid7 on MongoDB. */ - public function getConnectionId(): string + public function getIdAttributeType(): ColumnType { - return $this->adapter->getConnectionId(); + return $this->adapter->limits()->idType; } /** - * Skip relationships for all the calls inside the callback - * - * @template T - * @param callable(): T $callback - * @return T + * The adapter's limits and capabilities and this database's mode, built once and rebuilt when + * shared tables, migration or the schemaless mode change. DefinedAttributes is asked of the + * adapter on every check, as the database asks it. */ - public function skipRelationships(callable $callback): mixed + public function profile(): Profile { - $previous = $this->resolveRelationships; - $this->resolveRelationships = false; - - try { - return $callback(); - } finally { - $this->resolveRelationships = $previous; + if ($this->profile !== null) { + return $this->profile; } + + $adapter = $this->adapter; + $capabilities = \array_filter( + Capability::cases(), + static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes && $adapter->supports($capability), + ); + + return $this->profile = new Profile( + $adapter->limits(), + \array_values($capabilities), + \array_values(\array_filter(self::FEATURES, $adapter->hasFeature(...))), + $adapter->hasSharedTables(), + $this->migrating, + static fn (): bool => $adapter->supports(Capability::DefinedAttributes), + ); } /** - * Refetch documents after operator updates to get computed values + * Turn the adapter's schemaless mode on or off. An adapter without a schemaless mode always + * enforces its schema, so it only accepts false. * - * @param Document $collection - * @param array $documents - * @param array $selections Select queries from the caller, preserved so the refetch honors the original projection - * @return array * @throws DatabaseException */ - protected function refetchDocuments(Document $collection, array $documents, array $selections = []): array + public function setSchemaless(bool $schemaless): static { - if (empty($documents)) { - return $documents; - } - - $sequences = array_map(function ($doc) { - $sequence = $doc->getSequence(); - if ($sequence === null) { - throw new DatabaseException('Cannot refetch document without a $sequence: ' . $doc->getId()); - } - return $sequence; - }, $documents); - - // Fetch fresh copies with computed operator values, preserving the caller's projection. - // Chunk by maxQueryValues (the batch can be up to INSERT_BATCH_SIZE) and bound each find() - // to the chunk size, otherwise find()'s default limit would silently drop rows past it. - $refetchedMap = []; - foreach (\array_chunk($sequences, \max(1, $this->maxQueryValues)) as $chunk) { - $refetched = $this->getAuthorization()->skip(fn () => $this->silent( - fn () => $this->find( - $collection->getId(), - array_merge([ - Query::equal('$sequence', $chunk), - Query::limit(\count($chunk)), - ], $selections) - ) - )); - - foreach ($refetched as $doc) { - $refetchedMap[$doc->getSequence()] = $doc; - } + if ($this->adapterHasFeature(Feature\Schemaless::class)) { + $this->adapter->setSchemaless($schemaless); + } elseif ($schemaless) { + throw new DatabaseException('Adapter does not support schemaless'); } - $result = []; - foreach ($documents as $index => $doc) { - $result[$index] = $refetchedMap[$sequences[$index]] ?? $doc; - } + $this->resetProfile(); - return $result; + return $this; } - public function skipRelationshipsExistCheck(callable $callback): mixed + private function resetProfile(): void { - $previous = $this->checkRelationshipsExist; - $this->checkRelationshipsExist = false; - - try { - return $callback(); - } finally { - $this->checkRelationshipsExist = $previous; - } + $this->profile = null; + $this->documentsValidatorCache = []; } - public function skipDuplicates(callable $callback): mixed + public function getAdapter(): Adapter { - $previous = $this->skipDuplicates; - $this->skipDuplicates = true; - - try { - return $callback(); - } finally { - $this->skipDuplicates = $previous; - } + return $this->adapter; } /** - * Build a tenant-aware identity key for a document. - * Returns ":" in tenant-per-document shared-table mode, otherwise just the id. + * Pool answers for the adapter it borrows without implementing the Feature interface, but declares every + * Feature method, so a true answer makes those methods callable on the adapter either way. + * + * @template T of object + * + * @param class-string $feature + * + * @phpstan-assert-if-true T $this->adapter */ - private function tenantKey(Document $document): string + private function adapterHasFeature(string $feature): bool { - return ($this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument()) - ? $document->getTenant() . ':' . $document->getId() - : $document->getId(); + return $this->adapter->hasFeature($feature); } /** - * Trigger callback for events + * Get a utopia-php/query Builder over a collection's table, for statements the document API + * cannot express. Its statements run as written: they check no permissions, read past and never + * purge the document and query caches (purgeCachedDocument() what they change), keep no `_perms` + * rows, validate nothing and run no hooks or events, so a Mirror does not replicate them. It is + * therefore only handed out, and its statements only run, while authorization is disabled: + * inside getAuthorization()->skip(). * - * @param string $event - * @param mixed $args - * @return void + * Skipping authorization lifts permissions, never tenancy: under shared tables every statement + * stays within the tenant selected when the builder was handed out (see SQL::builder() for + * what that covers). Another tenant's rows are read by selecting that tenant, with setTenant() + * or withTenant(). + * + * $alias names the collection's table in the statement, and is the name its tenant condition and a right + * or full outer join pair with. The builder reads this collection only, and its join methods take + * collection ids too. + * + * @throws AuthorizationException While authorization is enabled + * @throws DatabaseException When the adapter has no query builder, or hands out one with no scope */ - protected function trigger(string $event, mixed $args = null): void + public function from(string $collection, string $alias = ''): \Utopia\Query\Builder { - if (\is_null($this->silentListeners)) { - return; + $this->requireSkippedAuthorization(); + + if (! $this->adapterHasFeature(Feature\QueryBuilder::class)) { + throw new DatabaseException('Query builder is not supported by this adapter'); } - foreach ($this->listeners[self::EVENT_ALL] as $name => $callback) { - if (isset($this->silentListeners[$name])) { - continue; - } - $callback($event, $args); + + $builder = $this->adapter->builder(); + if (! $builder->isScoped()) { + throw new DatabaseException('The adapter handed out a query builder with no scope'); } + $builder->from($collection, $alias); + $builder->setExecutor($this->runStatement(...)); - foreach (($this->listeners[$event] ?? []) as $name => $callback) { - if (isset($this->silentListeners[$name])) { - continue; - } - $callback($event, $args); + return $builder; + } + + public function schema(): \Utopia\Query\Schema + { + if (! $this->adapterHasFeature(Feature\QueryBuilder::class)) { + throw new DatabaseException('Schema builder is not supported by this adapter'); } + + $schema = $this->adapter->schema(); + $schema->setExecutor($this->runStatement(...)); + + return $schema; } /** - * Executes $callback with $timestamp set to $requestTimestamp + * Run a read as written, with everything from() says it bypasses; a builder runs its SELECT. * - * @template T - * @param ?\DateTime $requestTimestamp - * @param callable(): T $callback - * @return T + * @return list The rows the read returns + * @throws AuthorizationException While authorization is enabled + * @throws DatabaseException When the adapter cannot run raw statements */ - public function withRequestTimestamp(?\DateTime $requestTimestamp, callable $callback): mixed + public function query(\Utopia\Query\Builder|\Utopia\Query\Builder\Statement $query): array { - $previous = $this->timestamp; - $this->timestamp = $requestTimestamp; - try { - $result = $callback(); - } finally { - $this->timestamp = $previous; + $this->requireSkippedAuthorization(); + + if (! $this->adapterHasFeature(Feature\RawQuery::class)) { + throw new DatabaseException('Raw queries are not supported by this adapter'); } - return $result; + + $statement = self::statement($query); + + return \array_values($this->adapter->rawQuery($statement->query, $statement->bindings)); } /** - * Set Namespace. - * - * Set namespace to divide different scope of data sets - * - * @param string $namespace - * - * @return $this + * Run a write as written, with everything from() says it bypasses. * - * @throws DatabaseException + * @return int How many rows the write changed + * @throws AuthorizationException While authorization is enabled + * @throws DatabaseException When the adapter cannot run raw statements */ - public function setNamespace(string $namespace): static + public function mutate(\Utopia\Query\Builder|\Utopia\Query\Builder\Statement $query): int { - $this->adapter->setNamespace($namespace); + $this->requireSkippedAuthorization(); - return $this; + if (! $this->adapterHasFeature(Feature\RawQuery::class)) { + throw new DatabaseException('Raw queries are not supported by this adapter'); + } + + $statement = self::statement($query); + + return $this->adapter->rawMutation($statement->query, $statement->bindings); } /** - * Get Namespace. - * - * Get namespace of current set scope + * The executor of from() and schema(): a statement the builder marks read-only is a read. * - * @return string + * @return list|int */ - public function getNamespace(): string + private function runStatement(\Utopia\Query\Builder\Statement $statement): array|int { - return $this->adapter->getNamespace(); + return $statement->readOnly ? $this->query($statement) : $this->mutate($statement); + } + + private static function statement(\Utopia\Query\Builder|\Utopia\Query\Builder\Statement $query): \Utopia\Query\Builder\Statement + { + return $query instanceof \Utopia\Query\Builder\Statement ? $query : $query->build(); } /** - * Get ID Attribute Type. - * - * Returns the type of the internal ID attribute (e.g. VAR_INTEGER for SQL, VAR_UUID7 for MongoDB) - * - * @return string + * @throws AuthorizationException */ - public function getIdAttributeType(): string + private function requireSkippedAuthorization(): void { - return $this->adapter->getIdAttributeType(); + if ($this->authorization->getStatus()) { + throw new AuthorizationException('The query builder bypasses permissions, caches and events: build and run it inside getAuthorization()->skip()'); + } } /** - * Set database to use for current scope - * - * @param string $name - * - * @return static - * @throws DatabaseException + * Use these filters on this handle instead of the ones it was constructed with. They take precedence over the + * ones addFilter() registers, and the handles sharing the registry see what is registered on it later. */ - public function setDatabase(string $name): static + public function setFilters(Registry $filters): static { - $this->adapter->setDatabase($name); + $this->codecs = $filters; return $this; } + public function getFilters(): Registry + { + return $this->codecs ??= new Registry(); + } + /** - * Get Database. - * - * Get Database from current scope - * - * @return string - * @throws DatabaseException + * Cache find() results in the query cache, keyed by this database's cache name and timed by its writer timeout + * on each call, so the query cache may be shared with other databases; null stops caching them. */ - public function getDatabase(): string + public function setQueryCache(?ResultCache $queryCache): static { - return $this->adapter->getDatabase(); + $this->invalidator = null; + $this->queryCache = $queryCache; + + if ($queryCache !== null) { + $this->invalidator = new Invalidator($queryCache); + } + + return $this; + } + + public function getQueryCache(): ?ResultCache + { + return $this->queryCache; + } + + /** + * Record the queries the adapter runs in getProfiler(), or stop recording them. + */ + public function setProfiling(bool $profiling): static + { + if (! $profiling) { + $this->profiler?->disable(); + $this->adapter->setProfiler(null); + + return $this; + } + + $this->profiler ??= new Profiler(); + $this->profiler->enable(); + $this->adapter->setProfiler($this->profiler); + + return $this; + } + + public function isProfiling(): bool + { + return $this->profiler?->isEnabled() ?? false; + } + + public function getProfiler(): ?Profiler + { + return $this->profiler; } /** - * Set the cache instance - * - * @param Cache $cache - * * @return $this */ public function setCache(Cache $cache): static { $this->cache = $cache; + return $this; } - /** - * Get the cache instance - * - * @return Cache - */ public function getCache(): Cache { return $this->cache; } /** - * Set the name to use for cache - * - * @param string $name * @return $this */ public function setCacheName(string $name): static @@ -1153,345 +952,285 @@ public function setCacheName(string $name): static return $this; } - /** - * Get the cache name - * - * @return string - */ public function getCacheName(): string { return $this->cacheName; } - /** - * Set a metadata value to be printed in the query comments - * - * @param string $key - * @param mixed $value - * @return static - */ - public function setMetadata(string $key, mixed $value): static + public function setSharedTables(bool $sharedTables): static { - $this->adapter->setMetadata($key, $value); + $this->adapter->setSharedTables($sharedTables); + $this->resetProfile(); return $this; } /** - * Get metadata - * - * @return array + * Whether tenants share tables, told apart by the tenant column. */ - public function getMetadata(): array + public function hasSharedTables(): bool { - return $this->adapter->getMetadata(); + return $this->adapter->hasSharedTables(); } - /** - * Sets instance of authorization for permission checks - * - * @param Authorization $authorization - * @return self - */ - public function setAuthorization(Authorization $authorization): self + public function setTenant(int|string|null $tenant): static { - $this->adapter->setAuthorization($authorization); - $this->authorization = $authorization; + $this->adapter->setTenant($tenant); + return $this; } - /** - * Get Authorization - * - * @return Authorization - */ - public function getAuthorization(): Authorization + public function getTenant(): int|string|null { - return $this->authorization; + return $this->adapter->getTenant(); } /** - * Clear metadata + * Execute a callback with a specific tenant. Scoped to the calling coroutine and the coroutines it starts. + * + * @template T * - * @return void + * @param callable(): T $callback + * @return T */ - public function resetMetadata(): void + public function withTenant(int|string|null $tenant, callable $callback): mixed { - $this->adapter->resetMetadata(); + return $this->adapter->withTenant($tenant, $callback); } - /** - * Set maximum query execution time - * - * @param int $milliseconds - * @param string $event - * @return static - * @throws Exception - */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): static + public function setTenantPerDocument(bool $enabled): static { - $this->adapter->setTimeout($milliseconds, $event); + $this->adapter->setTenantPerDocument($enabled); return $this; } /** - * Clear maximum query execution time - * - * @param string $event - * @return void + * Whether a document carries its own tenant instead of the handle's. */ - public function clearTimeout(string $event = Database::EVENT_ALL): void + public function isTenantPerDocument(): bool { - $this->adapter->clearTimeout($event); + return $this->adapter->isTenantPerDocument(); } - /** - * Enable filters - * - * @return $this - */ - public function enableFilters(): static + public function setAuthorization(Authorization $authorization): static { - $this->filter = true; + $this->adapter->setAuthorization($authorization); + $this->authorization = $authorization; + return $this; } - /** - * Disable filters - * - * @return $this - */ - public function disableFilters(): static + public function getAuthorization(): Authorization { - $this->filter = false; - return $this; + return $this->authorization; } /** - * Skip filters - * - * Execute a callback without filters - * - * @template T - * @param callable(): T $callback - * @param array|null $filters - * @return T + * @throws Exception */ - public function skipFilters(callable $callback, ?array $filters = null): mixed + public function setTimeout(int $milliseconds, Event $event = Event::All): static { - if (empty($filters)) { - $initial = $this->filter; - $this->disableFilters(); - - try { - return $callback(); - } finally { - $this->filter = $initial; - } + if (! $this->adapterHasFeature(Feature\Timeouts::class)) { + throw new DatabaseException('Adapter does not support timeouts'); } - $previous = $this->filter; - $previousDisabled = $this->disabledFilters; - $disabled = []; - foreach ($filters as $name) { - $disabled[$name] = true; - } - $this->disabledFilters = $disabled; + $this->adapter->setTimeout($milliseconds, $event); - try { - return $callback(); - } finally { - $this->filter = $previous; - $this->disabledFilters = $previousDisabled; + return $this; + } + + public function clearTimeout(Event $event = Event::All): void + { + if (! $this->adapterHasFeature(Feature\Timeouts::class)) { + throw new DatabaseException('Adapter does not support timeouts'); } + + $this->adapter->clearTimeout($event); } /** - * Get instance filters - * - * @return array + * @internal */ - public function getInstanceFilters(): array + public function getRelationshipHook(): ?Relationships { - return $this->instanceFilters; + return $this->relationshipHook; } /** - * Enable validation + * Set whether to preserve original date values instead of overwriting with current timestamps. * * @return $this */ - public function enableValidation(): static + public function setPreserveDates(bool $preserve): static { - $this->validate = true; + $this->datePreservation()->set($preserve); return $this; } + public function isDroppingUnknownAttributes(): bool + { + return $this->dropUnknownAttributes; + } + /** - * Disable validation + * Drop attributes missing from the collection schema instead of rejecting the write. * - * @return $this + * Enable this where the schema is owned by the application rather than the caller, so a + * deploy that writes an attribute before its migration has run degrades to a warning + * instead of failing every write. */ - public function disableValidation(): static + public function setDropUnknownAttributes(bool $drop): static { - $this->validate = false; + $this->dropUnknownAttributes = $drop; return $this; } + public function isPreservingDates(): bool + { + return $this->datePreservation()->get(); + } + /** - * Skip Validation - * - * Execute a callback without validation + * Run the callback with date preservation on or off for the calling coroutine and the coroutines it starts. * * @template T - * @param callable(): T $callback + * + * @param callable(): T $callback * @return T */ - public function skipValidation(callable $callback): mixed + public function withPreserveDates(bool $preserve, callable $callback): mixed { - $initial = $this->validate; - $this->disableValidation(); - - try { - return $callback(); - } finally { - $this->validate = $initial; - } + return $this->datePreservation()->with($preserve, $callback); } /** - * Get shared tables + * Run the callback with creates that hit an existing id or unique key skipping that document instead of + * failing, for the calling coroutine and the coroutines it starts. + * + * @template T * - * Get whether to share tables between tenants - * @return bool + * @param callable(): T $callback + * @return T */ - public function getSharedTables(): bool + public function ignoreDuplicates(callable $callback): mixed { - return $this->adapter->getSharedTables(); + return $this->ignoringDuplicates()->with(true, $callback); } /** - * Set shard tables + * Set whether to preserve original sequence values instead of auto-generating them. * - * Set whether to share tables between tenants - * - * @param bool $sharedTables - * @return static + * @return $this */ - public function setSharedTables(bool $sharedTables): static + public function setPreserveSequence(bool $preserve): static { - $this->adapter->setSharedTables($sharedTables); + $this->sequencePreservation()->set($preserve); return $this; } - /** - * Set Tenant - * - * Set tenant to use if tables are shared - * - * @param int|string|null $tenant - * @return static - */ - public function setTenant(int|string|null $tenant): static + public function isPreservingSequence(): bool { - $this->adapter->setTenant($tenant); - - return $this; + return $this->sequencePreservation()->get(); } /** - * Get Tenant + * Run the callback with sequence preservation on or off for the calling coroutine and the coroutines it starts. * - * Get tenant to use if tables are shared + * @template T * - * @return int|string|null + * @param callable(): T $callback + * @return T */ - public function getTenant(): int|string|null + public function withPreserveSequence(bool $preserve, callable $callback): mixed { - return $this->adapter->getTenant(); + return $this->sequencePreservation()->with($preserve, $callback); } /** - * With Tenant - * - * Execute a callback with a specific tenant + * Set the migration mode flag, which relaxes certain constraints during data migrations. * - * @param int|string|null $tenant - * @param callable $callback - * @return mixed + * @return $this */ - public function withTenant(int|string|null $tenant, callable $callback): mixed + public function setMigrating(bool $migrating): static { - $previous = $this->adapter->getTenant(); - $this->adapter->setTenant($tenant); + $this->migrating = $migrating; + $this->resetProfile(); - try { - return $callback(); - } finally { - $this->adapter->setTenant($previous); - } + return $this; + } + + public function isMigrating(): bool + { + return $this->migrating; } /** - * Set whether to allow creating documents with tenant set per document. + * Set the maximum number of values allowed in a single query (e.g., IN clauses). * - * @param bool $enabled - * @return static + * @return $this */ - public function setTenantPerDocument(bool $enabled): static + public function setMaxQueryValues(int $max): static { - $this->adapter->setTenantPerDocument($enabled); + if ($this->maxQueryValues !== $max) { + // Validator cache key encodes maxQueryValues; entries built under + // the previous limit must be discarded so subsequent validation + // honors the new ceiling. + $this->documentsValidatorCache = []; + } + + $this->maxQueryValues = $max; return $this; } - /** - * Get whether to allow creating documents with tenant set per document. - * - * @return bool - */ - public function getTenantPerDocument(): bool + public function getMaxQueryValues(): int { - return $this->adapter->getTenantPerDocument(); + return $this->maxQueryValues; } /** - * Enable or disable LOCK=SHARED during ALTER TABLE operation - * - * Set lock mode when altering tables - * - * @param bool $enabled - * @return static + * @param array $collections + * @return $this */ - public function enableLocks(bool $enabled): static + public function setGlobalCollections(array $collections): static { - if ($this->adapter->getSupportForAlterLocks()) { - $this->adapter->enableAlterLocks($enabled); + foreach ($collections as $collection) { + $this->globalCollections[$collection] = true; } return $this; } /** - * Set custom document class for a collection + * @return array + */ + public function getGlobalCollections(): array + { + return \array_keys($this->globalCollections); + } + + public function resetGlobalCollections(): void + { + $this->globalCollections = []; + } + + /** + * @param string $className Fully qualified class name that extends Document * - * @param string $collection Collection ID - * @param class-string $className Fully qualified class name that extends Document - * @return static * @throws DatabaseException */ public function setDocumentType(string $collection, string $className): static { - if (!\class_exists($className)) { + if (! \class_exists($className)) { throw new DatabaseException("Class {$className} does not exist"); } - if (!\is_subclass_of($className, Document::class)) { - throw new DatabaseException("Class {$className} must extend " . Document::class); + if (! \is_subclass_of($className, Document::class)) { + throw new DatabaseException("Class {$className} must extend ".Document::class); } $this->documentTypes[$collection] = $className; @@ -1500,9 +1239,6 @@ public function setDocumentType(string $collection, string $className): static } /** - * Get custom document class for a collection - * - * @param string $collection Collection ID * @return class-string|null */ public function getDocumentType(string $collection): ?string @@ -1510,7942 +1246,456 @@ public function getDocumentType(string $collection): ?string return $this->documentTypes[$collection] ?? null; } - /** - * Clear document type mapping for a collection - * - * @param string $collection Collection ID - * @return static - */ - public function clearDocumentType(string $collection): static + public function clearDocumentType(string $collection): void { unset($this->documentTypes[$collection]); + } - return $this; + /** + * Clear every document type mapping a caller set; the metadata collection keeps its own. + */ + public function clearDocumentTypes(): void + { + $this->documentTypes = [self::METADATA => Collection::class]; } /** - * Clear all document type mappings - * - * @return static + * Whether ALTER TABLE statements take LOCK=SHARED, on adapters that support it. */ - public function clearAllDocumentTypes(): static + public function setLocks(bool $locks): static { - $this->documentTypes = []; + if ($this->adapter->supports(Capability::AlterLock)) { + $this->adapter->setLocks($locks); + } return $this; } - /** - * Create a document instance of the appropriate type - * - * @param string $collection Collection ID - * @param array $data Document data - * @return Document - */ - protected function createDocumentInstance(string $collection, array $data): Document + public function setValidation(bool $validation): static { - $className = $this->documentTypes[$collection] ?? Document::class; + $this->validation()->set($validation); - return new $className($data); + return $this; } - public function getDropUnknownAttributes(): bool + /** + * Whether documents, queries and schema changes are validated. + */ + public function isValidating(): bool { - return $this->dropUnknownAttributes; + return $this->validation()->get(); } /** - * Drop attributes missing from the collection schema instead of rejecting the write. + * Run the callback with validation on or off for the calling coroutine and the coroutines it starts. * - * Enable this where the schema is owned by the application rather than the caller, so a - * deploy that writes an attribute before its migration has run degrades to a warning - * instead of failing every write. + * @template T + * + * @param callable(): T $callback + * @return T */ - public function setDropUnknownAttributes(bool $drop): static + public function withValidation(bool $validation, callable $callback): mixed { - $this->dropUnknownAttributes = $drop; - - return $this; + return $this->validation()->with($validation, $callback); } - public function getPreserveDates(): bool + /** + * Run the callback without validation for the calling coroutine and the coroutines it starts. + * + * @template T + * + * @param callable(): T $callback + * @return T + */ + public function skipValidation(callable $callback): mixed { - return $this->preserveDates; + return $this->withValidation(false, $callback); } - public function setPreserveDates(bool $preserve): static + /** + * Register a hook into the database pipeline. + * + * Dispatches by type: + * - {@see Hook\Lifecycle} — side effects on database events (auditing, logging); a + * {@see Named} one replaces the lifecycle hook registered under its name + * - {@see Hook\Decorator} — document transformation on read/write results + * - {@see Hook\Relationships} — relationship resolution and mutation + * - {@see Hook\Write} — row-level write interception (permissions, tenant) + * - {@see Hook\Transform} — raw SQL transformation before execution + * - {@see Invalidator} — the query cache invalidation, replacing the one setQueryCache() made + * + * An {@see Hook\Attachable} hook is attached to this database first. + * + * @throws DatabaseException When the hook is none of these + */ + public function addHook(\Utopia\Query\Hook $hook): static { - $this->preserveDates = $preserve; + if ( + ! $hook instanceof Lifecycle + && ! $hook instanceof Invalidator + && ! $hook instanceof Hook\Decorator + && ! $hook instanceof Relationships + && ! $hook instanceof Hook\Write + && ! $hook instanceof Transform + ) { + throw new DatabaseException('Unknown hook: '.$hook::class); + } - return $this; - } + if ($hook instanceof Hook\Attachable) { + $hook->attach($this); + } - public function setMigrating(bool $migrating): self - { - $this->migrating = $migrating; + if ($hook instanceof Invalidator) { + $this->invalidator = $hook; + } + + if ($hook instanceof Lifecycle) { + $this->registerLifecycleHook($hook); + } + + if ($hook instanceof Hook\Decorator) { + $this->decorators[] = $hook; + } + + if ($hook instanceof Relationships) { + $this->relationshipHook = $hook; + } + + if ($hook instanceof Hook\Write) { + $this->adapter->addWriteHook($hook); + } + + if ($hook instanceof Transform) { + $this->adapter->addTransform($hook::class, $hook); + } return $this; } - public function isMigrating(): bool + /** + * Unregister a hook, or with a class-string every hook of that class, from wherever {@see self::addHook()} + * registered it. + * + * A SQL adapter owns its {@see Hook\Tenancy} write hook while shared tables are on and registers it again before + * its next write, so removing Hook\Tenancy::class lasts only until then. + * + * @param \Utopia\Query\Hook|class-string<\Utopia\Query\Hook> $hook + */ + public function removeHook(\Utopia\Query\Hook|string $hook): static { - return $this->migrating; - } + $kept = \is_string($hook) + ? static fn (object $registered): bool => ! $registered instanceof $hook + : static fn (object $registered): bool => $registered !== $hook; - public function withPreserveDates(callable $callback): mixed - { - $previous = $this->preserveDates; - $this->preserveDates = true; + $this->lifecycleHooks = \array_values(\array_filter($this->lifecycleHooks, $kept)); + $this->decorators = \array_values(\array_filter($this->decorators, $kept)); - try { - return $callback(); - } finally { - $this->preserveDates = $previous; + if ($this->invalidator !== null && ! $kept($this->invalidator)) { + $this->invalidator = null; } - } - public function getPreserveSequence(): bool - { - return $this->preserveSequence; - } + if ($this->relationshipHook !== null && ! $kept($this->relationshipHook)) { + $this->relationshipHook = null; + } - public function setPreserveSequence(bool $preserve): static - { - $this->preserveSequence = $preserve; + if (\is_string($hook) || $hook instanceof Hook\Write) { + $this->adapter->removeWriteHook($hook); + } + + if (\is_string($hook)) { + $this->adapter->removeTransform($hook); + } elseif ($hook instanceof Transform) { + $this->adapter->removeTransform($hook::class); + } return $this; } - public function withPreserveSequence(callable $callback): mixed + private function registerLifecycleHook(Lifecycle $hook): void { - $previous = $this->preserveSequence; - $this->preserveSequence = true; + if ($hook instanceof Named) { + foreach ($this->lifecycleHooks as $index => $registered) { + if ($registered instanceof Named && $registered->getName() === $hook->getName()) { + $this->lifecycleHooks[$index] = $hook; - try { - return $callback(); - } finally { - $this->preserveSequence = $previous; + return; + } + } } + + $this->lifecycleHooks[] = $hook; } - public function setMaxQueryValues(int $max): self + protected function decorateDocument(Event $event, Document $collection, Document $document): Document { - $this->maxQueryValues = $max; + if ($this->decorators === [] || $this->areEventsSilenced()) { + return $document; + } - return $this; - } + $collection = $this->unshared($collection); + foreach ($this->decorators as $decorator) { + $document = $decorator->decorate($event, $collection, $document); + } - public function getMaxQueryValues(): int - { - return $this->maxQueryValues; + return $document; } /** - * Set list of collections which are globally accessible - * - * @param array $collections - * @return $this + * @param array $documents + * @return array */ - public function setGlobalCollections(array $collections): static + protected function decorateDocuments(Event $event, Document $collection, array $documents): array { - foreach ($collections as $collection) { - $this->globalCollections[$collection] = true; + if ($this->decorators === [] || $this->areEventsSilenced()) { + return $documents; } - return $this; - } + $collection = $this->unshared($collection); + foreach ($documents as $i => $document) { + $documents[$i] = $this->decorateDocument($event, $collection, $document); + } - /** - * Get list of collections which are globally accessible - * - * @return array - */ - public function getGlobalCollections(): array - { - return \array_keys($this->globalCollections); + return $documents; } - /** - * Clear global collections - * - * @return void - */ - public function resetGlobalCollections(): void + private function unshared(Document $collection): Document { - $this->globalCollections = []; + return $collection === self::$definition ? clone $collection : $collection; } - /** - * Get list of keywords that cannot be used - * - * @return string[] - */ - public function getKeywords(): array + public function removeTransform(string $name): static { - return $this->adapter->getKeywords(); - } + $this->adapter->removeTransform($name); - /** - * Get Database Adapter - * - * @return Adapter - */ - public function getAdapter(): Adapter - { - return $this->adapter; + return $this; } /** - * Run a callback inside a transaction. + * Silence lifecycle hooks for calls inside the callback: every hook, or only the + * {@see Named} hooks listed. A nested silence never narrows the one around it, and + * silences are scoped to the calling coroutine and the coroutines it starts. * * @template T - * @param callable(): T $callback - * @return T - * @throws \Throwable - */ - public function withTransaction(callable $callback): mixed - { - return $this->adapter->withTransaction($callback); - } - - /** - * Ping Database * - * @return bool + * @param callable(): T $callback + * @param array|null $hooks Names of the hooks to silence; null silences every hook + * @return T */ - public function ping(): bool + public function silent(callable $callback, ?array $hooks = null): mixed { - return $this->adapter->ping(); - } + if ($hooks !== null) { + return $this->silenceListeners($callback, $hooks); + } - public function reconnect(): void - { - $this->adapter->reconnect(); + return $this->silenced()->with(true, $callback); } /** - * Create the database + * @template T * - * @param string|null $database - * @return bool - * @throws DuplicateException - * @throws LimitException - * @throws Exception + * @param callable(): T $callback + * @param array $listeners + * @return T */ - public function create(?string $database = null): bool + private function silenceListeners(callable $callback, array $listeners): mixed { - $database ??= $this->adapter->getDatabase(); + $silencedListeners = $this->silencedListeners(); - $this->adapter->create($database); - - /** - * Create array of attribute documents - * @var array $attributes - */ - $attributes = \array_map(function ($attribute) { - return new Document($attribute); - }, self::COLLECTION['attributes']); - - $this->silent(fn () => $this->createCollection(self::METADATA, $attributes)); - - try { - $this->trigger(self::EVENT_DATABASE_CREATE, $database); - } catch (\Throwable $e) { - // Ignore - } - - return true; + return $silencedListeners->with($silencedListeners->get() + \array_fill_keys($listeners, true), $callback); } - /** - * Check if database exists - * Optionally check if collection exists in database - * - * @param string|null $database (optional) database name - * @param string|null $collection (optional) collection name - * - * @return bool - */ - public function exists(?string $database = null, ?string $collection = null): bool + protected function areEventsSilenced(): bool { - $database ??= $this->adapter->getDatabase(); - - return $this->adapter->exists($database, $collection); + return $this->silenced()->get(); } /** - * List Databases - * - * @return array + * @return Value */ - public function list(): array + private function silenced(): Value { - $databases = $this->adapter->list(); - - try { - $this->trigger(self::EVENT_DATABASE_LIST, $databases); - } catch (\Throwable $e) { - // Ignore - } - - return $databases; + return $this->silenced ??= new Value(false); } /** - * Delete Database - * - * @param string|null $database - * @return bool - * @throws DatabaseException + * @return Value> */ - public function delete(?string $database = null): bool + private function silencedListeners(): Value { - $database = $database ?? $this->adapter->getDatabase(); - - $deleted = $this->adapter->delete($database); - - try { - $this->trigger(self::EVENT_DATABASE_DELETE, [ - 'name' => $database, - 'deleted' => $deleted - ]); - } catch (\Throwable $e) { - // Ignore + if ($this->silencedListeners === null) { + /** @var Value> $silencedListeners */ + $silencedListeners = new Value([]); + $this->silencedListeners = $silencedListeners; } - $this->cache->flush(); - - return $deleted; + return $this->silencedListeners; } /** - * Create Collection + * Capture the authorization status and roles, relationship, silence, tenant and toggle state the calling + * coroutine sees, so work started elsewhere can run under it with withSnapshot(). * - * @param string $id - * @param array $attributes - * @param array $indexes - * @param array|null $permissions - * @param bool $documentSecurity - * @return Document - * @throws DatabaseException - * @throws DuplicateException - * @throws LimitException + * @internal */ - public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document + public function snapshot(): Snapshot { - foreach ($attributes as &$attribute) { - if (in_array($attribute['type'], self::ATTRIBUTE_FILTER_TYPES)) { - $existingFilters = $attribute['filters'] ?? []; - if (!is_array($existingFilters)) { - $existingFilters = [$existingFilters]; - } - $attribute['filters'] = array_values( - array_unique(array_merge($existingFilters, [$attribute['type']])) - ); - } - } - unset($attribute); - - $permissions ??= [ - Permission::create(Role::any()), - ]; - - if ($this->validate) { - $validator = new Permissions(); - if (!$validator->isValid($permissions)) { - throw new DatabaseException($validator->getDescription()); - } - } - - $collection = $this->silent(fn () => $this->getCollection($id)); - - if (!$collection->isEmpty() && $id !== self::METADATA) { - throw new DuplicateException('Collection ' . $id . ' already exists'); - } - - // Enforce single TTL index per collection - if ($this->validate && $this->getAdapter()->getSupportForTTLIndexes()) { - $ttlIndexes = array_filter($indexes, fn (Document $idx) => $idx->getAttribute('type') === self::INDEX_TTL); - if (count($ttlIndexes) > 1) { - throw new IndexException('There can be only one TTL index in a collection'); - } - } - - /** - * Fix metadata index length & orders - */ - foreach ($indexes as $key => $index) { - $lengths = $index->getAttribute('lengths', []); - $orders = $index->getAttribute('orders', []); - - foreach ($index->getAttribute('attributes', []) as $i => $attr) { - foreach ($attributes as $collectionAttribute) { - if ($collectionAttribute->getAttribute('$id') === $attr) { - /** - * mysql does not save length in collection when length = attributes size - */ - if (in_array($collectionAttribute->getAttribute('type'), self::STRING_TYPES)) { - if (!empty($lengths[$i]) && $lengths[$i] === $collectionAttribute->getAttribute('size') && $this->adapter->getMaxIndexLength() > 0) { - $lengths[$i] = null; - } - } - - $isArray = $collectionAttribute->getAttribute('array', false); - if ($isArray) { - if ($this->adapter->getMaxIndexLength() > 0) { - $lengths[$i] = self::MAX_ARRAY_INDEX_LENGTH; - } - $orders[$i] = null; - } - break; - } - } - } - - $index->setAttribute('lengths', $lengths); - $index->setAttribute('orders', $orders); - $indexes[$key] = $index; - } - - $collection = new Document([ - '$id' => ID::custom($id), - '$permissions' => $permissions, - 'name' => $id, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => $documentSecurity - ]); - - if ($this->validate) { - $validator = new IndexValidator( - $attributes, - [], - $this->adapter->getMaxIndexLength(), - $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialIndexNull(), - $this->adapter->getSupportForSpatialIndexOrder(), - $this->adapter->getSupportForVectors(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForMultipleFulltextIndexes(), - $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObjectIndexes(), - $this->adapter->getSupportForTrigramIndex(), - $this->adapter->getSupportForSpatialAttributes(), - $this->adapter->getSupportForIndex(), - $this->adapter->getSupportForUniqueIndex(), - $this->adapter->getSupportForFulltextIndex(), - $this->adapter->getSupportForTTLIndexes(), - $this->adapter->getSupportForObject() - ); - foreach ($indexes as $index) { - if (!$validator->isValid($index)) { - throw new IndexException($validator->getDescription()); - } - } - } - - // Check index limits, if given - if ($indexes && $this->adapter->getCountOfIndexes($collection) > $this->adapter->getLimitForIndexes()) { - throw new LimitException('Index limit of ' . $this->adapter->getLimitForIndexes() . ' exceeded. Cannot create collection.'); - } - - // Check attribute limits, if given - if ($attributes) { - if ( - $this->adapter->getLimitForAttributes() > 0 && - $this->adapter->getCountOfAttributes($collection) > $this->adapter->getLimitForAttributes() - ) { - throw new LimitException('Attribute limit of ' . $this->adapter->getLimitForAttributes() . ' exceeded. Cannot create collection.'); - } - - if ( - $this->adapter->getDocumentSizeLimit() > 0 && - $this->adapter->getAttributeWidth($collection) > $this->adapter->getDocumentSizeLimit() - ) { - throw new LimitException('Document size limit of ' . $this->adapter->getDocumentSizeLimit() . ' exceeded. Cannot create collection.'); - } - } - - $createdPhysicalTable = false; - - try { - $this->adapter->createCollection($id, $attributes, $indexes); - $createdPhysicalTable = true; - } catch (DuplicateException $e) { - if ($id === self::METADATA - || ($this->adapter->getSharedTables() - && $this->adapter->exists($this->adapter->getDatabase(), $id))) { - // The metadata table must never be dropped during reconciliation. - // In shared-tables mode the physical table is reused across - // tenants. A DuplicateException simply means the table already - // exists for another tenant — not an orphan. - } else { - // The table exists and this process did not create it. It may - // belong to a peer that has not committed metadata yet, or it - // may be an orphan. Dropping it destroyed live collections - // during concurrent boot; attaching this caller's metadata to - // an unknown physical schema can invent columns that are not - // there. Leave the table and report Duplicate. Claiming the - // metadata row first is #939. - try { - $this->purgeCachedDocument(self::METADATA, $id); - } catch (\Throwable $cacheError) { - Console::warning('Warning: Failed to purge stale collection cache: ' . $cacheError->getMessage()); - } - throw new DuplicateException('Collection ' . $id . ' already exists', previous: $e); - } - } - - if ($id === self::METADATA) { - return new Document(self::COLLECTION); - } - - try { - $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); - } catch (DuplicateException $e) { - // A concurrent creator committed the metadata for this id first, so - // the physical table is the one its metadata describes. Rolling back - // here would drop a live collection out from under it. - try { - $this->purgeCachedDocument(self::METADATA, $id); - } catch (\Throwable $cacheError) { - Console::warning('Warning: Failed to purge stale collection cache: ' . $cacheError->getMessage()); - } - throw new DuplicateException('Collection ' . $id . ' already exists', previous: $e); - } catch (\Throwable $e) { - if ($createdPhysicalTable) { - try { - $this->cleanupCollection($id); - } catch (\Throwable $e) { - Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); - } - } - throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); - } - - try { - $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); - } catch (\Throwable $e) { - // Ignore - } - - return $createdCollection; + return new Snapshot( + authorization: $this->authorization->getStatus(), + roles: $this->authorization->getRoles(), + relationships: $this->relationshipHook?->isEnabled() ?? true, + existCheck: $this->relationshipHook?->shouldCheckExist() ?? true, + population: $this->relationshipHook?->isInBatchPopulation() ?? false, + silenced: $this->areEventsSilenced(), + silencedListeners: $this->silencedListeners()->get(), + tenant: $this->adapter->getTenant(), + filters: $this->filtering()->get(), + disabledFilters: $this->filterExclusions()->get(), + validation: $this->validation()->get(), + preserveDates: $this->datePreservation()->get(), + preserveSequence: $this->sequencePreservation()->get(), + ignoreDuplicates: $this->ignoringDuplicates()->get(), + requestTimestamp: $this->requestTimestamp()->get(), + ); } /** - * Update Collections Permissions. + * Run the callback under a snapshot's state. The state is scoped to the calling coroutine and the coroutines it + * starts, so what the callback changes never reaches the coroutine the snapshot was taken in. * - * @param string $id - * @param array $permissions - * @param bool $documentSecurity + * @internal * - * @return Document - * @throws ConflictException - * @throws DatabaseException + * @template T + * + * @param callable(): T $callback + * @return T */ - public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document + public function withSnapshot(Snapshot $snapshot, callable $callback): mixed { - if ($this->validate) { - $validator = new Permissions(); - if (!$validator->isValid($permissions)) { - throw new DatabaseException($validator->getDescription()); - } - } - - $collection = $this->silent(fn () => $this->getCollection($id)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - if ( - $this->adapter->getSharedTables() - && $collection->getTenant() != $this->adapter->getTenant() - ) { - throw new NotFoundException('Collection not found'); - } - - $collection - ->setAttribute('$permissions', $permissions) - ->setAttribute('documentSecurity', $documentSecurity); - - $collection = $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - - try { - $this->trigger(self::EVENT_COLLECTION_UPDATE, $collection); - } catch (\Throwable $e) { - // Ignore - } + $hook = $this->relationshipHook; + $scoped = fn () => $this->silenced()->with( + $snapshot->silenced, + fn () => $this->silencedListeners()->with( + $snapshot->silencedListeners, + fn () => $this->withToggles($snapshot, $callback), + ), + ); - return $collection; + return $this->authorization->restore( + $snapshot->authorization, + $snapshot->roles, + $hook === null ? $scoped : fn () => $hook->withSnapshot($snapshot, $scoped), + ); } /** - * Get Collection - * - * @param string $id + * @template T * - * @return Document - * @throws DatabaseException + * @param callable(): T $callback + * @return T */ - public function getCollection(string $id): Document + private function withToggles(Snapshot $snapshot, callable $callback): mixed { - $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); - - if ( - $id !== self::METADATA - && $this->adapter->getSharedTables() - && $collection->getTenant() !== null - && $collection->getTenant() != $this->adapter->getTenant() - ) { - return new Document(); - } - - try { - $this->trigger(self::EVENT_COLLECTION_READ, $collection); - } catch (\Throwable $e) { - // Ignore - } + $timestamped = fn (): mixed => $this->requestTimestamp()->with($snapshot->requestTimestamp, $callback); + $deduplicated = fn (): mixed => $this->ignoringDuplicates()->with($snapshot->ignoreDuplicates, $timestamped); + $sequenced = fn (): mixed => $this->sequencePreservation()->with($snapshot->preserveSequence, $deduplicated); + $dated = fn (): mixed => $this->datePreservation()->with($snapshot->preserveDates, $sequenced); + $validated = fn (): mixed => $this->validation()->with($snapshot->validation, $dated); + $excluded = fn (): mixed => $this->filterExclusions()->with($snapshot->disabledFilters, $validated); + $filtered = fn (): mixed => $this->filtering()->with($snapshot->filters, $excluded); - return $collection; + return $this->adapter->withTenant($snapshot->tenant, $filtered); } /** - * List Collections - * - * @param int $offset - * @param int $limit - * - * @return array - * @throws Exception + * @return Value */ - public function listCollections(int $limit = 25, int $offset = 0): array + private function requestTimestamp(): Value { - $result = $this->silent(fn () => $this->find(self::METADATA, [ - Query::limit($limit), - Query::offset($offset) - ])); - - try { - $this->trigger(self::EVENT_COLLECTION_LIST, $result); - } catch (\Throwable $e) { - // Ignore + if ($this->requestTimestamp === null) { + /** @var Value $requestTimestamp */ + $requestTimestamp = new Value(null); + $this->requestTimestamp = $requestTimestamp; } - return $result; + return $this->requestTimestamp; } /** - * Get Collection Size - * - * @param string $collection - * - * @return int - * @throws Exception + * @return Value */ - public function getSizeOfCollection(string $collection): int + private function filtering(): Value { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - if ($this->adapter->getSharedTables() && $collection->getTenant() != $this->adapter->getTenant()) { - throw new NotFoundException('Collection not found'); - } - - return $this->adapter->getSizeOfCollection($collection->getId()); + return $this->filtering ??= new Value(true); } /** - * Get Collection Size on disk - * - * @param string $collection - * - * @return int + * @return Value|null> */ - public function getSizeOfCollectionOnDisk(string $collection): int + private function filterExclusions(): Value { - if ($this->adapter->getSharedTables() && empty($this->adapter->getTenant())) { - throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - if ($this->adapter->getSharedTables() && $collection->getTenant() != $this->adapter->getTenant()) { - throw new NotFoundException('Collection not found'); + if ($this->filterExclusions === null) { + /** @var Value|null> $filterExclusions */ + $filterExclusions = new Value([]); + $this->filterExclusions = $filterExclusions; } - return $this->adapter->getSizeOfCollectionOnDisk($collection->getId()); - } - - /** - * Analyze a collection updating its metadata on the database engine - * - * @param string $collection - * @return bool - */ - public function analyzeCollection(string $collection): bool - { - return $this->adapter->analyzeCollection($collection); + return $this->filterExclusions; } /** - * Delete Collection - * - * @param string $id - * - * @return bool - * @throws DatabaseException + * @return Value */ - public function deleteCollection(string $id): bool + private function validation(): Value { - $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - if ($this->adapter->getSharedTables() && $collection->getTenant() != $this->adapter->getTenant()) { - throw new NotFoundException('Collection not found'); - } - - $relationships = \array_filter( - $collection->getAttribute('attributes'), - fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $this->deleteRelationship($collection->getId(), $relationship->getId()); - } - - // Re-fetch collection to get current state after relationship deletions - $currentCollection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); - $currentAttributes = $currentCollection->isEmpty() ? [] : $currentCollection->getAttribute('attributes', []); - $currentIndexes = $currentCollection->isEmpty() ? [] : $currentCollection->getAttribute('indexes', []); - - $schemaDeleted = false; - try { - $this->adapter->deleteCollection($id); - $schemaDeleted = true; - } catch (NotFoundException) { - // Ignore — collection already absent from schema - } - - if ($id === self::METADATA) { - $deleted = true; - } else { - try { - $deleted = $this->silent(fn () => $this->deleteDocument(self::METADATA, $id)); - } catch (\Throwable $e) { - if ($schemaDeleted) { - try { - $this->adapter->createCollection($id, $currentAttributes, $currentIndexes); - } catch (\Throwable) { - // Silent rollback — best effort to restore consistency - } - } - throw new DatabaseException( - "Failed to persist metadata for collection deletion '{$id}': " . $e->getMessage(), - previous: $e - ); - } - } - - if ($deleted) { - try { - $this->trigger(self::EVENT_COLLECTION_DELETE, $collection); - } catch (\Throwable $e) { - // Ignore - } - } - - $this->purgeCachedCollection($id); - - return $deleted; + return $this->validation ??= new Value(true); } /** - * Create Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size utf8mb4 chars length - * @param bool $required - * @param mixed $default - * @param bool $signed - * @param bool $array - * @param string|null $format optional validation format of attribute - * @param array $formatOptions assoc array with custom options that can be passed for the format validation - * @param array $filters - * - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws DuplicateException - * @throws LimitException - * @throws StructureException - * @throws Exception + * @return Value */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $required, mixed $default = null, bool $signed = true, bool $array = false, ?string $format = null, array $formatOptions = [], array $filters = []): bool + private function datePreservation(): Value { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - if (in_array($type, self::ATTRIBUTE_FILTER_TYPES)) { - $filters[] = $type; - $filters = array_unique($filters); - } - - $size = $this->normalizeBigIntSize($type, $size); - - $existsInSchema = false; - - $schemaAttributes = $this->adapter->getSupportForSchemaAttributes() - ? $this->getSchemaAttributes($collection->getId()) - : []; - - try { - $attribute = $this->validateAttribute( - $collection, - $id, - $type, - $size, - $required, - $default, - $signed, - $array, - $format, - $formatOptions, - $filters, - $schemaAttributes - ); - } catch (DuplicateException $e) { - // If the column exists in the physical schema but not in collection - // metadata, this is recovery from a partial failure where the column - // was created but metadata wasn't updated. Allow re-creation by - // skipping physical column creation and proceeding to metadata update. - // checkDuplicateId (metadata) runs before checkDuplicateInSchema, so - // if the attribute is absent from metadata the duplicate is in the - // physical schema only — a recoverable partial-failure state. - $existsInMetadata = false; - foreach ($collection->getAttribute('attributes', []) as $attr) { - if (\strtolower($attr->getAttribute('key', $attr->getId())) === \strtolower($id)) { - $existsInMetadata = true; - break; - } - } - - if ($existsInMetadata) { - throw $e; - } - - // Check if the existing schema column matches the requested type. - // If it matches we can skip column creation. If not, drop the - // orphaned column so it gets recreated with the correct type. - $typesMatch = true; - $expectedColumnType = $this->adapter->getColumnType($type, $size, $signed, $array, $required); - if ($expectedColumnType !== '') { - $filteredId = $this->adapter->filter($id); - foreach ($schemaAttributes as $schemaAttr) { - $schemaId = $schemaAttr->getId(); - if (\strtolower($schemaId) === \strtolower($filteredId)) { - $actualColumnType = \strtoupper($schemaAttr->getAttribute('columnType', '')); - if ($actualColumnType !== \strtoupper($expectedColumnType)) { - $typesMatch = false; - } - break; - } - } - } - - if (!$typesMatch) { - // Column exists with wrong type and is not tracked in metadata, - // so no indexes or relationships reference it. Drop and recreate. - $this->adapter->deleteAttribute($collection->getId(), $id); - } else { - $existsInSchema = true; - } - - $attribute = new Document([ - '$id' => ID::custom($id), - 'key' => $id, - 'type' => $type, - 'size' => $size, - 'required' => $required, - 'default' => $default, - 'signed' => $signed, - 'array' => $array, - 'format' => $format, - 'formatOptions' => $formatOptions, - 'filters' => $filters, - ]); - } - - $created = false; - - if (!$existsInSchema) { - try { - $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array, $required); - - if (!$created) { - throw new DatabaseException('Failed to create attribute'); - } - } catch (DuplicateException) { - // Attribute not in metadata (orphan detection above confirmed this). - // A DuplicateException from the adapter means the column exists only - // in physical schema — suppress and proceed to metadata update. - } - } - - $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->cleanupAttribute($collection->getId(), $id), - shouldRollback: $created, - operationDescription: "attribute creation '{$id}'" - ); - - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - - try { - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $collection->getId(), - '$collection' => self::METADATA - ])); - } catch (\Throwable $e) { - // Ignore - } - - try { - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); - } catch (\Throwable $e) { - // Ignore - } - - return true; - } - - /** - * Create Attribute - * - * @param string $collection - * @param array> $attributes - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws DuplicateException - * @throws LimitException - * @throws StructureException - * @throws Exception - */ - public function createAttributes(string $collection, array $attributes): bool - { - if (empty($attributes)) { - throw new DatabaseException('No attributes to create'); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $schemaAttributes = $this->adapter->getSupportForSchemaAttributes() - ? $this->getSchemaAttributes($collection->getId()) - : []; - - $attributeDocuments = []; - $attributesToCreate = []; - foreach ($attributes as $attribute) { - if (!isset($attribute['$id'])) { - throw new DatabaseException('Missing attribute key'); - } - if (!isset($attribute['type'])) { - throw new DatabaseException('Missing attribute type'); - } - if (!isset($attribute['size'])) { - throw new DatabaseException('Missing attribute size'); - } - if (!isset($attribute['required'])) { - throw new DatabaseException('Missing attribute required'); - } - if (!isset($attribute['default'])) { - $attribute['default'] = null; - } - if (!isset($attribute['signed'])) { - $attribute['signed'] = true; - } - if (!isset($attribute['array'])) { - $attribute['array'] = false; - } - if (!isset($attribute['format'])) { - $attribute['format'] = null; - } - if (!isset($attribute['formatOptions'])) { - $attribute['formatOptions'] = []; - } - if (!isset($attribute['filters'])) { - $attribute['filters'] = []; - } - - $attribute['size'] = $this->normalizeBigIntSize($attribute['type'], $attribute['size']); - - $existsInSchema = false; - - try { - $attributeDocument = $this->validateAttribute( - $collection, - $attribute['$id'], - $attribute['type'], - $attribute['size'], - $attribute['required'], - $attribute['default'], - $attribute['signed'], - $attribute['array'], - $attribute['format'], - $attribute['formatOptions'], - $attribute['filters'], - $schemaAttributes - ); - } catch (DuplicateException $e) { - // Check if the duplicate is in metadata or only in schema - $existsInMetadata = false; - foreach ($collection->getAttribute('attributes', []) as $attr) { - if (\strtolower($attr->getAttribute('key', $attr->getId())) === \strtolower($attribute['$id'])) { - $existsInMetadata = true; - break; - } - } - - if ($existsInMetadata) { - throw $e; - } - - // Schema-only orphan — check type match - $expectedColumnType = $this->adapter->getColumnType( - $attribute['type'], - $attribute['size'], - $attribute['signed'], - $attribute['array'], - $attribute['required'] - ); - if ($expectedColumnType !== '') { - $filteredId = $this->adapter->filter($attribute['$id']); - foreach ($schemaAttributes as $schemaAttr) { - if (\strtolower($schemaAttr->getId()) === \strtolower($filteredId)) { - $actualColumnType = \strtoupper($schemaAttr->getAttribute('columnType', '')); - if ($actualColumnType !== \strtoupper($expectedColumnType)) { - // Type mismatch — drop orphaned column so it gets recreated - $this->adapter->deleteAttribute($collection->getId(), $attribute['$id']); - } else { - $existsInSchema = true; - } - break; - } - } - } - - $attributeDocument = new Document([ - '$id' => ID::custom($attribute['$id']), - 'key' => $attribute['$id'], - 'type' => $attribute['type'], - 'size' => $attribute['size'], - 'required' => $attribute['required'], - 'default' => $attribute['default'], - 'signed' => $attribute['signed'], - 'array' => $attribute['array'], - 'format' => $attribute['format'], - 'formatOptions' => $attribute['formatOptions'], - 'filters' => $attribute['filters'], - ]); - } - - $attributeDocuments[] = $attributeDocument; - if (!$existsInSchema) { - $attributesToCreate[] = $attribute; - } - } - - $created = false; - - if (!empty($attributesToCreate)) { - try { - $created = $this->adapter->createAttributes($collection->getId(), $attributesToCreate); - - if (!$created) { - throw new DatabaseException('Failed to create attributes'); - } - } catch (DuplicateException) { - // Batch failed because at least one column already exists. - // Fallback to per-attribute creation so non-duplicates still land in schema. - foreach ($attributesToCreate as $attr) { - try { - $this->adapter->createAttribute( - $collection->getId(), - $attr['$id'], - $attr['type'], - $attr['size'], - $attr['signed'], - $attr['array'], - $attr['required'] - ); - $created = true; - } catch (DuplicateException) { - // Column already exists in schema — skip - } - } - } - } - - foreach ($attributeDocuments as $attributeDocument) { - $collection->setAttribute('attributes', $attributeDocument, Document::SET_TYPE_APPEND); - } - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->cleanupAttributes($collection->getId(), $attributeDocuments), - shouldRollback: $created, - operationDescription: 'attributes creation', - rollbackReturnsErrors: true - ); - - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - - try { - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $collection->getId(), - '$collection' => self::METADATA - ])); - } catch (\Throwable $e) { - // Ignore - } - - try { - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); - } catch (\Throwable $e) { - // Ignore - } - - return true; - } - - /** - * Normalize BIGINT size metadata. - */ - private function normalizeBigIntSize(string $type, int $size): int - { - return $type === self::VAR_BIGINT ? 0 : $size; - } - - /** - * @param Document $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $required - * @param mixed $default - * @param bool $signed - * @param bool $array - * @param string $format - * @param array $formatOptions - * @param array $filters - * @param array|null $schemaAttributes Pre-fetched schema attributes, or null to fetch internally - * @return Document - * @throws DuplicateException - * @throws LimitException - * @throws Exception - */ - private function validateAttribute( - Document $collection, - string $id, - string $type, - int $size, - bool $required, - mixed $default, - bool $signed, - bool $array, - ?string $format, - array $formatOptions, - array $filters, - ?array $schemaAttributes = null - ): Document { - $size = $this->normalizeBigIntSize($type, $size); - - $attribute = new Document([ - '$id' => ID::custom($id), - 'key' => $id, - 'type' => $type, - 'size' => $size, - 'required' => $required, - 'default' => $default, - 'signed' => $signed, - 'array' => $array, - 'format' => $format, - 'formatOptions' => $formatOptions, - 'filters' => $filters, - ]); - - $collectionClone = clone $collection; - $collectionClone->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); - - $validator = new AttributeValidator( - attributes: $collection->getAttribute('attributes', []), - schemaAttributes: $schemaAttributes ?? ($this->adapter->getSupportForSchemaAttributes() - ? $this->getSchemaAttributes($collection->getId()) - : []), - maxAttributes: $this->adapter->getLimitForAttributes(), - maxWidth: $this->adapter->getDocumentSizeLimit(), - maxStringLength: $this->adapter->getLimitForString(), - maxVarcharLength: $this->adapter->getMaxVarcharLength(), - maxIntLength: $this->adapter->getLimitForInt(), - maxBigIntLength: $this->adapter->getLimitForBigInt(), - supportForSchemaAttributes: $this->adapter->getSupportForSchemaAttributes(), - supportForVectors: $this->adapter->getSupportForVectors(), - supportForSpatialAttributes: $this->adapter->getSupportForSpatialAttributes(), - supportForObject: $this->adapter->getSupportForObject(), - supportUnsignedBigInt: $this->adapter->getSupportForUnsignedBigInt(), - attributeCountCallback: fn () => $this->adapter->getCountOfAttributes($collectionClone), - attributeWidthCallback: fn () => $this->adapter->getAttributeWidth($collectionClone), - filterCallback: fn ($id) => $this->adapter->filter($id), - isMigrating: $this->isMigrating(), - sharedTables: $this->getSharedTables(), - ); - - $validator->isValid($attribute); - - return $attribute; - } - - /** - * Get the list of required filters for each data type - * - * @param string|null $type Type of the attribute - * - * @return array - */ - protected function getRequiredFilters(?string $type): array - { - return match ($type) { - self::VAR_DATETIME => ['datetime'], - default => [], - }; - } - - /** - * Function to validate if the default value of an attribute matches its attribute type - * - * @param string $type Type of the attribute - * @param mixed $default Default value of the attribute - * - * @return void - * @throws DatabaseException - */ - protected function validateDefaultTypes(string $type, mixed $default): void - { - $defaultType = \gettype($default); - - if ($defaultType === 'NULL') { - // Disable null. No validation required - return; - } - - if ($defaultType === 'array') { - // Spatial types require the array itself - if (!in_array($type, Database::SPATIAL_TYPES) && $type != Database::VAR_OBJECT) { - foreach ($default as $value) { - $this->validateDefaultTypes($type, $value); - } - } - return; - } - - switch ($type) { - case self::VAR_STRING: - case self::VAR_VARCHAR: - case self::VAR_TEXT: - case self::VAR_MEDIUMTEXT: - case self::VAR_LONGTEXT: - if ($defaultType !== 'string') { - throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); - } - break; - case self::VAR_INTEGER: - case self::VAR_FLOAT: - case self::VAR_BOOLEAN: - if ($type !== $defaultType) { - throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); - } - break; - case Database::VAR_BIGINT: - if ($defaultType !== 'integer' && $defaultType !== 'string') { - throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); - } - if ($defaultType === 'string' && !BigIntValidator::isIntegerString($default)) { - throw new DatabaseException('Default value ' . $default . ' is not a valid integer string for type bigint'); - } - break; - case self::VAR_DATETIME: - if ($defaultType !== self::VAR_STRING) { - throw new DatabaseException('Default value ' . $default . ' does not match given type ' . $type); - } - break; - case self::VAR_VECTOR: - // When validating individual vector components (from recursion), they should be numeric - if ($defaultType !== 'double' && $defaultType !== 'integer') { - throw new DatabaseException('Vector components must be numeric values (float or integer)'); - } - break; - default: - $supportedTypes = [ - self::VAR_STRING, - self::VAR_VARCHAR, - self::VAR_TEXT, - self::VAR_MEDIUMTEXT, - self::VAR_LONGTEXT, - self::VAR_INTEGER, - self::VAR_BIGINT, - self::VAR_FLOAT, - self::VAR_BOOLEAN, - self::VAR_DATETIME, - self::VAR_RELATIONSHIP - ]; - if ($this->adapter->getSupportForVectors()) { - $supportedTypes[] = self::VAR_VECTOR; - } - if ($this->adapter->getSupportForSpatialAttributes()) { - \array_push($supportedTypes, ...self::SPATIAL_TYPES); - } - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); - } - } - - /** - * Update attribute metadata. Utility method for update attribute methods. - * - * @param string $collection - * @param string $id - * @param callable $updateCallback method that receives document, and returns it with changes applied - * - * @return Document - * @throws ConflictException - * @throws DatabaseException - */ - protected function updateIndexMeta(string $collection, string $id, callable $updateCallback): Document - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->getId() === self::METADATA) { - throw new DatabaseException('Cannot update metadata indexes'); - } - - $indexes = $collection->getAttribute('indexes', []); - $index = \array_search($id, \array_map(fn ($index) => $index['$id'], $indexes)); - - if ($index === false) { - throw new NotFoundException('Index not found'); - } - - // Execute update from callback - $updateCallback($indexes[$index], $collection, $index); - - $collection->setAttribute('indexes', $indexes); - - $this->updateMetadata( - collection: $collection, - rollbackOperation: null, - shouldRollback: false, - operationDescription: "index metadata update '{$id}'" - ); - - return $indexes[$index]; - } - - /** - * Update attribute metadata. Utility method for update attribute methods. - * - * @param string $collection - * @param string $id - * @param callable(Document, Document, int|string): void $updateCallback method that receives document, and returns it with changes applied - * - * @return Document - * @throws ConflictException - * @throws DatabaseException - */ - protected function updateAttributeMeta(string $collection, string $id, callable $updateCallback): Document - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->getId() === self::METADATA) { - throw new DatabaseException('Cannot update metadata attributes'); - } - - $attributes = $collection->getAttribute('attributes', []); - $index = \array_search($id, \array_map(fn ($attribute) => $attribute['$id'], $attributes)); - - if ($index === false) { - throw new NotFoundException('Attribute not found'); - } - - // Execute update from callback - $updateCallback($attributes[$index], $collection, $index); - - $collection->setAttribute('attributes', $attributes); - - $this->updateMetadata( - collection: $collection, - rollbackOperation: null, - shouldRollback: false, - operationDescription: "attribute metadata update '{$id}'" - ); - - try { - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attributes[$index]); - } catch (\Throwable $e) { - // Ignore - } - - return $attributes[$index]; - } - - /** - * Update required status of attribute. - * - * @param string $collection - * @param string $id - * @param bool $required - * - * @return Document - * @throws Exception - */ - public function updateAttributeRequired(string $collection, string $id, bool $required): Document - { - return $this->updateAttributeMeta($collection, $id, function ($attribute) use ($required) { - $attribute->setAttribute('required', $required); - }); - } - - /** - * Update format of attribute. - * - * @param string $collection - * @param string $id - * @param string $format validation format of attribute - * - * @return Document - * @throws Exception - */ - public function updateAttributeFormat(string $collection, string $id, string $format): Document - { - return $this->updateAttributeMeta($collection, $id, function ($attribute) use ($format) { - if (!Structure::hasFormat($format, $attribute->getAttribute('type'))) { - throw new DatabaseException('Format "' . $format . '" not available for attribute type "' . $attribute->getAttribute('type') . '"'); - } - - $attribute->setAttribute('format', $format); - }); - } - - /** - * Update format options of attribute. - * - * @param string $collection - * @param string $id - * @param array $formatOptions assoc array with custom options that can be passed for the format validation - * - * @return Document - * @throws Exception - */ - public function updateAttributeFormatOptions(string $collection, string $id, array $formatOptions): Document - { - return $this->updateAttributeMeta($collection, $id, function ($attribute) use ($formatOptions) { - $attribute->setAttribute('formatOptions', $formatOptions); - }); - } - - /** - * Update filters of attribute. - * - * @param string $collection - * @param string $id - * @param array $filters - * - * @return Document - * @throws Exception - */ - public function updateAttributeFilters(string $collection, string $id, array $filters): Document - { - return $this->updateAttributeMeta($collection, $id, function ($attribute) use ($filters) { - $attribute->setAttribute('filters', $filters); - }); - } - - /** - * Update default value of attribute - * - * @param string $collection - * @param string $id - * @param mixed $default - * - * @return Document - * @throws Exception - */ - public function updateAttributeDefault(string $collection, string $id, mixed $default = null): Document - { - return $this->updateAttributeMeta($collection, $id, function ($attribute) use ($default) { - if ($attribute->getAttribute('required') === true) { - throw new DatabaseException('Cannot set a default value on a required attribute'); - } - - $this->validateDefaultTypes($attribute->getAttribute('type'), $default); - - $attribute->setAttribute('default', $default); - }); - } - - /** - * Update Attribute. This method is for updating data that causes underlying structure to change. Check out other updateAttribute methods if you are looking for metadata adjustments. - * - * @param string $collection - * @param string $id - * @param string|null $type - * @param int|null $size utf8mb4 chars length - * @param bool|null $required - * @param mixed $default - * @param bool $signed - * @param bool $array - * @param string|null $format - * @param array|null $formatOptions - * @param array|null $filters - * @param string|null $newKey - * @return Document - * @throws Exception - */ - public function updateAttribute(string $collection, string $id, ?string $type = null, ?int $size = null, ?bool $required = null, mixed $default = null, ?bool $signed = null, ?bool $array = null, ?string $format = null, ?array $formatOptions = null, ?array $filters = null, ?string $newKey = null): Document - { - $collectionDoc = $this->silent(fn () => $this->getCollection($collection)); - - if ($collectionDoc->getId() === self::METADATA) { - throw new DatabaseException('Cannot update metadata attributes'); - } - - $attributes = $collectionDoc->getAttribute('attributes', []); - $attributeIndex = \array_search($id, \array_map(fn ($attribute) => $attribute['$id'], $attributes)); - - if ($attributeIndex === false) { - throw new NotFoundException('Attribute not found'); - } - - $attribute = $attributes[$attributeIndex]; - - $originalType = $attribute->getAttribute('type'); - $originalSize = $attribute->getAttribute('size'); - $originalSigned = $attribute->getAttribute('signed'); - $originalArray = $attribute->getAttribute('array'); - $originalRequired = $attribute->getAttribute('required'); - $originalKey = $attribute->getAttribute('key'); - - $originalIndexes = []; - foreach ($collectionDoc->getAttribute('indexes', []) as $index) { - $originalIndexes[] = clone $index; - } - - $altering = !\is_null($type) - || !\is_null($size) - || !\is_null($signed) - || !\is_null($array) - || !\is_null($newKey); - $type ??= $attribute->getAttribute('type'); - $size ??= $attribute->getAttribute('size'); - $signed ??= $attribute->getAttribute('signed'); - $required ??= $attribute->getAttribute('required'); - $default ??= $attribute->getAttribute('default'); - $array ??= $attribute->getAttribute('array'); - $format ??= $attribute->getAttribute('format'); - $formatOptions ??= $attribute->getAttribute('formatOptions'); - $filters ??= $attribute->getAttribute('filters'); - - $size = $this->normalizeBigIntSize($type, $size); - - if ($required === true && !\is_null($default)) { - $default = null; - } - - // we need to alter table attribute type to NOT NULL/NULL for change in required - if (!$this->adapter->getSupportForSpatialIndexNull() && in_array($type, Database::SPATIAL_TYPES)) { - $altering = true; - } - - switch ($type) { - case self::VAR_STRING: - if (empty($size)) { - throw new DatabaseException('Size length is required'); - } - - if ($size > $this->adapter->getLimitForString()) { - throw new DatabaseException('Max size allowed for string is: ' . number_format($this->adapter->getLimitForString())); - } - break; - - case self::VAR_VARCHAR: - if (empty($size)) { - throw new DatabaseException('Size length is required'); - } - - if ($size > $this->adapter->getMaxVarcharLength()) { - throw new DatabaseException('Max size allowed for varchar is: ' . number_format($this->adapter->getMaxVarcharLength())); - } - break; - - case self::VAR_TEXT: - case self::VAR_MEDIUMTEXT: - case self::VAR_LONGTEXT: - // Text types don't require size validation as they have fixed max sizes - break; - - case self::VAR_INTEGER: - $limit = ($signed) ? $this->adapter->getLimitForInt() / 2 : $this->adapter->getLimitForInt(); - if ($size > $limit) { - throw new DatabaseException('Max size allowed for int is: ' . number_format($limit)); - } - break; - case self::VAR_BIGINT: - break; - case self::VAR_FLOAT: - case self::VAR_BOOLEAN: - case self::VAR_DATETIME: - if (!empty($size)) { - throw new DatabaseException('Size must be empty'); - } - break; - case self::VAR_OBJECT: - if (!$this->adapter->getSupportForObject()) { - throw new DatabaseException('Object attributes are not supported'); - } - if (!empty($size)) { - throw new DatabaseException('Size must be empty for object attributes'); - } - if (!empty($array)) { - throw new DatabaseException('Object attributes cannot be arrays'); - } - break; - case self::VAR_POINT: - case self::VAR_LINESTRING: - case self::VAR_POLYGON: - if (!$this->adapter->getSupportForSpatialAttributes()) { - throw new DatabaseException('Spatial attributes are not supported'); - } - if (!empty($size)) { - throw new DatabaseException('Size must be empty for spatial attributes'); - } - if (!empty($array)) { - throw new DatabaseException('Spatial attributes cannot be arrays'); - } - break; - case self::VAR_VECTOR: - if (!$this->adapter->getSupportForVectors()) { - throw new DatabaseException('Vector types are not supported by the current database'); - } - if ($array) { - throw new DatabaseException('Vector type cannot be an array'); - } - if ($size <= 0) { - throw new DatabaseException('Vector dimensions must be a positive integer'); - } - if ($size > self::MAX_VECTOR_DIMENSIONS) { - throw new DatabaseException('Vector dimensions cannot exceed ' . self::MAX_VECTOR_DIMENSIONS); - } - if ($default !== null) { - if (!\is_array($default)) { - throw new DatabaseException('Vector default value must be an array'); - } - if (\count($default) !== $size) { - throw new DatabaseException('Vector default value must have exactly ' . $size . ' elements'); - } - foreach ($default as $component) { - if (!\is_int($component) && !\is_float($component)) { - throw new DatabaseException('Vector default value must contain only numeric elements'); - } - } - } - break; - default: - $supportedTypes = [ - self::VAR_STRING, - self::VAR_VARCHAR, - self::VAR_TEXT, - self::VAR_MEDIUMTEXT, - self::VAR_LONGTEXT, - self::VAR_INTEGER, - self::VAR_BIGINT, - self::VAR_FLOAT, - self::VAR_BOOLEAN, - self::VAR_DATETIME, - self::VAR_RELATIONSHIP - ]; - if ($this->adapter->getSupportForVectors()) { - $supportedTypes[] = self::VAR_VECTOR; - } - if ($this->adapter->getSupportForSpatialAttributes()) { - \array_push($supportedTypes, ...self::SPATIAL_TYPES); - } - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); - } - - /** Ensure required filters for the attribute are passed */ - $requiredFilters = $this->getRequiredFilters($type); - if (!empty(array_diff($requiredFilters, $filters))) { - throw new DatabaseException("Attribute of type: $type requires the following filters: " . implode(",", $requiredFilters)); - } - - if ($format) { - if (!Structure::hasFormat($format, $type)) { - throw new DatabaseException('Format ("' . $format . '") not available for this attribute type ("' . $type . '")'); - } - } - - if (!\is_null($default)) { - if ($required) { - throw new DatabaseException('Cannot set a default value on a required attribute'); - } - - $this->validateDefaultTypes($type, $default); - } - - $attribute - ->setAttribute('$id', $newKey ?? $id) - ->setattribute('key', $newKey ?? $id) - ->setAttribute('type', $type) - ->setAttribute('size', $size) - ->setAttribute('signed', $signed) - ->setAttribute('array', $array) - ->setAttribute('format', $format) - ->setAttribute('formatOptions', $formatOptions) - ->setAttribute('filters', $filters) - ->setAttribute('required', $required) - ->setAttribute('default', $default); - - $attributes = $collectionDoc->getAttribute('attributes'); - $attributes[$attributeIndex] = $attribute; - $collectionDoc->setAttribute('attributes', $attributes, Document::SET_TYPE_ASSIGN); - - if ( - $this->adapter->getDocumentSizeLimit() > 0 && - $this->adapter->getAttributeWidth($collectionDoc) >= $this->adapter->getDocumentSizeLimit() - ) { - throw new LimitException('Row width limit reached. Cannot update attribute.'); - } - - if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { - $attributeMap = []; - foreach ($attributes as $attrDoc) { - $key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id'))); - $attributeMap[$key] = $attrDoc; - } - - $indexes = $collectionDoc->getAttribute('indexes', []); - foreach ($indexes as $index) { - if ($index->getAttribute('type') !== self::INDEX_SPATIAL) { - continue; - } - $indexAttributes = $index->getAttribute('attributes', []); - foreach ($indexAttributes as $attributeName) { - $lookup = \strtolower($attributeName); - if (!isset($attributeMap[$lookup])) { - continue; - } - $attrDoc = $attributeMap[$lookup]; - $attrType = $attrDoc->getAttribute('type'); - $attrRequired = (bool)$attrDoc->getAttribute('required', false); - - if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) { - throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'); - } - } - } - } - - $updated = false; - - if ($altering) { - $indexes = $collectionDoc->getAttribute('indexes'); - - if (!\is_null($newKey) && $id !== $newKey) { - foreach ($indexes as $index) { - if (in_array($id, $index['attributes'])) { - $index['attributes'] = array_map(function ($attribute) use ($id, $newKey) { - return $attribute === $id ? $newKey : $attribute; - }, $index['attributes']); - } - } - - /** - * Check index dependency if we are changing the key - */ - $validator = new IndexDependencyValidator( - $collectionDoc->getAttribute('indexes', []), - $this->adapter->getSupportForCastIndexArray(), - ); - - if (!$validator->isValid($attribute)) { - throw new DependencyException($validator->getDescription()); - } - } - - /** - * Since we allow changing type & size we need to validate index length - */ - if ($this->validate) { - $validator = new IndexValidator( - $attributes, - $originalIndexes, - $this->adapter->getMaxIndexLength(), - $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialIndexNull(), - $this->adapter->getSupportForSpatialIndexOrder(), - $this->adapter->getSupportForVectors(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForMultipleFulltextIndexes(), - $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObjectIndexes(), - $this->adapter->getSupportForTrigramIndex(), - $this->adapter->getSupportForSpatialAttributes(), - $this->adapter->getSupportForIndex(), - $this->adapter->getSupportForUniqueIndex(), - $this->adapter->getSupportForFulltextIndex(), - $this->adapter->getSupportForTTLIndexes(), - $this->adapter->getSupportForObject() - ); - - foreach ($indexes as $index) { - if (!$validator->isValid($index)) { - throw new IndexException($validator->getDescription()); - } - } - } - - $updated = $this->adapter->updateAttribute($collection, $id, $type, $size, $signed, $array, $newKey, $required); - - if (!$updated) { - throw new DatabaseException('Failed to update attribute'); - } - } - - $collectionDoc->setAttribute('attributes', $attributes); - - $this->updateMetadata( - collection: $collectionDoc, - rollbackOperation: fn () => $this->adapter->updateAttribute( - $collection, - $newKey ?? $id, - $originalType, - (int)$originalSize, - $originalSigned, - $originalArray, - $originalKey, - $originalRequired - ), - shouldRollback: $updated, - operationDescription: "attribute update '{$id}'", - silentRollback: true - ); - - if ($altering) { - $this->withRetries(fn () => $this->purgeCachedCollection($collection)); - } - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection)); - - try { - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $collection, - '$collection' => self::METADATA - ])); - } catch (\Throwable $e) { - // Ignore - } - - try { - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); - } catch (\Throwable $e) { - // Ignore - } - - return $attribute; - } - - /** - * Checks if attribute can be added to collection. - * Used to check attribute limits without asking the database - * Returns true if attribute can be added to collection, throws exception otherwise - * - * @param Document $collection - * @param Document $attribute - * - * @return bool - * @throws LimitException - */ - public function checkAttribute(Document $collection, Document $attribute): bool - { - $collection = clone $collection; - - $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); - - if ( - $this->adapter->getLimitForAttributes() > 0 && - $this->adapter->getCountOfAttributes($collection) > $this->adapter->getLimitForAttributes() - ) { - throw new LimitException('Column limit reached. Cannot create new attribute. Current attribute count is ' . $this->adapter->getCountOfAttributes($collection) . ' but the maximum is ' . $this->adapter->getLimitForAttributes() . '. Remove some attributes to free up space.'); - } - - if ( - $this->adapter->getDocumentSizeLimit() > 0 && - $this->adapter->getAttributeWidth($collection) >= $this->adapter->getDocumentSizeLimit() - ) { - throw new LimitException('Row width limit reached. Cannot create new attribute. Current row width is ' . $this->adapter->getAttributeWidth($collection) . ' bytes but the maximum is ' . $this->adapter->getDocumentSizeLimit() . ' bytes. Reduce the size of existing attributes or remove some attributes to free up space.'); - } - - return true; - } - - /** - * Delete Attribute - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws ConflictException - * @throws DatabaseException - */ - public function deleteAttribute(string $collection, string $id): bool - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); - - $attribute = null; - - foreach ($attributes as $key => $value) { - if (isset($value['$id']) && $value['$id'] === $id) { - $attribute = $value; - unset($attributes[$key]); - break; - } - } - - if (\is_null($attribute)) { - throw new NotFoundException('Attribute not found'); - } - - if ($attribute['type'] === self::VAR_RELATIONSHIP) { - throw new DatabaseException('Cannot delete relationship as an attribute'); - } - - if ($this->validate) { - $validator = new IndexDependencyValidator( - $collection->getAttribute('indexes', []), - $this->adapter->getSupportForCastIndexArray(), - ); - - if (!$validator->isValid($attribute)) { - throw new DependencyException($validator->getDescription()); - } - } - - foreach ($indexes as $indexKey => $index) { - $indexAttributes = $index->getAttribute('attributes', []); - - $indexAttributes = \array_filter($indexAttributes, fn ($attribute) => $attribute !== $id); - - if (empty($indexAttributes)) { - unset($indexes[$indexKey]); - } else { - $index->setAttribute('attributes', \array_values($indexAttributes)); - } - } - - $collection->setAttribute('attributes', \array_values($attributes)); - $collection->setAttribute('indexes', \array_values($indexes)); - - $shouldRollback = false; - try { - if (!$this->adapter->deleteAttribute($collection->getId(), $id)) { - throw new DatabaseException('Failed to delete attribute'); - } - $shouldRollback = true; - } catch (NotFoundException) { - // Ignore - } - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->adapter->createAttribute( - $collection->getId(), - $id, - $attribute['type'], - $attribute['size'], - $attribute['signed'] ?? true, - $attribute['array'] ?? false, - $attribute['required'] ?? false - ), - shouldRollback: $shouldRollback, - operationDescription: "attribute deletion '{$id}'", - silentRollback: true - ); - - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - - try { - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $collection->getId(), - '$collection' => self::METADATA - ])); - } catch (\Throwable $e) { - // Ignore - } - - try { - $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $attribute); - } catch (\Throwable $e) { - // Ignore - } - - return true; - } - - /** - * Rename Attribute - * - * @param string $collection - * @param string $old Current attribute ID - * @param string $new - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws DuplicateException - * @throws StructureException - */ - public function renameAttribute(string $collection, string $old, string $new): bool - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - /** - * @var array $attributes - */ - $attributes = $collection->getAttribute('attributes', []); - - /** - * @var array $indexes - */ - $indexes = $collection->getAttribute('indexes', []); - - $attribute = new Document(); - - foreach ($attributes as $value) { - if ($value->getId() === $old) { - $attribute = $value; - } - - if ($value->getId() === $new) { - throw new DuplicateException('Attribute name already used'); - } - } - - if ($attribute->isEmpty()) { - throw new NotFoundException('Attribute not found'); - } - - if ($this->validate) { - $validator = new IndexDependencyValidator( - $collection->getAttribute('indexes', []), - $this->adapter->getSupportForCastIndexArray(), - ); - - if (!$validator->isValid($attribute)) { - throw new DependencyException($validator->getDescription()); - } - } - - $attribute->setAttribute('$id', $new); - $attribute->setAttribute('key', $new); - - foreach ($indexes as $index) { - $indexAttributes = $index->getAttribute('attributes', []); - - $indexAttributes = \array_map(fn ($attr) => ($attr === $old) ? $new : $attr, $indexAttributes); - - $index->setAttribute('attributes', $indexAttributes); - } - - $renamed = false; - try { - $renamed = $this->adapter->renameAttribute($collection->getId(), $old, $new); - if (!$renamed) { - throw new DatabaseException('Failed to rename attribute'); - } - } catch (\Throwable $e) { - // Check if the rename already happened in schema (orphan from prior - // partial failure where rename succeeded but metadata update failed). - // We verified $new doesn't exist in metadata (above), so if $new - // exists in schema, it must be from a prior rename. - if ($this->adapter->getSupportForSchemaAttributes()) { - $schemaAttributes = $this->getSchemaAttributes($collection->getId()); - $filteredNew = $this->adapter->filter($new); - $newExistsInSchema = false; - foreach ($schemaAttributes as $schemaAttr) { - if (\strtolower($schemaAttr->getId()) === \strtolower($filteredNew)) { - $newExistsInSchema = true; - break; - } - } - if ($newExistsInSchema) { - $renamed = true; - } else { - throw new DatabaseException("Failed to rename attribute '{$old}' to '{$new}': " . $e->getMessage(), previous: $e); - } - } else { - throw new DatabaseException("Failed to rename attribute '{$old}' to '{$new}': " . $e->getMessage(), previous: $e); - } - } - - $collection->setAttribute('attributes', $attributes); - $collection->setAttribute('indexes', $indexes); - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->adapter->renameAttribute($collection->getId(), $new, $old), - shouldRollback: $renamed, - operationDescription: "attribute rename '{$old}' to '{$new}'" - ); - - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - - try { - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); - } catch (\Throwable $e) { - // Ignore - } - - return $renamed; - } - - /** - * Cleanup (delete) a single attribute with retry logic - * - * @param string $collectionId The collection ID - * @param string $attributeId The attribute ID - * @param int $maxAttempts Maximum retry attempts - * @return void - * @throws DatabaseException If cleanup fails after all retries - */ - private function cleanupAttribute( - string $collectionId, - string $attributeId, - int $maxAttempts = 3 - ): void { - $this->cleanup( - fn () => $this->adapter->deleteAttribute($collectionId, $attributeId), - 'attribute', - $attributeId, - $maxAttempts - ); - } - - /** - * Cleanup (delete) multiple attributes with retry logic - * - * @param string $collectionId The collection ID - * @param array $attributeDocuments The attribute documents to cleanup - * @param int $maxAttempts Maximum retry attempts per attribute - * @return array Array of error messages for failed cleanups (empty if all succeeded) - */ - private function cleanupAttributes( - string $collectionId, - array $attributeDocuments, - int $maxAttempts = 3 - ): array { - $errors = []; - - foreach ($attributeDocuments as $attributeDocument) { - try { - $this->cleanupAttribute($collectionId, $attributeDocument->getId(), $maxAttempts); - } catch (DatabaseException $e) { - // Continue cleaning up other attributes even if one fails - $errors[] = $e->getMessage(); - } - } - - return $errors; - } - - /** - * Cleanup (delete) a collection with retry logic - * - * @param string $collectionId The collection ID - * @param int $maxAttempts Maximum retry attempts - * @return void - * @throws DatabaseException If cleanup fails after all retries - */ - private function cleanupCollection( - string $collectionId, - int $maxAttempts = 3 - ): void { - $this->cleanup( - fn () => $this->adapter->deleteCollection($collectionId), - 'collection', - $collectionId, - $maxAttempts - ); - } - - /** - * Cleanup (delete) a relationship with retry logic - * - * @param string $collectionId The collection ID - * @param string $relatedCollectionId The related collection ID - * @param string $type The relationship type - * @param bool $twoWay Whether the relationship is two-way - * @param string $key The relationship key - * @param string $twoWayKey The two-way relationship key - * @param string $side The relationship side - * @param int $maxAttempts Maximum retry attempts - * @return void - * @throws DatabaseException If cleanup fails after all retries - */ - private function cleanupRelationship( - string $collectionId, - string $relatedCollectionId, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side = Database::RELATION_SIDE_PARENT, - int $maxAttempts = 3 - ): void { - $this->cleanup( - fn () => $this->adapter->deleteRelationship( - $collectionId, - $relatedCollectionId, - $type, - $twoWay, - $key, - $twoWayKey, - $side - ), - 'relationship', - $key, - $maxAttempts - ); - } - - /** - * Create a relationship attribute - * - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string|null $id - * @param string|null $twoWayKey - * @param string $onDelete - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws DuplicateException - * @throws LimitException - * @throws StructureException - */ - public function createRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay = false, - ?string $id = null, - ?string $twoWayKey = null, - string $onDelete = Database::RELATION_MUTATE_RESTRICT - ): bool { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollection)); - - if ($relatedCollection->isEmpty()) { - throw new NotFoundException('Related collection not found'); - } - - $id ??= $relatedCollection->getId(); - - $twoWayKey ??= $collection->getId(); - - $attributes = $collection->getAttribute('attributes', []); - /** @var array $attributes */ - foreach ($attributes as $attribute) { - if (\strtolower($attribute->getId()) === \strtolower($id)) { - throw new DuplicateException('Attribute already exists'); - } - - if ( - $attribute->getAttribute('type') === self::VAR_RELATIONSHIP - && \strtolower($attribute->getAttribute('options')['twoWayKey']) === \strtolower($twoWayKey) - && $attribute->getAttribute('options')['relatedCollection'] === $relatedCollection->getId() - ) { - throw new DuplicateException('Related attribute already exists'); - } - } - - $relationship = new Document([ - '$id' => ID::custom($id), - 'key' => $id, - 'type' => Database::VAR_RELATIONSHIP, - 'required' => false, - 'default' => null, - 'options' => [ - 'relatedCollection' => $relatedCollection->getId(), - 'relationType' => $type, - 'twoWay' => $twoWay, - 'twoWayKey' => $twoWayKey, - 'onDelete' => $onDelete, - 'side' => Database::RELATION_SIDE_PARENT, - ], - ]); - - $twoWayRelationship = new Document([ - '$id' => ID::custom($twoWayKey), - 'key' => $twoWayKey, - 'type' => Database::VAR_RELATIONSHIP, - 'required' => false, - 'default' => null, - 'options' => [ - 'relatedCollection' => $collection->getId(), - 'relationType' => $type, - 'twoWay' => $twoWay, - 'twoWayKey' => $id, - 'onDelete' => $onDelete, - 'side' => Database::RELATION_SIDE_CHILD, - ], - ]); - - $this->checkAttribute($collection, $relationship); - $this->checkAttribute($relatedCollection, $twoWayRelationship); - - $junctionCollection = null; - if ($type === self::RELATION_MANY_TO_MANY) { - $junctionCollection = '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(); - $junctionAttributes = [ - new Document([ - '$id' => $id, - 'key' => $id, - 'type' => self::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => $twoWayKey, - 'key' => $twoWayKey, - 'type' => self::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ]; - $junctionIndexes = [ - new Document([ - '$id' => '_index_' . $id, - 'key' => 'index_' . $id, - 'type' => self::INDEX_KEY, - 'attributes' => [$id], - ]), - new Document([ - '$id' => '_index_' . $twoWayKey, - 'key' => '_index_' . $twoWayKey, - 'type' => self::INDEX_KEY, - 'attributes' => [$twoWayKey], - ]), - ]; - try { - $this->silent(fn () => $this->createCollection($junctionCollection, $junctionAttributes, $junctionIndexes)); - } catch (DuplicateException) { - // Junction metadata already exists from a prior partial failure. - // Ensure the physical schema also exists. - try { - $this->adapter->createCollection($junctionCollection, $junctionAttributes, $junctionIndexes); - } catch (DuplicateException) { - // Schema already exists — ignore - } - } - } - - $created = false; - - try { - $created = $this->adapter->createRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey - ); - - if (!$created) { - if ($junctionCollection !== null) { - try { - $this->silent(fn () => $this->cleanupCollection($junctionCollection)); - } catch (\Throwable $e) { - Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $e->getMessage()); - } - } - throw new DatabaseException('Failed to create relationship'); - } - } catch (DuplicateException) { - // Metadata checks (above) already verified relationship is absent - // from metadata. A DuplicateException from the adapter means the - // relationship exists only in physical schema — an orphan from a - // prior partial failure. Skip creation and proceed to metadata update. - } - - $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND); - $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND); - - $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $junctionCollection, $created) { - $indexesCreated = []; - try { - $this->withRetries(function () use ($collection, $relatedCollection) { - $this->withTransaction(function () use ($collection, $relatedCollection) { - $this->updateDocument(self::METADATA, $collection->getId(), $collection); - $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); - }); - }); - } catch (\Throwable $e) { - $this->rollbackAttributeMetadata($collection, [$id]); - $this->rollbackAttributeMetadata($relatedCollection, [$twoWayKey]); - - if ($created) { - try { - $this->cleanupRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - Database::RELATION_SIDE_PARENT - ); - } catch (\Throwable $e) { - Console::error("Failed to cleanup relationship '{$id}': " . $e->getMessage()); - } - - if ($junctionCollection !== null) { - try { - $this->cleanupCollection($junctionCollection); - } catch (\Throwable $e) { - Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $e->getMessage()); - } - } - } - - throw new DatabaseException('Failed to create relationship: ' . $e->getMessage()); - } - - $indexKey = '_index_' . $id; - $twoWayIndexKey = '_index_' . $twoWayKey; - $indexesCreated = []; - - try { - switch ($type) { - case self::RELATION_ONE_TO_ONE: - $this->createIndex($collection->getId(), $indexKey, self::INDEX_UNIQUE, [$id]); - $indexesCreated[] = ['collection' => $collection->getId(), 'index' => $indexKey]; - if ($twoWay) { - $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_UNIQUE, [$twoWayKey]); - $indexesCreated[] = ['collection' => $relatedCollection->getId(), 'index' => $twoWayIndexKey]; - } - break; - case self::RELATION_ONE_TO_MANY: - $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_KEY, [$twoWayKey]); - $indexesCreated[] = ['collection' => $relatedCollection->getId(), 'index' => $twoWayIndexKey]; - break; - case self::RELATION_MANY_TO_ONE: - $this->createIndex($collection->getId(), $indexKey, self::INDEX_KEY, [$id]); - $indexesCreated[] = ['collection' => $collection->getId(), 'index' => $indexKey]; - break; - case self::RELATION_MANY_TO_MANY: - // Indexes created on junction collection creation - break; - default: - throw new RelationshipException('Invalid relationship type.'); - } - } catch (\Throwable $e) { - foreach ($indexesCreated as $indexInfo) { - try { - $this->deleteIndex($indexInfo['collection'], $indexInfo['index']); - } catch (\Throwable $cleanupError) { - Console::error("Failed to cleanup index '{$indexInfo['index']}': " . $cleanupError->getMessage()); - } - } - - try { - $this->withTransaction(function () use ($collection, $relatedCollection, $id, $twoWayKey) { - $attributes = $collection->getAttribute('attributes', []); - $collection->setAttribute('attributes', array_filter($attributes, fn ($attr) => $attr->getId() !== $id)); - $this->updateDocument(self::METADATA, $collection->getId(), $collection); - - $relatedAttributes = $relatedCollection->getAttribute('attributes', []); - $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn ($attr) => $attr->getId() !== $twoWayKey)); - $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); - }); - } catch (\Throwable $cleanupError) { - Console::error("Failed to cleanup metadata for relationship '{$id}': " . $cleanupError->getMessage()); - } - - // Cleanup relationship - try { - $this->cleanupRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - Database::RELATION_SIDE_PARENT - ); - } catch (\Throwable $cleanupError) { - Console::error("Failed to cleanup relationship '{$id}': " . $cleanupError->getMessage()); - } - - if ($junctionCollection !== null) { - try { - $this->cleanupCollection($junctionCollection); - } catch (\Throwable $cleanupError) { - Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $cleanupError->getMessage()); - } - } - - throw new DatabaseException('Failed to create relationship indexes: ' . $e->getMessage()); - } - }); - - try { - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $relationship); - } catch (\Throwable $e) { - // Ignore - } - - return true; - } - - /** - * Update a relationship attribute - * - * @param string $collection - * @param string $id - * @param string|null $newKey - * @param string|null $newTwoWayKey - * @param bool|null $twoWay - * @param string|null $onDelete - * @return bool - * @throws ConflictException - * @throws DatabaseException - */ - public function updateRelationship( - string $collection, - string $id, - ?string $newKey = null, - ?string $newTwoWayKey = null, - ?bool $twoWay = null, - ?string $onDelete = null - ): bool { - if ( - \is_null($newKey) - && \is_null($newTwoWayKey) - && \is_null($twoWay) - && \is_null($onDelete) - ) { - return true; - } - - $collection = $this->getCollection($collection); - $attributes = $collection->getAttribute('attributes', []); - - if ( - !\is_null($newKey) - && \in_array($newKey, \array_map(fn ($attribute) => $attribute['key'], $attributes)) - ) { - throw new DuplicateException('Relationship already exists'); - } - - $attributeIndex = array_search($id, array_map(fn ($attribute) => $attribute['$id'], $attributes)); - - if ($attributeIndex === false) { - throw new NotFoundException('Relationship not found'); - } - - $attribute = $attributes[$attributeIndex]; - $type = $attribute['options']['relationType']; - $side = $attribute['options']['side']; - - $relatedCollectionId = $attribute['options']['relatedCollection']; - $relatedCollection = $this->getCollection($relatedCollectionId); - - // Determine if we need to alter the database (rename columns/indexes) - $oldAttribute = $attributes[$attributeIndex]; - $oldTwoWayKey = $oldAttribute['options']['twoWayKey']; - $altering = (!\is_null($newKey) && $newKey !== $id) - || (!\is_null($newTwoWayKey) && $newTwoWayKey !== $oldTwoWayKey); - - // Validate new keys don't already exist - if ( - !\is_null($newTwoWayKey) - && \in_array($newTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedCollection->getAttribute('attributes', []))) - ) { - throw new DuplicateException('Related attribute already exists'); - } - - $actualNewKey = $newKey ?? $id; - $actualNewTwoWayKey = $newTwoWayKey ?? $oldTwoWayKey; - $actualTwoWay = $twoWay ?? $oldAttribute['options']['twoWay']; - $actualOnDelete = $onDelete ?? $oldAttribute['options']['onDelete']; - - $adapterUpdated = false; - if ($altering) { - try { - $adapterUpdated = $this->adapter->updateRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $actualTwoWay, - $id, - $oldTwoWayKey, - $side, - $actualNewKey, - $actualNewTwoWayKey - ); - - if (!$adapterUpdated) { - throw new DatabaseException('Failed to update relationship'); - } - } catch (\Throwable $e) { - // Check if the rename already happened in schema (orphan from prior - // partial failure where adapter succeeded but metadata+rollback failed). - // If the new column names already exist, the prior rename completed. - if ($this->adapter->getSupportForSchemaAttributes()) { - $schemaAttributes = $this->getSchemaAttributes($collection->getId()); - $filteredNewKey = $this->adapter->filter($actualNewKey); - $newKeyExists = false; - foreach ($schemaAttributes as $schemaAttr) { - if (\strtolower($schemaAttr->getId()) === \strtolower($filteredNewKey)) { - $newKeyExists = true; - break; - } - } - if ($newKeyExists) { - $adapterUpdated = true; - } else { - throw new DatabaseException("Failed to update relationship '{$id}': " . $e->getMessage(), previous: $e); - } - } else { - throw new DatabaseException("Failed to update relationship '{$id}': " . $e->getMessage(), previous: $e); - } - } - } - - try { - $this->updateAttributeMeta($collection->getId(), $id, function ($attribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete, $relatedCollection, $type, $side) { - $attribute->setAttribute('$id', $actualNewKey); - $attribute->setAttribute('key', $actualNewKey); - $attribute->setAttribute('options', [ - 'relatedCollection' => $relatedCollection->getId(), - 'relationType' => $type, - 'twoWay' => $actualTwoWay, - 'twoWayKey' => $actualNewTwoWayKey, - 'onDelete' => $actualOnDelete, - 'side' => $side, - ]); - }); - - $this->updateAttributeMeta($relatedCollection->getId(), $oldTwoWayKey, function ($twoWayAttribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete) { - $options = $twoWayAttribute->getAttribute('options', []); - $options['twoWayKey'] = $actualNewKey; - $options['twoWay'] = $actualTwoWay; - $options['onDelete'] = $actualOnDelete; - - $twoWayAttribute->setAttribute('$id', $actualNewTwoWayKey); - $twoWayAttribute->setAttribute('key', $actualNewTwoWayKey); - $twoWayAttribute->setAttribute('options', $options); - }); - - if ($type === self::RELATION_MANY_TO_MANY) { - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - $this->updateAttributeMeta($junction, $id, function ($junctionAttribute) use ($actualNewKey) { - $junctionAttribute->setAttribute('$id', $actualNewKey); - $junctionAttribute->setAttribute('key', $actualNewKey); - }); - $this->updateAttributeMeta($junction, $oldTwoWayKey, function ($junctionAttribute) use ($actualNewTwoWayKey) { - $junctionAttribute->setAttribute('$id', $actualNewTwoWayKey); - $junctionAttribute->setAttribute('key', $actualNewTwoWayKey); - }); - - $this->withRetries(fn () => $this->purgeCachedCollection($junction)); - } - } catch (\Throwable $e) { - if ($adapterUpdated) { - try { - $this->adapter->updateRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $actualTwoWay, - $actualNewKey, - $actualNewTwoWayKey, - $side, - $id, - $oldTwoWayKey - ); - } catch (\Throwable $e) { - // Ignore - } - } - throw $e; - } - - // Update Indexes — wrapped in rollback for consistency with metadata - $renameIndex = function (string $collection, string $key, string $newKey) { - $this->updateIndexMeta( - $collection, - '_index_' . $key, - function ($index) use ($newKey) { - $index->setAttribute('attributes', [$newKey]); - } - ); - $this->silent( - fn () => $this->renameIndex($collection, '_index_' . $key, '_index_' . $newKey) - ); - }; - - $indexRenamesCompleted = []; - - try { - switch ($type) { - case self::RELATION_ONE_TO_ONE: - if ($id !== $actualNewKey) { - $renameIndex($collection->getId(), $id, $actualNewKey); - $indexRenamesCompleted[] = [$collection->getId(), $actualNewKey, $id]; - } - if ($actualTwoWay && $oldTwoWayKey !== $actualNewTwoWayKey) { - $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); - $indexRenamesCompleted[] = [$relatedCollection->getId(), $actualNewTwoWayKey, $oldTwoWayKey]; - } - break; - case self::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - if ($oldTwoWayKey !== $actualNewTwoWayKey) { - $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); - $indexRenamesCompleted[] = [$relatedCollection->getId(), $actualNewTwoWayKey, $oldTwoWayKey]; - } - } else { - if ($id !== $actualNewKey) { - $renameIndex($collection->getId(), $id, $actualNewKey); - $indexRenamesCompleted[] = [$collection->getId(), $actualNewKey, $id]; - } - } - break; - case self::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - if ($id !== $actualNewKey) { - $renameIndex($collection->getId(), $id, $actualNewKey); - $indexRenamesCompleted[] = [$collection->getId(), $actualNewKey, $id]; - } - } else { - if ($oldTwoWayKey !== $actualNewTwoWayKey) { - $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); - $indexRenamesCompleted[] = [$relatedCollection->getId(), $actualNewTwoWayKey, $oldTwoWayKey]; - } - } - break; - case self::RELATION_MANY_TO_MANY: - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - if ($id !== $actualNewKey) { - $renameIndex($junction, $id, $actualNewKey); - $indexRenamesCompleted[] = [$junction, $actualNewKey, $id]; - } - if ($oldTwoWayKey !== $actualNewTwoWayKey) { - $renameIndex($junction, $oldTwoWayKey, $actualNewTwoWayKey); - $indexRenamesCompleted[] = [$junction, $actualNewTwoWayKey, $oldTwoWayKey]; - } - break; - default: - throw new RelationshipException('Invalid relationship type.'); - } - } catch (\Throwable $e) { - // Reverse completed index renames - foreach (\array_reverse($indexRenamesCompleted) as [$coll, $from, $to]) { - try { - $renameIndex($coll, $from, $to); - } catch (\Throwable) { - // Best effort - } - } - - // Reverse attribute metadata - try { - $this->updateAttributeMeta($collection->getId(), $actualNewKey, function ($attribute) use ($id, $oldAttribute) { - $attribute->setAttribute('$id', $id); - $attribute->setAttribute('key', $id); - $attribute->setAttribute('options', $oldAttribute['options']); - }); - } catch (\Throwable) { - // Best effort - } - - try { - $this->updateAttributeMeta($relatedCollection->getId(), $actualNewTwoWayKey, function ($twoWayAttribute) use ($oldTwoWayKey, $id, $oldAttribute) { - $options = $twoWayAttribute->getAttribute('options', []); - $options['twoWayKey'] = $id; - $options['twoWay'] = $oldAttribute['options']['twoWay']; - $options['onDelete'] = $oldAttribute['options']['onDelete']; - $twoWayAttribute->setAttribute('$id', $oldTwoWayKey); - $twoWayAttribute->setAttribute('key', $oldTwoWayKey); - $twoWayAttribute->setAttribute('options', $options); - }); - } catch (\Throwable) { - // Best effort - } - - if ($type === self::RELATION_MANY_TO_MANY) { - $junctionId = $this->getJunctionCollection($collection, $relatedCollection, $side); - try { - $this->updateAttributeMeta($junctionId, $actualNewKey, function ($attr) use ($id) { - $attr->setAttribute('$id', $id); - $attr->setAttribute('key', $id); - }); - } catch (\Throwable) { - // Best effort - } - try { - $this->updateAttributeMeta($junctionId, $actualNewTwoWayKey, function ($attr) use ($oldTwoWayKey) { - $attr->setAttribute('$id', $oldTwoWayKey); - $attr->setAttribute('key', $oldTwoWayKey); - }); - } catch (\Throwable) { - // Best effort - } - } - - // Reverse adapter update - if ($adapterUpdated) { - try { - $this->adapter->updateRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $oldAttribute['options']['twoWay'], - $actualNewKey, - $actualNewTwoWayKey, - $side, - $id, - $oldTwoWayKey - ); - } catch (\Throwable) { - // Best effort - } - } - - throw new DatabaseException("Failed to update relationship indexes for '{$id}': " . $e->getMessage(), previous: $e); - } - - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); - - return true; - } - - /** - * Delete a relationship attribute - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws StructureException - */ - public function deleteRelationship(string $collection, string $id): bool - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - $attributes = $collection->getAttribute('attributes', []); - $relationship = null; - - foreach ($attributes as $name => $attribute) { - if ($attribute['$id'] === $id) { - $relationship = $attribute; - unset($attributes[$name]); - break; - } - } - - if (\is_null($relationship)) { - throw new NotFoundException('Relationship not found'); - } - - $collection->setAttribute('attributes', \array_values($attributes)); - - $relatedCollection = $relationship['options']['relatedCollection']; - $type = $relationship['options']['relationType']; - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - $side = $relationship['options']['side']; - - $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollection)); - $relatedAttributes = $relatedCollection->getAttribute('attributes', []); - - foreach ($relatedAttributes as $name => $attribute) { - if ($attribute['$id'] === $twoWayKey) { - unset($relatedAttributes[$name]); - break; - } - } - - $relatedCollection->setAttribute('attributes', \array_values($relatedAttributes)); - - $collectionAttributes = $collection->getAttribute('attributes'); - $relatedCollectionAttributes = $relatedCollection->getAttribute('attributes'); - - // Delete indexes BEFORE dropping columns to avoid referencing non-existent columns - // Track deleted indexes for rollback - $deletedIndexes = []; - $deletedJunction = null; - - $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $side, &$deletedIndexes, &$deletedJunction) { - $indexKey = '_index_' . $id; - $twoWayIndexKey = '_index_' . $twoWayKey; - - switch ($type) { - case self::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteIndex($collection->getId(), $indexKey); - $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => self::INDEX_UNIQUE, 'attributes' => [$id]]; - if ($twoWay) { - $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); - $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => self::INDEX_UNIQUE, 'attributes' => [$twoWayKey]]; - } - } - if ($side === Database::RELATION_SIDE_CHILD) { - $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); - $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => self::INDEX_UNIQUE, 'attributes' => [$twoWayKey]]; - if ($twoWay) { - $this->deleteIndex($collection->getId(), $indexKey); - $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => self::INDEX_UNIQUE, 'attributes' => [$id]]; - } - } - break; - case self::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); - $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => self::INDEX_KEY, 'attributes' => [$twoWayKey]]; - } else { - $this->deleteIndex($collection->getId(), $indexKey); - $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => self::INDEX_KEY, 'attributes' => [$id]]; - } - break; - case self::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteIndex($collection->getId(), $indexKey); - $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => self::INDEX_KEY, 'attributes' => [$id]]; - } else { - $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); - $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => self::INDEX_KEY, 'attributes' => [$twoWayKey]]; - } - break; - case self::RELATION_MANY_TO_MANY: - $junction = $this->getJunctionCollection( - $collection, - $relatedCollection, - $side - ); - - $deletedJunction = $this->silent(fn () => $this->getDocument(self::METADATA, $junction)); - $this->deleteDocument(self::METADATA, $junction); - break; - default: - throw new RelationshipException('Invalid relationship type.'); - } - }); - - $collection = $this->silent(fn () => $this->getCollection($collection->getId())); - $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollection->getId())); - $collection->setAttribute('attributes', $collectionAttributes); - $relatedCollection->setAttribute('attributes', $relatedCollectionAttributes); - - $shouldRollback = false; - try { - $deleted = $this->adapter->deleteRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - $side - ); - - if (!$deleted) { - throw new DatabaseException('Failed to delete relationship'); - } - $shouldRollback = true; - } catch (NotFoundException) { - // Ignore — relationship already absent from schema - } - - try { - $this->withRetries(function () use ($collection, $relatedCollection) { - $this->silent(function () use ($collection, $relatedCollection) { - $this->withTransaction(function () use ($collection, $relatedCollection) { - $this->updateDocument(self::METADATA, $collection->getId(), $collection); - $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); - }); - }); - }); - } catch (\Throwable $e) { - if ($shouldRollback) { - // Recreate relationship columns - try { - $this->adapter->createRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey - ); - } catch (\Throwable) { - // Silent rollback — best effort to restore consistency - } - } - - // Restore deleted indexes - foreach ($deletedIndexes as $indexInfo) { - try { - $this->createIndex( - $indexInfo['collection'], - $indexInfo['key'], - $indexInfo['type'], - $indexInfo['attributes'] - ); - } catch (\Throwable) { - // Silent rollback — best effort - } - } - - // Restore junction collection metadata for M2M - if ($deletedJunction !== null && !$deletedJunction->isEmpty()) { - try { - $this->silent(fn () => $this->createDocument(self::METADATA, $deletedJunction)); - } catch (\Throwable) { - // Silent rollback — best effort - } - } - - throw new DatabaseException( - "Failed to persist metadata after retries for relationship deletion '{$id}': " . $e->getMessage(), - previous: $e - ); - } - - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); - - try { - $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); - } catch (\Throwable $e) { - // Ignore - } - - return true; - } - - /** - * Rename Index - * - * @param string $collection - * @param string $old - * @param string $new - * - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws DuplicateException - * @throws StructureException - */ - public function renameIndex(string $collection, string $old, string $new): bool - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - $indexes = $collection->getAttribute('indexes', []); - - $index = \in_array($old, \array_map(fn ($index) => $index['$id'], $indexes)); - - if ($index === false) { - throw new NotFoundException('Index not found'); - } - - $indexNew = \in_array($new, \array_map(fn ($index) => $index['$id'], $indexes)); - - if ($indexNew !== false) { - throw new DuplicateException('Index name already used'); - } - - foreach ($indexes as $key => $value) { - if (isset($value['$id']) && $value['$id'] === $old) { - $indexes[$key]['key'] = $new; - $indexes[$key]['$id'] = $new; - $indexNew = $indexes[$key]; - break; - } - } - - $collection->setAttribute('indexes', $indexes); - - $renamed = false; - try { - $renamed = $this->adapter->renameIndex($collection->getId(), $old, $new); - if (!$renamed) { - throw new DatabaseException('Failed to rename index'); - } - } catch (\Throwable $e) { - // Check if the rename already happened in schema (orphan from prior - // partial failure where rename succeeded but metadata update and - // rollback both failed). Verify by attempting a reverse rename — if - // $new exists in schema, the reverse succeeds confirming a prior rename. - try { - $this->adapter->renameIndex($collection->getId(), $new, $old); - // Reverse succeeded — index was at $new. Re-rename to complete. - $renamed = $this->adapter->renameIndex($collection->getId(), $old, $new); - } catch (\Throwable) { - // Reverse also failed — genuine error - throw new DatabaseException("Failed to rename index '{$old}' to '{$new}': " . $e->getMessage(), previous: $e); - } - } - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->adapter->renameIndex($collection->getId(), $new, $old), - shouldRollback: $renamed, - operationDescription: "index rename '{$old}' to '{$new}'" - ); - - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - - try { - $this->trigger(self::EVENT_INDEX_RENAME, $indexNew); - } catch (\Throwable $e) { - // Ignore - } - - return true; - } - - /** - * Create Index - * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param int $ttl - * - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws DuplicateException - * @throws LimitException - * @throws StructureException - * @throws Exception - */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 1): bool - { - if (empty($attributes)) { - throw new DatabaseException('Missing attributes'); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - // index IDs are case-insensitive - $indexes = $collection->getAttribute('indexes', []); - - /** @var array $indexes */ - foreach ($indexes as $index) { - if (\strtolower($index->getId()) === \strtolower($id)) { - throw new DuplicateException('Index already exists'); - } - } - - if ($this->adapter->getCountOfIndexes($collection) >= $this->adapter->getLimitForIndexes()) { - throw new LimitException('Index limit reached. Cannot create new index.'); - } - - /** @var array $collectionAttributes */ - $collectionAttributes = $collection->getAttribute('attributes', []); - $indexAttributesWithTypes = []; - foreach ($attributes as $i => $attr) { - // Support nested paths on object attributes using dot notation: - // attribute.key.nestedKey -> base attribute "attribute" - $baseAttr = $attr; - if (\str_contains($attr, '.')) { - $baseAttr = \explode('.', $attr, 2)[0] ?? $attr; - } - - foreach ($collectionAttributes as $collectionAttribute) { - if ($collectionAttribute->getAttribute('key') === $baseAttr) { - - $attributeType = $collectionAttribute->getAttribute('type'); - $indexAttributesWithTypes[$attr] = $attributeType; - - /** - * mysql does not save length in collection when length = attributes size - */ - if (in_array($attributeType, self::STRING_TYPES)) { - if (!empty($lengths[$i]) && $lengths[$i] === $collectionAttribute->getAttribute('size') && $this->adapter->getMaxIndexLength() > 0) { - $lengths[$i] = null; - } - } - - $isArray = $collectionAttribute->getAttribute('array', false); - if ($isArray) { - if ($this->adapter->getMaxIndexLength() > 0) { - $lengths[$i] = self::MAX_ARRAY_INDEX_LENGTH; - } - $orders[$i] = null; - } - break; - } - } - } - - $index = new Document([ - '$id' => ID::custom($id), - 'key' => $id, - 'type' => $type, - 'attributes' => $attributes, - 'lengths' => $lengths, - 'orders' => $orders, - 'ttl' => $ttl - ]); - - if ($this->validate) { - - $validator = new IndexValidator( - $collection->getAttribute('attributes', []), - $collection->getAttribute('indexes', []), - $this->adapter->getMaxIndexLength(), - $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialIndexNull(), - $this->adapter->getSupportForSpatialIndexOrder(), - $this->adapter->getSupportForVectors(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForMultipleFulltextIndexes(), - $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObjectIndexes(), - $this->adapter->getSupportForTrigramIndex(), - $this->adapter->getSupportForSpatialAttributes(), - $this->adapter->getSupportForIndex(), - $this->adapter->getSupportForUniqueIndex(), - $this->adapter->getSupportForFulltextIndex(), - $this->adapter->getSupportForTTLIndexes(), - $this->adapter->getSupportForObject() - ); - if (!$validator->isValid($index)) { - throw new IndexException($validator->getDescription()); - } - } - - $created = false; - $existsInSchema = false; - - if ($this->adapter->getSupportForSchemaIndexes() - && !($this->adapter->getSharedTables() && $this->isMigrating())) { - $schemaIndexes = $this->getSchemaIndexes($collection->getId()); - $filteredId = $this->adapter->filter($id); - - foreach ($schemaIndexes as $schemaIndex) { - if (\strtolower($schemaIndex->getId()) === \strtolower($filteredId)) { - $schemaColumns = $schemaIndex->getAttribute('columns', []); - $schemaLengths = $schemaIndex->getAttribute('lengths', []); - - $filteredAttributes = \array_map(fn ($a) => $this->adapter->filter($a), $attributes); - $match = ($schemaColumns === $filteredAttributes && $schemaLengths === $lengths); - - if ($match) { - $existsInSchema = true; - } else { - // Orphan index with wrong definition — drop so it - // gets recreated with the correct shape. - try { - $this->adapter->deleteIndex($collection->getId(), $id); - } catch (NotFoundException) { - } - } - break; - } - } - } - - if (!$existsInSchema) { - try { - $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl); - - if (!$created) { - throw new DatabaseException('Failed to create index'); - } - } catch (UniqueException $e) { - // Existing rows violate the unique constraint, so no index was built. - throw $e; - } catch (DuplicateException) { - // Metadata check (lines above) already verified index is absent - // from metadata. A DuplicateException from the adapter means the - // index exists only in physical schema — an orphan from a prior - // partial failure. Skip creation and proceed to metadata update. - } - } - - $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->cleanupIndex($collection->getId(), $id), - shouldRollback: $created, - operationDescription: "index creation '{$id}'" - ); - - $this->trigger(self::EVENT_INDEX_CREATE, $index); - - return true; - } - - /** - * Delete Index - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws StructureException - */ - public function deleteIndex(string $collection, string $id): bool - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - $indexes = $collection->getAttribute('indexes', []); - - $indexDeleted = null; - foreach ($indexes as $key => $value) { - if (isset($value['$id']) && $value['$id'] === $id) { - $indexDeleted = $value; - unset($indexes[$key]); - } - } - - if (\is_null($indexDeleted)) { - throw new NotFoundException('Index not found'); - } - - $shouldRollback = false; - $deleted = false; - try { - $deleted = $this->adapter->deleteIndex($collection->getId(), $id); - - if (!$deleted) { - throw new DatabaseException('Failed to delete index'); - } - $shouldRollback = true; - } catch (NotFoundException) { - // Index already absent from schema; treat as deleted - $deleted = true; - } - - $collection->setAttribute('indexes', \array_values($indexes)); - - // Build indexAttributeTypes from collection attributes for rollback - /** @var array $collectionAttributes */ - $collectionAttributes = $collection->getAttribute('attributes', []); - $indexAttributeTypes = []; - foreach ($indexDeleted->getAttribute('attributes', []) as $attr) { - $baseAttr = \str_contains($attr, '.') ? \explode('.', $attr, 2)[0] : $attr; - foreach ($collectionAttributes as $collectionAttribute) { - if ($collectionAttribute->getAttribute('key') === $baseAttr) { - $indexAttributeTypes[$attr] = $collectionAttribute->getAttribute('type'); - break; - } - } - } - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->adapter->createIndex( - $collection->getId(), - $id, - $indexDeleted->getAttribute('type'), - $indexDeleted->getAttribute('attributes', []), - $indexDeleted->getAttribute('lengths', []), - $indexDeleted->getAttribute('orders', []), - $indexAttributeTypes, - [], - $indexDeleted->getAttribute('ttl', 1) - ), - shouldRollback: $shouldRollback, - operationDescription: "index deletion '{$id}'", - silentRollback: true - ); - - - try { - $this->trigger(self::EVENT_INDEX_DELETE, $indexDeleted); - } catch (\Throwable $e) { - // Ignore - } - - return $deleted; - } - - /** - * Get Document - * - * @param string $collection - * @param string $id - * @param Query[] $queries - * @param bool $forUpdate - * @return Document - * @throws NotFoundException - * @throws QueryException - * @throws Exception - */ - public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document - { - if ($collection === self::METADATA && $id === self::METADATA) { - return new Document(self::COLLECTION); - } - - if (empty($collection)) { - throw new NotFoundException('Collection not found'); - } - - if (empty($id)) { - return $this->createDocumentInstance($collection, []); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $attributes = $collection->getAttribute('attributes', []); - - $this->checkQueryTypes($queries); - - if ($this->validate) { - $validator = new DocumentValidator($attributes, $this->adapter->getSupportForAttributes()); - if (!$validator->isValid($queries)) { - throw new QueryException($validator->getDescription()); - } - } - - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP - ); - - $selects = Query::groupByType($queries)['selections']; - $selections = $this->validateSelections($collection, $selects); - $nestedSelections = $this->processRelationshipQueries($relationships, $queries); - - $documentSecurity = $collection->getAttribute('documentSecurity', false); - - [$collectionKey, $documentKey, $hashKey] = $this->getCacheKeys( - $collection->getId(), - $id, - $selections - ); - - // A locking read must observe the current row, not a cached copy: - // updateDocument merges the changes into this read and writes the result - // back, so serving it from a stale cache would persist the staleness. - $cached = null; - if (!$forUpdate) { - try { - $cached = $this->cache->load($documentKey, self::TTL, $hashKey); - } catch (Exception $e) { - Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); - } - } - - // Negative cache hit - if (\is_array($cached) && isset($cached[self::CACHE_EMPTY_MARKER])) { - return $this->createDocumentInstance($collection->getId(), []); - } - - if ($cached) { - $document = $this->createDocumentInstance($collection->getId(), $cached); - - // JSON serialization in cache backends collapses floats with zero - // fractions to ints. Re-cast so cached and freshly-loaded documents - // compare equal under strict equality (e.g. in updateDocument). - $document = $this->casting($collection, $document); - - if ($collection->getId() !== self::METADATA) { - - if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, [ - ...$collection->getRead(), - ...($documentSecurity ? $document->getRead() : []) - ]))) { - return $this->createDocumentInstance($collection->getId(), []); - } - } - - $this->trigger(self::EVENT_DOCUMENT_READ, $document); - - if ($this->isTtlExpired($collection, $document)) { - return $this->createDocumentInstance($collection->getId(), []); - } - - return $document; - } - - // Capture the generation before reading: if a concurrent purge advances - // it, saveWithLease() below rejects this now-stale value. '0' means no lease. - $generation = '0'; - if (!$forUpdate) { - try { - $generation = $this->cache->getGeneration($documentKey); - } catch (Exception $e) { - Console::warning('Warning: Failed to get cache generation: ' . $e->getMessage()); - } - } - - $document = $this->adapter->getDocument( - $collection, - $id, - $queries, - $forUpdate - ); - - if ($document->isEmpty()) { - if (!$forUpdate && empty($relationships)) { - try { - $marker = [self::CACHE_EMPTY_MARKER => true]; - - if ($this->cache->saveWithLease($documentKey, $marker, $hashKey, $generation) !== false) { - $this->cache->save($collectionKey, 'empty', $documentKey); - } - } catch (Exception $e) { - Console::warning('Failed to save empty document to cache: ' . $e->getMessage()); - } - } - - return $this->createDocumentInstance($collection->getId(), []); - } - - if ($this->isTtlExpired($collection, $document)) { - return $this->createDocumentInstance($collection->getId(), []); - } - - $document = $this->adapter->castingAfter($collection, $document); - - // Convert to custom document type if mapped - if (isset($this->documentTypes[$collection->getId()])) { - $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); - } - - $document->setAttribute('$collection', $collection->getId()); - - if ($collection->getId() !== self::METADATA) { - if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, [ - ...$collection->getRead(), - ...($documentSecurity ? $document->getRead() : []) - ]))) { - return $this->createDocumentInstance($collection->getId(), []); - } - } - - $document = $this->casting($collection, $document); - $document = $this->decode($collection, $document, $selections); - - // Skip relationship population if we're in batch mode (relationships will be populated later) - if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $nestedSelections)); - $document = $documents[0]; - } - - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] === Database::VAR_RELATIONSHIP - ); - - // Don't save to cache if it's part of a relationship, or if this is a - // locking read: a forUpdate read happens inside an open transaction, and - // caching the pre-commit row would poison the cache for other readers. - if (!$forUpdate && empty($relationships)) { - try { - // Index for invalidation only when the value was actually cached. - if ($this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation) !== false) { - $this->cache->save($collectionKey, 'empty', $documentKey); - } - } catch (Exception $e) { - Console::warning('Failed to save document to cache: ' . $e->getMessage()); - } - } - - $this->trigger(self::EVENT_DOCUMENT_READ, $document); - - return $document; - } - - private function isTtlExpired(Document $collection, Document $document): bool - { - if (!$this->adapter->getSupportForTTLIndexes()) { - return false; - } - foreach ($collection->getAttribute('indexes', []) as $index) { - if ($index->getAttribute('type') !== self::INDEX_TTL) { - continue; - } - $ttlSeconds = (int) $index->getAttribute('ttl', 0); - $ttlAttr = $index->getAttribute('attributes')[0] ?? null; - if ($ttlSeconds <= 0 || !$ttlAttr) { - return false; - } - $val = $document->getAttribute($ttlAttr); - if (is_string($val)) { - try { - $start = new \DateTime($val); - return (new \DateTime()) > (clone $start)->modify("+{$ttlSeconds} seconds"); - } catch (\Throwable) { - return false; - } - } - } - return false; - } - - /** - * Populate relationships for an array of documents with breadth-first traversal - * - * @param array $documents - * @param Document $collection - * @param int $relationshipFetchDepth - * @param array> $selects - * @return array - * @throws DatabaseException - */ - private function populateDocumentsRelationships( - array $documents, - Document $collection, - int $relationshipFetchDepth = 0, - array $selects = [] - ): array { - // Prevent nested relationship population during fetches - $this->inBatchRelationshipPopulation = true; - - try { - $queue = [ - [ - 'documents' => $documents, - 'collection' => $collection, - 'depth' => $relationshipFetchDepth, - 'selects' => $selects, - 'skipKey' => null, // No back-reference to skip at top level - 'hasExplicitSelects' => !empty($selects) // Track if we're in explicit select mode - ] - ]; - - $currentDepth = $relationshipFetchDepth; - - while (!empty($queue) && $currentDepth < self::RELATION_MAX_DEPTH) { - $nextQueue = []; - - foreach ($queue as $item) { - $docs = $item['documents']; - $coll = $item['collection']; - $sels = $item['selects']; - $skipKey = $item['skipKey'] ?? null; - $parentHasExplicitSelects = $item['hasExplicitSelects']; - - if (empty($docs)) { - continue; - } - - $attributes = $coll->getAttribute('attributes', []); - $relationships = []; - - foreach ($attributes as $attribute) { - if ($attribute['type'] === Database::VAR_RELATIONSHIP) { - // Skip the back-reference relationship that brought us here - if ($attribute['key'] === $skipKey) { - continue; - } - - // Include relationship if: - // 1. No explicit selects (fetch all) OR - // 2. Relationship is explicitly selected - if (!$parentHasExplicitSelects || \array_key_exists($attribute['key'], $sels)) { - $relationships[] = $attribute; - } - } - } - - foreach ($relationships as $relationship) { - $key = $relationship['key']; - $queries = $sels[$key] ?? []; - $relationship->setAttribute('collection', $coll->getId()); - $isAtMaxDepth = ($currentDepth + 1) >= self::RELATION_MAX_DEPTH; - - // If we're at max depth, remove this relationship from source documents and skip - if ($isAtMaxDepth) { - foreach ($docs as $doc) { - $doc->removeAttribute($key); - } - continue; - } - - $relatedDocs = $this->populateSingleRelationshipBatch( - $docs, - $relationship, - $queries - ); - - // Get two-way relationship info - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - - // Queue if: - // 1. No explicit selects (fetch all recursively), OR - // 2. Explicit nested selects for this relationship - $hasNestedSelectsForThisRel = isset($sels[$key]); - $shouldQueue = !empty($relatedDocs) && - ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); - - if ($shouldQueue) { - $relatedCollectionId = $relationship['options']['relatedCollection']; - $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollectionId)); - - if (!$relatedCollection->isEmpty()) { - // Get nested selections for this relationship - $relationshipQueries = $hasNestedSelectsForThisRel ? $sels[$key] : []; - - // Extract nested selections for the related collection - $relatedCollectionRelationships = $relatedCollection->getAttribute('attributes', []); - $relatedCollectionRelationships = \array_filter( - $relatedCollectionRelationships, - fn ($attr) => $attr['type'] === Database::VAR_RELATIONSHIP - ); - - $nextSelects = $this->processRelationshipQueries($relatedCollectionRelationships, $relationshipQueries); - - // If parent has explicit selects, child inherits that mode - // (even if nextSelects is empty, we're still in explicit mode) - $childHasExplicitSelects = $parentHasExplicitSelects; - - $nextQueue[] = [ - 'documents' => $relatedDocs, - 'collection' => $relatedCollection, - 'depth' => $currentDepth + 1, - 'selects' => $nextSelects, - 'skipKey' => $twoWay ? $twoWayKey : null, // Skip the back-reference at next depth - 'hasExplicitSelects' => $childHasExplicitSelects - ]; - } - } - - // Remove back-references for two-way relationships - // Back-references are always removed to prevent circular references - if ($twoWay && !empty($relatedDocs)) { - foreach ($relatedDocs as $relatedDoc) { - $relatedDoc->removeAttribute($twoWayKey); - } - } - } - } - - $queue = $nextQueue; - $currentDepth++; - } - } finally { - $this->inBatchRelationshipPopulation = false; - } - - return $documents; - } - - /** - * Populate a single relationship type for all documents in batch - * Returns all related documents that were populated - * - * @param array $documents - * @param Document $relationship - * @param array $queries - * @return array - * @throws DatabaseException - */ - private function populateSingleRelationshipBatch( - array $documents, - Document $relationship, - array $queries - ): array { - return match ($relationship['options']['relationType']) { - Database::RELATION_ONE_TO_ONE => $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries), - Database::RELATION_ONE_TO_MANY => $this->populateOneToManyRelationshipsBatch($documents, $relationship, $queries), - Database::RELATION_MANY_TO_ONE => $this->populateManyToOneRelationshipsBatch($documents, $relationship, $queries), - Database::RELATION_MANY_TO_MANY => $this->populateManyToManyRelationshipsBatch($documents, $relationship, $queries), - default => [], - }; - } - - /** - * Populate one-to-one relationships in batch - * Returns all related documents that were fetched - * - * @param array $documents - * @param Document $relationship - * @param array $queries - * @return array - * @throws DatabaseException - */ - private function populateOneToOneRelationshipsBatch(array $documents, Document $relationship, array $queries): array - { - $key = $relationship['key']; - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - - $relatedIds = []; - $documentsByRelatedId = []; - - foreach ($documents as $document) { - $value = $document->getAttribute($key); - if (!\is_null($value)) { - // Skip if value is already populated - if ($value instanceof Document) { - continue; - } - - // For one-to-one, multiple documents can reference the same related ID - $relatedIds[] = $value; - if (!isset($documentsByRelatedId[$value])) { - $documentsByRelatedId[$value] = []; - } - $documentsByRelatedId[$value][] = $document; - } - } - - if (empty($relatedIds)) { - return []; - } - - $uniqueRelatedIds = \array_unique($relatedIds); - $relatedDocuments = []; - - // Process in chunks to avoid exceeding query value limits - foreach (\array_chunk($uniqueRelatedIds, \max(1, $this->maxQueryValues)) as $chunk) { - $chunkDocs = $this->find($relatedCollection->getId(), [ - Query::equal('$id', $chunk), - Query::limit(PHP_INT_MAX), - ...$queries - ]); - \array_push($relatedDocuments, ...$chunkDocs); - } - - // Index related documents by ID for quick lookup - $relatedById = []; - foreach ($relatedDocuments as $related) { - $relatedById[$related->getId()] = $related; - } - - // Assign related documents to their parent documents - foreach ($documentsByRelatedId as $relatedId => $docs) { - if (isset($relatedById[$relatedId])) { - // Set the relationship for all documents that reference this related ID - foreach ($docs as $document) { - $document->setAttribute($key, $relatedById[$relatedId]); - } - } else { - // If related document not found, set to empty Document instead of leaving the string ID - foreach ($docs as $document) { - $document->setAttribute($key, new Document()); - } - } - } - - return $relatedDocuments; - } - - /** - * Populate one-to-many relationships in batch - * Returns all related documents that were fetched - * - * @param array $documents - * @param Document $relationship - * @param array $queries - * @return array - * @throws DatabaseException - */ - private function populateOneToManyRelationshipsBatch( - array $documents, - Document $relationship, - array $queries, - ): array { - $key = $relationship['key']; - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - $side = $relationship['options']['side']; - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - - if ($side === Database::RELATION_SIDE_CHILD) { - // Child side - treat like one-to-one - if (!$twoWay) { - foreach ($documents as $document) { - $document->removeAttribute($key); - } - return []; - } - return $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries); - } - - // Parent side - fetch multiple related documents - $parentIds = []; - foreach ($documents as $document) { - $parentId = $document->getId(); - $parentIds[] = $parentId; - } - - $parentIds = \array_unique($parentIds); - - if (empty($parentIds)) { - return []; - } - - // For batch relationship population, we need to fetch documents with all attributes - // to enable proper grouping by back-reference, then apply selects afterward - $selectQueries = []; - $otherQueries = []; - foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { - $selectQueries[] = $query; - } else { - $otherQueries[] = $query; - } - } - - $relatedDocuments = []; - - foreach (\array_chunk($parentIds, \max(1, $this->maxQueryValues)) as $chunk) { - $chunkDocs = $this->find($relatedCollection->getId(), [ - Query::equal($twoWayKey, $chunk), - Query::limit(PHP_INT_MAX), - ...$otherQueries - ]); - \array_push($relatedDocuments, ...$chunkDocs); - } - - // Group related documents by parent ID - $relatedByParentId = []; - foreach ($relatedDocuments as $related) { - $parentId = $related->getAttribute($twoWayKey); - if (!\is_null($parentId)) { - // Handle case where parentId might be a Document object instead of string - $parentKey = $parentId instanceof Document - ? $parentId->getId() - : $parentId; - - if (!isset($relatedByParentId[$parentKey])) { - $relatedByParentId[$parentKey] = []; - } - // We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in breadth-first traversal - $relatedByParentId[$parentKey][] = $related; - } - } - - $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); - - // Assign related documents to their parent documents - foreach ($documents as $document) { - $parentId = $document->getId(); - $relatedDocs = $relatedByParentId[$parentId] ?? []; - $document->setAttribute($key, $relatedDocs); - } - - return $relatedDocuments; - } - - /** - * Populate many-to-one relationships in batch - * - * @param array $documents - * @param Document $relationship - * @param array $queries - * @return array - * @throws DatabaseException - */ - private function populateManyToOneRelationshipsBatch( - array $documents, - Document $relationship, - array $queries, - ): array { - $key = $relationship['key']; - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - $side = $relationship['options']['side']; - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - - if ($side === Database::RELATION_SIDE_PARENT) { - // Parent side - treat like one-to-one - return $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries); - } - - // Child side - fetch multiple related documents - if (!$twoWay) { - foreach ($documents as $document) { - $document->removeAttribute($key); - } - return []; - } - - $childIds = []; - foreach ($documents as $document) { - $childId = $document->getId(); - $childIds[] = $childId; - } - - $childIds = array_unique($childIds); - - if (empty($childIds)) { - return []; - } - - $selectQueries = []; - $otherQueries = []; - foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_SELECT) { - $selectQueries[] = $query; - } else { - $otherQueries[] = $query; - } - } - - $relatedDocuments = []; - - foreach (\array_chunk($childIds, \max(1, $this->maxQueryValues)) as $chunk) { - $chunkDocs = $this->find($relatedCollection->getId(), [ - Query::equal($twoWayKey, $chunk), - Query::limit(PHP_INT_MAX), - ...$otherQueries - ]); - \array_push($relatedDocuments, ...$chunkDocs); - } - - // Group related documents by child ID - $relatedByChildId = []; - foreach ($relatedDocuments as $related) { - $childId = $related->getAttribute($twoWayKey); - if (!\is_null($childId)) { - // Handle case where childId might be a Document object instead of string - $childKey = $childId instanceof Document - ? $childId->getId() - : $childId; - - if (!isset($relatedByChildId[$childKey])) { - $relatedByChildId[$childKey] = []; - } - // We don't remove the back-reference here because documents may be reused across fetches - // Cycles are prevented by depth limiting in breadth-first traversal - $relatedByChildId[$childKey][] = $related; - } - } - - $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); - - foreach ($documents as $document) { - $childId = $document->getId(); - $document->setAttribute($key, $relatedByChildId[$childId] ?? []); - } - - return $relatedDocuments; - } - - /** - * Populate many-to-many relationships in batch - * - * @param array $documents - * @param Document $relationship - * @param array $queries - * @return array - * @throws DatabaseException - */ - private function populateManyToManyRelationshipsBatch( - array $documents, - Document $relationship, - array $queries - ): array { - $key = $relationship['key']; - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - $side = $relationship['options']['side']; - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - $collection = $this->getCollection($relationship->getAttribute('collection')); - - if (!$twoWay && $side === Database::RELATION_SIDE_CHILD) { - return []; - } - - $documentIds = []; - foreach ($documents as $document) { - $documentId = $document->getId(); - $documentIds[] = $documentId; - } - - $documentIds = array_unique($documentIds); - - if (empty($documentIds)) { - return []; - } - - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - $junctions = []; - - foreach (\array_chunk($documentIds, \max(1, $this->maxQueryValues)) as $chunk) { - $chunkJunctions = $this->skipRelationships(fn () => $this->find($junction, [ - Query::equal($twoWayKey, $chunk), - Query::limit(PHP_INT_MAX) - ])); - \array_push($junctions, ...$chunkJunctions); - } - - $relatedIds = []; - $junctionsByDocumentId = []; - - foreach ($junctions as $junctionDoc) { - $documentId = $junctionDoc->getAttribute($twoWayKey); - $relatedId = $junctionDoc->getAttribute($key); - - if (!\is_null($documentId) && !\is_null($relatedId)) { - if (!isset($junctionsByDocumentId[$documentId])) { - $junctionsByDocumentId[$documentId] = []; - } - $junctionsByDocumentId[$documentId][] = $relatedId; - $relatedIds[] = $relatedId; - } - } - - $related = []; - $allRelatedDocs = []; - if (!empty($relatedIds)) { - $uniqueRelatedIds = array_unique($relatedIds); - $foundRelated = []; - - foreach (\array_chunk($uniqueRelatedIds, \max(1, $this->maxQueryValues)) as $chunk) { - $chunkDocs = $this->find($relatedCollection->getId(), [ - Query::equal('$id', $chunk), - Query::limit(PHP_INT_MAX), - ...$queries - ]); - \array_push($foundRelated, ...$chunkDocs); - } - - $allRelatedDocs = $foundRelated; - - $relatedById = []; - foreach ($foundRelated as $doc) { - $relatedById[$doc->getId()] = $doc; - } - - // Build final related arrays maintaining junction order - foreach ($junctionsByDocumentId as $documentId => $relatedDocIds) { - $documentRelated = []; - foreach ($relatedDocIds as $relatedId) { - if (isset($relatedById[$relatedId])) { - $documentRelated[] = $relatedById[$relatedId]; - } - } - $related[$documentId] = $documentRelated; - } - } - - foreach ($documents as $document) { - $documentId = $document->getId(); - $document->setAttribute($key, $related[$documentId] ?? []); - } - - return $allRelatedDocs; - } - - /** - * Apply select filters to documents after fetching - * - * Filters document attributes based on select queries while preserving internal attributes. - * This is used in batch relationship population to apply selects after grouping. - * - * @param array $documents Documents to filter - * @param array $selectQueries Select query objects - * @return void - */ - private function applySelectFiltersToDocuments(array $documents, array $selectQueries): void - { - if (empty($selectQueries) || empty($documents)) { - return; - } - - // Collect all attributes to keep from select queries - $attributesToKeep = []; - foreach ($selectQueries as $selectQuery) { - foreach ($selectQuery->getValues() as $value) { - $attributesToKeep[$value] = true; - } - } - - // Early return if wildcard selector present - if (isset($attributesToKeep['*'])) { - return; - } - - // Always preserve internal attributes (use hashmap for O(1) lookup) - $internalKeys = \array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); - foreach ($internalKeys as $key) { - $attributesToKeep[$key] = true; - } - - foreach ($documents as $doc) { - $allKeys = \array_keys($doc->getArrayCopy()); - foreach ($allKeys as $attrKey) { - // Keep if: explicitly selected OR is internal attribute ($ prefix) - if (!isset($attributesToKeep[$attrKey]) && !\str_starts_with($attrKey, '$')) { - $doc->removeAttribute($attrKey); - } - } - } - } - - /** - * Create Document - * - * @param string $collection - * @param Document $document - * @return Document - * @throws AuthorizationException - * @throws DatabaseException - * @throws StructureException - */ - public function createDocument(string $collection, Document $document): Document - { - if ( - $collection !== self::METADATA - && $this->adapter->getSharedTables() - && !$this->adapter->getTenantPerDocument() - && empty($this->adapter->getTenant()) - ) { - throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); - } - - if ( - !$this->adapter->getSharedTables() - && $this->adapter->getTenantPerDocument() - ) { - throw new DatabaseException('Shared tables must be enabled if tenant per document is enabled.'); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->getId() !== self::METADATA) { - $isValid = $this->authorization->isValid(new Input(self::PERMISSION_CREATE, $collection->getCreate())); - if (!$isValid) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } - - $time = DateTime::now(); - - $createdAt = $document->getCreatedAt(); - $updatedAt = $document->getUpdatedAt(); - - $document - ->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId()) - ->setAttribute('$collection', $collection->getId()) - ->setAttribute('$createdAt', ($createdAt === null || !$this->preserveDates) ? $time : $createdAt) - ->setAttribute('$updatedAt', ($updatedAt === null || !$this->preserveDates) ? $time : $updatedAt); - - if (empty($document->getPermissions())) { - $document->setAttribute('$permissions', []); - } - - if ($this->adapter->getSharedTables()) { - if ($this->adapter->getTenantPerDocument()) { - if ( - $collection->getId() !== static::METADATA - && $document->getTenant() === null - ) { - throw new DatabaseException('Missing tenant. Tenant must be set when tenant per document is enabled.'); - } - } else { - $document->setAttribute('$tenant', $this->adapter->getTenant()); - } - } - - $document = $this->encode($collection, $document); - - if ($this->validate) { - $validator = new Permissions(); - if (!$validator->isValid($document->getPermissions())) { - throw new DatabaseException($validator->getDescription()); - } - } - - if ($this->validate) { - $structure = new Structure( - $collection, - $this->adapter->getIdAttributeType(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - supportUnsignedBigInt: $this->adapter->getSupportForUnsignedBigInt(), - currentDocument: null - ); - if (!$structure->isValid($document)) { - throw new StructureException($structure->getDescription()); - } - } - - $document = $this->adapter->castingBefore($collection, $document); - - $document = $this->withTransaction(function () use ($collection, $document) { - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); - } - return $this->adapter->createDocument($collection, $document); - }); - - // Clear any negative-cache entry for this id: a prior read may have - // recorded it as missing before this insert committed. - $this->withDocumentTenant($document, fn () => $this->purgeCachedDocumentInternal($collection->getId(), $document->getId())); - - if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - // Use the write stack depth for proper MAX_DEPTH enforcement during creation - $fetchDepth = count($this->relationshipWriteStack); - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $fetchDepth)); - $document = $this->adapter->castingAfter($collection, $documents[0]); - } - - $document = $this->casting($collection, $document); - $document = $this->decode($collection, $document); - - // Convert to custom document type if mapped - if (isset($this->documentTypes[$collection->getId()])) { - $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); - } - - $this->trigger(self::EVENT_DOCUMENT_CREATE, $document); - - return $document; - } - - /** - * Create Documents in a batch - * - * @param string $collection - * @param array $documents - * @param int $batchSize - * @param (callable(Document): void)|null $onNext - * @param (callable(Throwable): void)|null $onError - * @return int - * @throws AuthorizationException - * @throws StructureException - * @throws \Throwable - * @throws Exception - */ - public function createDocuments( - string $collection, - array $documents, - int $batchSize = self::INSERT_BATCH_SIZE, - ?callable $onNext = null, - ?callable $onError = null, - ): int { - if (!$this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument()) { - throw new DatabaseException('Shared tables must be enabled if tenant per document is enabled.'); - } - - if (empty($documents)) { - return 0; - } - - $batchSize = \min(Database::INSERT_BATCH_SIZE, \max(1, $batchSize)); - $collection = $this->silent(fn () => $this->getCollection($collection)); - if ($collection->getId() !== self::METADATA) { - if (!$this->authorization->isValid(new Input(self::PERMISSION_CREATE, $collection->getCreate()))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } - - $time = DateTime::now(); - $modified = 0; - $hasRelationships = !empty(\array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP - )); - - foreach ($documents as $document) { - $createdAt = $document->getCreatedAt(); - $updatedAt = $document->getUpdatedAt(); - - $document - ->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId()) - ->setAttribute('$collection', $collection->getId()) - ->setAttribute('$createdAt', ($createdAt === null || !$this->preserveDates) ? $time : $createdAt) - ->setAttribute('$updatedAt', ($updatedAt === null || !$this->preserveDates) ? $time : $updatedAt); - - if (empty($document->getPermissions())) { - $document->setAttribute('$permissions', []); - } - - if ($this->adapter->getSharedTables()) { - if ($this->adapter->getTenantPerDocument()) { - if ($document->getTenant() === null) { - throw new DatabaseException('Missing tenant. Tenant must be set when tenant per document is enabled.'); - } - } else { - $document->setAttribute('$tenant', $this->adapter->getTenant()); - } - } - - $document = $this->encode($collection, $document); - - if ($this->validate) { - $validator = new Structure( - $collection, - $this->adapter->getIdAttributeType(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - supportUnsignedBigInt: $this->adapter->getSupportForUnsignedBigInt(), - currentDocument: null - ); - if (!$validator->isValid($document)) { - throw new StructureException($validator->getDescription()); - } - } - - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); - } - - $document = $this->adapter->castingBefore($collection, $document); - } - - foreach (\array_chunk($documents, $batchSize) as $chunk) { - $insert = fn () => $this->withTransaction(fn () => $this->adapter->createDocuments($collection, $chunk)); - // Set adapter flag before withTransaction so Mongo can opt out of a real txn. - $batch = $this->skipDuplicates - ? $this->adapter->skipDuplicates($insert) - : $insert(); - - // A SELECT per batch, read only by relationship population and by whatever the - // caller does with the documents $onNext hands it. Skip it when neither applies. - if ($onNext !== null || $hasRelationships) { - $batch = $this->adapter->getSequences($collection->getId(), $batch); - } - - if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); - } - - foreach ($batch as $document) { - $document = $this->adapter->castingAfter($collection, $document); - $document = $this->casting($collection, $document); - $document = $this->decode($collection, $document); - - // Clear any negative-cache entry recorded before this insert. - $this->withDocumentTenant($document, fn () => $this->purgeCachedDocumentInternal($collection->getId(), $document->getId())); - - try { - $onNext && $onNext($document); - } catch (\Throwable $e) { - $onError ? $onError($e) : throw $e; - } - - $modified++; - } - } - - $this->trigger(self::EVENT_DOCUMENTS_CREATE, new Document([ - '$collection' => $collection->getId(), - 'modified' => $modified - ])); - - return $modified; - } - - /** - * @param Document $collection - * @param Document $document - * @return Document - * @throws DatabaseException - */ - private function createDocumentRelationships(Document $collection, Document $document): Document - { - $attributes = $collection->getAttribute('attributes', []); - - $relationships = \array_filter( - $attributes, - fn ($attribute) => $attribute['type'] === Database::VAR_RELATIONSHIP - ); - - $stackCount = count($this->relationshipWriteStack); - - foreach ($relationships as $relationship) { - $key = $relationship['key']; - $value = $document->getAttribute($key); - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - $relationType = $relationship['options']['relationType']; - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - $side = $relationship['options']['side']; - - if ($stackCount >= Database::RELATION_MAX_DEPTH - 1 && $this->relationshipWriteStack[$stackCount - 1] !== $relatedCollection->getId()) { - $document->removeAttribute($key); - - continue; - } - - $this->relationshipWriteStack[] = $collection->getId(); - - try { - switch (\gettype($value)) { - case 'array': - if ($relationType === Database::RELATION_ONE_TO_ONE && !$twoWay && $side === Database::RELATION_SIDE_CHILD) { - throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); - } - - if ( - ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_PARENT) || - ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_CHILD) || - ($relationType === Database::RELATION_ONE_TO_ONE) - ) { - throw new RelationshipException('Invalid relationship value. Must be either a document ID or a document, array given.'); - } - - // List of documents or IDs - foreach ($value as $related) { - switch (\gettype($related)) { - case 'object': - if (!$related instanceof Document) { - throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); - } - $this->relateDocuments( - $collection, - $relatedCollection, - $key, - $document, - $related, - $relationType, - $twoWay, - $twoWayKey, - $side, - ); - break; - case 'string': - $this->relateDocumentsById( - $collection, - $relatedCollection, - $key, - $document->getId(), - $related, - $relationType, - $twoWay, - $twoWayKey, - $side, - ); - break; - default: - throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); - } - } - $document->removeAttribute($key); - break; - - case 'object': - if (!$value instanceof Document) { - throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); - } - - if ($relationType === Database::RELATION_ONE_TO_ONE && !$twoWay && $side === Database::RELATION_SIDE_CHILD) { - throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); - } - - if ( - ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) || - ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) || - ($relationType === Database::RELATION_MANY_TO_MANY) - ) { - throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, document given.'); - } - - $relatedId = $this->relateDocuments( - $collection, - $relatedCollection, - $key, - $document, - $value, - $relationType, - $twoWay, - $twoWayKey, - $side, - ); - $document->setAttribute($key, $relatedId); - break; - - case 'string': - if ($relationType === Database::RELATION_ONE_TO_ONE && $twoWay === false && $side === Database::RELATION_SIDE_CHILD) { - throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); - } - - if ( - ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) || - ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) || - ($relationType === Database::RELATION_MANY_TO_MANY) - ) { - throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, document ID given.'); - } - - // Single document ID - $this->relateDocumentsById( - $collection, - $relatedCollection, - $key, - $document->getId(), - $value, - $relationType, - $twoWay, - $twoWayKey, - $side, - ); - break; - - case 'NULL': - // TODO: This might need to depend on the relation type, to be either set to null or removed? - - if ( - ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_CHILD) || - ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_PARENT) || - ($relationType === Database::RELATION_ONE_TO_ONE && $side === Database::RELATION_SIDE_PARENT) || - ($relationType === Database::RELATION_ONE_TO_ONE && $side === Database::RELATION_SIDE_CHILD && $twoWay === true) - ) { - break; - } - - $document->removeAttribute($key); - // No related document - break; - - default: - throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); - } - } finally { - \array_pop($this->relationshipWriteStack); - } - } - - return $document; - } - - /** - * @param Document $collection - * @param Document $relatedCollection - * @param string $key - * @param Document $document - * @param Document $relation - * @param string $relationType - * @param bool $twoWay - * @param string $twoWayKey - * @param string $side - * @return string related document ID - * - * @throws AuthorizationException - * @throws ConflictException - * @throws StructureException - * @throws Exception - */ - private function relateDocuments( - Document $collection, - Document $relatedCollection, - string $key, - Document $document, - Document $relation, - string $relationType, - bool $twoWay, - string $twoWayKey, - string $side, - ): string { - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - if ($twoWay) { - $relation->setAttribute($twoWayKey, $document->getId()); - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - $relation->setAttribute($twoWayKey, $document->getId()); - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - $relation->setAttribute($twoWayKey, $document->getId()); - } - break; - } - - // Try to get the related document - $related = $this->getDocument($relatedCollection->getId(), $relation->getId()); - - if ($related->isEmpty()) { - // If the related document doesn't exist, create it, inheriting permissions if none are set - if (!isset($relation['$permissions'])) { - $relation->setAttribute('$permissions', $document->getPermissions()); - } - - $related = $this->createDocument($relatedCollection->getId(), $relation); - } elseif ($related->getAttributes() != $relation->getAttributes()) { - // If the related document exists and the data is not the same, update it - foreach ($relation->getAttributes() as $attribute => $value) { - $related->setAttribute($attribute, $value); - } - - $related = $this->updateDocument($relatedCollection->getId(), $related->getId(), $related); - } - - if ($relationType === Database::RELATION_MANY_TO_MANY) { - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - $this->createDocument($junction, new Document([ - $key => $related->getId(), - $twoWayKey => $document->getId(), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ] - ])); - } - - return $related->getId(); - } - - /** - * @param Document $collection - * @param Document $relatedCollection - * @param string $key - * @param string $documentId - * @param string $relationId - * @param string $relationType - * @param bool $twoWay - * @param string $twoWayKey - * @param string $side - * @return void - * @throws AuthorizationException - * @throws ConflictException - * @throws StructureException - * @throws Exception - */ - private function relateDocumentsById( - Document $collection, - Document $relatedCollection, - string $key, - string $documentId, - string $relationId, - string $relationType, - bool $twoWay, - string $twoWayKey, - string $side, - ): void { - // Get the related document, will be empty on permissions failure - $related = $this->skipRelationships(fn () => $this->getDocument($relatedCollection->getId(), $relationId)); - - if ($related->isEmpty() && $this->checkRelationshipsExist) { - return; - } - - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - if ($twoWay) { - $related->setAttribute($twoWayKey, $documentId); - $this->skipRelationships(fn () => $this->updateDocument($relatedCollection->getId(), $relationId, $related)); - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - $related->setAttribute($twoWayKey, $documentId); - $this->skipRelationships(fn () => $this->updateDocument($relatedCollection->getId(), $relationId, $related)); - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { - $related->setAttribute($twoWayKey, $documentId); - $this->skipRelationships(fn () => $this->updateDocument($relatedCollection->getId(), $relationId, $related)); - } - break; - case Database::RELATION_MANY_TO_MANY: - $this->purgeCachedDocument($relatedCollection->getId(), $relationId); - - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - $this->skipRelationships(fn () => $this->createDocument($junction, new Document([ - $key => $relationId, - $twoWayKey => $documentId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ] - ]))); - break; - } - } - - /** - * Update Document - * - * @param string $collection - * @param string $id - * @param Document $document - * @return Document - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws StructureException - */ - public function updateDocument(string $collection, string $id, Document $document): Document - { - if (!$id) { - throw new DatabaseException('Must define $id attribute'); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - $newUpdatedAt = $document->getUpdatedAt(); - $hasOperators = false; - $document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt, &$hasOperators) { - $time = DateTime::now(); - $old = $this->authorization->skip(fn () => $this->silent( - fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) - )); - if ($old->isEmpty()) { - return new Document(); - } - - $skipPermissionsUpdate = true; - - if ($document->offsetExists('$permissions')) { - $originalPermissions = $old->getPermissions(); - $currentPermissions = $document->getPermissions(); - - sort($originalPermissions); - sort($currentPermissions); - - $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); - } - - // UID change - if ($document->offsetExists('$id') && $document->getId() !== $id) { - $skipPermissionsUpdate = false; - } - - $createdAt = $document->getCreatedAt(); - - $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); - $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID - $document['$sequence'] = $old->getSequence(); // Sequence is immutable - $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; - - if ($this->adapter->getSharedTables()) { - $tenant = $old->getTenant(); - $document['$tenant'] = $tenant; - $old->setAttribute('$tenant', $tenant); // Normalize for strict comparison - } - $document = new Document($document); - - // Ahead of change detection: a dropped attribute is never persisted, so - // counting it as a change would bump $updatedAt and fire an update event - // for a write that leaves the stored document identical. - $document = $this->removeUnknownAttributes($collection, $document); - - $attributes = $collection->getAttribute('attributes', []); - - $relationships = \array_filter($attributes, function ($attribute) { - return $attribute['type'] === Database::VAR_RELATIONSHIP; - }); - - $shouldUpdate = false; - - if ($collection->getId() !== self::METADATA) { - $documentSecurity = $collection->getAttribute('documentSecurity', false); - - foreach ($relationships as $relationship) { - $relationships[$relationship->getAttribute('key')] = $relationship; - } - - foreach ($document as $key => $value) { - if (Operator::isOperator($value)) { - $shouldUpdate = true; - break; - } - } - - // Compare if the document has any changes - foreach ($document as $key => $value) { - if (\array_key_exists($key, $relationships)) { - if (\count($this->relationshipWriteStack) >= Database::RELATION_MAX_DEPTH - 1) { - continue; - } - - $relationType = (string)$relationships[$key]['options']['relationType']; - $side = (string)$relationships[$key]['options']['side']; - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - $oldValue = $old->getAttribute($key) instanceof Document - ? $old->getAttribute($key)->getId() - : $old->getAttribute($key); - - if ((\is_null($value) !== \is_null($oldValue)) - || (\is_string($value) && $value !== $oldValue) - || ($value instanceof Document && $value->getId() !== $oldValue) - ) { - $shouldUpdate = true; - } - break; - case Database::RELATION_ONE_TO_MANY: - case Database::RELATION_MANY_TO_ONE: - case Database::RELATION_MANY_TO_MANY: - if ( - ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_PARENT) || - ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_CHILD) - ) { - $oldValue = $old->getAttribute($key) instanceof Document - ? $old->getAttribute($key)->getId() - : $old->getAttribute($key); - - if ((\is_null($value) !== \is_null($oldValue)) - || (\is_string($value) && $value !== $oldValue) - || ($value instanceof Document && $value->getId() !== $oldValue) - ) { - $shouldUpdate = true; - } - break; - } - - if (Operator::isOperator($value)) { - $shouldUpdate = true; - break; - } - - if (!\is_array($value) || !\array_is_list($value)) { - throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, ' . \gettype($value) . ' given.'); - } - - if (\count($old->getAttribute($key)) !== \count($value)) { - $shouldUpdate = true; - break; - } - - foreach ($value as $index => $relation) { - $oldValue = $old->getAttribute($key)[$index] instanceof Document - ? $old->getAttribute($key)[$index]->getId() - : $old->getAttribute($key)[$index]; - - if ( - (\is_string($relation) && $relation !== $oldValue) || - ($relation instanceof Document && $relation->getId() !== $oldValue) - ) { - $shouldUpdate = true; - break; - } - } - break; - } - - if ($shouldUpdate) { - break; - } - - continue; - } - - $oldValue = $old->getAttribute($key); - - if (!self::valuesEqual($value, $oldValue)) { - $shouldUpdate = true; - break; - } - } - - $updatePermissions = [ - ...$collection->getUpdate(), - ...($documentSecurity ? $old->getUpdate() : []) - ]; - - $readPermissions = [ - ...$collection->getRead(), - ...($documentSecurity ? $old->getRead() : []) - ]; - - if ($shouldUpdate) { - if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $updatePermissions))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } else { - if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } - } - - if ($shouldUpdate) { - $document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt); - } - - // Check if document was updated after the request timestamp - $oldUpdatedAt = new \DateTime($old->getUpdatedAt()); - if (!is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { - throw new ConflictException('Document was updated after the request timestamp'); - } - - $document = $this->encode($collection, $document); - - if ($this->validate) { - $structureValidator = new Structure( - $collection, - $this->adapter->getIdAttributeType(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - supportUnsignedBigInt: $this->adapter->getSupportForUnsignedBigInt(), - currentDocument: $old - ); - if (!$structureValidator->isValid($document)) { // Make sure updated structure still apply collection rules (if any) - throw new StructureException($structureValidator->getDescription()); - } - } - - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->updateDocumentRelationships($collection, $old, $document)); - } - - $document = $this->adapter->castingBefore($collection, $document); - - $this->adapter->updateDocument($collection, $id, $document, $skipPermissionsUpdate); - - $document = $this->adapter->castingAfter($collection, $document); - - $this->purgeCachedDocument($collection->getId(), $id); - - if ($document->getId() !== $id) { - $this->purgeCachedDocument($collection->getId(), $document->getId()); - } - - // If operators were used, refetch inside the transaction so the returned value reflects - // this operation's own write (read-your-writes). Refetching after commit could observe a - // concurrent update and return that value instead of the result of this operation. - foreach ($document->getArrayCopy() as $value) { - if (Operator::isOperator($value)) { - $hasOperators = true; - break; - } - } - - if ($hasOperators) { - $refetched = $this->refetchDocuments($collection, [$document]); - $document = $refetched[0]; - } - - return $document; - }); - - if ($document->isEmpty()) { - return $document; - } - - // Purge again after commit so readers cannot re-cache the pre-commit version - $this->purgeCachedDocumentInternal($collection->getId(), $id); - - if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth)); - $document = $documents[0]; - } - - // The operator refetch already returns a decoded document (via find()); decoding again - // would double-apply the decode filters. - if (!$hasOperators) { - $document = $this->decode($collection, $document); - } - - // Convert to custom document type if mapped - if (isset($this->documentTypes[$collection->getId()])) { - $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); - } - - $this->trigger(self::EVENT_DOCUMENT_UPDATE, $document); - - return $document; - } - - /** - * Update documents - * - * Updates all documents which match the given query. - * - * @param string $collection - * @param Document $updates - * @param array $queries - * @param int $batchSize - * @param (callable(Document $updated, Document $old): void)|null $onNext - * @param (callable(Throwable): void)|null $onError - * @return int - * @throws AuthorizationException - * @throws ConflictException - * @throws DuplicateException - * @throws QueryException - * @throws StructureException - * @throws TimeoutException - * @throws \Throwable - * @throws Exception - */ - public function updateDocuments( - string $collection, - Document $updates, - array $queries = [], - int $batchSize = self::INSERT_BATCH_SIZE, - ?callable $onNext = null, - ?callable $onError = null, - ): int { - if ($updates->isEmpty()) { - return 0; - } - - $batchSize = \min(Database::INSERT_BATCH_SIZE, \max(1, $batchSize)); - $collection = $this->silent(fn () => $this->getCollection($collection)); - if ($collection->isEmpty()) { - throw new DatabaseException('Collection not found'); - } - - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $collection->getUpdate())); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); - - $this->checkQueryTypes($queries); - - if ($this->validate) { - $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() - ); - - if (!$validator->isValid($queries)) { - throw new QueryException($validator->getDescription()); - } - } - - $grouped = Query::groupByType($queries); - $limit = $grouped['limit']; - $cursor = $grouped['cursor']; - - if (!empty($cursor) && $cursor->getCollection() !== $collection->getId()) { - throw new DatabaseException("Cursor document must be from the same Collection."); - } - - unset($updates['$id']); - unset($updates['$tenant']); - - if (($updates->getCreatedAt() === null || !$this->preserveDates)) { - unset($updates['$createdAt']); - } else { - $updates['$createdAt'] = $updates->getCreatedAt(); - } - - if ($this->adapter->getSharedTables()) { - $updates['$tenant'] = $this->adapter->getTenant(); - } - - $updatedAt = $updates->getUpdatedAt(); - $updates['$updatedAt'] = ($updatedAt === null || !$this->preserveDates) ? DateTime::now() : $updatedAt; - - $updates = $this->encode( - $collection, - $updates, - applyDefaults: false - ); - - if ($this->validate) { - $validator = new PartialStructure( - $collection, - $this->adapter->getIdAttributeType(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - supportUnsignedBigInt: $this->adapter->getSupportForUnsignedBigInt(), - currentDocument: null // No old document available in bulk updates - ); - - if (!$validator->isValid($updates)) { - throw new StructureException($validator->getDescription()); - } - } - - $originalLimit = $limit; - $last = $cursor; - $modified = 0; - - while (true) { - if ($limit && $limit < $batchSize) { - $batchSize = $limit; - } elseif (!empty($limit)) { - $limit -= $batchSize; - } - - $new = [ - Query::limit($batchSize) - ]; - - if (!empty($last)) { - $new[] = Query::cursorAfter($last); - } - - $batch = $this->silent(fn () => $this->find( - $collection->getId(), - array_merge($new, $queries), - forPermission: Database::PERMISSION_UPDATE - )); - - if (empty($batch)) { - break; - } - - $old = array_map(fn ($doc) => clone $doc, $batch); - $currentPermissions = $updates->getPermissions(); - sort($currentPermissions); - - $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions) { - foreach ($batch as $index => $document) { - $skipPermissionsUpdate = true; - - if ($updates->offsetExists('$permissions')) { - if (!$document->offsetExists('$permissions')) { - throw new QueryException('Permission document missing in select'); - } - - $originalPermissions = $document->getPermissions(); - - \sort($originalPermissions); - - $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); - } - - $document->setAttribute('$skipPermissionsUpdate', $skipPermissionsUpdate); - - $new = new Document(\array_merge($document->getArrayCopy(), $updates->getArrayCopy())); - - if ($this->resolveRelationships) { - $this->silent(fn () => $this->updateDocumentRelationships($collection, $document, $new)); - } - - $document = $new; - - // Check if document was updated after the request timestamp - try { - $oldUpdatedAt = new \DateTime($document->getUpdatedAt()); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } - - if (!is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { - throw new ConflictException('Document was updated after the request timestamp'); - } - $encoded = $this->encode($collection, $document); - $batch[$index] = $this->adapter->castingBefore($collection, $encoded); - } - - $this->adapter->updateDocuments( - $collection, - $updates, - $batch - ); - }); - - $updates = $this->adapter->castingBefore($collection, $updates); - - $hasOperators = false; - foreach ($updates->getArrayCopy() as $value) { - if (Operator::isOperator($value)) { - $hasOperators = true; - break; - } - } - - if ($hasOperators) { - $batch = $this->refetchDocuments($collection, $batch, $grouped['selections']); - } - - foreach ($batch as $index => $doc) { - $doc = $this->adapter->castingAfter($collection, $doc); - $doc->removeAttribute('$skipPermissionsUpdate'); - $this->purgeCachedDocument($collection->getId(), $doc->getId()); - // The operator refetch goes through find(), which already returns fully decoded - // documents. Decoding again would double-apply the decode filters (and, because - // this call passes no selections, re-materialize non-selected attributes). - if (!$hasOperators) { - $doc = $this->decode($collection, $doc); - } - try { - $onNext && $onNext($doc, $old[$index]); - } catch (Throwable $th) { - $onError ? $onError($th) : throw $th; - } - $modified++; - } - - if (count($batch) < $batchSize) { - break; - } elseif ($originalLimit && $modified == $originalLimit) { - break; - } - - $last = \end($batch); - } - - $this->trigger(self::EVENT_DOCUMENTS_UPDATE, new Document([ - '$collection' => $collection->getId(), - 'modified' => $modified - ])); - - return $modified; - } - - /** - * @param Document $collection - * @param Document $old - * @param Document $document - * - * @return Document - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws DuplicateException - * @throws StructureException - */ - private function updateDocumentRelationships(Document $collection, Document $old, Document $document): Document - { - $attributes = $collection->getAttribute('attributes', []); - - $relationships = \array_filter($attributes, function ($attribute) { - return $attribute['type'] === Database::VAR_RELATIONSHIP; - }); - - $stackCount = count($this->relationshipWriteStack); - - foreach ($relationships as $index => $relationship) { - /** @var string $key */ - $key = $relationship['key']; - $value = $document->getAttribute($key); - $oldValue = $old->getAttribute($key); - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - $relationType = (string)$relationship['options']['relationType']; - $twoWay = (bool)$relationship['options']['twoWay']; - $twoWayKey = (string)$relationship['options']['twoWayKey']; - $side = (string)$relationship['options']['side']; - - if (Operator::isOperator($value)) { - $operator = $value; - if ($operator->isArrayOperation()) { - $existingIds = []; - if (\is_array($oldValue)) { - $existingIds = \array_map(function ($item) { - if ($item instanceof Document) { - return $item->getId(); - } - return $item; - }, $oldValue); - } - - $value = $this->applyRelationshipOperator($operator, $existingIds); - $document->setAttribute($key, $value); - } - } - - if ($oldValue == $value) { - if ( - ($relationType === Database::RELATION_ONE_TO_ONE - || ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_PARENT)) && - $value instanceof Document - ) { - $document->setAttribute($key, $value->getId()); - continue; - } - $document->removeAttribute($key); - continue; - } - - if ($stackCount >= Database::RELATION_MAX_DEPTH - 1 && $this->relationshipWriteStack[$stackCount - 1] !== $relatedCollection->getId()) { - $document->removeAttribute($key); - continue; - } - - $this->relationshipWriteStack[] = $collection->getId(); - - try { - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - if (!$twoWay) { - if ($side === Database::RELATION_SIDE_CHILD) { - throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); - } - - if (\is_string($value)) { - $related = $this->skipRelationships(fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select(['$id'])])); - if ($related->isEmpty()) { - // If no such document exists in related collection - // For one-one we need to update the related key to null if no relation exists - $document->setAttribute($key, null); - } - } elseif ($value instanceof Document) { - $relationId = $this->relateDocuments( - $collection, - $relatedCollection, - $key, - $document, - $value, - $relationType, - false, - $twoWayKey, - $side, - ); - $document->setAttribute($key, $relationId); - } elseif (is_array($value)) { - throw new RelationshipException('Invalid relationship value. Must be either a document, document ID or null. Array given.'); - } - - break; - } - - switch (\gettype($value)) { - case 'string': - $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select(['$id'])]) - ); - - if ($related->isEmpty()) { - // If no such document exists in related collection - // For one-one we need to update the related key to null if no relation exists - $document->setAttribute($key, null); - break; - } - if ( - $oldValue?->getId() !== $value - && !($this->skipRelationships(fn () => $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), - Query::equal($twoWayKey, [$value]), - ]))->isEmpty()) - ) { - // Have to do this here because otherwise relations would be updated before the database can throw the unique violation - throw new DuplicateException('Document already has a related document'); - } - - $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - $related->setAttribute($twoWayKey, $document->getId()) - )); - break; - case 'object': - if ($value instanceof Document) { - $related = $this->skipRelationships(fn () => $this->getDocument($relatedCollection->getId(), $value->getId())); - - if ( - $oldValue?->getId() !== $value->getId() - && !($this->skipRelationships(fn () => $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), - Query::equal($twoWayKey, [$value->getId()]), - ]))->isEmpty()) - ) { - // Have to do this here because otherwise relations would be updated before the database can throw the unique violation - throw new DuplicateException('Document already has a related document'); - } - - $this->relationshipWriteStack[] = $relatedCollection->getId(); - if ($related->isEmpty()) { - if (!isset($value['$permissions'])) { - $value->setAttribute('$permissions', $document->getAttribute('$permissions')); - } - $related = $this->createDocument( - $relatedCollection->getId(), - $value->setAttribute($twoWayKey, $document->getId()) - ); - } else { - $related = $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - $value->setAttribute($twoWayKey, $document->getId()) - ); - } - \array_pop($this->relationshipWriteStack); - - $document->setAttribute($key, $related->getId()); - break; - } - // no break - case 'NULL': - if (!\is_null($oldValue?->getId())) { - $oldRelated = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $oldValue->getId()) - ); - $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $oldRelated->getId(), - new Document([$twoWayKey => null]) - )); - } - break; - default: - throw new RelationshipException('Invalid relationship value. Must be either a document, document ID or null.'); - } - break; - case Database::RELATION_ONE_TO_MANY: - case Database::RELATION_MANY_TO_ONE: - if ( - ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) || - ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) - ) { - if (!\is_array($value) || !\array_is_list($value)) { - throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, ' . \gettype($value) . ' given.'); - } - - $oldIds = \array_map(fn ($document) => $document->getId(), $oldValue); - - $newIds = \array_map(function ($item) { - if (\is_string($item)) { - return $item; - } elseif ($item instanceof Document) { - return $item->getId(); - } else { - throw new RelationshipException('Invalid relationship value. Must be either a document or document ID.'); - } - }, $value); - - $removedDocuments = \array_diff($oldIds, $newIds); - - foreach ($removedDocuments as $relation) { - $this->authorization->skip(fn () => $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $relation, - new Document([$twoWayKey => null]) - ))); - } - - foreach ($value as $relation) { - if (\is_string($relation)) { - $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $relation, [Query::select(['$id'])]) - ); - - if ($related->isEmpty()) { - continue; - } - - $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - $related->setAttribute($twoWayKey, $document->getId()) - )); - } elseif ($relation instanceof Document) { - $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select(['$id'])]) - ); - - if ($related->isEmpty()) { - if (!isset($relation['$permissions'])) { - $relation->setAttribute('$permissions', $document->getAttribute('$permissions')); - } - $this->createDocument( - $relatedCollection->getId(), - $relation->setAttribute($twoWayKey, $document->getId()) - ); - } else { - $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - $relation->setAttribute($twoWayKey, $document->getId()) - ); - } - } else { - throw new RelationshipException('Invalid relationship value.'); - } - } - - $document->removeAttribute($key); - break; - } - - if (\is_string($value)) { - $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $value, [Query::select(['$id'])]) - ); - - if ($related->isEmpty()) { - // If no such document exists in related collection - // For many-one we need to update the related key to null if no relation exists - $document->setAttribute($key, null); - } - $this->purgeCachedDocument($relatedCollection->getId(), $value); - } elseif ($value instanceof Document) { - $related = $this->skipRelationships( - fn () => $this->getDocument($relatedCollection->getId(), $value->getId(), [Query::select(['$id'])]) - ); - - if ($related->isEmpty()) { - if (!isset($value['$permissions'])) { - $value->setAttribute('$permissions', $document->getAttribute('$permissions')); - } - $this->createDocument( - $relatedCollection->getId(), - $value - ); - } elseif ($related->getAttributes() != $value->getAttributes()) { - $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - $value - ); - $this->purgeCachedDocument($relatedCollection->getId(), $related->getId()); - } - - $document->setAttribute($key, $value->getId()); - } elseif (\is_null($value)) { - break; - } elseif (is_array($value)) { - throw new RelationshipException('Invalid relationship value. Must be either a document ID or a document, array given.'); - } elseif (empty($value)) { - throw new RelationshipException('Invalid relationship value. Must be either a document ID or a document.'); - } else { - throw new RelationshipException('Invalid relationship value.'); - } - - break; - case Database::RELATION_MANY_TO_MANY: - if (\is_null($value)) { - break; - } - if (!\is_array($value)) { - throw new RelationshipException('Invalid relationship value. Must be an array of documents or document IDs.'); - } - - $oldIds = \array_map(fn ($document) => $document->getId(), $oldValue); - - $newIds = \array_map(function ($item) { - if (\is_string($item)) { - return $item; - } elseif ($item instanceof Document) { - return $item->getId(); - } else { - throw new RelationshipException('Invalid relationship value. Must be either a document or document ID.'); - } - }, $value); - - $removedDocuments = \array_diff($oldIds, $newIds); - - foreach ($removedDocuments as $relation) { - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - $junctions = $this->find($junction, [ - Query::equal($key, [$relation]), - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX) - ]); - - foreach ($junctions as $junction) { - $this->authorization->skip(fn () => $this->deleteDocument($junction->getCollection(), $junction->getId())); - } - } - - foreach ($value as $relation) { - if (\is_string($relation)) { - if (\in_array($relation, $oldIds) || $this->getDocument($relatedCollection->getId(), $relation, [Query::select(['$id'])])->isEmpty()) { - continue; - } - } elseif ($relation instanceof Document) { - $related = $this->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select(['$id'])]); - - if ($related->isEmpty()) { - if (!isset($value['$permissions'])) { - $relation->setAttribute('$permissions', $document->getAttribute('$permissions')); - } - $related = $this->createDocument( - $relatedCollection->getId(), - $relation - ); - } elseif ($related->getAttributes() != $relation->getAttributes()) { - $related = $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - $relation - ); - } - - if (\in_array($relation->getId(), $oldIds)) { - continue; - } - - $relation = $related->getId(); - } else { - throw new RelationshipException('Invalid relationship value. Must be either a document or document ID.'); - } - - $this->skipRelationships(fn () => $this->createDocument( - $this->getJunctionCollection($collection, $relatedCollection, $side), - new Document([ - $key => $relation, - $twoWayKey => $document->getId(), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ]) - )); - } - - $document->removeAttribute($key); - break; - } - } finally { - \array_pop($this->relationshipWriteStack); - } - } - - return $document; - } - - private function getJunctionCollection(Document $collection, Document $relatedCollection, string $side): string - { - return $side === Database::RELATION_SIDE_PARENT - ? '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() - : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); - } - - /** - * Apply an operator to a relationship array of IDs - * - * @param Operator $operator - * @param array $existingIds - * @return array - */ - private function applyRelationshipOperator(Operator $operator, array $existingIds): array - { - $method = $operator->getMethod(); - $values = $operator->getValues(); - - // Extract IDs from operator values (could be strings or Documents) - $valueIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $values)); - - switch ($method) { - case Operator::TYPE_ARRAY_APPEND: - return \array_values(\array_merge($existingIds, $valueIds)); - - case Operator::TYPE_ARRAY_PREPEND: - return \array_values(\array_merge($valueIds, $existingIds)); - - case Operator::TYPE_ARRAY_INSERT: - $index = $values[0] ?? 0; - $item = $values[1] ?? null; - $itemId = $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null); - if ($itemId !== null) { - \array_splice($existingIds, $index, 0, [$itemId]); - } - return \array_values($existingIds); - - case Operator::TYPE_ARRAY_REMOVE: - $toRemove = $values[0] ?? null; - if (\is_array($toRemove)) { - $toRemoveIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $toRemove)); - return \array_values(\array_diff($existingIds, $toRemoveIds)); - } - $toRemoveId = $toRemove instanceof Document ? $toRemove->getId() : (\is_string($toRemove) ? $toRemove : null); - if ($toRemoveId !== null) { - return \array_values(\array_diff($existingIds, [$toRemoveId])); - } - return $existingIds; - - case Operator::TYPE_ARRAY_UNIQUE: - return \array_values(\array_unique($existingIds)); - - case Operator::TYPE_ARRAY_INTERSECT: - return \array_values(\array_intersect($existingIds, $valueIds)); - - case Operator::TYPE_ARRAY_DIFF: - return \array_values(\array_diff($existingIds, $valueIds)); - - default: - return $existingIds; - } - } - - /** - * Create or update a document. - * - * @param string $collection - * @param Document $document - * @return Document - * @throws StructureException - * @throws Throwable - */ - public function upsertDocument( - string $collection, - Document $document, - ): Document { - $result = null; - - $this->upsertDocumentsWithIncrease( - $collection, - '', - [$document], - function (Document $doc, ?Document $_old = null) use (&$result) { - $result = $doc; - } - ); - - if ($result === null) { - // No-op (unchanged): return the current persisted doc - $result = $this->getDocument($collection, $document->getId()); - } - return $result; - } - - /** - * Create or update documents. - * - * @param string $collection - * @param array $documents - * @param int $batchSize - * @param (callable(Document, ?Document): void)|null $onNext - * @param (callable(Throwable): void)|null $onError - * @return int - * @throws StructureException - * @throws \Throwable - */ - public function upsertDocuments( - string $collection, - array $documents, - int $batchSize = self::INSERT_BATCH_SIZE, - ?callable $onNext = null, - ?callable $onError = null - ): int { - return $this->upsertDocumentsWithIncrease( - $collection, - '', - $documents, - $onNext, - $onError, - $batchSize - ); - } - - /** - * Create or update documents, increasing the value of the given attribute by the value in each document. - * - * @param string $collection - * @param string $attribute - * @param array $documents - * @param (callable(Document, ?Document): void)|null $onNext - * @param (callable(Throwable): void)|null $onError - * @param int $batchSize - * @return int - * @throws StructureException - * @throws \Throwable - * @throws Exception - */ - public function upsertDocumentsWithIncrease( - string $collection, - string $attribute, - array $documents, - ?callable $onNext = null, - ?callable $onError = null, - int $batchSize = self::INSERT_BATCH_SIZE - ): int { - if (empty($documents)) { - return 0; - } - - $batchSize = \min(Database::INSERT_BATCH_SIZE, \max(1, $batchSize)); - $collection = $this->silent(fn () => $this->getCollection($collection)); - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $collectionAttributes = $collection->getAttribute('attributes', []); - $time = DateTime::now(); - $created = 0; - $updated = 0; - $seenIds = []; - $hasRelationships = !empty(\array_filter( - $collectionAttributes, - fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP - )); - - // Batch-fetch existing documents in one query instead of N individual getDocument() calls. - // tenantPerDocument: group ids by tenant and run one find() per tenant under withTenant, - // so cross-tenant batches (e.g. StatsUsage worker) don't get silently scoped to the - // session tenant and miss rows belonging to other tenants. - $existingDocs = []; - - if ($this->getSharedTables() && $this->getTenantPerDocument()) { - $idsByTenant = []; - foreach ($documents as $doc) { - if ($doc->getId() !== '') { - $idsByTenant[$doc->getTenant()][] = $doc->getId(); - } - } - foreach ($idsByTenant as $tenant => $tenantIds) { - $tenantIds = \array_values(\array_unique($tenantIds)); - foreach (\array_chunk($tenantIds, \max(1, $this->maxQueryValues)) as $chunk) { - $found = $this->authorization->skip(fn () => $this->withTenant($tenant, fn () => $this->silent( - fn () => $this->find($collection->getId(), [ - Query::equal('$id', $chunk), - Query::limit($this->maxQueryValues), - ]) - ))); - foreach ($found as $doc) { - $existingDocs[$this->tenantKey($doc)] = $doc; - } - } - } - } else { - $docIds = \array_values(\array_unique(\array_filter( - \array_map(fn (Document $doc) => $doc->getId(), $documents), - fn ($id) => $id !== '' - ))); - - if (!empty($docIds)) { - foreach (\array_chunk($docIds, \max(1, $this->maxQueryValues)) as $chunk) { - $existing = $this->authorization->skip(fn () => $this->silent( - fn () => $this->find($collection->getId(), [ - Query::equal('$id', $chunk), - Query::limit($this->maxQueryValues), - ]) - )); - foreach ($existing as $doc) { - $existingDocs[$this->tenantKey($doc)] = $doc; - } - } - } - } - - foreach ($documents as $key => $document) { - $old = $existingDocs[$this->tenantKey($document)] ?? new Document(); - - $document = $this->removeUnknownAttributes($collection, $document); - - // Extract operators early to avoid comparison issues - $documentArray = $document->getArrayCopy(); - $extracted = Operator::extractOperators($documentArray); - $operators = $extracted['operators']; - $regularUpdates = $extracted['updates']; - - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - self::INTERNAL_ATTRIBUTES - ); - - $regularUpdatesUserOnly = \array_diff_key($regularUpdates, \array_flip($internalKeys)); - - $skipPermissionsUpdate = true; - - if ($document->offsetExists('$permissions')) { - $originalPermissions = $old->getPermissions(); - $currentPermissions = $document->getPermissions(); - - sort($originalPermissions); - sort($currentPermissions); - - $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); - } - - // Only skip if no operators and regular attributes haven't changed - $hasChanges = false; - if (!empty($operators)) { - $hasChanges = true; - } elseif (!empty($attribute)) { - $hasChanges = true; - } elseif (!$skipPermissionsUpdate) { - $hasChanges = true; - } else { - // Check if any of the provided attributes differ from old document - $oldAttributes = $old->getAttributes(); - foreach ($regularUpdatesUserOnly as $attrKey => $value) { - $oldValue = $oldAttributes[$attrKey] ?? null; - if ($oldValue != $value) { - $hasChanges = true; - break; - } - } - - // Also check if old document has attributes that new document doesn't - if (!$hasChanges) { - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - self::INTERNAL_ATTRIBUTES - ); - - $oldUserAttributes = array_diff_key($oldAttributes, array_flip($internalKeys)); - - foreach (array_keys($oldUserAttributes) as $oldAttrKey) { - if (!array_key_exists($oldAttrKey, $regularUpdatesUserOnly)) { - // Old document has an attribute that new document doesn't - $hasChanges = true; - break; - } - } - } - } - - if (!$hasChanges) { - // If not updating a single attribute and the document is the same as the old one, skip it - unset($documents[$key]); - continue; - } - - // If old is empty, check if user has create permission on the collection - // If old is not empty, check if user has update permission on the collection - // If old is not empty AND documentSecurity is enabled, check if user has update permission on the collection or document - - - if ($old->isEmpty()) { - if (!$this->authorization->isValid(new Input(self::PERMISSION_CREATE, $collection->getCreate()))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } elseif (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, [ - ...$collection->getUpdate(), - ...($documentSecurity ? $old->getUpdate() : []) - ]))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - - $updatedAt = $document->getUpdatedAt(); - - $document - ->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId()) - ->setAttribute('$collection', $collection->getId()) - ->setAttribute('$updatedAt', ($updatedAt === null || !$this->preserveDates) ? $time : $updatedAt); - - if (!$this->preserveSequence) { - $document->removeAttribute('$sequence'); - } - - $createdAt = $document->getCreatedAt(); - if ($createdAt === null || !$this->preserveDates) { - $document->setAttribute('$createdAt', $old->isEmpty() ? $time : $old->getCreatedAt()); - } else { - $document->setAttribute('$createdAt', $createdAt); - } - - // Force matching optional parameter sets - // Doesn't use decode as that intentionally skips null defaults to reduce payload size - foreach ($collectionAttributes as $attr) { - if (!$attr->getAttribute('required') && !\array_key_exists($attr['$id'], (array)$document)) { - $document->setAttribute( - $attr['$id'], - $old->getAttribute($attr['$id'], ($attr['default'] ?? null)) - ); - } - } - - if ($skipPermissionsUpdate) { - $document->setAttribute('$permissions', $old->getPermissions()); - } - - if ($this->adapter->getSharedTables()) { - if ($this->adapter->getTenantPerDocument()) { - if ($document->getTenant() === null) { - throw new DatabaseException('Missing tenant. Tenant must be set when tenant per document is enabled.'); - } - if (!$old->isEmpty() && $old->getTenant() != $document->getTenant()) { - throw new DatabaseException('Tenant cannot be changed.'); - } - } else { - $document->setAttribute('$tenant', $this->adapter->getTenant()); - } - } - - $document = $this->encode($collection, $document); - - if ($this->validate) { - $validator = new Structure( - $collection, - $this->adapter->getIdAttributeType(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - supportUnsignedBigInt: $this->adapter->getSupportForUnsignedBigInt(), - currentDocument: $old->isEmpty() ? null : $old - ); - - if (!$validator->isValid($document)) { - throw new StructureException($validator->getDescription()); - } - } - - if (!$old->isEmpty()) { - // Check if document was updated after the request timestamp - try { - $oldUpdatedAt = new \DateTime($old->getUpdatedAt()); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } - - if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { - throw new ConflictException('Document was updated after the request timestamp'); - } - } - - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); - } - - $seenIds[] = $this->tenantKey($document); - $old = $this->adapter->castingBefore($collection, $old); - $document = $this->adapter->castingBefore($collection, $document); - - $documents[$key] = new Change( - old: $old, - new: $document - ); - } - - // Required because *some* DBs will allow duplicate IDs for upsert - if (\count($seenIds) !== \count(\array_unique($seenIds))) { - throw new DuplicateException('Duplicate document IDs found in the input array.'); - } - - foreach (\array_chunk($documents, $batchSize) as $chunk) { - /** - * @var array $chunk - */ - $batch = $this->withTransaction(fn () => $this->authorization->skip(fn () => $this->adapter->upsertDocuments( - $collection, - $attribute, - $chunk - ))); - - // Every row that already existed was read into $existingDocs above, so its - // sequence is already in hand and does not need fetching a second time. - foreach ($batch as $index => $doc) { - if (empty($doc->getSequence()) && !empty($chunk[$index]->getOld()->getSequence())) { - $doc->setAttribute('$sequence', $chunk[$index]->getOld()->getSequence()); - } - } - - // Nothing leaves this method except through $onNext -- the return value is a - // count -- so a caller that passes none never sees these documents, and the work - // that finishes them has no reader. Bulk writers such as the usage/stats workers - // upsert several batches a second and read nothing back. - if ($onNext !== null || $hasRelationships) { - $batch = $this->adapter->getSequences($collection->getId(), $batch); - } - - foreach ($chunk as $change) { - if ($change->getOld()->isEmpty()) { - $created++; - } else { - $updated++; - } - } - - if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { - $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); - } - - // Check if any document in the batch contains operators - $hasOperators = false; - foreach ($batch as $doc) { - $extracted = Operator::extractOperators($doc->getArrayCopy()); - if (!empty($extracted['operators'])) { - $hasOperators = true; - break; - } - } - - // Refetching only exists to hand computed operator values back to the caller, and - // $onNext is the only way anything leaves this method -- the return value is a - // count. $hasOperators still has to reflect the batch, because the decode below - // keys off it. - if ($hasOperators && $onNext !== null) { - $batch = $this->refetchDocuments($collection, $batch); - } - - foreach ($batch as $index => $doc) { - $doc = $this->adapter->castingAfter($collection, $doc); - if (!$hasOperators) { - $doc = $this->decode($collection, $doc); - } - - $this->withDocumentTenant($doc, fn () => $this->purgeCachedDocument($collection->getId(), $doc->getId())); - - $old = $chunk[$index]->getOld(); - - if (!$old->isEmpty()) { - $old = $this->adapter->castingAfter($collection, $old); - } - - try { - $onNext && $onNext($doc, $old->isEmpty() ? null : $old); - } catch (\Throwable $th) { - $onError ? $onError($th) : throw $th; - } - } - } - - $this->trigger(self::EVENT_DOCUMENTS_UPSERT, new Document([ - '$collection' => $collection->getId(), - 'created' => $created, - 'updated' => $updated, - ])); - - return $created + $updated; - } - - /** - * Increase a document attribute by a value - * - * @param string $collection The collection ID - * @param string $id The document ID - * @param string $attribute The attribute to increase - * @param int|float $value The value to increase the attribute by, can be a float - * @param int|float|null $max The maximum value the attribute can reach after the increase, null means no limit - * @return Document - * @throws AuthorizationException - * @throws DatabaseException - * @throws LimitException - * @throws NotFoundException - * @throws TypeException - * @throws \Throwable - */ - public function increaseDocumentAttribute( - string $collection, - string $id, - string $attribute, - int|float $value = 1, - int|float|null $max = null - ): Document { - if ($value <= 0) { // Can be a float - throw new \InvalidArgumentException('Value must be numeric and greater than 0'); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - if ($this->adapter->getSupportForAttributes()) { - $attr = \array_filter($collection->getAttribute('attributes', []), function ($a) use ($attribute) { - return $a['$id'] === $attribute; - }); - - if (empty($attr)) { - throw new NotFoundException('Attribute not found'); - } - - $whiteList = [ - self::VAR_INTEGER, - self::VAR_BIGINT, - self::VAR_FLOAT - ]; - - /** @var Document $attr */ - $attr = \end($attr); - if (!\in_array($attr->getAttribute('type'), $whiteList) || $attr->getAttribute('array')) { - throw new TypeException('Attribute must be an integer or float and can not be an array.'); - } - } - - $document = $this->withTransaction(function () use ($collection, $id, $attribute, $value, $max) { - /* @var $document Document */ - $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument($collection->getId(), $id, forUpdate: true))); // Skip ensures user does not need read permission for this - - if ($document->isEmpty()) { - throw new NotFoundException('Document not found'); - } - - if ($collection->getId() !== self::METADATA) { - $documentSecurity = $collection->getAttribute('documentSecurity', false); - - if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, [ - ...$collection->getUpdate(), - ...($documentSecurity ? $document->getUpdate() : []) - ]))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } - - if (!\is_null($max) && ($document->getAttribute($attribute) + $value > $max)) { - throw new LimitException('Attribute value exceeds maximum limit: ' . $max); - } - - $time = DateTime::now(); - $updatedAt = $document->getUpdatedAt(); - $updatedAt = (empty($updatedAt) || !$this->preserveDates) ? $time : DateTime::format(new \DateTime($updatedAt)); - $max = $max ? $max - $value : null; - - $this->adapter->increaseDocumentAttribute( - $collection->getId(), - $id, - $attribute, - $value, - $updatedAt, - max: $max - ); - - return $document->setAttribute( - $attribute, - $document->getAttribute($attribute) + $value - ); - }); - - $this->purgeCachedDocument($collection->getId(), $id); - - $this->trigger(self::EVENT_DOCUMENT_INCREASE, $document); - - return $document; - } - - - /** - * Decrease a document attribute by a value - * - * @param string $collection - * @param string $id - * @param string $attribute - * @param int|float $value - * @param int|float|null $min - * @return Document - * - * @throws AuthorizationException - * @throws DatabaseException - */ - public function decreaseDocumentAttribute( - string $collection, - string $id, - string $attribute, - int|float $value = 1, - int|float|null $min = null - ): Document { - if ($value <= 0) { // Can be a float - throw new \InvalidArgumentException('Value must be numeric and greater than 0'); - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($this->adapter->getSupportForAttributes()) { - $attr = \array_filter($collection->getAttribute('attributes', []), function ($a) use ($attribute) { - return $a['$id'] === $attribute; - }); - - if (empty($attr)) { - throw new NotFoundException('Attribute not found'); - } - - $whiteList = [ - self::VAR_INTEGER, - self::VAR_BIGINT, - self::VAR_FLOAT - ]; - - /** - * @var Document $attr - */ - $attr = \end($attr); - if (!\in_array($attr->getAttribute('type'), $whiteList) || $attr->getAttribute('array')) { - throw new TypeException('Attribute must be an integer or float and can not be an array.'); - } - } - - $document = $this->withTransaction(function () use ($collection, $id, $attribute, $value, $min) { - /* @var $document Document */ - $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument($collection->getId(), $id, forUpdate: true))); // Skip ensures user does not need read permission for this - - if ($document->isEmpty()) { - throw new NotFoundException('Document not found'); - } - - if ($collection->getId() !== self::METADATA) { - $documentSecurity = $collection->getAttribute('documentSecurity', false); - - if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, [ - ...$collection->getUpdate(), - ...($documentSecurity ? $document->getUpdate() : []) - ]))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } - - if (!\is_null($min) && ($document->getAttribute($attribute) - $value < $min)) { - throw new LimitException('Attribute value exceeds minimum limit: ' . $min); - } - - $time = DateTime::now(); - $updatedAt = $document->getUpdatedAt(); - $updatedAt = (empty($updatedAt) || !$this->preserveDates) ? $time : DateTime::format(new \DateTime($updatedAt)); - $min = $min ? $min + $value : null; - - $this->adapter->increaseDocumentAttribute( - $collection->getId(), - $id, - $attribute, - $value * -1, - $updatedAt, - min: $min - ); - - return $document->setAttribute( - $attribute, - $document->getAttribute($attribute) - $value - ); - }); - - $this->purgeCachedDocument($collection->getId(), $id); - - $this->trigger(self::EVENT_DOCUMENT_DECREASE, $document); - - return $document; - } - - /** - * Delete Document - * - * Also fires EVENT_DOCUMENT_UPDATE for each document on the other side of a two-way relationship that the delete changed. - * - * @param string $collection - * @param string $id - * - * @return bool - * - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws RestrictedException - */ - public function deleteDocument(string $collection, string $id): bool - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - $related = []; - - $deleted = $this->withTransaction(function () use ($collection, $id, &$document, &$related) { - $document = $this->authorization->skip(fn () => $this->silent( - fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) - )); - - if ($document->isEmpty()) { - return false; - } - - if ($collection->getId() !== self::METADATA) { - $documentSecurity = $collection->getAttribute('documentSecurity', false); - - if (!$this->authorization->isValid(new Input(self::PERMISSION_DELETE, [ - ...$collection->getDelete(), - ...($documentSecurity ? $document->getDelete() : []) - ]))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } - - // Check if document was updated after the request timestamp - try { - $oldUpdatedAt = new \DateTime($document->getUpdatedAt()); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } - - if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { - throw new ConflictException('Document was updated after the request timestamp'); - } - - if ($this->resolveRelationships) { - // A delete made while silenced, like a cascade's, has no one to report to - $report = $this->silentListeners !== null; - $related = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document, $report)); - } - - $result = $this->adapter->deleteDocument($collection->getId(), $id); - - $this->purgeCachedDocument($collection->getId(), $id); - - return $result; - }); - - if ($deleted) { - // Purge again after commit so readers cannot re-cache the pre-commit version - $this->purgeCachedDocumentInternal($collection->getId(), $id); - $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); - - foreach ($related as $relation) { - $this->trigger(self::EVENT_DOCUMENT_UPDATE, $relation); - } - } - - return $deleted; - } - - /** - * @param Document $collection - * @param Document $document - * @param bool $report - * @return array The two-way related documents left changed, when $report is set - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws RestrictedException - * @throws StructureException - */ - private function deleteDocumentRelationships(Document $collection, Document $document, bool $report = false): array - { - $related = []; - $cascaded = false; - - $attributes = $collection->getAttribute('attributes', []); - - $relationships = \array_filter($attributes, function ($attribute) { - return $attribute['type'] === Database::VAR_RELATIONSHIP; - }); - - foreach ($relationships as $relationship) { - $key = $relationship['key']; - $value = $document->getAttribute($key); - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - $relationType = $relationship['options']['relationType']; - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - $onDelete = $relationship['options']['onDelete']; - $side = $relationship['options']['side']; - - $relationship->setAttribute('collection', $collection->getId()); - $relationship->setAttribute('document', $document->getId()); - - // This side holds the key, so deleting it takes the reference with it and nothing writes the other side - $holdsKey = - ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_CHILD) || - ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_PARENT); - - // Whether the other side survives this delete without being written to - $unwritten = false; - - switch ($onDelete) { - case Database::RELATION_MUTATE_RESTRICT: - $this->deleteRestrict($relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side); - $unwritten = true; - break; - case Database::RELATION_MUTATE_SET_NULL: - $updated = $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side); - - if ($twoWay) { - foreach ($updated as $relation) { - $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; - } - } - - $unwritten = $holdsKey || $relationType === Database::RELATION_MANY_TO_MANY; - break; - case Database::RELATION_MUTATE_CASCADE: - $unwritten = $holdsKey || ($relationType === Database::RELATION_MANY_TO_MANY && $side === Database::RELATION_SIDE_CHILD); - - foreach ($this->relationshipDeleteStack as $processedRelationship) { - $existingKey = $processedRelationship['key']; - $existingCollection = $processedRelationship['collection']; - $existingRelatedCollection = $processedRelationship['options']['relatedCollection']; - $existingTwoWayKey = $processedRelationship['options']['twoWayKey']; - $existingSide = $processedRelationship['options']['side']; - - // If this relationship has already been fetched for this document, skip it - $reflexive = $processedRelationship == $relationship; - - // If this relationship is the same as a previously fetched relationship, but on the other side, skip it - $symmetric = $existingKey === $twoWayKey - && $existingTwoWayKey === $key - && $existingRelatedCollection === $collection->getId() - && $existingCollection === $relatedCollection->getId() - && $existingSide !== $side; - - // If this relationship is not directly related but relates across multiple collections, skip it. - // - // These conditions ensure that a relationship is considered transitive if it has the same - // two-way key and related collection, but is on the opposite side of the relationship (the first and second conditions). - // - // They also ensure that a relationship is considered transitive if it has the same key and related - // collection as an existing relationship, but a different two-way key (the third condition), - // or the same two-way key as an existing relationship, but a different key (the fourth condition). - $transitive = (($existingKey === $twoWayKey - && $existingCollection === $relatedCollection->getId() - && $existingSide !== $side) - || ($existingTwoWayKey === $key - && $existingRelatedCollection === $collection->getId() - && $existingSide !== $side) - || ($existingKey === $key - && $existingTwoWayKey !== $twoWayKey - && $existingRelatedCollection === $relatedCollection->getId() - && $existingSide !== $side) - || ($existingKey !== $key - && $existingTwoWayKey === $twoWayKey - && $existingRelatedCollection === $relatedCollection->getId() - && $existingSide !== $side)); - - if ($reflexive || $symmetric || $transitive) { - break 2; - } - } - $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship); - break; - } - - foreach (\is_array($value) ? $value : [$value] as $relation) { - if (!$relation instanceof Document || $relation->isEmpty()) { - continue; - } - - if ($onDelete === Database::RELATION_MUTATE_CASCADE && !$unwritten) { - $cascaded = true; - } elseif ($twoWay && $unwritten) { - $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; - } - } - } - - if (!$report) { - return []; - } - - // A document related to itself is deleted, not changed - unset($related[$collection->getId() . ':' . $document->getId()]); - - if (!$cascaded || empty($related)) { - return $related; - } - - // A cascade can remove a related document anywhere down its chain, so keep only the ones still there - $idsByCollection = []; - foreach ($related as $relation) { - $idsByCollection[$relation->getCollection()][] = $relation->getId(); - } - - $existing = []; - foreach ($idsByCollection as $collectionId => $ids) { - foreach (\array_chunk($ids, \max(1, $this->maxQueryValues)) as $chunk) { - $found = $this->authorization->skip(fn () => $this->find($collectionId, [ - Query::equal('$id', $chunk), - Query::select(['$id']), - Query::limit(\count($chunk)), - ])); - - foreach ($found as $doc) { - $existing[$collectionId][$doc->getId()] = true; - } - } - } - - return \array_filter( - $related, - fn (Document $relation) => isset($existing[$relation->getCollection()][$relation->getId()]), - ); - } - - /** - * @param Document $relatedCollection - * @param Document $document - * @param mixed $value - * @param string $relationType - * @param bool $twoWay - * @param string $twoWayKey - * @param string $side - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws RestrictedException - * @throws StructureException - */ - private function deleteRestrict( - Document $relatedCollection, - Document $document, - mixed $value, - string $relationType, - bool $twoWay, - string $twoWayKey, - string $side - ): void { - if ($value instanceof Document && $value->isEmpty()) { - $value = null; - } - - if ( - !empty($value) - && $relationType !== Database::RELATION_MANY_TO_ONE - && $side === Database::RELATION_SIDE_PARENT - ) { - throw new RestrictedException('Cannot delete document because it has at least one related document.'); - } - - if ( - $relationType === Database::RELATION_ONE_TO_ONE - && $side === Database::RELATION_SIDE_CHILD - && !$twoWay - ) { - $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { - $related = $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), - Query::equal($twoWayKey, [$document->getId()]) - ]); - - if ($related->isEmpty()) { - return; - } - - $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - new Document([ - $twoWayKey => null - ]) - )); - }); - } - - if ( - $relationType === Database::RELATION_MANY_TO_ONE - && $side === Database::RELATION_SIDE_CHILD - ) { - $related = $this->authorization->skip(fn () => $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), - Query::equal($twoWayKey, [$document->getId()]) - ])); - - if (!$related->isEmpty()) { - throw new RestrictedException('Cannot delete document because it has at least one related document.'); - } - } - } - - /** - * Find every document in $relatedCollection whose $twoWayKey points at $document. - * - * Deletes can start from a document fetched without its relationships populated - - * deleteDocuments() passes the caller's queries straight to find(), and a select - * query turns relationship population off - so the relationship value carried on - * the document cannot be trusted here. - * - * Permissions are skipped: a referencing document the caller cannot read still has - * to have its foreign key cleared, or it is left pointing at a deleted row. - * - * @return array - * @throws DatabaseException - */ - private function findReferencingDocuments(Document $relatedCollection, Document $document, string $twoWayKey): array - { - return $this->authorization->skip(fn () => $this->find($relatedCollection->getId(), [ - Query::select(['$id']), - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX) - ])); - } - - /** - * @param Document $collection - * @param Document $relatedCollection - * @param Document $document - * @param string $relationType - * @param bool $twoWay - * @param string $twoWayKey - * @param string $side - * @return array The documents written - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws RestrictedException - * @throws StructureException - */ - private function deleteSetNull(Document $collection, Document $relatedCollection, Document $document, string $relationType, bool $twoWay, string $twoWayKey, string $side): array - { - $updated = []; - - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - if (!$twoWay && $side === Database::RELATION_SIDE_PARENT) { - break; - } - - // Shouldn't need read or update permission to delete - $result = $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { - $related = $this->findOne($relatedCollection->getId(), [ - Query::select(['$id']), - Query::equal($twoWayKey, [$document->getId()]) - ]); - - if ($related->isEmpty()) { - return; - } - - return $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $related->getId(), - new Document([ - $twoWayKey => null - ]) - )); - }); - - if ($result !== null && !$result->isEmpty()) { - $updated[] = $result; - } - break; - - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_CHILD) { - break; - } - - $relations = $this->findReferencingDocuments($relatedCollection, $document, $twoWayKey); - - foreach ($relations as $relation) { - $result = $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { - return $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $relation->getId(), - new Document([ - $twoWayKey => null - ]), - )); - }); - - if (!$result->isEmpty()) { - $updated[] = $result; - } - } - break; - - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - break; - } - - $relations = $this->findReferencingDocuments($relatedCollection, $document, $twoWayKey); - - foreach ($relations as $relation) { - $result = $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { - return $this->skipRelationships(fn () => $this->updateDocument( - $relatedCollection->getId(), - $relation->getId(), - new Document([ - $twoWayKey => null - ]) - )); - }); - - if (!$result->isEmpty()) { - $updated[] = $result; - } - } - break; - - case Database::RELATION_MANY_TO_MANY: - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - $junctions = $this->find($junction, [ - Query::select(['$id']), - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX) - ]); - - foreach ($junctions as $document) { - $this->skipRelationships(fn () => $this->deleteDocument( - $junction, - $document->getId() - )); - } - break; - } - - return $updated; - } - - /** - * @param Document $collection - * @param Document $relatedCollection - * @param Document $document - * @param string $key - * @param mixed $value - * @param string $relationType - * @param string $twoWayKey - * @param string $side - * @param Document $relationship - * @return void - * @throws AuthorizationException - * @throws ConflictException - * @throws DatabaseException - * @throws RestrictedException - * @throws StructureException - */ - private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, mixed $value, string $relationType, string $twoWayKey, string $side, Document $relationship): void - { - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - if ($value !== null) { - $this->relationshipDeleteStack[] = $relationship; - - $this->deleteDocument( - $relatedCollection->getId(), - ($value instanceof Document) ? $value->getId() : $value - ); - - \array_pop($this->relationshipDeleteStack); - } - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_CHILD) { - break; - } - - $this->relationshipDeleteStack[] = $relationship; - - foreach ($value as $relation) { - $this->deleteDocument( - $relatedCollection->getId(), - $relation->getId() - ); - } - - \array_pop($this->relationshipDeleteStack); - - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - break; - } - - $value = $this->find($relatedCollection->getId(), [ - Query::select(['$id']), - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX), - ]); - - $this->relationshipDeleteStack[] = $relationship; - - foreach ($value as $relation) { - $this->deleteDocument( - $relatedCollection->getId(), - $relation->getId() - ); - } - - \array_pop($this->relationshipDeleteStack); - - break; - case Database::RELATION_MANY_TO_MANY: - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - - $junctions = $this->skipRelationships(fn () => $this->find($junction, [ - Query::select(['$id', $key]), - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX) - ])); - - $this->relationshipDeleteStack[] = $relationship; - - foreach ($junctions as $document) { - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteDocument( - $relatedCollection->getId(), - $document->getAttribute($key) - ); - } - $this->deleteDocument( - $junction, - $document->getId() - ); - } - - \array_pop($this->relationshipDeleteStack); - break; - } - } - - /** - * Delete Documents - * - * Deletes all documents which match the given query, will respect the relationship's onDelete optin. - * - * @param string $collection - * @param array $queries - * @param int $batchSize - * @param (callable(Document, Document): void)|null $onNext - * @param (callable(Throwable): void)|null $onError - * @return int - * @throws AuthorizationException - * @throws DatabaseException - * @throws RestrictedException - * @throws \Throwable - */ - public function deleteDocuments( - string $collection, - array $queries = [], - int $batchSize = self::DELETE_BATCH_SIZE, - ?callable $onNext = null, - ?callable $onError = null, - ): int { - if ($this->adapter->getSharedTables() && empty($this->adapter->getTenant())) { - throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); - } - - $batchSize = \min(Database::DELETE_BATCH_SIZE, \max(1, $batchSize)); - $collection = $this->silent(fn () => $this->getCollection($collection)); - if ($collection->isEmpty()) { - throw new DatabaseException('Collection not found'); - } - - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_DELETE, $collection->getDelete())); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); - - $this->checkQueryTypes($queries); - - if ($this->validate) { - $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() - ); - - if (!$validator->isValid($queries)) { - throw new QueryException($validator->getDescription()); - } - } - - $grouped = Query::groupByType($queries); - $limit = $grouped['limit']; - $cursor = $grouped['cursor']; - - if (!empty($cursor) && $cursor->getCollection() !== $collection->getId()) { - throw new DatabaseException("Cursor document must be from the same Collection."); - } - - $originalLimit = $limit; - $last = $cursor; - $modified = 0; - - while (true) { - if ($limit && $limit < $batchSize && $limit > 0) { - $batchSize = $limit; - } elseif (!empty($limit)) { - $limit -= $batchSize; - } - - $new = [ - Query::limit($batchSize) - ]; - - if (!empty($last)) { - $new[] = Query::cursorAfter($last); - } - - /** - * @var array $batch - */ - $batch = $this->silent(fn () => $this->find( - $collection->getId(), - array_merge($new, $queries), - forPermission: Database::PERMISSION_DELETE - )); - - if (empty($batch)) { - break; - } - - $old = array_map(fn ($doc) => clone $doc, $batch); - $sequences = []; - $permissionIds = []; - - $this->withTransaction(function () use ($collection, $sequences, $permissionIds, $batch) { - foreach ($batch as $document) { - $sequences[] = $document->getSequence(); - if (!empty($document->getPermissions())) { - $permissionIds[] = $document->getId(); - } - - if ($this->resolveRelationships) { - $this->silent(fn () => $this->deleteDocumentRelationships( - $collection, - $document - )); - } - - // Check if document was updated after the request timestamp - try { - $oldUpdatedAt = new \DateTime($document->getUpdatedAt()); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } - - if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { - throw new ConflictException('Document was updated after the request timestamp'); - } - } - - $this->adapter->deleteDocuments( - $collection->getId(), - $sequences, - $permissionIds - ); - }); - - foreach ($batch as $index => $document) { - $this->withDocumentTenant($document, fn () => $this->purgeCachedDocument($collection->getId(), $document->getId())); - try { - $onNext && $onNext($document, $old[$index]); - } catch (Throwable $th) { - $onError ? $onError($th) : throw $th; - } - $modified++; - } - - if (count($batch) < $batchSize) { - break; - } elseif ($originalLimit && $modified >= $originalLimit) { - break; - } - - $last = \end($batch); - } - - $this->trigger(self::EVENT_DOCUMENTS_DELETE, new Document([ - '$collection' => $collection->getId(), - 'modified' => $modified - ])); - - return $modified; - } - - /** - * Cleans the all the collection's documents from the cache - * And the all related cached documents. - * - * @param string $collectionId - * - * @return bool - */ - public function purgeCachedCollection(string $collectionId): bool - { - [$collectionKey] = $this->getCacheKeys($collectionId); - - $documentKeys = $this->cache->list($collectionKey); - foreach ($documentKeys as $documentKey) { - $this->cache->purge($documentKey); - } - - $this->cache->purge($collectionKey); - - return true; - } - - /** - * Cleans a specific document from cache - * And related document reference in the collection cache. - * - * @param string $collectionId - * @param string|null $id - * @return bool - * @throws Exception - */ - protected function purgeCachedDocumentInternal(string $collectionId, ?string $id): bool - { - if ($id === null) { - return true; - } - - [$collectionKey, $documentKey] = $this->getCacheBaseKeys($collectionId, $id); - - $this->cache->purge($collectionKey, $documentKey); - $this->cache->purge($documentKey); - - return true; - } - - /** - * Run a per-document cache operation under the document's own tenant. - * - * With tenant-per-document, cache keys are scoped by the adapter's current - * tenant, so a document's purge must run under that document's tenant to - * target the right key; otherwise the callback runs as-is. - * - * @param Document $document - * @param callable():mixed $callback - * @return void - * @throws Exception - */ - private function withDocumentTenant(Document $document, callable $callback): void - { - if ($this->getSharedTables() && $this->getTenantPerDocument()) { - $this->withTenant($document->getTenant(), $callback); - } else { - $callback(); - } - } - - /** - * Cleans a specific document from cache and triggers EVENT_DOCUMENT_PURGE. - * And related document reference in the collection cache. - * - * Note: Do not retry this method as it triggers events. Use purgeCachedDocumentInternal() with retry instead. - * - * @param string $collectionId - * @param string|null $id - * @return bool - * @throws Exception - */ - public function purgeCachedDocument(string $collectionId, ?string $id): bool - { - $result = $this->purgeCachedDocumentInternal($collectionId, $id); - - if ($id !== null) { - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $id, - '$collection' => $collectionId - ])); - } - - return $result; - } - - /** - * Find Documents - * - * @param string $collection - * @param array $queries - * @param string $forPermission - * @return array - * @throws DatabaseException - * @throws QueryException - * @throws TimeoutException - * @throws Exception - */ - public function find(string $collection, array $queries = [], string $forPermission = Database::PERMISSION_READ): array - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); - - $this->checkQueryTypes($queries); - - if ($this->validate) { - $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() - ); - if (!$validator->isValid($queries)) { - throw new QueryException($validator->getDescription()); - } - } - - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input($forPermission, $collection->getPermissionsByType($forPermission))); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP - ); - - $grouped = Query::groupByType($queries); - $filters = $grouped['filters']; - $selects = $grouped['selections']; - $limit = $grouped['limit']; - $offset = $grouped['offset']; - $orderAttributes = $grouped['orderAttributes']; - $orderTypes = $grouped['orderTypes']; - $cursor = $grouped['cursor']; - $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; - - $uniqueOrderBy = false; - foreach ($orderAttributes as $order) { - if ($order === '$id' || $order === '$sequence') { - $uniqueOrderBy = true; - } - } - - $vectorSearch = false; - foreach ($filters as $filter) { - if (\in_array($filter->getMethod(), Query::VECTOR_TYPES)) { - $vectorSearch = true; - break; - } - } - - // A vector search is ordered by distance, and a vector index can only answer that one - // sort key. Appending a tie break makes the ordering unsatisfiable from the index and - // costs a full scan of the collection. The tie break exists to hold a page boundary - // still, so it is only owed to a cursor. - if ($uniqueOrderBy === false && (!$vectorSearch || !empty($cursor))) { - $leadingAttribute = $orderAttributes[0] ?? null; - $leadingOrderType = $orderTypes[0] ?? Database::ORDER_ASC; - - if (\in_array($leadingAttribute, ['$createdAt', '$updatedAt'], true)) { - $orderAttributes[] = '$sequence'; - $orderTypes[] = $leadingOrderType; - } else { - $orderAttributes[] = '$sequence'; - $orderTypes[] = Database::ORDER_ASC; - } - } - - if (!empty($cursor)) { - foreach ($orderAttributes as $order) { - if ($cursor->getAttribute($order) === null) { - throw new OrderException( - message: "Order attribute '{$order}' is empty", - attribute: $order - ); - } - } - } - - if (!empty($cursor) && $cursor->getCollection() !== $collection->getId()) { - throw new DatabaseException("cursor Document must be from the same Collection."); - } - - if (!empty($cursor)) { - $cursor = $this->encode($collection, $cursor); - $cursor = $this->adapter->castingBefore($collection, $cursor); - $cursor = $cursor->getArrayCopy(); - } else { - $cursor = []; - } - - /** @var array $queries */ - $queries = \array_merge( - $selects, - $this->convertQueries($collection, $filters) - ); - - $selections = $this->validateSelections($collection, $selects); - $nestedSelections = $this->processRelationshipQueries($relationships, $queries); - - // Convert relationship filter queries to SQL-level subqueries - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); - - // If conversion returns null, it means no documents can match (relationship filter found no matches) - if ($queriesOrNull === null) { - $results = []; - } else { - $queries = $queriesOrNull; - - $getResults = fn () => $this->adapter->find( - $collection, - $queries, - $limit ?? 25, - $offset ?? 0, - $orderAttributes, - $orderTypes, - $cursor, - $cursorDirection, - $forPermission - ); - - $results = $skipAuth ? $this->authorization->skip($getResults) : $getResults(); - } - - if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { - if (count($results) > 0) { - $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $nestedSelections)); - } - } - - foreach ($results as $index => $node) { - $node = $this->adapter->castingAfter($collection, $node); - $node = $this->casting($collection, $node); - $node = $this->decode($collection, $node, $selections); - - // Convert to custom document type if mapped - if (isset($this->documentTypes[$collection->getId()])) { - $node = $this->createDocumentInstance($collection->getId(), $node->getArrayCopy()); - } - - if (!$node->isEmpty()) { - $node->setAttribute('$collection', $collection->getId()); - } - - $results[$index] = $node; - } - - $this->trigger(self::EVENT_DOCUMENT_FIND, $results); - - return $results; - } - - /** - * Purge all cached query entries for a collection namespace. - * - * @param string $collection - * @param string|null $namespace - * @return bool - */ - public function purgeCachedQueries(string $collection, ?string $namespace = null): bool - { - $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); - $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); - - return $this->cache->purge( - $this->getQueryCacheKey($collection, $namespace) - ); - } - - /** - * Execute a callback behind a cache-aside lookup. - * - * The callback runs on cache miss and its value is returned to the caller. - * Query document payloads are converted to arrays before save and restored - * back into Documents on cache hits. A rejected document payload refreshes - * the cached value. - * A literal false value is treated as a cache miss and is not cacheable. - * - * @template T - * @param string $key - * @param callable(): T $callback - * @param string|null $hash - * @return T - * @throws AuthorizationException - * @throws Exception - */ - public function withCache( - string $key, - callable $callback, - ?string $hash = '', - ): mixed { - if ($hash === null) { - return $callback(); - } - - $shouldRefreshCache = false; - - try { - $cached = $this->cache->load($key, self::TTL, $hash); - } catch (Throwable $e) { - Console::warning('Warning: Failed to load cache value: ' . $e->getMessage()); - $cached = false; - } - - if ($cached !== false && $cached !== null) { - $cachedValue = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; - - if ($cachedValue !== false) { - $decoded = $cachedValue; - $collection = $cached['collection'] ?? null; - - if (\is_string($collection) && $collection !== '') { - // Cached document payloads are stored as arrays; restore them - // to the same Document shape that find()/getDocument() return. - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - $decoded = false; - } else { - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $collection->getRead())); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - - $payload = ($cached['type'] ?? null) === 'document' ? [$cachedValue] : $cachedValue; - - if (!\is_array($payload)) { - $decoded = false; - } else { - $documents = []; - - foreach ($payload as $document) { - if (!\is_array($document)) { - $decoded = false; - break; - } - - $document = $this->createDocumentInstance($collection->getId(), $document); - $document = $this->casting($collection, $document); - - if ($this->isTtlExpired($collection, $document)) { - $decoded = false; - break; - } - - if (!$skipAuth && $documentSecurity && $collection->getId() !== self::METADATA) { - if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $document->getRead()))) { - if (($cached['type'] ?? null) === 'document') { - $decoded = false; - break; - } - - continue; - } - } - - $documents[] = $document; - } - - if ($decoded !== false) { - $decoded = ($cached['type'] ?? null) === 'document' ? ($documents[0] ?? false) : $documents; - } - } - } - } - - if ($decoded !== false) { - return $decoded; - } - } - - $shouldRefreshCache = true; - } - - if ($shouldRefreshCache) { - try { - $this->cache->purge($key, $hash); - } catch (Throwable $e) { - Console::warning('Warning: Failed to purge rejected cache value: ' . $e->getMessage()); - } - } - - // Capture the generation before the callback runs its read: if a - // concurrent write purges this query key in between, saveWithLease() - // below rejects the now-stale list instead of re-poisoning the cache. - $generation = '0'; - try { - $generation = $this->cache->getGeneration($key); - } catch (Throwable $e) { - Console::warning('Warning: Failed to get cache generation: ' . $e->getMessage()); - } - - $callbackValue = $callback(); - - if ($callbackValue !== false) { - try { - $encoded = false; - - if ($callbackValue instanceof Document) { - $collection = $callbackValue->getCollection(); - - if ($collection !== '') { - $encoded = [ - 'collection' => $collection, - 'type' => 'document', - 'value' => $callbackValue->getArrayCopy(), - ]; - } - } elseif (!\is_array($callbackValue)) { - $encoded = ['value' => $callbackValue]; - } else { - // Only homogeneous top-level document lists are safe to restore - // from cache. Plain arrays containing Documents are left uncached. - $collection = null; - $hasDocuments = false; - $hasNonDocuments = false; - $cacheable = true; - $documents = []; - $containsDocument = function (mixed $item) use (&$containsDocument): bool { - if ($item instanceof Document) { - return true; - } - - if (!\is_array($item)) { - return false; - } - - foreach ($item as $child) { - if ($containsDocument($child)) { - return true; - } - } - - return false; - }; - - foreach ($callbackValue as $item) { - if (!$item instanceof Document) { - if ($hasDocuments || $containsDocument($item)) { - $cacheable = false; - break; - } - - $hasNonDocuments = true; - continue; - } - - if ($hasNonDocuments) { - $cacheable = false; - break; - } - - $documentCollection = $item->getCollection(); - if ($documentCollection === '') { - $cacheable = false; - break; - } - - if ($collection !== null && $collection !== $documentCollection) { - $cacheable = false; - break; - } - - $collection = $documentCollection; - $hasDocuments = true; - $documents[] = $item->getArrayCopy(); - } - - if ($cacheable) { - $encoded = $hasDocuments ? [ - 'collection' => $collection, - 'type' => 'documents', - 'value' => $documents, - ] : ['value' => $callbackValue]; - } - } - - if ($encoded !== false) { - $this->cache->saveWithLease($key, $encoded, $hash, $generation); - } - } catch (Throwable $e) { - Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); - } - } - - /** @var T $callbackValue */ - return $callbackValue; - } - - /** - * Helper method to iterate documents in collection using callback pattern - * Alterative is - * - * @param string $collection - * @param callable $callback - * @param array $queries - * @param string $forPermission - * @return void - * @throws \Utopia\Database\Exception - */ - public function foreach(string $collection, callable $callback, array $queries = [], string $forPermission = Database::PERMISSION_READ): void - { - foreach ($this->iterate($collection, $queries, $forPermission) as $document) { - $callback($document); - } - } - - /** - * Return each document of the given collection - * that matches the given queries - * - * @param string $collection - * @param array $queries - * @param string $forPermission - * @return \Generator - * @throws \Utopia\Database\Exception - */ - public function iterate(string $collection, array $queries = [], string $forPermission = Database::PERMISSION_READ): \Generator - { - $grouped = Query::groupByType($queries); - $limitExists = $grouped['limit'] !== null; - $limit = $grouped['limit'] ?? 25; - $offset = $grouped['offset']; - - $cursor = $grouped['cursor']; - $cursorDirection = $grouped['cursorDirection']; - - // Cursor before is not supported - if ($cursor !== null && $cursorDirection === Database::CURSOR_BEFORE) { - throw new DatabaseException('Cursor ' . Database::CURSOR_BEFORE . ' not supported in this method.'); - } - - $sum = $limit; - $latestDocument = null; - - while ($sum === $limit) { - $newQueries = $queries; - if ($latestDocument !== null) { - //reset offset and cursor as groupByType ignores same type query after first one is encountered - if ($offset !== null) { - array_unshift($newQueries, Query::offset(0)); - } - - array_unshift($newQueries, Query::cursorAfter($latestDocument)); - } - if (!$limitExists) { - $newQueries[] = Query::limit($limit); - } - $results = $this->find($collection, $newQueries, $forPermission); - - if (empty($results)) { - return; - } - - $sum = count($results); - - foreach ($results as $document) { - yield $document; - } - - $latestDocument = $results[array_key_last($results)]; - } - } - - /** - * @param string $collection - * @param array $queries - * @return Document - * @throws DatabaseException - */ - public function findOne(string $collection, array $queries = []): Document - { - $results = $this->silent(fn () => $this->find($collection, \array_merge([ - Query::limit(1) - ], $queries))); - - $found = \reset($results); - - $this->trigger(self::EVENT_DOCUMENT_FIND, $found); - - if (!$found) { - return new Document(); - } - - return $found; - } - - /** - * Count Documents - * - * Count the number of documents. - * - * @param string $collection - * @param array $queries - * @param int|null $max - * - * @return int - * @throws DatabaseException - */ - public function count(string $collection, array $queries = [], ?int $max = null): int - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); - - $this->checkQueryTypes($queries); - - if ($this->validate) { - $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() - ); - if (!$validator->isValid($queries)) { - throw new QueryException($validator->getDescription()); - } - } - - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $collection->getRead())); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP - ); - - $queries = Query::groupByType($queries)['filters']; - $queries = $this->convertQueries($collection, $queries); - - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); - - if ($queriesOrNull === null) { - return 0; - } - - $queries = $queriesOrNull; - - $getCount = fn () => $this->adapter->count($collection, $queries, $max); - $count = $skipAuth ? $this->authorization->skip($getCount) : $getCount(); - - $this->trigger(self::EVENT_DOCUMENT_COUNT, $count); - - return $count; - } - - /** - * Sum an attribute - * - * Sum an attribute for all the documents. Pass $max=0 for unlimited count - * - * @param string $collection - * @param string $attribute - * @param array $queries - * @param int|null $max - * - * @return int|float - * @throws DatabaseException - */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int - { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $attributes = $collection->getAttribute('attributes', []); - $indexes = $collection->getAttribute('indexes', []); - - $this->checkQueryTypes($queries); - - if ($this->validate) { - $validator = new DocumentsValidator( - $attributes, - $indexes, - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() - ); - if (!$validator->isValid($queries)) { - throw new QueryException($validator->getDescription()); - } - } - - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $collection->getRead())); + return $this->datePreservation ??= new Value(false); + } - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } + /** + * @return Value + */ + private function sequencePreservation(): Value + { + return $this->sequencePreservation ??= new Value(false); + } - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP - ); + /** + * @return Value + */ + private function ignoringDuplicates(): Value + { + return $this->ignoringDuplicates ??= new Value(false); + } - $queries = $this->convertQueries($collection, $queries); - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); + protected function isIgnoringDuplicates(): bool + { + return $this->ignoringDuplicates()->get(); + } - // If conversion returns null, it means no documents can match (relationship filter found no matches) - if ($queriesOrNull === null) { - return 0; + private function getEventContext(): int + { + if (! \extension_loaded('swoole')) { + return -1; } - $queries = $queriesOrNull; - - $getSum = fn () => $this->adapter->sum($collection, $attribute, $queries, $max); - $sum = $skipAuth ? $this->authorization->skip($getSum) : $getSum(); - - $this->trigger(self::EVENT_DOCUMENT_SUM, $sum); + $context = Coroutine::getCid(); - return $sum; + return \is_int($context) ? $context : -1; } /** - * Add Attribute Filter + * Register a global attribute filter with encode and decode callbacks for data transformation. * - * @param string $name - * @param callable $encode - * @param callable $decode - * - * @return void + * @param string $name The unique filter name. + * @param callable $encode Callback to transform the value before storage. + * @param callable $decode Callback to transform the value after retrieval. */ public static function addFilter(string $name, callable $encode, callable $decode): void { @@ -9456,114 +1706,120 @@ public static function addFilter(string $name, callable $encode, callable $decod 'decode' => $decode, 'signature' => self::computeCallableSignature($encode) . ':' . self::computeCallableSignature($decode), ]; - - self::$filtersVersion++; } - /** - * Remove attributes the collection schema does not declare. - * - * Used ahead of change detection on update/upsert so a dropped key is not - * counted as a write. Encode also calls this after iterating attributes. - * - * @param Document $collection - * @param Document $document - * @param array|null $known Attribute ids already collected (e.g. during encode) - * - * @return Document - */ - protected function removeUnknownAttributes(Document $collection, Document $document, ?array $known = null): Document + private static function computeCallableSignature(callable $callable): string { - if (!$this->dropUnknownAttributes || !$this->adapter->getSupportForAttributes()) { - return $document; + if (\is_string($callable)) { + return $callable; } - if ($known === null) { - $known = []; - foreach ($collection->getAttribute('attributes', []) as $attribute) { - $known[$attribute['$id'] ?? ''] = true; - } + if (\is_array($callable)) { + $class = \is_object($callable[0]) ? \get_class($callable[0]) : $callable[0]; + return $class . '::' . $callable[1]; } - $dropped = []; - $documentKeys = []; - foreach ($document as $key => $value) { - $documentKeys[] = $key; - } - foreach ($documentKeys as $key) { - if (\str_starts_with($key, '$') || isset($known[$key])) { - continue; - } + $reflection = new \ReflectionFunction(\Closure::fromCallable($callable)); - $dropped[] = $key; - $document->removeAttribute($key); - } + return ($reflection->getFileName() ?: 'unknown').':'.$reflection->getStartLine(); + } - if (!empty($dropped)) { - Console::warning( - 'Dropped unknown attributes "' . \implode('", "', $dropped) . '" from collection "' . $collection->getId() . '"' - . ($this->adapter->getTenant() === null ? '' : ' on tenant ' . $this->adapter->getTenant()) - ); + public function setFiltering(bool $filtering): static + { + $this->filtering()->set($filtering); + + return $this; + } + + /** + * Whether attribute filters encode values before they are stored and decode them after they are read. + */ + public function isFiltering(): bool + { + return $this->filtering()->get(); + } + + /** + * Run the callback with filtering on or off for the calling coroutine and the coroutines it starts. Given + * filter names, filtering stays as it is and only the named filters are turned off (they become the ones + * turned off) or back on. + * + * @template T + * + * @param callable(): T $callback + * @param array|null $filters + * @return T + */ + public function withFiltering(bool $filtering, callable $callback, ?array $filters = null): mixed + { + if (empty($filters)) { + return $this->filtering()->with($filtering, $callback); } - return $document; + $named = \array_fill_keys($filters, true); + $excluded = $filtering ? \array_diff_key($this->filterExclusions()->get() ?? [], $named) : $named; + + return $this->filtering()->with( + $this->filtering()->get(), + fn (): mixed => $this->filterExclusions()->with($excluded, $callback), + ); } /** - * Encode Document + * Run the callback without filters, or without the named ones, for the calling coroutine and the coroutines it + * starts. * - * When dropUnknownAttributes is enabled, attributes missing from the - * collection schema are removed here while the known set is collected. + * @template T * - * @param Document $collection - * @param Document $document - * @param bool $applyDefaults Whether to apply default values to null attributes + * @param callable(): T $callback + * @param array|null $filters + * @return T + */ + public function skipFilters(callable $callback, ?array $filters = null): mixed + { + return $this->withFiltering(false, $callback, $filters); + } + + /** + * @param bool $applyDefaults Whether to apply default values to null attributes * - * @return Document * @throws DatabaseException */ public function encode(Document $collection, Document $document, bool $applyDefaults = true): Document { - $attributes = $collection->getAttribute('attributes', []); - $internalDateAttributes = ['$createdAt', '$updatedAt']; - foreach ($this->getInternalAttributes() as $attribute) { - $attributes[] = $attribute; - } - $known = []; - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; + + foreach (Collection::fromDocument($collection)->attributesWith($this->internalAttributes()) as $attribute) { + $key = $attribute->key; + $array = $attribute->array; + $default = $attribute->default; + $filters = $attribute->filters; $known[$key] = true; - $array = $attribute['array'] ?? false; - $default = $attribute['default'] ?? null; - $filters = $attribute['filters'] ?? []; - $value = $document->getAttribute($key); + $exists = $document->offsetExists($key); + $value = $exists ? $document[$key] : null; - if (in_array($key, $internalDateAttributes) && is_string($value) && empty($value)) { + if (($key === Document::CREATED_AT || $key === Document::UPDATED_AT) && \is_string($value) && empty($value)) { $document->setAttribute($key, null); + continue; } - if ($key === '$permissions') { + if ($key === Document::PERMISSIONS) { continue; } // Continue on optional param with no default - if (is_null($value) && is_null($default)) { + if (! $exists && $default === null) { continue; } - // Skip encoding for Operator objects if ($value instanceof Operator) { continue; } - // Assign default only if no value provided - // False positive "Call to function is_null() with mixed will always evaluate to false" - // @phpstan-ignore-next-line - if (is_null($value) && !is_null($default)) { + if ($value === null && $default !== null) { // Skip applying defaults during updates to avoid resetting unspecified attributes - if (!$applyDefaults) { + if (! $applyDefaults) { continue; } $value = ($array) ? $default : [$default]; @@ -9571,7 +1827,12 @@ public function encode(Document $collection, Document $document, bool $applyDefa $value = ($array) ? $value : [$value]; } - if (!empty($filters)) { + if ($value === null) { + continue; + } + + /** @var array $value */ + if (! empty($filters)) { foreach ($value as $index => $node) { if ($node !== null) { foreach ($filters as $filter) { @@ -9582,7 +1843,7 @@ public function encode(Document $collection, Document $document, bool $applyDefa } } - if (!$array) { + if (! $array) { $value = $value[0]; } $document->setAttribute($key, $value); @@ -9592,81 +1853,139 @@ public function encode(Document $collection, Document $document, bool $applyDefa } /** - * Decode Document + * Remove attributes the collection schema does not declare. + * + * Used ahead of change detection on update and upsert so a dropped key is not + * counted as a write. encode() also calls this once it has collected the known + * set while iterating the schema. + * + * @param array|null $known Attribute ids already collected + */ + protected function removeUnknownAttributes(Document $collection, Document $document, ?array $known = null): Document + { + if (! $this->dropUnknownAttributes || ! $this->adapter->supports(Capability::DefinedAttributes)) { + return $document; + } + + if ($known === null) { + $known = []; + foreach (Collection::fromDocument($collection)->attributes() as $attribute) { + $known[$attribute->key] = true; + } + } + + $dropped = []; + $documentKeys = []; + foreach ($document as $key => $value) { + $documentKeys[] = (string) $key; + } + + foreach ($documentKeys as $key) { + if (\str_starts_with($key, '$') || isset($known[$key])) { + continue; + } + + $dropped[] = $key; + $document->removeAttribute($key); + } + + if (! empty($dropped)) { + Console::warning( + 'Dropped unknown attributes "'.\implode('", "', $dropped).'" from collection "'.$collection->getId().'"' + .($this->adapter->getTenant() === null ? '' : ' on tenant '.$this->adapter->getTenant()) + ); + } + + return $document; + } + + /** + * @param array $selections * - * @param Document $collection - * @param Document $document - * @param array $selections - * @return Document * @throws DatabaseException */ public function decode(Document $collection, Document $document, array $selections = []): Document { - $attributes = \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] !== self::VAR_RELATIONSHIP - ); + $allAttributes = Collection::fromDocument($collection)->attributes(); - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP - ); + $attributes = []; + $relationships = []; + foreach ($allAttributes as $attribute) { + if ($attribute->relationship !== null) { + $relationships[] = $attribute; + } else { + $attributes[] = $attribute; + } + } $filteredValue = []; + $relationshipKeys = []; + + if (! empty($relationships)) { + $documentArray = (array) $document; + foreach ($relationships as $relationship) { + $key = $relationship->key; + $relationshipKeys[$key] = true; + $filteredKey = $this->adapter->filter($key); - foreach ($relationships as $relationship) { - $key = $relationship['$id'] ?? ''; - - if ( - \array_key_exists($key, (array)$document) - || \array_key_exists($this->adapter->filter($key), (array)$document) - ) { - $value = $document->getAttribute($key); - $value ??= $document->getAttribute($this->adapter->filter($key)); - $document->removeAttribute($this->adapter->filter($key)); - $document->setAttribute($key, $value); + if ( + \array_key_exists($key, $documentArray) + || \array_key_exists($filteredKey, $documentArray) + ) { + $value = $document->getAttribute($key); + $value ??= $document->getAttribute($filteredKey); + $document->removeAttribute($filteredKey); + $document->setAttribute($key, $value); + } } } $internalKeys = []; - foreach ($this->getInternalAttributes() as $attribute) { + foreach ($this->internalAttributes() as $attribute) { $attributes[] = $attribute; - $internalKeys[$attribute['$id']] = true; + $internalKeys[$attribute->key] = true; } + $hasSelections = ! empty($selections); + $selectAll = $hasSelections && \in_array('*', $selections, true); + $selectionsMap = ($hasSelections && ! $selectAll) + ? \array_fill_keys($selections, true) + : null; + + $filtering = null; + $disabledFilters = null; + $hasRelationshipSelections = false; - foreach ($selections as $selection) { - if (\str_contains($selection, '.')) { - $hasRelationshipSelections = true; - break; + if ($selectionsMap !== null && $relationshipKeys !== []) { + foreach ($selections as $selection) { + $dot = \strpos($selection, '.'); + if ($dot !== false && isset($relationshipKeys[\substr($selection, 0, $dot)])) { + $hasRelationshipSelections = true; + break; + } } } foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; - $filters = $attribute['filters'] ?? []; - $value = $document->getAttribute($key); - - if ($key === '$permissions') { + $key = $attribute->key; + if ($key === Document::PERMISSIONS) { continue; } + $array = $attribute->array; + $filters = $attribute->filters; + $value = $document->getAttribute($key); + // filter() strips the leading "$" off an internal key, leaving a name a user // attribute is allowed to have ("$collection" -> "collection"). An internal value // never reaches the document under that name, so the alias lookup below has // nothing of its own to find and can only steal the user's attribute. - if (\is_null($value) && !isset($internalKeys[$key])) { + if (\is_null($value) && ! isset($internalKeys[$key])) { $filteredKey = $this->adapter->filter($key); $value = $document->getAttribute($filteredKey); - if (!\is_null($value)) { - $document->removeAttribute($filteredKey); - } elseif ($filteredKey !== $key && $document->offsetExists($filteredKey)) { - // SQL adapter column names use filter($key); remove the alias so the - // in-memory document only exposes keys (e.g. "a.b") that match the schema. + if ($filteredKey !== $key && $document->offsetExists($filteredKey)) { $document->removeAttribute($filteredKey); } } @@ -9679,294 +1998,481 @@ public function decode(Document $collection, Document $document, array $selectio $value = ($array) ? $value : [$value]; $value = (is_null($value)) ? [] : $value; - $selected = empty($selections) - || \in_array($key, $selections) - || \in_array('*', $selections); + /** @var array $value */ + $selected = ! $hasSelections + || $selectAll + || ($selectionsMap !== null && isset($selectionsMap[$key])); - if (!empty($filters) && ($selected || $hasRelationshipSelections)) { - $filters = \array_reverse($filters); - foreach ($value as $index => $node) { - foreach ($filters as $filter) { - $node = $this->decodeAttribute($filter, $node, $document, $key); + $filterCount = \count($filters); + + if ($filterCount > 0 && ($selected || $hasRelationshipSelections)) { + $filtering ??= $this->filtering()->get(); + $disabledFilters ??= $this->filterExclusions()->get() ?? []; + + if ($filtering) { + foreach ($value as $index => $node) { + for ($i = $filterCount - 1; $i >= 0; $i--) { + if (! isset($disabledFilters[$filters[$i]])) { + $node = $this->decodeAttribute($filters[$i], $node, $document, $key); + } + } + $value[$index] = $node; } - $value[$index] = $node; } } - $filteredValue[$key] = ($array) ? $value : $value[0]; + $resolved = $array ? $value : $value[0]; + $filteredValue[$key] = $resolved; if ($selected) { - $document->setAttribute($key, ($array) ? $value : $value[0]); + $document->setAttribute($key, $resolved); } } - if ($hasRelationshipSelections && !empty($selections) && !\in_array('*', $selections)) { - foreach ($collection->getAttribute('attributes', []) as $attribute) { - $key = $attribute['$id'] ?? ''; + if ($hasRelationshipSelections && $selectionsMap !== null) { + foreach ($allAttributes as $attribute) { + $key = $attribute->key; - if ($attribute['type'] === self::VAR_RELATIONSHIP || $key === '$permissions') { + if ($attribute->relationship !== null || $key === Document::PERMISSIONS) { continue; } - if (!in_array($key, $selections) && isset($filteredValue[$key])) { - $document->setAttribute($key, $filteredValue[$key]); - } - } - } - return $document; + if (! isset($selectionsMap[$key]) && isset($filteredValue[$key])) { + $document->setAttribute($key, $filteredValue[$key]); + } + } + } + + return $document; + } + + /** + * Decode the values a document carries under each join alias as a direct read of the joined + * collection would: cast to their types, then passed through every decode filter they declare, + * with a document built from the joined row. An alias whose `$id` is null matched no row, and + * its values stay null. + * + * @param array $collections The collection each join alias reads + * + * @throws DatabaseException + */ + protected function decodeJoins(Document $document, array $collections): Document + { + foreach ($this->joinedRows($document, $collections) as $alias => $row) { + $collection = $collections[$alias]; + $keys = \array_map(\strval(...), \array_keys($row)); + + $joined = Document::fromRow([...$row, Document::COLLECTION => $collection->getId()]); + $joined = $this->castAfterDocument($collection, $joined); + $joined = $this->casting($collection, $joined); + $joined = $this->decode($collection, $joined, $keys); + + foreach ($keys as $key) { + $document->setAttribute($alias.'.'.$key, $joined->getAttribute($key)); + } + } + + return $document; + } + + /** + * Encode the values a document carries under each join alias back to how the joined collection + * stores them. Returns a copy: the document is usually a caller's cursor, which keeps its + * decoded values. + * + * @param array $collections The collection each join alias reads + * + * @throws DatabaseException + */ + protected function encodeJoins(Document $document, array $collections): Document + { + $rows = $this->joinedRows($document, $collections); + if ($rows === []) { + return $document; + } + + $encoded = clone $document; + foreach ($rows as $alias => $row) { + $collection = $collections[$alias]; + $keys = \array_map(\strval(...), \array_keys($row)); + + $joined = Document::fromRow([...$row, Document::COLLECTION => $collection->getId()]); + $joined = $this->encode($collection, $joined, applyDefaults: false); + $joined = $this->castBefore($collection, $joined); + + foreach ($keys as $key) { + $encoded->setAttribute($alias.'.'.$key, $joined->getAttribute($key)); + } + } + + return $encoded; + } + + /** + * The row each join alias carries in a document, by attribute. `$permissions` is never encoded + * or decoded, and an alias whose `$id` is null matched no row: both are left out. + * + * @param array $collections + * @return array> + */ + private function joinedRows(Document $document, array $collections): array + { + if ($collections === []) { + return []; + } + + $rows = []; + foreach ($document as $key => $value) { + $key = (string) $key; + $dot = \strpos($key, '.'); + if ($dot === false) { + continue; + } + + $alias = \substr($key, 0, $dot); + $attribute = \substr($key, $dot + 1); + if (! isset($collections[$alias]) || $attribute === Document::PERMISSIONS) { + continue; + } + + $rows[$alias][$attribute] = $value; + } + + return \array_filter( + $rows, + static fn (array $row): bool => ! \array_key_exists(Document::ID, $row) || $row[Document::ID] !== null, + ); + } + + /** + * Cast document attribute values to their PHP types by the collection's attributes. + * + * @internal + */ + public function casting(Document $collection, Document $document): Document + { + if ($this->adapter->hasFeature(Feature\Casting::class)) { + return $document; + } + + foreach (Collection::fromDocument($collection)->attributesWith($this->internalAttributes()) as $attribute) { + $key = $attribute->key; + $type = $attribute->type; + $array = $attribute->array; + + $needsCast = $array || match ($type) { + ColumnType::Id, + ColumnType::Boolean, + ColumnType::Integer, + ColumnType::BigInteger, + ColumnType::Float, + ColumnType::Double => true, + default => false, + }; + if (! $needsCast || $key === Document::PERMISSIONS) { + continue; + } + + $value = $document->getAttribute($key); + if (\is_null($value)) { + continue; + } + + if ($array) { + $value = ! \is_string($value) + ? $value + : \json_decode($value, true); + } else { + $value = [$value]; + } + + /** @var array $value */ + foreach ($value as $index => $node) { + $value[$index] = match ($type) { + ColumnType::Id => (string) $node, + ColumnType::Boolean => (bool) $node, + ColumnType::Integer => (int) $node, + ColumnType::BigInteger => $this->castBigInteger($node, $attribute->signed), + ColumnType::Float, + ColumnType::Double => (float) $node, + default => $node, + }; + } + + $document->setAttribute($key, ($array) ? $value : $value[0]); + } + + return $document; + } + + private function castBigInteger(mixed $value, bool $signed): mixed + { + if (\is_string($value) && BigInt::fitsPhpInt($value, $signed)) { + return (int) $value; + } + + return $value; + } + + /** + * Set a metadata value to be printed in the query comments + */ + public function setMetadata(string $key, mixed $value): static + { + $this->adapter->setMetadata($key, $value); + + return $this; + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->adapter->getMetadata(); + } + + public function resetMetadata(): void + { + $this->adapter->resetMetadata(); } /** - * Casting + * Executes $callback with $timestamp set to $requestTimestamp. + * Scoped to the calling coroutine and the coroutines it starts. * - * @param Document $collection - * @param Document $document + * @template T * - * @return Document + * @param callable(): T $callback + * @return T */ - public function casting(Document $collection, Document $document): Document + public function withRequestTimestamp(?NativeDateTime $requestTimestamp, callable $callback): mixed { - if (!$this->adapter->getSupportForCasting()) { - return $document; - } - - $attributes = $collection->getAttribute('attributes', []); + return $this->requestTimestamp()->with($requestTimestamp, $callback); + } - foreach ($this->getInternalAttributes() as $attribute) { - $attributes[] = $attribute; + /** + * The id of the adapter's connection, or null for an adapter without one. + */ + public function getConnectionId(): ?string + { + if (! $this->adapterHasFeature(Feature\Connection::class)) { + return null; } - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $signed = $attribute['signed'] ?? true; - $array = $attribute['array'] ?? false; - $value = $document->getAttribute($key, null); - if (is_null($value)) { - continue; - } - - if ($key === '$permissions') { - continue; - } - - if ($array) { - $value = !is_string($value) - ? $value - : json_decode($value, true); - } else { - $value = [$value]; - } + return $this->adapter->id(); + } - if (\in_array($type, [self::VAR_ID, self::VAR_BOOLEAN, self::VAR_INTEGER, self::VAR_BIGINT, self::VAR_FLOAT], true)) { - foreach ($value as $index => $node) { - switch ($type) { - case self::VAR_ID: - // Disabled until Appwrite migrates to use real int ID's for MySQL - //$type = $this->adapter->getIdAttributeType(); - //\settype($node, $type); - $node = (string)$node; - break; - case self::VAR_BOOLEAN: - $node = (bool)$node; - break; - case self::VAR_INTEGER: - $node = (int)$node; - break; - case self::VAR_BIGINT: - if (\is_string($node) && BigIntValidator::fitsPhpInt($node, $signed)) { - $node = (int)$node; - } - break; - case self::VAR_FLOAT: - $node = (float)$node; - break; - default: - break; - } + /** + * The host the adapter is connected to, or null for an adapter without a connection. + */ + public function getHostname(): ?string + { + if (! $this->adapterHasFeature(Feature\Connection::class)) { + return null; + } - $value[$index] = $node; - } - } + return $this->adapter->hostname(); + } - $document->setAttribute($key, ($array) ? $value : $value[0]); + /** + * Whether the adapter's connection answers. An adapter without a connection is always reachable. + */ + public function ping(): bool + { + if (! $this->adapterHasFeature(Feature\Connection::class)) { + return true; } - return $document; + return $this->adapter->ping(); } /** - * Encode Attribute - * - * Passes the attribute $value, and $document context to a predefined filter - * that allow you to manipulate the input format of the given attribute. - * - * @param string $name - * @param mixed $value - * @param Document $document - * - * @return mixed - * @throws DatabaseException + * Re-establish the adapter's connection; nothing to do for an adapter without one. */ - protected function encodeAttribute(string $name, mixed $value, Document $document): mixed + public function reconnect(): void { - if (!array_key_exists($name, self::$filters) && !array_key_exists($name, $this->instanceFilters)) { - throw new NotFoundException("Filter: {$name} not found"); + if ($this->adapterHasFeature(Feature\Connection::class)) { + $this->adapter->reconnect(); } + } - try { - if (\array_key_exists($name, $this->instanceFilters)) { - $value = $this->instanceFilters[$name]['encode']($value, $document, $this); - } else { - $value = self::$filters[$name]['encode']($value, $document, $this); - } - } catch (\Throwable $th) { - throw new DatabaseException($th->getMessage(), $th->getCode(), $th); - } + /** + * The attributes a collection may declare besides the internal ones; 0 when there is no limit. + */ + public function getLimitForAttributes(): int + { + $limits = $this->adapter->limits(); - return $value; + return $limits->attributes === 0 ? 0 : $limits->attributes - $limits->defaultAttributes; } /** - * Decode Attribute - * - * Passes the attribute $value, and $document context to a predefined filter - * that allow you to manipulate the output format of the given attribute. - * - * @param string $filter - * @param mixed $value - * @param Document $document - * @param string $attribute - * @return mixed - * @throws NotFoundException + * The indexes a collection may declare besides the internal ones. */ - protected function decodeAttribute(string $filter, mixed $value, Document $document, string $attribute): mixed + public function getLimitForIndexes(): int { - if (!$this->filter) { - return $value; - } + $limits = $this->adapter->limits(); - if (!\is_null($this->disabledFilters) && isset($this->disabledFilters[$filter])) { - return $value; - } + return $limits->indexes - $limits->defaultIndexes; + } - if (!array_key_exists($filter, self::$filters) && !array_key_exists($filter, $this->instanceFilters)) { - throw new NotFoundException("Filter \"{$filter}\" not found for attribute \"{$attribute}\""); - } + public function getMaxIndexLength(): int + { + return $this->adapter->limits()->indexLength; + } - if (array_key_exists($filter, $this->instanceFilters)) { - $value = $this->instanceFilters[$filter]['decode']($value, $document, $this); - } else { - $value = self::$filters[$filter]['decode']($value, $document, $this); - } + public function getMaxVarcharLength(): int + { + return $this->adapter->limits()->varchar; + } - return $value; + public function getMaxUidLength(): int + { + return $this->adapter->limits()->uidLength; + } + + /** + * A copy, so a caller that modifies it leaves the adapter's limit as it is. + */ + public function getMinDateTime(): NativeDateTime + { + return clone $this->adapter->limits()->minDateTime; + } + + /** + * A copy, so a caller that modifies it leaves the adapter's limit as it is. + */ + public function getMaxDateTime(): NativeDateTime + { + return clone $this->adapter->limits()->maxDateTime; } /** - * Validate if a set of attributes can be selected from the collection + * Convert each filter to what its attribute stores. With the collections a query set's joins + * read, a filter on `alias.attribute` is converted by that collection's attribute, and the + * filters of each join's ON list and the conditions of each having() are converted too. A + * having condition on the alias of a min or max is converted by the aggregated attribute, on + * any other aggregate alias it is left as it is. Aggregates and selects are left as they are. + * + * @param array $queries + * @param array $joinedCollections The collection each join alias reads + * @return array * - * @param Document $collection - * @param array $queries - * @return array * @throws QueryException + * @throws \Utopia\Database\Exception */ - private function validateSelections(Document $collection, array $queries): array + public function convertQueries(Document $collection, array $queries, array $joinedCollections = []): array { - if (empty($queries)) { - return []; - } + $attributesById = $this->buildAttributeMap($collection, $joinedCollections); + $isNestedQueryAttributeSupported = $this->adapter->supports(Capability::Objects) + && $this->adapter->supports(Capability::DefinedAttributes); - $selections = []; - $relationshipSelections = []; + $havingAttributesById = null; + foreach ($queries as $index => $query) { + $method = $query->getMethod(); - foreach ($queries as $query) { - if ($query->getMethod() == Query::TYPE_SELECT) { - foreach ($query->getValues() as $value) { - if (!\is_string($value)) { - throw new QueryException('Attribute selection must be a string, got ' . \get_debug_type($value)); - } - if (\str_contains($value, '.')) { - $relationshipSelections[] = $value; - continue; - } - $selections[] = $value; - } + if ($method->isAggregate() || $method === Method::Select) { + continue; } - } - // Allow querying internal attributes - $keys = \array_map( - fn ($attribute) => $attribute['$id'], - $this->getInternalAttributes() - ); + if ($method->isJoin()) { + /** @var array $onQueries */ + $onQueries = $query->getJoinOnQueries(); + $this->convertQueriesWithMap($onQueries, $attributesById, $isNestedQueryAttributeSupported); - foreach ($collection->getAttribute('attributes', []) as $attribute) { - if ($attribute['type'] !== self::VAR_RELATIONSHIP) { - // Fallback to $id when key property is not present in metadata table for some tables such as Indexes or Attributes - $keys[] = $attribute['key'] ?? $attribute['$id']; - } - } - if ($this->adapter->getSupportForAttributes()) { - $invalid = \array_diff($selections, $keys); - if (!empty($invalid) && !\in_array('*', $invalid)) { - throw new QueryException('Cannot select attributes: ' . \implode(', ', $invalid)); + continue; } - } - $selections = \array_merge($selections, $relationshipSelections); + if ($method === Method::Having) { + $havingAttributesById ??= $this->withAggregateAliases($attributesById, $queries); + /** @var array $conditions */ + $conditions = $query->getValues(); + $query->setValues($this->convertQueriesWithMap($conditions, $havingAttributesById, $isNestedQueryAttributeSupported)); + + continue; + } - $selections[] = '$id'; - $selections[] = '$sequence'; - $selections[] = '$collection'; - $selections[] = '$createdAt'; - $selections[] = '$updatedAt'; - $selections[] = '$permissions'; + $queries[$index] = $this->convertQueriesWithMap([$query], $attributesById, $isNestedQueryAttributeSupported)[0]; + } - return \array_values(\array_unique($selections)); + return $queries; } /** - * Get adapter attribute limit, accounting for internal metadata - * Returns 0 to indicate no limit + * The collection's attributes and the internal ones by key, and each joined collection's under + * `alias.key`, built once per conversion rather than per query. * - * @return int + * @param array $joinedCollections + * @return array */ - public function getLimitForAttributes(): int + private function buildAttributeMap(Document $collection, array $joinedCollections = []): array { - if ($this->adapter->getLimitForAttributes() === 0) { - return 0; + $internal = $this->internalAttributes(); + + $attributesById = []; + foreach ([...Collection::fromDocument($collection)->attributes(), ...$internal] as $attribute) { + $attributesById[$attribute->key] = $attribute; + } + + foreach ($joinedCollections as $alias => $joined) { + foreach ([...Collection::fromDocument($joined)->attributes(), ...$internal] as $attribute) { + $attributesById[$alias.'.'.$attribute->key] ??= $attribute; + } } - return $this->adapter->getLimitForAttributes() - $this->adapter->getCountOfDefaultAttributes(); + return $attributesById; } /** - * Get adapter index limit + * The attribute map a having condition is converted by: an aggregate alias names the result of + * its aggregate, which for min and max has the type of the aggregated attribute. * - * @return int + * @param array $attributesById + * @param array $queries + * @return array */ - public function getLimitForIndexes(): int + private function withAggregateAliases(array $attributesById, array $queries): array { - return $this->adapter->getLimitForIndexes() - $this->adapter->getCountOfDefaultIndexes(); + foreach ($queries as $query) { + $method = $query->getMethod(); + $alias = $query->getAlias(); + if (! $method->isAggregate() || $alias === '') { + continue; + } + + $aggregated = \in_array($method, [Method::Min, Method::Max], true) + ? $attributesById[$query->getAttribute()] ?? null + : null; + + if ($aggregated === null) { + unset($attributesById[$alias]); + } else { + $attributesById[$alias] = $aggregated; + } + } + + return $attributesById; } /** - * @param Document $collection * @param array $queries + * @param array $attributesById * @return array * @throws QueryException * @throws \Utopia\Database\Exception */ - public function convertQueries(Document $collection, array $queries): array + private function convertQueriesWithMap(array $queries, array $attributesById, bool $isNestedQueryAttributeSupported): array { foreach ($queries as $index => $query) { if ($query->isNested()) { - $values = $this->convertQueries($collection, $query->getValues()); + /** @var array $nestedQueries */ + $nestedQueries = $query->getValues(); + $values = $this->convertQueriesWithMap($nestedQueries, $attributesById, $isNestedQueryAttributeSupported); $query->setValues($values); } - $query = $this->convertQuery($collection, $query); + $query = $this->convertQueryWithMap($query, $attributesById, $isNestedQueryAttributeSupported); $queries[$index] = $query; } @@ -9975,99 +2481,61 @@ public function convertQueries(Document $collection, array $queries): array } /** - * @param Document $collection - * @param Query $query - * @return Query * @throws QueryException * @throws \Utopia\Database\Exception */ - /** - * Check if values are compatible with object attribute type (hashmap/multi-dimensional array) - * - * @param array $values - * @return bool - */ - private function isCompatibleObjectValue(array $values): bool + public function convertQuery(Document $collection, Query $query): Query { - if (empty($values)) { - return false; - } - - foreach ($values as $value) { - if (!\is_array($value)) { - return false; - } - - // Check associative array (hashmap) or nested structure - if (empty($value)) { - continue; - } - - // simple indexed array => not an object - if (\array_keys($value) === \range(0, \count($value) - 1)) { - return false; - } - - foreach ($value as $nestedValue) { - if (\is_array($nestedValue)) { - continue; - } - } - } + $attributesById = $this->buildAttributeMap($collection); + $isNestedQueryAttributeSupported = $this->adapter->supports(Capability::Objects) + && $this->adapter->supports(Capability::DefinedAttributes); - return true; + return $this->convertQueryWithMap($query, $attributesById, $isNestedQueryAttributeSupported); } - public function convertQuery(Document $collection, Query $query): Query + /** + * @param array $attributesById + * @throws QueryException + * @throws \Utopia\Database\Exception + */ + private function convertQueryWithMap(Query $query, array $attributesById, bool $isNestedQueryAttributeSupported): Query { - /** - * @var array $attributes - */ - $attributes = $collection->getAttribute('attributes', []); + $queryAttribute = $query->getAttribute(); + $isNestedQueryAttribute = $isNestedQueryAttributeSupported && \str_contains($queryAttribute, '.'); - foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { - $attributes[] = new Document($attribute); - } + $attribute = $attributesById[$queryAttribute] ?? null; - $queryAttribute = $query->getAttribute(); - $isNestedQueryAttribute = $this->getAdapter()->getSupportForAttributes() && $this->getAdapter()->getSupportForObject() && \str_contains($queryAttribute, '.'); - - $attribute = new Document(); - - foreach ($attributes as $attr) { - if ($attr->getId() === $query->getAttribute()) { - $attribute = $attr; - } elseif ($isNestedQueryAttribute) { - // nested object query - $baseAttribute = \explode('.', $queryAttribute, 2)[0]; - if ($baseAttribute === $attr->getId() && $attr->getAttribute('type') === Database::VAR_OBJECT) { - $query->setAttributeType(Database::VAR_OBJECT); - } + if ($attribute === null && $isNestedQueryAttribute) { + $baseAttribute = \explode('.', $queryAttribute, 2)[0]; + $base = $attributesById[$baseAttribute] ?? null; + if ($base !== null && $base->type === ColumnType::Object) { + $query->setAttributeType(ColumnType::Object->value); } } - if (!$attribute->isEmpty()) { - $query->setOnArray($attribute->getAttribute('array', false)); - $query->setAttributeType($attribute->getAttribute('type')); + if ($attribute !== null) { + $query->setOnArray($attribute->array); + $query->setAttributeType($attribute->type->value); - if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { + if ($attribute->type === ColumnType::Datetime) { $values = $query->getValues(); foreach ($values as $valueIndex => $value) { try { - $values[$valueIndex] = $this->adapter->getSupportForUTCCasting() - ? $this->adapter->setUTCDatetime($value) + /** @var string $value */ + $values[$valueIndex] = $this->adapterHasFeature(Feature\Casting::class) + ? $this->adapter->castDatetime($value) : DateTime::setTimezone($value); - } catch (\Throwable $e) { + } catch (Throwable $e) { throw new QueryException($e->getMessage(), $e->getCode(), $e); } } $query->setValues($values); } - } elseif (!$this->adapter->getSupportForAttributes()) { + } elseif (! $this->adapter->supports(Capability::DefinedAttributes)) { $values = $query->getValues(); // setting attribute type to properly apply filters in the adapter level - if ($this->adapter->getSupportForObject() && $this->isCompatibleObjectValue($values)) { - $query->setAttributeType(Database::VAR_OBJECT); + if ($this->adapter->supports(Capability::Objects) && $this->isCompatibleObjectValue($values)) { + $query->setAttributeType(ColumnType::Object->value); } } @@ -10075,66 +2543,126 @@ public function convertQuery(Document $collection, Query $query): Query } /** - * @return array> + * The definition of the metadata collection, which stores every other collection's definition. + */ + public static function collectionDefinition(): Collection + { + return clone self::sharedDefinition(); + } + + /** + * The one instance collectionDefinition() copies; whoever is handed it must not let it reach code that may + * change it. */ - public function getInternalAttributes(): array + private static function sharedDefinition(): Collection { - if ($this->adapter->getSharedTables()) { - return self::INTERNAL_ATTRIBUTES; + if (self::$definition !== null) { + return self::$definition; } - return self::$tenantlessInternalAttributes ??= \array_values(\array_filter( - self::INTERNAL_ATTRIBUTES, - fn (array $attribute): bool => $attribute['$id'] !== '$tenant', - )); + $definition = Collection::create( + id: self::METADATA, + name: 'collections', + attributes: [ + Attribute::string(self::COLLECTION_NAME, 256, required: true), + Attribute::string(self::COLLECTION_ATTRIBUTES, 1_000_000, filters: [Filter::Json]), + Attribute::string(self::COLLECTION_INDEXES, 1_000_000, filters: [Filter::Json]), + Attribute::boolean(self::COLLECTION_DOCUMENT_SECURITY, required: true), + ], + documentSecurity: false, + metadata: [Document::COLLECTION => self::METADATA], + ); + $definition->attributes(); + $definition->indexes(); + + return self::$definition = $definition; } /** - * Get Schema Attributes + * The attributes every document carries; `$tenant` only under shared tables. + * + * @return list + */ + public function internalAttributes(): array + { + return self::internalAttributesFor($this->adapter->hasSharedTables()); + } + + /** + * @internal for library code without a Database instance; use internalAttributes() + * + * @return list + */ + public static function internalAttributesFor(bool $sharedTables): array + { + return self::$internalAttributes[(int) $sharedTables] ??= [ + Attribute::string(Document::ID, required: true), + Attribute::id(Document::SEQUENCE, required: true), + Attribute::string(Document::COLLECTION, required: true), + ...($sharedTables ? [Attribute::id(Document::TENANT)] : []), + Attribute::datetime(Document::CREATED_AT), + Attribute::datetime(Document::UPDATED_AT), + Attribute::string(Document::PERMISSIONS, 1_000_000, default: [], filters: [Filter::Json]), + ]; + } + + /** + * The columns the engine holds for a collection, read back from its catalog; empty where the adapter does not + * support Capability::SchemaIntrospection. + * + * @return list * - * @param string $collection - * @return array * @throws DatabaseException */ public function getSchemaAttributes(string $collection): array { + if (! $this->adapter->supports(Capability::SchemaIntrospection)) { + return []; + } + return $this->adapter->getSchemaAttributes($collection); } /** - * @param string $collection - * @return array + * The indexes the engine holds for a collection, read back from its catalog; empty where the adapter does not + * support Capability::SchemaIntrospection. + * + * @return list + * + * @throws DatabaseException */ public function getSchemaIndexes(string $collection): array { + if (! $this->adapter->supports(Capability::SchemaIntrospection)) { + return []; + } + return $this->adapter->getSchemaIndexes($collection); } /** - * @param string $collectionId - * @param string|null $documentId * @return array{0: string, 1: string} */ public function getCacheBaseKeys(string $collectionId, ?string $documentId = null): array { - if ($this->adapter->getSupportForHostname()) { - $hostname = $this->adapter->getHostname(); - } + $hostname = $this->getHostname(); $tenantSegment = $this->adapter->getTenant(); if ( $collectionId === self::METADATA && - $this->adapter->getSharedTables() && + $this->adapter->hasSharedTables() && + $documentId !== null && isset($this->globalCollections[$documentId]) ) { $tenantSegment = null; } $collectionKey = \sprintf( - '%s-cache-%s:%s:%s:collection:%s', + '%s-cache-%s:%s:%s:%s:collection:%s', $this->cacheName, $hostname ?? '', + $this->adapter->getDatabase(), $this->getNamespace(), $tenantSegment, $collectionId @@ -10144,9 +2672,7 @@ public function getCacheBaseKeys(string $collectionId, ?string $documentId = nul } /** - * @param string $collectionId - * @param string|null $documentId - * @param array $selects + * @param array $selects * @return array{0: string, 1: string, 2: string} */ public function getCacheKeys(string $collectionId, ?string $documentId = null, array $selects = []): array @@ -10157,64 +2683,75 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $sortedSelects = $selects; \sort($sortedSelects); - $payload = ($this->resolveRelationships ? '1' : '0') - . ':' . $this->getFilterSignatureKey() - . ':' . ($sortedSelects === [] ? '' : (\json_encode($sortedSelects) ?: '')); - + $payload = \json_encode([ + 'selects' => $sortedSelects, + 'relationships' => $collectionId !== self::METADATA && ($this->relationshipHook?->isEnabled() ?? false), + 'filters' => $this->getActiveFilterSignatures(), + ]) ?: ''; $documentHashKey = $documentKey . ':' . \md5($payload); } return [ $collectionKey, $documentKey, - $documentHashKey ?? '' + $documentHashKey ?? '', ]; } /** - * Stable cache key for cached query entries on a collection. - * - * @param string $collectionId - * @param string|null $namespace - * @return string + * Key of a collection's caller-owned withCache() region. find() caches its results in + * the query cache instead; purgeCachedQueries() clears both. */ public function getQueryCacheKey(string $collectionId, ?string $namespace = null): string { - $hostname = $this->adapter->getSupportForHostname() - ? $this->adapter->getHostname() - : ''; - return \sprintf( - '%s-cache-%s:%s:%s:collection:%s:query', + '%s-cache-%s:%s:%s:%s:collection:%s:query', $this->cacheName, - $hostname, + $this->getHostname() ?? '', + $this->adapter->getDatabase(), $namespace ?? $this->getNamespace(), $this->adapter->getTenant(), $collectionId, ); } + protected function getQueryCacheScope(?string $namespace = null): Scope + { + return new Scope( + hostname: $this->getHostname() ?? '', + database: $this->adapter->getDatabase(), + namespace: $namespace ?? $this->adapter->getNamespace(), + tenant: $this->adapter->getTenant(), + name: $this->cacheName, + writerTimeout: $this->cacheWriterTimeout, + ); + } + /** * Stable cache field for cached query entries on a collection. * - * @param Document|null $collection - * @param array $queries - * @param string $field - * @param string $forPermission - * @return string|null + * @internal + * + * @param array $queries */ public function getQueryCacheField( ?Document $collection = null, array $queries = [], string $field = 'documents', - string $forPermission = self::PERMISSION_READ, + PermissionType $forPermission = PermissionType::Read, ): ?string { $this->checkQueryTypes($queries); - if ($forPermission !== self::PERMISSION_READ) { + if ($forPermission !== PermissionType::Read || $this->adapter->inTransaction()) { return null; } + foreach ($queries as $query) { + if ($query->getMethod() === Method::OrderRandom) { + return null; + } + } + $authorizationRoles = \array_values(\array_unique($this->authorization->getRoles())); \sort($authorizationRoles); @@ -10229,24 +2766,13 @@ public function getQueryCacheField( fn (Query $query): array => $this->serializeQueryCacheQuery($query), $queries, ), - 'relationships' => $this->resolveRelationships, + 'relationships' => $this->relationshipHook?->isEnabled() ?? false, 'filters' => $this->getActiveFilterSignatures(), ]; - $schemaHash = ''; - if ($collection !== null && !$collection->isEmpty()) { - // Schema-affecting changes must move callers onto a fresh cache field. - $schemaHash = \md5( - \json_encode($collection->getAttribute('attributes', [])) - . \json_encode($collection->getAttribute('indexes', [])) - . \json_encode($collection->getAttribute('$permissions', [])) - . \json_encode($collection->getAttribute('documentSecurity', false)) - ); - } - return \sprintf( '%s:%s:%s', - $schemaHash, + $collection === null ? '' : Collection::fromDocument($collection)->fingerprint(), \md5(\json_encode($queryPayload) ?: ''), $field, ); @@ -10258,7 +2784,7 @@ public function getQueryCacheField( private function serializeQueryCacheQuery(Query $query): array { $serialized = [ - 'method' => $query->getMethod(), + 'method' => $query->getMethod()->value, ]; if ($query->getAttribute() !== '') { @@ -10286,7 +2812,7 @@ private function normalizeQueryCacheQueryValue(mixed $value): mixed $value = $value->getArrayCopy(); } - if (!\is_array($value)) { + if (! \is_array($value)) { return $value; } @@ -10297,757 +2823,337 @@ private function normalizeQueryCacheQueryValue(mixed $value): mixed return $value; } + protected function getCollectionMetadataCacheKey(string $collection): string + { + $tenant = $this->adapter->getTenant(); + $tenantKey = match (true) { + $tenant === null => 'null', + \is_int($tenant) => 'integer:'.$tenant, + default => 'string:'.\strlen($tenant).':'.$tenant, + }; + + return $this->adapter->getDatabase().'::'.$this->adapter->getNamespace() + .'::'.$tenantKey.'::'.$collection; + } + /** * @return array */ private function getActiveFilterSignatures(): array { - if (!$this->filter) { + if (! $this->filtering()->get()) { return []; } - $this->refreshFilterSignatures(); - - return $this->disabledFilters - ? \array_diff_key($this->filterSignatures, $this->disabledFilters) - : $this->filterSignatures; - } - - private function refreshFilterSignatures(): void - { - if ( - $this->filterSignaturesVersion === self::$filtersVersion - && $this->filterSignaturesSource === $this->instanceFilters - ) { - return; - } - $signatures = []; foreach (self::$filters as $name => $callbacks) { - if (\array_key_exists($name, $this->instanceFilters)) { - continue; - } - $signatures[$name] = $callbacks['signature']; - } - - foreach ($this->instanceFilters as $name => $callbacks) { $signatures[$name] = $callbacks['signature']; } + $signatures = \array_diff_key(($this->codecs?->signatures() ?? []) + $signatures, $this->filterExclusions()->get() ?? []); \ksort($signatures); - $this->filterSignatures = $signatures; - $this->filterSignaturesEncoded = \json_encode($signatures) ?: ''; - $this->filterSignaturesVersion = self::$filtersVersion; - $this->filterSignaturesSource = $this->instanceFilters; + return $signatures; } - private function getFilterSignatureKey(): string + /** + * Run mandatory cache invalidation for a lifecycle event. + */ + protected function invalidate(Event $event, mixed $data = null): void { - if (!$this->filter) { - return ''; - } - - if ($this->disabledFilters) { - return \json_encode($this->getActiveFilterSignatures()) ?: ''; + $invalidator = $this->invalidator; + if ($invalidator === null || ! $invalidator->isMutation($event)) { + return; } - $this->refreshFilterSignatures(); - - return $this->filterSignaturesEncoded; + $tokens = $this->getInvalidationTokens($event, $data); + $invalidator->block($tokens); + $invalidator->activate($tokens, $this->cacheWriterTimeout); } - private static function computeCallableSignature(callable $callable): string + /** + * @return array + */ + protected function getInvalidationTokens(Event $event, mixed $data = null): array { - if (\is_string($callable)) { - return $callable; - } - - if (\is_array($callable)) { - $class = \is_object($callable[0]) ? \get_class($callable[0]) : $callable[0]; - return $class . '::' . $callable[1]; - } - - $closure = \Closure::fromCallable($callable); - $ref = new \ReflectionFunction($closure); - return ($ref->getFileName() ?: 'unknown') . ':' . $ref->getStartLine(); + return $this->invalidator?->tokens( + $event, + $data, + $this->getQueryCacheScope(), + $this->adapter->hasSharedTables() && $this->adapter->isTenantPerDocument(), + ) ?? []; } /** - * @param array $queries - * @return void - * @throws QueryException + * @param array $tokens */ - private function checkQueryTypes(array $queries): void + protected function blockInvalidation(array $tokens): void { - foreach ($queries as $query) { - if (!$query instanceof Query) { - throw new QueryException('Invalid query type: "' . \gettype($query) . '". Expected instances of "' . Query::class . '"'); - } + $this->invalidator?->block($tokens); + } - if ($query->isNested()) { - $this->checkQueryTypes($query->getValues()); - } - } + /** + * @param array $tokens + */ + protected function activateInvalidation(array $tokens): void + { + $this->invalidator?->activate($tokens, $this->cacheWriterTimeout); } /** - * Process relationship queries, extracting nested selections. + * The registered lifecycle hooks that handle the event now; a trigger site builds the typed event only when there + * is one, and hands them to {@see self::dispatch()}. * - * @param array $relationships - * @param array $queries - * @return array> $selects + * An operation computes the list once and reuses it for every event of its dispatch loop, so the list is a + * snapshot: a hook silenced while the loop runs still receives the rest of that operation's events, one + * unsilenced stays out of them, and the change applies from the next operation. + * + * @return list */ - private function processRelationshipQueries( - array $relationships, - array $queries, - ): array { - $nestedSelections = []; + protected function listens(Event $event): array + { + if ($this->lifecycleHooks === [] || $this->areEventsSilenced()) { + return []; + } - foreach ($queries as $query) { - if ($query->getMethod() !== Query::TYPE_SELECT) { + $silenced = $this->silencedListeners()->get(); + $active = []; + foreach ($this->lifecycleHooks as $hook) { + if ($hook instanceof Named && isset($silenced[$hook->getName()])) { continue; } - - $values = $query->getValues(); - foreach ($values as $valueIndex => $value) { - if (!\is_string($value) || !\str_contains($value, '.')) { - continue; - } - - $nesting = \explode('.', $value); - $selectedKey = \array_shift($nesting); // Remove and return first item - - $relationship = \array_values(\array_filter( - $relationships, - fn (Document $relationship) => $relationship->getAttribute('key') === $selectedKey, - ))[0] ?? null; - - if (!$relationship) { - continue; - } - - // Shift the top level off the dot-path to pass the selection down the chain - // 'foo.bar.baz' becomes 'bar.baz' - - $nestingPath = \implode('.', $nesting); - - // If nestingPath is empty, it means we want all attributes (*) for this relationship - if (empty($nestingPath)) { - $nestedSelections[$selectedKey][] = Query::select(['*']); - } else { - $nestedSelections[$selectedKey][] = Query::select([$nestingPath]); - } - - $type = $relationship->getAttribute('options')['relationType']; - $side = $relationship->getAttribute('options')['side']; - - switch ($type) { - case Database::RELATION_MANY_TO_MANY: - unset($values[$valueIndex]); - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { - unset($values[$valueIndex]); - } else { - $values[$valueIndex] = $selectedKey; - } - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - $values[$valueIndex] = $selectedKey; - } else { - unset($values[$valueIndex]); - } - break; - case Database::RELATION_ONE_TO_ONE: - $values[$valueIndex] = $selectedKey; - break; - } - } - - $finalValues = \array_values($values); - if ($query->getMethod() === Query::TYPE_SELECT) { - if (empty($finalValues)) { - $finalValues = ['*']; - } + if ($hook instanceof Selective && ! $hook->handles($event)) { + continue; } - $query->setValues($finalValues); + $active[] = $hook; } - return $nestedSelections; + return $active; } /** - * Process nested relationship path iteratively - * - * Instead of recursive calls, this method processes multi-level queries in a single loop - * working from the deepest level up to minimize database queries. + * Hand a typed event to the lifecycle hooks {@see self::listens()} returned for it, after mandatory invalidation + * succeeded. * - * Example: For "project.employee.company.name": - * 1. Query companies matching name filter -> IDs [c1, c2] - * 2. Query employees with company IN [c1, c2] -> IDs [e1, e2, e3] - * 3. Query projects with employee IN [e1, e2, e3] -> IDs [p1, p2] - * 4. Return [p1, p2] + * Whether a hook's exception reaches the caller depends on the event + * ({@see propagatesHookFailures()}); an \Error always does. * - * @param string $startCollection The starting collection for the path - * @param array $queries Queries with nested paths - * @return array|null Array of matching IDs or null if no matches + * @param list $listeners */ - private function processNestedRelationshipPath(string $startCollection, array $queries): ?array + protected function dispatch(Domain $event, array $listeners): void { - // Build a map of all nested paths and their queries - $pathGroups = []; - foreach ($queries as $query) { - $attribute = $query->getAttribute(); - if (\str_contains($attribute, '.')) { - $parts = \explode('.', $attribute); - $pathKey = \implode('.', \array_slice($parts, 0, -1)); // Everything except the last part - if (!isset($pathGroups[$pathKey])) { - $pathGroups[$pathKey] = []; - } - $pathGroups[$pathKey][] = [ - 'method' => $query->getMethod(), - 'attribute' => \end($parts), // The actual attribute to query - 'values' => $query->getValues(), - ]; - } - } - - $allMatchingIds = []; - foreach ($pathGroups as $path => $queryGroup) { - $pathParts = \explode('.', $path); - $currentCollection = $startCollection; - $relationshipChain = []; - - foreach ($pathParts as $relationshipKey) { - $collectionDoc = $this->silent(fn () => $this->getCollection($currentCollection)); - $relationships = \array_filter( - $collectionDoc->getAttribute('attributes', []), - fn ($attr) => $attr['type'] === self::VAR_RELATIONSHIP - ); - - $relationship = null; - foreach ($relationships as $rel) { - if ($rel['key'] === $relationshipKey) { - $relationship = $rel; - break; - } - } - - if (!$relationship) { - return null; - } - - $relationshipChain[] = [ - 'key' => $relationshipKey, - 'fromCollection' => $currentCollection, - 'toCollection' => $relationship['options']['relatedCollection'], - 'relationType' => $relationship['options']['relationType'], - 'side' => $relationship['options']['side'], - 'twoWayKey' => $relationship['options']['twoWayKey'], - ]; - - $currentCollection = $relationship['options']['relatedCollection']; - } - - // Now walk backwards from the deepest collection to the starting collection - $leafQueries = []; - foreach ($queryGroup as $q) { - $leafQueries[] = new Query($q['method'], $q['attribute'], $q['values']); - } - - // Query the deepest collection - $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( - $currentCollection, - \array_merge($leafQueries, [ - Query::select(['$id']), - Query::limit(PHP_INT_MAX), - ]) - ))); - - $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); - - if (empty($matchingIds)) { - return null; - } - - // Walk back up the chain - for ($i = \count($relationshipChain) - 1; $i >= 0; $i--) { - $link = $relationshipChain[$i]; - $relationType = $link['relationType']; - $side = $link['side']; - - // Determine how to query the parent collection - $needsReverseLookup = ( - ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || - ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || - ($relationType === self::RELATION_MANY_TO_MANY) - ); - - if ($needsReverseLookup) { - if ($relationType === self::RELATION_MANY_TO_MANY) { - // For many-to-many, query the junction table directly instead - // of resolving full relationships on the child documents. - $fromCollectionDoc = $this->silent(fn () => $this->getCollection($link['fromCollection'])); - $toCollectionDoc = $this->silent(fn () => $this->getCollection($link['toCollection'])); - $junction = $this->getJunctionCollection($fromCollectionDoc, $toCollectionDoc, $link['side']); - - $junctionDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find($junction, [ - Query::equal($link['key'], $matchingIds), - Query::limit(PHP_INT_MAX), - ]))); - - $parentIds = []; - foreach ($junctionDocs as $jDoc) { - $pId = $jDoc->getAttribute($link['twoWayKey']); - if ($pId && !\in_array($pId, $parentIds)) { - $parentIds[] = $pId; - } - } - } else { - // Need to find parents by querying children and extracting parent IDs - $childDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( - $link['toCollection'], - [ - Query::equal('$id', $matchingIds), - Query::select(['$id', $link['twoWayKey']]), - Query::limit(PHP_INT_MAX), - ] - ))); - - $parentIds = []; - foreach ($childDocs as $doc) { - $parentValue = $doc->getAttribute($link['twoWayKey']); - if (\is_array($parentValue)) { - foreach ($parentValue as $pId) { - if ($pId instanceof Document) { - $pId = $pId->getId(); - } - if ($pId && !\in_array($pId, $parentIds)) { - $parentIds[] = $pId; - } - } - } else { - if ($parentValue instanceof Document) { - $parentValue = $parentValue->getId(); - } - if ($parentValue && !\in_array($parentValue, $parentIds)) { - $parentIds[] = $parentValue; - } - } - } - } - $matchingIds = $parentIds; - } else { - // Can directly filter parent by the relationship key - $parentDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( - $link['fromCollection'], - [ - Query::equal($link['key'], $matchingIds), - Query::select(['$id']), - Query::limit(PHP_INT_MAX), - ] - ))); - $matchingIds = \array_map(fn ($doc) => $doc->getId(), $parentDocs); - } - - if (empty($matchingIds)) { - return null; - } - } - - $allMatchingIds = \array_merge($allMatchingIds, $matchingIds); - } + $propagates = $this->propagatesHookFailures($event->event); - return \array_unique($allMatchingIds); + foreach ($listeners as $hook) { + try { + $hook->handle($event); + } catch (Exception $exception) { + if ($propagates) { + throw $exception; + } + } + } + } + + /** + * Hand a typed event to the lifecycle hooks that handle it and let the first hook exception reach the caller + * whatever the event's default. Document writes and purgeCachedDocument() dispatch Event::DocumentPurge through + * it; the schema changes that purge a collection dispatch it through dispatch(), isolated. + * + * @param list $listeners + */ + protected function dispatchPropagating(Domain $event, array $listeners): void + { + foreach ($listeners as $hook) { + $hook->handle($event); + } + } + + /** + * Keeps 7.x behaviour: the events it dispatched unguarded let a listener failure fail + * the call; the ones it wrapped in try/catch isolate every hook from the others. + */ + private function propagatesHookFailures(Event $event): bool + { + return match ($event) { + Event::IndexCreate, + Event::IndexesCreate, + Event::DocumentRead, + Event::DocumentCreate, + Event::DocumentsCreate, + Event::DocumentUpdate, + Event::DocumentsUpdate, + Event::DocumentUpsert, + Event::DocumentsUpsert, + Event::DocumentIncrease, + Event::DocumentDecrease, + Event::DocumentDelete, + Event::DocumentsDelete, + Event::DocumentFind, + Event::DocumentAggregate, + Event::DocumentCount, + Event::DocumentSum => true, + Event::All, + Event::DatabaseList, + Event::DatabaseCreate, + Event::DatabaseUpdate, + Event::DatabaseDelete, + Event::CollectionList, + Event::CollectionCreate, + Event::CollectionUpdate, + Event::CollectionRead, + Event::CollectionDelete, + Event::DocumentPurge, + Event::PermissionsCreate, + Event::PermissionsRead, + Event::PermissionsDelete, + Event::AttributeCreate, + Event::AttributesCreate, + Event::AttributeUpdate, + Event::AttributeRename, + Event::AttributeDelete, + Event::IndexRename, + Event::IndexDelete => false, + }; } /** - * Convert relationship queries to SQL-safe subqueries recursively - * - * Queries like Query::equal('author.name', ['Alice']) are converted to - * Query::equal('author', []) - * - * This method supports multi-level nested relationship queries: - * - Depth 1: employee.name - * - Depth 2: employee.company.name - * - Depth 3: project.employee.company.name + * Create a document instance of the appropriate type from data read back from storage or the + * cache. Non-string permissions are dropped, as Document::fromStorage() does, instead of failing + * the read; a mapped type is kept. * - * The method works by: - * 1. Parsing dot-path queries (e.g., "project.employee.company.name") - * 2. Extracting the first relationship (e.g., "project") - * 3. If the nested attribute still contains dots, using iterative processing - * 4. Finding matching documents in the related collection - * 5. Converting to filters on the parent collection - * - * @param array $relationships - * @param array $queries - * @return array|null Returns null if relationship filters cannot match any documents + * @param array $data Document data */ - private function convertRelationshipQueries( - array $relationships, - array $queries, - ?Document $collection = null, - ): ?array { - // Early return if no relationship queries exist - $hasRelationshipQuery = false; - foreach ($queries as $query) { - $attr = $query->getAttribute(); - if (\str_contains($attr, '.') || $query->getMethod() === Query::TYPE_CONTAINS_ALL) { - $hasRelationshipQuery = true; - break; - } + protected function newDocument(string $collection, array $data): Document + { + $className = $this->documentTypes[$collection] ?? null; + if ($className === null) { + return Document::fromStorage($data); } - if (!$hasRelationshipQuery) { - return $queries; + try { + return $className::fromArray($data); + } catch (StructureException) { + return $className::fromArray(self::withStringPermissions($data)); } + } - $relationshipsByKey = []; - foreach ($relationships as $relationship) { - $relationshipsByKey[$relationship->getAttribute('key')] = $relationship; + /** + * The data with the non-string permissions of the document, and of the documents nested in it + * the way the Document constructor nests them, dropped. + * + * @param array $data + * @return array + */ + private static function withStringPermissions(array $data): array + { + $permissions = $data[Document::PERMISSIONS] ?? null; + if (\is_array($permissions)) { + $data[Document::PERMISSIONS] = \array_values(\array_filter($permissions, \is_string(...))); } - $additionalQueries = []; - $groupedQueries = []; - $indicesToRemove = []; - - // Handle containsAll queries first - foreach ($queries as $index => $query) { - if ($query->getMethod() !== Query::TYPE_CONTAINS_ALL) { + foreach ($data as $key => $value) { + if (! \is_array($value)) { continue; } - $attribute = $query->getAttribute(); - - if (!\str_contains($attribute, '.')) { - continue; // Non-relationship containsAll handled by adapter - } - - $parts = \explode('.', $attribute); - $relationshipKey = \array_shift($parts); - $nestedAttribute = \implode('.', $parts); - $relationship = $relationshipsByKey[$relationshipKey] ?? null; + if (isset($value[Document::ID]) || isset($value[Document::COLLECTION])) { + /** @var array $value */ + $data[$key] = self::withStringPermissions($value); - if (!$relationship) { continue; } - // Resolve each value independently, then intersect parent IDs - $parentIdSets = []; - $resolvedAttribute = '$id'; - foreach ($query->getValues() as $value) { - $relatedQuery = Query::equal($nestedAttribute, [$value]); - $result = $this->resolveRelationshipGroupToIds($relationship, [$relatedQuery], $collection); - - if ($result === null) { - return null; + foreach ($value as $childKey => $child) { + if (\is_array($child) && (isset($child[Document::ID]) || isset($child[Document::COLLECTION]))) { + /** @var array $child */ + $value[$childKey] = self::withStringPermissions($child); } - - $resolvedAttribute = $result['attribute']; - $parentIdSets[] = $result['ids']; - } - - $ids = \count($parentIdSets) > 1 - ? \array_values(\array_intersect(...$parentIdSets)) - : ($parentIdSets[0] ?? []); - - if (empty($ids)) { - return null; } - - $additionalQueries[] = Query::equal($resolvedAttribute, $ids); - $indicesToRemove[] = $index; + $data[$key] = $value; } - // Group regular dot-path queries by relationship key - foreach ($queries as $index => $query) { - if ($query->getMethod() === Query::TYPE_SELECT || $query->getMethod() === Query::TYPE_CONTAINS_ALL) { - continue; - } - - $attribute = $query->getAttribute(); - - if (!\str_contains($attribute, '.')) { - continue; - } - - $parts = \explode('.', $attribute); - $relationshipKey = \array_shift($parts); - $nestedAttribute = \implode('.', $parts); - $relationship = $relationshipsByKey[$relationshipKey] ?? null; + return $data; + } - if (!$relationship) { - continue; + /** + * @throws DatabaseException + */ + protected function encodeAttribute(string $name, mixed $value, Document $document): mixed + { + try { + $codec = $this->codecs?->get($name); + if ($codec !== null) { + return $codec->encode($value); } - if (!isset($groupedQueries[$relationshipKey])) { - $groupedQueries[$relationshipKey] = [ - 'relationship' => $relationship, - 'queries' => [], - 'indices' => [] - ]; + if (\array_key_exists($name, self::$filters)) { + return self::$filters[$name]['encode']($value, $document, $this); } - - $groupedQueries[$relationshipKey]['queries'][] = [ - 'method' => $query->getMethod(), - 'attribute' => $nestedAttribute, - 'values' => $query->getValues() - ]; - - $groupedQueries[$relationshipKey]['indices'][] = $index; + } catch (Throwable $th) { + throw new DatabaseException($th->getMessage(), $th->getCode(), $th); } - // Process each relationship group - foreach ($groupedQueries as $relationshipKey => $group) { - $relationship = $group['relationship']; - - // Detect impossible conditions: multiple equal on same attribute - $equalAttrs = []; - foreach ($group['queries'] as $queryData) { - if ($queryData['method'] === Query::TYPE_EQUAL) { - $attr = $queryData['attribute']; - if (isset($equalAttrs[$attr])) { - throw new QueryException("Multiple equal queries on '{$relationshipKey}.{$attr}' will never match a single document. Use Query::containsAll() to match across different related documents."); - } - $equalAttrs[$attr] = true; - } - } - - $relatedQueries = []; - foreach ($group['queries'] as $queryData) { - $relatedQueries[] = new Query( - $queryData['method'], - $queryData['attribute'], - $queryData['values'] - ); - } - - try { - $result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries, $collection); - - if ($result === null) { - return null; - } - - $additionalQueries[] = Query::equal($result['attribute'], $result['ids']); + throw new NotFoundException("Filter: {$name} not found"); + } - foreach ($group['indices'] as $originalIndex) { - $indicesToRemove[] = $originalIndex; - } - } catch (QueryException $e) { - throw $e; - } catch (\Exception $e) { - return null; - } + /** + * @throws NotFoundException + */ + protected function decodeAttribute(string $filter, mixed $value, Document $document, string $attribute): mixed + { + $codec = $this->codecs?->get($filter); + if ($codec !== null) { + return $codec->decode($value); } - // Remove the original queries - foreach ($indicesToRemove as $index) { - unset($queries[$index]); + if (\array_key_exists($filter, self::$filters)) { + return self::$filters[$filter]['decode']($value, $document, $this, $attribute); } - // Merge additional queries - return \array_merge(\array_values($queries), $additionalQueries); + throw new NotFoundException("Filter \"{$filter}\" not found for attribute \"{$attribute}\""); } /** - * Resolve a group of relationship queries to matching document IDs. + * Check if values are compatible with object attribute type (hashmap/multi-dimensional array) * - * @param Document $relationship - * @param array $relatedQueries Queries on the related collection - * @param Document|null $collection The parent collection document (needed for junction table lookups) - * @return array{attribute: string, ids: string[]}|null + * @param array $values */ - private function resolveRelationshipGroupToIds( - Document $relationship, - array $relatedQueries, - ?Document $collection = null, - ): ?array { - $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; - $relationType = $relationship->getAttribute('options')['relationType']; - $side = $relationship->getAttribute('options')['side']; - $relationshipKey = $relationship->getAttribute('key'); - - // Process multi-level queries by walking the relationship chain - $hasNestedPaths = false; - foreach ($relatedQueries as $relatedQuery) { - if (\str_contains($relatedQuery->getAttribute(), '.')) { - $hasNestedPaths = true; - break; - } + private function isCompatibleObjectValue(array $values): bool + { + if (empty($values)) { + return false; } - if ($hasNestedPaths) { - $matchingIds = $this->processNestedRelationshipPath( - $relatedCollection, - $relatedQueries - ); - - if ($matchingIds === null || empty($matchingIds)) { - return null; + foreach ($values as $value) { + if (! \is_array($value)) { + return false; } - $relatedQueries = \array_values(\array_merge( - \array_filter($relatedQueries, fn (Query $q) => !\str_contains($q->getAttribute(), '.')), - [Query::equal('$id', $matchingIds)] - )); - } - - $needsParentResolution = ( - ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || - ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || - ($relationType === self::RELATION_MANY_TO_MANY) - ); - - if ($relationType === self::RELATION_MANY_TO_MANY && $needsParentResolution && $collection !== null) { - // For many-to-many, query the junction table directly instead of relying - // on relationship population (which fails when resolveRelationships is false, - // e.g. when the outer find() is wrapped in skipRelationships()). - $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( - $relatedCollection, - \array_merge($relatedQueries, [ - Query::select(['$id']), - Query::limit(PHP_INT_MAX), - ]) - ))); - - $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); - - if (empty($matchingIds)) { - return null; + if (empty($value)) { + continue; } - $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; - $relatedCollectionDoc = $this->silent(fn () => $this->getCollection($relatedCollection)); - $junction = $this->getJunctionCollection($collection, $relatedCollectionDoc, $side); - - $junctionDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find($junction, [ - Query::equal($relationshipKey, $matchingIds), - Query::limit(PHP_INT_MAX), - ]))); - - $parentIds = []; - foreach ($junctionDocs as $jDoc) { - $pId = $jDoc->getAttribute($twoWayKey); - if ($pId && !\in_array($pId, $parentIds)) { - $parentIds[] = $pId; - } + // simple indexed array => not an object + if (\array_keys($value) === \range(0, \count($value) - 1)) { + return false; } - return empty($parentIds) ? null : ['attribute' => '$id', 'ids' => $parentIds]; - } elseif ($needsParentResolution) { - // For one-to-many/many-to-one parent resolution, we need relationship - // population to read the twoWayKey attribute from the related documents. - $matchingDocs = $this->silent(fn () => $this->find( - $relatedCollection, - \array_merge($relatedQueries, [ - Query::limit(PHP_INT_MAX), - ]) - )); - - $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; - $parentIds = []; - - foreach ($matchingDocs as $doc) { - $parentId = $doc->getAttribute($twoWayKey); - - if (\is_array($parentId)) { - foreach ($parentId as $id) { - if ($id instanceof Document) { - $id = $id->getId(); - } - if ($id && !\in_array($id, $parentIds)) { - $parentIds[] = $id; - } - } - } else { - if ($parentId instanceof Document) { - $parentId = $parentId->getId(); - } - if ($parentId && !\in_array($parentId, $parentIds)) { - $parentIds[] = $parentId; - } + foreach ($value as $nestedValue) { + if (\is_array($nestedValue)) { + continue; } } - - return empty($parentIds) ? null : ['attribute' => '$id', 'ids' => $parentIds]; - } else { - $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( - $relatedCollection, - \array_merge($relatedQueries, [ - Query::select(['$id']), - Query::limit(PHP_INT_MAX), - ]) - ))); - - $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); - return empty($matchingIds) ? null : ['attribute' => $relationshipKey, 'ids' => $matchingIds]; - } - } - - /** - * Encode spatial data from array format to WKT (Well-Known Text) format - * - * @param mixed $value - * @param string $type - * @return string - * @throws DatabaseException - */ - protected function encodeSpatialData(mixed $value, string $type): string - { - $validator = new Spatial($type); - if (!$validator->isValid($value)) { - throw new StructureException($validator->getDescription()); } - switch ($type) { - case self::VAR_POINT: - return "POINT({$value[0]} {$value[1]})"; - - case self::VAR_LINESTRING: - $points = []; - foreach ($value as $point) { - $points[] = "{$point[0]} {$point[1]}"; - } - return 'LINESTRING(' . implode(', ', $points) . ')'; - - case self::VAR_POLYGON: - // Check if this is a single ring (flat array of points) or multiple rings - $isSingleRing = count($value) > 0 && is_array($value[0]) && - count($value[0]) === 2 && is_numeric($value[0][0]) && is_numeric($value[0][1]); - - if ($isSingleRing) { - // Convert single ring format [[x1,y1], [x2,y2], ...] to multi-ring format - $value = [$value]; - } - - $rings = []; - foreach ($value as $ring) { - $points = []; - foreach ($ring as $point) { - $points[] = "{$point[0]} {$point[1]}"; - } - $rings[] = '(' . implode(', ', $points) . ')'; - } - return 'POLYGON(' . implode(', ', $rings) . ')'; - - default: - throw new DatabaseException('Unknown spatial type: ' . $type); - } + return true; } /** * Retry a callable with exponential backoff * - * @param callable $operation The operation to retry - * @param int $maxAttempts Maximum number of retry attempts - * @param int $initialDelayMs Initial delay in milliseconds - * @param float $multiplier Backoff multiplier - * @return void The result of the operation - * @throws \Throwable The last exception if all retries fail + * @throws Throwable The last exception if all retries fail */ private function withRetries( callable $operation, @@ -11057,13 +3163,18 @@ private function withRetries( ): void { $attempt = 0; $delayMs = $initialDelayMs; - $lastException = null; + $lastException = new DatabaseException('All retry attempts failed'); while ($attempt < $maxAttempts) { try { $operation(); + return; - } catch (\Throwable $e) { + } catch (Throwable $e) { + if (! $this->isRetryable($e)) { + throw $e; + } + $lastException = $e; $attempt++; @@ -11077,21 +3188,33 @@ private function withRetries( \usleep($delayMs * 1000); } - $delayMs = (int)($delayMs * $multiplier); + $delayMs = (int) ($delayMs * $multiplier); } } throw $lastException; } + private function isRetryable(Throwable $error): bool + { + if ($this->mayHaveCommitted($error) || $this->retriedByTransaction($error)) { + return false; + } + + foreach (self::DETERMINISTIC_FAILURES as $deterministic) { + if ($error instanceof $deterministic) { + return false; + } + } + + return true; + } + /** * Generic cleanup operation with retry logic * - * @param callable $operation The cleanup operation to execute - * @param string $resourceType Type of resource being cleaned up (e.g., 'attribute', 'index') - * @param string $resourceId ID of the resource being cleaned up - * @param int $maxAttempts Maximum retry attempts - * @return void + * @param string $resourceType Type of resource being cleaned up (e.g., 'attribute', 'index') + * * @throws DatabaseException If cleanup fails after all retries */ private function cleanup( @@ -11102,49 +3225,24 @@ private function cleanup( ): void { try { $this->withRetries($operation, maxAttempts: $maxAttempts); - } catch (\Throwable $e) { - Console::error("Failed to cleanup {$resourceType} '{$resourceId}' after {$maxAttempts} attempts: " . $e->getMessage()); + } catch (Throwable $e) { + Console::error("Failed to cleanup {$resourceType} '{$resourceId}' after {$maxAttempts} attempts: ".$e->getMessage()); throw $e; } } - /** - * Cleanup (delete) an index with retry logic - * - * @param string $collectionId The collection ID - * @param string $indexId The index ID - * @param int $maxAttempts Maximum retry attempts - * @return void - * @throws DatabaseException If cleanup fails after all retries - */ - private function cleanupIndex( - string $collectionId, - string $indexId, - int $maxAttempts = 3 - ): void { - $this->cleanup( - fn () => $this->adapter->deleteIndex($collectionId, $indexId), - 'index', - $indexId, - $maxAttempts - ); - } - /** * Persist metadata with automatic rollback on failure * - * Centralizes the common pattern of: - * 1. Attempting to persist metadata with retry - * 2. Rolling back database operations if metadata persistence fails - * 3. Providing detailed error messages for both success and failure scenarios + * A failure raised after the metadata write committed (its cache invalidation or events), or a commit that + * could not be confirmed, is rethrown unchanged and rolls nothing back: the definition it reports on may be stored. + * + * @param callable|null $rollbackOperation Cleanup operation to run if persistence fails (null if no cleanup needed) + * @param bool $shouldRollback Whether rollback should be attempted (e.g., false for duplicates in shared tables) + * @param string $operationDescription Description of the operation for error messages + * @param bool $rollbackReturnsErrors Whether rollback operation returns error array (true) or throws (false) + * @param bool $silentRollback Whether a failed rollback is reported after the persistence error (true) or fails the call as a cleanup failure (false) * - * @param Document $collection The collection document to persist - * @param callable|null $rollbackOperation Cleanup operation to run if persistence fails (null if no cleanup needed) - * @param bool $shouldRollback Whether rollback should be attempted (e.g., false for duplicates in shared tables) - * @param string $operationDescription Description of the operation for error messages - * @param bool $rollbackReturnsErrors Whether rollback operation returns error array (true) or throws (false) - * @param bool $silentRollback Whether rollback errors should be silently caught (true) or thrown (false) - * @return void * @throws DatabaseException If metadata persistence fails after all retries */ private function updateMetadata( @@ -11161,32 +3259,34 @@ private function updateMetadata( fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) ); } - } catch (\Throwable $e) { - // Attempt rollback only if conditions are met + } catch (Throwable $e) { + if ($this->mayHaveCommitted($e)) { + throw $e; + } + + $cleanupFailure = ''; if ($shouldRollback && $rollbackOperation !== null) { if ($rollbackReturnsErrors) { - // Batch mode: rollback returns array of errors + /** @var array $cleanupErrors */ $cleanupErrors = $rollbackOperation(); - if (!empty($cleanupErrors)) { + if (! empty($cleanupErrors)) { throw new DatabaseException( - "Failed to persist metadata after retries and cleanup encountered errors for {$operationDescription}: " . $e->getMessage() . ' | Cleanup errors: ' . implode(', ', $cleanupErrors), + "Failed to persist metadata after retries and cleanup encountered errors for {$operationDescription}: ".$e->getMessage().' | Cleanup errors: '.implode(', ', $cleanupErrors), previous: $e ); } } elseif ($silentRollback) { - // Silent mode: swallow rollback errors try { $rollbackOperation(); - } catch (\Throwable $e) { - // Silent rollback - errors are swallowed + } catch (Throwable $cleanupError) { + $cleanupFailure = ' | Cleanup error: '.$cleanupError->getMessage(); } } else { - // Regular mode: rollback throws on failure try { $rollbackOperation(); - } catch (\Throwable $ex) { + } catch (Throwable $cleanupError) { throw new DatabaseException( - "Failed to persist metadata after retries and cleanup failed for {$operationDescription}: " . $ex->getMessage() . ' | Cleanup error: ' . $e->getMessage(), + "Failed to persist metadata after retries and cleanup failed for {$operationDescription}: ".$e->getMessage().' | Cleanup error: '.$cleanupError->getMessage(), previous: $e ); } @@ -11194,26 +3294,9 @@ private function updateMetadata( } throw new DatabaseException( - "Failed to persist metadata after retries for {$operationDescription}: " . $e->getMessage(), + "Failed to persist metadata after retries for {$operationDescription}: ".$e->getMessage().$cleanupFailure, previous: $e ); } } - - /** - * Rollback metadata state by removing specified attributes from collection - * - * @param Document $collection The collection document - * @param array $attributeIds Attribute IDs to remove - * @return void - */ - private function rollbackAttributeMetadata(Document $collection, array $attributeIds): void - { - $attributes = $collection->getAttribute('attributes', []); - $filteredAttributes = \array_filter( - $attributes, - fn ($attr) => !\in_array($attr->getId(), $attributeIds) - ); - $collection->setAttribute('attributes', \array_values($filteredAttributes)); - } } diff --git a/src/Database/DateTime.php b/src/Database/DateTime.php index e5c8850fb4..3492840f8f 100644 --- a/src/Database/DateTime.php +++ b/src/Database/DateTime.php @@ -2,11 +2,19 @@ namespace Utopia\Database; +use DateInterval; +use DateTime as PhpDateTime; +use DateTimeZone; +use Throwable; use Utopia\Database\Exception as DatabaseException; +/** + * Utility class for formatting and manipulating date-time values in the database. + */ class DateTime { protected static string $formatDb = 'Y-m-d H:i:s.v'; + protected static string $formatTz = 'Y-m-d\TH:i:s.vP'; private function __construct() @@ -14,36 +22,65 @@ private function __construct() } /** + * Get the current date-time formatted for database storage. + * * @return string */ public static function now(): string { - $date = new \DateTime(); + $date = new PhpDateTime(); + return self::format($date); } /** - * @param \DateTime $date + * Get the current date-time, advancing by one millisecond when needed. + */ + public static function nowAfter(?string $previous): string + { + $current = self::format(new PhpDateTime()); + if ($previous === null) { + return $current; + } + + try { + $date = new PhpDateTime($current); + $minimum = new PhpDateTime($previous); + } catch (Throwable $error) { + throw new DatabaseException($error->getMessage(), $error->getCode(), $error); + } + + if ($date <= $minimum) { + $minimum->modify('+1 millisecond'); + + return self::format($minimum); + } + + return $current; + } + + /** + * Format a DateTime object into the database storage format. + * + * @param PhpDateTime $date The date to format * @return string */ - public static function format(\DateTime $date): string + public static function format(PhpDateTime $date): string { return $date->format(self::$formatDb); } /** - * @param \DateTime $date - * @param int $seconds + * Add seconds to a DateTime and return the formatted result. + * + * @param PhpDateTime $date The base date + * @param int $seconds Number of seconds to add * @return string * @throws DatabaseException */ - public static function addSeconds(\DateTime $date, int $seconds): string + public static function addSeconds(PhpDateTime $date, int $seconds): string { - $interval = \DateInterval::createFromDateString($seconds . ' seconds'); - - if (!$interval) { - throw new DatabaseException('Invalid interval'); - } + $interval = DateInterval::createFromDateString($seconds.' seconds'); $date->add($interval); @@ -51,24 +88,29 @@ public static function addSeconds(\DateTime $date, int $seconds): string } /** - * @param string $datetime + * Parse a datetime string and convert it to the system's default timezone. + * + * @param string $datetime The datetime string to convert * @return string * @throws DatabaseException */ public static function setTimezone(string $datetime): string { try { - $value = new \DateTime($datetime); - $value->setTimezone(new \DateTimeZone(date_default_timezone_get())); + $value = new PhpDateTime($datetime); + $value->setTimezone(new DateTimeZone(date_default_timezone_get())); + return DateTime::format($value); - } catch (\Throwable $e) { + } catch (Throwable $e) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } } /** - * @param string|null $dbFormat - * @return string|null + * Convert a database-format date string to a timezone-aware ISO 8601 format. + * + * @param string|null $dbFormat The date string in database format, or null + * @return string|null The formatted date string with timezone, or null if input is null */ public static function formatTz(?string $dbFormat): ?string { @@ -77,9 +119,10 @@ public static function formatTz(?string $dbFormat): ?string } try { - $value = new \DateTime($dbFormat); + $value = new PhpDateTime($dbFormat); + return $value->format(self::$formatTz); - } catch (\Throwable) { + } catch (Throwable) { return $dbFormat; } } diff --git a/src/Database/Document.php b/src/Database/Document.php index e684956135..798511983e 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -7,51 +7,91 @@ use Utopia\Database\Exception\Structure as StructureException; /** + * Represents a database document as an array-accessible object with support for nested documents and permissions. + * * @extends ArrayObject */ class Document extends ArrayObject { - public const SET_TYPE_ASSIGN = 'assign'; - public const SET_TYPE_PREPEND = 'prepend'; - public const SET_TYPE_APPEND = 'append'; + public const string ID = '$id'; + + public const string SEQUENCE = '$sequence'; + + public const string COLLECTION = '$collection'; + + public const string CREATED_AT = '$createdAt'; + + public const string UPDATED_AT = '$updatedAt'; + + public const string PERMISSIONS = '$permissions'; + + public const string TENANT = '$tenant'; + + public const string DISTANCE = '$distance'; + + public const string DELETED_AT = '$deletedAt'; + + /** @var array|null */ + private static ?array $internalKeySet = null; + + /** + * Keyed on the stored value it was parsed from: a write through any path (ArrayAccess, a reference, + * exchangeArray(), unset) invalidates it without intercepting every write to the document. + * + * @var array{source: array, permissions: list, roles: array>}|null + */ + private ?array $parsedPermissions = null; + /** + * @return array + */ + private static function getInternalKeySet(): array + { + if (self::$internalKeySet === null) { + self::$internalKeySet = []; + foreach (Database::internalAttributesFor(true) as $attribute) { + self::$internalKeySet[$attribute->key] = true; + } + } + return self::$internalKeySet; + } /** * Construct. * * Construct a new fields object * - * @param array $input + * @param array $input + * * @throws DatabaseException - * @see ArrayObject::__construct * + * @see ArrayObject::__construct */ public function __construct(array $input = []) { - if (array_key_exists('$id', $input) && !\is_string($input['$id'])) { - throw new StructureException('$id must be of type string'); + if (array_key_exists(self::ID, $input) && ! \is_string($input[self::ID])) { + throw new StructureException(self::ID.' must be of type string'); } - if (array_key_exists('$permissions', $input) && !is_array($input['$permissions'])) { - throw new StructureException('$permissions must be of type array'); + if (array_key_exists(self::PERMISSIONS, $input)) { + $input[self::PERMISSIONS] = self::normalizePermissions($input[self::PERMISSIONS]); } foreach ($input as $key => $value) { - if (!\is_array($value)) { + if (! \is_array($value)) { continue; } - if (isset($value['$id']) || isset($value['$collection'])) { + if (isset($value[self::ID]) || isset($value[self::COLLECTION])) { + /** @var array $value */ $input[$key] = new self($value); + continue; } $converted = false; foreach ($value as $childKey => $child) { - // An array value is either a list of nested sub-documents or a list of - // plain items (dates, numbers, strings): wrap the former, leave the latter. - // is_array() tells them apart and avoids array-accessing a non-array - // value (e.g. a UTCDateTime), which would otherwise fatal. - if (\is_array($child) && (isset($child['$id']) || isset($child['$collection']))) { + if (\is_array($child) && (isset($child[self::ID]) || isset($child[self::COLLECTION]))) { + /** @var array $child */ $value[$childKey] = new self($child); $converted = true; } @@ -66,132 +106,278 @@ public function __construct(array $input = []) } /** - * @return string + * @param array $data */ - public function getId(): string + public static function fromArray(array $data): self { - return $this->getAttribute('$id', ''); + $class = static::class; + + return new $class($data); } /** - * @return string|null + * @internal + * + * @param array $row + * + * @throws DatabaseException */ - public function getSequence(): ?string + final public static function fromRow(array $row): self { - $sequence = $this->getAttribute('$sequence'); + foreach (\array_keys($row) as $key) { + if (\is_int($key)) { + unset($row[$key]); + } + } - if ($sequence === null) { - return null; + if (array_key_exists(self::ID, $row)) { + if ($row[self::ID] === null) { + $row[self::ID] = ''; + } elseif (! \is_string($row[self::ID])) { + throw new StructureException(self::ID.' must be of type string'); + } } - return $sequence; + if (array_key_exists(self::PERMISSIONS, $row)) { + if (! \is_array($row[self::PERMISSIONS])) { + throw new StructureException(self::PERMISSIONS.' must be of type array'); + } + $permissions = []; + foreach ($row[self::PERMISSIONS] as $permission) { + if (\is_string($permission)) { + $permissions[] = $permission; + } + } + $row[self::PERMISSIONS] = \array_values(\array_unique($permissions)); + } + + $document = new self(); + $document->exchangeArray($row); + + return $document; } /** - * @return string + * @internal + * + * @param array $data + * + * @throws StructureException When $id is not a string or $permissions is not an array */ - public function getCollection(): string + final public static function fromStorage(array $data): self { - return $this->getAttribute('$collection', ''); + if (array_key_exists(self::ID, $data) && ! \is_string($data[self::ID])) { + throw new StructureException(self::ID.' must be of type string'); + } + + if (array_key_exists(self::PERMISSIONS, $data)) { + if (! \is_array($data[self::PERMISSIONS])) { + throw new StructureException(self::PERMISSIONS.' must be of type array'); + } + $data[self::PERMISSIONS] = \array_values(\array_unique(\array_filter($data[self::PERMISSIONS], \is_string(...)))); + } + + foreach ($data as $key => $value) { + if (! \is_array($value)) { + continue; + } + + if (isset($value[self::ID]) || isset($value[self::COLLECTION])) { + /** @var array $value */ + $data[$key] = self::fromStorage($value); + + continue; + } + + $converted = false; + foreach ($value as $childKey => $child) { + if (\is_array($child) && (isset($child[self::ID]) || isset($child[self::COLLECTION]))) { + /** @var array $child */ + $value[$childKey] = self::fromStorage($child); + $converted = true; + } + } + + if ($converted) { + $data[$key] = $value; + } + } + + $document = new self(); + $document->exchangeArray($data); + + return $document; } /** - * @return array + * Get the document's unique identifier. + * + * @return string The document ID, or empty string if not set. */ - public function getPermissions(): array + public function getId(): string { - return \array_values(\array_unique($this->getAttribute('$permissions', []))); + /** @var string $id */ + $id = $this->getAttribute(self::ID, ''); + return $id; } /** - * @return array + * Get the document's auto-generated sequence identifier. + * + * @return string|null The sequence value, or null if not set. */ - public function getRead(): array + public function getSequence(): ?string { - return $this->getPermissionsByType(Database::PERMISSION_READ); + $sequence = $this->getAttribute(self::SEQUENCE); + + if ($sequence === null) { + return null; + } + + /** @var string $sequence */ + return $sequence; } /** - * @return array + * Get the collection ID this document belongs to. + * + * @return string The collection ID, or empty string if not set. */ - public function getCreate(): array + public function getCollection(): string { - return $this->getPermissionsByType(Database::PERMISSION_CREATE); + /** @var string $collection */ + $collection = $this->getAttribute(self::COLLECTION, ''); + return $collection; } /** - * @return array + * Get all unique permissions assigned to this document. + * + * @return list + * + * @throws StructureException When the stored permissions are not an array of strings */ - public function getUpdate(): array + public function getPermissions(): array { - return $this->getPermissionsByType(Database::PERMISSION_UPDATE); + return $this->parsePermissions()['permissions']; } /** - * @return array + * Get roles for a specific permission type from this document's permissions. + * + * @param PermissionType|string $type A built-in permission type, or a consumer-defined type such as 'execute' + * @return list + * + * @throws StructureException When the stored permissions are not an array of strings */ - public function getDelete(): array + public function getPermissionsByType(PermissionType|string $type): array { - return $this->getPermissionsByType(Database::PERMISSION_DELETE); + $type = $type instanceof PermissionType ? $type->value : $type; + + return $this->parsePermissions()['roles'][$type] ?? []; } /** - * @return array + * @return array{source: array, permissions: list, roles: array>} + * + * @throws StructureException */ - public function getWrite(): array + private function parsePermissions(): array { - return \array_unique(\array_intersect( - $this->getCreate(), - $this->getUpdate(), - $this->getDelete() - )); + $source = $this->getAttribute(self::PERMISSIONS, []); + + if ($this->parsedPermissions !== null && $this->parsedPermissions['source'] === $source) { + return $this->parsedPermissions; + } + + $permissions = self::normalizePermissions($source); + + $roles = []; + foreach ($permissions as $permission) { + $open = \strpos($permission, '('); + if ($open === false) { + continue; + } + $type = \trim(\substr($permission, 0, $open)); + $roles[$type][] = \str_replace([')', '"', ' '], '', \substr($permission, $open + 1)); + } + + return $this->parsedPermissions = [ + 'source' => $source, + 'permissions' => $permissions, + 'roles' => \array_map(static fn (array $names): array => \array_values(\array_unique($names)), $roles), + ]; } /** - * @return array + * @return list + * + * @phpstan-assert array $permissions + * + * @throws StructureException */ - public function getPermissionsByType(string $type): array + private static function normalizePermissions(mixed $permissions): array { - $typePermissions = []; + if (! \is_array($permissions)) { + throw new StructureException(self::PERMISSIONS.' must be of type array'); + } - foreach ($this->getPermissions() as $permission) { - if (!\str_starts_with($permission, $type)) { - continue; + $strings = []; + foreach ($permissions as $permission) { + if (! \is_string($permission)) { + throw new StructureException('Every permission must be of type string'); } - $typePermissions[] = \str_replace([$type . '(', ')', '"', ' '], '', $permission); + $strings[] = $permission; } - return \array_unique($typePermissions); + return \array_values(\array_unique($strings)); } /** - * @return string|null + * Get the document's creation timestamp. + * + * @return string|null The creation datetime string, or null if not set. */ public function getCreatedAt(): ?string { - return $this->getAttribute('$createdAt'); + /** @var string|null $createdAt */ + $createdAt = $this->getAttribute(self::CREATED_AT); + return $createdAt; } /** - * @return string|null + * Get the document's last update timestamp. + * + * @return string|null The update datetime string, or null if not set. */ public function getUpdatedAt(): ?string { - return $this->getAttribute('$updatedAt'); + /** @var string|null $updatedAt */ + $updatedAt = $this->getAttribute(self::UPDATED_AT); + return $updatedAt; } /** - * @return int|string|null + * Get the tenant ID associated with this document. + * + * Numeric string values are normalized to int for consistent comparison + * across adapters that may return string representations (e.g. PDO stringify). + * + * @return int|string|null The tenant ID, or null if not set. */ public function getTenant(): int|string|null { - $tenant = $this->getAttribute('$tenant'); + $tenant = $this->getAttribute(self::TENANT); - if (\is_numeric($tenant)) { + if (\is_string($tenant) && \ctype_digit($tenant)) { return (int) $tenant; } - return $tenant; + if (\is_int($tenant) || \is_string($tenant) || $tenant === null) { + return $tenant; + } + + return null; } /** @@ -202,14 +388,10 @@ public function getTenant(): int|string|null public function getAttributes(): array { $attributes = []; - - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - Database::INTERNAL_ATTRIBUTES - ); + $keySet = self::getInternalKeySet(); foreach ($this as $attribute => $value) { - if (\in_array($attribute, $internalKeys)) { + if (isset($keySet[$attribute])) { continue; } @@ -223,11 +405,6 @@ public function getAttributes(): array * Get Attribute. * * Method for getting a specific fields attribute. If $name is not found $default value will be returned. - * - * @param string $name - * @param mixed $default - * - * @return mixed */ public function getAttribute(string $name, mixed $default = null): mixed { @@ -238,41 +415,92 @@ public function getAttribute(string $name, mixed $default = null): mixed return $default; } + /** + * @return array + */ + public function getArray(string $key): array + { + $value = $this->offsetExists($key) ? $this[$key] : []; + + return \is_array($value) ? $value : []; + } + + /** + * @return list + */ + public function getDocuments(string $key): array + { + $documents = []; + foreach ($this->getArray($key) as $item) { + if ($item instanceof self) { + $documents[] = $item; + continue; + } + if (! \is_array($item)) { + continue; + } + $typed = []; + foreach ($item as $name => $value) { + if (\is_string($name)) { + $typed[$name] = $value; + } + } + $documents[] = new self($typed); + } + + return $documents; + } + + public function getDocument(string $key): self + { + $value = $this->offsetExists($key) ? $this[$key] : null; + if ($value instanceof self) { + return $value; + } + if (! \is_array($value) || $value === [] || \array_is_list($value)) { + return new self(); + } + + $typed = []; + foreach ($value as $name => $item) { + if (\is_string($name)) { + $typed[$name] = $item; + } + } + + return new self($typed); + } + /** * Set Attribute. * * Method for setting a specific field attribute * - * @param string $key - * @param mixed $value - * @param string $type - * - * @return static + * @throws StructureException When $permissions is set to something other than null or an array of strings */ - public function setAttribute(string $key, mixed $value, string $type = self::SET_TYPE_ASSIGN): static + public function setAttribute(string $key, mixed $value, SetType $type = SetType::Assign): static { - switch ($type) { - case self::SET_TYPE_ASSIGN: - $this[$key] = $value; - break; - case self::SET_TYPE_APPEND: - $this[$key] = (!isset($this[$key]) || !\is_array($this[$key])) ? [] : $this[$key]; - \array_push($this[$key], $value); - break; - case self::SET_TYPE_PREPEND: - $this[$key] = (!isset($this[$key]) || !\is_array($this[$key])) ? [] : $this[$key]; - \array_unshift($this[$key], $value); - break; + if ($type !== SetType::Assign) { + $current = $this->getArray($key); + $value = match ($type) { + SetType::Append => [...$current, $value], + SetType::Prepend => [$value, ...$current], + }; + } + + if ($key === self::PERMISSIONS && $value !== null) { + $value = self::normalizePermissions($value); } + $this[$key] = $value; + return $this; } /** * Set Attributes. * - * @param array $attributes - * @return static + * @param array $attributes */ public function setAttributes(array $attributes): static { @@ -287,171 +515,79 @@ public function setAttributes(array $attributes): static * Remove Attribute. * * Method for removing a specific field attribute - * - * @param string $key - * - * @return static */ public function removeAttribute(string $key): static { - unset($this[$key]); + $this->offsetUnset($key); - /* @phpstan-ignore-next-line */ return $this; } /** - * Find. - * - * @param string $key - * @param mixed $find - * @param string $subject - * - * @return mixed + * Checks if document has data. */ - public function find(string $key, $find, string $subject = ''): mixed + public function isEmpty(): bool { - $subject = $this[$subject] ?? null; - $subject = (empty($subject)) ? $this : $subject; - - if (is_array($subject)) { - foreach ($subject as $i => $value) { - if (isset($value[$key]) && $value[$key] === $find) { - return $value; - } - } - return false; - } - - if (isset($subject[$key]) && $subject[$key] === $find) { - return $subject; - } - return false; + return ! \count($this); } /** - * Find and Replace. - * - * Get array child by key and value match - * - * @param string $key - * @param mixed $find - * @param mixed $replace - * @param string $subject - * - * @return bool + * Checks if a document key is set. */ - public function findAndReplace(string $key, $find, $replace, string $subject = ''): bool + public function isSet(string $key): bool { - $subject = &$this[$subject] ?? null; - $subject = (empty($subject)) ? $this : $subject; - - if (is_array($subject)) { - foreach ($subject as $i => &$value) { - if (isset($value[$key]) && $value[$key] === $find) { - $value = $replace; - return true; - } - } - return false; - } - - if (isset($subject[$key]) && $subject[$key] === $find) { - $subject[$key] = $replace; - return true; - } - return false; + return isset($this[$key]); } /** - * Find and Remove. + * The document as a PHP array, with every nested document converted to an array too. * - * Get array child by key and value match - * - * @param string $key - * @param mixed $find - * @param string $subject - * - * @return bool + * @return array */ - public function findAndRemove(string $key, $find, string $subject = ''): bool + #[\Override] + public function getArrayCopy(): array { - $subject = &$this[$subject] ?? null; - $subject = (empty($subject)) ? $this : $subject; - - if (is_array($subject)) { - foreach ($subject as $i => &$value) { - if (isset($value[$key]) && $value[$key] === $find) { - unset($subject[$i]); - return true; - } - } - return false; - } - - if (isset($subject[$key]) && $subject[$key] === $find) { - unset($subject[$key]); - return true; - } - return false; + return self::export(parent::getArrayCopy()); } /** - * Checks if document has data. + * The given top-level keys of getArrayCopy(), in the document's order. * - * @return bool + * @param array $keys + * @return array */ - public function isEmpty(): bool + public function only(array $keys): array { - return !\count($this); + return self::export(\array_intersect_key(parent::getArrayCopy(), \array_flip($keys))); } /** - * Checks if a document key is set. + * getArrayCopy() without the given top-level keys. * - * @param string $key - * - * @return bool + * @param array $keys + * @return array */ - public function isSet(string $key): bool + public function except(array $keys): array { - return isset($this[$key]); + return self::export(\array_diff_key(parent::getArrayCopy(), \array_flip($keys))); } /** - * Get Array Copy. - * - * Outputs entity as a PHP array - * - * @param array $allow - * @param array $disallow - * + * @param array $values * @return array */ - public function getArrayCopy(array $allow = [], array $disallow = []): array + private static function export(array $values): array { - $array = parent::getArrayCopy(); - $output = []; - - foreach ($array as $key => $value) { - if (!empty($allow) && !\in_array($key, $allow)) { // Export only allow fields - continue; - } - - if (!empty($disallow) && \in_array($key, $disallow)) { // Don't export disallowed fields - continue; - } - + foreach ($values as $key => $value) { if ($value instanceof self) { - $output[$key] = $value->getArrayCopy($allow, $disallow); + $output[$key] = $value->getArrayCopy(); } elseif (\is_array($value)) { - $value = \array_map( - fn ($item) => $item instanceof self ? $item->getArrayCopy($allow, $disallow) : $item, - $value - ); - - $output[$key] = $value; + $items = []; + foreach ($value as $index => $item) { + $items[$index] = $item instanceof self ? $item->getArrayCopy() : $item; + } + $output[$key] = $items; } else { $output[$key] = $value; } @@ -460,14 +596,27 @@ public function getArrayCopy(array $allow = [], array $disallow = []): array return $output; } + /** + * Deep clone the document including nested Document instances. + */ public function __clone() { - foreach ($this as $key => $value) { + foreach (parent::getArrayCopy() as $key => $value) { if ($value instanceof self) { $this[$key] = clone $value; - } elseif (\is_array($value)) { - $this[$key] = \array_map(fn ($item) => $item instanceof self ? clone $item : $item, $value); + + continue; + } + + if (! \is_array($value) || $value === []) { + continue; + } + + $copy = []; + foreach ($value as $index => $item) { + $copy[$index] = $item instanceof self ? clone $item : $item; } + $this[$key] = $copy; } } } diff --git a/src/Database/Event.php b/src/Database/Event.php new file mode 100644 index 0000000000..2b59b93c1f --- /dev/null +++ b/src/Database/Event.php @@ -0,0 +1,97 @@ +|null + */ + public function domain(): ?string + { + return match ($this) { + self::All => null, + self::DatabaseList => Event\Database\Listed::class, + self::DatabaseCreate => Event\Database\Created::class, + self::DatabaseUpdate => Event\Database\Updated::class, + self::DatabaseDelete => Event\Database\Deleted::class, + self::CollectionList => Event\Collection\Listed::class, + self::CollectionCreate => Event\Collection\Created::class, + self::CollectionRead => Event\Collection\Read::class, + self::CollectionUpdate => Event\Collection\Updated::class, + self::CollectionDelete => Event\Collection\Deleted::class, + self::AttributeCreate => Event\Attribute\Created::class, + self::AttributesCreate => Event\Attribute\BatchCreated::class, + self::AttributeUpdate => Event\Attribute\Updated::class, + self::AttributeRename => Event\Attribute\Renamed::class, + self::AttributeDelete => Event\Attribute\Deleted::class, + self::IndexCreate => Event\Index\Created::class, + self::IndexesCreate => Event\Index\BatchCreated::class, + self::IndexRename => Event\Index\Renamed::class, + self::IndexDelete => Event\Index\Deleted::class, + self::DocumentRead => Event\Document\Read::class, + self::DocumentFind => Event\Document\Found::class, + self::DocumentAggregate => Event\Document\Aggregated::class, + self::DocumentCreate => Event\Document\Created::class, + self::DocumentsCreate => Event\Document\BatchCreated::class, + self::DocumentUpdate => Event\Document\Updated::class, + self::DocumentsUpdate => Event\Document\BatchUpdated::class, + self::DocumentUpsert => Event\Document\Upserted::class, + self::DocumentsUpsert => Event\Document\BatchUpserted::class, + self::DocumentDelete => Event\Document\Deleted::class, + self::DocumentsDelete => Event\Document\BatchDeleted::class, + self::DocumentIncrease => Event\Document\Increased::class, + self::DocumentDecrease => Event\Document\Decreased::class, + self::DocumentCount => Event\Document\Counted::class, + self::DocumentSum => Event\Document\Summed::class, + self::DocumentPurge => Event\Document\Purged::class, + self::PermissionsCreate => Event\Permission\Created::class, + self::PermissionsRead => Event\Permission\Read::class, + self::PermissionsDelete => Event\Permission\Deleted::class, + }; + } +} diff --git a/src/Database/Event/Attribute/BatchCreated.php b/src/Database/Event/Attribute/BatchCreated.php new file mode 100644 index 0000000000..68769aa315 --- /dev/null +++ b/src/Database/Event/Attribute/BatchCreated.php @@ -0,0 +1,20 @@ + $attributes + */ + public function __construct( + public string $collection, + public array $attributes, + ) { + parent::__construct(Event::AttributesCreate); + } +} diff --git a/src/Database/Event/Attribute/Created.php b/src/Database/Event/Attribute/Created.php new file mode 100644 index 0000000000..6406aec43a --- /dev/null +++ b/src/Database/Event/Attribute/Created.php @@ -0,0 +1,17 @@ + $collections + */ + public function __construct( + public array $collections, + ) { + parent::__construct(Event::CollectionList); + } +} diff --git a/src/Database/Event/Collection/Read.php b/src/Database/Event/Collection/Read.php new file mode 100644 index 0000000000..6fb9a5de63 --- /dev/null +++ b/src/Database/Event/Collection/Read.php @@ -0,0 +1,17 @@ + $databases + */ + public function __construct( + public array $databases, + ) { + parent::__construct(Event::DatabaseList); + } +} diff --git a/src/Database/Event/Database/Updated.php b/src/Database/Event/Database/Updated.php new file mode 100644 index 0000000000..1a8f68ca98 --- /dev/null +++ b/src/Database/Event/Database/Updated.php @@ -0,0 +1,16 @@ +, list> */ + private array $listeners = []; + + public function __construct( + private readonly ?object $dispatcher = null, + ) { + } + + /** + * @template T of Domain + * + * @param class-string $eventClass + * @param callable(T): void $listener + */ + public function on(string $eventClass, callable $listener): static + { + $this->listeners[$eventClass][] = $listener; + + return $this; + } + + #[\Override] + public function handles(Event $event): bool + { + $class = $event->domain(); + + return $class !== null && (isset($this->listeners[$class]) || $this->dispatches()); + } + + #[\Override] + public function handle(Domain $event): void + { + $failure = null; + + foreach ($this->listeners[$event::class] ?? [] as $listener) { + try { + $listener($event); + } catch (Exception $exception) { + $failure ??= $exception; + } + } + + if ($this->dispatcher !== null && \method_exists($this->dispatcher, 'dispatch')) { + try { + $this->dispatcher->dispatch($event); + } catch (Exception $exception) { + $failure ??= $exception; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + private function dispatches(): bool + { + return $this->dispatcher !== null && \method_exists($this->dispatcher, 'dispatch'); + } +} diff --git a/src/Database/Event/Document/Aggregated.php b/src/Database/Event/Document/Aggregated.php new file mode 100644 index 0000000000..f669264d61 --- /dev/null +++ b/src/Database/Event/Document/Aggregated.php @@ -0,0 +1,19 @@ +> $rows + */ + public function __construct( + public string $collection, + public array $rows, + ) { + parent::__construct(Event::DocumentAggregate); + } +} diff --git a/src/Database/Event/Document/BatchCreated.php b/src/Database/Event/Document/BatchCreated.php new file mode 100644 index 0000000000..1569170d00 --- /dev/null +++ b/src/Database/Event/Document/BatchCreated.php @@ -0,0 +1,16 @@ +count = $created + $updated; + } +} diff --git a/src/Database/Event/Document/Counted.php b/src/Database/Event/Document/Counted.php new file mode 100644 index 0000000000..eaa0a8d682 --- /dev/null +++ b/src/Database/Event/Document/Counted.php @@ -0,0 +1,16 @@ + $documents + */ + public function __construct( + public string $collection, + public array $documents, + ) { + parent::__construct(Event::DocumentFind); + } +} diff --git a/src/Database/Event/Document/Increased.php b/src/Database/Event/Document/Increased.php new file mode 100644 index 0000000000..47519ddbf2 --- /dev/null +++ b/src/Database/Event/Document/Increased.php @@ -0,0 +1,18 @@ + $indexes + */ + public function __construct( + public string $collection, + public array $indexes, + ) { + parent::__construct(Event::IndexesCreate); + } +} diff --git a/src/Database/Event/Index/Created.php b/src/Database/Event/Index/Created.php new file mode 100644 index 0000000000..5b861e3a3c --- /dev/null +++ b/src/Database/Event/Index/Created.php @@ -0,0 +1,17 @@ + $permissions + */ + public function __construct( + public string $collection, + public string $document, + public array $permissions, + ) { + parent::__construct(Event::PermissionsCreate); + } +} diff --git a/src/Database/Event/Permission/Deleted.php b/src/Database/Event/Permission/Deleted.php new file mode 100644 index 0000000000..e723a47187 --- /dev/null +++ b/src/Database/Event/Permission/Deleted.php @@ -0,0 +1,24 @@ + $permissions + */ + public function __construct( + public string $collection, + public string $document, + public array $permissions, + ) { + parent::__construct(Event::PermissionsDelete); + } +} diff --git a/src/Database/Event/Permission/Read.php b/src/Database/Event/Permission/Read.php new file mode 100644 index 0000000000..af03c04a5a --- /dev/null +++ b/src/Database/Event/Permission/Read.php @@ -0,0 +1,24 @@ + $permissions + */ + public function __construct( + public string $collection, + public string $document, + public array $permissions, + ) { + parent::__construct(Event::PermissionsRead); + } +} diff --git a/src/Database/Exception.php b/src/Database/Exception.php index d86e94c2bb..0d603e3182 100644 --- a/src/Database/Exception.php +++ b/src/Database/Exception.php @@ -2,20 +2,32 @@ namespace Utopia\Database; +use Exception as PhpException; use Throwable; -class Exception extends \Exception +/** + * Base exception class for all database-related errors. + */ +class Exception extends PhpException { - public function __construct(string $message, int|string $code = 0, ?Throwable $previous = null) + /** + * The SQLSTATE a driver reported as the code, which PHP's integer code cannot hold. + */ + public readonly ?string $state; + + public function __construct(string $message = '', int|string $code = 0, ?Throwable $previous = null) + { + $this->state = \is_string($code) ? $code : null; + + parent::__construct($message, self::integerCode($code), $previous); + } + + private static function integerCode(int|string $code): int { - if (\is_string($code)) { - if (\is_numeric($code)) { - $code = (int) $code; - } else { - $code = 0; - } + if (\is_int($code)) { + return $code; } - parent::__construct($message, $code, $previous); + return \is_numeric($code) ? (int) $code : 0; } } diff --git a/src/Database/Exception/Authorization.php b/src/Database/Exception/Authorization.php index a7ab33a7c2..1689f8844c 100644 --- a/src/Database/Exception/Authorization.php +++ b/src/Database/Exception/Authorization.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a database operation fails due to insufficient permissions. + */ class Authorization extends Exception { } diff --git a/src/Database/Exception/Character.php b/src/Database/Exception/Character.php index bf184803ac..ddb58b617d 100644 --- a/src/Database/Exception/Character.php +++ b/src/Database/Exception/Character.php @@ -2,8 +2,9 @@ namespace Utopia\Database\Exception; -use Utopia\Database\Exception; - -class Character extends Exception +/** + * Thrown when a value contains invalid or unsupported characters. + */ +class Character extends Schema { } diff --git a/src/Database/Exception/Conflict.php b/src/Database/Exception/Conflict.php index 8803bf9023..b0a8d67469 100644 --- a/src/Database/Exception/Conflict.php +++ b/src/Database/Exception/Conflict.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a database operation encounters a conflict, such as a concurrent modification. + */ class Conflict extends Exception { } diff --git a/src/Database/Exception/Contention.php b/src/Database/Exception/Contention.php new file mode 100644 index 0000000000..5e0dd347b7 --- /dev/null +++ b/src/Database/Exception/Contention.php @@ -0,0 +1,12 @@ +attribute = $attribute; + /** + * @param string|null $attribute The attribute that caused the ordering error + */ + public function __construct( + string $message, + protected readonly ?string $attribute = null, + int|string $code = 0, + ?Throwable $previous = null, + ) { parent::__construct($message, $code, $previous); } + + /** + * Get the attribute that caused the ordering error. + */ public function getAttribute(): ?string { return $this->attribute; diff --git a/src/Database/Exception/Query.php b/src/Database/Exception/Query.php index 58f699d12a..ba1ebcfef0 100644 --- a/src/Database/Exception/Query.php +++ b/src/Database/Exception/Query.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a query is malformed or contains invalid parameters. + */ class Query extends Exception { } diff --git a/src/Database/Exception/Refused.php b/src/Database/Exception/Refused.php new file mode 100644 index 0000000000..3cd888b9a7 --- /dev/null +++ b/src/Database/Exception/Refused.php @@ -0,0 +1,12 @@ + $filters + * @return list + */ + public static function names(array $filters): array + { + $names = []; + foreach ($filters as $filter) { + $names[] = $filter instanceof self ? $filter->value : $filter; + } + + return $names; + } +} diff --git a/src/Database/Filter/Callback.php b/src/Database/Filter/Callback.php new file mode 100644 index 0000000000..1026e38f5e --- /dev/null +++ b/src/Database/Filter/Callback.php @@ -0,0 +1,52 @@ +name; + } + + #[\Override] + public function encode(mixed $value): mixed + { + return ($this->encode)($value); + } + + #[\Override] + public function decode(mixed $value): mixed + { + return ($this->decode)($value); + } + + /** + * Where the two closures are declared, so cached documents decoded by other closures are told apart while + * every process declaring the same ones shares them. + */ + #[\Override] + public function signature(): string + { + return self::declaration($this->encode).':'.self::declaration($this->decode); + } + + private static function declaration(Closure $closure): string + { + $reflection = new ReflectionFunction($closure); + $scope = $reflection->getClosureScopeClass()?->getName() ?? ''; + + return ($reflection->getFileName() ?: $scope.'::'.$reflection->getName()).':'.$reflection->getStartLine(); + } +} diff --git a/src/Database/Filter/Codec.php b/src/Database/Filter/Codec.php new file mode 100644 index 0000000000..0d087c740d --- /dev/null +++ b/src/Database/Filter/Codec.php @@ -0,0 +1,19 @@ + */ + private array $codecs = []; + + /** @var array */ + private array $signatures = []; + + /** + * @throws DuplicateException When the codec is named after a built-in filter + */ + public function register(Codec $codec): static + { + $name = $codec->name(); + + if (Filter::tryFrom($name) !== null) { + throw new DuplicateException("Filter \"{$name}\" collides with the built-in filter of the same name"); + } + + $this->codecs[$name] = $codec; + $this->signatures[$name] = $codec instanceof Signed ? $codec->signature() : $codec::class; + + return $this; + } + + public function get(string $name): ?Codec + { + return $this->codecs[$name] ?? null; + } + + public function has(string $name): bool + { + return isset($this->codecs[$name]); + } + + /** + * What tells each registered codec apart in a cache key, by name. + * + * @internal + * + * @return array + */ + public function signatures(): array + { + return $this->signatures; + } +} diff --git a/src/Database/Filter/Signed.php b/src/Database/Filter/Signed.php new file mode 100644 index 0000000000..c51f714d66 --- /dev/null +++ b/src/Database/Filter/Signed.php @@ -0,0 +1,15 @@ + $options + * + * @throws Structure + */ + public function __construct( + public string $name, + public array $options = [], + ) { + if ($name === '') { + throw new Structure('Format name must not be empty'); + } + } +} diff --git a/src/Database/Helpers/ID.php b/src/Database/Helpers/ID.php deleted file mode 100644 index 3a690a7b1d..0000000000 --- a/src/Database/Helpers/ID.php +++ /dev/null @@ -1,38 +0,0 @@ - 0) { - try { - $bytes = \random_bytes(\max(1, (int)\ceil(($padding / 2)))); // one byte expands to two chars - } catch (\Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } - - $uniqid .= \substr(\bin2hex($bytes), 0, $padding); - } - - return $uniqid; - } - - /** - * Create a new ID from a string - */ - public static function custom(string $id): string - { - return $id; - } -} diff --git a/src/Database/Hook/Attachable.php b/src/Database/Hook/Attachable.php new file mode 100644 index 0000000000..42b4b0c4f3 --- /dev/null +++ b/src/Database/Hook/Attachable.php @@ -0,0 +1,14 @@ + $filters + * @return array + */ + #[\Override] + public function applyFilters(array $filters, string $collection, PermissionType $forPermission): array + { + if (! $this->authorization->getStatus()) { + return $filters; + } + + $permissions = []; + foreach ($this->authorization->getRoles() as $role) { + $permissions[] = $forPermission->value.'("'.$role.'")'; + } + + /** @var array $permissionsFilter */ + $permissionsFilter = isset($filters[Storage::PERMISSIONS]) && \is_array($filters[Storage::PERMISSIONS]) + ? $filters[Storage::PERMISSIONS] + : []; + $permissionsFilter['$in'] = $permissions; + $filters[Storage::PERMISSIONS] = $permissionsFilter; + + return $filters; + } +} diff --git a/src/Database/Hook/Mongo/Read.php b/src/Database/Hook/Mongo/Read.php new file mode 100644 index 0000000000..9be7a6ade0 --- /dev/null +++ b/src/Database/Hook/Mongo/Read.php @@ -0,0 +1,21 @@ + $filters + * @return array + */ + public function applyFilters(array $filters, string $collection, PermissionType $forPermission): array; +} diff --git a/src/Database/Hook/Mongo/Tenant.php b/src/Database/Hook/Mongo/Tenant.php new file mode 100644 index 0000000000..97526c58b1 --- /dev/null +++ b/src/Database/Hook/Mongo/Tenant.php @@ -0,0 +1,39 @@ +=): (int|string|null|array>) $tenantFilters + */ + public function __construct( + private bool $sharedTables, + private Closure $tenantFilters, + ) { + } + + /** + * @param array $filters + * @return array + */ + #[\Override] + public function applyFilters(array $filters, string $collection, PermissionType $forPermission = PermissionType::Read): array + { + if (! $this->sharedTables) { + return $filters; + } + + $filters[Storage::TENANT] = ($this->tenantFilters)($collection); + + return $filters; + } +} diff --git a/src/Database/Hook/Named.php b/src/Database/Hook/Named.php new file mode 100644 index 0000000000..bc81194bb6 --- /dev/null +++ b/src/Database/Hook/Named.php @@ -0,0 +1,15 @@ + $documents + */ + #[\Override] + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + $permissionsBuilder = $context->builder()->into($context->rawTable(Storage::permissionsTable($collection))); + $hasPermissions = false; + + foreach ($documents as $document) { + foreach ($this->buildPermissionRows($document, $context) as $row) { + $permissionsBuilder->set($row); + $hasPermissions = true; + } + } + + if ($hasPermissions) { + if ($context->ignoreDuplicates()) { + if (! $permissionsBuilder instanceof InsertOrIgnoreFeature) { + throw new DatabaseException('Insert-or-ignore is not supported on this dialect'); + } + + $result = $permissionsBuilder->insertOrIgnore(); + } else { + $result = $permissionsBuilder->insert(); + } + $context->run($result, Event::PermissionsCreate); + } + } + + /** + * Diff current vs. new permissions and apply additions/removals for a single document. + */ + #[\Override] + public function afterDocumentUpdate(string $collection, string $id, Document $document, WriteContext $context): void + { + if ($context->skipPermissions($document)) { + return; + } + + if ($id !== '' && $id !== $document->getId()) { + $this->movePermissions($collection, $id, $document, $context); + + return; + } + + [$permissionsMap, $storedIds] = $this->readCurrentPermissionsBatch($collection, [$document], $context); + $permissions = $this->currentPermissions($permissionsMap, $document->getId()); + $permissionDocumentId = $this->permissionDocumentId($document->getId(), $storedIds); + + /** @var array> $removals */ + $removals = []; + /** @var array> $additions */ + $additions = []; + foreach (self::PERMISSION_TYPES as $type) { + $removed = \array_values(\array_diff($permissions[$type->value], $document->getPermissionsByType($type))); + if (! empty($removed)) { + $removals[$type->value] = $removed; + } + + $added = $this->uniqueAdditions($document->getPermissionsByType($type), $permissions[$type->value]); + if (! empty($added)) { + $additions[$type->value] = $added; + } + } + + $this->deletePermissions($collection, $permissionDocumentId, $removals, $context); + $this->insertPermissions($collection, $document, $permissionDocumentId, $additions, $context); + } + + /** + * Diff and sync permission rows for a batch of updated documents. + * + * @param array $documents + */ + #[\Override] + public function afterDocumentBatchUpdate(string $collection, Document $updates, array $documents, WriteContext $context): void + { + if (! $updates->offsetExists(Document::PERMISSIONS)) { + return; + } + + $removeConditions = []; + $addBuilder = $context->builder()->into($context->rawTable(Storage::permissionsTable($collection))); + $hasAdditions = false; + + $eligible = []; + foreach ($documents as $document) { + if ($context->skipPermissions($document)) { + continue; + } + $eligible[] = $document; + } + + if (empty($eligible)) { + return; + } + + [$permissionsMap, $storedIds] = $this->readCurrentPermissionsBatch($collection, $eligible, $context); + $updatesByType = []; + foreach (self::PERMISSION_TYPES as $type) { + $updatesByType[$type->value] = $updates->getPermissionsByType($type); + } + + foreach ($eligible as $document) { + $permissions = $this->currentPermissions($permissionsMap, $document->getId()); + $permissionDocumentId = $this->permissionDocumentId($document->getId(), $storedIds); + + foreach (self::PERMISSION_TYPES as $type) { + $diff = \array_diff($permissions[$type->value], $updatesByType[$type->value]); + if (! empty($diff)) { + $removeConditions[] = Query::and([ + Query::equal(Storage::PERMISSIONS_DOCUMENT, [$permissionDocumentId]), + Query::equal(Storage::PERMISSIONS_TYPE, [$type->value]), + Query::equal(Storage::PERMISSIONS_PERMISSION, \array_values($diff)), + ]); + } + } + + foreach (self::PERMISSION_TYPES as $type) { + $diff = $this->uniqueAdditions($updatesByType[$type->value], $permissions[$type->value]); + if (! empty($diff)) { + foreach ($diff as $permission) { + $row = $context->decorateRow([ + Storage::PERMISSIONS_DOCUMENT => $permissionDocumentId, + Storage::PERMISSIONS_TYPE => $type->value, + Storage::PERMISSIONS_PERMISSION => $permission, + ], $document); + $addBuilder->set($row); + $hasAdditions = true; + } + } + } + } + + if (! empty($removeConditions)) { + $removeBuilder = $context->builder()->from(Storage::permissionsTable($collection)); + $removeBuilder->filter([Query::or($removeConditions)]); + $context->run($removeBuilder->delete(), Event::PermissionsDelete); + } + + if ($hasAdditions) { + $context->run($addBuilder->insert(), Event::PermissionsCreate); + } + } + + /** + * Diff old vs. new permissions from upsert change sets and apply additions/removals. + * + * @param array $changes + */ + #[\Override] + public function afterDocumentUpsert(string $collection, array $changes, WriteContext $context): void + { + $removeConditions = []; + $addBuilder = $context->builder()->into($context->rawTable(Storage::permissionsTable($collection))); + $hasAdditions = false; + + foreach ($changes as $change) { + $old = $change->old; + $document = $change->new; + $tenantScope = $this->tenantScope($document, $context); + + $current = []; + foreach (self::PERMISSION_TYPES as $type) { + $current[$type->value] = $old->getPermissionsByType($type); + } + + foreach (self::PERMISSION_TYPES as $type) { + $toRemove = \array_diff($current[$type->value], $document->getPermissionsByType($type)); + if (! empty($toRemove)) { + $removeConditions[] = Query::and([ + Query::equal(Storage::PERMISSIONS_DOCUMENT, [$document->getId()]), + ...$tenantScope, + Query::equal(Storage::PERMISSIONS_TYPE, [$type->value]), + Query::equal(Storage::PERMISSIONS_PERMISSION, \array_values($toRemove)), + ]); + } + } + + foreach (self::PERMISSION_TYPES as $type) { + $toAdd = $this->uniqueAdditions($document->getPermissionsByType($type), $current[$type->value]); + foreach ($toAdd as $permission) { + $row = $context->decorateRow([ + Storage::PERMISSIONS_DOCUMENT => $document->getId(), + Storage::PERMISSIONS_TYPE => $type->value, + Storage::PERMISSIONS_PERMISSION => $permission, + ], $document); + $addBuilder->set($row); + $hasAdditions = true; + } + } + } + + if (! empty($removeConditions)) { + $removeBuilder = $context->builder()->fromTable($context->rawTable(Storage::permissionsTable($collection))); + $removeBuilder->filter([Query::or($removeConditions)]); + $context->run($removeBuilder->delete(), Event::PermissionsDelete); + } + + if ($hasAdditions) { + $context->run($addBuilder->insert(), Event::PermissionsCreate); + } + } + + /** + * An upsert batch can hold documents of several tenants, none of them the adapter's, so its + * removals cannot take from()'s filter on the adapter's tenant: each one is scoped to + * the tenant decorateRow() stores its own document's rows under instead. + * + * @return list + */ + private function tenantScope(Document $document, WriteContext $context): array + { + $row = $context->decorateRow([], $document); + if (! \array_key_exists(Storage::TENANT, $row)) { + return []; + } + + $tenant = $row[Storage::TENANT]; + + return [Query::equal(Storage::TENANT, [\is_int($tenant) || \is_string($tenant) ? $tenant : null])]; + } + + /** + * Delete all permission rows for the given document IDs. + * + * @param list $documentIds + * @throws DatabaseException If the permission deletion fails + */ + #[\Override] + public function afterDocumentDelete(string $collection, array $documentIds, WriteContext $context): void + { + if (empty($documentIds)) { + return; + } + + $permissionsBuilder = $context->builder()->from(Storage::permissionsTable($collection)); + $permissionsBuilder->filter([Query::equal(Storage::PERMISSIONS_DOCUMENT, $documentIds)]); + + if (! $context->run($permissionsBuilder->delete(), Event::PermissionsDelete)) { + throw new DatabaseException('Failed to delete permissions'); + } + } + + /** + * Batched version of readCurrentPermissions — issues a single SELECT scoped + * to all document ids and groups rows into the same shape per document. + * + * @param array $documents + * @return array{0: array>>, 1: array} + */ + private function readCurrentPermissionsBatch(string $collection, array $documents, WriteContext $context): array + { + if (empty($documents)) { + return [[], []]; + } + + $documentIds = $this->permissionReadIds($documents); + if ($documentIds === []) { + return [[], []]; + } + + $readBuilder = $context->builder()->from(Storage::permissionsTable($collection)); + $readBuilder->select([Storage::PERMISSIONS_DOCUMENT, Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION]); + $readBuilder->filter([Query::equal(Storage::PERMISSIONS_DOCUMENT, $documentIds)]); + + /** @var array> $rows */ + $rows = $context->fetch($readBuilder->build(), Event::PermissionsRead); + + return [ + $this->groupPermissionRows($documentIds, $rows), + $this->storedDocumentIds($documentIds, $rows), + ]; + } + + /** + * @param array $documents + * @return list + */ + private function permissionReadIds(array $documents): array + { + $documentIds = []; + foreach ($documents as $document) { + $id = $document->getId(); + if ($id !== '') { + $documentIds[] = $id; + } + } + + return \array_values(\array_unique($documentIds)); + } + + /** + * @param list $documentIds + * @param array> $rows + * @return array + */ + private function storedDocumentIds(array $documentIds, array $rows): array + { + $stored = []; + foreach ($rows as $row) { + $storedId = $row[Storage::PERMISSIONS_DOCUMENT] ?? null; + if (! \is_string($storedId) || $storedId === '') { + continue; + } + + foreach ($documentIds as $id) { + if (\strcasecmp($storedId, $id) === 0) { + $stored[$id] = $storedId; + } + } + } + + return $stored; + } + + /** + * @param array $storedIds + */ + private function permissionDocumentId(string $requestedId, array $storedIds): string + { + if (isset($storedIds[$requestedId]) && \strcasecmp($storedIds[$requestedId], $requestedId) === 0) { + return $storedIds[$requestedId]; + } + + foreach ($storedIds as $storedId) { + if (\strcasecmp($storedId, $requestedId) === 0) { + return $storedId; + } + } + + return $requestedId; + } + + /** + * @param list $documentIds + * @param array> $rows + * @return array>> + */ + private function groupPermissionRows(array $documentIds, array $rows): array + { + $result = []; + $requestedByLower = []; + foreach ($documentIds as $id) { + $result[$id] = $this->emptyPermissions(); + $requestedByLower[\strtolower($id)][] = $id; + } + + foreach ($rows as $row) { + $storedId = $row[Storage::PERMISSIONS_DOCUMENT] ?? null; + $type = $row[Storage::PERMISSIONS_TYPE] ?? null; + $permission = $row[Storage::PERMISSIONS_PERMISSION] ?? null; + if ($storedId === null || $type === null || $permission === null) { + continue; + } + + $targets = $requestedByLower[\strtolower($storedId)] ?? []; + if ($targets === []) { + $targets = [$this->resolveStoredDocumentId($storedId, $result, $requestedByLower)]; + } + + foreach ($targets as $key) { + if (! isset($result[$key])) { + $result[$key] = $this->emptyPermissions(); + } + $result[$key][$type][] = $permission; + } + } + + return $result; + } + + /** + * @param array>> $result + * @param array> $requestedByLower + */ + private function resolveStoredDocumentId(string $storedId, array $result, array $requestedByLower): string + { + if (isset($result[$storedId])) { + return $storedId; + } + + $candidates = $requestedByLower[\strtolower($storedId)] ?? []; + if (\count($candidates) === 1) { + return $candidates[0]; + } + + return $storedId; + } + + /** + * @param array>> $map + * @return array> + */ + private function currentPermissions(array $map, string $documentId): array + { + return $map[$documentId] ?? $this->emptyPermissions(); + } + + /** + * @param array $desired + * @param array $current + * @return list + */ + private function uniqueAdditions(array $desired, array $current): array + { + return \array_values(\array_unique(\array_diff($desired, $current))); + } + + /** + * @return array> + */ + private function emptyPermissions(): array + { + $initial = []; + foreach (self::PERMISSION_TYPES as $type) { + $initial[$type->value] = []; + } + + return $initial; + } + + /** + * A renamed document leaves its rows keyed by the old id, which nothing reads any more, so + * they are dropped and the full set is written under the new id. + */ + private function movePermissions(string $collection, string $previousId, Document $document, WriteContext $context): void + { + $removeBuilder = $context->builder()->from(Storage::permissionsTable($collection)); + $removeBuilder->filter([Query::equal(Storage::PERMISSIONS_DOCUMENT, [$previousId])]); + $context->run($removeBuilder->delete(), Event::PermissionsDelete); + + $this->afterDocumentCreate($collection, [$document], $context); + } + + /** + * @param array> $removals + */ + private function deletePermissions(string $collection, string $documentId, array $removals, WriteContext $context): void + { + if (empty($removals)) { + return; + } + + $removeConditions = []; + foreach ($removals as $type => $permissions) { + $removeConditions[] = Query::and([ + Query::equal(Storage::PERMISSIONS_DOCUMENT, [$documentId]), + Query::equal(Storage::PERMISSIONS_TYPE, [$type]), + Query::equal(Storage::PERMISSIONS_PERMISSION, $permissions), + ]); + } + + $removeBuilder = $context->builder()->from(Storage::permissionsTable($collection)); + $removeBuilder->filter([Query::or($removeConditions)]); + $context->run($removeBuilder->delete(), Event::PermissionsDelete); + } + + /** + * @param array> $additions + */ + private function insertPermissions(string $collection, Document $document, string $documentId, array $additions, WriteContext $context): void + { + if (empty($additions)) { + return; + } + + $addBuilder = $context->builder()->into($context->rawTable(Storage::permissionsTable($collection))); + + foreach ($additions as $type => $permissions) { + foreach (\array_values(\array_unique($permissions)) as $permission) { + $row = $context->decorateRow([ + Storage::PERMISSIONS_DOCUMENT => $documentId, + Storage::PERMISSIONS_TYPE => $type, + Storage::PERMISSIONS_PERMISSION => $permission, + ], $document); + $addBuilder->set($row); + } + } + + $context->run($addBuilder->insert(), Event::PermissionsCreate); + } + + /** + * Build permission rows for a document, applying decorateRow for tenant etc. + * + * @return list> + */ + private function buildPermissionRows(Document $document, WriteContext $context): array + { + $rows = []; + + foreach (self::PERMISSION_TYPES as $type) { + foreach ($document->getPermissionsByType($type) as $permission) { + $row = [ + Storage::PERMISSIONS_DOCUMENT => $document->getId(), + Storage::PERMISSIONS_TYPE => $type->value, + Storage::PERMISSIONS_PERMISSION => \str_replace('"', '', $permission), + ]; + $rows[] = $context->decorateRow($row, $document); + } + } + + return $rows; + } +} diff --git a/src/Database/Hook/PreparedCreate.php b/src/Database/Hook/PreparedCreate.php new file mode 100644 index 0000000000..61a76a5bf7 --- /dev/null +++ b/src/Database/Hook/PreparedCreate.php @@ -0,0 +1,37 @@ + The prepared documents not written yet, each after its collection, in + * the order they are to be written + */ + public array $documents = []; + + /** + * @var array> The documents not finished preparing, which one by one would not be + * written yet, by collection id and document id + */ + public array $preparing = []; + + /** + * @var array The collections read while preparing, by id + */ + public array $collections = []; +} diff --git a/src/Database/Hook/Relationships.php b/src/Database/Hook/Relationships.php new file mode 100644 index 0000000000..e2b657f741 --- /dev/null +++ b/src/Database/Hook/Relationships.php @@ -0,0 +1,3374 @@ + + */ + private Value $enabled; + + /** + * @var Value + */ + private Value $checkExist; + + private int $fetchDepth = 0; + + /** + * @var Value + */ + private Value $inBatchPopulation; + + /** + * @var array> The collections of each coroutine's relationship writes in progress, innermost + * last, by coroutine id + */ + private array $writeStacks = []; + + /** + * @var array> The relationships of each coroutine's cascading deletes in progress, + * innermost last, by coroutine id + */ + private array $deleteStacks = []; + + /** + * @var array Each coroutine's create in progress whose related documents are prepared, by + * coroutine id + */ + private array $prepared = []; + + /** + * @var array How many creates each coroutine is relating one document at a time, by coroutine id + */ + private array $replays = []; + + private Database $database; + + /** + * @param bool $prepare Whether a create whose related documents are all new prepares them instead of creating + * each through createDocument(), which reads it before and after writing it + */ + public function __construct( + private readonly bool $prepare = true, + ) { + $this->enabled = new Value(true); + $this->checkExist = new Value(true); + $this->inBatchPopulation = new Value(false); + } + + /** + * A copy configured like this hook, with none of its state and attached to no database, for another database to + * attach. + */ + public function __clone() + { + unset($this->database); + $this->enabled = new Value(true); + $this->checkExist = new Value(true); + $this->inBatchPopulation = new Value(false); + $this->fetchDepth = 0; + $this->writeStacks = []; + $this->deleteStacks = []; + $this->prepared = []; + $this->replays = []; + } + + /** + * Attaching again to the same database does nothing; a hook is bound to one database for its lifetime, so attach + * a clone to another. + * + * @throws DatabaseException When the hook is already attached to another database + */ + #[\Override] + public function attach(Database $database): void + { + if (isset($this->database) && $this->database !== $database) { + throw new DatabaseException('This relationships hook is already attached to another database; attach a clone of it instead'); + } + + $this->database = $database; + } + + /** + * Capped by RELATION_QUERY_CHUNK_SIZE as a memory bound, but never larger than the configured maxQueryValues, + * otherwise a caller that lowers the validator cap would still see relationship updates throw QueryException on + * the chunked find/update fallback. + * + * @return int<1, max> + */ + private function relationQueryChunkSize(): int + { + return \max(1, \min(Database::RELATION_QUERY_CHUNK_SIZE, $this->database->getMaxQueryValues())); + } + + /** + * Run one read per chunk and return their documents in chunk order. Several reads run at the same time only where + * each can borrow its own connection: inside a coroutine, on a pooled adapter whose connection no transaction + * has pinned, and at most as many as {@see self::READ_CONCURRENCY} and the pool's idle connections allow. Each + * concurrent read starts from its caller's authorization, relationship and silence state, and what it changes + * stays in its own coroutine. + * + * @param array> $reads + * @return array + */ + private function readChunks(array $reads): array + { + $reads = \array_values($reads); + $concurrency = $this->readConcurrency(\count($reads)); + + if ($concurrency > 1) { + $chunks = $this->readConcurrently($reads, $concurrency); + } else { + $chunks = []; + foreach ($reads as $read) { + $chunks[] = $read(); + } + } + + $documents = []; + foreach ($chunks as $chunk) { + \array_push($documents, ...$chunk); + } + + return $documents; + } + + /** + * @param list> $reads + * @param int<2, max> $concurrency + * @return array> + */ + private function readConcurrently(array $reads, int $concurrency): array + { + $snapshot = $this->database->snapshot(); + $chunks = []; + $next = 0; + + $reader = function () use ($reads, $snapshot, &$chunks, &$next): void { + while (isset($reads[$next])) { + $index = $next++; + + try { + $chunks[$index] = $this->database->withSnapshot($snapshot, $reads[$index]); + } catch (Throwable $error) { + $next = \count($reads); + + throw $error; + } + } + }; + + Promise::map(\array_fill(0, $concurrency, $reader))->await(); + \ksort($chunks); + + return $chunks; + } + + private function readConcurrency(int $reads): int + { + if ($reads < 2 || ! \extension_loaded('swoole') || Coroutine::getCid() <= 0) { + return 1; + } + + $adapter = $this->database->getAdapter(); + if (! $adapter instanceof Pool) { + return 1; + } + + return \min($reads, self::READ_CONCURRENCY, $adapter->getReadConcurrency()); + } + + /** + * @param array $ids + * @param Closure(array): array $read + * @return array + */ + private function readByIds(array $ids, Closure $read): array + { + $documents = []; + foreach (\array_chunk($ids, $this->relationQueryChunkSize()) as $chunk) { + \array_push($documents, ...$read($chunk)); + } + + return $documents; + } + + private function coerceToDocument(Document $document, string $key, mixed $value): mixed + { + if (\is_array($value) && ! \array_is_list($value)) { + try { + $value = new Document($value); // @phpstan-ignore argument.type + } catch (StructureException $e) { + throw new RelationshipException('Invalid relationship value. ' . $e->getMessage()); + } + $document->setAttribute($key, $value); + } + + return $value; + } + + /** + * @internal + */ + public function isEnabled(): bool + { + return $this->enabled->get(); + } + + /** + * @internal + */ + public function setEnabled(bool $enabled): void + { + $this->enabled->set($enabled); + } + + /** + * Run the callback with relationships enabled or disabled for the calling coroutine and the coroutines it starts. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @internal + */ + public function withEnabled(bool $enabled, callable $callback): mixed + { + return $this->enabled->with($enabled, $callback); + } + + /** + * @internal + */ + public function shouldCheckExist(): bool + { + return $this->checkExist->get(); + } + + /** + * Run the callback with existence checks on or off for the calling coroutine and the coroutines it starts. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @internal + */ + public function withCheckExist(bool $check, callable $callback): mixed + { + return $this->checkExist->with($check, $callback); + } + + /** + * @internal + */ + public function getWriteStackCount(): int + { + return \count($this->writeStacks[$this->coroutine()] ?? []); + } + + /** + * @internal + */ + public function getFetchDepth(): int + { + return $this->fetchDepth; + } + + /** + * @internal + */ + public function isInBatchPopulation(): bool + { + return $this->inBatchPopulation->get(); + } + + /** + * Run the callback under the relationship state a snapshot carries. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @internal + */ + public function withSnapshot(Snapshot $snapshot, callable $callback): mixed + { + return $this->enabled->with( + $snapshot->relationships, + fn () => $this->checkExist->with( + $snapshot->existCheck, + fn () => $this->inBatchPopulation->with($snapshot->population, $callback), + ), + ); + } + + /** + * Relate the related documents of a new document, removing or replacing its relationship values with what is + * stored for them. + * + * @param array}>|null $copies Given an array, receives the document + * and the documents nested in it as + * they were before relating changed + * them, for restore() to give back to an + * attempt the transaction retries + * + * @throws DuplicateException If a related document already exists + * @throws RelationshipException If a relationship constraint is violated + * + * @internal + */ + public function afterDocumentCreate(Document $collection, Document $document, ?array &$copies = null): Document + { + $coroutine = $this->coroutine(); + $relate = function (?PreparedCreate $prepared) use ($collection, $document, $coroutine, &$copies): Document { + return $this->relate($collection, $document, $coroutine, $prepared, $copies); + }; + + if (! $this->canPrepare($coroutine) || ! $this->hasRelatedDocuments($collection, $document)) { + return $relate(null); + } + + $prepared = $this->createPrepared(); + $created = []; + $visited = []; + if (! $this->collectCreated($prepared, $collection, $document, $this->writeStacks[$coroutine] ?? [], $created, $visited)) { + return $this->relateOneByOne($coroutine, $relate); + } + + if ($this->isReferencedBack($collection, $document)) { + $created[$collection->getId()][\strtolower($document->getId())] = $document->getId(); + $prepared->preparing[$collection->getId()][$document->getId()] = true; + $prepared->collections[$collection->getId()] ??= $collection; + } + + return $this->relatePrepared($prepared, $coroutine, $created, [$document], $relate); + } + + /** + * Relate new related documents through $relate without reading each before creating it and reading it back + * after, when none of them is stored yet. Where the adapter has savepoints, the new related documents and + * their junction documents are prepared without being written, then written in the order they would have + * been written one by one, and any failure rolls the attempt back and relates them one by one instead, so a + * failing write fails the way it always has. Elsewhere each is written where it would have been written on its + * own. Either way a failure restores every document it changed, so relating one by one, or a transaction that + * retries the write after the engine rolled it back, starts over from the documents it was given. + * + * @template T + * + * @param array> $created The ids of the related documents to create, by collection + * @param list $documents The documents the relating changes + * @param Closure(?PreparedCreate): T $relate Relates the documents, prepared or, given null, one by one + * @return T + */ + private function relatePrepared(PreparedCreate $prepared, int $coroutine, array $created, array $documents, Closure $relate): mixed + { + if ($this->anyStored($prepared, $created)) { + return $this->relateOneByOne($coroutine, $relate); + } + + $copies = []; + foreach ($documents as $document) { + $this->copy($document, $copies); + } + + $attempt = function () use ($prepared, $coroutine, $relate, $copies): mixed { + $this->prepared[$coroutine] = $prepared; + + try { + $result = $relate($prepared); + $this->writePrepared($prepared); + + return $result; + } catch (Throwable $error) { + $this->restore($copies); + + throw $error; + } finally { + unset($this->prepared[$coroutine]); + } + }; + + if (! $prepared->deferred) { + return $attempt(); + } + + $replayed = false; + $replay = function () use ($coroutine, $relate, &$replayed): mixed { + $replayed = true; + + return $this->relateOneByOne($coroutine, $relate); + }; + + try { + return $this->database->withSavepoint($attempt, $replay); + } catch (Throwable $error) { + if (! $replayed) { + $this->restore($copies); + } + + throw $error; + } + } + + /** + * Relate an updated document's new related documents through $relate, prepared when none of them is stored. + * + * @param list $documents + * @param Closure(?PreparedCreate): void $relate Relates the documents, prepared or, given null, one by one + */ + private function relateUpdated(int $coroutine, Document $relatedCollection, array $documents, Closure $relate): void + { + if ($documents === [] || ! $this->canPrepare($coroutine)) { + $relate(null); + + return; + } + + $prepared = $this->createPrepared(); + $prepared->collections[$relatedCollection->getId()] = $relatedCollection; + $created = []; + $visited = []; + if (! $this->collectRelated($prepared, $relatedCollection, $documents, $this->writeStacks[$coroutine] ?? [], $created, $visited)) { + $this->relateOneByOne($coroutine, $relate); + + return; + } + + $this->relatePrepared($prepared, $coroutine, $created, $documents, $relate); + } + + private function createPrepared(): PreparedCreate + { + return new PreparedCreate($this->database->getAdapter()->supports(Capability::TransactionNested)); + } + + /** + * @template T + * + * @param Closure(?PreparedCreate): T $relate + * @return T + */ + private function relateOneByOne(int $coroutine, Closure $relate): mixed + { + $this->replays[$coroutine] = ($this->replays[$coroutine] ?? 0) + 1; + + try { + return $relate(null); + } finally { + if (--$this->replays[$coroutine] === 0) { + unset($this->replays[$coroutine]); + } + } + } + + /** + * Whether this coroutine's write may prepare its new related documents: none of its writes is preparing or + * relating one by one already, and it runs in a transaction. + */ + private function canPrepare(int $coroutine): bool + { + if (! $this->prepare || isset($this->prepared[$coroutine]) || isset($this->replays[$coroutine])) { + return false; + } + + $adapter = $this->database->getAdapter(); + + return $adapter->inTransaction() && ! $adapter->isTenantPerDocument(); + } + + private function hasRelatedDocuments(Document $collection, Document $document): bool + { + foreach (self::attributes($collection) as $attribute) { + if ($attribute->relationship === null) { + continue; + } + + $value = $document->getAttribute($attribute->key); + if ($value instanceof Document) { + return true; + } + + if (\is_array($value)) { + foreach ($value as $related) { + if ($related instanceof Document) { + return true; + } + } + } + } + + return false; + } + + /** + * Collect the ids of the related documents a new document's relationships would create, by related + * collection, as deep as relate() goes from the given write stack. Returns false when a related document + * appears twice, as its second appearance may find the first one written. + * + * @param list $writeStack + * @param array> $created Ids by related collection id and lower-cased id + * @param array $visited + */ + private function collectCreated(PreparedCreate $prepared, Document $collection, Document $document, array $writeStack, array &$created, array &$visited): bool + { + $depth = \count($writeStack); + + foreach (self::attributes($collection) as $attribute) { + $relationship = $attribute->relationship; + if ($relationship === null) { + continue; + } + + $key = $attribute->key; + try { + $value = $this->coerceToDocument($document, $key, $document->getAttribute($key)); + } catch (RelationshipException) { + return false; + } + $related = \array_values(\array_filter( + $value instanceof Document ? [$value] : (\is_array($value) ? $value : []), + static fn (mixed $item): bool => $item instanceof Document, + )); + if ($related === []) { + continue; + } + + $relatedCollection = $this->collection($prepared, $relationship->relatedCollection); + + if ($depth >= Database::RELATION_MAX_DEPTH - 1 && $writeStack[$depth - 1] !== $relatedCollection->getId()) { + continue; + } + + if (! $this->collectRelated($prepared, $relatedCollection, $related, [...$writeStack, $collection->getId()], $created, $visited)) { + return false; + } + } + + return true; + } + + /** + * Collect the ids of related documents, and of the related documents they would create in turn. + * + * @param list $documents + * @param list $writeStack The write stack relating each of them sees + * @param array> $created + * @param array $visited + */ + private function collectRelated(PreparedCreate $prepared, Document $relatedCollection, array $documents, array $writeStack, array &$created, array &$visited): bool + { + foreach ($documents as $document) { + $object = \spl_object_id($document); + if (isset($visited[$object])) { + return false; + } + $visited[$object] = true; + + $id = $document->getId(); + if ($id !== '') { + $lowered = \strtolower($id); + if (isset($created[$relatedCollection->getId()][$lowered])) { + return false; + } + $created[$relatedCollection->getId()][$lowered] = $id; + } + + if (! $this->collectCreated($prepared, $relatedCollection, $document, $writeStack, $created, $visited)) { + return false; + } + } + + return true; + } + + /** + * Whether a new document's related documents are given a reference back to it that relating them writes + * through, which reads it first. + */ + private function isReferencedBack(Document $collection, Document $document): bool + { + foreach (self::attributes($collection) as $attribute) { + $relationship = $attribute->relationship; + if ($relationship === null || $relationship->type !== RelationshipType::OneToOne || ! $relationship->twoWay) { + continue; + } + + $value = $document->getAttribute($attribute->key); + if ($value instanceof Document || \is_array($value)) { + return true; + } + } + + return false; + } + + /** + * Whether any of the documents is already stored, readable by the caller or not, or cannot be looked up. + * + * @param array> $ids Ids by collection id + */ + private function anyStored(PreparedCreate $prepared, array $ids): bool + { + $adapter = $this->database->getAdapter(); + $tenant = $adapter->hasSharedTables() ? $adapter->getTenant() : null; + + try { + foreach ($ids as $collection => $collectionIds) { + foreach (\array_chunk(\array_values($collectionIds), $this->relationQueryChunkSize()) as $chunk) { + $documents = \array_map( + static fn (string $id): Document => new Document([Document::ID => $id, Document::TENANT => $tenant]), + $chunk, + ); + foreach ($adapter->getSequences($this->collection($prepared, $collection), $documents) as $document) { + if ($document->getSequence() !== null) { + return true; + } + } + } + } + } catch (Throwable) { + return true; + } + + return false; + } + + /** + * A collection read once for the whole prepared create, as it cannot change while the create's transaction + * is open. + */ + private function collection(PreparedCreate $prepared, string $id): Document + { + return $prepared->collections[$id] ??= $this->database->getCollection($id); + } + + /** + * The attributes $collection declares, hydrated at most once per collection instance. + * + * @return list + */ + private static function attributes(Document $collection): array + { + return Collection::fromDocument($collection)->attributes(); + } + + /** + * The relationship attributes $collection declares. + * + * @return list + */ + private static function relationships(Document $collection): array + { + $relationships = []; + foreach (self::attributes($collection) as $attribute) { + if ($attribute->relationship !== null) { + $relationships[] = $attribute; + } + } + + return $relationships; + } + + /** + * Record the attributes of the document and of every document nested in it, so they can be restored. + * + * @param array}> $copies + */ + private function copy(Document $document, array &$copies): void + { + $id = \spl_object_id($document); + if (isset($copies[$id])) { + return; + } + + /** @var array $attributes */ + $attributes = (array) $document; + $copies[$id] = [$document, $attributes]; + + foreach ($attributes as $value) { + if ($value instanceof Document) { + $this->copy($value, $copies); + } elseif (\is_array($value)) { + foreach ($value as $item) { + if ($item instanceof Document) { + $this->copy($item, $copies); + } + } + } + } + } + + /** + * Give each copied document back the attributes it was copied with. + * + * @internal + * + * @param array}> $copies + */ + public function restore(array $copies): void + { + foreach ($copies as [$document, $attributes]) { + $document->exchangeArray($attributes); + } + } + + /** + * Prepare a new related document through the checks createDocument() applies, relate its own related + * documents, and write or queue it after them, where createDocument() would have written it. + */ + private function prepare(PreparedCreate $prepared, Document $collection, Document $document, int $coroutine): string + { + $document = $this->database->prepareCreate($collection, $document); + $id = $document->getId(); + + $prepared->preparing[$collection->getId()][$id] = true; + $document = $this->relate($collection, $document, $coroutine, $prepared); + unset($prepared->preparing[$collection->getId()][$id]); + + $prepared->documents[] = [$collection, $document]; + if (! $prepared->deferred) { + $this->writePrepared($prepared); + } + + return $id; + } + + /** + * Write the prepared documents queued so far, so whatever comes next sees the database as it would be had + * each been written on its own. + */ + private function writePrepared(PreparedCreate $prepared): void + { + if ($prepared->documents === []) { + return; + } + + $documents = $prepared->documents; + $prepared->documents = []; + $this->database->createPrepared($documents); + } + + /** + * @param array}>|null $copies Receives the document and the + * documents nested in it before + * relating changes them, unless the + * relating is nested in another write + */ + private function relate(Document $collection, Document $document, int $coroutine, ?PreparedCreate $prepared, ?array &$copies = null): Document + { + $writeStack = $this->writeStacks[$coroutine] ?? []; + $stackCount = \count($writeStack); + + foreach (self::attributes($collection) as $attribute) { + $relationship = $attribute->relationship; + $side = $attribute->side; + if ($relationship === null || $side === null) { + continue; + } + + $key = $attribute->key; + $value = $document->getAttribute($key); + if ($copies !== null && $stackCount === 0 && (\is_array($value) || $value instanceof Document)) { + $this->copy($document, $copies); + } + $relatedCollection = $prepared === null + ? $this->database->getCollection($relationship->relatedCollection) + : $this->collection($prepared, $relationship->relatedCollection); + $relationType = $relationship->type; + $twoWay = $relationship->twoWay; + $twoWayKey = $relationship->twoWayKey ?? ''; + + if ($stackCount >= Database::RELATION_MAX_DEPTH - 1 && $writeStack[$stackCount - 1] !== $relatedCollection->getId()) { + $document->removeAttribute($key); + + continue; + } + + $this->writeStacks[$coroutine][] = $collection->getId(); + + try { + $value = $this->coerceToDocument($document, $key, $value); + + if (\is_array($value)) { + if ($relationType === RelationshipType::OneToOne && ! $twoWay && $side === RelationshipSide::Child) { + throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); + } + + if ( + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Child) || + ($relationType === RelationshipType::OneToOne) + ) { + throw new RelationshipException('Invalid relationship value. Must be either a document ID or a document, array given.'); + } + + foreach ($value as $related) { + if ($related instanceof Document) { + $this->relateDocuments( + $collection, + $relatedCollection, + $key, + $document, + $related, + $relationType, + $twoWay, + $twoWayKey, + $side, + $coroutine, + $prepared, + ); + } elseif (\is_string($related)) { + $this->relateDocumentsById( + $collection, + $relatedCollection, + $key, + $document->getId(), + $related, + $relationType, + $twoWay, + $twoWayKey, + $side, + $prepared, + ); + } else { + throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); + } + } + $document->removeAttribute($key); + } elseif ($value instanceof Document) { + if ($relationType === RelationshipType::OneToOne && ! $twoWay && $side === RelationshipSide::Child) { + throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); + } + + if ( + ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Child) || + ($relationType === RelationshipType::ManyToMany) + ) { + throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, document given.'); + } + + $relatedId = $this->relateDocuments( + $collection, + $relatedCollection, + $key, + $document, + $value, + $relationType, + $twoWay, + $twoWayKey, + $side, + $coroutine, + $prepared, + ); + $document->setAttribute($key, $relatedId); + } elseif (\is_string($value)) { + if ($relationType === RelationshipType::OneToOne && $twoWay === false && $side === RelationshipSide::Child) { + throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); + } + + if ( + ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Child) || + ($relationType === RelationshipType::ManyToMany) + ) { + throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, document ID given.'); + } + + $this->relateDocumentsById( + $collection, + $relatedCollection, + $key, + $document->getId(), + $value, + $relationType, + $twoWay, + $twoWayKey, + $side, + $prepared, + ); + } elseif ($value === null) { + if ( + !(($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Child) || + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::OneToOne && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::OneToOne && $twoWay === true)) + ) { + $document->removeAttribute($key); + } + } else { + throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); + } + } finally { + $this->leaveWrite($coroutine); + } + } + + return $document; + } + + /** + * Relate the related documents of an updated document. + * + * @throws DuplicateException If a related document already exists + * @throws RelationshipException If a relationship constraint is violated + * @throws RestrictedException If a restricted relationship is violated + * + * @internal + */ + public function afterDocumentUpdate(Document $collection, Document $old, Document $document): Document + { + $coroutine = $this->coroutine(); + $writeStack = $this->writeStacks[$coroutine] ?? []; + $stackCount = \count($writeStack); + + foreach (self::attributes($collection) as $attribute) { + $relationship = $attribute->relationship; + $side = $attribute->side; + if ($relationship === null || $side === null) { + continue; + } + + $key = $attribute->key; + $value = $document->getAttribute($key); + + $value = $this->coerceToDocument($document, $key, $value); + + $oldValue = $old->getAttribute($key); + $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + $relationType = $relationship->type; + $twoWay = $relationship->twoWay; + $twoWayKey = $relationship->twoWayKey ?? ''; + + if (Operator::isOperator($value)) { + /** @var Operator $operator */ + $operator = $value; + if ($operator->isArrayOperation()) { + $existingIds = []; + if (\is_array($oldValue)) { + /** @var array $oldValue */ + $existingIds = \array_map(fn ($item) => $item instanceof Document ? $item->getId() : (string) $item, $oldValue); + } + + $value = $this->applyRelationshipOperator($operator, $existingIds); + $document->setAttribute($key, $value); + } + } + + if ($oldValue == $value) { + if ( + ($relationType === RelationshipType::OneToOne + || ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Parent)) && + $value instanceof Document + ) { + $document->setAttribute($key, $value->getId()); + + continue; + } + $document->removeAttribute($key); + + continue; + } + + if ($stackCount >= Database::RELATION_MAX_DEPTH - 1 && $writeStack[$stackCount - 1] !== $relatedCollection->getId()) { + $document->removeAttribute($key); + + continue; + } + + $this->writeStacks[$coroutine][] = $collection->getId(); + + try { + switch ($relationType) { + case RelationshipType::OneToOne: + if (! $twoWay) { + if ($side === RelationshipSide::Child) { + throw new RelationshipException('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); + } + + if (\is_string($value)) { + $related = $this->database->skipRelationships(fn () => $this->database->getDocument($relatedCollection->getId(), $value, [Query::select([Document::ID])])); + if ($related->isEmpty()) { + $document->setAttribute($key, null); + } + } elseif ($value instanceof Document) { + $relationId = $this->relateDocuments( + $collection, + $relatedCollection, + $key, + $document, + $value, + $relationType, + false, + $twoWayKey, + $side, + $coroutine, + null, + ); + $document->setAttribute($key, $relationId); + } elseif (is_array($value)) { + throw new RelationshipException('Invalid relationship value. Must be either a document, document ID or null. Array given.'); + } + + break; + } + + if (\is_string($value)) { + $related = $this->database->skipRelationships( + fn () => $this->database->getDocument($relatedCollection->getId(), $value, [Query::select([Document::ID])]) + ); + + if ($related->isEmpty()) { + $document->setAttribute($key, null); + } else { + /** @var Document|null $oldValueDoc */ + $oldValueDoc = $oldValue instanceof Document ? $oldValue : null; + if ( + $oldValueDoc?->getId() !== $value + && $this->isLinkedElsewhere($collection, $key, $value, $document) + ) { + throw new DuplicateException('Document already has a related document'); + } + + $this->database->skipRelationships(fn () => $this->database->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $related->setAttribute($twoWayKey, $document->getId()) + )); + } + } elseif ($value instanceof Document) { + $related = $this->database->skipRelationships(fn () => $this->database->getDocument($relatedCollection->getId(), $value->getId())); + + /** @var Document|null $oldValueDoc2 */ + $oldValueDoc2 = $oldValue instanceof Document ? $oldValue : null; + if ( + $oldValueDoc2?->getId() !== $value->getId() + && $this->isLinkedElsewhere($collection, $key, $value->getId(), $document) + ) { + throw new DuplicateException('Document already has a related document'); + } + + if ($related->isEmpty()) { + if (! isset($value[Document::PERMISSIONS])) { + $value->setAttribute(Document::PERMISSIONS, $document->getAttribute(Document::PERMISSIONS)); + } + $related = $this->database->createDocument( + $relatedCollection->getId(), + $value->setAttribute($twoWayKey, $document->getId()) + ); + } else { + $related = $this->database->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $value->setAttribute($twoWayKey, $document->getId()) + ); + } + + $document->setAttribute($key, $related->getId()); + } elseif ($value === null) { + /** @var Document|null $oldValueDocNull */ + $oldValueDocNull = $oldValue instanceof Document ? $oldValue : null; + if ($oldValueDocNull?->getId() !== null) { + $oldRelated = $this->database->skipRelationships( + fn () => $this->database->getDocument($relatedCollection->getId(), $oldValueDocNull->getId()) + ); + $this->database->skipRelationships(fn () => $this->database->updateDocument( + $relatedCollection->getId(), + $oldRelated->getId(), + new Document([$twoWayKey => null]) + )); + } + } else { + throw new RelationshipException('Invalid relationship value. Must be either a document, document ID or null.'); + } + break; + case RelationshipType::OneToMany: + case RelationshipType::ManyToOne: + if ( + ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Child) + ) { + if (! \is_array($value) || ! \array_is_list($value)) { + throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, '.\gettype($value).' given.'); + } + + /** @var array $oldValueArr */ + $oldValueArr = \is_array($oldValue) ? $oldValue : []; + $oldIds = \array_map(fn (Document $document) => $document->getId(), $oldValueArr); + + $newIds = \array_map(function ($item) { + if (\is_string($item)) { + return $item; + } elseif ($item instanceof Document) { + return $item->getId(); + } else { + throw new RelationshipException('Invalid relationship value. Must be either a document or document ID.'); + } + }, $value); + + $removedDocuments = \array_values(\array_diff($oldIds, $newIds)); + + if (! empty($removedDocuments)) { + // Chunk to honor the validator's maxQueryValues cap; without + // this a relationship update with thousands of removed + // children would throw QueryException. + foreach (\array_chunk($removedDocuments, $this->relationQueryChunkSize()) as $chunk) { + $this->database->getAuthorization()->skip(fn () => $this->database->skipRelationships(fn () => $this->database->updateDocuments( + $relatedCollection->getId(), + new Document([$twoWayKey => null]), + [Query::equal(Document::ID, $chunk)], + ))); + } + } + + $stringRelations = []; + $documentRelations = []; + foreach ($value as $relation) { + if (\is_string($relation)) { + $stringRelations[] = $relation; + } elseif ($relation instanceof Document) { + $documentRelations[] = $relation; + } else { + throw new RelationshipException('Invalid relationship value.'); + } + } + + if (! empty($stringRelations)) { + $unlinkedIds = []; + foreach (\array_chunk($stringRelations, $this->relationQueryChunkSize()) as $chunk) { + $unlinked = $this->database->skipRelationships( + fn () => $this->database->find($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal(Document::ID, $chunk), + $this->notReferencing($twoWayKey, $document->getId()), + Query::limit(\count($chunk)), + ]) + ); + foreach ($unlinked as $related) { + $unlinkedIds[] = $related->getId(); + } + } + + $this->linkRelatedDocuments($relatedCollection, $twoWayKey, $document->getId(), $unlinkedIds); + } + + $this->relateUpdated($coroutine, $relatedCollection, $documentRelations, function (?PreparedCreate $prepared) use ($documentRelations, $relatedCollection, $document, $twoWayKey, $coroutine): void { + foreach ($documentRelations as $relation) { + if ($prepared !== null) { + if (! isset($relation[Document::PERMISSIONS])) { + $relation->setAttribute(Document::PERMISSIONS, $document->getAttribute(Document::PERMISSIONS)); + } + $this->prepare($prepared, $relatedCollection, $relation->setAttribute($twoWayKey, $document->getId()), $coroutine); + + continue; + } + + $related = $this->database->skipRelationships( + fn () => $this->database->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select([Document::ID])]) + ); + + if ($related->isEmpty()) { + if (! isset($relation[Document::PERMISSIONS])) { + $relation->setAttribute(Document::PERMISSIONS, $document->getAttribute(Document::PERMISSIONS)); + } + $this->database->createDocument( + $relatedCollection->getId(), + $relation->setAttribute($twoWayKey, $document->getId()) + ); + } else { + $this->database->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $relation->setAttribute($twoWayKey, $document->getId()) + ); + } + } + }); + + $document->removeAttribute($key); + break; + } + + if (\is_string($value)) { + $related = $this->database->skipRelationships( + fn () => $this->database->getDocument($relatedCollection->getId(), $value, [Query::select([Document::ID])]) + ); + + if ($related->isEmpty()) { + $document->setAttribute($key, null); + } + $this->database->purgeCachedDocument($relatedCollection->getId(), $value); + } elseif ($value instanceof Document) { + if ($value->getId() === '') { + throw new RelationshipException('Invalid relationship value. Document must have a valid '.Document::ID.'.'); + } + + $related = $this->database->skipRelationships( + fn () => $this->database->getDocument($relatedCollection->getId(), $value->getId(), [Query::select([Document::ID])]) + ); + + if ($related->isEmpty()) { + if (! isset($value[Document::PERMISSIONS])) { + $value->setAttribute(Document::PERMISSIONS, $document->getAttribute(Document::PERMISSIONS)); + } + $this->database->createDocument( + $relatedCollection->getId(), + $value + ); + } elseif ($related->getAttributes() != $value->getAttributes()) { + $this->database->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $value + ); + $this->database->purgeCachedDocument($relatedCollection->getId(), $related->getId()); + } + + $document->setAttribute($key, $value->getId()); + } elseif ($value === null) { + break; + } elseif (is_array($value)) { + throw new RelationshipException('Invalid relationship value. Must be either a document ID or a document, array given.'); + } elseif (empty($value)) { + throw new RelationshipException('Invalid relationship value. Must be either a document ID or a document.'); + } else { + throw new RelationshipException('Invalid relationship value.'); + } + + break; + case RelationshipType::ManyToMany: + if ($value === null) { + break; + } + if (! \is_array($value)) { + throw new RelationshipException('Invalid relationship value. Must be an array of documents or document IDs.'); + } + + /** @var array $oldValueArrM2M */ + $oldValueArrM2M = \is_array($oldValue) ? $oldValue : []; + $oldIds = \array_map(fn (Document $document) => $document->getId(), $oldValueArrM2M); + + $newIds = \array_map(function ($item) { + if (\is_string($item)) { + return $item; + } elseif ($item instanceof Document) { + return $item->getId(); + } else { + throw new RelationshipException('Invalid relationship value. Must be either a document or document ID.'); + } + }, $value); + + $removedDocuments = \array_values(\array_diff($oldIds, $newIds)); + + if (! empty($removedDocuments)) { + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + // Chunk both the lookup and the delete so a many-to-many + // diff with thousands of removed peers stays within the + // validator's maxQueryValues ceiling. + $junctionIds = []; + foreach (\array_chunk($removedDocuments, $this->relationQueryChunkSize()) as $chunk) { + $junctions = $this->database->find($junction, [ + Query::select([Document::ID]), + Query::equal($key, $chunk), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX), + ]); + foreach ($junctions as $junctionDoc) { + $junctionIds[] = $junctionDoc->getId(); + } + } + + if (! empty($junctionIds)) { + foreach (\array_chunk($junctionIds, $this->relationQueryChunkSize()) as $chunk) { + $this->database->getAuthorization()->skip(fn () => $this->database->deleteDocuments( + $junction, + [Query::equal(Document::ID, $chunk)], + )); + } + } + } + + $relatedDocuments = \array_values(\array_filter($value, static fn (mixed $relation): bool => $relation instanceof Document)); + $this->relateUpdated($coroutine, $relatedCollection, $relatedDocuments, function (?PreparedCreate $prepared) use ($value, $oldIds, $collection, $relatedCollection, $document, $key, $twoWayKey, $side, $coroutine): void { + foreach ($value as $relation) { + if ($prepared !== null) { + if ($relation instanceof Document) { + if (! isset($relation[Document::PERMISSIONS])) { + $relation->setAttribute(Document::PERMISSIONS, $document->getAttribute(Document::PERMISSIONS)); + } + $relatedId = $this->prepare($prepared, $relatedCollection, $relation, $coroutine); + $this->prepare( + $prepared, + $this->collection($prepared, $this->getJunctionCollection($collection, $relatedCollection, $side)), + $this->junctionDocument($key, $relatedId, $twoWayKey, $document->getId()), + $coroutine, + ); + + continue; + } + + $this->writePrepared($prepared); + } + + if (\is_string($relation)) { + if (\in_array($relation, $oldIds)) { + continue; + } + + $related = $this->database->getDocument($relatedCollection->getId(), $relation, [Query::select([Document::ID])]); + + if ($related->isEmpty()) { + continue; + } + + $this->authorizeLink($relatedCollection, $related); + } elseif ($relation instanceof Document) { + $related = $this->database->getDocument($relatedCollection->getId(), $relation->getId(), [Query::select([Document::ID])]); + + if (! $related->isEmpty() && ! \in_array($relation->getId(), $oldIds)) { + $this->authorizeLink($relatedCollection, $related); + } + + if ($related->isEmpty()) { + if (! isset($relation[Document::PERMISSIONS])) { + $relation->setAttribute(Document::PERMISSIONS, $document->getAttribute(Document::PERMISSIONS)); + } + $related = $this->database->createDocument( + $relatedCollection->getId(), + $relation + ); + } elseif ($related->getAttributes() != $relation->getAttributes()) { + $related = $this->database->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $relation + ); + } + + if (\in_array($relation->getId(), $oldIds)) { + continue; + } + + $relation = $related->getId(); + } else { + throw new RelationshipException('Invalid relationship value. Must be either a document or document ID.'); + } + + $this->database->skipRelationships(fn () => $this->database->createDocument( + $this->getJunctionCollection($collection, $relatedCollection, $side), + $this->junctionDocument($key, $relation, $twoWayKey, $document->getId()), + )); + } + }); + + $document->removeAttribute($key); + break; + } + } finally { + $this->leaveWrite($coroutine); + } + } + + return $document; + } + + /** + * Apply the onDelete of each relationship on $collection before $document is deleted. + * + * With $report set, returns the documents on the other side of a two-way relationship that + * the delete leaves changed: the ones it wrote, and the ones it left holding a reference to + * the deleted document without writing them. One-way peers are left out, and so is every + * peer a cascade removed, anywhere down its chain. + * + * A peer is the copy the delete itself worked with: read and written with permissions + * skipped, like the rest of the delete, and returned without a read check on the principal + * running it. Whoever can read the peer is who needs to hear that it changed, and that is + * rarely whoever deleted the other side, so the report carries the trust the bulk callbacks + * already carry: deleteDocuments() hands $onNext each whole document it deleted, and + * upsertDocuments() hands it a pre-image read with permissions skipped. + * + * How the delete reached a peer decides its shape. One the delete wrote is the copy that + * write returned, carrying the key it cleared. One it did not write is the copy read off the + * deleted document, where relationship population has already stripped the back-reference, + * so that key is absent rather than null. Read a peer back to use more than its identity. + * + * @return list + * + * @throws RestrictedException If a restricted relationship prevents deletion + * + * @internal + */ + public function beforeDocumentDelete(Document $collection, Document $document, bool $report = false): array + { + /** @var array> $changed */ + $changed = []; + $cascaded = false; + + foreach (self::attributes($collection) as $attribute) { + $relationship = $attribute->relationship; + $side = $attribute->side; + if ($relationship === null || $side === null) { + continue; + } + + $key = $attribute->key; + $value = $document->getAttribute($key); + $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + $relationType = $relationship->type; + $twoWay = $relationship->twoWay; + $twoWayKey = $relationship->twoWayKey ?? ''; + $onDelete = $relationship->onDelete; + + $holdsKey = ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Child) + || ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Parent); + $unwritten = false; + + switch ($onDelete) { + case RelationshipDeleteAction::Restrict: + $this->deleteRestrict($collection, $relatedCollection, $document, $key, $relationType, $twoWay, $twoWayKey, $side); + $unwritten = true; + break; + case RelationshipDeleteAction::SetNull: + $written = $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side, $report && $twoWay); + + foreach ($written as $related) { + $changed[$relatedCollection->getId()][$related->getId()] = $related; + } + + $unwritten = $holdsKey || $relationType === RelationshipType::ManyToMany; + break; + case RelationshipDeleteAction::Cascade: + $unwritten = $holdsKey || ($relationType === RelationshipType::ManyToMany && $side === RelationshipSide::Child); + $cascade = new Cascade($collection->getId(), $document->getId(), $attribute); + + foreach ($this->deleteStacks[$this->coroutine()] ?? [] as $processed) { + $existingKey = $processed->attribute->key; + $existingCollection = $processed->collection; + $existingRelatedCollection = $processed->attribute->relationship?->relatedCollection; + $existingTwoWayKey = $processed->attribute->relationship->twoWayKey ?? ''; + $existingSide = $processed->attribute->side; + + $reflexive = $processed == $cascade; + + $symmetric = $existingKey === $twoWayKey + && $existingTwoWayKey === $key + && $existingRelatedCollection === $collection->getId() + && $existingCollection === $relatedCollection->getId() + && $existingSide !== $side; + + $transitive = (($existingKey === $twoWayKey + && $existingCollection === $relatedCollection->getId() + && $existingSide !== $side) + || ($existingTwoWayKey === $key + && $existingRelatedCollection === $collection->getId() + && $existingSide !== $side) + || ($existingKey === $key + && $existingTwoWayKey !== $twoWayKey + && $existingRelatedCollection === $relatedCollection->getId() + && $existingSide !== $side) + || ($existingKey !== $key + && $existingTwoWayKey === $twoWayKey + && $existingRelatedCollection === $relatedCollection->getId() + && $existingSide !== $side)); + + if ($reflexive || $symmetric || $transitive) { + break 2; + } + } + $this->deleteCascade($collection, $relatedCollection, $document, $key, $relationType, $twoWay, $twoWayKey, $side, $cascade); + break; + } + + foreach (\is_array($value) ? $value : [$value] as $related) { + if (! $related instanceof Document || $related->isEmpty()) { + continue; + } + + if ($onDelete === RelationshipDeleteAction::Cascade && ! $unwritten) { + $cascaded = true; + } elseif ($twoWay && $unwritten) { + $changed[$relatedCollection->getId()][$related->getId()] = $related; + } + } + } + + if (! $report) { + return []; + } + + unset($changed[$collection->getId()][$document->getId()]); + + if ($cascaded) { + $changed = $this->withoutRemoved($changed); + } + + $reported = []; + foreach ($changed as $documents) { + \array_push($reported, ...\array_values($documents)); + } + + return $reported; + } + + /** + * Keep the documents that still exist: a cascade can remove one anywhere down its chain. + * + * @param array> $documents Keyed by collection, then by id + * @return array> + */ + private function withoutRemoved(array $documents): array + { + $remaining = []; + + foreach ($documents as $collectionId => $byId) { + $ids = \array_values(\array_map(fn (Document $document): string => $document->getId(), $byId)); + + foreach (\array_chunk($ids, $this->relationQueryChunkSize()) as $chunk) { + $found = $this->database->getAuthorization()->skip(fn () => $this->database->find($collectionId, [ + Query::equal(Document::ID, $chunk), + Query::select([Document::ID]), + Query::limit(\count($chunk)), + ])); + + foreach ($found as $existing) { + $remaining[$collectionId][$existing->getId()] = $byId[$existing->getId()]; + } + } + } + + return $remaining; + } + + /** + * @param array $documents + * @param array> $selects + * @return array + * + * @internal + */ + public function populateDocuments(array $documents, Document $collection, int $fetchDepth, array $selects = []): array + { + return $this->inBatchPopulation->with(true, function () use ($documents, $collection, $fetchDepth, $selects): array { + $queue = [ + [ + 'documents' => $documents, + 'collection' => $collection, + 'depth' => $fetchDepth, + 'selects' => $selects, + 'skipKey' => null, + 'hasExplicitSelects' => ! empty($selects), + ], + ]; + + $currentDepth = $fetchDepth; + + while (! empty($queue) && $currentDepth < Database::RELATION_MAX_DEPTH) { + $nextQueue = []; + + foreach ($queue as $item) { + $batchDocuments = $item['documents']; + $batchCollection = $item['collection']; + $batchSelects = $item['selects']; + $skipKey = $item['skipKey'] ?? null; + $parentHasExplicitSelects = $item['hasExplicitSelects']; + + if (empty($batchDocuments)) { + continue; + } + + foreach (self::attributes($batchCollection) as $attribute) { + $relationship = $attribute->relationship; + $side = $attribute->side; + $key = $attribute->key; + if ( + $relationship === null + || $side === null + || $key === $skipKey + || ($parentHasExplicitSelects && ! \array_key_exists($key, $batchSelects)) + ) { + continue; + } + + $queries = $batchSelects[$key] ?? []; + $isAtMaxDepth = ($currentDepth + 1) >= Database::RELATION_MAX_DEPTH; + + if ($isAtMaxDepth) { + foreach ($batchDocuments as $document) { + $document->removeAttribute($key); + } + + continue; + } + + $relatedDocuments = $this->populateSingleRelationshipBatch( + $batchDocuments, + $batchCollection, + $key, + $relationship, + $side, + $queries + ); + + $twoWay = $relationship->twoWay; + $twoWayKey = $relationship->twoWayKey ?? ''; + + $hasNestedSelects = isset($batchSelects[$key]); + $shouldQueue = ! empty($relatedDocuments) && + ($hasNestedSelects || ! $parentHasExplicitSelects); + + if ($shouldQueue) { + $relatedCollectionId = $relationship->relatedCollection; + $relatedCollection = $this->database->silent(fn () => $this->database->findCollection($relatedCollectionId)); + + if ($relatedCollection !== null) { + $relationshipQueries = $hasNestedSelects ? $batchSelects[$key] : []; + + $nextSelects = $this->processQueries(self::relationships($relatedCollection), $relationshipQueries); + + $childHasExplicitSelects = $parentHasExplicitSelects; + + $nextQueue[] = [ + 'documents' => $relatedDocuments, + 'collection' => $relatedCollection, + 'depth' => $currentDepth + 1, + 'selects' => $nextSelects, + 'skipKey' => $twoWay ? $twoWayKey : null, + 'hasExplicitSelects' => $childHasExplicitSelects, + ]; + } + } + + if ($twoWay && ! empty($relatedDocuments)) { + foreach ($relatedDocuments as $relatedDocument) { + $relatedDocument->removeAttribute($twoWayKey); + } + } + } + } + + $queue = $nextQueue; + $currentDepth++; + } + + return $documents; + }); + } + + /** + * @param array $relationships The relationship attributes of the collection the queries read + * @param array $queries + * @return array> + * + * @internal + */ + public function processQueries(array $relationships, array $queries): array + { + $nestedSelections = []; + + // Fast exit: collections without relationships short-circuit before + // walking the query list. This is the common case for flat tables. + if (empty($relationships)) { + return $nestedSelections; + } + + // Pre-index relationships by key once so per-value lookups are O(1) + // instead of O(relationships) with a fresh array_filter each iteration. + /** @var array $relationshipsByKey */ + $relationshipsByKey = []; + foreach ($relationships as $relationship) { + $relationshipsByKey[$relationship->key] = $relationship; + } + + foreach ($queries as $query) { + if ($query->getMethod() !== Method::Select) { + continue; + } + + $values = $query->getValues(); + foreach ($values as $valueIndex => $value) { + if (! \is_string($value)) { + throw new QueryException('Select queries must contain only string attributes.'); + } + + if (! \str_contains($value, '.')) { + continue; + } + + $nesting = \explode('.', $value); + $selectedKey = \array_shift($nesting); + + $attribute = $relationshipsByKey[$selectedKey] ?? null; + $relationship = $attribute?->relationship; + + if ($relationship === null) { + continue; + } + + $nestingPath = \implode('.', $nesting); + + if (empty($nestingPath)) { + $nestedSelections[$selectedKey][] = Query::select(['*']); + } else { + $nestedSelections[$selectedKey][] = Query::select([$nestingPath]); + } + + switch ($relationship->type) { + case RelationshipType::ManyToMany: + unset($values[$valueIndex]); + break; + case RelationshipType::OneToMany: + if ($attribute->side === RelationshipSide::Parent) { + unset($values[$valueIndex]); + } else { + $values[$valueIndex] = $selectedKey; + } + break; + case RelationshipType::ManyToOne: + if ($attribute->side === RelationshipSide::Parent) { + $values[$valueIndex] = $selectedKey; + } else { + unset($values[$valueIndex]); + } + break; + case RelationshipType::OneToOne: + $values[$valueIndex] = $selectedKey; + break; + } + } + + $finalValues = \array_values($values); + if (empty($finalValues)) { + $finalValues = ['*']; + } + $query->setValues($finalValues); + } + + return $nestedSelections; + } + + /** + * @param array $relationships The relationship attributes of $collection + * @param array $queries + * @return array|null + * + * @throws QueryException If a relationship query references an invalid attribute + * + * @internal + */ + public function convertQueries(array $relationships, array $queries, ?Document $collection = null): ?array + { + // Fast exit: nothing to convert when the collection has no + // relationship attributes — saves the per-find query walk. + if (empty($relationships)) { + return $queries; + } + + $hasRelationshipQuery = false; + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (\str_contains($attr, '.') || $query->getMethod() === Method::ContainsAll) { + $hasRelationshipQuery = true; + break; + } + } + + if (! $hasRelationshipQuery) { + return $queries; + } + + /** @var array $relationshipsByKey */ + $relationshipsByKey = []; + foreach ($relationships as $relationship) { + $relationshipsByKey[$relationship->key] = $relationship; + } + + $additionalQueries = []; + $groupedQueries = []; + $indicesToRemove = []; + + foreach ($queries as $index => $query) { + if ($query->getMethod() !== Method::ContainsAll) { + continue; + } + + $attribute = $query->getAttribute(); + + if (! \str_contains($attribute, '.')) { + continue; + } + + $parts = \explode('.', $attribute); + $relationshipKey = \array_shift($parts); + $nestedAttribute = \implode('.', $parts); + $relationship = $relationshipsByKey[$relationshipKey] ?? null; + + if (! $relationship) { + continue; + } + + $parentIdSets = []; + $resolvedAttribute = Document::ID; + foreach ($query->getValues() as $value) { + /** @var string|int|float|bool|null $value */ + $relatedQuery = Query::equal($nestedAttribute, [$value]); + $result = $this->resolveRelationshipGroupToIds($relationship, [$relatedQuery], $collection); + + if ($result === null) { + return null; + } + + $resolvedAttribute = $result['attribute']; + $parentIdSets[] = $result['ids']; + } + + $ids = \count($parentIdSets) > 1 + ? \array_values(\array_intersect(...$parentIdSets)) + : ($parentIdSets[0] ?? []); + + if (empty($ids)) { + return null; + } + + $additionalQueries[] = Query::equal($resolvedAttribute, $ids); + $indicesToRemove[] = $index; + } + + foreach ($queries as $index => $query) { + if ($query->getMethod() === Method::Select || $query->getMethod() === Method::ContainsAll) { + continue; + } + + $attribute = $query->getAttribute(); + + if (! \str_contains($attribute, '.')) { + continue; + } + + $parts = \explode('.', $attribute); + $relationshipKey = \array_shift($parts); + $nestedAttribute = \implode('.', $parts); + $relationship = $relationshipsByKey[$relationshipKey] ?? null; + + if (! $relationship) { + continue; + } + + if (! isset($groupedQueries[$relationshipKey])) { + $groupedQueries[$relationshipKey] = [ + 'relationship' => $relationship, + 'queries' => [], + 'indices' => [], + ]; + } + + $groupedQueries[$relationshipKey]['queries'][] = [ + 'method' => $query->getMethod(), + 'attribute' => $nestedAttribute, + 'values' => $query->getValues(), + ]; + + $groupedQueries[$relationshipKey]['indices'][] = $index; + } + + foreach ($groupedQueries as $relationshipKey => $group) { + $relationship = $group['relationship']; + + $equalAttrs = []; + foreach ($group['queries'] as $queryData) { + if ($queryData['method'] === Method::Equal) { + $attr = $queryData['attribute']; + if (isset($equalAttrs[$attr])) { + throw new QueryException("Multiple equal queries on '{$relationshipKey}.{$attr}' will never match a single document. Use Query::containsAll() to match across different related documents."); + } + $equalAttrs[$attr] = true; + } + } + + $relatedQueries = []; + foreach ($group['queries'] as $queryData) { + $relatedQueries[] = new Query( + $queryData['method'], + $queryData['attribute'], + $queryData['values'] + ); + } + + try { + $result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries, $collection); + + if ($result === null) { + return null; + } + + $additionalQueries[] = Query::equal($result['attribute'], $result['ids']); + + foreach ($group['indices'] as $originalIndex) { + $indicesToRemove[] = $originalIndex; + } + } catch (QueryException $e) { + throw $e; + } catch (Exception $e) { + return null; + } + } + + foreach ($indicesToRemove as $index) { + unset($queries[$index]); + } + + return \array_merge(\array_values($queries), $additionalQueries); + } + + private function relateDocuments( + Document $collection, + Document $relatedCollection, + string $key, + Document $document, + Document $relation, + RelationshipType $relationType, + bool $twoWay, + string $twoWayKey, + RelationshipSide $side, + int $coroutine, + ?PreparedCreate $prepared, + ): string { + switch ($relationType) { + case RelationshipType::OneToOne: + if ($twoWay) { + $relation->setAttribute($twoWayKey, $document->getId()); + } + break; + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { + $relation->setAttribute($twoWayKey, $document->getId()); + } + break; + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Child) { + $relation->setAttribute($twoWayKey, $document->getId()); + } + break; + } + + if ($prepared !== null) { + return $this->prepareRelated($prepared, $collection, $relatedCollection, $key, $document, $relation, $relationType, $twoWayKey, $side, $coroutine); + } + + $related = $this->database->getDocument($relatedCollection->getId(), $relation->getId()); + + if ($relationType === RelationshipType::ManyToMany && ! $related->isEmpty()) { + $this->authorizeLink($relatedCollection, $related); + } + + if ($related->isEmpty()) { + if (! isset($relation[Document::PERMISSIONS])) { + $relation->setAttribute(Document::PERMISSIONS, $document->getPermissions()); + } + + $related = $this->database->createDocument($relatedCollection->getId(), $relation); + } elseif ($related->getAttributes() != $relation->getAttributes()) { + foreach ($relation->getAttributes() as $attribute => $value) { + $related->setAttribute($attribute, $value); + } + + $related = $this->database->updateDocument($relatedCollection->getId(), $related->getId(), $related); + } + + if ($relationType === RelationshipType::ManyToMany) { + $this->database->createDocument( + $this->getJunctionCollection($collection, $relatedCollection, $side), + $this->junctionDocument($key, $related->getId(), $twoWayKey, $document->getId()), + ); + } + + return $related->getId(); + } + + /** + * Prepare a related document as new, with its junction document: relatePrepared() found none of the related + * documents stored, where relateDocuments() reads each one to find out. + */ + private function prepareRelated( + PreparedCreate $prepared, + Document $collection, + Document $relatedCollection, + string $key, + Document $document, + Document $relation, + RelationshipType $relationType, + string $twoWayKey, + RelationshipSide $side, + int $coroutine, + ): string { + if (! isset($relation[Document::PERMISSIONS])) { + $relation->setAttribute(Document::PERMISSIONS, $document->getPermissions()); + } + + $relatedId = $this->prepare($prepared, $relatedCollection, $relation, $coroutine); + + if ($relationType === RelationshipType::ManyToMany) { + $this->prepare( + $prepared, + $this->collection($prepared, $this->getJunctionCollection($collection, $relatedCollection, $side)), + $this->junctionDocument($key, $relatedId, $twoWayKey, $document->getId()), + $coroutine, + ); + } + + return $relatedId; + } + + private function junctionDocument(string $key, string $relatedId, string $twoWayKey, string $documentId): Document + { + return new Document([ + $key => $relatedId, + $twoWayKey => $documentId, + Document::PERMISSIONS => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + } + + private function relateDocumentsById( + Document $collection, + Document $relatedCollection, + string $key, + string $documentId, + string $relationId, + RelationshipType $relationType, + bool $twoWay, + string $twoWayKey, + RelationshipSide $side, + ?PreparedCreate $prepared, + ): void { + if ($prepared !== null) { + if (! $this->writesById($relationType, $twoWay, $side)) { + return; + } + + // One by one, a document still being prepared is written after this read, and it was not stored before. + if ($this->checkExist->get() && isset($prepared->preparing[$relatedCollection->getId()][$relationId])) { + return; + } + + $this->writePrepared($prepared); + } + + $related = $this->database->skipRelationships(fn () => $this->database->getDocument($relatedCollection->getId(), $relationId)); + + if ($related->isEmpty() && $this->checkExist->get()) { + return; + } + + switch ($relationType) { + case RelationshipType::OneToOne: + if ($twoWay) { + $related->setAttribute($twoWayKey, $documentId); + $this->database->skipRelationships(fn () => $this->database->updateDocument($relatedCollection->getId(), $relationId, $related)); + } + break; + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Parent) { + $related->setAttribute($twoWayKey, $documentId); + $this->database->skipRelationships(fn () => $this->database->updateDocument($relatedCollection->getId(), $relationId, $related)); + } + break; + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Child) { + $related->setAttribute($twoWayKey, $documentId); + $this->database->skipRelationships(fn () => $this->database->updateDocument($relatedCollection->getId(), $relationId, $related)); + } + break; + case RelationshipType::ManyToMany: + if (! $related->isEmpty()) { + $this->authorizeLink($relatedCollection, $related); + } + + $this->database->purgeCachedDocument($relatedCollection->getId(), $relationId); + + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $this->database->skipRelationships(fn () => $this->database->createDocument( + $junction, + $this->junctionDocument($key, $relationId, $twoWayKey, $documentId), + )); + break; + } + } + + /** + * Whether relating a document by id writes anything; otherwise it only reads the related document. + */ + private function writesById(RelationshipType $relationType, bool $twoWay, RelationshipSide $side): bool + { + return match ($relationType) { + RelationshipType::OneToOne => $twoWay, + RelationshipType::OneToMany => $side === RelationshipSide::Parent, + RelationshipType::ManyToOne => $side === RelationshipSide::Child, + RelationshipType::ManyToMany => true, + }; + } + + private function getJunctionCollection(Document $collection, Document $relatedCollection, RelationshipSide $side): string + { + return $side === RelationshipSide::Parent + ? '_'.$collection->getSequence().'_'.$relatedCollection->getSequence() + : '_'.$relatedCollection->getSequence().'_'.$collection->getSequence(); + } + + private function isLinkedElsewhere(Document $collection, string $key, string $relatedId, Document $document): bool + { + return ! $this->database->getAuthorization()->skip(fn () => $this->database->skipRelationships(fn () => $this->database->findOne($collection->getId(), [ + Query::select([Document::ID]), + Query::equal($key, [$relatedId]), + Query::notEqual(Document::ID, $document->getId()), + ])))->isEmpty(); + } + + /** + * @param array $existingIds + * @return array + */ + private function applyRelationshipOperator(Operator $operator, array $existingIds): array + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + $valueIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $values)); + + switch ($method) { + case OperatorType::ArrayAppend: + return \array_values(\array_merge($existingIds, $valueIds)); + + case OperatorType::ArrayPrepend: + return \array_values(\array_merge($valueIds, $existingIds)); + + case OperatorType::ArrayInsert: + /** @var int $index */ + $index = $values[0] ?? 0; + $item = $values[1] ?? null; + $itemId = $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null); + if ($itemId !== null) { + \array_splice($existingIds, (int) $index, 0, [$itemId]); + } + + return \array_values($existingIds); + + case OperatorType::ArrayRemove: + $toRemove = $values[0] ?? null; + if (\is_array($toRemove)) { + $toRemoveIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $toRemove)); + + return \array_values(\array_diff($existingIds, $toRemoveIds)); + } + $toRemoveId = $toRemove instanceof Document ? $toRemove->getId() : (\is_string($toRemove) ? $toRemove : null); + if ($toRemoveId !== null) { + return \array_values(\array_diff($existingIds, [$toRemoveId])); + } + + return $existingIds; + + case OperatorType::ArrayUnique: + return \array_values(\array_unique($existingIds)); + + case OperatorType::ArrayIntersect: + return \array_values(\array_intersect($existingIds, $valueIds)); + + case OperatorType::ArrayDiff: + return \array_values(\array_diff($existingIds, $valueIds)); + + default: + return $existingIds; + } + } + + /** + * @param array $documents Documents of $collection + * @param string $key The key $relationship is stored under on $collection + * @param array $queries + * @return array + */ + private function populateSingleRelationshipBatch(array $documents, Document $collection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + { + return match ($relationship->type) { + RelationshipType::OneToOne => $this->populateOneToOneRelationshipsBatch($documents, $key, $relationship, $queries), + RelationshipType::OneToMany => $this->populateOneToManyRelationshipsBatch($documents, $key, $relationship, $side, $queries), + RelationshipType::ManyToOne => $this->populateManyToOneRelationshipsBatch($documents, $key, $relationship, $side, $queries), + RelationshipType::ManyToMany => $this->populateManyToManyRelationshipsBatch($documents, $collection, $key, $relationship, $side, $queries), + }; + } + + /** + * @param array $documents + * @param array $queries + * @return array + */ + private function populateOneToOneRelationshipsBatch(array $documents, string $key, Relationship $relationship, array $queries): array + { + $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + + $relatedIds = []; + $documentsByRelatedId = []; + + foreach ($documents as $document) { + $value = $document->getAttribute($key); + if ($value !== null) { + if ($value instanceof Document) { + continue; + } + + /** @var string $relId */ + $relId = $value; + $relatedIds[] = $relId; + if (! isset($documentsByRelatedId[$relId])) { + $documentsByRelatedId[$relId] = []; + } + $documentsByRelatedId[$relId][] = $document; + } + } + + if (empty($relatedIds)) { + return []; + } + + $selectQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if ($query->getMethod() === Method::Select) { + $selectQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + /** @var array $uniqueRelatedIds */ + $uniqueRelatedIds = \array_unique($relatedIds); + $collectionId = $relatedCollection->getId(); + $relatedDocuments = $this->readChunks(\array_map( + fn (array $chunk): Closure => fn (): array => $this->database->find($collectionId, [ + Query::equal(Document::ID, $chunk), + Query::limit(PHP_INT_MAX), + ...$otherQueries, + ]), + \array_chunk($uniqueRelatedIds, $this->relationQueryChunkSize()), + )); + + $relatedById = []; + foreach ($relatedDocuments as $related) { + $relatedById[$related->getId()] = $related; + } + + $this->database->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); + + foreach ($documentsByRelatedId as $relatedId => $docs) { + if (isset($relatedById[$relatedId])) { + foreach ($docs as $document) { + $document->setAttribute($key, $relatedById[$relatedId]); + } + } else { + foreach ($docs as $document) { + $document->setAttribute($key, new Document()); + } + } + } + + return $relatedDocuments; + } + + /** + * @param array $documents + * @param array $queries + * @return array + */ + private function populateOneToManyRelationshipsBatch(array $documents, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + { + $twoWayKey = $relationship->twoWayKey ?? ''; + $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + + if ($side === RelationshipSide::Child) { + if (! $relationship->twoWay) { + foreach ($documents as $document) { + $document->removeAttribute($key); + } + + return []; + } + + return $this->populateOneToOneRelationshipsBatch($documents, $key, $relationship, $queries); + } + + $parentIds = []; + foreach ($documents as $document) { + $parentId = $document->getId(); + $parentIds[] = $parentId; + } + + $parentIds = \array_unique($parentIds); + + if (empty($parentIds)) { + return []; + } + + $selectQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if ($query->getMethod() === Method::Select) { + $selectQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + $collectionId = $relatedCollection->getId(); + $relatedDocuments = $this->readChunks(\array_map( + fn (array $chunk): Closure => fn (): array => $this->database->find($collectionId, [ + Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX), + ...$otherQueries, + ]), + \array_chunk($parentIds, $this->relationQueryChunkSize()), + )); + + $relatedByParentId = []; + foreach ($relatedDocuments as $related) { + $parentId = $related->getAttribute($twoWayKey); + if ($parentId instanceof Document) { + $parentKey = $parentId->getId(); + } elseif (\is_string($parentId)) { + $parentKey = $parentId; + } else { + continue; + } + + if (! isset($relatedByParentId[$parentKey])) { + $relatedByParentId[$parentKey] = []; + } + $relatedByParentId[$parentKey][] = $related; + } + + $this->database->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); + + foreach ($documents as $document) { + $parentId = $document->getId(); + $relatedDocs = $relatedByParentId[$parentId] ?? []; + $document->setAttribute($key, $relatedDocs); + } + + return $relatedDocuments; + } + + /** + * @param array $documents + * @param array $queries + * @return array + */ + private function populateManyToOneRelationshipsBatch(array $documents, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + { + $twoWayKey = $relationship->twoWayKey ?? ''; + $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + + if ($side === RelationshipSide::Parent) { + return $this->populateOneToOneRelationshipsBatch($documents, $key, $relationship, $queries); + } + + if (! $relationship->twoWay) { + foreach ($documents as $document) { + $document->removeAttribute($key); + } + + return []; + } + + $childIds = []; + foreach ($documents as $document) { + $childId = $document->getId(); + $childIds[] = $childId; + } + + $childIds = array_unique($childIds); + + if (empty($childIds)) { + return []; + } + + $selectQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if ($query->getMethod() === Method::Select) { + $selectQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + $collectionId = $relatedCollection->getId(); + $relatedDocuments = $this->readChunks(\array_map( + fn (array $chunk): Closure => fn (): array => $this->database->find($collectionId, [ + Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX), + ...$otherQueries, + ]), + \array_chunk($childIds, $this->relationQueryChunkSize()), + )); + + $relatedByChildId = []; + foreach ($relatedDocuments as $related) { + $childId = $related->getAttribute($twoWayKey); + if ($childId instanceof Document) { + $childKey = $childId->getId(); + } elseif (\is_string($childId)) { + $childKey = $childId; + } else { + continue; + } + + if (! isset($relatedByChildId[$childKey])) { + $relatedByChildId[$childKey] = []; + } + $relatedByChildId[$childKey][] = $related; + } + + $this->database->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); + + foreach ($documents as $document) { + $childId = $document->getId(); + $document->setAttribute($key, $relatedByChildId[$childId] ?? []); + } + + return $relatedDocuments; + } + + /** + * @param array $documents + * @param array $queries + * @return array + */ + private function populateManyToManyRelationshipsBatch(array $documents, Document $collection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + { + $twoWayKey = $relationship->twoWayKey ?? ''; + $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + + if (! $relationship->twoWay && $side === RelationshipSide::Child) { + return []; + } + + $documentIds = []; + foreach ($documents as $document) { + $documentId = $document->getId(); + $documentIds[] = $documentId; + } + + $documentIds = array_unique($documentIds); + + if (empty($documentIds)) { + return []; + } + + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $junctions = $this->readChunks(\array_map( + fn (array $chunk): Closure => fn (): array => $this->database->skipRelationships(fn (): array => $this->database->find($junction, [ + Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX), + ])), + \array_chunk($documentIds, $this->relationQueryChunkSize()), + )); + + /** @var array $relatedIds */ + $relatedIds = []; + /** @var array> $junctionsByDocumentId */ + $junctionsByDocumentId = []; + + foreach ($junctions as $junctionDoc) { + $documentId = $junctionDoc->getAttribute($twoWayKey); + $relatedId = $junctionDoc->getAttribute($key); + + if ($documentId !== null && $relatedId !== null) { + $documentIdStr = $documentId instanceof Document ? $documentId->getId() : (\is_string($documentId) ? $documentId : null); + $relatedIdStr = $relatedId instanceof Document ? $relatedId->getId() : (\is_string($relatedId) ? $relatedId : null); + if ($documentIdStr === null || $relatedIdStr === null) { + continue; + } + if (! isset($junctionsByDocumentId[$documentIdStr])) { + $junctionsByDocumentId[$documentIdStr] = []; + } + $junctionsByDocumentId[$documentIdStr][] = $relatedIdStr; + $relatedIds[] = $relatedIdStr; + } + } + + $selectQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if ($query->getMethod() === Method::Select) { + $selectQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + $related = []; + $allRelatedDocs = []; + if (! empty($relatedIds)) { + $uniqueRelatedIds = array_unique($relatedIds); + $relatedCollectionId = $relatedCollection->getId(); + $foundRelated = $this->readChunks(\array_map( + fn (array $chunk): Closure => fn (): array => $this->database->find($relatedCollectionId, [ + Query::equal(Document::ID, $chunk), + Query::limit(PHP_INT_MAX), + ...$otherQueries, + ]), + \array_chunk($uniqueRelatedIds, $this->relationQueryChunkSize()), + )); + + $allRelatedDocs = $foundRelated; + + $relatedById = []; + foreach ($foundRelated as $doc) { + $relatedById[$doc->getId()] = $doc; + } + + $this->database->applySelectFiltersToDocuments($allRelatedDocs, $selectQueries); + + foreach ($junctionsByDocumentId as $documentId => $relatedDocIds) { + $documentRelated = []; + foreach ($relatedDocIds as $relatedId) { + if (isset($relatedById[$relatedId])) { + $documentRelated[] = $relatedById[$relatedId]; + } + } + $related[$documentId] = $documentRelated; + } + } + + foreach ($documents as $document) { + $documentId = $document->getId(); + $document->setAttribute($key, $related[$documentId] ?? []); + } + + return $allRelatedDocs; + } + + private function deleteRestrict( + Document $collection, + Document $relatedCollection, + Document $document, + string $key, + RelationshipType $relationType, + bool $twoWay, + string $twoWayKey, + RelationshipSide $side + ): void { + if ( + $relationType !== RelationshipType::ManyToOne + && $side === RelationshipSide::Parent + && $this->hasRelatedDocument($collection, $relatedCollection, $document, $key, $relationType, $twoWay, $twoWayKey, $side) + ) { + throw new RestrictedException('Cannot delete document because it has at least one related document.'); + } + + if ( + $relationType === RelationshipType::OneToOne + && $side === RelationshipSide::Child + && ! $twoWay + ) { + $this->database->getAuthorization()->skip(function () use ($document, $relatedCollection, $twoWayKey) { + $related = $this->database->findOne($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + ]); + + if ($related->isEmpty()) { + return; + } + + $this->database->skipRelationships(fn () => $this->database->updateDocument( + $relatedCollection->getId(), + $related->getId(), + new Document([ + $twoWayKey => null, + ]) + )); + }); + } + + if ( + $relationType === RelationshipType::ManyToOne + && $side === RelationshipSide::Child + ) { + $related = $this->database->getAuthorization()->skip(fn () => $this->database->findOne($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + ])); + + if (! $related->isEmpty()) { + throw new RestrictedException('Cannot delete document because it has at least one related document.'); + } + } + } + + private function hasRelatedDocument(Document $collection, Document $relatedCollection, Document $document, string $key, RelationshipType $relationType, bool $twoWay, string $twoWayKey, RelationshipSide $side): bool + { + $authorization = $this->database->getAuthorization(); + + if ($relationType === RelationshipType::OneToMany) { + return ! $authorization->skip(fn () => $this->database->findOne($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + ]))->isEmpty(); + } + + $relatedIds = $this->findRelatedIds($collection, $relatedCollection, $document, $key, $relationType, $twoWay, $twoWayKey, $side); + + foreach (\array_chunk($relatedIds, $this->relationQueryChunkSize()) as $chunk) { + $related = $authorization->skip(fn () => $this->database->findOne($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal(Document::ID, $chunk), + ])); + + if (! $related->isEmpty()) { + return true; + } + } + + return false; + } + + /** + * The IDs of the documents on the other side of the relationship that a delete of $document + * reaches, read from storage with permissions and relationships skipped. The relationship + * value on $document cannot be used: it holds only what the caller could read, and nothing + * at all when deleteDocuments() read the batch with a select. + * + * One-to-one and many-to-many IDs come from a stored reference, so the document they name + * may already be gone. + * + * @return list + */ + private function findRelatedIds(Document $collection, Document $relatedCollection, Document $document, string $key, RelationshipType $relationType, bool $twoWay, string $twoWayKey, RelationshipSide $side): array + { + return match ($relationType) { + RelationshipType::OneToOne => $side === RelationshipSide::Parent || $twoWay + ? $this->findStoredRelatedIds($collection, $document, $key) + : [], + RelationshipType::OneToMany => $side === RelationshipSide::Parent + ? $this->findReferencingIds($relatedCollection, $document, $twoWayKey) + : [], + RelationshipType::ManyToOne => $side === RelationshipSide::Child + ? $this->findReferencingIds($relatedCollection, $document, $twoWayKey) + : [], + RelationshipType::ManyToMany => $this->findJunctionRelatedIds($collection, $relatedCollection, $document, $key, $twoWayKey, $side), + }; + } + + /** + * @return list + */ + private function findStoredRelatedIds(Document $collection, Document $document, string $key): array + { + $stored = $this->database->getAuthorization()->skip(fn () => $this->database->skipRelationships( + fn () => $this->database->getDocument($collection->getId(), $document->getId(), forUpdate: true) + )); + $relatedId = $stored->getAttribute($key); + + return \is_string($relatedId) && $relatedId !== '' ? [$relatedId] : []; + } + + /** + * @return list + */ + private function findReferencingIds(Document $relatedCollection, Document $document, string $twoWayKey): array + { + return \array_values(\array_map( + fn (Document $related) => $related->getId(), + $this->findReferencingDocuments($relatedCollection, $document, $twoWayKey), + )); + } + + /** + * @return list + */ + private function findJunctionRelatedIds(Document $collection, Document $relatedCollection, Document $document, string $key, string $twoWayKey, RelationshipSide $side): array + { + $junctions = $this->database->getAuthorization()->skip(fn () => $this->database->skipRelationships(fn () => $this->database->find( + $this->getJunctionCollection($collection, $relatedCollection, $side), + [ + Query::select([$key]), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX), + ], + ))); + + $relatedIds = []; + foreach ($junctions as $junction) { + $relatedId = $junction->getAttribute($key); + if (\is_string($relatedId) && $relatedId !== '') { + $relatedIds[] = $relatedId; + } + } + + return \array_values(\array_unique($relatedIds)); + } + + /** + * Find every document in $relatedCollection whose $twoWayKey points at $document. + * + * A delete can start from a document fetched without its relationships + * populated, because deleteDocuments passes the caller's queries to find() + * and a select turns population off, so the value carried on the document + * cannot be trusted to list the referencing rows. + * + * Permissions are skipped: a referencing document the caller cannot read + * still has to have its foreign key cleared, or it is left pointing at a + * row that no longer exists. + * + * @return array + */ + private function findReferencingDocuments(Document $relatedCollection, Document $document, string $twoWayKey): array + { + return $this->database->getAuthorization()->skip(fn () => $this->database->find($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX), + ])); + } + + /** + * Clear the foreign key on every document referencing $document. + * + * @return list The documents as the write left them, or none unless $report is set + */ + private function clearReferences(Document $relatedCollection, Document $document, string $twoWayKey, bool $report): array + { + $relations = $this->findReferencingDocuments($relatedCollection, $document, $twoWayKey); + + if (empty($relations)) { + return []; + } + + $relationIds = \array_map(fn (Document $relation) => $relation->getId(), $relations); + + $cleared = []; + $collect = function (Document $updated) use (&$cleared): void { + $cleared[] = $updated; + }; + + foreach (\array_chunk($relationIds, $this->relationQueryChunkSize()) as $chunk) { + $this->database->getAuthorization()->skip(fn () => $this->database->skipRelationships(fn () => $this->database->updateDocuments( + $relatedCollection->getId(), + new Document([$twoWayKey => null]), + [Query::equal(Document::ID, $chunk)], + onNext: $report ? $collect : null, + ))); + } + + return $cleared; + } + + /** + * @return list The documents the delete wrote, as the write left them, or none unless $report is set + */ + private function deleteSetNull(Document $collection, Document $relatedCollection, Document $document, RelationshipType $relationType, bool $twoWay, string $twoWayKey, RelationshipSide $side, bool $report): array + { + switch ($relationType) { + case RelationshipType::OneToOne: + if (! $twoWay && $side === RelationshipSide::Parent) { + return []; + } + + $written = $this->database->getAuthorization()->skip(function () use ($document, $relatedCollection, $twoWayKey): ?Document { + $related = $this->database->findOne($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + ]); + + if ($related->isEmpty()) { + return null; + } + + return $this->database->skipRelationships(fn () => $this->database->updateDocument( + $relatedCollection->getId(), + $related->getId(), + new Document([ + $twoWayKey => null, + ]) + )); + }); + + return ! $report || $written === null || $written->isEmpty() ? [] : [$written]; + + case RelationshipType::OneToMany: + if ($side === RelationshipSide::Child) { + return []; + } + + return $this->clearReferences($relatedCollection, $document, $twoWayKey, $report); + + case RelationshipType::ManyToOne: + if ($side === RelationshipSide::Parent) { + return []; + } + + return $this->clearReferences($relatedCollection, $document, $twoWayKey, $report); + + case RelationshipType::ManyToMany: + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $junctions = $this->database->find($junction, [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX), + ]); + + $junctionIds = \array_map(fn (Document $junctionDocument) => $junctionDocument->getId(), $junctions); + $this->database->skipRelationships(fn () => $this->deleteRelatedDocuments($junction, $junctionIds)); + break; + } + + return []; + } + + private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, RelationshipType $relationType, bool $twoWay, string $twoWayKey, RelationshipSide $side, Cascade $cascade): void + { + switch ($relationType) { + case RelationshipType::OneToOne: + case RelationshipType::OneToMany: + case RelationshipType::ManyToOne: + $relatedIds = $this->findRelatedIds($collection, $relatedCollection, $document, $key, $relationType, $twoWay, $twoWayKey, $side); + + if ($relatedIds !== []) { + $this->cascade($cascade, fn () => $this->deleteRelatedDocuments($relatedCollection->getId(), $relatedIds)); + } + + break; + case RelationshipType::ManyToMany: + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $junctions = $this->database->skipRelationships(fn () => $this->database->find($junction, [ + Query::select([Document::ID, $key]), + Query::equal($twoWayKey, [$document->getId()]), + Query::limit(PHP_INT_MAX), + ])); + + $junctionIds = []; + $relatedIds = []; + foreach ($junctions as $junctionDocument) { + $junctionIds[] = $junctionDocument->getId(); + if ($side === RelationshipSide::Parent) { + $relatedAttribute = $junctionDocument->getAttribute($key); + $relatedId = $relatedAttribute instanceof Document ? $relatedAttribute->getId() : (\is_string($relatedAttribute) ? $relatedAttribute : null); + if ($relatedId !== null) { + $relatedIds[] = $relatedId; + } + } + } + + $this->cascade($cascade, function () use ($relatedCollection, $relatedIds, $junction, $junctionIds): void { + $this->deleteRelatedDocuments($relatedCollection->getId(), $relatedIds); + $this->deleteRelatedDocuments($junction, $junctionIds); + }); + break; + } + } + + /** + * @param callable(): mixed $callback + */ + private function cascade(Cascade $cascade, callable $callback): void + { + $coroutine = $this->coroutine(); + $this->deleteStacks[$coroutine][] = $cascade; + + try { + $callback(); + } finally { + \array_pop($this->deleteStacks[$coroutine]); + if ($this->deleteStacks[$coroutine] === []) { + unset($this->deleteStacks[$coroutine]); + } + } + } + + private function leaveWrite(int $coroutine): void + { + \array_pop($this->writeStacks[$coroutine]); + if ($this->writeStacks[$coroutine] === []) { + unset($this->writeStacks[$coroutine]); + } + } + + private function coroutine(): int + { + /** @var int $coroutine */ + $coroutine = \extension_loaded('swoole') ? Coroutine::getCid() : -1; + + return $coroutine; + } + + /** + * Delete the related documents with the given IDs, one at a time through + * deleteDocument(). A bulk delete selects its batch under the caller's + * read permission, so a related document the caller may not read is + * left behind, together with everything below it, and its own + * relationships are never checked. deleteDocument() loads the document + * without reading it, checks only the caller's delete permission, and + * cascades below it, so it throws AuthorizationException or + * RestrictedException for a document that cannot go, which rolls back + * the delete that started the cascade, and skips one that is already + * gone. + * + * @param array $ids + */ + private function deleteRelatedDocuments(string $collection, array $ids): void + { + foreach (\array_values(\array_unique($ids)) as $id) { + $this->database->deleteDocument($collection, $id); + } + } + + /** + * Point $twoWayKey on the related documents with the given IDs at $documentId. + * + * updateDocuments() leaves out every document the caller may not update, + * so a chunk that comes back short is finished one document at a time + * through linkRelatedDocument(). + * + * @param array $ids + */ + private function linkRelatedDocuments(Document $collection, string $twoWayKey, string $documentId, array $ids): void + { + foreach (\array_chunk(\array_values(\array_unique($ids)), $this->relationQueryChunkSize()) as $chunk) { + $linked = $this->database->skipRelationships(fn () => $this->database->updateDocuments( + $collection->getId(), + new Document([$twoWayKey => $documentId]), + [Query::equal(Document::ID, $chunk)], + )); + + if ($linked === \count($chunk)) { + continue; + } + + $unlinked = $this->database->getAuthorization()->skip(fn () => $this->database->skipRelationships(fn () => $this->database->find($collection->getId(), [ + Query::select([Document::ID]), + Query::equal(Document::ID, $chunk), + $this->notReferencing($twoWayKey, $documentId), + Query::limit(\count($chunk)), + ]))); + + foreach ($unlinked as $related) { + $this->linkRelatedDocument($collection, $related->getId(), $twoWayKey, $documentId); + } + } + } + + /** + * Link one related document that updateDocuments() left out. A document that is already gone + * or already linked is skipped. + * + * @throws AuthorizationException + */ + private function linkRelatedDocument(Document $collection, string $id, string $twoWayKey, string $documentId): void + { + $authorization = $this->database->getAuthorization(); + + $related = $authorization->skip(fn () => $this->database->skipRelationships( + fn () => $this->database->getDocument($collection->getId(), $id, forUpdate: true) + )); + + if ($related->isEmpty() || $related->getAttribute($twoWayKey) === $documentId) { + return; + } + + $this->authorizeLink($collection, $related); + + $this->database->skipRelationships(fn () => $this->database->updateDocument( + $collection->getId(), + $id, + new Document([$twoWayKey => $documentId]), + )); + } + + /** + * Linking an existing document to another one needs update permission on it, whichever + * relationship type holds the link. + * + * @throws AuthorizationException + */ + private function authorizeLink(Document $collection, Document $related): void + { + $authorization = $this->database->getAuthorization(); + + if (! $authorization->isValid(new Input(PermissionType::Update, [ + ...$collection->getPermissionsByType(PermissionType::Update), + ...($collection->getAttribute('documentSecurity', false) ? $related->getPermissionsByType(PermissionType::Update) : []), + ]))) { + throw new AuthorizationException($authorization->getDescription()); + } + } + + private function notReferencing(string $twoWayKey, string $documentId): Query + { + return Query::or([ + Query::isNull($twoWayKey), + Query::notEqual($twoWayKey, $documentId), + ]); + } + + /** + * @param array $queries + * @return array|null + */ + private function processNestedRelationshipPath(string $startCollection, array $queries): ?array + { + $pathGroups = []; + foreach ($queries as $query) { + $attribute = $query->getAttribute(); + if (\str_contains($attribute, '.')) { + $parts = \explode('.', $attribute); + $pathKey = \implode('.', \array_slice($parts, 0, -1)); + if (! isset($pathGroups[$pathKey])) { + $pathGroups[$pathKey] = []; + } + $pathGroups[$pathKey][] = [ + 'method' => $query->getMethod(), + 'attribute' => \end($parts), + 'values' => $query->getValues(), + ]; + } + } + + /** @var array $allMatchingIds */ + $allMatchingIds = []; + foreach ($pathGroups as $path => $queryGroup) { + $pathParts = \explode('.', $path); + $currentCollection = $startCollection; + /** @var list $relationshipChain Each link's key, the collection it starts from, its relationship and side */ + $relationshipChain = []; + + foreach ($pathParts as $relationshipKey) { + $definition = $this->database->silent(fn () => $this->database->findCollection($currentCollection)); + if ($definition === null) { + return null; + } + + $link = null; + foreach (self::attributes($definition) as $attribute) { + if ($attribute->key === $relationshipKey && $attribute->relationship !== null && $attribute->side !== null) { + $link = [$relationshipKey, $currentCollection, $attribute->relationship, $attribute->side]; + break; + } + } + + if ($link === null) { + return null; + } + + $relationshipChain[] = $link; + $currentCollection = $link[2]->relatedCollection; + } + + $leafQueries = []; + foreach ($queryGroup as $q) { + $leafQueries[] = new Query($q['method'], $q['attribute'], $q['values']); + } + + /** @var array $matchingDocs */ + $matchingDocs = $this->database->silent(fn () => $this->database->skipRelationships(fn () => $this->database->find( + $currentCollection, + \array_merge($leafQueries, [ + Query::select([Document::ID]), + Query::limit(PHP_INT_MAX), + ]) + ))); + + /** @var array $matchingIds */ + $matchingIds = \array_map(fn (Document $doc) => $doc->getId(), $matchingDocs); + + if (empty($matchingIds)) { + return null; + } + + for ($i = \count($relationshipChain) - 1; $i >= 0; $i--) { + [$linkKey, $linkFromCollection, $linkRelationship, $side] = $relationshipChain[$i]; + $relationType = $linkRelationship->type; + $linkToCollection = $linkRelationship->relatedCollection; + $linkTwoWayKey = $linkRelationship->twoWayKey ?? ''; + + $needsReverseLookup = ( + ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Child) || + ($relationType === RelationshipType::ManyToMany) + ); + + if ($needsReverseLookup) { + if ($relationType === RelationshipType::ManyToMany) { + $fromCollectionDoc = $this->database->silent(fn () => $this->database->getCollection($linkFromCollection)); + $toCollectionDoc = $this->database->silent(fn () => $this->database->getCollection($linkToCollection)); + $junction = $this->getJunctionCollection($fromCollectionDoc, $toCollectionDoc, $side); + + $junctionDocs = $this->readByIds($matchingIds, fn (array $chunk): array => $this->database->silent(fn () => $this->database->skipRelationships(fn () => $this->database->find($junction, [ + Query::equal($linkKey, $chunk), + Query::limit(PHP_INT_MAX), + ])))); + + /** @var array $parentIds */ + $parentIds = []; + foreach ($junctionDocs as $jDoc) { + $pIdRaw = $jDoc->getAttribute($linkTwoWayKey); + $pId = $pIdRaw instanceof Document ? $pIdRaw->getId() : (\is_string($pIdRaw) ? $pIdRaw : null); + if ($pId && ! \in_array($pId, $parentIds)) { + $parentIds[] = $pId; + } + } + } else { + $childDocs = $this->readByIds($matchingIds, fn (array $chunk): array => $this->database->silent(fn () => $this->database->skipRelationships(fn () => $this->database->find( + $linkToCollection, + [ + Query::equal(Document::ID, $chunk), + Query::limit(PHP_INT_MAX), + ] + )))); + + /** @var array $parentIds */ + $parentIds = []; + foreach ($childDocs as $doc) { + $parentValue = $doc->getAttribute($linkTwoWayKey); + if (\is_array($parentValue)) { + foreach ($parentValue as $pId) { + if ($pId instanceof Document) { + $pId = $pId->getId(); + } + if (\is_string($pId) && $pId && ! \in_array($pId, $parentIds)) { + $parentIds[] = $pId; + } + } + } else { + if ($parentValue instanceof Document) { + $parentValue = $parentValue->getId(); + } + if (\is_string($parentValue) && $parentValue && ! \in_array($parentValue, $parentIds)) { + $parentIds[] = $parentValue; + } + } + } + } + $matchingIds = $parentIds; + } else { + $parentDocs = $this->readByIds($matchingIds, fn (array $chunk): array => $this->database->silent(fn () => $this->database->skipRelationships(fn () => $this->database->find( + $linkFromCollection, + [ + Query::equal($linkKey, $chunk), + Query::select([Document::ID]), + Query::limit(PHP_INT_MAX), + ] + )))); + $matchingIds = \array_map(fn (Document $doc) => $doc->getId(), $parentDocs); + } + + if (empty($matchingIds)) { + return null; + } + } + + $allMatchingIds = \array_merge($allMatchingIds, $matchingIds); + } + + return \array_unique($allMatchingIds); + } + + /** + * @param array $relatedQueries + * @return array{attribute: string, ids: string[]}|null + */ + private function resolveRelationshipGroupToIds( + Attribute $attribute, + array $relatedQueries, + ?Document $collection = null, + ): ?array { + $relationship = $attribute->relationship; + $side = $attribute->side; + if ($relationship === null || $side === null) { + return null; + } + + $relatedCollection = $relationship->relatedCollection; + $relationType = $relationship->type; + $twoWayKey = $relationship->twoWayKey ?? ''; + $relationshipKey = $attribute->key; + + $hasNestedPaths = false; + foreach ($relatedQueries as $relatedQuery) { + if (\str_contains($relatedQuery->getAttribute(), '.')) { + $hasNestedPaths = true; + break; + } + } + + $pathIds = null; + + if ($hasNestedPaths) { + $pathIds = $this->processNestedRelationshipPath( + $relatedCollection, + $relatedQueries + ); + + if ($pathIds === null || empty($pathIds)) { + return null; + } + + $relatedQueries = \array_values(\array_filter($relatedQueries, fn (Query $q) => ! \str_contains($q->getAttribute(), '.'))); + } + + $needsParentResolution = ( + ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Child) || + ($relationType === RelationshipType::ManyToMany) + ); + + if ($relationType === RelationshipType::ManyToMany && $needsParentResolution && $collection !== null) { + $matchingDocs = $this->findRelated($relatedCollection, $relatedQueries, $pathIds, [ + Query::select([Document::ID]), + Query::limit(PHP_INT_MAX), + ]); + + $matchingIds = \array_map(fn (Document $doc) => $doc->getId(), $matchingDocs); + + if (empty($matchingIds)) { + return null; + } + + /** @var Document $relatedCollectionDoc */ + $relatedCollectionDoc = $this->database->silent(fn () => $this->database->getCollection($relatedCollection)); + $junction = $this->getJunctionCollection($collection, $relatedCollectionDoc, $side); + + $junctionDocs = $this->readByIds($matchingIds, fn (array $chunk): array => $this->database->silent(fn () => $this->database->skipRelationships(fn () => $this->database->find($junction, [ + Query::equal($relationshipKey, $chunk), + Query::limit(PHP_INT_MAX), + ])))); + + /** @var array $parentIds */ + $parentIds = []; + foreach ($junctionDocs as $jDoc) { + $pIdRaw = $jDoc->getAttribute($twoWayKey); + $pId = $pIdRaw instanceof Document ? $pIdRaw->getId() : (\is_string($pIdRaw) ? $pIdRaw : null); + if ($pId && ! \in_array($pId, $parentIds)) { + $parentIds[] = $pId; + } + } + + return empty($parentIds) ? null : ['attribute' => Document::ID, 'ids' => $parentIds]; + } elseif ($needsParentResolution) { + $matchingDocs = $this->findRelated($relatedCollection, $relatedQueries, $pathIds, [Query::limit(PHP_INT_MAX)]); + + /** @var array $parentIds */ + $parentIds = []; + + foreach ($matchingDocs as $doc) { + $parentId = $doc->getAttribute($twoWayKey); + + if (\is_array($parentId)) { + foreach ($parentId as $id) { + if ($id instanceof Document) { + $id = $id->getId(); + } + if (\is_string($id) && $id && ! \in_array($id, $parentIds)) { + $parentIds[] = $id; + } + } + } else { + if ($parentId instanceof Document) { + $parentId = $parentId->getId(); + } + if (\is_string($parentId) && $parentId && ! \in_array($parentId, $parentIds)) { + $parentIds[] = $parentId; + } + } + } + + return empty($parentIds) ? null : ['attribute' => Document::ID, 'ids' => $parentIds]; + } else { + $matchingDocs = $this->findRelated($relatedCollection, $relatedQueries, $pathIds, [ + Query::select([Document::ID]), + Query::limit(PHP_INT_MAX), + ]); + + /** @var array $matchingIds */ + $matchingIds = \array_map(fn (Document $doc) => $doc->getId(), $matchingDocs); + + return empty($matchingIds) ? null : ['attribute' => $relationshipKey, 'ids' => $matchingIds]; + } + } + + /** + * Read the related documents matching $relatedQueries, limited to $pathIds when a nested path resolved them, + * without populating their relationships. + * + * @param array $relatedQueries + * @param array|null $pathIds + * @param array $queries + * @return array + */ + private function findRelated(string $relatedCollection, array $relatedQueries, ?array $pathIds, array $queries): array + { + if ($pathIds === null) { + return $this->database->silent(fn () => $this->database->skipRelationships(fn () => $this->database->find($relatedCollection, \array_merge($relatedQueries, $queries)))); + } + + return $this->readByIds($pathIds, fn (array $chunk): array => $this->database->silent(fn () => $this->database->skipRelationships(fn () => $this->database->find( + $relatedCollection, + \array_merge($relatedQueries, [Query::equal(Document::ID, $chunk)], $queries) + )))); + } +} diff --git a/src/Database/Hook/RowMetadata.php b/src/Database/Hook/RowMetadata.php new file mode 100644 index 0000000000..39a6b59ded --- /dev/null +++ b/src/Database/Hook/RowMetadata.php @@ -0,0 +1,17 @@ +column] = $metadata->tenant; + + return $row; + } +} diff --git a/src/Database/Hook/Transform.php b/src/Database/Hook/Transform.php new file mode 100644 index 0000000000..1271b00aa5 --- /dev/null +++ b/src/Database/Hook/Transform.php @@ -0,0 +1,25 @@ + $row + * @return array + */ + public function decorateRow(array $row, RowMetadata $metadata): array; + + /** + * @param array $documents + */ + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void; + + /** + * @param string $id The id the document was stored under before the update + */ + public function afterDocumentUpdate(string $collection, string $id, Document $document, WriteContext $context): void; + + /** + * @param array $documents + */ + public function afterDocumentBatchUpdate(string $collection, Document $updates, array $documents, WriteContext $context): void; + + /** + * @param array $changes + */ + public function afterDocumentUpsert(string $collection, array $changes, WriteContext $context): void; + + /** + * @param list $documentIds + */ + public function afterDocumentDelete(string $collection, array $documentIds, WriteContext $context): void; +} diff --git a/src/Database/Hook/WriteContext.php b/src/Database/Hook/WriteContext.php new file mode 100644 index 0000000000..ca93a61f7a --- /dev/null +++ b/src/Database/Hook/WriteContext.php @@ -0,0 +1,59 @@ +> + */ + public function fetch(Statement $statement, Event $event): array; + + /** + * The row as every registered write hook decorates a row written for the document. + * + * @param array $row + * @return array + */ + public function decorateRow(array $row, Document $document): array; + + /** + * Whether the update keeps the document's permissions, so its permission rows need no change. + */ + public function skipPermissions(Document $document): bool; + + /** + * Whether the write skips the documents already stored, so a row it repeats is ignored rather than rejected. + */ + public function ignoreDuplicates(): bool; +} diff --git a/src/Database/Id.php b/src/Database/Id.php new file mode 100644 index 0000000000..ed63cecef8 --- /dev/null +++ b/src/Database/Id.php @@ -0,0 +1,47 @@ + 0) { + try { + $bytes = \random_bytes(\max(1, (int) \ceil(($padding / 2)))); // one byte expands to two chars + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + $uniqid .= \substr(\bin2hex($bytes), 0, $padding); + } + + return $uniqid; + } + + /** + * Create an ID from a custom string value. + * + * @param string $id The custom identifier string + * @return string The provided identifier + */ + public static function custom(string $id): string + { + return $id; + } +} diff --git a/src/Database/Index.php b/src/Database/Index.php new file mode 100644 index 0000000000..d2479210e8 --- /dev/null +++ b/src/Database/Index.php @@ -0,0 +1,367 @@ + $attributes + * @param list $lengths + * @param list $orders + */ + private function __construct( + public string $key, + public IndexType $type, + public array $attributes, + public array $lengths, + public array $orders, + public ?int $ttl, + ) { + } + + /** + * @param list $attributes + * @param list $lengths + * @param list $orders + * + * @throws IndexException + */ + public static function key(string $key, array $attributes, array $lengths = [], array $orders = []): self + { + return self::make($key, IndexType::Key, $attributes, $lengths, $orders, null); + } + + /** + * @param list $attributes + * @param list $lengths + * @param list $orders + * + * @throws IndexException + */ + public static function unique(string $key, array $attributes, array $lengths = [], array $orders = []): self + { + return self::make($key, IndexType::Unique, $attributes, $lengths, $orders, null); + } + + /** + * @param list $attributes + * + * @throws IndexException + */ + public static function fulltext(string $key, array $attributes): self + { + return self::make($key, IndexType::Fulltext, $attributes, [], [], null); + } + + /** + * @param list $attributes + * + * @throws IndexException + */ + public static function trigram(string $key, array $attributes): self + { + return self::make($key, IndexType::Trigram, $attributes, [], [], null); + } + + /** + * @throws IndexException + */ + public static function spatial(string $key, string $attribute, ?OrderDirection $order = null): self + { + return self::make($key, IndexType::Spatial, [$attribute], [], $order === null ? [] : [$order], null); + } + + /** + * @throws IndexException + */ + public static function object(string $key, string $attribute): self + { + return self::make($key, IndexType::Object, [$attribute], [], [], null); + } + + /** + * @throws IndexException + */ + public static function hnswEuclidean(string $key, string $attribute): self + { + return self::make($key, IndexType::HnswEuclidean, [$attribute], [], [], null); + } + + /** + * @throws IndexException + */ + public static function hnswCosine(string $key, string $attribute): self + { + return self::make($key, IndexType::HnswCosine, [$attribute], [], [], null); + } + + /** + * @throws IndexException + */ + public static function hnswDot(string $key, string $attribute): self + { + return self::make($key, IndexType::HnswDot, [$attribute], [], [], null); + } + + /** + * @throws IndexException + */ + public static function ttl(string $key, string $attribute, int $ttl): self + { + return self::make($key, IndexType::Ttl, [$attribute], [], [], $ttl); + } + + /** + * @throws IndexException + */ + public static function fromDocument(Document $document): self + { + return self::hydrate( + $document->getAttribute(self::KEY, $document->getId()), + $document->getAttribute(self::TYPE, IndexType::Key->value), + $document->getAttribute(self::ATTRIBUTES, []), + $document->getAttribute(self::LENGTHS, []), + $document->getAttribute(self::ORDERS, []), + $document->getAttribute(self::TTL), + ); + } + + /** + * @param array $data + * + * @throws IndexException + */ + public static function fromArray(array $data): self + { + return self::hydrate( + $data[self::KEY] ?? $data[Document::ID] ?? '', + $data[self::TYPE] ?? IndexType::Key->value, + $data[self::ATTRIBUTES] ?? [], + $data[self::LENGTHS] ?? [], + $data[self::ORDERS] ?? [], + $data[self::TTL] ?? null, + ); + } + + public function toDocument(): Document + { + $data = [ + Document::ID => $this->key, + self::KEY => $this->key, + self::TYPE => $this->type->value, + self::ATTRIBUTES => $this->attributes, + ]; + + if (self::storesLengths($this->type)) { + $data[self::LENGTHS] = $this->lengths; + } + + if (self::storesOrders($this->type)) { + $data[self::ORDERS] = \array_map( + static fn (?OrderDirection $order): ?string => $order?->value, + $this->orders, + ); + } + + if ($this->type === IndexType::Ttl) { + $data[self::TTL] = $this->ttl; + } + + return new Document($data); + } + + public function withKey(string $key): self + { + return clone($this, ['key' => $key]); + } + + /** + * @param list $lengths + * + * @throws IndexException + */ + public function withLengths(array $lengths): self + { + if ($lengths !== [] && ! self::storesLengths($this->type)) { + throw new IndexException('A '.$this->type->value.' index does not take lengths'); + } + + return clone($this, ['lengths' => self::lengths($lengths)]); + } + + /** + * @param list $orders + * + * @throws IndexException + */ + public function withOrders(array $orders): self + { + if ($orders !== [] && ! self::storesOrders($this->type)) { + throw new IndexException('A '.$this->type->value.' index does not take orders'); + } + + return clone($this, ['orders' => self::orders($orders)]); + } + + private static function storesLengths(IndexType $type): bool + { + return $type !== IndexType::Fulltext; + } + + private static function storesOrders(IndexType $type): bool + { + return $type !== IndexType::Fulltext && $type !== IndexType::Ttl; + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + * + * @throws IndexException + */ + private static function make(string $key, IndexType $type, array $attributes, array $lengths, array $orders, mixed $ttl): self + { + if ($type === IndexType::Index) { + $type = IndexType::Key; + } + + if ($type === IndexType::Ttl && (! \is_int($ttl) || $ttl < 1)) { + throw new IndexException('TTL must be at least 1 second'); + } + + return new self( + key: $key, + type: $type, + attributes: self::attributes($attributes), + lengths: self::storesLengths($type) ? self::lengths($lengths) : [], + orders: self::storesOrders($type) ? self::orders($orders) : [], + ttl: $type === IndexType::Ttl && \is_int($ttl) ? $ttl : null, + ); + } + + /** + * @throws IndexException + */ + private static function hydrate(mixed $key, mixed $type, mixed $attributes, mixed $lengths, mixed $orders, mixed $ttl): self + { + if (! \is_string($key)) { + throw new IndexException('Index key must be a string'); + } + + $type = $type instanceof IndexType ? $type : IndexType::tryFrom(\is_string($type) ? $type : '') ?? IndexType::Key; + + return self::make( + $key, + $type, + \is_array($attributes) ? $attributes : [], + \is_array($lengths) ? \array_map(self::storedLength(...), $lengths) : [], + \is_array($orders) ? \array_map(self::storedOrder(...), $orders) : [], + $type === IndexType::Ttl ? self::storedLength($ttl) : null, + ); + } + + private static function storedLength(mixed $length): mixed + { + return \is_string($length) && \ctype_digit($length) ? (int) $length : $length; + } + + /** + * @throws IndexException + */ + private static function storedOrder(mixed $order): mixed + { + if ($order instanceof OrderDirection || $order === null) { + return $order; + } + + if ($order instanceof \BackedEnum) { + $order = $order->value; + } + + if ($order === '') { + return null; + } + + if (! \is_string($order)) { + return $order; + } + + return OrderDirection::tryFrom(\strtoupper($order)) + ?? throw new IndexException('Unknown index order "'.$order.'"'); + } + + /** + * @param array $attributes + * @return list + * + * @throws IndexException + */ + private static function attributes(array $attributes): array + { + $list = []; + foreach ($attributes as $attribute) { + if (! \is_string($attribute)) { + throw new IndexException('Index attributes must be strings'); + } + $list[] = $attribute; + } + + return $list; + } + + /** + * @param array $lengths + * @return list + * + * @throws IndexException + */ + private static function lengths(array $lengths): array + { + $list = []; + foreach ($lengths as $length) { + if ($length !== null && ! \is_int($length)) { + throw new IndexException('Index lengths must be integers or null'); + } + $list[] = $length; + } + + return $list; + } + + /** + * @param array $orders + * @return list + * + * @throws IndexException + */ + private static function orders(array $orders): array + { + $list = []; + foreach ($orders as $order) { + $list[] = match (true) { + $order === OrderDirection::Random => throw new IndexException('Index orders cannot be random'), + $order === null, $order instanceof OrderDirection => $order, + default => throw new IndexException('Index orders must be OrderDirection cases or null'), + }; + } + + return $list; + } +} diff --git a/src/Database/IntegerWidth.php b/src/Database/IntegerWidth.php new file mode 100644 index 0000000000..8f686d3260 --- /dev/null +++ b/src/Database/IntegerWidth.php @@ -0,0 +1,24 @@ += self::BITS64_SIZE ? self::Bits64 : self::Bits32; + } + + public function size(): ?int + { + return match ($this) { + self::Bits32 => null, + self::Bits64 => self::BITS64_SIZE, + }; + } +} diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index a0151cb92f..b34e0d1f9a 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -2,42 +2,75 @@ namespace Utopia\Database; +use Closure; +use DateTime; +use Swoole\Coroutine; +use Swoole\Coroutine\Channel; +use Throwable; +use Utopia\Async\Promise; +use Utopia\Cache\Cache; +use Utopia\Database\Cache\Invalidator; +use Utopia\Database\Cache\Query as ResultCache; +use Utopia\Database\Event\Domain; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Mirroring\Filter; +use Utopia\Database\Filter\Registry; +use Utopia\Database\Hook\Lifecycle; +use Utopia\Database\Hook\Relationships; +use Utopia\Database\Hook\Write; +use Utopia\Database\Mirror\Failure; +use Utopia\Database\Mirror\Filter; use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\Queries\Base; +use Utopia\Query\OrderDirection; +/** + * Wraps a source Database and replicates write operations to an optional destination Database. + */ class Mirror extends Database { protected Database $source; - protected ?Database $destination; + + protected ?Database $destination = null; /** - * Filters to apply to documents before writing to the destination database - * * @var array */ protected array $writeFilters = []; /** - * Callbacks to run when an error occurs on the destination database - * - * @var array + * @var array */ protected array $errorCallbacks = []; - /** - * Collections that should only be present in the source database - */ - protected const SOURCE_ONLY_COLLECTIONS = [ + protected const array SOURCE_ONLY_COLLECTIONS = [ 'upgrades', ]; /** - * @param Database $source - * @param ?Database $destination - * @param array $filters + * Closed once the latest destination change queued through the mirror has been applied or has failed + * + * @var Channel|null + */ + private ?object $latestReplication = null; + + /** + * Coroutines applying a destination change, by coroutine id + * + * @var array + */ + private array $applying = []; + + /** + * Set once the constructor has stored the source and destination. Database::__construct() calls + * setAuthorization() before then, and that call must leave their authorization in place. + */ + private bool $wrapped = false; + + /** + * The mirror uses the source's authorization; the source and the destination keep their own. + * + * @param array $filters */ public function __construct( Database $source, @@ -51,6 +84,8 @@ public function __construct( $this->source = $source; $this->destination = $destination; $this->writeFilters = $filters; + $this->wrapped = true; + parent::setAuthorization($source->getAuthorization()); } public function getSource(): Database @@ -58,78 +93,370 @@ public function getSource(): Database return $this->source; } + /** + * Delegate validator caching to the source database so Mirror reuses the + * source's cache and inherits its invalidation. Mirror's own mutator + * overrides bypass the inherited traits, so its local + * `documentsValidatorCache` would otherwise go stale on schema changes. + * + * @param array $joinedCollections + */ + #[\Override] + protected function getDocumentsValidator(Document $collection, array $joinedCollections = []): Validator\Queries\Documents + { + return $this->source->getDocumentsValidator($collection, $joinedCollections); + } + + /** + * Delegate to the source database, so a narrow list is checked under the source's adapter and + * query limits like every other list. + * + * @param array $queries + * @param array $joinedCollections + */ + #[\Override] + protected function getQueriesValidator(Document $collection, array $queries, array $joinedCollections = []): Base + { + return $this->source->getQueriesValidator($collection, $queries, $joinedCollections); + } + + /** + * Delegate metadata reads to the source database. Mirror's schema mutator + * overrides forward writes to source and destination directly, so routing + * reads through the source keeps its view of attributes and relationships + * in lockstep with the authoritative database. + */ + #[\Override] + public function getCollection(string $collection): Collection + { + return $this->source->getCollection($collection); + } + + #[\Override] + public function findCollection(string $collection): ?Collection + { + return $this->source->findCollection($collection); + } + public function getDestination(): ?Database { return $this->destination; } /** - * @return array + * @param callable(Failure $failure): void $callback */ - public function getWriteFilters(): array + public function onError(callable $callback): static { - return $this->writeFilters; + $this->errorCallbacks[] = $callback; + + return $this; } /** - * @param callable(string, \Throwable): void $callback - * @return void + * Waits until every replication queued through the mirror so far has reached the destination or has been + * reported to onError(), or until the timeout has passed. Outside a coroutine, and inside a replication, there is + * nothing to wait for. + * + * @param int|null $timeout Milliseconds to wait at most: null waits for as long as the replications take, and 0 + * does not wait + * + * @throws Exception When the timeout is negative */ - public function onError(callable $callback): void + public function awaitReplications(?int $timeout = null): void { - $this->errorCallbacks[] = $callback; + if ($timeout !== null && $timeout < 0) { + throw new Exception("A replication timeout cannot be negative, {$timeout} given"); + } + + if ($timeout === 0 || $this->appliesInline()) { + return; + } + + $this->latestReplication?->pop($timeout === null ? -1 : $timeout / 1000); } /** - * @param string $method - * @param array $args - * @return mixed + * @param array $args */ protected function delegate(string $method, array $args = []): mixed { - $result = $this->source->{$method}(...$args); - if ($this->destination === null) { - return $result; + return $this->source->{$method}(...$args); } + $sourceResult = $this->source->{$method}(...$args); + try { - $result = $this->destination->{$method}(...$args); - } catch (\Throwable $err) { - $this->logError($method, $err); + $this->destination->{$method}(...$args); + } catch (Throwable $error) { + $this->logError($method, $error); } - return $result; + return $sourceResult; } + /** + * Calls the method on the source, then on the destination in order with the replications (see inOrder()); a + * destination failure is reported to onError(). + * + * @param array $args + */ + private function delegateInOrder(string $method, array $args = []): mixed + { + $sourceResult = $this->source->{$method}(...$args); + + $destination = $this->destination; + if ($destination === null) { + return $sourceResult; + } + + try { + $this->inOrder(fn (): mixed => $destination->{$method}(...$args)); + } catch (Throwable $error) { + $this->logError($method, $error); + } + + return $sourceResult; + } + + #[\Override] public function setDatabase(string $name): static { - $this->delegate(__FUNCTION__, \func_get_args()); + parent::setDatabase($name); + $this->source->setDatabase($name); + $this->destination?->setDatabase($name); return $this; } + #[\Override] public function setNamespace(string $namespace): static { - $this->delegate(__FUNCTION__, \func_get_args()); + parent::setNamespace($namespace); + $this->source->setNamespace($namespace); + $this->destination?->setNamespace($namespace); return $this; } + #[\Override] public function setSharedTables(bool $sharedTables): static { - $this->delegate(__FUNCTION__, \func_get_args()); + parent::setSharedTables($sharedTables); + $this->source->setSharedTables($sharedTables); + $this->destination?->setSharedTables($sharedTables); return $this; } + #[\Override] public function setTenant(int|string|null $tenant): static { + parent::setTenant($tenant); + $this->source->setTenant($tenant); + $this->destination?->setTenant($tenant); + + return $this; + } + + #[\Override] + public function setMaxQueryValues(int $max): static + { + parent::setMaxQueryValues($max); + $this->source->setMaxQueryValues($max); + $this->destination?->setMaxQueryValues($max); + + return $this; + } + + #[\Override] + public function setCache(Cache $cache): static + { + parent::setCache($cache); + $this->source->setCache($cache); + $this->destination?->setCache($cache); + + return $this; + } + + /** + * The query cache is attached to the mirror last, so it takes its name and writer timeout from the mirror. + */ + #[\Override] + public function setQueryCache(?ResultCache $queryCache): static + { + $this->source->setQueryCache($queryCache); + $this->destination?->setQueryCache($queryCache); + + return parent::setQueryCache($queryCache); + } + + #[\Override] + public function setCacheName(string $name): static + { + parent::setCacheName($name); + $this->source->setCacheName($name); + $this->destination?->setCacheName($name); + + return $this; + } + + #[\Override] + public function setCacheWriterTimeout(int $seconds): static + { + parent::setCacheWriterTimeout($seconds); + $this->source->setCacheWriterTimeout($seconds); + $this->destination?->setCacheWriterTimeout($seconds); + + return $this; + } + + #[\Override] + public function setTenantPerDocument(bool $enabled): static + { + parent::setTenantPerDocument($enabled); + $this->source->setTenantPerDocument($enabled); + $this->destination?->setTenantPerDocument($enabled); + + return $this; + } + + /** + * A destination that cannot apply the timeout is reported through onError(), like a + * failed destination write: MariaDB applies it on the destination's connection. + */ + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): static + { + $this->delegateInOrder(__FUNCTION__, \func_get_args()); + + return $this; + } + + #[\Override] + public function clearTimeout(Event $event = Event::All): void + { + $this->delegateInOrder(__FUNCTION__, \func_get_args()); + } + + #[\Override] + public function setGlobalCollections(array $collections): static + { + parent::setGlobalCollections($collections); + $this->source->setGlobalCollections($collections); + $this->destination?->setGlobalCollections($collections); + + return $this; + } + + #[\Override] + public function resetGlobalCollections(): void + { + parent::resetGlobalCollections(); + $this->source->resetGlobalCollections(); + $this->destination?->resetGlobalCollections(); + } + + #[\Override] + public function setMetadata(string $key, mixed $value): static + { + parent::setMetadata($key, $value); + $this->source->setMetadata($key, $value); + $this->destination?->setMetadata($key, $value); + + return $this; + } + + #[\Override] + public function resetMetadata(): void + { + parent::resetMetadata(); + $this->source->resetMetadata(); + $this->destination?->resetMetadata(); + } + + /** + * The source shares this database's adapter; the destination keeps its own mode. + */ + #[\Override] + public function setSchemaless(bool $schemaless): static + { + parent::setSchemaless($schemaless); + $this->source->setSchemaless($schemaless); + + return $this; + } + + #[\Override] + public function setMigrating(bool $migrating): static + { + parent::setMigrating($migrating); + $this->source->setMigrating($migrating); + $this->destination?->setMigrating($migrating); + + return $this; + } + + #[\Override] + public function setFilters(Registry $filters): static + { + parent::setFilters($filters); + $this->source->setFilters($filters); + $this->destination?->setFilters($filters); + + return $this; + } + + /** + * A destination that cannot apply the setting is reported through onError(), like setTimeout(). + */ + #[\Override] + public function setLocks(bool $locks): static + { + parent::setLocks($locks); $this->delegate(__FUNCTION__, \func_get_args()); return $this; } + #[\Override] + public function setFiltering(bool $filtering): static + { + parent::setFiltering($filtering); + $this->source->setFiltering($filtering); + $this->destination?->setFiltering($filtering); + + return $this; + } + + #[\Override] + public function withFiltering(bool $filtering, callable $callback, ?array $filters = null): mixed + { + $scoped = fn (): mixed => parent::withFiltering($filtering, $callback, $filters); + $destination = $this->destination; + + return $this->source->withFiltering( + $filtering, + fn (): mixed => $destination === null ? $scoped() : $destination->withFiltering($filtering, $scoped, $filters), + $filters, + ); + } + + /** + * The mirror queries through its source's adapter, so it reports to the source's profiler. + */ + #[\Override] + public function setProfiling(bool $profiling): static + { + $this->source->setProfiling($profiling); + $this->destination?->setProfiling($profiling); + $this->profiler = $this->source->getProfiler(); + + return $this; + } + + #[\Override] public function setDropUnknownAttributes(bool $drop): static { $this->delegate(__FUNCTION__, \func_get_args()); @@ -139,486 +466,693 @@ public function setDropUnknownAttributes(bool $drop): static return $this; } + #[\Override] public function setPreserveDates(bool $preserve): static { $this->delegate(__FUNCTION__, \func_get_args()); - $this->preserveDates = $preserve; - - return $this; + return parent::setPreserveDates($preserve); } + #[\Override] public function setPreserveSequence(bool $preserve): static { $this->delegate(__FUNCTION__, \func_get_args()); - $this->preserveSequence = $preserve; + return parent::setPreserveSequence($preserve); + } - return $this; + #[\Override] + public function setValidation(bool $validation): static + { + $this->delegate(__FUNCTION__, \func_get_args()); + + return parent::setValidation($validation); } - public function enableValidation(): static + #[\Override] + public function withValidation(bool $validation, callable $callback): mixed { - $this->delegate(__FUNCTION__); + $destination = $this->destination; + $scoped = $destination === null ? $callback : fn (): mixed => $destination->withValidation($validation, $callback); - $this->validate = true; + return parent::withValidation($validation, fn (): mixed => $this->source->withValidation($validation, $scoped)); + } - return $this; + /** + * Opens the scope on the mirror, its source and its destination, so the writes the mirror replicates before + * returning use the tenant too; the replications it queues carry it (see replicate()). + */ + #[\Override] + public function withTenant(int|string|null $tenant, callable $callback): mixed + { + $destination = $this->destination; + $scoped = $destination === null ? $callback : fn (): mixed => $destination->withTenant($tenant, $callback); + + return parent::withTenant($tenant, fn (): mixed => $this->source->withTenant($tenant, $scoped)); } - public function disableValidation(): static + #[\Override] + public function withPreserveDates(bool $preserve, callable $callback): mixed { - $this->delegate(__FUNCTION__); + $destination = $this->destination; + $scoped = $destination === null ? $callback : fn (): mixed => $destination->withPreserveDates($preserve, $callback); - $this->validate = false; + return parent::withPreserveDates($preserve, fn (): mixed => $this->source->withPreserveDates($preserve, $scoped)); + } - return $this; + #[\Override] + public function withPreserveSequence(bool $preserve, callable $callback): mixed + { + $destination = $this->destination; + $scoped = $destination === null ? $callback : fn (): mixed => $destination->withPreserveSequence($preserve, $callback); + + return parent::withPreserveSequence($preserve, fn (): mixed => $this->source->withPreserveSequence($preserve, $scoped)); } - public function on(string $event, string $name, ?callable $callback): static + #[\Override] + public function skipRelationships(callable $callback): mixed { - $this->source->on($event, $name, $callback); + return parent::skipRelationships(fn (): mixed => $this->source->skipRelationships($callback)); + } + + #[\Override] + public function skipRelationshipsExistCheck(callable $callback): mixed + { + return parent::skipRelationshipsExistCheck(fn (): mixed => $this->source->skipRelationshipsExistCheck($callback)); + } + + /** + * Lifecycle hooks are registered on the source, where the mirror's writes run; the mirror keeps its own + * query-cache invalidator as well. + */ + protected function addLifecycleHook(Lifecycle $hook): static + { + $this->source->addHook($hook); return $this; } - protected function trigger(string $event, mixed $args = null): void + /** + * Lifecycle hooks are registered on the source (see addLifecycleHook()). + */ + #[\Override] + protected function listens(Event $event): array + { + return $this->source->listens($event); + } + + /** + * Lifecycle hooks are registered on the source (see addLifecycleHook()). + */ + #[\Override] + protected function dispatch(Domain $event, array $listeners): void { - $this->source->trigger($event, $args); + $this->source->dispatch($event, $listeners); } - public function silent(callable $callback, ?array $listeners = null): mixed + /** + * Also invalidates a query cache the source holds that is not the mirror's own. + */ + #[\Override] + protected function invalidate(Event $event, mixed $data = null): void { - return $this->source->silent($callback, $listeners); + parent::invalidate($event, $data); + + if ($this->source->getQueryCache() !== $this->queryCache) { + $this->source->invalidate($event, $data); + } } - public function withRequestTimestamp(?\DateTime $requestTimestamp, callable $callback): mixed + /** + * Lifecycle hooks are registered on the source (see addLifecycleHook()). + */ + #[\Override] + protected function dispatchPropagating(Domain $event, array $listeners): void { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->source->dispatchPropagating($event, $listeners); } - public function exists(?string $database = null, ?string $collection = null): bool + #[\Override] + public function silent(callable $callback, ?array $hooks = null): mixed { - return $this->delegate(__FUNCTION__, \func_get_args()); + return parent::silent(fn () => $this->source->silent($callback, $hooks), $hooks); + } + + /** + * Scoped to the mirror and its source only: the source checks the timestamp, and the destination applies + * what the source accepted. + */ + #[\Override] + public function withRequestTimestamp(?DateTime $requestTimestamp, callable $callback): mixed + { + return parent::withRequestTimestamp( + $requestTimestamp, + fn (): mixed => $this->source->withRequestTimestamp($requestTimestamp, $callback), + ); + } + + /** + * Keep the source database's cache invalidation scope open until its outer + * adapter transaction commits. + */ + #[\Override] + public function withTransaction(callable $callback): mixed + { + return $this->source->withTransaction($callback); + } + + #[\Override] + public function exists(?string $database = null): bool + { + /** @var bool $result */ + $result = $this->delegateInOrder(__FUNCTION__, \func_get_args()); + + return $result; + } + + #[\Override] + public function collectionExists(string $collection, ?string $database = null): bool + { + /** @var bool $result */ + $result = $this->delegateInOrder(__FUNCTION__, \func_get_args()); + + return $result; + } + + #[\Override] + public function update(string $database, string $new): bool + { + /** @var bool $result */ + $result = $this->delegateInOrder(__FUNCTION__, [$database, $new]); + + return $result; } + #[\Override] public function create(?string $database = null): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + $result = $this->source->create($database); + + $destination = $this->destination; + if ($destination !== null) { + $this->inOrder(fn (): bool => $destination->create($database)); + } + + return $result; } + #[\Override] public function delete(?string $database = null): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegateInOrder(__FUNCTION__, \func_get_args()); + return $result; } - public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document + #[\Override] + public function listCollections(int $limit = 25, int $offset = 0): array { - $result = $this->source->createCollection( - $id, - $attributes, - $indexes, - $permissions, - $documentSecurity - ); + $result = $this->silent(fn () => $this->source->find(self::METADATA, [ + Query::notEqual(Document::ID, self::SOURCE_ONLY_COLLECTIONS), + Query::limit($limit), + Query::offset($offset), + ])); + + $collections = []; + foreach ($result as $doc) { + $collections[] = Collection::fromDocument($doc); + } - if ($this->destination === null) { + $listeners = $this->listens(Event::CollectionList); + if ($listeners !== []) { + $this->dispatch(new Event\Collection\Listed($collections), $listeners); + } + + return $collections; + } + + #[\Override] + public function createCollection(Collection $collection): Collection + { + $collectionId = $collection->getId(); + + $result = $this->source->createCollection($collection); + + $destination = $this->destination; + if ($destination === null) { return $result; } try { - foreach ($this->writeFilters as $filter) { - $result = $filter->beforeCreateCollection( - source: $this->source, - destination: $this->destination, - collectionId: $id, - collection: $result, - ); - } + $replicated = $this->inOrder(function () use ($destination, $collection, $collectionId, $result): ?Document { + $filtered = $result; + foreach ($this->writeFilters as $filter) { + $filtered = $filter->beforeCreateCollection( + source: $this->source, + destination: $destination, + collectionId: $collectionId, + collection: $filtered, + ); + if ($filtered === null) { + return null; + } + } - $this->destination->createCollection( - $id, - $attributes, - $indexes, - $permissions, - $documentSecurity - ); + $destination->createCollection($collection); + + return $filtered; + }); + if ($replicated === null) { + return $result; + } + $result = $replicated; - $this->silent(function () use ($id) { + $this->silent(function () use ($collectionId) { $this->createUpgrades(); $this->source->createDocument('upgrades', new Document([ - '$id' => $id, - 'collectionId' => $id, - 'status' => 'upgraded' + Document::ID => $collectionId, + 'collectionId' => $collectionId, + 'status' => 'upgraded', ])); }); - } catch (\Throwable $err) { - $this->logError('createCollection', $err); + } catch (Throwable $error) { + $this->logError('createCollection', $error); } - return $result; + + return Collection::fromDocument($result); } - public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document + #[\Override] + public function updateCollection(string $collection, CollectionUpdate $update): Collection { - $result = $this->source->updateCollection($id, $permissions, $documentSecurity); + $result = $this->source->updateCollection($collection, $update); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } try { - foreach ($this->writeFilters as $filter) { - $result = $filter->beforeUpdateCollection( - source: $this->source, - destination: $this->destination, - collectionId: $id, - collection: $result, - ); - } + $this->inOrder(function () use ($destination, $collection, $update, $result): void { + $filtered = $result; + foreach ($this->writeFilters as $filter) { + $filtered = $filter->beforeUpdateCollection( + source: $this->source, + destination: $destination, + collectionId: $collection, + collection: $filtered, + ); + if ($filtered === null) { + return; + } + } - $this->destination->updateCollection($id, $permissions, $documentSecurity); - } catch (\Throwable $err) { - $this->logError('updateCollection', $err); + $destination->updateCollection($collection, $update); + }); + } catch (Throwable $error) { + $this->logError('updateCollection', $error); } return $result; } - public function deleteCollection(string $id): bool + #[\Override] + public function deleteCollection(string $collection): void { - $result = $this->source->deleteCollection($id); + $this->source->deleteCollection($collection); - if ($this->destination === null) { - return $result; + $destination = $this->destination; + if ($destination === null) { + return; } try { - $this->destination->deleteCollection($id); + $this->inOrder(function () use ($destination, $collection): void { + $destination->deleteCollection($collection); - foreach ($this->writeFilters as $filter) { - $filter->beforeDeleteCollection( - source: $this->source, - destination: $this->destination, - collectionId: $id, - ); - } - } catch (\Throwable $err) { - $this->logError('deleteCollection', $err); + foreach ($this->writeFilters as $filter) { + $filter->beforeDeleteCollection( + source: $this->source, + destination: $destination, + collectionId: $collection, + ); + } + }); + } catch (Throwable $error) { + $this->logError('deleteCollection', $error); + } + } + + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): Attribute + { + $result = $this->source->createAttribute($collection, $attribute); + + $destination = $this->destination; + if ($destination === null) { + return $result; + } + + try { + $this->inOrder(function () use ($destination, $collection, $result): void { + $filtered = $this->filterCreatedAttribute($destination, $collection, $result); + if ($filtered !== null) { + $destination->createAttribute($collection, $filtered); + } + }); + } catch (Throwable $error) { + $this->logError('createAttribute', $error); } return $result; } - public function createAttribute(string $collection, string $id, string $type, int $size, bool $required, $default = null, bool $signed = true, bool $array = false, ?string $format = null, array $formatOptions = [], array $filters = []): bool + /** + * @param list $attributes + * @return list + */ + #[\Override] + public function createAttributes(string $collection, array $attributes): array { - $result = $this->source->createAttribute( - $collection, - $id, - $type, - $size, - $required, - $default, - $signed, - $array, - $format, - $formatOptions, - $filters - ); + $result = $this->source->createAttributes($collection, $attributes); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } try { - $document = new Document([ - '$id' => $id, - 'type' => $type, - 'size' => $size, - 'required' => $required, - 'default' => $default, - 'signed' => $signed, - 'array' => $array, - 'format' => $format, - 'formatOptions' => $formatOptions, - 'filters' => $filters, - ]); - - foreach ($this->writeFilters as $filter) { - $document = $filter->beforeCreateAttribute( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - attributeId: $id, - attribute: $document, - ); - } + $this->inOrder(function () use ($destination, $collection, $result): void { + $filtered = []; + foreach ($result as $attribute) { + $attribute = $this->filterCreatedAttribute($destination, $collection, $attribute); + if ($attribute !== null) { + $filtered[] = $attribute; + } + } - $result = $this->destination->createAttribute( - $collection, - $document->getId(), - $document->getAttribute('type'), - $document->getAttribute('size'), - $document->getAttribute('required'), - $document->getAttribute('default'), - $document->getAttribute('signed'), - $document->getAttribute('array'), - $document->getAttribute('format'), - $document->getAttribute('formatOptions'), - $document->getAttribute('filters'), - ); - } catch (\Throwable $err) { - $this->logError('createAttribute', $err); + if ($filtered !== []) { + $destination->createAttributes($collection, $filtered); + } + }); + } catch (Throwable $error) { + $this->logError('createAttributes', $error); } return $result; } - public function createAttributes(string $collection, array $attributes): bool + #[\Override] + public function updateAttribute(string $collection, string $key, AttributeUpdate $update): Attribute { - $result = $this->source->createAttributes($collection, $attributes); + $result = $this->source->updateAttribute($collection, $key, $update); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } try { - foreach ($attributes as &$attribute) { + $this->inOrder(function () use ($destination, $collection, $key, $update, $result): void { + $filtered = $result->toDocument(); foreach ($this->writeFilters as $filter) { - $document = $filter->beforeCreateAttribute( + $filtered = $filter->beforeUpdateAttribute( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, - attributeId: $attribute['$id'], - attribute: new Document($attribute), + attributeId: $key, + attribute: $filtered, ); - - $attribute = $document->getArrayCopy(); + if ($filtered === null) { + return; + } } - } - $result = $this->destination->createAttributes( - $collection, - $attributes, - ); - } catch (\Throwable $err) { - $this->logError('createAttributes', $err); + $destination->updateAttribute( + $collection, + $key, + self::filteredUpdate($update, $result, Attribute::fromDocument($filtered)), + ); + }); + } catch (Throwable $error) { + $this->logError('updateAttribute', $error); } return $result; } - public function updateAttribute(string $collection, string $id, ?string $type = null, ?int $size = null, ?bool $required = null, mixed $default = null, ?bool $signed = null, ?bool $array = null, ?string $format = null, ?array $formatOptions = null, ?array $filters = null, ?string $newKey = null): Document + #[\Override] + public function deleteAttribute(string $collection, string $key): void { - $document = $this->source->updateAttribute( - $collection, - $id, - $type, - $size, - $required, - $default, - $signed, - $array, - $format, - $formatOptions, - $filters, - $newKey, - ); + $this->source->deleteAttribute($collection, $key); - if ($this->destination === null) { - return $document; + $destination = $this->destination; + if ($destination === null) { + return; } try { - foreach ($this->writeFilters as $filter) { - $document = $filter->beforeUpdateAttribute( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - attributeId: $id, - attribute: $document, - ); - } + $this->inOrder(function () use ($destination, $collection, $key): void { + foreach ($this->writeFilters as $filter) { + $filter->beforeDeleteAttribute( + source: $this->source, + destination: $destination, + collectionId: $collection, + attributeId: $key, + ); + } - $this->destination->updateAttribute( - $collection, - $id, - $document->getAttribute('type'), - $document->getAttribute('size'), - $document->getAttribute('required'), - $document->getAttribute('default'), - $document->getAttribute('signed'), - $document->getAttribute('array'), - $document->getAttribute('format'), - $document->getAttribute('formatOptions'), - $document->getAttribute('filters'), - $newKey, - ); - } catch (\Throwable $err) { - $this->logError('updateAttribute', $err); + $destination->deleteAttribute($collection, $key); + }); + } catch (Throwable $error) { + $this->logError('deleteAttribute', $error); } - - return $document; } - public function deleteAttribute(string $collection, string $id): bool + #[\Override] + public function createIndex(string $collection, Index $index): Index { - $result = $this->source->deleteAttribute($collection, $id); + $result = $this->source->createIndex($collection, $index); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } try { - foreach ($this->writeFilters as $filter) { - $filter->beforeDeleteAttribute( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - attributeId: $id, - ); - } + $this->inOrder(function () use ($destination, $collection, $result): void { + $document = $result->toDocument(); - $this->destination->deleteAttribute($collection, $id); - } catch (\Throwable $err) { - $this->logError('deleteAttribute', $err); + foreach ($this->writeFilters as $filter) { + $document = $filter->beforeCreateIndex( + source: $this->source, + destination: $destination, + collectionId: $collection, + indexId: $result->key, + index: $document, + ); + if ($document === null) { + return; + } + } + + $destination->createIndex($collection, Index::fromDocument($document)); + }); + } catch (Throwable $error) { + $this->logError('createIndex', $error); } return $result; } - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 1): bool + /** + * @param list $indexes + * @return list + */ + #[\Override] + public function createIndexes(string $collection, array $indexes): array { - $result = $this->source->createIndex($collection, $id, $type, $attributes, $lengths, $orders, $ttl); + $result = $this->source->createIndexes($collection, $indexes); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } try { - $document = new Document([ - '$id' => $id, - 'type' => $type, - 'attributes' => $attributes, - 'lengths' => $lengths, - 'orders' => $orders, - ]); - - foreach ($this->writeFilters as $filter) { - $document = $filter->beforeCreateIndex( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - indexId: $id, - index: $document, - ); - } + $this->inOrder(function () use ($destination, $collection, $result): void { + $filtered = []; + foreach ($result as $index) { + $document = $index->toDocument(); + + foreach ($this->writeFilters as $filter) { + $document = $filter->beforeCreateIndex( + source: $this->source, + destination: $destination, + collectionId: $collection, + indexId: $index->key, + index: $document, + ); + if ($document === null) { + continue 2; + } + } + + $filtered[] = Index::fromDocument($document); + } - $result = $this->destination->createIndex( - $collection, - $document->getId(), - $document->getAttribute('type'), - $document->getAttribute('attributes'), - $document->getAttribute('lengths'), - $document->getAttribute('orders'), - $document->getAttribute('ttl', 0) - ); - } catch (\Throwable $err) { - $this->logError('createIndex', $err); + if ($filtered !== []) { + $destination->createIndexes($collection, $filtered); + } + }); + } catch (Throwable $error) { + $this->logError('createIndexes', $error); } return $result; } - public function deleteIndex(string $collection, string $id): bool + #[\Override] + public function deleteIndex(string $collection, string $key): void { - $result = $this->source->deleteIndex($collection, $id); + $this->source->deleteIndex($collection, $key); - if ($this->destination === null) { - return $result; + $destination = $this->destination; + if ($destination === null) { + return; } try { - $this->destination->deleteIndex($collection, $id); + $this->inOrder(function () use ($destination, $collection, $key): void { + $destination->deleteIndex($collection, $key); - foreach ($this->writeFilters as $filter) { - $filter->beforeDeleteIndex( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - indexId: $id, - ); + foreach ($this->writeFilters as $filter) { + $filter->beforeDeleteIndex( + source: $this->source, + destination: $destination, + collectionId: $collection, + indexId: $key, + ); + } + }); + } catch (Throwable $error) { + $this->logError('deleteIndex', $error); + } + } + + /** + * The attribute the write filters let through to the destination, or null when one of them drops it. + */ + private function filterCreatedAttribute(Database $destination, string $collection, Attribute $attribute): ?Attribute + { + $document = $attribute->toDocument(); + + foreach ($this->writeFilters as $filter) { + $document = $filter->beforeCreateAttribute( + source: $this->source, + destination: $destination, + collectionId: $collection, + attributeId: $attribute->key, + attribute: $document, + ); + if ($document === null) { + return null; } - } catch (\Throwable $err) { - $this->logError('deleteIndex', $err); } - return $result; + return Attribute::fromDocument($document); + } + + /** + * The update the destination applies: every field the source update set or a write filter changed, at the + * filtered value, and nothing else, so an unchanged column is not rewritten. + */ + private static function filteredUpdate(AttributeUpdate $update, Attribute $source, Attribute $filtered): AttributeUpdate + { + $formatChanged = $filtered->format?->name !== $source->format?->name + || $filtered->format?->options !== $source->format?->options; + + return new AttributeUpdate( + type: $update->type !== null || $filtered->type !== $source->type ? $filtered->type : null, + size: $update->size !== null || $filtered->size !== $source->size ? $filtered->size : null, + required: $update->required !== null || $filtered->required !== $source->required ? $filtered->required : null, + default: $update->changesDefault() || $filtered->default !== $source->default ? $filtered->default : Unchanged::Value, + signed: $update->signed !== null || $filtered->signed !== $source->signed ? $filtered->signed : null, + array: $update->array !== null || $filtered->array !== $source->array ? $filtered->array : null, + format: $update->changesFormat() || $formatChanged ? $filtered->format : Unchanged::Value, + filters: $update->filters !== null || $filtered->filters !== $source->filters ? $filtered->filters : null, + key: $update->key !== null || $filtered->key !== $source->key ? $filtered->key : null, + ); } + #[\Override] public function createDocument(string $collection, Document $document): Document { $document = $this->source->createDocument($collection, $document); + $destination = $this->destination; if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) - || $this->destination === null + || $destination === null ) { - return $document; + return $this->decorate(Event::DocumentCreate, $collection, $document); } $upgrade = $this->silent(fn () => $this->getUpgradeStatus($collection)); if ($upgrade === null || $upgrade->getAttribute('status', '') !== 'upgraded') { - return $document; + return $this->decorate(Event::DocumentCreate, $collection, $document); } try { $clone = clone $document; + $this->inOrder(function () use ($destination, $collection, $clone): void { + foreach ($this->writeFilters as $filter) { + $clone = $filter->beforeCreateDocument( + source: $this->source, + destination: $destination, + collectionId: $collection, + document: $clone, + ); + } - foreach ($this->writeFilters as $filter) { - $clone = $filter->beforeCreateDocument( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - document: $clone, - ); - } - - $this->destination->setPreserveDates(true); - $document = $this->destination->createDocument($collection, $clone); - $this->destination->setPreserveDates(false); + $destination->withPreserveDates(true, fn (): Document => $destination->createDocument($collection, $clone)); - foreach ($this->writeFilters as $filter) { - $filter->afterCreateDocument( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - document: $clone, - ); - } - } catch (\Throwable $err) { - $this->logError('createDocument', $err); + foreach ($this->writeFilters as $filter) { + $filter->afterCreateDocument( + source: $this->source, + destination: $destination, + collectionId: $collection, + document: $clone, + ); + } + }); + } catch (Throwable $error) { + $this->logError('createDocument', $error); } - return $document; + return $this->decorate(Event::DocumentCreate, $collection, $document); } + #[\Override] public function createDocuments( string $collection, array $documents, - int $batchSize = self::INSERT_BATCH_SIZE, + int $batchSize = self::BATCH_SIZE, ?callable $onNext = null, - ?callable $onError = null, ): int { - $modified = $this->skipDuplicates - ? $this->source->skipDuplicates( - fn () => $this->source->createDocuments($collection, $documents, $batchSize, $onNext, $onError) + $onNext = $this->decorating(Event::DocumentsCreate, $collection, $onNext); + $modified = $this->isIgnoringDuplicates() + ? $this->source->ignoreDuplicates( + fn () => $this->source->createDocuments($collection, $documents, $batchSize, $onNext) ) - : $this->source->createDocuments($collection, $documents, $batchSize, $onNext, $onError); + : $this->source->createDocuments($collection, $documents, $batchSize, $onNext); + $destination = $this->destination; if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) - || $this->destination === null + || $destination === null ) { return $modified; } @@ -628,127 +1162,113 @@ public function createDocuments( return $modified; } - // Forward every input to destination. "upgraded" status means the schema - // is mirrored, not that every row is backfilled, so a row that is a - // duplicate on source may not yet exist on destination. In skipDuplicates - // mode the destination runs its own INSERT IGNORE and decides per-row. - try { - $clones = []; - foreach ($documents as $document) { - $clone = clone $document; + $clones = \array_map(static fn (Document $document): Document => clone $document, $documents); + $ignoreDuplicates = $this->isIgnoringDuplicates(); + + $this->replicate('createDocuments', function () use ($destination, $collection, $clones, $batchSize, $ignoreDuplicates): void { + foreach ($clones as $index => $clone) { foreach ($this->writeFilters as $filter) { $clone = $filter->beforeCreateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } - $clones[] = $clone; + $clones[$index] = $clone; } - if ($this->skipDuplicates) { - $this->destination->skipDuplicates( - fn () => $this->destination->withPreserveDates( - fn () => $this->destination->createDocuments($collection, $clones, $batchSize) - ) - ); - } else { - $this->destination->withPreserveDates( - fn () => - $this->destination->createDocuments( - $collection, - $clones, - $batchSize, - ) - ); - } + $create = fn (): mixed => $destination->withPreserveDates( + true, + fn (): int => $destination->createDocuments($collection, $clones, $batchSize), + ); + $ignoreDuplicates ? $destination->ignoreDuplicates($create) : $create(); foreach ($clones as $clone) { foreach ($this->writeFilters as $filter) { $filter->afterCreateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } } - } catch (\Throwable $err) { - $this->logError('createDocuments', $err); - } + }); return $modified; } + #[\Override] public function updateDocument(string $collection, string $id, Document $document): Document { $document = $this->source->updateDocument($collection, $id, $document); + $destination = $this->destination; if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) - || $this->destination === null + || $destination === null ) { - return $document; + return $this->decorate(Event::DocumentUpdate, $collection, $document); } $upgrade = $this->silent(fn () => $this->getUpgradeStatus($collection)); if ($upgrade === null || $upgrade->getAttribute('status', '') !== 'upgraded') { - return $document; + return $this->decorate(Event::DocumentUpdate, $collection, $document); } try { $clone = clone $document; + $this->inOrder(function () use ($destination, $collection, $id, $clone): void { + foreach ($this->writeFilters as $filter) { + $clone = $filter->beforeUpdateDocument( + source: $this->source, + destination: $destination, + collectionId: $collection, + document: $clone, + ); + } - foreach ($this->writeFilters as $filter) { - $clone = $filter->beforeUpdateDocument( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - document: $clone, - ); - } - - $this->destination->setPreserveDates(true); - $this->destination->updateDocument($collection, $id, $clone); - $this->destination->setPreserveDates(false); + $destination->withPreserveDates(true, fn (): Document => $destination->updateDocument($collection, $id, $clone)); - foreach ($this->writeFilters as $filter) { - $filter->afterUpdateDocument( - source: $this->source, - destination: $this->destination, - collectionId: $collection, - document: $clone, - ); - } - } catch (\Throwable $err) { - $this->logError('updateDocument', $err); + foreach ($this->writeFilters as $filter) { + $filter->afterUpdateDocument( + source: $this->source, + destination: $destination, + collectionId: $collection, + document: $clone, + ); + } + }); + } catch (Throwable $error) { + $this->logError('updateDocument', $error); } - return $document; + return $this->decorate(Event::DocumentUpdate, $collection, $document); } + #[\Override] public function updateDocuments( string $collection, Document $updates, array $queries = [], - int $batchSize = self::INSERT_BATCH_SIZE, + int $batchSize = self::BATCH_SIZE, ?callable $onNext = null, - ?callable $onError = null, ): int { + $onNext = $this->decorating(Event::DocumentsUpdate, $collection, $onNext); $modified = $this->source->updateDocuments( $collection, $updates, $queries, $batchSize, $onNext, - $onError, ); + $destination = $this->destination; if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) - || $this->destination === null + || $destination === null ) { return $modified; } @@ -758,22 +1278,22 @@ public function updateDocuments( return $modified; } - try { - $clone = clone $updates; + $clone = clone $updates; + $this->replicate('updateDocuments', function () use ($destination, $collection, $clone, $queries, $batchSize): void { foreach ($this->writeFilters as $filter) { $clone = $filter->beforeUpdateDocuments( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, updates: $clone, queries: $queries, ); } - $this->destination->withPreserveDates( - fn () => - $this->destination->updateDocuments( + $destination->withPreserveDates( + true, + fn (): int => $destination->updateDocuments( $collection, $clone, $queries, @@ -784,37 +1304,83 @@ public function updateDocuments( foreach ($this->writeFilters as $filter) { $filter->afterUpdateDocuments( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, updates: $clone, queries: $queries, ); } - } catch (\Throwable $err) { - $this->logError('updateDocuments', $err); - } + }); return $modified; } + #[\Override] + public function upsertDocument(string $collection, Document $document): Document + { + $upserted = $this->source->upsertDocument($collection, $document); + + $destination = $this->destination; + if ( + \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) + || $destination === null + ) { + return $this->decorate(Event::DocumentUpsert, $collection, $upserted); + } + + $upgrade = $this->silent(fn () => $this->getUpgradeStatus($collection)); + if ($upgrade === null || $upgrade->getAttribute('status', '') !== 'upgraded') { + return $this->decorate(Event::DocumentUpsert, $collection, $upserted); + } + + $clone = clone $document; + + $this->replicate('upsertDocument', function () use ($destination, $collection, $clone): void { + foreach ($this->writeFilters as $filter) { + $clone = $filter->beforeCreateOrUpdateDocument( + source: $this->source, + destination: $destination, + collectionId: $collection, + document: $clone, + ); + } + + $destination->withPreserveDates(true, fn (): Document => $destination->upsertDocument($collection, $clone)); + + foreach ($this->writeFilters as $filter) { + $filter->afterCreateOrUpdateDocument( + source: $this->source, + destination: $destination, + collectionId: $collection, + document: $clone, + ); + } + }); + + return $this->decorate(Event::DocumentUpsert, $collection, $upserted); + } + + #[\Override] public function upsertDocuments( string $collection, array $documents, - int $batchSize = Database::INSERT_BATCH_SIZE, + int $batchSize = self::BATCH_SIZE, ?callable $onNext = null, - ?callable $onError = null, + ?string $increase = null, ): int { + $onNext = $this->decorating(Event::DocumentsUpsert, $collection, $onNext); $modified = $this->source->upsertDocuments( $collection, $documents, $batchSize, $onNext, - $onError, + $increase, ); + $destination = $this->destination; if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) - || $this->destination === null + || $destination === null ) { return $modified; } @@ -824,30 +1390,28 @@ public function upsertDocuments( return $modified; } - try { - $clones = []; - - foreach ($documents as $document) { - $clone = clone $document; + $clones = \array_map(static fn (Document $document): Document => clone $document, $documents); + $this->replicate('upsertDocuments', function () use ($destination, $collection, $increase, $clones, $batchSize): void { + foreach ($clones as $index => $clone) { foreach ($this->writeFilters as $filter) { $clone = $filter->beforeCreateOrUpdateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } - - $clones[] = $clone; + $clones[$index] = $clone; } - $this->destination->withPreserveDates( - fn () => - $this->destination->upsertDocuments( + $destination->withPreserveDates( + true, + fn (): int => $destination->upsertDocuments( $collection, $clones, $batchSize, + increase: $increase, ) ); @@ -855,26 +1419,26 @@ public function upsertDocuments( foreach ($this->writeFilters as $filter) { $filter->afterCreateOrUpdateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } } - } catch (\Throwable $err) { - $this->logError('upsertDocuments', $err); - } + }); return $modified; } + #[\Override] public function deleteDocument(string $collection, string $id): bool { $result = $this->source->deleteDocument($collection, $id); + $destination = $this->destination; if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) - || $this->destination === null + || $destination === null ) { return $result; } @@ -884,51 +1448,49 @@ public function deleteDocument(string $collection, string $id): bool return $result; } - try { + $this->replicate('deleteDocument', function () use ($destination, $collection, $id): void { foreach ($this->writeFilters as $filter) { $filter->beforeDeleteDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, documentId: $id, ); } - $this->destination->deleteDocument($collection, $id); + $destination->deleteDocument($collection, $id); foreach ($this->writeFilters as $filter) { $filter->afterDeleteDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, documentId: $id, ); } - } catch (\Throwable $err) { - $this->logError('deleteDocument', $err); - } + }); return $result; } + #[\Override] public function deleteDocuments( string $collection, array $queries = [], - int $batchSize = self::DELETE_BATCH_SIZE, + int $batchSize = self::BATCH_SIZE, ?callable $onNext = null, - ?callable $onError = null, ): int { $modified = $this->source->deleteDocuments( $collection, $queries, $batchSize, $onNext, - $onError, ); + $destination = $this->destination; if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) - || $this->destination === null + || $destination === null ) { return $modified; } @@ -938,17 +1500,17 @@ public function deleteDocuments( return $modified; } - try { + $this->replicate('deleteDocuments', function () use ($destination, $collection, $queries, $batchSize): void { foreach ($this->writeFilters as $filter) { $filter->beforeDeleteDocuments( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, queries: $queries, ); } - $this->destination->deleteDocuments( + $destination->deleteDocuments( $collection, $queries, $batchSize, @@ -957,148 +1519,87 @@ public function deleteDocuments( foreach ($this->writeFilters as $filter) { $filter->afterDeleteDocuments( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, queries: $queries, ); } - } catch (\Throwable $err) { - $this->logError('deleteDocuments', $err); - } + }); return $modified; } - public function updateAttributeRequired(string $collection, string $id, bool $required): Document - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function updateAttributeFormat(string $collection, string $id, string $format): Document - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function updateAttributeFormatOptions(string $collection, string $id, array $formatOptions): Document - { - return $this->delegate(__FUNCTION__, [$collection, $id, $formatOptions]); - } - - public function updateAttributeFilters(string $collection, string $id, array $filters): Document + #[\Override] + public function renameAttribute(string $collection, string $old, string $new): void { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->delegateInOrder(__FUNCTION__, \func_get_args()); } - public function updateAttributeDefault(string $collection, string $id, mixed $default = null): Document + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): Relationship { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Relationship $result */ + $result = $this->delegateInOrder(__FUNCTION__, \func_get_args()); + return $result; } - public function renameAttribute(string $collection, string $old, string $new): bool + #[\Override] + public function updateRelationship(string $collection, string $key, RelationshipUpdate $update): Relationship { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function createRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay = false, - ?string $id = null, - ?string $twoWayKey = null, - string $onDelete = Database::RELATION_MUTATE_RESTRICT - ): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function updateRelationship( - string $collection, - string $id, - ?string $newKey = null, - ?string $newTwoWayKey = null, - ?bool $twoWay = null, - ?string $onDelete = null - ): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Relationship $result */ + $result = $this->delegateInOrder(__FUNCTION__, \func_get_args()); + return $result; } - public function deleteRelationship(string $collection, string $id): bool + #[\Override] + public function deleteRelationship(string $collection, string $key): void { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->delegateInOrder(__FUNCTION__, \func_get_args()); } - - public function renameIndex(string $collection, string $old, string $new): bool + #[\Override] + public function renameIndex(string $collection, string $old, string $new): void { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->delegateInOrder(__FUNCTION__, \func_get_args()); } - public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value = 1, int|float|null $max = null): Document + #[\Override] + public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float|string $value = 1, int|float|string|null $max = null): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegateInOrder(__FUNCTION__, \func_get_args()); + return $result; } - public function decreaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value = 1, int|float|null $min = null): Document + #[\Override] + public function decreaseDocumentAttribute(string $collection, string $id, string $attribute, int|float|string $value = 1, int|float|string|null $min = null): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegateInOrder(__FUNCTION__, \func_get_args()); + return $result; } /** + * Create the upgrades tracking collection in the source database if it does not exist. + * + * @return void * @throws Limit * @throws DuplicateException * @throws Exception */ public function createUpgrades(): void { - $collection = $this->source->getCollection('upgrades'); - - if (!$collection->isEmpty()) { + if ($this->source->findCollection('upgrades') !== null) { return; } - $this->source->createCollection( - id: 'upgrades', - attributes: [ - new Document([ - '$id' => ID::custom('collectionId'), - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - 'default' => null, - 'format' => '' - ]), - new Document([ - '$id' => ID::custom('status'), - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - 'default' => null, - 'format' => '' - ]), - ], - indexes: [ - new Document([ - '$id' => ID::custom('_unique_collection'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['collectionId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [], - ]), - new Document([ - '$id' => ID::custom('_status_index'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['status'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ]), - ], - ); + $this->source->createCollection(Collection::create('upgrades', attributes: [ + Attribute::string('collectionId', required: true), + Attribute::string('status'), + ], indexes: [ + Index::unique('_unique_collection', ['collectionId'], [Database::LENGTH_KEY]), + Index::key('_status_index', ['status'], [Database::LENGTH_KEY], [OrderDirection::Asc]), + ])); } /** @@ -1113,29 +1614,247 @@ protected function getUpgradeStatus(string $collection): ?Document return $this->getSource()->getAuthorization()->skip(function () use ($collection) { try { return $this->source->getDocument('upgrades', $collection); - } catch (\Throwable) { + } catch (Throwable) { return; } }); } - protected function logError(string $action, \Throwable $err): void + /** + * Applies the mirror's decorators to a document one of its writes returns. The source has none of them, so what + * it wrote, and what replication clones from it, stays undecorated. + */ + private function decorate(Event $event, string $collection, Document $document): Document + { + if ($this->decorators === []) { + return $document; + } + + return $this->decorateDocument($event, $this->silent(fn (): Collection => $this->getCollection($collection)), $document); + } + + /** + * Hands $onNext a decorated copy of each document a bulk write returns: the source may pass the very documents + * the caller gave it, which replication clones afterwards. + */ + private function decorating(Event $event, string $collection, ?callable $onNext): ?callable + { + if ($onNext === null || $this->decorators === []) { + return $onNext; + } + + return function (Document $document, mixed ...$arguments) use ($event, $collection, $onNext): void { + $onNext($this->decorate($event, $collection, clone $document), ...$arguments); + }; + } + + /** + * Applies a write to the destination under the authorization, relationship, silence, tenant and toggle state the + * caller has at the time of the call, without its request timestamp (the source checked it), and reports a + * failure through onError(). Inside a coroutine the write runs in a coroutine of its own, in order with every other + * destination change made through the mirror (see inOrder()); outside one it runs before this returns, since a + * task that yields outside a scheduler never resumes. + * + * @param Closure(): void $write + */ + private function replicate(string $action, Closure $write): void + { + $destination = $this->destination; + if ($destination === null) { + return; + } + + $snapshot = $this->source->snapshot(); + $apply = function () use ($action, $destination, $snapshot, $write): void { + try { + $destination->withSnapshot($snapshot, function () use ($destination, $write): void { + $destination->withRequestTimestamp(null, $write); + }); + } catch (Throwable $error) { + $this->logError($action, $error); + } + }; + + if ($this->appliesInline()) { + $apply(); + + return; + } + + Promise::async($this->queue($apply)); + } + + /** + * Applies a destination change before returning, once every destination change queued through the mirror before + * it has been applied or has failed; the changes queued after it wait for it. The destination therefore receives + * the mirror's changes one at a time, in the order they were made, so they never share its connection and a write + * never overtakes an earlier one, whichever documents, related documents or schema they reach. + * + * @template T + * + * @param Closure(): T $change + * @return T + */ + private function inOrder(Closure $change): mixed + { + if ($this->appliesInline()) { + return $change(); + } + + return $this->queue($change)(); + } + + /** + * Whether a destination change applies at once: outside a coroutine nothing is queued, and a change made while + * this coroutine applies one, such as from onError() or a write filter, is part of that change. + */ + private function appliesInline(): bool + { + $coroutine = self::coroutine(); + + return $coroutine <= 0 || isset($this->applying[$coroutine]); + } + + private static function coroutine(): int + { + /** @var int $coroutine */ + $coroutine = \extension_loaded('swoole') ? Coroutine::getCid() : -1; + + return $coroutine; + } + + /** + * Queues a destination change behind the latest one and returns what applies it once that one has been applied. + * + * @template T + * + * @param Closure(): T $change + * @return Closure(): T + */ + private function queue(Closure $change): Closure + { + $earlier = $this->latestReplication; + $applied = new Channel(1); + $this->latestReplication = $applied; + + return function () use ($earlier, $applied, $change): mixed { + $coroutine = self::coroutine(); + + try { + $earlier?->pop(); + $this->applying[$coroutine] = true; + + return $change(); + } finally { + unset($this->applying[$coroutine]); + $applied->close(); + if ($this->latestReplication === $applied) { + $this->latestReplication = null; + } + } + }; + } + + protected function logError(string $action, Throwable $error): void { + $failure = new Failure($action, self::eventOf($action), $error); + foreach ($this->errorCallbacks as $callback) { - $callback($action, $err); + $callback($failure); } } - public function setAuthorization(Authorization $authorization): self + private static function eventOf(string $method): ?Event { + return match ($method) { + 'create' => Event::DatabaseCreate, + 'delete' => Event::DatabaseDelete, + 'createCollection' => Event::CollectionCreate, + 'updateCollection' => Event::CollectionUpdate, + 'deleteCollection' => Event::CollectionDelete, + 'createAttribute' => Event::AttributeCreate, + 'createAttributes' => Event::AttributesCreate, + 'updateAttribute' => Event::AttributeUpdate, + 'deleteAttribute' => Event::AttributeDelete, + 'createIndex' => Event::IndexCreate, + 'createIndexes' => Event::IndexesCreate, + 'renameIndex' => Event::IndexRename, + 'deleteIndex' => Event::IndexDelete, + 'createDocument' => Event::DocumentCreate, + 'createDocuments' => Event::DocumentsCreate, + 'updateDocument' => Event::DocumentUpdate, + 'updateDocuments' => Event::DocumentsUpdate, + 'upsertDocument' => Event::DocumentUpsert, + 'upsertDocuments' => Event::DocumentsUpsert, + 'deleteDocument' => Event::DocumentDelete, + 'deleteDocuments' => Event::DocumentsDelete, + 'increaseDocumentAttribute' => Event::DocumentIncrease, + 'decreaseDocumentAttribute' => Event::DocumentDecrease, + default => null, + }; + } + #[\Override] + public function setAuthorization(Authorization $authorization): static + { parent::setAuthorization($authorization); - if (isset($this->source)) { + if ($this->wrapped) { $this->source->setAuthorization($authorization); + $this->destination?->setAuthorization($authorization); } - if (isset($this->destination)) { - $this->destination->setAuthorization($authorization); + + return $this; + } + + /** + * A relationships hook is the mirror's own, and the source and the destination each attach a copy of it, so + * both sides relate documents exactly as it is configured to. A write hook also intercepts the destination's + * writes. + */ + #[\Override] + public function addHook(\Utopia\Query\Hook $hook): static + { + if ($hook instanceof Invalidator) { + parent::addHook($hook); + $this->source->addHook($hook); + } elseif ($hook instanceof Lifecycle) { + $this->addLifecycleHook($hook); + } else { + parent::addHook($hook); + } + + if ($hook instanceof Relationships) { + $this->source->addHook(clone $hook); + $this->destination?->addHook(clone $hook); + } + + if ($hook instanceof Write) { + $this->destination?->getAdapter()->addWriteHook($hook); + } + + return $this; + } + + /** + * Also removes the hook from where {@see self::addHook()} registered it on the source and the destination. + */ + #[\Override] + public function removeHook(\Utopia\Query\Hook|string $hook): static + { + parent::removeHook($hook); + + if ($hook instanceof Lifecycle || (\is_string($hook) && \is_a($hook, Lifecycle::class, true))) { + $this->source->removeHook($hook); + } + + if ($hook instanceof Relationships || (\is_string($hook) && \is_a($hook, Relationships::class, true))) { + $this->source->removeHook(Relationships::class); + $this->destination?->removeHook(Relationships::class); + } + + if ($hook instanceof Write || (\is_string($hook) && \is_a($hook, Write::class, true))) { + $this->destination?->getAdapter()->removeWriteHook($hook); } return $this; @@ -1144,42 +1863,35 @@ public function setAuthorization(Authorization $authorization): self /** * Set custom document class for a collection * - * @param string $collection Collection ID - * @param class-string $className Fully qualified class name that extends Document - * @return static + * @param string $collection Collection ID + * @param class-string $className Fully qualified class name that extends Document */ + #[\Override] public function setDocumentType(string $collection, string $className): static { $this->delegate(__FUNCTION__, \func_get_args()); - $this->documentTypes[$collection] = $className; - return $this; + + return parent::setDocumentType($collection, $className); } /** * Clear document type mapping for a collection * - * @param string $collection Collection ID - * @return static + * @param string $collection Collection ID */ - public function clearDocumentType(string $collection): static + #[\Override] + public function clearDocumentType(string $collection): void { $this->delegate(__FUNCTION__, \func_get_args()); - unset($this->documentTypes[$collection]); - return $this; + parent::clearDocumentType($collection); } - /** - * Clear all document type mappings - * - * @return static - */ - public function clearAllDocumentTypes(): static + #[\Override] + public function clearDocumentTypes(): void { $this->delegate(__FUNCTION__); - $this->documentTypes = []; - return $this; + parent::clearDocumentTypes(); } - } diff --git a/src/Database/Mirror/Failure.php b/src/Database/Mirror/Failure.php new file mode 100644 index 0000000000..623c51eb5d --- /dev/null +++ b/src/Database/Mirror/Failure.php @@ -0,0 +1,20 @@ + $queries The queries filtering which documents to update + * @return Document The possibly transformed updates document + */ + public function beforeUpdateDocuments( + Database $source, + Database $destination, + string $collectionId, + Document $updates, + array $queries + ): Document { + return $updates; + } + + /** + * Called after documents are bulk-updated in the destination database. + * + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param Document $updates The document containing the update fields + * @param array $queries The queries filtering which documents were updated + * @return void + */ + public function afterUpdateDocuments( + Database $source, + Database $destination, + string $collectionId, + Document $updates, + array $queries + ): void { + } + + /** + * Called before a document is deleted in the destination database. + * + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param string $documentId The document identifier + * @return void + */ + public function beforeDeleteDocument( + Database $source, + Database $destination, + string $collectionId, + string $documentId, + ): void { + } + + /** + * Called after a document is deleted in the destination database. + * + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param string $documentId The document identifier + * @return void + */ + public function afterDeleteDocument( + Database $source, + Database $destination, + string $collectionId, + string $documentId, + ): void { + } + + /** + * Called before documents are bulk-deleted in the destination database. + * + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param array $queries The queries filtering which documents to delete + * @return void + */ + public function beforeDeleteDocuments( + Database $source, + Database $destination, + string $collectionId, + array $queries + ): void { + } + + /** + * Called after documents are bulk-deleted in the destination database. + * + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param array $queries The queries filtering which documents were deleted + * @return void + */ + public function afterDeleteDocuments( + Database $source, + Database $destination, + string $collectionId, + array $queries + ): void { + } + + /** + * Called before a document is upserted in the destination database. + * + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param Document $document The document to upsert + * @return Document The possibly transformed document + */ + public function beforeCreateOrUpdateDocument( + Database $source, + Database $destination, + string $collectionId, + Document $document, + ): Document { + return $document; + } + + /** + * Called after a document is upserted in the destination database. + * + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param Document $document The upserted document + * @return Document The possibly transformed document + */ + public function afterCreateOrUpdateDocument( + Database $source, + Database $destination, + string $collectionId, + Document $document, + ): Document { + return $document; + } +} diff --git a/src/Database/Mirroring/Filter.php b/src/Database/Mirroring/Filter.php deleted file mode 100644 index 2da00534b1..0000000000 --- a/src/Database/Mirroring/Filter.php +++ /dev/null @@ -1,398 +0,0 @@ - $queries - * @return Document - */ - public function beforeUpdateDocuments( - Database $source, - Database $destination, - string $collectionId, - Document $updates, - array $queries - ): Document { - return $updates; - } - - /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $updates - * @param array $queries - * @return void - */ - public function afterUpdateDocuments( - Database $source, - Database $destination, - string $collectionId, - Document $updates, - array $queries - ): void { - } - - /** - * Called before document is deleted in the destination database - * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $documentId - * @return void - */ - public function beforeDeleteDocument( - Database $source, - Database $destination, - string $collectionId, - string $documentId, - ): void { - } - - /** - * Called after document is deleted in the destination database - * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $documentId - * @return void - */ - public function afterDeleteDocument( - Database $source, - Database $destination, - string $collectionId, - string $documentId, - ): void { - } - - /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param array $queries - * @return void - */ - public function beforeDeleteDocuments( - Database $source, - Database $destination, - string $collectionId, - array $queries - ): void { - } - - /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param array $queries - * @return void - */ - public function afterDeleteDocuments( - Database $source, - Database $destination, - string $collectionId, - array $queries - ): void { - } - - /** - * Called before document is upserted in the destination database - * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document - */ - public function beforeCreateOrUpdateDocument( - Database $source, - Database $destination, - string $collectionId, - Document $document, - ): Document { - return $document; - } - - /** - * Called after document is upserted in the destination database - * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document - */ - public function afterCreateOrUpdateDocument( - Database $source, - Database $destination, - string $collectionId, - Document $document, - ): Document { - return $document; - } -} diff --git a/src/Database/NumericBounds.php b/src/Database/NumericBounds.php new file mode 100644 index 0000000000..dfaf4df424 --- /dev/null +++ b/src/Database/NumericBounds.php @@ -0,0 +1,12 @@ + - */ - protected array $values = []; - /** * Construct a new operator object * - * @param string $method - * @param string $attribute - * @param array $values + * @param array $values */ - public function __construct(string $method, string $attribute = '', array $values = []) - { - $this->method = $method; - $this->attribute = $attribute; - $this->values = $values; + public function __construct( + protected OperatorType $method, + protected string $attribute = '', + protected array $values = [], + ) { } + /** + * Deep clone operator values that are themselves Operator instances. + * + * @return void + */ public function __clone(): void { foreach ($this->values as $index => $value) { @@ -149,14 +54,18 @@ public function __clone(): void } /** - * @return string + * Get the operator method type. + * + * @return OperatorType */ - public function getMethod(): string + public function getMethod(): OperatorType { return $this->method; } /** + * Get the target attribute name. + * * @return string */ public function getAttribute(): string @@ -165,29 +74,41 @@ public function getAttribute(): string } /** + * Get all operator values. + * * @return array */ public function getValues(): array { - return $this->values; + $exponent = $this->values[0] ?? null; + if ($this->method !== OperatorType::Power || ! \is_string($exponent) || ! \is_numeric($exponent)) { + return $this->values; + } + + $values = $this->values; + $values[0] = $exponent + 0; + + return $values; } /** - * @param mixed $default + * Get the first value, or a default if none is set. + * + * @param mixed $default The fallback value * @return mixed */ public function getValue(mixed $default = null): mixed { - return $this->values[0] ?? $default; + return $this->getValues()[0] ?? $default; } /** * Sets method * - * @param string $method + * @param OperatorType $method The operator method type * @return self */ - public function setMethod(string $method): self + public function setMethod(OperatorType $method): self { $this->method = $method; @@ -197,7 +118,7 @@ public function setMethod(string $method): self /** * Sets attribute * - * @param string $attribute + * @param string $attribute The target attribute name * @return self */ public function setAttribute(string $attribute): self @@ -210,7 +131,7 @@ public function setAttribute(string $attribute): self /** * Sets values * - * @param array $values + * @param array $values * @return self */ public function setValues(array $values): self @@ -222,7 +143,8 @@ public function setValues(array $values): self /** * Sets value - * @param mixed $value + * + * @param mixed $value The value to set * @return self */ public function setValue(mixed $value): self @@ -235,34 +157,16 @@ public function setValue(mixed $value): self /** * Check if method is supported * - * @param string $value + * @param OperatorType|string $value The method to check * @return bool */ - public static function isMethod(string $value): bool - { - return match ($value) { - self::TYPE_INCREMENT, - self::TYPE_DECREMENT, - self::TYPE_MULTIPLY, - self::TYPE_DIVIDE, - self::TYPE_MODULO, - self::TYPE_POWER, - self::TYPE_STRING_CONCAT, - self::TYPE_STRING_REPLACE, - self::TYPE_ARRAY_APPEND, - self::TYPE_ARRAY_PREPEND, - self::TYPE_ARRAY_INSERT, - self::TYPE_ARRAY_REMOVE, - self::TYPE_ARRAY_UNIQUE, - self::TYPE_ARRAY_INTERSECT, - self::TYPE_ARRAY_DIFF, - self::TYPE_ARRAY_FILTER, - self::TYPE_TOGGLE, - self::TYPE_DATE_ADD_DAYS, - self::TYPE_DATE_SUB_DAYS, - self::TYPE_DATE_SET_NOW => true, - default => false, - }; + public static function isMethod(OperatorType|string $value): bool + { + if ($value instanceof OperatorType) { + return true; + } + + return OperatorType::tryFrom($value) !== null; } /** @@ -272,7 +176,7 @@ public static function isMethod(string $value): bool */ public function isNumericOperation(): bool { - return \in_array($this->method, self::NUMERIC_TYPES); + return $this->method->isNumeric(); } /** @@ -282,7 +186,7 @@ public function isNumericOperation(): bool */ public function isArrayOperation(): bool { - return \in_array($this->method, self::ARRAY_TYPES); + return $this->method->isArray(); } /** @@ -292,7 +196,7 @@ public function isArrayOperation(): bool */ public function isStringOperation(): bool { - return \in_array($this->method, self::STRING_TYPES); + return $this->method->isString(); } /** @@ -302,10 +206,9 @@ public function isStringOperation(): bool */ public function isBooleanOperation(): bool { - return \in_array($this->method, self::BOOLEAN_TYPES); + return $this->method->isBoolean(); } - /** * Check if method is a date operation * @@ -313,13 +216,13 @@ public function isBooleanOperation(): bool */ public function isDateOperation(): bool { - return \in_array($this->method, self::DATE_TYPES); + return $this->method->isDate(); } /** * Parse operator from string * - * @param string $operator + * @param string $operator JSON-encoded operator string * @return self * @throws OperatorException */ @@ -327,21 +230,22 @@ public static function parse(string $operator): self { try { $operator = \json_decode($operator, true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new OperatorException('Invalid operator: ' . $e->getMessage()); + } catch (JsonException $e) { + throw new OperatorException('Invalid operator: '.$e->getMessage()); } - if (!\is_array($operator)) { - throw new OperatorException('Invalid operator. Must be an array, got ' . \gettype($operator)); + if (! \is_array($operator)) { + throw new OperatorException('Invalid operator. Must be an array, got '.\gettype($operator)); } + /** @var array $operator */ return self::parseOperator($operator); } /** * Parse operator from array * - * @param array $operator + * @param array $operator * @return self * @throws OperatorException */ @@ -351,57 +255,56 @@ public static function parseOperator(array $operator): self $attribute = $operator['attribute'] ?? ''; $values = $operator['values'] ?? []; - if (!\is_string($method)) { - throw new OperatorException('Invalid operator method. Must be a string, got ' . \gettype($method)); + if (! \is_string($method)) { + throw new OperatorException('Invalid operator method. Must be a string, got '.\gettype($method)); } - if (!self::isMethod($method)) { - throw new OperatorException('Invalid operator method: ' . $method); + $operatorType = OperatorType::tryFrom($method); + if ($operatorType === null) { + throw new OperatorException('Invalid operator method: '.$method); } - if (!\is_string($attribute)) { - throw new OperatorException('Invalid operator attribute. Must be a string, got ' . \gettype($attribute)); + if (! \is_string($attribute)) { + throw new OperatorException('Invalid operator attribute. Must be a string, got '.\gettype($attribute)); } - if (!\is_array($values)) { - throw new OperatorException('Invalid operator values. Must be an array, got ' . \gettype($values)); + if (! \is_array($values)) { + throw new OperatorException('Invalid operator values. Must be an array, got '.\gettype($values)); } - return new self($method, $attribute, $values); + return new self($operatorType, $attribute, $values); } /** * Parse an array of operators * - * @param array $operators - * + * @param array $operators * @return array + * * @throws OperatorException */ public static function parseOperators(array $operators): array { - $parsed = []; - - foreach ($operators as $operator) { - $parsed[] = self::parse($operator); - } - - return $parsed; + return \array_map(self::parse(...), $operators); } /** + * Convert this operator to an associative array. + * * @return array */ public function toArray(): array { return [ - 'method' => $this->method, + 'method' => $this->method->value, 'attribute' => $this->attribute, 'values' => $this->values, ]; } /** + * Serialize this operator to a JSON string. + * * @return string * @throws OperatorException */ @@ -410,136 +313,138 @@ public function toString(): string try { return \json_encode($this->toArray(), flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { - throw new OperatorException('Invalid Json: ' . $e->getMessage()); + throw new OperatorException('Invalid Json: '.$e->getMessage()); } } /** * Helper method to create increment operator * - * @param int|float $value - * @param int|float|null $max Maximum value (won't increment beyond this) - * @return Operator + * @param int|float|string $value The amount to increment by + * @param int|float|string|null $max Maximum value (won't increment beyond this) + * @return self */ - public static function increment(int|float $value = 1, int|float|null $max = null): self + public static function increment(int|float|string $value = 1, int|float|string|null $max = null): self { $values = [$value]; if ($max !== null) { $values[] = $max; } - return new self(self::TYPE_INCREMENT, '', $values); + + return new self(OperatorType::Increment, '', $values); } /** * Helper method to create decrement operator * - * @param int|float $value - * @param int|float|null $min Minimum value (won't decrement below this) - * @return Operator + * @param int|float|string $value The amount to decrement by + * @param int|float|string|null $min Minimum value (won't decrement below this) + * @return self */ - public static function decrement(int|float $value = 1, int|float|null $min = null): self + public static function decrement(int|float|string $value = 1, int|float|string|null $min = null): self { $values = [$value]; if ($min !== null) { $values[] = $min; } - return new self(self::TYPE_DECREMENT, '', $values); - } + return new self(OperatorType::Decrement, '', $values); + } /** * Helper method to create array append operator * - * @param array $values - * @return Operator + * @param array $values + * @return self */ public static function arrayAppend(array $values): self { - return new self(self::TYPE_ARRAY_APPEND, '', $values); + return new self(OperatorType::ArrayAppend, '', $values); } /** * Helper method to create array prepend operator * - * @param array $values - * @return Operator + * @param array $values + * @return self */ public static function arrayPrepend(array $values): self { - return new self(self::TYPE_ARRAY_PREPEND, '', $values); + return new self(OperatorType::ArrayPrepend, '', $values); } /** * Helper method to create array insert operator * - * @param int $index - * @param mixed $value - * @return Operator + * @param int $index The position to insert at + * @param mixed $value The value to insert + * @return self */ public static function arrayInsert(int $index, mixed $value): self { - return new self(self::TYPE_ARRAY_INSERT, '', [$index, $value]); + return new self(OperatorType::ArrayInsert, '', [$index, $value]); } /** * Helper method to create array remove operator * - * @param mixed $value - * @return Operator + * @param mixed $value The value to remove + * @return self */ public static function arrayRemove(mixed $value): self { - return new self(self::TYPE_ARRAY_REMOVE, '', [$value]); + return new self(OperatorType::ArrayRemove, '', [$value]); } /** * Helper method to create concatenation operator * - * @param mixed $value Value to concatenate (string or array) - * @return Operator + * @param mixed $value Value to concatenate (string or array) + * @return self */ public static function stringConcat(mixed $value): self { - return new self(self::TYPE_STRING_CONCAT, '', [$value]); + return new self(OperatorType::StringConcat, '', [$value]); } /** * Helper method to create replace operator * - * @param string $search - * @param string $replace - * @return Operator + * @param string $search The substring to search for + * @param string $replace The replacement string + * @return self */ public static function stringReplace(string $search, string $replace): self { - return new self(self::TYPE_STRING_REPLACE, '', [$search, $replace]); + return new self(OperatorType::StringReplace, '', [$search, $replace]); } /** * Helper method to create multiply operator * - * @param int|float $factor - * @param int|float|null $max Maximum value (won't multiply beyond this) - * @return Operator + * @param int|float|string $factor The factor to multiply by + * @param int|float|string|null $max Maximum value (won't multiply beyond this) + * @return self */ - public static function multiply(int|float $factor, int|float|null $max = null): self + public static function multiply(int|float|string $factor, int|float|string|null $max = null): self { $values = [$factor]; if ($max !== null) { $values[] = $max; } - return new self(self::TYPE_MULTIPLY, '', $values); + + return new self(OperatorType::Multiply, '', $values); } /** * Helper method to create divide operator * - * @param int|float $divisor - * @param int|float|null $min Minimum value (won't divide below this) - * @return Operator + * @param int|float|string $divisor The divisor + * @param int|float|string|null $min Minimum value (won't divide below this) + * @return self * @throws OperatorException if divisor is zero */ - public static function divide(int|float $divisor, int|float|null $min = null): self + public static function divide(int|float|string $divisor, int|float|string|null $min = null): self { if ($divisor == 0) { throw new OperatorException('Division by zero is not allowed'); @@ -548,132 +453,133 @@ public static function divide(int|float $divisor, int|float|null $min = null): s if ($min !== null) { $values[] = $min; } - return new self(self::TYPE_DIVIDE, '', $values); + + return new self(OperatorType::Divide, '', $values); } /** * Helper method to create toggle operator * - * @return Operator + * @return self */ public static function toggle(): self { - return new self(self::TYPE_TOGGLE, '', []); + return new self(OperatorType::Toggle, '', []); } - /** * Helper method to create date add days operator * - * @param int $days Number of days to add (can be negative to subtract) - * @return Operator + * @param int $days Number of days to add (can be negative to subtract) + * @return self */ public static function dateAddDays(int $days): self { - return new self(self::TYPE_DATE_ADD_DAYS, '', [$days]); + return new self(OperatorType::DateAddDays, '', [$days]); } /** * Helper method to create date subtract days operator * - * @param int $days Number of days to subtract - * @return Operator + * @param int $days Number of days to subtract + * @return self */ public static function dateSubDays(int $days): self { - return new self(self::TYPE_DATE_SUB_DAYS, '', [$days]); + return new self(OperatorType::DateSubDays, '', [$days]); } /** * Helper method to create date set now operator * - * @return Operator + * @return self */ public static function dateSetNow(): self { - return new self(self::TYPE_DATE_SET_NOW, '', []); + return new self(OperatorType::DateSetNow, '', []); } /** * Helper method to create modulo operator * - * @param int|float $divisor The divisor for modulo operation - * @return Operator + * @param int|float|string $divisor The divisor for modulo operation + * @return self * @throws OperatorException if divisor is zero */ - public static function modulo(int|float $divisor): self + public static function modulo(int|float|string $divisor): self { if ($divisor == 0) { throw new OperatorException('Modulo by zero is not allowed'); } - return new self(self::TYPE_MODULO, '', [$divisor]); + + return new self(OperatorType::Modulo, '', [$divisor]); } /** * Helper method to create power operator * - * @param int|float $exponent The exponent to raise to - * @param int|float|null $max Maximum value (won't exceed this) - * @return Operator + * @param int|float|string $exponent The exponent to raise to + * @param int|float|string|null $max Maximum value (won't exceed this) + * @return self */ - public static function power(int|float $exponent, int|float|null $max = null): self + public static function power(int|float|string $exponent, int|float|string|null $max = null): self { $values = [$exponent]; if ($max !== null) { $values[] = $max; } - return new self(self::TYPE_POWER, '', $values); - } + return new self(OperatorType::Power, '', $values); + } /** * Helper method to create array unique operator * - * @return Operator + * @return self */ public static function arrayUnique(): self { - return new self(self::TYPE_ARRAY_UNIQUE, '', []); + return new self(OperatorType::ArrayUnique, '', []); } /** * Helper method to create array intersect operator * - * @param array $values Values to intersect with current array - * @return Operator + * @param array $values Values to intersect with current array + * @return self */ public static function arrayIntersect(array $values): self { - return new self(self::TYPE_ARRAY_INTERSECT, '', $values); + return new self(OperatorType::ArrayIntersect, '', $values); } /** * Helper method to create array diff operator * - * @param array $values Values to remove from current array - * @return Operator + * @param array $values Values to remove from current array + * @return self */ public static function arrayDiff(array $values): self { - return new self(self::TYPE_ARRAY_DIFF, '', $values); + return new self(OperatorType::ArrayDiff, '', $values); } /** * Helper method to create array filter operator * - * @param string $condition Filter condition ('equals', 'notEquals', 'greaterThan', 'lessThan', 'null', 'notNull') - * @param mixed $value Value to filter by (not used for 'null'/'notNull' conditions) - * @return Operator + * @param string $condition Filter condition ('equals', 'notEquals', 'greaterThan', 'lessThan', 'null', 'notNull') + * @param mixed $value Value to filter by (not used for 'null'/'notNull' conditions) + * @return self */ public static function arrayFilter(string $condition, mixed $value = null): self { - return new self(self::TYPE_ARRAY_FILTER, '', [$condition, $value]); + return new self(OperatorType::ArrayFilter, '', [$condition, $value]); } /** * Check if a value is an operator instance * - * @param mixed $value + * @param mixed $value The value to check * @return bool */ public static function isOperator(mixed $value): bool @@ -684,16 +590,17 @@ public static function isOperator(mixed $value): bool /** * Extract operators from document data * - * @param array $data + * @param array $data * @return array{operators: array, updates: array} */ public static function extractOperators(array $data): array { + /** @var array $operators */ $operators = []; $updates = []; foreach ($data as $key => $value) { - if (self::isOperator($value)) { + if ($value instanceof self) { // Set the attribute from the document key if not already set if (empty($value->getAttribute())) { $value->setAttribute($key); @@ -709,5 +616,4 @@ public static function extractOperators(array $data): array 'updates' => $updates, ]; } - } diff --git a/src/Database/OperatorType.php b/src/Database/OperatorType.php new file mode 100644 index 0000000000..ac75158ba3 --- /dev/null +++ b/src/Database/OperatorType.php @@ -0,0 +1,119 @@ + true, + default => false, + }; + } + + /** + * Check if this operator type is an array operation. + * + * @return bool + */ + public function isArray(): bool + { + return match ($this) { + self::ArrayAppend, + self::ArrayPrepend, + self::ArrayInsert, + self::ArrayRemove, + self::ArrayUnique, + self::ArrayIntersect, + self::ArrayDiff, + self::ArrayFilter => true, + default => false, + }; + } + + /** + * Check if this operator type is a string operation. + * + * @return bool + */ + public function isString(): bool + { + return match ($this) { + self::StringConcat, + self::StringReplace => true, + default => false, + }; + } + + /** + * Check if this operator type is a boolean operation. + * + * @return bool + */ + public function isBoolean(): bool + { + return match ($this) { + self::Toggle => true, + default => false, + }; + } + + /** + * Check if this operator type is a date operation. + * + * @return bool + */ + public function isDate(): bool + { + return match ($this) { + self::DateAddDays, + self::DateSubDays, + self::DateSetNow => true, + default => false, + }; + } +} diff --git a/src/Database/PDO.php b/src/Database/PDO.php index b8c98e260f..aa3c37fdbe 100644 --- a/src/Database/PDO.php +++ b/src/Database/PDO.php @@ -2,25 +2,74 @@ namespace Utopia\Database; +use Exception; use InvalidArgumentException; +use PDO as PhpPDO; +use Pdo\Sqlite as PdoSqlite; +use PDOException; +use PDOStatement as PhpPDOStatement; +use Throwable; use Utopia\Console; /** * A PDO wrapper that forwards method calls to the internal PDO instance. * - * @mixin \PDO + * @mixin PhpPDO + * + * @method int|false exec(string $statement) + * @method bool beginTransaction() + * @method bool commit() + * @method bool rollBack() + * @method bool inTransaction() + * @method string|false quote(string $string, int $type = PhpPDO::PARAM_STR) + * @method mixed getAttribute(int $attribute) + * @method string|false lastInsertId(?string $name = null) + * @method \PDOStatement|false query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs) */ class PDO { - protected \PDO $pdo; + protected PhpPDO $pdo; + + /** + * Methods that send a statement, lowercased as PHP compares method names. + */ + private const array STATEMENTS = ['begintransaction', 'commit', 'exec', 'query']; + + private const string ROLLBACK = 'ROLLBACK'; + + private const string SQLITE = 'sqlite'; private ?string $hostname = null; /** - * @param string $dsn - * @param ?string $username - * @param ?string $password - * @param array $config + * The lost connection that dropped the caller's open transaction, while the caller has + * not rolled it back yet. Until then every statement is refused, so none of them runs + * in autocommit on the new connection; each refusal carries it as its previous error. + * A rollBack() that finds the connection lost has ended the transaction, so it sets none. + */ + private ?Throwable $lostTransaction = null; + + /** + * Statements that set session state, keyed by the setting each one sets. + * + * @var array + */ + private array $session = []; + + /** + * Attributes set after connecting, replayed on every connection a reconnect opens. + * + * @var array + */ + private array $attributes = []; + + /** + * Create a new PDO wrapper instance. + * + * @param string $dsn The Data Source Name + * @param string|null $username The database username + * @param string|null $password The database password + * @param array $config PDO driver options */ public function __construct( protected string $dsn, @@ -28,23 +77,16 @@ public function __construct( protected ?string $password, protected array $config = [] ) { - $this->config[\PDO::ATTR_ERRMODE] ??= \PDO::ERRMODE_EXCEPTION; + $this->config[PhpPDO::ATTR_ERRMODE] ??= PhpPDO::ERRMODE_EXCEPTION; - $this->pdo = new \PDO( - $this->dsn, - $this->username, - $this->password, - $this->config - ); + $this->pdo = $this->connect(); } /** - * Prepare a statement, returning a wrapper that transparently re-prepares - * itself on the underlying connection if that connection is lost before the - * statement is executed. + * Prepare a statement that can re-prepare itself after connection recovery. * - * @param array $options - * @throws \Throwable + * @param array $options + * @throws Throwable */ public function prepare(string $query, array $options = []): PDOStatement { @@ -61,14 +103,16 @@ public function prepare(string $query, array $options = []): PDOStatement * with native prepares the server is contacted here, so a lost connection * outside a transaction is reconnected and retried, matching __call(). * - * @param array $options - * @throws \Throwable + * @param array $options + * @throws Throwable */ - public function prepareNative(string $query, array $options = []): \PDOStatement + public function prepareNative(string $query, array $options = []): PhpPDOStatement { + $this->guard($query); + try { $statement = $this->pdo->prepare($query, $options); - } catch (\Throwable $e) { + } catch (Throwable $e) { if (!Connection::hasError($e) || $this->pdo->inTransaction()) { throw $e; } @@ -78,37 +122,47 @@ public function prepareNative(string $query, array $options = []): \PDOStatement } if ($statement === false) { - throw new \PDOException("Failed to prepare statement: {$query}"); + throw new PDOException("Failed to prepare statement: {$query}"); } return $statement; } /** - * @param string $method - * @param array $args - * @return mixed - * @throws \Throwable + * @param array $args + * @throws Throwable */ public function __call(string $method, array $args): mixed { + if ($this->lostTransaction !== null && \strcasecmp($method, 'rollBack') === 0) { + $this->lostTransaction = null; + + return true; + } + + if (\in_array(\strtolower($method), self::STATEMENTS, true)) { + $statement = $args[0] ?? null; + $this->guard(\is_string($statement) ? $statement : $method); + } + try { return $this->pdo->{$method}(...$args); - } catch (\Throwable $e) { + } catch (Throwable $e) { if (Connection::hasError($e)) { - Console::warning('[Database] ' . $e->getMessage()); + Console::warning('[Database] '.$e->getMessage()); Console::warning('[Database] Lost connection detected. Reconnecting...'); $inTransaction = $this->pdo->inTransaction(); - // Attempt to reconnect $this->reconnect(); - // If we weren't in a transaction, also retry the query - // In a transaction we can't retry as the state is attached to the previous connection - if (!$inTransaction) { + if (! $inTransaction) { return $this->pdo->{$method}(...$args); } + + if (\strcasecmp($method, 'rollBack') !== 0) { + $this->lostTransaction = $e; + } } throw $e; @@ -116,13 +170,98 @@ public function __call(string $method, array $args): mixed } /** - * Create a new connection to the database + * Set an attribute on the connection and on every connection a reconnect opens. + */ + public function setAttribute(int $attribute, mixed $value): bool + { + if (! $this->pdo->setAttribute($attribute, $value)) { + return false; + } + + $this->attributes[$attribute] = $value; + + return true; + } + + /** + * Run a statement that sets session state and replay it on every connection a + * reconnect opens, before a call that lost the old connection is retried. A later + * statement for the same setting replaces the earlier one. + * + * @throws Throwable + */ + public function configure(string $setting, string $statement): void + { + if ($this->exec($statement) === false) { + throw new PDOException("Failed to configure session: {$statement}"); + } + + $this->session[$setting] = $statement; + } + + /** + * Create a new connection to the database with the attributes set after connecting + * and the configured session. + * + * It replaces the current connection only once both are replayed: after a failed + * replay the lost connection stays, so the next call reconnects again instead of + * running without them. * - * @return void + * @throws Throwable */ public function reconnect(): void { - $this->pdo = new \PDO( + $pdo = $this->connect(); + + foreach ($this->attributes as $attribute => $value) { + if (! $pdo->setAttribute($attribute, $value)) { + throw new PDOException("Failed to restore attribute {$attribute}"); + } + } + + foreach ($this->session as $statement) { + if ($pdo->exec($statement) === false) { + throw new PDOException("Failed to configure session: {$statement}"); + } + } + + $this->pdo = $pdo; + $this->lostTransaction = null; + } + + /** + * Refuse a statement while a lost transaction is not rolled back. A bare ROLLBACK is + * the caller ending that transaction, so it is let through and ends the refusal. + * + * @throws PDOException + */ + private function guard(string $statement): void + { + if ($this->lostTransaction === null) { + return; + } + + if (\strcasecmp(\trim($statement), self::ROLLBACK) === 0) { + $this->lostTransaction = null; + + return; + } + + throw new PDOException('The transaction was lost with the connection: roll it back before running another statement', previous: $this->lostTransaction); + } + + private function connect(): PhpPDO + { + if (\str_starts_with($this->dsn, 'sqlite:')) { + return PdoSqlite::connect( + $this->dsn, + $this->username, + $this->password, + $this->config + ); + } + + return new PhpPDO( $this->dsn, $this->username, $this->password, @@ -130,11 +269,15 @@ public function reconnect(): void ); } + public function inTransaction(): bool + { + return $this->lostTransaction !== null || $this->pdo->inTransaction(); + } + /** * Get the hostname from the DSN. * - * @return string - * @throws \Exception + * @throws Exception */ public function getHostname(): string { @@ -144,7 +287,7 @@ public function getHostname(): string /** * @var string $host */ - $host = $parts['host'] ?? throw new \Exception('No host found in DSN'); + $host = $parts['host'] ?? throw new Exception('No host found in DSN'); $this->hostname = $host; } @@ -156,11 +299,12 @@ public function getHostname(): string * Parse a PDO-style DSN string. * * @return array + * * @throws InvalidArgumentException If the DSN is malformed. */ private function parseDsn(string $dsn): array { - if ($dsn === '' || !\str_contains($dsn, ':')) { + if ($dsn === '' || ! \str_contains($dsn, ':')) { throw new InvalidArgumentException('Malformed DSN: missing driver separator.'); } @@ -168,9 +312,9 @@ private function parseDsn(string $dsn): array $parsed = ['driver' => \trim($driver)]; - // Handle “path only” DSNs like sqlite:/path/to.db - if (\in_array($driver, ['sqlite'], true) && $parameterString !== '') { + if ($driver === self::SQLITE && $parameterString !== '') { $parsed['path'] = \ltrim($parameterString, '/'); + return $parsed; } @@ -179,10 +323,9 @@ private function parseDsn(string $dsn): array foreach ($parameterSegments as $segment) { [$name, $rawValue] = \array_pad(\explode('=', $segment, 2), 2, null); - $name = \trim($name); + $name = \trim((string) $name); $value = $rawValue !== null ? \trim($rawValue) : null; - // Casting for scalars if ($value === 'true' || $value === 'false') { $value = $value === 'true'; } elseif (\is_numeric($value)) { diff --git a/src/Database/PDOStatement.php b/src/Database/PDOStatement.php index 5dbfc4d689..d9f0a52017 100644 --- a/src/Database/PDOStatement.php +++ b/src/Database/PDOStatement.php @@ -49,10 +49,12 @@ class PDOStatement implements \IteratorAggregate */ private array $attributes = []; + private ?int $fetchMode = null; + /** - * @var array|null + * @var array */ - private ?array $fetchMode = null; + private array $fetchModeArguments = []; /** * @param array $options @@ -94,6 +96,7 @@ public function __clone(): void * Preserve \PDOStatement's native iterability (foreach over rows), which * does not route through __call(). */ + #[\Override] public function getIterator(): \Traversable { return $this->statement; @@ -130,6 +133,11 @@ public function getStatement(): \PDOStatement return $this->statement; } + public function getQueryString(): string + { + return $this->statement->queryString; + } + public function setAttribute(int $attribute, mixed $value): bool { $this->attributes[$attribute] = $value; @@ -139,7 +147,8 @@ public function setAttribute(int $attribute, mixed $value): bool public function setFetchMode(int $mode, mixed ...$args): bool { - $this->fetchMode = [$mode, ...$args]; + $this->fetchMode = $mode; + $this->fetchModeArguments = \array_values($args); return $this->statement->setFetchMode($mode, ...$args); } @@ -183,7 +192,7 @@ private function reprepare(): void } if ($this->fetchMode !== null) { - $this->statement->setFetchMode(...$this->fetchMode); + $this->statement->setFetchMode($this->fetchMode, ...$this->fetchModeArguments); } // Replay value/param bindings in the original call order so a placeholder diff --git a/src/Database/ParsedQuery.php b/src/Database/ParsedQuery.php new file mode 100644 index 0000000000..3c4fd1ba78 --- /dev/null +++ b/src/Database/ParsedQuery.php @@ -0,0 +1,46 @@ + $filters + * @param list $selections + * @param list $aggregations + * @param list $groupBy + * @param list $having + * @param list $joins + * @param list $unions + * @param Document|null $cursor + * @param list $timeBuckets + * @param list $orderAttributes + * @param list $orderTypes + */ + public function __construct( + #[\Override] public array $filters = [], + #[\Override] public array $selections = [], + #[\Override] public array $aggregations = [], + #[\Override] public array $groupBy = [], + #[\Override] public array $having = [], + #[\Override] public bool $distinct = false, + #[\Override] public array $joins = [], + #[\Override] public array $unions = [], + #[\Override] public ?int $limit = null, + #[\Override] public ?int $offset = null, + #[\Override] public mixed $cursor = null, + #[\Override] public ?CursorDirection $cursorDirection = null, + #[\Override] public array $timeBuckets = [], + public array $orderAttributes = [], + public array $orderTypes = [], + ) { + } +} diff --git a/src/Database/Helpers/Permission.php b/src/Database/Permission.php similarity index 63% rename from src/Database/Helpers/Permission.php rename to src/Database/Permission.php index 18c4fe5a94..8ded25a208 100644 --- a/src/Database/Helpers/Permission.php +++ b/src/Database/Permission.php @@ -1,11 +1,13 @@ > */ private static array $aggregates = [ - 'write' => [ - Database::PERMISSION_CREATE, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ] + PermissionType::Write->value => [ + PermissionType::Create->value, + PermissionType::Update->value, + PermissionType::Delete->value, + ], ]; + /** + * @param string $permission The permission type (e.g. read, create, update, delete, write) + * @param string $role The role name + * @param string $identifier The role identifier + * @param string $dimension The role dimension + */ public function __construct( private string $permission, string $role, @@ -31,16 +39,17 @@ public function __construct( } /** - * Create a permission string from this Permission instance + * Create a permission string from this Permission instance. * - * @return string + * @return string The formatted permission string (e.g. 'read("user:123")') */ public function toString(): string { - return $this->permission . '("' . $this->role->toString() . '")'; + return $this->permission.'("'.$this->role->toString().'")'; } /** + * Get the permission type string. * * @return string */ @@ -50,6 +59,8 @@ public function getPermission(): string } /** + * Get the role name associated with this permission. + * * @return string */ public function getRole(): string @@ -58,6 +69,8 @@ public function getRole(): string } /** + * Get the role identifier associated with this permission. + * * @return string */ public function getIdentifier(): string @@ -66,6 +79,8 @@ public function getIdentifier(): string } /** + * Get the role dimension associated with this permission. + * * @return string */ public function getDimension(): string @@ -74,24 +89,24 @@ public function getDimension(): string } /** - * Parse a permission string into a Permission object + * Parse a permission string into a Permission object. * - * @param string $permission + * @param string $permission The permission string to parse (e.g. 'read("user:123")') * @return self - * @throws Exception + * @throws DatabaseException If the permission string format or type is invalid */ public static function parse(string $permission): self { $permissionParts = \explode('("', $permission); if (\count($permissionParts) !== 2) { - throw new DatabaseException('Invalid permission string format: "' . $permission . '".'); + throw new DatabaseException('Invalid permission string format: "'.$permission.'".'); } $permission = $permissionParts[0]; - if (!\in_array($permission, array_merge(Database::PERMISSIONS, [Database::PERMISSION_WRITE]))) { - throw new DatabaseException('Invalid permission type: "' . $permission . '".'); + if (! \in_array($permission, array_column(PermissionType::cases(), 'value'))) { + throw new DatabaseException('Invalid permission type: "'.$permission.'".'); } $fullRole = \str_replace('")', '', $permissionParts[1]); $roleParts = \explode(':', $fullRole); @@ -100,16 +115,17 @@ public static function parse(string $permission): self $hasIdentifier = \count($roleParts) > 1; $hasDimension = \str_contains($fullRole, '/'); - if (!$hasIdentifier && !$hasDimension) { + if (! $hasIdentifier && ! $hasDimension) { return new self($permission, $role); } - if ($hasIdentifier && !$hasDimension) { + if ($hasIdentifier && ! $hasDimension) { $identifier = $roleParts[1]; + return new self($permission, $role, $identifier); } - if (!$hasIdentifier) { + if (! $hasIdentifier) { $dimensionParts = \explode('/', $fullRole); if (\count($dimensionParts) !== 2) { throw new DatabaseException('Only one dimension can be provided'); @@ -121,6 +137,7 @@ public static function parse(string $permission): self if (empty($dimension)) { throw new DatabaseException('Dimension must not be empty'); } + return new self($permission, $role, '', $dimension); } @@ -143,26 +160,36 @@ public static function parse(string $permission): self /** * Map aggregate permissions into the set of individual permissions they represent. * - * @param array|null $permissions - * @param array $allowed + * @param array|null $permissions + * @param array $allowed * @return array|null + * * @throws Exception */ - public static function aggregate(?array $permissions, array $allowed = Database::PERMISSIONS): ?array + /** + * @param array|null $permissions + * @param array $allowed + * @return array|null + * + * @throws Exception + */ + public static function aggregate(?array $permissions, array $allowed = [PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete]): ?array { if (\is_null($permissions)) { return null; } + $allowedValues = \array_map(fn (PermissionType $p) => $p->value, $allowed); $mutated = []; foreach ($permissions as $i => $permission) { $permission = self::parse($permission); foreach (self::$aggregates as $type => $subTypes) { if ($permission->getPermission() != $type) { $mutated[] = $permission->toString(); + continue; } foreach ($subTypes as $subType) { - if (!\in_array($subType, $allowed)) { + if (! \in_array($subType, $allowedValues)) { continue; } $mutated[] = (new self( @@ -174,91 +201,97 @@ public static function aggregate(?array $permissions, array $allowed = Database: } } } + return \array_values(\array_unique($mutated)); } /** - * Create a read permission string from the given Role + * Create a read permission string from the given Role. * - * @param Role $role - * @return string + * @param Role $role The role to grant read permission to + * @return string The formatted permission string */ public static function read(Role $role): string { $permission = new self( - 'read', + PermissionType::Read->value, $role->getRole(), $role->getIdentifier(), $role->getDimension() ); + return $permission->toString(); } /** - * Create a create permission string from the given Role + * Create a create permission string from the given Role. * - * @param Role $role - * @return string + * @param Role $role The role to grant create permission to + * @return string The formatted permission string */ public static function create(Role $role): string { $permission = new self( - 'create', + PermissionType::Create->value, $role->getRole(), $role->getIdentifier(), $role->getDimension() ); + return $permission->toString(); } /** - * Create an update permission string from the given Role + * Create an update permission string from the given Role. * - * @param Role $role - * @return string + * @param Role $role The role to grant update permission to + * @return string The formatted permission string */ public static function update(Role $role): string { $permission = new self( - 'update', + PermissionType::Update->value, $role->getRole(), $role->getIdentifier(), $role->getDimension() ); + return $permission->toString(); } /** - * Create a delete permission string from the given Role + * Create a delete permission string from the given Role. * - * @param Role $role - * @return string + * @param Role $role The role to grant delete permission to + * @return string The formatted permission string */ public static function delete(Role $role): string { $permission = new self( - 'delete', + PermissionType::Delete->value, $role->getRole(), $role->getIdentifier(), $role->getDimension() ); + return $permission->toString(); } /** - * Create a write permission string from the given Role + * Create a write permission string from the given Role. * - * @param Role $role - * @return string + * @param Role $role The role to grant write permission to + * @return string The formatted permission string */ public static function write(Role $role): string { $permission = new self( - 'write', + PermissionType::Write->value, $role->getRole(), $role->getIdentifier(), $role->getDimension() ); + return $permission->toString(); } } diff --git a/src/Database/PermissionType.php b/src/Database/PermissionType.php new file mode 100644 index 0000000000..dac87c7230 --- /dev/null +++ b/src/Database/PermissionType.php @@ -0,0 +1,15 @@ + */ + private array $logs = []; + + private int $oldest = 0; + + private int $capacity = self::DEFAULT_CAPACITY; + + private int $queryCount = 0; + + private float $totalTime = 0.0; + + private float $slowThreshold = 100.0; + + private bool $enabled = false; + + private bool $captureBacktrace = false; + + /** @var (Closure(Log): mixed)|null */ + private ?Closure $onSlowQuery = null; + + public function enable(): static + { + $this->enabled = true; + + return $this; + } + + public function disable(): static + { + $this->enabled = false; + + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setSlowThreshold(float $milliseconds): static + { + $this->slowThreshold = $milliseconds; + + return $this; + } + + /** + * Keep at most this many of the newest entries. + * + * @throws InvalidArgumentException + */ + public function setCapacity(int $capacity): static + { + if ($capacity < 1) { + throw new InvalidArgumentException('Profiler capacity must be at least 1'); + } + + $this->logs = \array_slice($this->getLogs(), -$capacity); + $this->oldest = 0; + $this->capacity = $capacity; + + return $this; + } + + public function getCapacity(): int + { + return $this->capacity; + } + + public function enableBacktrace(bool $enabled = true): static + { + $this->captureBacktrace = $enabled; + + return $this; + } + + public function onSlowQuery(callable $callback): static + { + $this->onSlowQuery = $callback(...); + + return $this; + } + + /** + * @param array $bindings + */ + public function log(string $query, array $bindings, float $durationMs, string $collection = '', string $operation = ''): void + { + if (! $this->enabled) { + return; + } + + $backtrace = null; + if ($this->captureBacktrace) { + $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 10); + $backtrace = \array_map( + fn (array $frame) => ($frame['file'] ?? '') . ':' . ($frame['line'] ?? '') . ' ' . $frame['function'], + $trace + ); + } + + $entry = new Log( + query: $query, + bindings: $bindings, + durationMs: $durationMs, + collection: $collection, + operation: $operation, + backtrace: $backtrace, + ); + + $this->record($entry); + + if ($durationMs >= $this->slowThreshold && $this->onSlowQuery !== null) { + ($this->onSlowQuery)($entry); + } + } + + /** + * The newest entries up to the capacity, oldest first. + * + * @return list + */ + public function getLogs(): array + { + return [ + ...\array_slice($this->logs, $this->oldest), + ...\array_slice($this->logs, 0, $this->oldest), + ]; + } + + /** + * @return array + */ + public function getSlowQueries(): array + { + return \array_filter($this->getLogs(), fn (Log $log) => $log->durationMs >= $this->slowThreshold); + } + + /** + * Every query logged since the last reset, including those the capacity + * has since dropped. + */ + public function getQueryCount(): int + { + return $this->queryCount; + } + + /** + * The time of every query logged since the last reset, including those + * the capacity has since dropped. + */ + public function getTotalTime(): float + { + return $this->totalTime; + } + + /** + * @return array + */ + public function detectNPlusOne(int $threshold = 5): array + { + $patterns = []; + + foreach ($this->logs as $log) { + $pattern = \preg_replace('/\?(?:,\s*\?)*/', '?...', $log->query) ?? $log->query; + $pattern = \preg_replace('/\'[^\']*\'/', '?', $pattern) ?? $pattern; + $pattern = \preg_replace('/\d+/', '?', $pattern) ?? $pattern; + + if (! isset($patterns[$pattern])) { + $patterns[$pattern] = 0; + } + + $patterns[$pattern]++; + } + + return \array_filter($patterns, fn (int $count) => $count >= $threshold); + } + + public function reset(): void + { + $this->logs = []; + $this->oldest = 0; + $this->queryCount = 0; + $this->totalTime = 0.0; + } + + private function record(Log $entry): void + { + $this->queryCount++; + $this->totalTime += $entry->durationMs; + + if (\count($this->logs) < $this->capacity) { + $this->logs[] = $entry; + + return; + } + + $this->logs[$this->oldest] = $entry; + $this->oldest = ($this->oldest + 1) % $this->capacity; + } +} diff --git a/src/Database/Profiler/Log.php b/src/Database/Profiler/Log.php new file mode 100644 index 0000000000..a0a7344653 --- /dev/null +++ b/src/Database/Profiler/Log.php @@ -0,0 +1,20 @@ + $bindings + * @param array|null $backtrace + */ + public function __construct( + public string $query, + public array $bindings, + public float $durationMs, + public string $collection = '', + public string $operation = '', + public ?array $backtrace = null, + ) { + } +} diff --git a/src/Database/Query.php b/src/Database/Query.php index 147c463ad0..1d1990c97b 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -2,427 +2,200 @@ namespace Utopia\Database; -use JsonException; use Utopia\Database\Exception\Query as QueryException; - -class Query +use Utopia\Query\Exception as BaseQueryException; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Query as BaseQuery; +use Utopia\Query\Schema\ColumnType; + +/** + * Extends the base query library with database-specific query construction, parsing, and grouping. + * + * @phpstan-consistent-constructor + */ +class Query extends BaseQuery { - // Filter methods - public const TYPE_EQUAL = 'equal'; - public const TYPE_NOT_EQUAL = 'notEqual'; - public const TYPE_LESSER = 'lessThan'; - public const TYPE_LESSER_EQUAL = 'lessThanEqual'; - public const TYPE_GREATER = 'greaterThan'; - public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; - public const TYPE_CONTAINS = 'contains'; - public const TYPE_CONTAINS_ANY = 'containsAny'; - public const TYPE_NOT_CONTAINS = 'notContains'; - public const TYPE_SEARCH = 'search'; - public const TYPE_NOT_SEARCH = 'notSearch'; - public const TYPE_IS_NULL = 'isNull'; - public const TYPE_IS_NOT_NULL = 'isNotNull'; - public const TYPE_BETWEEN = 'between'; - public const TYPE_NOT_BETWEEN = 'notBetween'; - public const TYPE_STARTS_WITH = 'startsWith'; - public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; - public const TYPE_ENDS_WITH = 'endsWith'; - public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; - public const TYPE_REGEX = 'regex'; - public const TYPE_EXISTS = 'exists'; - public const TYPE_NOT_EXISTS = 'notExists'; - - // Spatial methods - public const TYPE_CROSSES = 'crosses'; - public const TYPE_NOT_CROSSES = 'notCrosses'; - public const TYPE_DISTANCE_EQUAL = 'distanceEqual'; - public const TYPE_DISTANCE_NOT_EQUAL = 'distanceNotEqual'; - public const TYPE_DISTANCE_GREATER_THAN = 'distanceGreaterThan'; - public const TYPE_DISTANCE_LESS_THAN = 'distanceLessThan'; - public const TYPE_INTERSECTS = 'intersects'; - public const TYPE_NOT_INTERSECTS = 'notIntersects'; - public const TYPE_OVERLAPS = 'overlaps'; - public const TYPE_NOT_OVERLAPS = 'notOverlaps'; - public const TYPE_TOUCHES = 'touches'; - public const TYPE_NOT_TOUCHES = 'notTouches'; - - // Vector query methods - public const TYPE_VECTOR_DOT = 'vectorDot'; - public const TYPE_VECTOR_COSINE = 'vectorCosine'; - public const TYPE_VECTOR_EUCLIDEAN = 'vectorEuclidean'; - - public const TYPE_SELECT = 'select'; - - // Order methods - public const TYPE_ORDER_DESC = 'orderDesc'; - public const TYPE_ORDER_ASC = 'orderAsc'; - public const TYPE_ORDER_RANDOM = 'orderRandom'; - - // Pagination methods - public const TYPE_LIMIT = 'limit'; - public const TYPE_OFFSET = 'offset'; - public const TYPE_CURSOR_AFTER = 'cursorAfter'; - public const TYPE_CURSOR_BEFORE = 'cursorBefore'; - - // Logical methods - public const TYPE_AND = 'and'; - public const TYPE_OR = 'or'; - public const TYPE_CONTAINS_ALL = 'containsAll'; - public const TYPE_ELEM_MATCH = 'elemMatch'; - public const DEFAULT_ALIAS = 'main'; - - public const TYPES = [ - self::TYPE_EQUAL, - self::TYPE_NOT_EQUAL, - self::TYPE_LESSER, - self::TYPE_LESSER_EQUAL, - self::TYPE_GREATER, - self::TYPE_GREATER_EQUAL, - self::TYPE_CONTAINS, - self::TYPE_CONTAINS_ANY, - self::TYPE_NOT_CONTAINS, - self::TYPE_SEARCH, - self::TYPE_NOT_SEARCH, - self::TYPE_IS_NULL, - self::TYPE_IS_NOT_NULL, - self::TYPE_BETWEEN, - self::TYPE_NOT_BETWEEN, - self::TYPE_STARTS_WITH, - self::TYPE_NOT_STARTS_WITH, - self::TYPE_ENDS_WITH, - self::TYPE_NOT_ENDS_WITH, - self::TYPE_CROSSES, - self::TYPE_NOT_CROSSES, - self::TYPE_DISTANCE_EQUAL, - self::TYPE_DISTANCE_NOT_EQUAL, - self::TYPE_DISTANCE_GREATER_THAN, - self::TYPE_DISTANCE_LESS_THAN, - self::TYPE_INTERSECTS, - self::TYPE_NOT_INTERSECTS, - self::TYPE_OVERLAPS, - self::TYPE_NOT_OVERLAPS, - self::TYPE_TOUCHES, - self::TYPE_NOT_TOUCHES, - self::TYPE_VECTOR_DOT, - self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN, - self::TYPE_EXISTS, - self::TYPE_NOT_EXISTS, - self::TYPE_SELECT, - self::TYPE_ORDER_DESC, - self::TYPE_ORDER_ASC, - self::TYPE_ORDER_RANDOM, - self::TYPE_LIMIT, - self::TYPE_OFFSET, - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE, - self::TYPE_AND, - self::TYPE_OR, - self::TYPE_CONTAINS_ALL, - self::TYPE_ELEM_MATCH, - self::TYPE_REGEX - ]; - - public const VECTOR_TYPES = [ - self::TYPE_VECTOR_DOT, - self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN, - ]; - - protected const LOGICAL_TYPES = [ - self::TYPE_AND, - self::TYPE_OR, - self::TYPE_ELEM_MATCH, - ]; - - protected string $method = ''; - protected string $attribute = ''; - protected string $attributeType = ''; - protected bool $onArray = false; protected bool $isObjectAttribute = false; /** - * @var array + * Default table alias used in queries */ - protected array $values = []; + public const string DEFAULT_ALIAS = 'table_main'; /** - * Construct a new query object + * Methods that compose child queries and contribute their inner + * structure to a shape/fingerprint. Widened from parent's protected + * declaration so external validators (Queries.php) can reuse it + * without redeclaring the list. * - * @param string $method - * @param string $attribute - * @param array $values + * @var list */ - public function __construct(string $method, string $attribute = '', array $values = []) - { - if ($attribute === '' && \in_array($method, [Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC])) { - $attribute = '$sequence'; - } - - $this->method = $method; - $this->attribute = $attribute; - $this->values = $values; - } - - public function __clone(): void - { - foreach ($this->values as $index => $value) { - if ($value instanceof self) { - $this->values[$index] = clone $value; - } - } - } + public const array LOGICAL_TYPES = [Method::And, Method::Or, Method::ElemMatch]; /** - * @return string + * Ceiling on the nodes shape() will walk. + * + * A query tree that reaches this is not one anybody wrote by hand: either a + * child points back at an ancestor, which makes the walk unbounded, or the + * same child object is shared often enough that the preorder walk visits it + * exponentially. The walk deliberately does not deduplicate -- a node + * reachable by two paths has to be listed under both, or the reversed pass + * would shape a parent before its child -- so the count is what bounds it. */ - public function getMethod(): string - { - return $this->method; - } + public const int SHAPE_MAX_NODES = 10000; /** - * @return string + * @param array $values */ - public function getAttribute(): string + public function __construct(Method|string $method, string $attribute = '', array $values = [], string $alias = '') { - return $this->attribute; - } + $methodEnum = $method instanceof Method ? $method : Method::from($method); - /** - * @return array - */ - public function getValues(): array - { - return $this->values; - } + if ($attribute === '' && \in_array($methodEnum, [Method::OrderAsc, Method::OrderDesc])) { + $attribute = Document::SEQUENCE; + } - /** - * @param mixed $default - * @return mixed - */ - public function getValue(mixed $default = null): mixed - { - return $this->values[0] ?? $default; + parent::__construct($methodEnum, $attribute, $values, $alias); } /** - * Sets method - * - * @param string $method - * @return self + * @throws QueryException */ - public function setMethod(string $method): self + #[\Override] + public static function parse(string $query, bool $allowRaw = false): static { - $this->method = $method; + try { + $parsed = parent::parse($query, $allowRaw); - return $this; + return new static($parsed->getMethod(), $parsed->getAttribute(), $parsed->getValues(), $parsed->getAlias()); + } catch (BaseQueryException $e) { + throw new QueryException($e->getMessage(), $e->getCode(), $e); + } } /** - * Sets attribute + * @param array $query * - * @param string $attribute - * @return self + * @throws QueryException */ - public function setAttribute(string $attribute): self + #[\Override] + public static function parseQuery(array $query, bool $allowRaw = false): static { - $this->attribute = $attribute; + try { + $parsed = parent::parseQuery(self::decodeNestedValues($query, $allowRaw), $allowRaw); - return $this; + return new static($parsed->getMethod(), $parsed->getAttribute(), $parsed->getValues(), $parsed->getAlias()); + } catch (BaseQueryException $e) { + throw new QueryException($e->getMessage(), $e->getCode(), $e); + } } /** - * Sets values + * Decode a logical query's children to the array form the parser recurses into. + * + * Clients may serialise those children as query strings rather than nested + * objects. The parser's recursion is typed for arrays and only documented as + * such, so a string child reaches it and raises a TypeError, which is not a + * QueryException and so escapes every caller that guards for one. * - * @param array $values - * @return self + * @param array $query + * @return array + * + * @throws QueryException */ - public function setValues(array $values): self + private static function decodeNestedValues(array $query, bool $allowRaw): array { - $this->values = $values; + $method = $query['method'] ?? null; - return $this; - } + if (! \is_string($method)) { + return $query; + } - /** - * Sets value - * @param mixed $value - * @return self - */ - public function setValue(mixed $value): self - { - $this->values = [$value]; + if (! (Method::tryFrom($method)?->isNested() ?? false)) { + return $query; + } + + $values = $query['values'] ?? []; + + if (! \is_array($values)) { + return $query; + } + + foreach ($values as $index => $value) { + if (\is_array($value)) { + continue; + } + + if (! \is_string($value)) { + throw new QueryException('Invalid nested query. Must be an array or string, got '.\gettype($value)); + } - return $this; + $values[$index] = self::parse($value, $allowRaw)->toArray(); + } + + $query['values'] = $values; + + return $query; } /** - * Check if method is supported - * - * @param string $value - * @return bool + * @param array|object $value a Document; Validator\Query\Cursor also takes its id, and refuses an array */ - public static function isMethod(string $value): bool + #[\Override] + public static function cursorAfter(array|object $value): static { - return match ($value) { - self::TYPE_EQUAL, - self::TYPE_NOT_EQUAL, - self::TYPE_LESSER, - self::TYPE_LESSER_EQUAL, - self::TYPE_GREATER, - self::TYPE_GREATER_EQUAL, - self::TYPE_CONTAINS, - self::TYPE_CONTAINS_ANY, - self::TYPE_NOT_CONTAINS, - self::TYPE_SEARCH, - self::TYPE_NOT_SEARCH, - self::TYPE_ORDER_ASC, - self::TYPE_ORDER_DESC, - self::TYPE_ORDER_RANDOM, - self::TYPE_LIMIT, - self::TYPE_OFFSET, - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE, - self::TYPE_IS_NULL, - self::TYPE_IS_NOT_NULL, - self::TYPE_BETWEEN, - self::TYPE_NOT_BETWEEN, - self::TYPE_STARTS_WITH, - self::TYPE_NOT_STARTS_WITH, - self::TYPE_ENDS_WITH, - self::TYPE_NOT_ENDS_WITH, - self::TYPE_CROSSES, - self::TYPE_NOT_CROSSES, - self::TYPE_DISTANCE_EQUAL, - self::TYPE_DISTANCE_NOT_EQUAL, - self::TYPE_DISTANCE_GREATER_THAN, - self::TYPE_DISTANCE_LESS_THAN, - self::TYPE_INTERSECTS, - self::TYPE_NOT_INTERSECTS, - self::TYPE_OVERLAPS, - self::TYPE_NOT_OVERLAPS, - self::TYPE_TOUCHES, - self::TYPE_NOT_TOUCHES, - self::TYPE_OR, - self::TYPE_AND, - self::TYPE_CONTAINS_ALL, - self::TYPE_ELEM_MATCH, - self::TYPE_SELECT, - self::TYPE_VECTOR_DOT, - self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN, - self::TYPE_EXISTS, - self::TYPE_NOT_EXISTS => true, - default => false, - }; + return new static(Method::CursorAfter, values: [$value]); } /** - * Check if method is a spatial-only query method - * @return bool + * @param array|object $value a Document; Validator\Query\Cursor also takes its id, and refuses an array */ - public function isSpatialQuery(): bool + #[\Override] + public static function cursorBefore(array|object $value): static { - return match ($this->method) { - self::TYPE_CROSSES, - self::TYPE_NOT_CROSSES, - self::TYPE_DISTANCE_EQUAL, - self::TYPE_DISTANCE_NOT_EQUAL, - self::TYPE_DISTANCE_GREATER_THAN, - self::TYPE_DISTANCE_LESS_THAN, - self::TYPE_INTERSECTS, - self::TYPE_NOT_INTERSECTS, - self::TYPE_OVERLAPS, - self::TYPE_NOT_OVERLAPS, - self::TYPE_TOUCHES, - self::TYPE_NOT_TOUCHES => true, - default => false, - }; + return new static(Method::CursorBefore, values: [$value]); } /** - * Parse query + * Standard deviation over the **population**. * - * @param string $query - * @return self - * @throws QueryException + * Bare SQL `STDDEV` is population on MySQL and MariaDB and sample on + * PostgreSQL. The adapters pin this method to `STDDEV_POP` so every engine + * answers the same number; ask for stddevSamp() when you want the sample + * statistic. */ - public static function parse(string $query): self + #[\Override] + public static function stddev(string $attribute, string $alias = ''): static { - try { - $query = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new QueryException('Invalid query: ' . $e->getMessage()); - } - - if (!\is_array($query)) { - throw new QueryException('Invalid query. Must be an array, got ' . \gettype($query)); - } - - return self::parseQuery($query); + return parent::stddev($attribute, $alias); } /** - * Parse query + * Variance over the **population**. * - * @param array $query - * @return self - * @throws QueryException + * Bare SQL `VARIANCE` is population on MySQL and MariaDB and sample on + * PostgreSQL. The adapters pin this method to `VAR_POP` so every engine + * answers the same number; ask for varSamp() when you want the sample + * statistic. */ - public static function parseQuery(array $query): self + #[\Override] + public static function variance(string $attribute, string $alias = ''): static { - $method = $query['method'] ?? ''; - $attribute = $query['attribute'] ?? ''; - $values = $query['values'] ?? []; - - if (!\is_string($method)) { - throw new QueryException('Invalid query method. Must be a string, got ' . \gettype($method)); - } - - if (!self::isMethod($method)) { - throw new QueryException('Invalid query method: ' . $method); - } - - if (!\is_string($attribute)) { - throw new QueryException('Invalid query attribute. Must be a string, got ' . \gettype($attribute)); - } - - if (!\is_array($values)) { - throw new QueryException('Invalid query values. Must be an array, got ' . \gettype($values)); - } - - if (\in_array($method, self::LOGICAL_TYPES)) { - foreach ($values as $index => $value) { - if (\is_string($value)) { - $values[$index] = self::parse($value); - } elseif (\is_array($value)) { - $values[$index] = self::parseQuery($value); - } else { - throw new QueryException('Invalid nested query. Must be an array or string, got ' . \gettype($value)); - } - } - } - - return new self($method, $attribute, $values); + return parent::variance($attribute, $alias); } /** - * Parse an array of queries - * - * @param array $queries - * - * @return array - * @throws QueryException + * Check if method is supported. Accepts both string and Method enum. */ - public static function parseQueries(array $queries): array + #[\Override] + public static function isMethod(Method|string $value): bool { - $parsed = []; - - foreach ($queries as $query) { - $parsed[] = Query::parse($query); + if ($value instanceof Method) { + return true; } - return $parsed; + return Method::tryFrom($value) !== null; } /** @@ -443,6 +216,7 @@ public static function parseQueries(array $queries): array * @return string md5 hash of the canonical shape * @throws QueryException if an element is neither a string nor a Query */ + #[\Override] public static function fingerprint(array $queries): string { $shapes = []; @@ -476,7 +250,10 @@ public static function fingerprint(array $queries): string * always resolved before the node itself. * * @return string + * + * @throws QueryException if the tree exceeds self::SHAPE_MAX_NODES */ + #[\Override] public function shape(): string { // 1. Preorder flatten the tree. @@ -487,6 +264,10 @@ public function shape(): string $node = \array_pop($stack); $nodes[] = $node; + if (\count($nodes) > self::SHAPE_MAX_NODES) { + throw new QueryException('Query is too deeply nested to fingerprint: exceeded '.self::SHAPE_MAX_NODES.' nodes, which means a cycle or a child shared across too many parents'); + } + if (!\in_array($node->method, self::LOGICAL_TYPES, true)) { continue; } @@ -503,7 +284,7 @@ public function shape(): string $id = \spl_object_id($node); if (!\in_array($node->method, self::LOGICAL_TYPES, true)) { - $shapes[$id] = $node->method . ':' . $node->attribute; + $shapes[$id] = $node->method->value . ':' . $node->attribute; continue; } @@ -515,7 +296,7 @@ public function shape(): string } \sort($childShapes); // Attribute is empty for and/or; meaningful for elemMatch (the field being matched). - $shapes[$id] = $node->method . ':' . $node->attribute . '(' . \implode('|', $childShapes) . ')'; + $shapes[$id] = $node->method->value . ':' . $node->attribute . '(' . \implode('|', $childShapes) . ')'; } return $shapes[\spl_object_id($this)]; @@ -524,25 +305,36 @@ public function shape(): string /** * @return array */ + #[\Override] public function toArray(): array { - $array = ['method' => $this->method]; + $array = ['method' => $this->method->value]; - if (!empty($this->attribute)) { + if (! empty($this->attribute)) { $array['attribute'] = $this->attribute; } - if (\in_array($array['method'], self::LOGICAL_TYPES)) { + if ($this->alias !== '') { + $array['alias'] = $this->alias; + } + + if (\in_array($this->method, self::LOGICAL_TYPES, true) || $this->method === Method::Having) { foreach ($this->values as $index => $value) { + if (! $value instanceof self) { + throw new QueryException( + 'Invalid child query in '.$this->method->value.' at index '.$index.': expected Query, got '.\get_debug_type($value) + ); + } $array['values'][$index] = $value->toArray(); } } else { $array['values'] = []; foreach ($this->values as $value) { - if ($value instanceof Document && in_array($this->method, [self::TYPE_CURSOR_AFTER, self::TYPE_CURSOR_BEFORE])) { - $value = $value->getId(); - } - $array['values'][] = $value; + $array['values'][] = match (true) { + $value instanceof BaseQuery => $value->toArray(), + $value instanceof Document && \in_array($this->method, [Method::CursorAfter, Method::CursorBefore], true) => $value->getId(), + default => $value, + }; } } @@ -550,838 +342,94 @@ public function toArray(): array } /** - * @return string - * @throws QueryException - */ - public function toString(): string - { - try { - return \json_encode($this->toArray(), flags: JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new QueryException('Invalid Json: ' . $e->getMessage()); - } - } - - /** - * Helper method to create Query with equal method - * - * @param string $attribute - * @param array> $values - * @return Query - */ - public static function equal(string $attribute, array $values): self - { - return new self(self::TYPE_EQUAL, $attribute, $values); - } - - /** - * Helper method to create Query with notEqual method - * - * @param string $attribute - * @param string|int|float|bool|array $value - * @return Query - */ - public static function notEqual(string $attribute, string|int|float|bool|array $value): self - { - // maps or not an array - if ((is_array($value) && !array_is_list($value)) || !is_array($value)) { - $value = [$value]; - } - return new self(self::TYPE_NOT_EQUAL, $attribute, $value); - } - - /** - * Helper method to create Query with lessThan method - * - * @param string $attribute - * @param string|int|float|bool $value - * @return Query - */ - public static function lessThan(string $attribute, string|int|float|bool $value): self - { - return new self(self::TYPE_LESSER, $attribute, [$value]); - } - - /** - * Helper method to create Query with lessThanEqual method - * - * @param string $attribute - * @param string|int|float|bool $value - * @return Query - */ - public static function lessThanEqual(string $attribute, string|int|float|bool $value): self - { - return new self(self::TYPE_LESSER_EQUAL, $attribute, [$value]); - } - - /** - * Helper method to create Query with greaterThan method - * - * @param string $attribute - * @param string|int|float|bool $value - * @return Query - */ - public static function greaterThan(string $attribute, string|int|float|bool $value): self - { - return new self(self::TYPE_GREATER, $attribute, [$value]); - } - - /** - * Helper method to create Query with greaterThanEqual method - * - * @param string $attribute - * @param string|int|float|bool $value - * @return Query - */ - public static function greaterThanEqual(string $attribute, string|int|float|bool $value): self - { - return new self(self::TYPE_GREATER_EQUAL, $attribute, [$value]); - } - - /** - * Helper method to create Query with contains method - * - * @deprecated Use containsAny() for array attributes, or keep using contains() for string substring matching. - * @param string $attribute - * @param array $values - * @return Query - */ - public static function contains(string $attribute, array $values): self - { - return new self(self::TYPE_CONTAINS, $attribute, $values); - } - - /** - * Helper method to create Query with containsAny method. - * For array and relationship attributes, matches documents where the attribute contains ANY of the given values. - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function containsAny(string $attribute, array $values): self - { - return new self(self::TYPE_CONTAINS_ANY, $attribute, $values); - } - - /** - * Helper method to create Query with notContains method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function notContains(string $attribute, array $values): self - { - return new self(self::TYPE_NOT_CONTAINS, $attribute, $values); - } - - /** - * Helper method to create Query with between method - * - * @param string $attribute - * @param string|int|float|bool $start - * @param string|int|float|bool $end - * @return Query - */ - public static function between(string $attribute, string|int|float|bool $start, string|int|float|bool $end): self - { - return new self(self::TYPE_BETWEEN, $attribute, [$start, $end]); - } - - /** - * Helper method to create Query with notBetween method - * - * @param string $attribute - * @param string|int|float|bool $start - * @param string|int|float|bool $end - * @return Query - */ - public static function notBetween(string $attribute, string|int|float|bool $start, string|int|float|bool $end): self - { - return new self(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); - } - - /** - * Helper method to create Query with search method - * - * @param string $attribute - * @param string $value - * @return Query - */ - public static function search(string $attribute, string $value): self - { - return new self(self::TYPE_SEARCH, $attribute, [$value]); - } - - /** - * Helper method to create Query with notSearch method - * - * @param string $attribute - * @param string $value - * @return Query - */ - public static function notSearch(string $attribute, string $value): self - { - return new self(self::TYPE_NOT_SEARCH, $attribute, [$value]); - } - - /** - * Helper method to create Query with select method - * - * @param array $attributes - * @return Query - */ - public static function select(array $attributes): self - { - return new self(self::TYPE_SELECT, values: $attributes); - } - - /** - * Helper method to create Query with orderDesc method - * - * @param string $attribute - * @return Query - */ - public static function orderDesc(string $attribute = ''): self - { - return new self(self::TYPE_ORDER_DESC, $attribute); - } - - /** - * Helper method to create Query with orderAsc method - * - * @param string $attribute - * @return Query - */ - public static function orderAsc(string $attribute = ''): self - { - return new self(self::TYPE_ORDER_ASC, $attribute); - } - - /** - * Helper method to create Query with orderRandom method - * - * @return Query - */ - public static function orderRandom(): self - { - return new self(self::TYPE_ORDER_RANDOM); - } - - /** - * Helper method to create Query with limit method - * - * @param int $value - * @return Query - */ - public static function limit(int $value): self - { - return new self(self::TYPE_LIMIT, values: [$value]); - } - - /** - * Helper method to create Query with offset method - * - * @param int $value - * @return Query - */ - public static function offset(int $value): self - { - return new self(self::TYPE_OFFSET, values: [$value]); - } - - /** - * Helper method to create Query with cursorAfter method - * - * @param Document $value - * @return Query - */ - public static function cursorAfter(Document $value): self - { - return new self(self::TYPE_CURSOR_AFTER, values: [$value]); - } - - /** - * Helper method to create Query with cursorBefore method - * - * @param Document $value - * @return Query - */ - public static function cursorBefore(Document $value): self - { - return new self(self::TYPE_CURSOR_BEFORE, values: [$value]); - } - - /** - * Helper method to create Query with isNull method - * - * @param string $attribute - * @return Query - */ - public static function isNull(string $attribute): self - { - return new self(self::TYPE_IS_NULL, $attribute); - } - - /** - * Helper method to create Query with isNotNull method - * - * @param string $attribute - * @return Query - */ - public static function isNotNull(string $attribute): self - { - return new self(self::TYPE_IS_NOT_NULL, $attribute); - } - - public static function startsWith(string $attribute, string $value): self - { - return new self(self::TYPE_STARTS_WITH, $attribute, [$value]); - } - - public static function notStartsWith(string $attribute, string $value): self - { - return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); - } - - public static function endsWith(string $attribute, string $value): self - { - return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); - } - - public static function notEndsWith(string $attribute, string $value): self - { - return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); - } - - /** - * Helper method to create Query for documents created before a specific date - * - * @param string $value - * @return Query - */ - public static function createdBefore(string $value): self - { - return self::lessThan('$createdAt', $value); - } - - /** - * Helper method to create Query for documents created after a specific date - * - * @param string $value - * @return Query - */ - public static function createdAfter(string $value): self - { - return self::greaterThan('$createdAt', $value); - } - - /** - * Helper method to create Query for documents updated before a specific date - * - * @param string $value - * @return Query - */ - public static function updatedBefore(string $value): self - { - return self::lessThan('$updatedAt', $value); - } - - /** - * Helper method to create Query for documents updated after a specific date + * Group the queries by kind, with their orders in the order the queries give them. * - * @param string $value - * @return Query - */ - public static function updatedAfter(string $value): self - { - return self::greaterThan('$updatedAt', $value); - } - - /** - * Helper method to create Query for documents created between two dates - * - * @param string $start - * @param string $end - * @return Query - */ - public static function createdBetween(string $start, string $end): self - { - return self::between('$createdAt', $start, $end); - } - - /** - * Helper method to create Query for documents updated between two dates + * @param array $queries Database queries * - * @param string $start - * @param string $end - * @return Query + * @throws QueryException When a cursor is not a document */ - public static function updatedBetween(string $start, string $end): self + #[\Override] + public static function groupByType(array $queries): ParsedQuery { - return self::between('$updatedAt', $start, $end); - } - - /** - * @param array $queries - * @return Query - */ - public static function or(array $queries): self - { - return new self(self::TYPE_OR, '', $queries); - } - - /** - * @param array $queries - * @return Query - */ - public static function and(array $queries): self - { - return new self(self::TYPE_AND, '', $queries); - } + $grouped = parent::groupByType($queries); - /** - * @param string $attribute - * @param array $values - * @return Query - */ - public static function containsAll(string $attribute, array $values): self - { - return new self(self::TYPE_CONTAINS_ALL, $attribute, $values); - } - - /** - * Filters $queries for $types - * - * @param array $queries - * @param array $types - * @param bool $clone - * @return array - */ - public static function getByType(array $queries, array $types, bool $clone = true): array - { - $filtered = []; - - foreach ($queries as $query) { - if (\in_array($query->getMethod(), $types, true)) { - $filtered[] = $clone ? clone $query : $query; - } + $cursor = $grouped->cursor; + if ($cursor !== null && ! $cursor instanceof Document) { + throw new QueryException('Invalid query: Invalid cursor: a cursor must be a document, '.\get_debug_type($cursor).' given'); } - return $filtered; - } - - /** - * @param array $queries - * @param bool $clone - * @return array - */ - public static function getCursorQueries(array $queries, bool $clone = true): array - { - return self::getByType( - $queries, - [ - Query::TYPE_CURSOR_AFTER, - Query::TYPE_CURSOR_BEFORE, - ], - $clone - ); - } - - /** - * Iterates through queries are groups them by type - * - * @param array $queries - * @return array{ - * filters: array, - * selections: array, - * limit: int|null, - * offset: int|null, - * orderAttributes: array, - * orderTypes: array, - * cursor: Document|null, - * cursorDirection: string|null - * } - */ - public static function groupByType(array $queries): array - { - $filters = []; - $selections = []; - $limit = null; - $offset = null; $orderAttributes = []; $orderTypes = []; - $cursor = null; - $cursorDirection = null; - foreach ($queries as $query) { - if (!$query instanceof Query) { + if (! $query instanceof BaseQuery) { continue; } - $method = $query->getMethod(); - $attribute = $query->getAttribute(); - $values = $query->getValues(); - - switch ($method) { - case Query::TYPE_ORDER_ASC: - case Query::TYPE_ORDER_DESC: - case Query::TYPE_ORDER_RANDOM: - if (!empty($attribute)) { - $orderAttributes[] = $attribute; - } - - $orderTypes[] = match ($method) { - Query::TYPE_ORDER_ASC => Database::ORDER_ASC, - Query::TYPE_ORDER_DESC => Database::ORDER_DESC, - Query::TYPE_ORDER_RANDOM => Database::ORDER_RANDOM, - }; - - break; - case Query::TYPE_LIMIT: - // Keep the 1st limit encountered and ignore the rest - if ($limit !== null) { - break; - } - - $limit = $values[0] ?? $limit; - break; - case Query::TYPE_OFFSET: - // Keep the 1st offset encountered and ignore the rest - if ($offset !== null) { - break; - } - - $offset = $values[0] ?? $limit; - break; - case Query::TYPE_CURSOR_AFTER: - case Query::TYPE_CURSOR_BEFORE: - // Keep the 1st cursor encountered and ignore the rest - if ($cursor !== null) { - break; - } + $direction = match ($query->getMethod()) { + Method::OrderAsc => OrderDirection::Asc, + Method::OrderDesc => OrderDirection::Desc, + Method::OrderRandom => OrderDirection::Random, + default => null, + }; - $cursor = $values[0] ?? $limit; - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? Database::CURSOR_AFTER : Database::CURSOR_BEFORE; - break; - - case Query::TYPE_SELECT: - $selections[] = clone $query; - break; - - default: - $filters[] = clone $query; - break; + if ($direction === null) { + continue; } - } - return [ - 'filters' => $filters, - 'selections' => $selections, - 'limit' => $limit, - 'offset' => $offset, - 'orderAttributes' => $orderAttributes, - 'orderTypes' => $orderTypes, - 'cursor' => $cursor, - 'cursorDirection' => $cursorDirection, - ]; - } - - /** - * Is this query able to contain other queries - * - * @return bool - */ - public function isNested(): bool - { - if (in_array($this->getMethod(), self::LOGICAL_TYPES)) { - return true; + $orderAttributes[] = $query->getAttribute(); + $orderTypes[] = $direction; } - return false; - } - - /** - * @return bool - */ - public function onArray(): bool - { - return $this->onArray; - } - - /** - * @param bool $bool - * @return void - */ - public function setOnArray(bool $bool): void - { - $this->onArray = $bool; - } - - /** - * @param string $type - * @return void - */ - public function setAttributeType(string $type): void - { - $this->attributeType = $type; - } - - /** - * @return string - */ - public function getAttributeType(): string - { - return $this->attributeType; - } - /** - * @return bool - */ - public function isSpatialAttribute(): bool - { - return in_array($this->attributeType, Database::SPATIAL_TYPES); - } - - /** - * @return bool - */ - public function isObjectAttribute(): bool - { - return $this->attributeType === Database::VAR_OBJECT; - } - - // Spatial query methods - - /** - * Helper method to create Query with distanceEqual method - * - * @param string $attribute - * @param array $values - * @param int|float $distance - * @param bool $meters - * @return Query - */ - public static function distanceEqual(string $attribute, array $values, int|float $distance, bool $meters = false): self - { - return new self(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values,$distance,$meters]]); - } - - /** - * Helper method to create Query with distanceNotEqual method - * - * @param string $attribute - * @param array $values - * @param int|float $distance - * @param bool $meters - * @return Query - */ - public static function distanceNotEqual(string $attribute, array $values, int|float $distance, bool $meters = false): self - { - return new self(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values,$distance,$meters]]); - } - - /** - * Helper method to create Query with distanceGreaterThan method - * - * @param string $attribute - * @param array $values - * @param int|float $distance - * @param bool $meters - * @return Query - */ - public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, bool $meters = false): self - { - return new self(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values,$distance, $meters]]); - } - - /** - * Helper method to create Query with distanceLessThan method - * - * @param string $attribute - * @param array $values - * @param int|float $distance - * @param bool $meters - * @return Query - */ - public static function distanceLessThan(string $attribute, array $values, int|float $distance, bool $meters = false): self - { - return new self(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values,$distance,$meters]]); - } - - /** - * Helper method to create Query with intersects method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function intersects(string $attribute, array $values): self - { - return new self(self::TYPE_INTERSECTS, $attribute, [$values]); - } - - /** - * Helper method to create Query with notIntersects method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function notIntersects(string $attribute, array $values): self - { - return new self(self::TYPE_NOT_INTERSECTS, $attribute, [$values]); - } - - /** - * Helper method to create Query with crosses method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function crosses(string $attribute, array $values): self - { - return new self(self::TYPE_CROSSES, $attribute, [$values]); - } - - /** - * Helper method to create Query with notCrosses method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function notCrosses(string $attribute, array $values): self - { - return new self(self::TYPE_NOT_CROSSES, $attribute, [$values]); - } - - /** - * Helper method to create Query with overlaps method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function overlaps(string $attribute, array $values): self - { - return new self(self::TYPE_OVERLAPS, $attribute, [$values]); - } - - /** - * Helper method to create Query with notOverlaps method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function notOverlaps(string $attribute, array $values): self - { - return new self(self::TYPE_NOT_OVERLAPS, $attribute, [$values]); - } - - /** - * Helper method to create Query with touches method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function touches(string $attribute, array $values): self - { - return new self(self::TYPE_TOUCHES, $attribute, [$values]); - } - - /** - * Helper method to create Query with notTouches method - * - * @param string $attribute - * @param array $values - * @return Query - */ - public static function notTouches(string $attribute, array $values): self - { - return new self(self::TYPE_NOT_TOUCHES, $attribute, [$values]); - } - - /** - * Helper method to create Query with vectorDot method - * - * @param string $attribute - * @param array $vector - * @return Query - */ - public static function vectorDot(string $attribute, array $vector): self - { - return new self(self::TYPE_VECTOR_DOT, $attribute, [$vector]); - } - - /** - * Helper method to create Query with vectorCosine method - * - * @param string $attribute - * @param array $vector - * @return Query - */ - public static function vectorCosine(string $attribute, array $vector): self - { - return new self(self::TYPE_VECTOR_COSINE, $attribute, [$vector]); - } - - /** - * Helper method to create Query with vectorEuclidean method - * - * @param string $attribute - * @param array $vector - * @return Query - */ - public static function vectorEuclidean(string $attribute, array $vector): self - { - return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); - } - - /** - * Helper method to create Query with regex method - * - * @param string $attribute - * @param string $pattern - * @return Query - */ - public static function regex(string $attribute, string $pattern): self - { - return new self(self::TYPE_REGEX, $attribute, [$pattern]); + /** @var list $filters */ + $filters = $grouped->filters; + /** @var list $selections */ + $selections = $grouped->selections; + /** @var list $aggregations */ + $aggregations = $grouped->aggregations; + /** @var list $having */ + $having = $grouped->having; + /** @var list $joins */ + $joins = $grouped->joins; + /** @var list $unions */ + $unions = $grouped->unions; + + return new ParsedQuery( + filters: $filters, + selections: $selections, + aggregations: $aggregations, + groupBy: $grouped->groupBy, + having: $having, + distinct: $grouped->distinct, + joins: $joins, + unions: $unions, + limit: $grouped->limit, + offset: $grouped->offset, + cursor: $cursor, + cursorDirection: $grouped->cursorDirection, + timeBuckets: $grouped->timeBuckets, + orderAttributes: $orderAttributes, + orderTypes: $orderTypes, + ); } /** - * Helper method to create Query with exists method + * Check whether this query targets a spatial attribute type (point, linestring, or polygon). * - * @param array $attributes - * @return Query + * @return bool True if the attribute type is spatial. */ - public static function exists(array $attributes): self + public function isSpatialAttribute(): bool { - return new self(self::TYPE_EXISTS, '', $attributes); + $type = ColumnType::tryFrom($this->attributeType); + return in_array($type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true); } /** - * Helper method to create Query with notExists method + * Check whether this query targets an object (JSON/hashmap) attribute type. * - * @param string|int|float|bool|array $attribute - * @return Query + * @return bool True if the attribute type is object. */ - public static function notExists(string|int|float|bool|array $attribute): self - { - return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]); - } - - /** - * @param string $attribute - * @param array $queries - * @return Query - */ - public static function elemMatch(string $attribute, array $queries): self + public function isObjectAttribute(): bool { - return new self(self::TYPE_ELEM_MATCH, $attribute, $queries); + return ColumnType::tryFrom($this->attributeType) === ColumnType::Object; } } diff --git a/src/Database/Relationship.php b/src/Database/Relationship.php new file mode 100644 index 0000000000..9e25d4532f --- /dev/null +++ b/src/Database/Relationship.php @@ -0,0 +1,237 @@ +relatedCollection, + $this->type, + $update->twoWay ?? $this->twoWay, + $update->key ?? $this->key, + $update->twoWayKey ?? $this->twoWayKey, + $update->onDelete ?? $this->onDelete, + ); + } + + public function inverse(string $collection): self + { + return new self($collection, $this->type, $this->twoWay, $this->twoWayKey, $this->key, $this->onDelete); + } + + /** + * @throws RelationshipException + */ + public static function fromDocument(Document $document): self + { + return self::hydrate( + $document->getAttribute(self::RELATED_COLLECTION), + $document->getAttribute(self::RELATION_TYPE), + $document->getAttribute(self::TWO_WAY), + $document->getAttribute(self::KEY), + $document->getAttribute(self::TWO_WAY_KEY), + $document->getAttribute(self::ON_DELETE), + ); + } + + /** + * @param array $data + * + * @throws RelationshipException + */ + public static function fromArray(array $data): self + { + return self::hydrate( + $data[self::RELATED_COLLECTION] ?? null, + $data[self::RELATION_TYPE] ?? null, + $data[self::TWO_WAY] ?? null, + $data[self::KEY] ?? null, + $data[self::TWO_WAY_KEY] ?? null, + $data[self::ON_DELETE] ?? null, + ); + } + + public function toDocument(): Document + { + return new Document($this->fields()); + } + + /** + * @return array + */ + public function toOptions(RelationshipSide $side): array + { + $options = $this->fields(); + unset($options[self::KEY]); + $options[self::SIDE] = $side->value; + + return $options; + } + + /** + * @return array + */ + private function fields(): array + { + return [ + self::RELATED_COLLECTION => $this->relatedCollection, + self::RELATION_TYPE => $this->type->value, + self::TWO_WAY => $this->twoWay, + self::KEY => $this->key, + self::TWO_WAY_KEY => $this->twoWayKey, + self::ON_DELETE => $this->onDelete->value, + ]; + } + + /** + * @throws RelationshipException + */ + private static function hydrate( + mixed $relatedCollection, + mixed $type, + mixed $twoWay, + mixed $key, + mixed $twoWayKey, + mixed $onDelete, + ): self { + if (! \is_string($relatedCollection) || $relatedCollection === '') { + throw new RelationshipException('Relationship has no related collection'); + } + + return new self( + $relatedCollection, + self::hydrateType($type), + (bool) $twoWay, + self::hydrateKey(self::KEY, $key), + self::hydrateKey(self::TWO_WAY_KEY, $twoWayKey), + self::hydrateOnDelete($onDelete), + ); + } + + /** + * @throws RelationshipException + */ + private static function hydrateType(mixed $type): RelationshipType + { + if ($type instanceof RelationshipType) { + return $type; + } + + return (\is_string($type) ? RelationshipType::tryFrom($type) : null) + ?? throw new RelationshipException('Unknown relationship type "'.self::describe($type).'"'); + } + + /** + * @throws RelationshipException + */ + private static function hydrateKey(string $name, mixed $key): ?string + { + if ($key === null || $key === '') { + return null; + } + + if (! \is_string($key)) { + throw new RelationshipException('Relationship '.$name.' must be a string, got '.\get_debug_type($key)); + } + + return $key; + } + + /** + * @throws RelationshipException + */ + private static function hydrateOnDelete(mixed $onDelete): RelationshipDeleteAction + { + if ($onDelete === null) { + return RelationshipDeleteAction::Restrict; + } + + if ($onDelete instanceof RelationshipDeleteAction) { + return $onDelete; + } + + if ($onDelete instanceof ForeignKeyAction) { + $onDelete = $onDelete->value; + } + + return (\is_string($onDelete) ? RelationshipDeleteAction::tryFrom($onDelete) : null) + ?? throw new RelationshipException( + 'Unsupported relationship onDelete action "'.self::describe($onDelete).'"; expected one of: ' + .\implode(', ', \array_column(RelationshipDeleteAction::cases(), 'value')) + ); + } + + private static function describe(mixed $value): string + { + return \is_string($value) ? $value : \get_debug_type($value); + } +} diff --git a/src/Database/RelationshipDeleteAction.php b/src/Database/RelationshipDeleteAction.php new file mode 100644 index 0000000000..56dc3e9e4f --- /dev/null +++ b/src/Database/RelationshipDeleteAction.php @@ -0,0 +1,21 @@ + ForeignKeyAction::Cascade, + self::Restrict => ForeignKeyAction::Restrict, + self::SetNull => ForeignKeyAction::SetNull, + }; + } +} diff --git a/src/Database/RelationshipSide.php b/src/Database/RelationshipSide.php new file mode 100644 index 0000000000..2a7f730ac8 --- /dev/null +++ b/src/Database/RelationshipSide.php @@ -0,0 +1,9 @@ +role; + $string = $this->role; if ($this->identifier) { - $str .= ':' . $this->identifier; + $string .= ':'.$this->identifier; } if ($this->dimension) { - $str .= '/' . $this->dimension; + $string .= '/'.$this->dimension; } - return $str; + + return $string; } - /** - * @return string - */ public function getRole(): string { return $this->role; } - /** - * @return string - */ public function getIdentifier(): string { return $this->identifier; } - /** - * @return string - */ public function getDimension(): string { return $this->dimension; } /** - * Parse a role string into a Role object + * Parse a role string into a Role object. * - * @param string $role - * @return self - * @throws \Exception + * @param string $role The role string to parse (e.g. 'user:123/verified') + * @throws Exception If the dimension format is invalid */ public static function parse(string $role): self { @@ -66,62 +67,63 @@ public static function parse(string $role): self $hasDimension = \str_contains($role, '/'); $role = $roleParts[0]; - if (!$hasIdentifier && !$hasDimension) { + if (! $hasIdentifier && ! $hasDimension) { return new self($role); } - if ($hasIdentifier && !$hasDimension) { + if ($hasIdentifier && ! $hasDimension) { $identifier = $roleParts[1]; + return new self($role, $identifier); } - if (!$hasIdentifier) { + if (! $hasIdentifier) { $dimensionParts = \explode('/', $role); if (\count($dimensionParts) !== 2) { - throw new \Exception('Only one dimension can be provided'); + throw new Exception('Only one dimension can be provided'); } $role = $dimensionParts[0]; $dimension = $dimensionParts[1]; if (empty($dimension)) { - throw new \Exception('Dimension must not be empty'); + throw new Exception('Dimension must not be empty'); } + return new self($role, '', $dimension); } // Has both identifier and dimension $dimensionParts = \explode('/', $roleParts[1]); if (\count($dimensionParts) !== 2) { - throw new \Exception('Only one dimension can be provided'); + throw new Exception('Only one dimension can be provided'); } $identifier = $dimensionParts[0]; $dimension = $dimensionParts[1]; if (empty($dimension)) { - throw new \Exception('Dimension must not be empty'); + throw new Exception('Dimension must not be empty'); } + return new self($role, $identifier, $dimension); } /** - * Create a user role from the given ID + * Create a user role from the given ID. * - * @param string $identifier - * @param string $status - * @return self + * @param string $identifier The user ID + * @param string $status The user status dimension (e.g. 'verified') */ - public static function user(string $identifier, string $status = ''): Role + public static function user(string $identifier, string $status = ''): self { return new self('user', $identifier, $status); } /** - * Create a users role + * Create a users role representing all authenticated users. * - * @param string $status - * @return self + * @param string $status The user status dimension (e.g. 'verified') */ public static function users(string $status = ''): self { @@ -129,11 +131,10 @@ public static function users(string $status = ''): self } /** - * Create a team role from the given ID and dimension + * Create a team role from the given ID and dimension. * - * @param string $identifier - * @param string $dimension - * @return self + * @param string $identifier The team ID + * @param string $dimension The team role dimension (e.g. 'admin', 'member') */ public static function team(string $identifier, string $dimension = ''): self { @@ -141,10 +142,9 @@ public static function team(string $identifier, string $dimension = ''): self } /** - * Create a label role from the given ID + * Create a label role from the given identifier. * - * @param string $identifier - * @return self + * @param string $identifier The label identifier */ public static function label(string $identifier): self { @@ -152,25 +152,26 @@ public static function label(string $identifier): self } /** - * Create an any satisfy role - * - * @return self + * Create a role that matches any user, authenticated or not. */ - public static function any(): Role + public static function any(): self { - return new Role('any'); + return new self('any'); } /** - * Create a guests role - * - * @return self + * Create a role representing unauthenticated guest users. */ public static function guests(): self { return new self('guests'); } + /** + * Create a member role from the given identifier. + * + * @param string $identifier The member ID + */ public static function member(string $identifier): self { return new self('member', $identifier); diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php new file mode 100644 index 0000000000..65e749f879 --- /dev/null +++ b/src/Database/Schema/Column.php @@ -0,0 +1,23 @@ + $columns The indexed columns, in index order + * @param list $lengths The prefix length of each column; null where the whole value is indexed or the + * engine reports no prefix lengths, so null is never compared + */ + public function __construct( + public string $name, + public IndexType $type, + public array $columns, + public array $lengths, + ) { + } +} diff --git a/src/Database/SetType.php b/src/Database/SetType.php new file mode 100644 index 0000000000..ef8ea0b406 --- /dev/null +++ b/src/Database/SetType.php @@ -0,0 +1,13 @@ + The values written by coroutines that inherited this override, by coroutine id + */ + public array $writes = []; + + /** + * @param int $coroutine The coroutine that opened the override or kept the write, or -1 outside coroutines + * @param T $value + * @param Frame|null $outer The override it is nested in, opened by the same coroutine + */ + public function __construct( + public readonly int $coroutine, + public mixed $value, + public readonly ?Frame $outer, + ) { + } +} diff --git a/src/Database/State/Group.php b/src/Database/State/Group.php new file mode 100644 index 0000000000..c4183f8c96 --- /dev/null +++ b/src/Database/State/Group.php @@ -0,0 +1,23 @@ + $roles The roles authorization checks against + * @param bool $relationships Whether relationships are populated and written + * @param bool $existCheck Whether related documents must exist before they are linked + * @param bool $population Whether a relationship population is already running + * @param bool $silenced Whether every lifecycle hook is silenced + * @param array $silencedListeners Names of the silenced named lifecycle hooks + * @param int|string|null $tenant The tenant reads and writes use + * @param bool $filters Whether attribute filters apply + * @param array|null $disabledFilters Names of the attribute filters that do not apply + * @param bool $validation Whether documents and queries are validated + * @param bool $preserveDates Whether writes keep the dates they are given + * @param bool $preserveSequence Whether writes keep the sequences they are given + * @param bool $ignoreDuplicates Whether creating a document that exists is skipped instead of failing + * @param DateTime|null $requestTimestamp The time an update conflicts after + */ + public function __construct( + public bool $authorization, + public array $roles, + public bool $relationships, + public bool $existCheck, + public bool $population, + public bool $silenced, + public array $silencedListeners, + public int|string|null $tenant, + public bool $filters, + public ?array $disabledFilters, + public bool $validation, + public bool $preserveDates, + public bool $preserveSequence, + public bool $ignoreDuplicates, + public ?DateTime $requestTimestamp, + ) { + } +} diff --git a/src/Database/State/Value.php b/src/Database/State/Value.php new file mode 100644 index 0000000000..c23b24c9a5 --- /dev/null +++ b/src/Database/State/Value.php @@ -0,0 +1,219 @@ +> The innermost open override of each coroutine that has one, by coroutine id + */ + private array $scopes = []; + + private int $open = 0; + + /** + * @param T $value + */ + public function __construct( + private mixed $value, + private readonly Group $group = new Group(), + ) { + self::$coroutines ??= \extension_loaded('swoole'); + } + + /** + * @return T + */ + public function get(): mixed + { + if ($this->open === 0) { + return $this->value; + } + + /** @var int $reader */ + $reader = self::$coroutines ? Coroutine::getCid() : self::OUTSIDE; + $coroutine = $reader; + while (! isset($this->scopes[$coroutine])) { + if ($coroutine === self::OUTSIDE) { + return $this->value; + } + + /** @var int|false $parent */ + $parent = Coroutine::getPcid($coroutine); + $coroutine = $parent === false ? self::OUTSIDE : $parent; + } + + $scope = $this->scopes[$coroutine]; + if ($scope->writes === [] || $coroutine === $reader) { + return $scope->value; + } + + return self::inherited($scope, $reader); + } + + /** + * @param T $value + */ + public function set(mixed $value): void + { + if ($this->group->open === 0) { + $this->value = $value; + + return; + } + + $writer = self::coroutine(); + $coroutine = $writer; + $detached = false; + while (! isset($this->scopes[$coroutine])) { + if ($coroutine === self::OUTSIDE) { + if ($detached && $this->group->overrides > 0) { + $this->detach($writer, $value); + } else { + $this->value = $value; + } + + return; + } + + /** @var int|false $parent */ + $parent = Coroutine::getPcid($coroutine); + if ($parent === false) { + $detached = true; + $coroutine = self::OUTSIDE; + } else { + $coroutine = $parent; + } + } + + $scope = $this->scopes[$coroutine]; + if ($coroutine === $writer) { + $scope->value = $value; + + return; + } + + if (! \array_key_exists($writer, $scope->writes)) { + Coroutine::defer(static function () use ($scope, $writer): void { + unset($scope->writes[$writer]); + }); + } + + $scope->writes[$writer] = $value; + } + + /** + * Run the callback with the value overridden for the calling coroutine and the coroutines it starts. + * + * @template R + * + * @param T $value + * @param callable(): R $callback + * @return R + */ + public function with(mixed $value, callable $callback): mixed + { + $coroutine = self::coroutine(); + $scope = new Frame($coroutine, $value, $this->scopes[$coroutine] ?? null); + $this->scopes[$coroutine] = $scope; + $this->open++; + $this->group->open++; + $this->group->overrides++; + + try { + return $callback(); + } finally { + $this->open--; + $this->group->open--; + $this->group->overrides--; + + if ($scope->outer === null) { + unset($this->scopes[$coroutine]); + } else { + $this->scopes[$coroutine] = $scope->outer; + } + } + } + + /** + * The value the reader sees through an override it inherited: the nearest write by the reader or an ancestor + * below the override's owner, else the override's value. + * + * @param Frame $scope + * @return T + */ + private static function inherited(Frame $scope, int $reader): mixed + { + for ($coroutine = $reader; $coroutine !== $scope->coroutine; $coroutine = self::parent($coroutine)) { + if (\array_key_exists($coroutine, $scope->writes)) { + return $scope->writes[$coroutine]; + } + } + + return $scope->value; + } + + /** + * Keep the write of a coroutine that cannot reach the open overrides as its own scope until it ends. + * + * @param T $value + */ + private function detach(int $writer, mixed $value): void + { + $scope = new Frame($writer, $value, null); + + Coroutine::defer(function () use ($writer, $scope): void { + if (($this->scopes[$writer] ?? null) === $scope) { + unset($this->scopes[$writer]); + } + + $this->open--; + $this->group->open--; + }); + + $this->scopes[$writer] = $scope; + $this->open++; + $this->group->open++; + } + + private static function coroutine(): int + { + /** @var int $coroutine */ + $coroutine = self::$coroutines ? Coroutine::getCid() : self::OUTSIDE; + + return $coroutine; + } + + private static function parent(int $coroutine): int + { + /** @var int|false $parent */ + $parent = Coroutine::getPcid($coroutine); + + return $parent === false ? self::OUTSIDE : $parent; + } +} diff --git a/src/Database/Storage.php b/src/Database/Storage.php new file mode 100644 index 0000000000..d9622270bc --- /dev/null +++ b/src/Database/Storage.php @@ -0,0 +1,97 @@ + + */ + private const array ATTRIBUTE_MAP = [ + Document::ID => self::UID, + Document::SEQUENCE => self::SEQUENCE, + Document::COLLECTION => self::COLLECTION, + Document::TENANT => self::TENANT, + Document::CREATED_AT => self::CREATED_AT, + Document::UPDATED_AT => self::UPDATED_AT, + Document::DELETED_AT => self::DELETED_AT, + Document::PERMISSIONS => self::PERMISSIONS, + Document::DISTANCE => self::DISTANCE, + ]; + + private function __construct() + { + } + + public static function column(string $attribute): string + { + return self::ATTRIBUTE_MAP[$attribute] ?? $attribute; + } + + public static function attribute(string $column): string + { + return self::columnMap()[$column] ?? $column; + } + + /** + * @return array + */ + public static function attributeMap(): array + { + return self::ATTRIBUTE_MAP; + } + + /** + * @return array + */ + public static function columnMap(): array + { + /** @var array $columnMap */ + static $columnMap = \array_flip(self::ATTRIBUTE_MAP); + + return $columnMap; + } + + public static function permissionsTable(string $collection): string + { + return $collection.self::PERMISSIONS_TABLE_SUFFIX; + } +} diff --git a/src/Database/Trait/Attributes.php b/src/Database/Trait/Attributes.php new file mode 100644 index 0000000000..130a0dec90 --- /dev/null +++ b/src/Database/Trait/Attributes.php @@ -0,0 +1,990 @@ +silent(fn () => $this->getCollection($collection)); + $attribute = self::normalise($attribute); + + $schemaAttributes = $this->getSchemaAttributes($definition->getId()); + + $existsInSchema = false; + + try { + $this->validateAttribute($definition, $attribute, $schemaAttributes); + } catch (DuplicateException $error) { + $existsInSchema = $this->reconcileSchemaOnlyColumn($definition, $attribute, $schemaAttributes, $error); + } + + $created = ! $existsInSchema && $this->createAttributeInSchema($definition->getId(), $attribute); + + $definition->setAttribute(self::COLLECTION_ATTRIBUTES, $attribute->toDocument(), SetType::Append); + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->cleanupAttribute($definition->getId(), $attribute->key), + shouldRollback: $created, + operationDescription: "attribute creation '{$attribute->key}'" + ); + + $this->purgeCollectionCaches($definition->getId()); + + $listeners = $this->listens(Event::AttributeCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Attribute\Created($definition->getId(), $attribute), $listeners); + } + + return $attribute; + } + + /** + * @param list $attributes + * @return list The attributes as stored + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws LimitException + * @throws NotFoundException + * @throws RefusedException When the adapter does not create the columns + * @throws StructureException + * @throws Exception + */ + public function createAttributes(string $collection, array $attributes): array + { + if ($attributes === []) { + throw new DatabaseException('No attributes to create'); + } + + $definition = $this->silent(fn () => $this->getCollection($collection)); + + $schemaAttributes = $this->getSchemaAttributes($definition->getId()); + + $stored = []; + $toCreate = []; + foreach ($attributes as $attribute) { + if ($attribute->key === '') { + throw new DatabaseException('Missing attribute key'); + } + + $attribute = self::normalise($attribute); + $existsInSchema = false; + + try { + $this->validateAttribute($definition, $attribute, $schemaAttributes); + } catch (DuplicateException $error) { + $existsInSchema = $this->reconcileSchemaOnlyColumn($definition, $attribute, $schemaAttributes, $error); + } + + $stored[] = $attribute; + if (! $existsInSchema) { + $toCreate[] = $attribute; + } + } + + $created = $toCreate === [] ? [] : $this->createAttributesInSchema($definition->getId(), $toCreate); + + foreach ($stored as $attribute) { + $definition->setAttribute(self::COLLECTION_ATTRIBUTES, $attribute->toDocument(), SetType::Append); + } + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->cleanupAttributes($definition->getId(), $created), + shouldRollback: $created !== [], + operationDescription: 'attributes creation', + rollbackReturnsErrors: true + ); + + $this->purgeCollectionCaches($definition->getId()); + + $listeners = $this->listens(Event::AttributeCreate); + if ($listeners !== []) { + foreach ($stored as $attribute) { + $this->dispatch(new Event\Attribute\Created($definition->getId(), $attribute), $listeners); + } + } + + $listeners = $this->listens(Event::AttributesCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Attribute\BatchCreated($definition->getId(), $stored), $listeners); + } + + return $stored; + } + + /** + * Applies a sparse update: a null field keeps its value and `default: null` clears the default. An explicit + * `required: true` clears the default; a default on an attribute that is or becomes required is refused. + * `required: false` relaxes the column's NOT NULL. + * + * @return Attribute The attribute as stored + * + * @throws DatabaseException + * @throws DependencyException + * @throws DuplicateException + * @throws IndexException + * @throws LimitException + * @throws NotFoundException + * @throws RefusedException When the adapter does not alter the column + * @throws StructureException + * @throws Exception + */ + public function updateAttribute(string $collection, string $key, AttributeUpdate $update): Attribute + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + + if ($definition->getId() === self::METADATA) { + throw new DatabaseException('Cannot update metadata attributes'); + } + + $attributes = $definition->attributes(); + $position = self::attributePosition($attributes, $key); + + if ($position === null) { + throw new NotFoundException('Attribute not found'); + } + + $stored = $attributes[$position]; + + if ($stored->type === ColumnType::Relationship) { + throw new DatabaseException('Cannot update relationship as an attribute'); + } + + if ($update->isEmpty()) { + return $stored; + } + + $required = $update->required ?? $stored->required; + if ($required && $update->changesDefault() && $update->default !== null) { + throw new DatabaseException('Cannot set a default value on a required attribute'); + } + + $updated = $stored->apply($update); + if ($update->required === true && $updated->default !== null) { + $updated = $updated->apply(new AttributeUpdate(default: null)); + } + + $newKey = $updated->key; + $renaming = $newKey !== $key; + if ($renaming && self::attributePosition($attributes, $newKey) !== null) { + throw new DuplicateException('Attribute name already used'); + } + + $this->validateAttributeUpdate($updated, $update->size ?? $stored->size ?? 0, $update->array ?? $stored->array); + + $altering = $update->type !== null + || $update->size !== null + || $update->signed !== null + || $update->array !== null + || $update->key !== null + || ($updated->isSpatial() && ! $this->adapter->supports(Capability::IndexSpatialNull)); + + $originalIndexes = $definition->indexes(); + $attributes = self::replacing($attributes, $key, $updated); + $indexes = $renaming ? self::renameIndexedAttribute($originalIndexes, $key, $newKey) : $originalIndexes; + + $this->writeAttributes($definition, $attributes); + if ($renaming) { + $this->writeIndexes($definition, $indexes); + } + + if ( + $this->adapter->limits()->documentSize > 0 && + $this->adapter->getAttributeWidth($definition) >= $this->adapter->limits()->documentSize + ) { + throw new LimitException('Row width limit reached. Cannot update attribute.'); + } + + if ($updated->isSpatial() && ! $this->adapter->supports(Capability::IndexSpatialNull)) { + $this->assertSpatialIndexesRequired($attributes, $indexes); + } + + $updatedInSchema = false; + + if ($altering) { + if ($renaming) { + $validator = new IndexDependencyValidator( + $indexes, + $this->adapter->supports(Capability::IndexArrayCast), + ); + + if (! $validator->isValid($updated)) { + throw new DependencyException($validator->getDescription()); + } + } + + if ($this->validation()->get()) { + $validator = $this->indexValidator($attributes, $originalIndexes); + + foreach ($indexes as $index) { + if (! $validator->isValid($index)) { + throw new IndexException($validator->getDescription()); + } + } + } + + $updatedInSchema = $this->adapter->updateAttribute($definition->getId(), $key, $updated); + + if (! $updatedInSchema) { + throw new RefusedException('Failed to update attribute'); + } + } elseif ($stored->required && ! $updated->required) { + if (! $this->adapter->relaxAttributeRequired($definition->getId(), $key)) { + throw new RefusedException('Failed to update attribute'); + } + } + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->adapter->updateAttribute($definition->getId(), $newKey, $stored), + shouldRollback: $updatedInSchema, + operationDescription: "attribute update '{$key}'", + silentRollback: true + ); + + if ($altering) { + $this->withRetries(fn () => $this->purgeCachedCollection($definition->getId())); + } + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $definition->getId())); + + $listeners = $this->listens(Event::DocumentPurge); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Purged(self::METADATA, $definition->getId()), $listeners); + } + + $listeners = $this->listens(Event::AttributeUpdate); + if ($listeners !== []) { + $this->dispatch(new Event\Attribute\Updated($definition->getId(), $updated), $listeners); + } + + return $updated; + } + + /** + * Checks that the attribute can be added to the collection without exceeding its limits. + * + * @throws LimitException + * @throws NotFoundException + */ + public function checkAttribute(string $collection, Attribute $attribute): bool + { + $definition = clone ($this->silent(fn () => $this->getCollection($collection))); + + $definition->setAttribute(self::COLLECTION_ATTRIBUTES, $attribute->toDocument(), SetType::Append); + + if ( + $this->adapter->limits()->attributes > 0 && + $this->adapter->getCountOfAttributes($definition) > $this->adapter->limits()->attributes + ) { + throw new LimitException('Column limit reached. Cannot create new attribute. Current attribute count is '.$this->adapter->getCountOfAttributes($definition).' but the maximum is '.$this->adapter->limits()->attributes.'. Remove some attributes to free up space.'); + } + + if ( + $this->adapter->limits()->documentSize > 0 && + $this->adapter->getAttributeWidth($definition) >= $this->adapter->limits()->documentSize + ) { + throw new LimitException('Row width limit reached. Cannot create new attribute. Current row width is '.$this->adapter->getAttributeWidth($definition).' bytes but the maximum is '.$this->adapter->limits()->documentSize.' bytes. Reduce the size of existing attributes or remove some attributes to free up space.'); + } + + return true; + } + + /** + * @throws ConflictException + * @throws DatabaseException + * @throws DependencyException + * @throws NotFoundException + * @throws RefusedException When the adapter does not drop the column + */ + public function deleteAttribute(string $collection, string $key): void + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + $attributes = $definition->attributes(); + $position = self::attributePosition($attributes, $key); + + if ($position === null) { + throw new NotFoundException('Attribute not found'); + } + + $attribute = $attributes[$position]; + + if ($attribute->type === ColumnType::Relationship) { + throw new DatabaseException('Cannot delete relationship as an attribute'); + } + + $indexes = $definition->indexes(); + + if ($this->validation()->get()) { + $validator = new IndexDependencyValidator( + $indexes, + $this->adapter->supports(Capability::IndexArrayCast), + ); + + if (! $validator->isValid($attribute)) { + throw new DependencyException($validator->getDescription()); + } + } + + unset($attributes[$position]); + $this->writeAttributes($definition, \array_values($attributes)); + $this->writeIndexes($definition, self::withoutIndexedAttribute($indexes, $key)); + + $deletedInSchema = $this->deleteAttributeFromSchema($definition->getId(), $key); + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->adapter->createAttribute($definition->getId(), $attribute), + shouldRollback: $deletedInSchema, + operationDescription: "attribute deletion '{$key}'", + silentRollback: true + ); + + $this->purgeCollectionCaches($definition->getId()); + + $listeners = $this->listens(Event::AttributeDelete); + if ($listeners !== []) { + $this->dispatch(new Event\Attribute\Deleted($definition->getId(), $attribute), $listeners); + } + } + + /** + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DependencyException + * @throws DuplicateException + * @throws NotFoundException + * @throws RefusedException When the adapter does not rename the column + * @throws StructureException + */ + public function renameAttribute(string $collection, string $old, string $new): void + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + $attributes = $definition->attributes(); + $position = self::attributePosition($attributes, $old); + + if (self::attributePosition($attributes, $new) !== null) { + throw new DuplicateException('Attribute name already used'); + } + + if ($position === null) { + throw new NotFoundException('Attribute not found'); + } + + $indexes = $definition->indexes(); + + if ($this->validation()->get()) { + $validator = new IndexDependencyValidator( + $indexes, + $this->adapter->supports(Capability::IndexArrayCast), + ); + + if (! $validator->isValid($attributes[$position])) { + throw new DependencyException($validator->getDescription()); + } + } + + $renamed = $attributes[$position]->apply(new AttributeUpdate(key: $new)); + $attributes = self::replacing($attributes, $old, $renamed); + + try { + $renamedInSchema = $this->adapter->renameAttribute($definition->getId(), $old, $new); + } catch (DuplicateException|NotFoundException $error) { + throw $error; + } catch (Throwable $error) { + throw new DatabaseException("Failed to rename attribute '{$old}' to '{$new}': ".$error->getMessage(), previous: $error); + } + + if (! $renamedInSchema) { + throw new RefusedException("Failed to rename attribute '{$old}' to '{$new}'"); + } + + $this->writeAttributes($definition, $attributes); + $this->writeIndexes($definition, self::renameIndexedAttribute($indexes, $old, $new)); + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->adapter->renameAttribute($definition->getId(), $new, $old), + shouldRollback: true, + operationDescription: "attribute rename '{$old}' to '{$new}'" + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($definition->getId())); + + $listeners = $this->listens(Event::AttributeRename); + if ($listeners !== []) { + $this->dispatch(new Event\Attribute\Renamed($definition->getId(), $old, $renamed), $listeners); + } + } + + /** + * @return bool True when this call created the column, false when the schema already held it + * + * @throws MismatchException When the schema holds the column with another type + * @throws RefusedException When the adapter does not create the column + */ + private function createAttributeInSchema(string $collection, Attribute $attribute): bool + { + try { + $created = $this->adapter->createAttribute($collection, $attribute); + } catch (MismatchException $error) { + throw $error; + } catch (DuplicateException) { + // The column exists only in the physical schema (the metadata check passed), so the metadata is + // written for it. + return false; + } + + if (! $created) { + throw new RefusedException('Failed to create attribute'); + } + + return true; + } + + /** + * @param non-empty-list $attributes + * @return list The attributes this call created, without those the schema already held + * + * @throws MismatchException When the schema holds one of the columns with another type + * @throws RefusedException When the adapter does not create the columns + */ + private function createAttributesInSchema(string $collection, array $attributes): array + { + try { + $created = $this->adapter->createAttributes($collection, $attributes); + } catch (MismatchException $error) { + throw $error; + } catch (DuplicateException) { + // At least one column already exists, so each is created on its own and the duplicates are skipped. + $created = []; + try { + foreach ($attributes as $attribute) { + if ($this->createAttributeInSchema($collection, $attribute)) { + $created[] = $attribute; + } + } + } catch (Throwable $error) { + $this->cleanupAttributes($collection, $created); + + throw $error; + } + + return $created; + } + + if (! $created) { + throw new RefusedException('Failed to create attributes'); + } + + return $attributes; + } + + /** + * @return bool True when this call dropped the column, false when the schema no longer held it + * + * @throws RefusedException When the adapter does not drop the column + */ + private function deleteAttributeFromSchema(string $collection, string $key): bool + { + try { + $deleted = $this->adapter->deleteAttribute($collection, $key); + } catch (NotFoundException) { + // Already absent from the schema; the metadata is still removed. + return false; + } + + if (! $deleted) { + throw new RefusedException('Failed to delete attribute'); + } + + return true; + } + + /** + * A column in the schema but not in this collection's metadata is reused when its type + * matches the request, and dropped to be recreated otherwise. Under shared tables it + * belongs to another tenant's collection, so a mismatch is refused instead. + * + * @param list $schemaAttributes + * @return bool True when the existing column is reused + * + * @throws DuplicateException + */ + private function reconcileSchemaOnlyColumn( + Collection $definition, + Attribute $attribute, + array $schemaAttributes, + DuplicateException $duplicate, + ): bool { + $key = \strtolower($attribute->key); + foreach ($definition->attributes() as $existing) { + if (\strtolower($existing->key) === $key) { + throw $duplicate; + } + } + + $expected = $this->adapter->getColumnType($attribute); + if ($expected === null) { + return true; + } + + $filteredId = \strtolower($this->adapter->filter($attribute->key)); + foreach ($schemaAttributes as $column) { + if (\strtolower($column->name) !== $filteredId) { + continue; + } + + if ($column->type === $expected) { + return true; + } + + if ($this->hasSharedTables()) { + throw new DuplicateException('Attribute exists in the shared table with another type', previous: $duplicate); + } + + $this->adapter->deleteAttribute($definition->getId(), $attribute->key); + + return false; + } + + return true; + } + + /** + * @param list $schemaAttributes + * + * @throws DuplicateException + * @throws LimitException + * @throws Exception + */ + private function validateAttribute(Collection $definition, Attribute $attribute, array $schemaAttributes): void + { + $withAttribute = clone $definition; + $withAttribute->setAttribute(self::COLLECTION_ATTRIBUTES, $attribute->toDocument(), SetType::Append); + + $validator = new AttributeDefinition( + attributes: $definition->attributes(), + profile: $this->profile(), + schemaAttributes: $schemaAttributes, + attributeCount: fn (): int => $this->adapter->getCountOfAttributes($withAttribute), + attributeWidth: fn (): int => $this->adapter->getAttributeWidth($withAttribute), + filter: $this->adapter->filter(...), + ); + + $validator->isValid($attribute); + } + + /** + * Checks the updated attribute against the adapter. Size and array are the requested values, since + * the model normalises them away for types that take neither. + * + * @throws DatabaseException + */ + private function validateAttributeUpdate(Attribute $attribute, int $size, bool $array): void + { + switch ($attribute->type) { + case ColumnType::String: + if ($size === 0) { + throw new DatabaseException('Size length is required'); + } + if ($size > $this->adapter->limits()->string) { + throw new DatabaseException('Max size allowed for string is: '.\number_format($this->adapter->limits()->string)); + } + break; + + case ColumnType::Varchar: + if ($size === 0) { + throw new DatabaseException('Size length is required'); + } + if ($size > $this->adapter->limits()->varchar) { + throw new DatabaseException('Max size allowed for varchar is: '.\number_format($this->adapter->limits()->varchar)); + } + break; + + case ColumnType::Integer: + $limit = $attribute->signed ? $this->adapter->limits()->integer / 2 : $this->adapter->limits()->integer; + if ($size > $limit) { + throw new DatabaseException('Max size allowed for int is: '.\number_format($limit)); + } + break; + + case ColumnType::Float: + case ColumnType::Double: + case ColumnType::Boolean: + case ColumnType::Datetime: + if ($size !== 0) { + throw new DatabaseException('Size must be empty'); + } + break; + + case ColumnType::Object: + if (! $this->adapter->supports(Capability::Objects)) { + throw new DatabaseException('Object attributes are not supported'); + } + if ($size !== 0) { + throw new DatabaseException('Size must be empty for object attributes'); + } + if ($array) { + throw new DatabaseException('Object attributes cannot be arrays'); + } + break; + + case ColumnType::Point: + case ColumnType::Linestring: + case ColumnType::Polygon: + if (! $this->adapter->hasFeature(Feature\Spatial::class)) { + throw new DatabaseException('Spatial attributes are not supported'); + } + if ($size !== 0) { + throw new DatabaseException('Size must be empty for spatial attributes'); + } + if ($array) { + throw new DatabaseException('Spatial attributes cannot be arrays'); + } + break; + + case ColumnType::Vector: + if (! $this->adapter->supports(Capability::Vectors)) { + throw new DatabaseException('Vector types are not supported by the current database'); + } + if ($array) { + throw new DatabaseException('Vector type cannot be an array'); + } + if ($size <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } + if ($size > self::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed '.self::MAX_VECTOR_DIMENSIONS); + } + if ($attribute->default !== null) { + if (! \is_array($attribute->default)) { + throw new DatabaseException('Vector default value must be an array'); + } + if (\count($attribute->default) !== $size) { + throw new DatabaseException('Vector default value must have exactly '.$size.' elements'); + } + foreach ($attribute->default as $component) { + if (! \is_int($component) && ! \is_float($component)) { + throw new DatabaseException('Vector default value must contain only numeric elements'); + } + } + } + break; + + default: + break; + } + + if ($attribute->format !== null && ! Structure::hasFormat($attribute->format->name, $attribute->type)) { + throw new DatabaseException('Format ("'.$attribute->format->name.'") not available for this attribute type ("'.$attribute->type->value.'")'); + } + + if ($attribute->default !== null) { + $this->validateDefaultTypes($attribute->type, $attribute->default, $attribute->signed); + } + } + + /** + * @throws DatabaseException + */ + protected function validateDefaultTypes(ColumnType $type, mixed $default, bool $signed = true): void + { + if ($default === null) { + return; + } + + if (\is_array($default)) { + if ($type === ColumnType::Point || $type === ColumnType::Linestring || $type === ColumnType::Polygon) { + $spatial = new SpatialValidator($type->value); + if (! $spatial->isValid($default)) { + throw new DatabaseException('Invalid default value: '.$spatial->getDescription()); + } + + return; + } + + if ($type !== ColumnType::Object) { + foreach ($default as $value) { + $this->validateDefaultTypes($type, $value, $signed); + } + } + + return; + } + + $matches = match ($type) { + ColumnType::String, ColumnType::Varchar, ColumnType::Text, ColumnType::MediumText, ColumnType::LongText, ColumnType::Datetime => \is_string($default), + ColumnType::Integer => \is_int($default), + ColumnType::Boolean => \is_bool($default), + ColumnType::BigInteger => (new BigInt($signed, $this->adapter->supports(Capability::UnsignedBigInt)))->isValid($default), + ColumnType::Float, ColumnType::Double => \is_float($default), + ColumnType::Vector => \is_int($default) || \is_float($default), + default => false, + }; + + if ($matches) { + return; + } + + if ($type === ColumnType::Vector) { + throw new DatabaseException('Vector components must be numeric values (float or integer)'); + } + + $value = \is_scalar($default) ? (string) $default : '[non-scalar]'; + + throw new DatabaseException('Default value '.$value.' does not match given type '.Attribute::storedType($type)); + } + + private function typeValidator(): AttributeDefinition + { + return new AttributeDefinition([], $this->profile()); + } + + /** + * @param list $attributes + * @param list $indexes + */ + private function indexValidator(array $attributes, array $indexes): IndexDefinition + { + return new IndexDefinition($attributes, $indexes, $this->profile()); + } + + /** + * Engines without nullable spatial indexes need every attribute a spatial index covers to be required. + * + * @param list $attributes + * @param list $indexes + * + * @throws IndexException + */ + private function assertSpatialIndexesRequired(array $attributes, array $indexes): void + { + $byKey = []; + foreach ($attributes as $attribute) { + $byKey[\strtolower($attribute->key)] = $attribute; + } + + foreach ($indexes as $index) { + if ($index->type !== IndexType::Spatial) { + continue; + } + + foreach ($index->attributes as $key) { + $attribute = $byKey[\strtolower($key)] ?? null; + if ($attribute !== null && $attribute->isSpatial() && ! $attribute->required) { + throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "'.$key.'" as required or create the index on a column with no null values.'); + } + } + } + } + + /** + * @param list $attributes + */ + private static function attributePosition(array $attributes, string $key): ?int + { + foreach ($attributes as $position => $attribute) { + if ($attribute->key === $key) { + return $position; + } + } + + return null; + } + + /** + * The attribute with every field normalised for its type, as it is stored. + * + * @throws StructureException + */ + private static function normalise(Attribute $attribute): Attribute + { + return $attribute->apply(new AttributeUpdate()); + } + + /** + * @param list $attributes + * @return list + */ + private static function replacing(array $attributes, string $key, Attribute $replacement): array + { + return \array_map( + static fn (Attribute $attribute): Attribute => $attribute->key === $key ? $replacement : $attribute, + $attributes, + ); + } + + /** + * @param list $indexes + * @return list + * + * @throws IndexException + */ + private static function renameIndexedAttribute(array $indexes, string $old, string $new): array + { + $renamed = []; + foreach ($indexes as $index) { + $renamed[] = \in_array($old, $index->attributes, true) + ? self::withIndexedAttributes($index, \array_map( + static fn (string $attribute): string => $attribute === $old ? $new : $attribute, + $index->attributes, + )) + : $index; + } + + return $renamed; + } + + /** + * Drops the attribute from every index on it, and every index left on no attribute. + * + * @param list $indexes + * @return list + * + * @throws IndexException + */ + private static function withoutIndexedAttribute(array $indexes, string $key): array + { + $remaining = []; + foreach ($indexes as $index) { + if (! \in_array($key, $index->attributes, true)) { + $remaining[] = $index; + continue; + } + + $attributes = \array_values(\array_filter( + $index->attributes, + static fn (string $attribute): bool => $attribute !== $key, + )); + + if ($attributes !== []) { + $remaining[] = self::withIndexedAttributes($index, $attributes); + } + } + + return $remaining; + } + + /** + * @param list $attributes + * + * @throws IndexException + */ + private static function withIndexedAttributes(Index $index, array $attributes): Index + { + return Index::fromDocument($index->toDocument()->setAttribute(self::INDEX_ATTRIBUTES, $attributes)); + } + + /** + * @param list $attributes + */ + private function writeAttributes(Collection $definition, array $attributes): void + { + $definition->setAttribute( + self::COLLECTION_ATTRIBUTES, + \array_map(static fn (Attribute $attribute): Document => $attribute->toDocument(), $attributes), + ); + } + + /** + * @param list $indexes + */ + private function writeIndexes(Collection $definition, array $indexes): void + { + $definition->setAttribute( + self::COLLECTION_INDEXES, + \array_map(static fn (Index $index): Document => $index->toDocument(), $indexes), + ); + } + + private function purgeCollectionCaches(string $collection): void + { + $this->withRetries(fn () => $this->purgeCachedCollection($collection)); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection)); + + $listeners = $this->listens(Event::DocumentPurge); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Purged(self::METADATA, $collection), $listeners); + } + } + + /** + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupAttribute(string $collection, string $key, int $maxAttempts = 3): void + { + $this->cleanup( + fn () => $this->adapter->deleteAttribute($collection, $key), + 'attribute', + $key, + $maxAttempts + ); + } + + /** + * @param list $attributes + * @return list The errors of the cleanups that failed + */ + private function cleanupAttributes(string $collection, array $attributes, int $maxAttempts = 3): array + { + $errors = []; + + foreach ($attributes as $attribute) { + try { + $this->cleanupAttribute($collection, $attribute->key, $maxAttempts); + } catch (Exception $error) { + $errors[] = $error->getMessage(); + } + } + + return $errors; + } +} diff --git a/src/Database/Trait/Collections.php b/src/Database/Trait/Collections.php new file mode 100644 index 0000000000..ee06d09b55 --- /dev/null +++ b/src/Database/Trait/Collections.php @@ -0,0 +1,526 @@ +getArrayCopy(), self::COLLECTION_RESERVED_KEYS); + if ($unknown !== []) { + throw new StructureException('Unknown collection keys: '.\implode(', ', \array_keys($unknown))); + } + + $id = $collection->getId(); + $attributes = \array_map(self::normalise(...), $collection->attributes()); + $permissions = $collection->declaredPermissions() ?? [Permission::create(Role::any())]; + + $typeValidator = $this->typeValidator(); + foreach ($attributes as $attribute) { + $typeValidator->checkType($attribute); + } + + if ($this->validation()->get()) { + $validator = new Permissions(); + if (! $validator->isValid($permissions)) { + throw new DatabaseException($validator->getDescription()); + } + } + + if ($id !== self::METADATA && $this->silent(fn () => $this->findCollection($id)) !== null) { + throw new DuplicateException('Collection '.$id.' already exists'); + } + + $indexes = $collection->indexes(); + + if ($this->validation()->get() && $this->adapter->supports(Capability::IndexTtl)) { + $ttlIndexes = \array_filter($indexes, static fn (Index $index): bool => $index->type === IndexType::Ttl); + if (\count($ttlIndexes) > 1) { + throw new IndexException('There can be only one TTL index in a collection'); + } + } + + $indexes = \array_map(fn (Index $index): Index => $this->fitIndexToAttributes($index, $attributes), $indexes); + + $definition = Collection::create( + id: $id, + name: $collection->name(), + attributes: $attributes, + indexes: $indexes, + permissions: $permissions, + documentSecurity: $collection->documentSecurity(), + ); + + if ($this->validation()->get()) { + $validator = new IndexDefinition($attributes, [], $this->profile()); + foreach ($indexes as $index) { + if (! $validator->isValid($index)) { + throw new IndexException($validator->getDescription()); + } + } + } + + if ($indexes !== [] && $this->adapter->getCountOfIndexes($definition) > $this->adapter->limits()->indexes) { + throw new LimitException('Index limit of '.$this->adapter->limits()->indexes.' exceeded. Cannot create collection.'); + } + + if ($attributes !== []) { + if ( + $this->adapter->limits()->attributes > 0 && + $this->adapter->getCountOfAttributes($definition) > $this->adapter->limits()->attributes + ) { + throw new LimitException('Attribute limit of '.$this->adapter->limits()->attributes.' exceeded. Cannot create collection.'); + } + + if ( + $this->adapter->limits()->documentSize > 0 && + $this->adapter->getAttributeWidth($definition) > $this->adapter->limits()->documentSize + ) { + throw new LimitException('Document size limit of '.$this->adapter->limits()->documentSize.' exceeded. Cannot create collection.'); + } + } + + $created = $this->createCollectionInSchema($id, $attributes, $indexes); + + if ($id === self::METADATA) { + return self::collectionDefinition(); + } + + try { + $stored = $this->silent(fn () => $this->createDocument(self::METADATA, $definition)); + } catch (DuplicateException $error) { + // A concurrent creator committed the metadata for this id first, so + // the physical table is the one its metadata describes. Rolling back + // here would drop a live collection out from under it. + $this->purgeStaleCollectionCache($id); + throw new DuplicateException('Collection '.$id.' already exists', previous: $error); + } catch (Throwable $error) { + if ($this->mayHaveCommitted($error)) { + throw $error; + } + + if ($created && ! $this->adapter->hasSharedTables()) { + try { + $this->cleanupCollection($id); + } catch (Throwable $cleanupError) { + Console::error("Failed to rollback collection '{$id}': ".$cleanupError->getMessage()); + } + } + throw new DatabaseException("Failed to create collection metadata for '{$id}': ".$error->getMessage(), previous: $error); + } + + $stored = $this->toCollection($stored); + + $listeners = $this->listens(Event::CollectionCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Collection\Created($stored->getId(), $stored), $listeners); + } + + return $stored; + } + + /** + * @throws ConflictException + * @throws DatabaseException + * @throws NotFoundException + */ + public function updateCollection(string $collection, CollectionUpdate $update): Collection + { + if ($update->permissions !== null && $this->validation()->get()) { + $validator = new Permissions(); + if (! $validator->isValid($update->permissions)) { + throw new DatabaseException($validator->getDescription()); + } + } + + $definition = $this->silent(fn () => $this->getOwnCollection($collection)); + + if ($update->permissions === null && $update->documentSecurity === null) { + return $definition; + } + + if ($update->permissions !== null) { + $definition->setAttribute(Document::PERMISSIONS, $update->permissions); + } + + if ($update->documentSecurity !== null) { + $definition->setAttribute(self::COLLECTION_DOCUMENT_SECURITY, $update->documentSecurity); + } + + $updated = $this->toCollection( + $this->silent(fn () => $this->updateDocument(self::METADATA, $definition->getId(), $definition)), + ); + + $listeners = $this->listens(Event::CollectionUpdate); + if ($listeners !== []) { + $this->dispatch(new Event\Collection\Updated($updated->getId(), $updated), $listeners); + } + + return $updated; + } + + /** + * @throws DatabaseException + * @throws NotFoundException when the collection does not exist, or belongs to another tenant under shared tables + */ + public function getCollection(string $collection): Collection + { + return $this->findCollection($collection) + ?? throw new NotFoundException('Collection not found'); + } + + /** + * @return Collection|null null when the collection does not exist, or belongs to another tenant under shared tables + * + * @throws DatabaseException + */ + public function findCollection(string $collection): ?Collection + { + if ($collection === self::METADATA) { + $definition = self::collectionDefinition(); + $listeners = $this->listens(Event::CollectionRead); + if ($listeners !== []) { + $this->dispatch(new Event\Collection\Read($definition->getId(), $definition), $listeners); + } + + return $definition; + } + + $stored = $this->silent(fn () => $this->getDocument(self::METADATA, $collection)); + + if ($stored->getId() === '') { + return null; + } + + if ( + $this->adapter->hasSharedTables() + && $stored->getTenant() !== null + && $stored->getTenant() !== $this->adapter->getTenant() + ) { + return null; + } + + $definition = $this->toCollection($stored); + + $listeners = $this->listens(Event::CollectionRead); + if ($listeners !== []) { + $this->dispatch(new Event\Collection\Read($definition->getId(), $definition), $listeners); + } + + return $definition; + } + + /** + * @return list + * + * @throws Exception + */ + public function listCollections(int $limit = 25, int $offset = 0): array + { + $result = $this->silent(fn () => $this->find(self::METADATA, [ + Query::limit($limit), + Query::offset($offset), + ])); + + $collections = \array_map($this->toCollection(...), \array_values($result)); + + $listeners = $this->listens(Event::CollectionList); + if ($listeners !== []) { + $this->dispatch(new Event\Collection\Listed($collections), $listeners); + } + + return $collections; + } + + /** + * @throws Exception + */ + public function getSizeOfCollection(string $collection): int + { + $definition = $this->silent(fn () => $this->getOwnCollection($collection)); + + return $this->adapter->getSizeOfCollection($definition->getId()); + } + + /** + * @throws DatabaseException + * @throws NotFoundException + */ + public function getSizeOfCollectionOnDisk(string $collection): int + { + if ($this->adapter->hasSharedTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $definition = $this->silent(fn () => $this->getOwnCollection($collection)); + + return $this->adapter->getSizeOfCollectionOnDisk($definition->getId()); + } + + public function analyzeCollection(string $collection): bool + { + return $this->adapter->analyzeCollection($collection); + } + + /** + * @throws DatabaseException + * @throws NotFoundException + * @throws RefusedException When the adapter does not drop the table + */ + public function deleteCollection(string $collection): void + { + $definition = $this->silent(fn () => $this->getOwnCollection($collection)); + + foreach ($definition->attributes() as $attribute) { + if ($attribute->type === ColumnType::Relationship) { + $this->deleteRelationship($collection, $attribute->key); + } + } + + $current = $this->silent(fn () => $this->findCollection($collection)); + $currentAttributes = $current?->attributes() ?? []; + $currentIndexes = $current?->indexes() ?? []; + + if ($collection === self::METADATA) { + $this->purgeCachedCollection($collection); + } + + $schemaDeleted = $this->deleteCollectionFromSchema($collection); + + $deleted = true; + if ($collection !== self::METADATA) { + try { + $deleted = $this->silent(fn () => $this->deleteDocument(self::METADATA, $collection)); + } catch (Throwable $error) { + if ($schemaDeleted) { + try { + $this->adapter->createCollection($collection, $currentAttributes, $currentIndexes); + } catch (Throwable) { + // Best-effort restore; the metadata error below is what the caller needs. + } + } + throw new DatabaseException( + "Failed to persist metadata for collection deletion '{$collection}': ".$error->getMessage(), + previous: $error + ); + } + + $this->purgeCachedCollection($collection); + } + + $listeners = $deleted ? $this->listens(Event::CollectionDelete) : []; + if ($listeners !== []) { + $this->dispatch(new Event\Collection\Deleted($definition->getId(), $definition), $listeners); + } + } + + /** + * The collection, refused when it belongs to another tenant under shared tables. + * + * @throws NotFoundException + */ + private function getOwnCollection(string $collection): Collection + { + $definition = $this->getCollection($collection); + + if ($this->adapter->hasSharedTables() && $definition->getTenant() !== $this->adapter->getTenant()) { + throw new NotFoundException('Collection not found'); + } + + return $definition; + } + + private function toCollection(Document $definition): Collection + { + if ($definition instanceof Collection) { + return $definition; + } + + $collection = Collection::fromArray($definition->getArrayCopy()); + $this->attachCollectionCacheEpoch($collection, $this->getCollectionCacheEpoch($definition)); + + return $collection; + } + + /** + * Engines that keep index lengths do not store one equal to a string's size, and index arrays + * by a fixed prefix in no order. + * + * @param list $attributes + * + * @throws IndexException + */ + private function fitIndexToAttributes(Index $index, array $attributes): Index + { + $maxIndexLength = $this->adapter->limits()->indexLength; + $lengths = $index->lengths; + $orders = $index->orders; + + foreach ($index->attributes as $position => $key) { + foreach ($attributes as $attribute) { + if ($attribute->key !== $key) { + continue; + } + + $length = $lengths[$position] ?? null; + if ($attribute->type === ColumnType::String && ! empty($length) && $length === $attribute->size && $maxIndexLength > 0) { + $lengths = self::withPosition($lengths, $position, null); + } + + if ($attribute->array) { + if ($maxIndexLength > 0 && $index->type !== IndexType::Fulltext) { + $lengths = self::withPosition($lengths, $position, self::MAX_ARRAY_INDEX_LENGTH); + } + if (($orders[$position] ?? null) !== null) { + $orders = self::withPosition($orders, $position, null); + } + } + break; + } + } + + if ($lengths !== $index->lengths) { + $index = $index->withLengths($lengths); + } + + if ($orders !== $index->orders) { + $index = $index->withOrders($orders); + } + + return $index; + } + + /** + * @template T + * + * @param list $list + * @param T|null $value + * @return list + */ + private static function withPosition(array $list, int $position, mixed $value): array + { + $list = \array_pad($list, $position + 1, null); + $list[$position] = $value; + + return \array_values($list); + } + + private function purgeStaleCollectionCache(string $collection): void + { + try { + $this->purgeCachedDocument(self::METADATA, $collection); + } catch (Throwable $cacheError) { + Console::warning('Warning: Failed to purge stale collection cache: '.$cacheError->getMessage()); + } + } + + /** + * @param list $attributes + * @param list $indexes + * @return bool True when this call created the table, false when it reuses the metadata table or a table shared + * with other tenants + * + * @throws DuplicateException When the table exists and is neither + * @throws RefusedException When the adapter does not create the table + */ + private function createCollectionInSchema(string $id, array $attributes, array $indexes): bool + { + try { + $created = $this->adapter->createCollection($id, $attributes, $indexes); + } catch (DuplicateException $error) { + if ($id === self::METADATA + || ($this->adapter->hasSharedTables() + && $this->adapter->collectionExists($this->adapter->getDatabase(), $id))) { + // The metadata table must never be dropped during reconciliation. + // In shared-tables mode the physical table is reused across + // tenants. A DuplicateException simply means the table already + // exists for another tenant — not an orphan. + return false; + } + + // The table exists and this process did not create it. It may + // belong to a peer that has not committed metadata yet, or it + // may be an orphan. Dropping it destroyed live collections + // during concurrent boot; attaching this caller's metadata to + // an unknown physical schema can invent columns that are not + // there. Leave the table and report Duplicate. Claiming the + // metadata row first is #939. + $this->purgeStaleCollectionCache($id); + throw new DuplicateException('Collection '.$id.' already exists', previous: $error); + } + + if (! $created) { + throw new RefusedException('Failed to create collection'); + } + + return true; + } + + /** + * @return bool True when this call dropped the table, false when the schema no longer held it + * + * @throws RefusedException When the adapter does not drop the table + */ + private function deleteCollectionFromSchema(string $collection): bool + { + try { + $deleted = $this->adapter->deleteCollection($collection); + } catch (NotFoundException) { + // Already absent from the schema; the metadata is still removed. + return false; + } + + if (! $deleted) { + throw new RefusedException('Failed to delete collection'); + } + + return true; + } + + /** + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupCollection(string $collection, int $maxAttempts = 3): void + { + $this->cleanup( + fn () => $this->adapter->deleteCollection($collection), + 'collection', + $collection, + $maxAttempts + ); + } +} diff --git a/src/Database/Trait/Databases.php b/src/Database/Trait/Databases.php new file mode 100644 index 0000000000..57f1c932a7 --- /dev/null +++ b/src/Database/Trait/Databases.php @@ -0,0 +1,180 @@ +adapter->getDatabase(); + + if (! $this->adapter->create($database)) { + throw new RefusedException('Failed to create database'); + } + + $this->silent(fn () => $this->createCollection(self::collectionDefinition())); + + $listeners = $this->listens(Event::DatabaseCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Database\Created($database), $listeners); + } + + return true; + } + + /** + * @param string|null $database Database name, defaults to the adapter's configured database + */ + public function exists(?string $database = null): bool + { + return $this->adapter->exists($database ?? $this->adapter->getDatabase()); + } + + /** + * @param string|null $database Database name, defaults to the adapter's configured database + */ + public function collectionExists(string $collection, ?string $database = null): bool + { + return $this->adapter->collectionExists($database ?? $this->adapter->getDatabase(), $collection); + } + + /** + * @return array + */ + public function list(): array + { + $databases = $this->adapter->list(); + + $listeners = $this->listens(Event::DatabaseList); + if ($listeners !== []) { + $this->dispatch(new Event\Database\Listed(\array_values($databases)), $listeners); + } + + return $databases; + } + + /** + * Renames a database. Under shared tables a database holds other tenants' data, so renaming it is refused. + * The definitions and documents cached under either name are retired, and a database that was current + * stays current under its new name. + * + * @throws DatabaseException + * @throws DuplicateException when a database is already named $new + * @throws NotFoundException when no database is named $database + * @throws RefusedException When the adapter does not rename the database + */ + public function update(string $database, string $new): bool + { + if ($this->adapter->hasSharedTables()) { + throw new DatabaseException('Cannot rename a database while shared tables are enabled'); + } + + $collections = $this->adapter->exists($database) + ? $this->inDatabase($database, $this->getCollectionIds(...)) + : []; + + if (! $this->adapter->update($database, $new)) { + throw new RefusedException("Failed to rename database '{$database}' to '{$new}'"); + } + + foreach ([$database, $new] as $name) { + $this->inDatabase($name, fn () => $this->purgeCachedCollections($collections)); + } + + if ($this->adapter->getDatabase() === $this->adapter->filter($database)) { + $this->setDatabase($new); + } + + $listeners = $this->listens(Event::DatabaseUpdate); + if ($listeners !== []) { + $this->dispatch(new Event\Database\Updated($database, $new), $listeners); + } + + return true; + } + + /** + * @param string|null $database Database name, defaults to the adapter's configured database + * + * @throws DatabaseException + * @throws RefusedException When the adapter does not drop the database + */ + public function delete(?string $database = null): bool + { + $database ??= $this->adapter->getDatabase(); + + if (! $this->adapter->delete($database)) { + throw new RefusedException('Failed to delete database'); + } + + $this->cache->flush(); + + $listeners = $this->listens(Event::DatabaseDelete); + if ($listeners !== []) { + $this->dispatch(new Event\Database\Deleted($database), $listeners); + } + + return true; + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T + */ + private function inDatabase(string $database, callable $callback): mixed + { + $current = $this->adapter->getDatabase(); + if ($current === $this->adapter->filter($database)) { + return $callback(); + } + + $this->adapter->setDatabase($database); + + try { + return $callback(); + } finally { + $this->adapter->setDatabase($current); + } + } + + /** + * @return list + */ + private function getCollectionIds(): array + { + return $this->silent(fn (): array => $this->authorization->skip(function (): array { + $ids = []; + foreach ($this->cursor(self::METADATA, batchSize: 25) as $definition) { + $ids[] = $definition->getId(); + } + + return $ids; + })); + } + + /** + * @param list $collections + */ + private function purgeCachedCollections(array $collections): void + { + foreach ($collections as $collection) { + $this->purgeCachedCollection($collection); + } + + $this->queryCache?->invalidateCollection($this->getQueryCacheScope(), self::METADATA); + } +} diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php new file mode 100644 index 0000000000..827538f447 --- /dev/null +++ b/src/Database/Trait/Documents.php @@ -0,0 +1,5252 @@ +> Definition keys of the collections the open invalidation scope wrote, by coroutine id and collection key. */ + private array $documentCacheDefinitions = []; + + /** @var array, model: Document}> The model built from each definition's cached copy, by its cache key. */ + private static array $definitionModels = []; + + private const int DEFINITION_MODELS_LIMIT = 256; + + /** @var WeakMap|null The document-cache epoch each collection definition was read with, until the definition is let go. */ + private static ?WeakMap $collectionCacheEpochs = null; + + /** + * Seconds after which an invalidation that has not finished is treated as abandoned (a worker killed + * mid-transaction): readers stop waiting for it, and the next write re-enables the collection's cache. + */ + public function setCacheWriterTimeout(int $seconds): static + { + $this->cacheWriterTimeout = \max(0, $seconds); + + return $this; + } + + public function getCacheWriterTimeout(): int + { + return $this->cacheWriterTimeout; + } + + private function getNumericResult(Attribute $attribute, mixed $current, int|float|string $value, bool $increase): int|float|string + { + $current ??= 0; + + if ($attribute->isInteger()) { + if (! $attribute->signed + && $attribute->type === ColumnType::BigInteger + && ! $this->adapter->supports(Capability::UnsignedBigInt)) { + throw new TypeException('Unsigned 64-bit arithmetic is not supported by this adapter.'); + } + if (! \is_int($current) && (! \is_string($current) || ! BigInt::isIntegerString($current))) { + throw new TypeException('Attribute value must be an integer.'); + } + if (! \is_int($value) && (! \is_string($value) || ! BigInt::isIntegerString($value))) { + throw new TypeException('Change value must be an integer.'); + } + + $result = $increase + ? BigInt::add($current, $value) + : BigInt::subtract($current, $value); + $bounds = $attribute->bounds(); + if ($bounds === null) { + throw new TypeException('Attribute value must be numeric.'); + } + if (BigInt::compare($result, $bounds->max) > 0) { + throw new LimitException('Attribute value exceeds maximum limit: '.$bounds->max); + } + if (BigInt::compare($result, $bounds->min) < 0) { + throw new LimitException('Attribute value exceeds minimum limit: '.$bounds->min); + } + + return $result; + } + + if (! \is_numeric($current)) { + throw new TypeException('Attribute value must be numeric.'); + } + + $current = $this->getNativeNumber($current); + $value = $this->getNativeNumber($value); + $bounds = $attribute->bounds(); + + if ($bounds === null || (\is_float($current) && ! \is_finite($current))) { + throw new TypeException('Attribute value must be a finite numeric value.'); + } + $maximum = $this->getNativeNumber($bounds->max); + $minimum = $this->getNativeNumber($bounds->min); + + if ($current > $maximum) { + throw new LimitException('Attribute value exceeds maximum limit: '.$maximum); + } + + if ($current < $minimum) { + throw new LimitException('Attribute value exceeds minimum limit: '.$minimum); + } + + $overflows = $increase + ? ($value > 0 && $current > $maximum - $value) + : ($value < 0 && $current > $maximum + $value); + if ($overflows) { + throw new LimitException('Attribute value exceeds maximum limit: '.$maximum); + } + + $underflows = $increase + ? ($value < 0 && $current < $minimum - $value) + : ($value > 0 && $current < $minimum + $value); + if ($underflows) { + throw new LimitException('Attribute value exceeds minimum limit: '.$minimum); + } + + $result = $increase ? $current + $value : $current - $value; + if (\is_float($result) && ! \is_finite($result)) { + throw new TypeException('Attribute value must be a finite numeric value.'); + } + + return $result; + } + + private function getNativeNumber(int|float|string $value): int|float + { + if (\is_int($value) || \is_float($value)) { + return $value; + } + if (! \is_numeric($value)) { + throw new TypeException('Value must be numeric.'); + } + + return \str_contains(\strtolower($value), '.') || \str_contains(\strtolower($value), 'e') + ? (float) $value + : (int) $value; + } + + private function declaredAttribute(Collection $collection, string $key): ?Attribute + { + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + return null; + } + + /** + * @return list + */ + private static function relationshipAttributes(Collection $collection): array + { + $relationships = []; + foreach ($collection->attributes() as $attribute) { + if ($attribute->relationship !== null) { + $relationships[] = $attribute; + } + } + + return $relationships; + } + + /** + * The single-valued numeric attribute an increase or decrease changes; null when the adapter does not define + * attributes. + * + * @throws NotFoundException + * @throws TypeException + */ + private function numericAttribute(Collection $collection, string $key): ?Attribute + { + if (! $this->adapter->supports(Capability::DefinedAttributes)) { + return null; + } + + $attribute = $this->declaredAttribute($collection, $key); + if ($attribute === null) { + throw new NotFoundException('Attribute not found'); + } + + if (! $attribute->isNumeric() || $attribute->array) { + throw new TypeException('Attribute must be an integer or float and can not be an array.'); + } + + return $attribute; + } + + private function isDeclaredInteger(?Attribute $attribute): bool + { + return $attribute !== null && ! $attribute->array && $attribute->isInteger(); + } + + private function assertIntegerChange(int|float|string $value): void + { + if (! \is_int($value) && (! \is_string($value) || ! BigInt::isIntegerString($value))) { + throw new TypeException('Change value must be an integer.'); + } + } + + private function integerBound(int|float|string $bound, string $name): int|string + { + return BigInt::integralValue($bound) ?? throw new TypeException($name.' must be an integer.'); + } + + /** + * Cached validator instances keyed by context and a + * stable schema/authorization fingerprint. + * + * Building DocumentsValidator deep-copies every collection attribute via + * Attribute::getArrayCopy(), which is expensive on the find/count/sum + * hot path. The composite key keeps the cache coherent when the same + * Database instance is reused across namespaces, tenants, or with a + * different max-query-values cap. Fresh collection metadata contributes + * a stable schema and authorization fingerprint to each key. + * + * @var array + */ + private array $documentsValidatorCache = []; + + private const int DOCUMENTS_VALIDATOR_CACHE_LIMIT = 256; + + /** @var array Aggregate validators of sums of declared attributes, by collection schema. */ + private array $sumValidatorCache = []; + + /** + * Return a DocumentsValidator for the given collection, building it on + * first request and caching the instance for subsequent calls. The cache + * is purged when the collection's schema changes. Queries that join other + * collections get a fresh validator every time: the cache key describes + * only this collection, never the joined ones. + * + * @param array $joinedCollections + */ + protected function getDocumentsValidator(Document $collection, array $joinedCollections = []): DocumentsValidator + { + if ($joinedCollections !== []) { + return $this->createDocumentsValidator($collection); + } + + $key = $this->documentsValidatorCacheKey($collection); + + if (isset($this->documentsValidatorCache[$key])) { + return $this->documentsValidatorCache[$key]; + } + + $validator = $this->createDocumentsValidator($collection); + + if (\count($this->documentsValidatorCache) >= self::DOCUMENTS_VALIDATOR_CACHE_LIMIT) { + $this->documentsValidatorCache = []; + } + $this->documentsValidatorCache[$key] = $validator; + + return $validator; + } + + /** + * The validator of a query list. A narrow list, of plain filters, limits, offsets, cursors and + * orders on the collection's own top-level attributes, is checked by those validators built + * from only the attributes it names, from the collection as it is passed; any other list by the + * collection's documents validator. Both accept the same narrow lists with the same messages. + * + * @param array $queries + * @param array $joinedCollections + */ + protected function getQueriesValidator(Document $collection, array $queries, array $joinedCollections = []): Base + { + if ($joinedCollections === [] && Narrow::accepts($queries)) { + $narrow = Narrow::of( + $queries, + Collection::fromDocument($collection)->attributes(), + $this->profile(), + $this->maxQueryValues, + ); + + if ($narrow !== null) { + return $narrow; + } + } + + return $this->getDocumentsValidator($collection, $joinedCollections); + } + + protected function createDocumentsValidator(Document $collection): DocumentsValidator + { + $definition = Collection::fromDocument($collection); + + return new DocumentsValidator($definition->attributes(), $definition->indexes(), $this->profile(), $this->maxQueryValues); + } + + /** + * Build the composite cache key for the DocumentsValidator cache. Scoping + * by namespace + tenant + max-query-values keeps two collections that share + * an id (different tenant schemas, different namespace prefixes, different + * per-request limits) from aliasing onto the same validator; a new profile + * empties the cache. + */ + private function documentsValidatorCacheKey(Document $collection): string + { + return $this->getCollectionMetadataCacheKey($collection->getId()).'::'.$this->maxQueryValues.'::'.Collection::fromDocument($collection)->fingerprint(); + } + + /** + * @param array $queries + * + * @throws QueryException + */ + private function rejectJoins(array $queries, string $message): void + { + foreach ($queries as $query) { + if ($query instanceof Query && $query->getMethod()->isJoin()) { + throw new QueryException($message); + } + } + } + + /** + * @param array $documents + * @param array $selections + * @return array + * + * @throws DatabaseException + */ + protected function refetchDocuments(Document $collection, array $documents, array $selections = []): array + { + if (empty($documents)) { + return $documents; + } + + $sequences = \array_map(function (Document $document): string { + $sequence = $document->getSequence(); + if ($sequence === null) { + throw new DatabaseException('Cannot refetch document without a $sequence: '.$document->getId()); + } + + return $sequence; + }, $documents); + + $refetchedMap = []; + foreach (\array_chunk($sequences, \max(1, $this->maxQueryValues)) as $chunk) { + $refetched = $this->getAuthorization()->skip(fn () => $this->silent( + fn () => $this->find( + $collection->getId(), + \array_merge([ + Query::equal(Document::SEQUENCE, $chunk), + Query::limit(\count($chunk)), + ], $selections) + ) + )); + + foreach ($refetched as $document) { + $sequence = $document->getSequence(); + if ($sequence === null) { + throw new DatabaseException('Cannot index refetched document without a $sequence: '.$document->getId()); + } + + $refetchedMap[$sequence] = $document; + } + } + + $result = []; + foreach ($documents as $index => $document) { + $result[$index] = $refetchedMap[$sequences[$index]] ?? $document; + } + + return $result; + } + + /** + * @param string $collection The collection identifier + * @param string $id The document identifier + * @param array $queries Optional select/filter queries + * @param bool $forUpdate Whether to lock the document for update + * @return Document The document, or an empty Document if not found + * + * @throws DatabaseException + * @throws QueryException + */ + public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + if (empty($collection)) { + throw new NotFoundException('Collection not found'); + } + + if (empty($id)) { + return $this->newDocument($collection, []); + } + + $definition = $collection === self::METADATA && ($this->decorators === [] || $this->areEventsSilenced()) + ? self::sharedDefinition() + : $this->silent(fn () => $this->getCollection($collection)); + + return $this->readDocument($definition, $id, $queries, $forUpdate); + } + + /** + * getDocument() under a definition the caller already resolved, as a write locking the row it writes does: the + * row is cast and decoded under the definition the write validates and encodes with. + * + * @param array $queries + * + * @throws DatabaseException + * @throws QueryException + */ + private function readDocument(Collection $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + if ($collection->getId() === self::METADATA && $id === self::METADATA) { + return self::collectionDefinition(); + } + + if (empty($id)) { + return $this->newDocument($collection->getId(), []); + } + + $attributes = $collection->attributes(); + + $this->checkQueryTypes($queries); + + $joinedCollections = null; + + if ($this->validation()->get() && $queries !== []) { + $joinedCollections = $this->resolveJoinedCollections($queries); + $validator = new DocumentValidator($attributes, $this->profile(), $this->maxQueryValues); + $validator->setJoinedCollections($joinedCollections); + if (! $validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + + $relationships = self::relationshipAttributes($collection); + + $parsed = Query::groupByType($queries); + $selects = $parsed->selections; + $joins = $parsed->joins; + + if (! empty($joins) && ! $this->adapter->supports(Capability::Joins)) { + throw new QueryException('Join queries are not supported by this adapter'); + } + + $this->assertJoinCount($joins); + + $joinedCollections ??= $this->resolveJoinedCollections($joins); + $joinDocumentSecurity = []; + $joinedByAlias = []; + if (! empty($joins)) { + $joinDocumentSecurity = $this->authorizeJoins($joins, PermissionType::Read, $joinedCollections); + $joinedByAlias = $this->joinedCollectionsByAlias($joins, $joinedCollections); + $queries = $this->convertQueries($collection, $queries, $joinedByAlias); + } + + $outerJoinIds = $this->outerJoinIdSelections($selects, $joins, $joinedByAlias); + if ($outerJoinIds !== []) { + $queries[] = Query::select($outerJoinIds); + } + + $selections = $this->validateSelections($collection, $selects); + $nestedSelections = $this->relationshipHook?->processQueries($relationships, $queries) ?? []; + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + + [$collectionKey, $documentKey, $hashKey] = $this->getCacheKeys( + $collection->getId(), + $id, + $selections + ); + $definition = $collection->getId() === self::METADATA; + // The cache lower-cases keys; the hash key keeps the id's case, so casings an adapter tells apart keep separate fields. + // A global collection's definition has one key for every tenant, and each tenant has its own collection epoch. + $field = \md5($definition ? $hashKey.':'.\json_encode($this->adapter->getTenant()) : $hashKey); + + // Collection definitions are cacheable because every schema mutation + // persists the definition through updateMetadata(), which writes the + // row via the METADATA collection's own document path and therefore + // purges that definition's slot; a cached definition is served without + // an epoch check. Any new schema mutator must keep writing through that + // path, or its readers will serve a stale schema. + $inTransaction = $this->adapter->inTransaction(); + $cacheable = ! $forUpdate + && empty($joins) + && (! $inTransaction || $this->isCachedInTransaction($documentKey)) + && ! isset($this->documentCacheMutations[$this->getEventContext()][$collectionKey]); + $epoch = $cacheable && ! $definition ? $this->getCollectionCacheEpoch($collection) : null; + // A transaction reads its own snapshot, which can predate another writer's commit and purge. + $fillEpoch = $inTransaction ? null : $epoch; + $fillDefinition = $cacheable && $definition && ! $inTransaction; + $cached = null; + $collectionEpoch = null; + $cacheFailed = false; + try { + if ($cacheable && $definition) { + $entry = $this->loadCachedDefinition($documentKey, $field); + if ($entry !== null) { + $cached = $entry[self::DOCUMENT_CACHE_VALUE]; + $collectionEpoch = $entry[self::DOCUMENT_CACHE_COLLECTION_EPOCH] ?? null; + } + } elseif ($epoch !== null) { + $cached = $this->loadCachedDocument($documentKey, $field, $epoch); + } + } catch (Exception $e) { + $cacheFailed = true; + Console::warning('Warning: Failed to get document from cache: '.$e->getMessage()); + } + + if (\is_array($cached) && isset($cached[self::CACHE_EMPTY_MARKER])) { + return $this->newDocument($collection->getId(), []); + } + + if ($cached) { + /** @var array $cached */ + $document = $definition + ? $this->createDefinitionInstance($documentKey, $cached) + : $this->newDocument($collection->getId(), $cached); + $document = $this->casting($collection, $document); + + if ($collection->getId() !== self::METADATA) { + + if (! $this->authorization->isValid(new Input(PermissionType::Read, [ + ...$collection->getPermissionsByType(PermissionType::Read), + ...($documentSecurity ? $document->getPermissionsByType(PermissionType::Read) : []), + ]))) { + return $this->newDocument($collection->getId(), []); + } + } + + $document = $this->decorateDocument(Event::DocumentRead, $collection, $document); + + $listeners = $this->listens(Event::DocumentRead); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Read($collection->getId(), $document), $listeners); + } + + if ($this->isTtlExpired($collection, $document)) { + return $this->newDocument($collection->getId(), []); + } + + $this->attachCollectionCacheEpoch($document, \is_string($collectionEpoch) ? $collectionEpoch : null); + + return $document; + } + + $generation = '0'; + if ($fillEpoch !== null || $fillDefinition) { + try { + $generation = $this->cache->getGeneration($documentKey); + } catch (Exception $e) { + Console::warning('Warning: Failed to get cache generation: '.$e->getMessage()); + } + } + + $transactionDefinition = $cacheable && $definition && $inTransaction && $queries === []; + $transactionDefinitionKey = \strtolower($documentKey); + $readInTransaction = $transactionDefinition + ? ($this->transactionDefinitions[$this->getEventContext()][$transactionDefinitionKey][$field] ?? null) + : null; + if ($readInTransaction !== null) { + $collectionState = $this->loadDocumentCacheState($this->getCacheBaseKeys($id)[0]); + $document = $this->decorateDocument(Event::DocumentRead, $collection, clone $readInTransaction); + $listeners = $this->listens(Event::DocumentRead); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Read($collection->getId(), $document), $listeners); + } + $this->attachCollectionCacheEpoch($document, $collectionState->value); + + return $document; + } + + $collectionGranted = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getPermissionsByType(PermissionType::Read))); + $skipAuth = empty($joins) + && $collection->getId() !== self::METADATA + && $collectionGranted; + + $getDocument = fn () => $this->adapter->getDocument( + $this->withJoinIndexed($this->withJoinAttributes($this->withJoinAuthorization($collection, $joinDocumentSecurity, $collectionGranted || $collection->getId() === self::METADATA), $joins, $joinedCollections), $joins, $joinedCollections), + $id, + $queries, + $forUpdate + ); + + $document = $skipAuth ? $this->authorization->skip($getDocument) : $getDocument(); + $fillEpoch = $this->isReadFromReplica() ? null : $fillEpoch; + $fillDefinition = $fillDefinition && ! $this->isReadFromReplica(); + + if ($document->isEmpty()) { + // The marker is shared by every reader, so a miss observed with authorization + // enabled only proves absence once an unfiltered read agrees: an adapter may have + // filtered the row out by the caller's permissions. Collection definitions are + // never filtered that way, as every write resolves its collection through them. + $missing = true; + if ($fillEpoch !== null && empty($relationships) && ! $skipAuth && $collection->getId() !== self::METADATA) { + $missing = $this->authorization->skip($getDocument)->isEmpty(); + $fillEpoch = $this->isReadFromReplica() ? null : $fillEpoch; + } + + try { + if ($fillEpoch !== null && empty($relationships) && $missing) { + $this->saveCachedDocument($documentKey, $field, $fillEpoch, [self::CACHE_EMPTY_MARKER => true], $generation); + } elseif ($fillDefinition) { + $this->saveCachedDefinition($documentKey, $field, [self::CACHE_EMPTY_MARKER => true], [], $generation); + } + } catch (Exception $e) { + Console::warning('Failed to save empty document to cache: '.$e->getMessage()); + } + + return $this->newDocument($collection->getId(), []); + } + + if ($this->isTtlExpired($collection, $document)) { + return $this->newDocument($collection->getId(), []); + } + + $describedKey = ''; + $collectionState = new Epoch(); + if ($cacheable && $definition) { + $describedKey = $this->getCacheBaseKeys($id)[0]; + $collectionState = $this->loadDocumentCacheState($describedKey); + } + + $document = $this->castAfterDocument($collection, $document); + + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collection->getId()])) { + $document = $this->newDocument($collection->getId(), $document->getArrayCopy()); + } + + $document->setAttribute(Document::COLLECTION, $collection->getId()); + + if ($collection->getId() !== self::METADATA) { + if (! $this->authorization->isValid(new Input(PermissionType::Read, [ + ...$collection->getPermissionsByType(PermissionType::Read), + ...($documentSecurity ? $document->getPermissionsByType(PermissionType::Read) : []), + ]))) { + return $this->newDocument($collection->getId(), []); + } + } + + $document = $this->casting($collection, $document); + $document = $this->decode($collection, $document, $selections); + if (! empty($joins)) { + $document = $this->decodeJoins($document, $joinedByAlias); + foreach ($outerJoinIds as $outerJoinId) { + $document->removeAttribute($outerJoinId); + } + } + + // Skip relationship population if we're in batch mode (relationships will be populated later) + if ($this->relationshipHook !== null && ! $this->relationshipHook->isInBatchPopulation() && $this->relationshipHook->isEnabled() && ! empty($relationships) && (empty($selects) || ! empty($nestedSelections))) { + $documents = $this->silent(fn () => $this->relationshipHook->populateDocuments([$document], $collection, $this->relationshipHook->getFetchDepth(), $nestedSelections)); + $document = $documents[0]; + } + + try { + if ($fillEpoch !== null && empty($relationships)) { + $this->saveCachedDocument($documentKey, $field, $fillEpoch, $document->getArrayCopy(), $generation); + } elseif ($fillDefinition) { + $this->saveCachedDefinition( + $documentKey, + $field, + $document->getArrayCopy(), + [ + self::DOCUMENT_CACHE_COLLECTION_EPOCH => $collectionState->value, + self::DOCUMENT_CACHE_BLOCKED_AT => $collectionState->blockedAt, + self::DOCUMENT_CACHE_CHECKED_AT => \time(), + ], + $generation, + fn (): bool => $this->loadDocumentCacheState($describedKey)->value === $collectionState->value, + ); + } + } catch (Exception $e) { + Console::warning('Failed to save document to cache: '.$e->getMessage()); + } + + if ($transactionDefinition) { + $this->transactionDefinitions[$this->getEventContext()][$transactionDefinitionKey][$field] = clone $document; + if (! $cacheFailed) { + $this->queueDefinitionRefill($transactionDefinitionKey, $field, $id); + } + } + + $document = $this->decorateDocument(Event::DocumentRead, $collection, $document); + + $listeners = $this->listens(Event::DocumentRead); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Read($collection->getId(), $document), $listeners); + } + + $this->attachCollectionCacheEpoch($document, $collectionState->value); + + return $document; + } + + /** + * Whether a read inside a transaction may serve the document's cached copy: only when this + * context's invalidation scope started the transaction and has not written the document. Ids + * compare case-insensitively, as an adapter may match any casing of a written id. + */ + private function isCachedInTransaction(string $documentKey): bool + { + $written = $this->transactionWrites[$this->getEventContext()] ?? null; + + return $written !== null && ! isset($written[\strtolower($documentKey)]); + } + + /** + * A replica may lag the primary, so what it served must not be cached for other readers. + */ + private function isReadFromReplica(): bool + { + return $this->adapter instanceof ReadWritePool && $this->adapter->servedByReplica(); + } + + /** + * What a read under $epoch may serve from a document's cache slot: its copy, the absence + * marker, or null when the slot holds nothing for this read. + * + * @return array|null + */ + private function loadCachedDocument(string $documentKey, string $field, string $epoch): ?array + { + $entry = $this->cache->load($documentKey, self::TTL, $field); + if (! \is_array($entry)) { + return null; + } + + $document = $entry[self::DOCUMENT_CACHE_VALUE] ?? null; + if ( + ! \is_array($document) + || ($entry[self::DOCUMENT_CACHE_EPOCH] ?? null) !== $epoch + || ($entry[self::DOCUMENT_CACHE_FIELD] ?? null) !== $field + ) { + return null; + } + + return $document; + } + + /** + * @param array $document + */ + private function saveCachedDocument(string $documentKey, string $field, string $epoch, array $document, string $generation): void + { + $this->cache->saveWithLease($documentKey, [ + self::DOCUMENT_CACHE_EPOCH => $epoch, + self::DOCUMENT_CACHE_FIELD => $field, + self::DOCUMENT_CACHE_VALUE => $document, + ], $field, $generation); + } + + /** + * The Collection model of a collection definition's cached copy, as a deep clone of the one built + * the last time this copy was read: it is built again whenever the copy read differs in any value. + * The kept model has its permissions parsed, so its clones start with the parse, which each one + * checks against its own permissions before using it. + * A custom document type for the metadata collection is built on every read, as its constructor + * may do more than copy the data. + * + * @param array $cached + */ + private function createDefinitionInstance(string $documentKey, array $cached): Document + { + if (($this->documentTypes[self::METADATA] ?? null) !== Collection::class) { + return $this->newDocument(self::METADATA, $cached); + } + + $entry = self::$definitionModels[$documentKey] ?? null; + if ($entry !== null && $entry['source'] === $cached) { + return clone $entry['model']; + } + + $model = $this->newDocument(self::METADATA, $cached); + + if (\count(self::$definitionModels) >= self::DEFINITION_MODELS_LIMIT) { + self::$definitionModels = []; + } + $kept = clone $model; + try { + $kept->getPermissions(); + } catch (StructureException) { + // Permissions that do not parse fail where a clone's are read, as they would unparsed. + } + if ($kept instanceof Collection) { + try { + $kept->attributes(); + $kept->indexes(); + } catch (SchemaException) { + // Models that do not build fail where a clone's are read, as they would unbuilt. + } + } + self::$definitionModels[$documentKey] = ['source' => $cached, 'model' => $kept]; + + return $model; + } + + /** + * What a read may serve from a collection definition's cache slot: its entry, carrying the epoch + * the collection's documents are cached under, or null when the slot holds nothing for this read + * or holds a blocked collection due for another look. + * + * @return array{document: array, collectionEpoch?: mixed}|null + */ + private function loadCachedDefinition(string $documentKey, string $field): ?array + { + $entry = $this->cache->load($documentKey, self::TTL, $field); + if ( + ! \is_array($entry) + || ! \is_array($entry[self::DOCUMENT_CACHE_VALUE] ?? null) + || ($entry[self::DOCUMENT_CACHE_FIELD] ?? null) !== $field + ) { + return null; + } + + if (isset($entry[self::DOCUMENT_CACHE_VALUE][self::CACHE_EMPTY_MARKER])) { + return [self::DOCUMENT_CACHE_VALUE => $entry[self::DOCUMENT_CACHE_VALUE]]; + } + + $collectionEpoch = $entry[self::DOCUMENT_CACHE_COLLECTION_EPOCH] ?? null; + if ($collectionEpoch === null) { + $now = \time(); + $blockedAt = $entry[self::DOCUMENT_CACHE_BLOCKED_AT] ?? null; + $checkedAt = $entry[self::DOCUMENT_CACHE_CHECKED_AT] ?? null; + if ( + ! \is_int($blockedAt) + || ! \is_int($checkedAt) + || $blockedAt + $this->cacheWriterTimeout <= $now + || $checkedAt + self::DOCUMENT_CACHE_RECHECK <= $now + ) { + return null; + } + } + + return [ + self::DOCUMENT_CACHE_VALUE => $entry[self::DOCUMENT_CACHE_VALUE], + self::DOCUMENT_CACHE_COLLECTION_EPOCH => $collectionEpoch, + ]; + } + + /** + * Without generations a fill can land after the purge that should have removed it, so the state it + * was filled under is read again and the fill is dropped when that state has moved on. + * + * @param array $document + * @param array $validity + * @param (Closure(): bool)|null $isCurrent + */ + private function saveCachedDefinition(string $documentKey, string $field, array $document, array $validity, string $generation, ?Closure $isCurrent = null): void + { + $saved = $this->cache->saveWithLease($documentKey, [ + ...$validity, + self::DOCUMENT_CACHE_FIELD => $field, + self::DOCUMENT_CACHE_VALUE => $document, + ], $field, $generation); + + if ($saved === false) { + $this->definitionFillsFail = $generation === '0' && $this->cache->getGeneration($documentKey) === '0'; + + return; + } + + $this->definitionFillsFail = false; + + if ($generation === '0' && $isCurrent !== null && ! $isCurrent()) { + $this->cache->purge($documentKey); + } + } + + private function attachCollectionCacheEpoch(Document $definition, ?string $epoch): void + { + self::$collectionCacheEpochs ??= new WeakMap(); + if ($epoch === null) { + unset(self::$collectionCacheEpochs[$definition]); + + return; + } + + self::$collectionCacheEpochs[$definition] = $epoch; + } + + /** + * The epoch a collection's documents may be cached under, as read with its definition; null when + * they must not be. + */ + private function getCollectionCacheEpoch(Document $definition): ?string + { + $epochs = self::$collectionCacheEpochs; + + return $epochs !== null && isset($epochs[$definition]) ? $epochs[$definition] : null; + } + + private function isTtlExpired(Document $collection, Document $document): bool + { + if (! $this->adapter->supports(Capability::IndexTtl)) { + return false; + } + foreach (Collection::fromDocument($collection)->indexes() as $index) { + if ($index->type !== IndexType::Ttl) { + continue; + } + $seconds = $index->ttl ?? 0; + $attribute = $index->attributes[0] ?? null; + if ($seconds <= 0 || $attribute === null || $attribute === '') { + return false; + } + $value = $document->getAttribute($attribute); + if (\is_string($value)) { + try { + $start = new PhpDateTime($value); + + return (new PhpDateTime()) > $start->modify("+{$seconds} seconds"); + } catch (Throwable) { + return false; + } + } + } + + return false; + } + + /** + * Strip non-selected attributes from documents based on select queries. + * + * @internal + * + * @param array $documents + * @param array $selectQueries + */ + public function applySelectFiltersToDocuments(array $documents, array $selectQueries): void + { + if (empty($selectQueries) || empty($documents)) { + return; + } + + // Collect all attributes to keep from select queries + $attributesToKeep = []; + foreach ($selectQueries as $selectQuery) { + foreach ($selectQuery->getValues() as $value) { + /** @var string $strValue */ + $strValue = $value; + $attributesToKeep[$strValue] = true; + } + } + + // Early return if wildcard selector present + if (isset($attributesToKeep['*'])) { + return; + } + + // Always preserve internal attributes (use hashmap for O(1) lookup) + $internalKeys = \array_map(static fn (Attribute $attribute): string => $attribute->key, $this->internalAttributes()); + foreach ($internalKeys as $key) { + /** @var string $key */ + $attributesToKeep[$key] = true; + } + + foreach ($documents as $doc) { + $allKeys = \array_keys($doc->getArrayCopy()); + foreach ($allKeys as $attrKey) { + // Keep if: explicitly selected OR is internal attribute ($ prefix) + if (! isset($attributesToKeep[$attrKey]) && ! \str_starts_with($attrKey, '$')) { + $doc->removeAttribute($attrKey); + } + } + } + } + + /** + * @param string $collection The collection identifier + * @param Document $document The document to create + * @return Document The created document with generated ID and timestamps + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws StructureException + */ + public function createDocument(string $collection, Document $document): Document + { + $this->assertCreateTenancy($collection); + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $document = $this->prepareDocument($collection, $document); + + /** @var array}> $copies */ + $copies = []; + try { + $document = $this->withMutation(Event::DocumentCreate, $document, function () use ($collection, $document, &$copies) { + $hook = $this->relationshipHook; + if ($hook?->isEnabled()) { + if ($copies !== [] && $copies !== null) { + $hook->restore($copies); + } + $document = $this->silent(function () use ($hook, $collection, $document, &$copies): Document { + return $hook->afterDocumentCreate($collection, $document, $copies); + }); + } + + $document = $this->adapter->createDocument($collection, $document); + $this->withDocumentTenant( + $document, + fn () => $this->purgeCachedDocumentInternal($collection->getId(), $document->getId()) + ); + + return $document; + }); + } catch (Throwable $error) { + if ($copies !== [] && $copies !== null) { + $this->relationshipHook?->restore($copies); + } + + throw $error; + } + + $hook = $this->relationshipHook; + if ($hook !== null && ! $hook->isInBatchPopulation() && $hook->isEnabled()) { + $fetchDepth = $hook->getWriteStackCount(); + $documents = $this->silent(fn () => $hook->populateDocuments([$document], $collection, $fetchDepth)); + $document = $documents[0]; + } + + $document = $this->castAfterDocument($collection, $document); + $document = $this->casting($collection, $document); + $document = $this->decode($collection, $document); + + if (isset($this->documentTypes[$collection->getId()])) { + $document = $this->newDocument($collection->getId(), $document->getArrayCopy()); + } + + $document = $this->decorateDocument(Event::DocumentCreate, $collection, $document); + + $listeners = $this->listens(Event::DocumentCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Created($collection->getId(), $document), $listeners); + } + + return $document; + } + + /** + * Apply to a document everything createDocument() does before it writes the document: the tenancy and + * permission checks, the generated attributes, encoding and validation. The relationship hook prepares + * the related documents of a write this way and writes them through createPrepared(). + * + * @internal + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws StructureException + */ + public function prepareCreate(Document $collection, Document $document): Document + { + $this->assertCreateTenancy($collection->getId()); + + return $this->prepareDocument($collection, $document); + } + + /** + * Write documents prepared by prepareCreate() one at a time in the order given, each the way + * createDocument() writes it, under one invalidation scope. + * + * @internal + * + * @param list $documents Each prepared document after its collection + * + * @throws DuplicateException + * @throws DatabaseException + */ + public function createPrepared(array $documents): void + { + $this->withInvalidationScope(function () use ($documents): void { + $collections = []; + foreach ($documents as [$collection, $document]) { + $collections[$collection->getId()][] = $document; + } + + foreach ($collections as $created) { + $this->blockMutation(Event::DocumentCreate, $created); + } + + foreach ($documents as [$collection, $document]) { + $document = $this->adapter->createDocument($collection, $document); + $this->withDocumentTenant( + $document, + fn () => $this->purgeCachedDocumentInternal($collection->getId(), $document->getId()) + ); + } + }); + } + + /** + * @throws DatabaseException + */ + private function assertCreateTenancy(string $collection): void + { + if ( + $collection !== self::METADATA + && $this->adapter->hasSharedTables() + && ! $this->adapter->isTenantPerDocument() + && empty($this->adapter->getTenant()) + ) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + if ( + ! $this->adapter->hasSharedTables() + && $this->adapter->isTenantPerDocument() + ) { + throw new DatabaseException('Shared tables must be enabled if tenant per document is enabled.'); + } + } + + /** + * @throws AuthorizationException + * @throws DatabaseException + * @throws StructureException + */ + private function prepareDocument(Document $collection, Document $document): Document + { + if ($collection->getId() !== self::METADATA) { + $isValid = $this->authorization->isValid(new Input(PermissionType::Create, $collection->getPermissionsByType(PermissionType::Create))); + if (! $isValid) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + + $time = DateTime::now(); + + $createdAt = $document->getCreatedAt(); + $updatedAt = $document->getUpdatedAt(); + + $id = $document->getId(); + $document + ->setAttribute(Document::ID, empty($id) ? Id::unique() : $id) + ->setAttribute(Document::COLLECTION, $collection->getId()) + ->setAttribute(Document::CREATED_AT, ($createdAt === null || ! $this->datePreservation()->get()) ? $time : $createdAt) + ->setAttribute(Document::UPDATED_AT, ($updatedAt === null || ! $this->datePreservation()->get()) ? $time : $updatedAt); + + if (empty($document->getPermissions())) { + $document->setAttribute(Document::PERMISSIONS, []); + } + + if ($this->adapter->hasSharedTables()) { + if ($this->adapter->isTenantPerDocument()) { + if ( + $collection->getId() !== static::METADATA + && $document->getTenant() === null + ) { + throw new DatabaseException('Missing tenant. Tenant must be set when tenant per document is enabled.'); + } + } else { + $document->setAttribute(Document::TENANT, $this->adapter->getTenant()); + } + } + + $document = $this->encode($collection, $document); + + if ($this->validation()->get()) { + $validator = new Permissions(); + if (! $validator->isValid($document->getPermissions())) { + throw new DatabaseException($validator->getDescription()); + } + } + + if ($this->validation()->get()) { + $structure = new Structure( + collection: $collection, + profile: $this->profile() + ); + if (! $structure->isValid($document)) { + throw new StructureException($structure->getDescription()); + } + } + + return $this->castBefore($collection, $document); + } + + /** + * Create Documents in a batch + * + * @param string $collection The collection identifier + * @param array $documents The documents to create + * @param int $batchSize Number of documents per batch insert, at most BATCH_SIZE + * @param (callable(Document $document, ?Document $previous): void)|null $onNext Given each created document once its + * batch is written, and null; an exception + * it throws aborts the call + * @return int The number of documents created + * + * @throws AuthorizationException + * @throws LimitException When $batchSize is above BATCH_SIZE + * @throws StructureException + * @throws Throwable + * @throws Exception + */ + public function createDocuments( + string $collection, + array $documents, + int $batchSize = self::BATCH_SIZE, + ?callable $onNext = null, + ): int { + $batchSize = self::batchSize($batchSize); + + if ( + $this->adapter->hasSharedTables() + && ! $this->adapter->isTenantPerDocument() + && empty($this->adapter->getTenant()) + ) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + if (! $this->adapter->hasSharedTables() && $this->adapter->isTenantPerDocument()) { + throw new DatabaseException('Shared tables must be enabled if tenant per document is enabled.'); + } + + if (empty($documents)) { + return 0; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + if ($collection->getId() !== self::METADATA) { + if (! $this->authorization->isValid(new Input(PermissionType::Create, $collection->getPermissionsByType(PermissionType::Create)))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + + $time = DateTime::now(); + $modified = 0; + $hasRelationships = self::relationshipAttributes($collection) !== []; + + // Hoisted: validator only depends on the collection + adapter properties, + // both stable for this call. Allocating once and reusing across all + // documents avoids per-document construction and (with the in-class + // memo) per-document `array_merge` of the attribute list. + $validator = $this->validation()->get() + ? new Structure( + collection: $collection, + profile: $this->profile() + ) + : null; + + foreach ($documents as $document) { + $createdAt = $document->getCreatedAt(); + $updatedAt = $document->getUpdatedAt(); + + $document + ->setAttribute(Document::ID, empty($document->getId()) ? Id::unique() : $document->getId()) + ->setAttribute(Document::COLLECTION, $collection->getId()) + ->setAttribute(Document::CREATED_AT, ($createdAt === null || ! $this->datePreservation()->get()) ? $time : $createdAt) + ->setAttribute(Document::UPDATED_AT, ($updatedAt === null || ! $this->datePreservation()->get()) ? $time : $updatedAt); + + if (empty($document->getPermissions())) { + $document->setAttribute(Document::PERMISSIONS, []); + } + + if ($this->adapter->hasSharedTables()) { + if ($this->adapter->isTenantPerDocument()) { + if ($document->getTenant() === null) { + throw new DatabaseException('Missing tenant. Tenant must be set when tenant per document is enabled.'); + } + } else { + $document->setAttribute(Document::TENANT, $this->adapter->getTenant()); + } + } + + $document = $this->encode($collection, $document); + + if ($validator !== null) { + if (! $validator->isValid($document)) { + throw new StructureException($validator->getDescription()); + } + } + + if ($this->relationshipHook?->isEnabled()) { + $document = $this->silent(fn () => $this->relationshipHook->afterDocumentCreate($collection, $document)); + } + + $document = $this->castBefore($collection, $document); + } + + foreach (\array_chunk($documents, $batchSize) as $chunk) { + $insert = fn () => $this->withMutation( + Event::DocumentsCreate, + $chunk, + function () use ($collection, $chunk): array { + $batch = $this->adapter->createDocuments($collection, $chunk); + + foreach ($chunk as $document) { + $this->withDocumentTenant( + $document, + fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) + ); + } + + return $batch; + } + ); + $batch = $this->ignoringDuplicates()->get() + ? $this->adapter->ignoreDuplicates($insert) + : $insert(); + + if ($onNext !== null || $hasRelationships) { + $batch = $this->adapter->getSequences($collection, $batch); + } + + $hook = $this->relationshipHook; + if ($hook !== null && ! $hook->isInBatchPopulation() && $hook->isEnabled()) { + $batch = $this->silent(fn () => $hook->populateDocuments($batch, $collection, $hook->getFetchDepth())); + } + + /** @var array $batch */ + $batch = \array_map( + fn (Document $document) => $this->decode($collection, $this->casting($collection, $document)), + $this->castAfter($collection, $batch) + ); + + $batch = $this->decorateDocuments(Event::DocumentsCreate, $collection, $batch); + + foreach ($batch as $document) { + if ($onNext !== null) { + $onNext($document, null); + } + + $modified++; + } + } + + $listeners = $this->listens(Event::DocumentsCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Document\BatchCreated($collection->getId(), $modified), $listeners); + } + + return $modified; + } + + /** + * @param string $collection The collection identifier + * @param string $id The document identifier + * @param Document $document The document with updated fields + * @return Document The updated document + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws StructureException + */ + public function updateDocument(string $collection, string $id, Document $document): Document + { + if (! $id) { + throw new DatabaseException('Must define $id attribute'); + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + $newUpdatedAt = $document->getUpdatedAt(); + $hasOperators = false; + $cacheTarget = $collection->getId() === self::METADATA + ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) + : $collection->getId(); + $document = $this->withMutation(Event::DocumentUpdate, $cacheTarget, function () use ($collection, $id, $document, $newUpdatedAt, &$hasOperators) { + $old = $this->authorization->skip(fn () => $this->silent( + fn () => $this->readDocument($collection, $id, forUpdate: true) + )); + if ($old->isEmpty()) { + return new Document(); + } + $time = DateTime::nowAfter($old->getUpdatedAt() ?: null); + + $skipPermissionsUpdate = true; + + if ($document->offsetExists(Document::PERMISSIONS)) { + $originalPermissions = $old->getPermissions(); + $currentPermissions = $document->getPermissions(); + + sort($originalPermissions); + sort($currentPermissions); + + $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); + } + $createdAt = $document->getCreatedAt(); + + $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); + $document[Document::COLLECTION] = $old->getAttribute(Document::COLLECTION); // Make sure user doesn't switch collection ID + $document[Document::SEQUENCE] = $old->getSequence(); // Sequence is immutable, and adapters key the UPDATE on it + if ($document[Document::ID] !== $old->getId()) { + $skipPermissionsUpdate = false; + } + $document[Document::CREATED_AT] = ($createdAt === null || ! $this->datePreservation()->get()) ? $old->getCreatedAt() : $createdAt; + + if ($this->adapter->hasSharedTables()) { + $document[Document::TENANT] = $old->getTenant(); // Make sure user doesn't switch tenant + } + $document = new Document($document); + + // Ahead of change detection: a dropped attribute is never persisted, so + // counting it as a change would bump $updatedAt and fire an update event + // for a write that leaves the stored document identical. + $document = $this->removeUnknownAttributes($collection, $document); + + $shouldUpdate = false; + + if ($collection->getId() !== self::METADATA) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + + /** @var array $relationships */ + $relationships = []; + foreach ($collection->attributes() as $attribute) { + if ($attribute->relationship !== null) { + $relationships[$attribute->key] = $attribute; + } + } + + foreach ($document as $key => $value) { + if (Operator::isOperator($value)) { + $shouldUpdate = true; + break; + } + } + + $internalKeys = [Document::COLLECTION, Document::TENANT, Document::SEQUENCE]; + + // Compare if the document has any changes + foreach ($document as $key => $value) { + if (\in_array($key, $internalKeys, true)) { + continue; + } + + $relationship = isset($relationships[$key]) ? $relationships[$key]->relationship : null; + if ($relationship !== null) { + $relationType = $relationship->type; + $side = $relationships[$key]->side; + $storesKey = $relationType === RelationshipType::OneToOne + || ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Parent) + || ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Child); + + if (! $storesKey && $this->relationshipHook !== null && $this->relationshipHook->getWriteStackCount() >= Database::RELATION_MAX_DEPTH - 1) { + continue; + } + + switch ($relationType) { + case RelationshipType::OneToOne: + $oldValue = $old->getAttribute($key) instanceof Document + ? $old->getAttribute($key)->getId() + : $old->getAttribute($key); + + if ((\is_null($value) !== \is_null($oldValue)) + || (\is_string($value) && $value !== $oldValue) + || ($value instanceof Document && $value->getId() !== $oldValue) + ) { + $shouldUpdate = true; + } + break; + case RelationshipType::OneToMany: + case RelationshipType::ManyToOne: + case RelationshipType::ManyToMany: + if ( + ($relationType === RelationshipType::ManyToOne && $side === RelationshipSide::Parent) || + ($relationType === RelationshipType::OneToMany && $side === RelationshipSide::Child) + ) { + $oldValue = $old->getAttribute($key) instanceof Document + ? $old->getAttribute($key)->getId() + : $old->getAttribute($key); + + if ((\is_null($value) !== \is_null($oldValue)) + || (\is_string($value) && $value !== $oldValue) + || ($value instanceof Document && $value->getId() !== $oldValue) + ) { + $shouldUpdate = true; + } + break; + } + + if (Operator::isOperator($value)) { + $shouldUpdate = true; + break; + } + + if (! \is_array($value) || ! \array_is_list($value)) { + throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, '.\gettype($value).' given.'); + } + + /** @var array $oldRelValues */ + $oldRelValues = $old->getAttribute($key); + if (\count($oldRelValues) !== \count($value)) { + $shouldUpdate = true; + break; + } + + foreach ($value as $index => $relation) { + $oldValue = $oldRelValues[$index] instanceof Document + ? $oldRelValues[$index]->getId() + : $oldRelValues[$index]; + + if ( + (\is_string($relation) && $relation !== $oldValue) || + ($relation instanceof Document && $relation->getId() !== $oldValue) + ) { + $shouldUpdate = true; + break; + } + } + break; + } + + if ($shouldUpdate) { + break; + } + + continue; + } + + $oldValue = $old->getAttribute($key); + + // If values are not equal we need to update document. + if (! self::valuesEqual($value, $oldValue)) { + $shouldUpdate = true; + break; + } + } + + $updatePermissions = [ + ...$collection->getPermissionsByType(PermissionType::Update), + ...($documentSecurity ? $old->getPermissionsByType(PermissionType::Update) : []), + ]; + + $readPermissions = [ + ...$collection->getPermissionsByType(PermissionType::Read), + ...($documentSecurity ? $old->getPermissionsByType(PermissionType::Read) : []), + ]; + + if ($shouldUpdate) { + if (! $this->authorization->isValid(new Input(PermissionType::Update, $updatePermissions))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } else { + if (! $this->authorization->isValid(new Input(PermissionType::Read, $readPermissions))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + } + + if ($shouldUpdate) { + $document->setAttribute(Document::UPDATED_AT, ($newUpdatedAt === null || ! $this->datePreservation()->get()) ? $time : $newUpdatedAt); + } + + // Check if document was updated after the request timestamp + $oldUpdatedAt = new PhpDateTime($old->getUpdatedAt() ?? 'now'); + $requestTimestamp = $this->requestTimestamp()->get(); + if ($requestTimestamp !== null && $oldUpdatedAt > $requestTimestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + + $storedAttributes = []; + if ($this->validation()->get() && $collection->getId() !== self::METADATA) { + foreach ($document as $key => $value) { + if ($old->offsetExists($key) && self::valuesEqual($value, $old->getAttribute($key))) { + $storedAttributes[] = $key; + } + } + } + + $document = $this->encode($collection, $document); + + if ($this->validation()->get()) { + $structureValidator = new Structure( + collection: $collection, + profile: $this->profile(), + currentDocument: $old, + storedAttributes: $storedAttributes, + ); + if (! $structureValidator->isValid($document)) { // Make sure updated structure still apply collection rules (if any) + throw new StructureException($structureValidator->getDescription()); + } + } + + if ($this->relationshipHook?->isEnabled()) { + $document = $this->silent(fn () => $this->relationshipHook->afterDocumentUpdate($collection, $old, $document)); + } + + foreach ($document->getArrayCopy() as $value) { + if (Operator::isOperator($value)) { + $hasOperators = true; + break; + } + } + + $document = $this->castBefore($collection, $document); + + $this->authorization->skip(fn () => $this->adapter->updateDocument($collection, $old->getId(), $document, $skipPermissionsUpdate)); + + $document = $this->castAfterDocument($collection, $document); + + $purgedIds = \array_values(\array_unique([$id, $old->getId(), $document->getId()])); + + foreach ($purgedIds as $purgedId) { + $this->purgeCachedDocumentInternal($collection->getId(), $purgedId); + } + + foreach ($purgedIds as $purgedId) { + $this->queueDocumentPurge($collection->getId(), $purgedId); + } + + if ($hasOperators) { + $refetched = $this->refetchDocuments($collection, [$document]); + $document = $refetched[0]; + } + + return $document; + }); + + if ($document->isEmpty()) { + return $document; + } + + $hook = $this->relationshipHook; + if ($hook !== null && ! $hook->isInBatchPopulation() && $hook->isEnabled()) { + $documents = $this->silent(fn () => $hook->populateDocuments([$document], $collection, $hook->getFetchDepth())); + $document = $documents[0]; + } + + if (! $hasOperators) { + $document = $this->decode($collection, $document); + } + + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collection->getId()])) { + $document = $this->newDocument($collection->getId(), $document->getArrayCopy()); + } + + $document = $this->decorateDocument(Event::DocumentUpdate, $collection, $document); + + $listeners = $this->listens(Event::DocumentUpdate); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Updated($collection->getId(), $document), $listeners); + } + + return $document; + } + + /** + * Updates all documents which match the given queries. + * + * @param string $collection The collection identifier + * @param Document $updates The document containing fields to update + * @param array $queries Queries to filter documents for update + * @param int $batchSize Number of documents per batch update, at most BATCH_SIZE + * @param (callable(Document $document, ?Document $previous): void)|null $onNext Given each updated document once its + * batch is written, and a copy of the + * stored document it updated; an exception + * it throws aborts the call + * @return int The number of documents updated + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DuplicateException + * @throws LimitException When $batchSize is above BATCH_SIZE + * @throws QueryException + * @throws StructureException + * @throws TimeoutException + * @throws Throwable + * @throws Exception + */ + public function updateDocuments( + string $collection, + Document $updates, + array $queries = [], + int $batchSize = self::BATCH_SIZE, + ?callable $onNext = null, + ): int { + $batchSize = self::batchSize($batchSize); + + $this->rejectJoins($queries, 'Join queries are not supported for bulk updates'); + + if ($updates->isEmpty()) { + return 0; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input(PermissionType::Update, $collection->getPermissionsByType(PermissionType::Update))); + + if (! $skipAuth && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $this->checkQueryTypes($queries); + + if ($this->validation()->get()) { + $validator = $this->getQueriesValidator($collection, $queries); + + if (! $validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + + $parsed = Query::groupByType($queries); + $limit = $parsed->limit; + $cursor = $parsed->cursor; + + if (! empty($cursor) && $cursor->getCollection() !== $collection->getId()) { + throw new DatabaseException('Cursor document must be from the same Collection.'); + } + + unset($updates[Document::ID]); + unset($updates[Document::TENANT]); + + if (($updates->getCreatedAt() === null || ! $this->datePreservation()->get())) { + unset($updates[Document::CREATED_AT]); + } else { + $updates[Document::CREATED_AT] = $updates->getCreatedAt(); + } + + if ($this->adapter->hasSharedTables()) { + $updates[Document::TENANT] = $this->adapter->getTenant(); + } + + $updatedAt = $updates->getUpdatedAt(); + $updates[Document::UPDATED_AT] = ($updatedAt === null || ! $this->datePreservation()->get()) ? DateTime::now() : $updatedAt; + + $decodedUpdates = clone $updates; + $updates = $this->encode( + $collection, + $updates, + applyDefaults: false + ); + + if ($this->validation()->get()) { + $validator = new PartialStructure( + collection: $collection, + profile: $this->profile(), + ); + + if (! $validator->isValid($updates)) { + throw new StructureException($validator->getDescription()); + } + } + + $hasOperators = false; + $adapterData = []; + foreach ($updates->getArrayCopy() as $key => $value) { + if ($value instanceof Operator) { + $hasOperators = true; + $value = clone $value; + } + $adapterData[$key] = $value; + } + $selections = $this->validateSelections($collection, $parsed->selections); + $decodedKeys = $selections === [] + ? [] + : \array_values(\array_unique([...$selections, ...\array_map(\strval(...), \array_keys($adapterData))])); + $adapterUpdates = $this->castBefore($collection, new Document($adapterData)); + + $originalLimit = $limit; + $last = $cursor; + $modified = 0; + + while (true) { + if ($limit && $limit < $batchSize) { + $batchSize = $limit; + } elseif (! empty($limit)) { + $limit -= $batchSize; + } + + $new = [ + Query::limit($batchSize), + ]; + + if (! empty($last)) { + $new[] = Query::cursorAfter($last); + } + + $batch = $this->silent(fn () => $this->fetch( + $collection, + array_merge($new, $queries), + PermissionType::Update, + false, + )); + + if (empty($batch)) { + break; + } + + $old = array_map(fn ($doc) => clone $doc, $batch); + $currentPermissions = $updates->getPermissions(); + sort($currentPermissions); + + $cacheTarget = $collection->getId() === self::METADATA ? $batch : $collection->getId(); + $found = $batch; + $this->withMutation(Event::DocumentsUpdate, $cacheTarget, function () use ($collection, $updates, $decodedUpdates, $adapterUpdates, &$batch, $found, $currentPermissions) { + $keepsPermissions = []; + foreach ($found as $index => $document) { + $skipPermissionsUpdate = true; + + if ($updates->offsetExists(Document::PERMISSIONS)) { + if (! $document->offsetExists(Document::PERMISSIONS)) { + throw new QueryException('Permission document missing in select'); + } + + $originalPermissions = $document->getPermissions(); + + \sort($originalPermissions); + + $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); + } + + // An id repeats across tenants in a tenant-per-document batch: one that changes its permissions + // keeps the permission rows of every document under that id rewritten. + $keepsPermissions[$document->getId()] = $skipPermissionsUpdate && ($keepsPermissions[$document->getId()] ?? true); + + $updateData = []; + foreach ($decodedUpdates->getArrayCopy() as $key => $value) { + $updateData[$key] = $value instanceof Operator ? clone $value : $value; + } + $new = new Document(\array_merge($document->getArrayCopy(), $updateData)); + + $hook = $this->relationshipHook; + if ($hook?->isEnabled()) { + $this->silent(fn () => $hook->afterDocumentUpdate($collection, $document, $new)); + } + + try { + $oldUpdatedAt = new PhpDateTime($document->getUpdatedAt() ?? 'now'); + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + $requestTimestamp = $this->requestTimestamp()->get(); + if ($requestTimestamp !== null && $oldUpdatedAt > $requestTimestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + + $document = $new; + + $encoded = $this->encode($collection, $document); + $batch[$index] = $this->castBefore($collection, $encoded); + } + + $this->adapter->updateDocuments( + $collection, + $adapterUpdates, + $batch, + \array_filter($keepsPermissions), + ); + + foreach ($batch as $document) { + $this->withDocumentTenant( + $document, + fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) + ); + } + + $this->queueDocumentPurges($collection->getId(), $batch); + }); + + if ($hasOperators) { + $batch = $this->refetchDocuments($collection, $batch, $parsed->selections); + } + + // The operator refetch goes through find(), which already decoded every document; + // decoding again would run each decode filter twice. + /** @var array $batch */ + $batch = $this->castAfter($collection, $batch); + if (! $hasOperators) { + $batch = \array_map( + fn (Document $doc) => $this->decode($collection, $doc, $decodedKeys), + $batch + ); + } + + $batch = $this->decorateDocuments(Event::DocumentsUpdate, $collection, $batch); + + foreach ($batch as $index => $doc) { + if ($onNext !== null) { + $onNext($doc, $old[$index]); + } + $modified++; + } + + if (count($batch) < $batchSize) { + break; + } elseif ($originalLimit && $modified == $originalLimit) { + break; + } + + /** @var Document|false $last */ + $last = \end($batch); + } + + $listeners = $this->listens(Event::DocumentsUpdate); + if ($listeners !== []) { + $this->dispatch(new Event\Document\BatchUpdated($collection->getId(), $modified), $listeners); + } + + return $modified; + } + + /** + * Create or update a single document. + * + * @param string $collection The collection identifier + * @param Document $document The document to create or update + * @return Document The created or updated document, or the stored one when nothing changed, which fires no event + * + * @throws StructureException + * @throws Throwable + */ + public function upsertDocument( + string $collection, + Document $document, + ): Document { + $result = null; + $created = false; + + $this->upsert( + $collection, + [$document], + 1, + function (Document $upserted, ?Document $previous) use (&$result, &$created): void { + $result = $upserted; + $created = $previous === null; + }, + null, + ); + + if ($result === null) { + return $this->getDocument($collection, $document->getId()); + } + + $listeners = $this->listens(Event::DocumentUpsert); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Upserted($collection, $result, $created), $listeners); + } + + return $result; + } + + /** + * Create or update documents. With $increase, an update adds the given document's value of that attribute to + * the stored one instead of replacing it. + * + * @param string $collection The collection identifier + * @param array $documents The documents to create or update + * @param int $batchSize Number of documents per batch, at most BATCH_SIZE + * @param (callable(Document $document, ?Document $previous): void)|null $onNext Given each upserted document once its + * batch is written, and the stored document + * it updated, or null when it was created; + * an exception it throws aborts the call + * @param string|null $increase The attribute an update increases by the document's value + * @return int The number of documents created or updated + * + * @throws LimitException When $batchSize is above BATCH_SIZE + * @throws StructureException + * @throws Throwable + * @throws Exception + */ + public function upsertDocuments( + string $collection, + array $documents, + int $batchSize = self::BATCH_SIZE, + ?callable $onNext = null, + ?string $increase = null, + ): int { + [$created, $updated] = $this->upsert($collection, $documents, self::batchSize($batchSize), $onNext, $increase); + + $listeners = $documents === [] ? [] : $this->listens(Event::DocumentsUpsert); + if ($listeners !== []) { + $this->dispatch(new Event\Document\BatchUpserted($collection, $created, $updated), $listeners); + } + + return $created + $updated; + } + + /** + * @param array $documents + * @param int<1, max> $batchSize + * @param (callable(Document $document, ?Document $previous): void)|null $onNext + * @return array{int, int} How many documents were created and how many updated + * + * @throws StructureException + * @throws Throwable + * @throws Exception + */ + private function upsert( + string $collection, + array $documents, + int $batchSize, + ?callable $onNext, + ?string $increase, + ): array { + if (! $this->adapterHasFeature(Feature\Upserts::class)) { + throw new DatabaseException('Adapter does not support upserts'); + } + + if ( + $this->adapter->hasSharedTables() + && ! $this->adapter->isTenantPerDocument() + && empty($this->adapter->getTenant()) + ) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + if (! $this->adapter->hasSharedTables() && $this->adapter->isTenantPerDocument()) { + throw new DatabaseException('Shared tables must be enabled if tenant per document is enabled.'); + } + + if (empty($documents)) { + return [0, 0]; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $collectionAttributes = $collection->attributes(); + $time = DateTime::now(); + $created = 0; + $updated = 0; + $operatorIds = []; + $seenIds = []; + $hasRelationships = self::relationshipAttributes($collection) !== []; + $internalKeys = \array_flip(\array_map( + static fn (Attribute $attribute): string => $attribute->key, + self::internalAttributesFor(true) + )); + $existing = $this->findDocumentsToUpsert($collection->getId(), $documents); + + foreach ($documents as $key => $document) { + $old = $existing[$this->upsertKey($document)] ?? new Document(); + + $document = $this->removeUnknownAttributes($collection, $document); + + // Extract operators early to avoid comparison issues + $documentArray = $document->getArrayCopy(); + $extracted = Operator::extractOperators($documentArray); + $operators = $extracted['operators']; + $regularUpdates = $extracted['updates']; + + $regularUpdatesUserOnly = \array_diff_key($regularUpdates, $internalKeys); + + $skipPermissionsUpdate = true; + + if ($document->offsetExists(Document::PERMISSIONS)) { + $originalPermissions = $old->getPermissions(); + $currentPermissions = $document->getPermissions(); + + sort($originalPermissions); + sort($currentPermissions); + + $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); + } + + // Only skip if no operators and regular attributes haven't changed + $hasChanges = false; + if (! empty($operators)) { + $hasChanges = true; + } elseif (($increase ?? '') !== '') { + $hasChanges = true; + } elseif (! $skipPermissionsUpdate) { + $hasChanges = true; + } else { + // Check if any of the provided attributes differ from old document + $oldAttributes = $old->getAttributes(); + foreach ($regularUpdatesUserOnly as $attrKey => $value) { + $oldValue = $oldAttributes[$attrKey] ?? null; + if ($oldValue != $value) { + $hasChanges = true; + break; + } + } + + // Also check if old document has attributes that new document doesn't + if (! $hasChanges) { + $oldUserAttributes = \array_diff_key($oldAttributes, $internalKeys); + + foreach (array_keys($oldUserAttributes) as $oldAttrKey) { + if (! array_key_exists($oldAttrKey, $regularUpdatesUserOnly)) { + // Old document has an attribute that new document doesn't + $hasChanges = true; + break; + } + } + } + } + + if (! $hasChanges) { + // If not updating a single attribute and the document is the same as the old one, skip it + unset($documents[$key]); + + continue; + } + + // If old is empty, check if user has create permission on the collection + // If old is not empty, check if user has update permission on the collection + // If old is not empty AND documentSecurity is enabled, check if user has update permission on the collection or document + + if ($old->isEmpty()) { + if (! $this->authorization->isValid(new Input(PermissionType::Create, $collection->getPermissionsByType(PermissionType::Create)))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } elseif (! $this->authorization->isValid(new Input(PermissionType::Update, \array_merge( + $collection->getPermissionsByType(PermissionType::Update), + ((bool) $documentSecurity ? $old->getPermissionsByType(PermissionType::Update) : []) + )))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $updatedAt = $document->getUpdatedAt(); + + $document + ->setAttribute(Document::ID, empty($document->getId()) ? Id::unique() : $document->getId()) + ->setAttribute(Document::COLLECTION, $collection->getId()) + ->setAttribute(Document::UPDATED_AT, ($updatedAt === null || ! $this->datePreservation()->get()) ? $time : $updatedAt); + + if (! $this->sequencePreservation()->get()) { + $document->removeAttribute(Document::SEQUENCE); + } + + $createdAt = $document->getCreatedAt(); + if ($createdAt === null || ! $this->datePreservation()->get()) { + $document->setAttribute(Document::CREATED_AT, $old->isEmpty() ? $time : $old->getCreatedAt()); + } else { + $document->setAttribute(Document::CREATED_AT, $createdAt); + } + + // Force matching optional parameter sets + // Doesn't use decode as that intentionally skips null defaults to reduce payload size + foreach ($collectionAttributes as $declared) { + if (! $declared->required && ! \array_key_exists($declared->key, (array) $document)) { + $document->setAttribute( + $declared->key, + $old->getAttribute($declared->key, $declared->default) + ); + } + } + + if ($skipPermissionsUpdate) { + $document->setAttribute(Document::PERMISSIONS, $old->getPermissions()); + } + + if ($this->adapter->hasSharedTables()) { + if ($this->adapter->isTenantPerDocument()) { + if ($document->getTenant() === null) { + throw new DatabaseException('Missing tenant. Tenant must be set when tenant per document is enabled.'); + } + if (! $old->isEmpty() && $old->getTenant() !== $document->getTenant()) { + throw new DatabaseException('Tenant cannot be changed.'); + } + } else { + $document->setAttribute(Document::TENANT, $this->adapter->getTenant()); + } + } + + $document = $this->encode($collection, $document); + + if ($this->validation()->get()) { + $validator = new Structure( + collection: $collection, + profile: $this->profile(), + currentDocument: $old->isEmpty() ? null : $old + ); + + if (! $validator->isValid($document)) { + throw new StructureException($validator->getDescription()); + } + } + + if (! $old->isEmpty()) { + // Check if document was updated after the request timestamp + try { + $oldUpdatedAt = new PhpDateTime($old->getUpdatedAt() ?? 'now'); + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + $requestTimestamp = $this->requestTimestamp()->get(); + if ($requestTimestamp !== null && $oldUpdatedAt > $requestTimestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + } + + $hook = $this->relationshipHook; + if ($hook?->isEnabled()) { + $document = $this->silent(fn () => $hook->afterDocumentCreate($collection, $document)); + } + + $identity = $this->getDocumentIdentity($document); + $seenIds[] = $identity; + if (! empty($operators)) { + $operatorIds[$identity] = true; + } + $old = $this->castBefore($collection, $old); + $document = $this->castBefore($collection, $document); + + $documents[$key] = new Change( + old: $old, + new: $document + ); + } + + // Required because *some* DBs will allow duplicate IDs for upsert + if (\count($seenIds) !== \count(\array_unique($seenIds))) { + throw new DuplicateException('Duplicate document IDs found in the input array.'); + } + + foreach (\array_chunk($documents, $batchSize) as $chunk) { + /** + * @var array $chunk + */ + $hasOperators = false; + foreach ($chunk as $change) { + if (isset($operatorIds[$this->getDocumentIdentity($change->new)])) { + $hasOperators = true; + break; + } + } + + $batch = $this->withMutation( + Event::DocumentsUpsert, + \array_map(static fn (Change $change): Document => $change->new, $chunk), + function () use ($collection, $increase, $chunk): array { + if (! $this->adapterHasFeature(Feature\Upserts::class)) { + throw new DatabaseException('Adapter does not support upserts'); + } + + $adapter = $this->adapter; + $batch = $this->authorization->skip(fn () => $adapter->upsertDocuments( + $collection, + $chunk, + $increase, + )); + + foreach ($batch as $document) { + $this->withDocumentTenant( + $document, + fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) + ); + } + + $this->queueDocumentPurges($collection->getId(), $batch); + + return $batch; + } + ); + + foreach ($batch as $index => $document) { + if (empty($document->getSequence()) && ! empty($chunk[$index]->old->getSequence())) { + $document->setAttribute(Document::SEQUENCE, $chunk[$index]->old->getSequence()); + } + } + + if ($onNext !== null || $hasRelationships) { + $batch = $this->adapter->getSequences($collection, $batch); + } + + foreach ($chunk as $change) { + if ($change->old->isEmpty()) { + $created++; + } else { + $updated++; + } + } + + $hook = $this->relationshipHook; + if ($hook !== null && ! $hook->isInBatchPopulation() && $hook->isEnabled()) { + $batch = $this->silent(fn () => $hook->populateDocuments($batch, $collection, $hook->getFetchDepth())); + } + + if ($hasOperators && $onNext !== null) { + $batch = $this->refetchDocuments($collection, $batch); + } + + /** @var array $batch */ + $batch = $this->castAfter($collection, $batch); + if (! $hasOperators) { + $batch = \array_map( + fn (Document $doc) => $this->decode($collection, $doc), + $batch + ); + } + + $batch = $this->decorateDocuments(Event::DocumentsUpsert, $collection, $batch); + + foreach ($batch as $index => $doc) { + $old = $chunk[$index]->old; + + if (! $old->isEmpty()) { + $old = $this->castAfterDocument($collection, $old); + } + + if ($onNext !== null) { + $onNext($doc, $old->isEmpty() ? null : $old); + } + } + } + + return [$created, $updated]; + } + + /** + * Load the stored documents an upsert batch will be compared against, in one + * read per tenant instead of one per document. A batch of N documents costs a + * bounded number of round trips; getDocument() per document cost 2N, which + * doubled the stats-resources sweep and is what this replaces. + * + * @param array $documents + * @return array + * + * @throws Throwable + */ + private function findDocumentsToUpsert(string $collection, array $documents): array + { + $perTenant = $this->hasSharedTables() && $this->isTenantPerDocument(); + + $batches = []; + foreach ($documents as $document) { + if ($document->getId() === '') { + continue; + } + + $tenant = $perTenant ? $document->getTenant() : null; + $key = $tenant === null ? '' : (string) $tenant; + + if (! isset($batches[$key])) { + $batches[$key] = ['tenant' => $tenant, 'ids' => []]; + } + + $batches[$key]['ids'][] = $document->getId(); + } + + $existing = []; + foreach ($batches as $batch) { + foreach (\array_chunk(\array_values(\array_unique($batch['ids'])), \max(1, $this->maxQueryValues)) as $chunk) { + $read = fn (): array => $this->authorization->skip(fn () => $this->silent(fn () => $this->find($collection, [ + Query::equal(Document::ID, $chunk), + Query::limit($this->maxQueryValues), + ], forPermission: PermissionType::Update))); + + $found = $perTenant + ? $this->withTenant($batch['tenant'], $read) + : $read(); + + foreach ($found as $document) { + $existing[$this->upsertKey($document)] = $document; + } + } + } + + return $existing; + } + + /** + * Identity of a document within one upsert batch. Two tenants may hold the + * same document id, so the tenant is part of the key whenever a batch can + * span tenants. + */ + private function upsertKey(Document $document): string + { + return $this->hasSharedTables() && $this->isTenantPerDocument() + ? $document->getTenant().':'.$document->getId() + : $document->getId(); + } + + /** + * Increase a document attribute by a value + * + * @param string $collection The collection ID + * @param string $id The document ID + * @param string $attribute The attribute to increase + * @param int|float|string $value The value to increase the attribute by, a number greater than 0 + * @param int|float|string|null $max The maximum value the attribute can reach after the increase, null means no limit + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws LimitException + * @throws NotFoundException + * @throws TypeException + * @throws Throwable + */ + public function increaseDocumentAttribute( + string $collection, + string $id, + string $attribute, + int|float|string $value = 1, + int|float|string|null $max = null + ): Document { + $this->assertPositiveChange($value); + + $collection = $this->silent(fn () => $this->getCollection($collection)); + $numericAttribute = $this->numericAttribute($collection, $attribute); + + if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { + $this->assertIntegerChange($value); + if ($max !== null) { + $max = $this->integerBound($max, 'Max'); + } + } + + $cacheTarget = $collection->getId() === self::METADATA + ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) + : $collection->getId(); + $document = $this->withMutation(Event::DocumentIncrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $max, $numericAttribute) { + /** @var Document $document */ + $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->readDocument($collection, $id, forUpdate: true))); // Skip ensures user does not need read permission for this + + if ($document->isEmpty()) { + throw new NotFoundException('Document not found'); + } + + if ($collection->getId() !== self::METADATA) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + + if (! $this->authorization->isValid(new Input(PermissionType::Update, \array_merge( + $collection->getPermissionsByType(PermissionType::Update), + ((bool) $documentSecurity ? $document->getPermissionsByType(PermissionType::Update) : []) + )))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + + $attributeExists = $document->offsetExists($attribute); + $currentVal = $document->getAttribute($attribute); + if ($numericAttribute instanceof Attribute) { + $result = $this->getNumericResult($numericAttribute, $currentVal, $value, true); + } else { + if (! $attributeExists) { + $currentVal = 0; + } + if (! \is_int($currentVal) && ! \is_float($currentVal)) { + throw new TypeException('Attribute value must be numeric.'); + } + $result = $currentVal + $this->getNativeNumber($value); + } + $exceedsMaximum = ! \is_null($max) && ( + $numericAttribute?->isInteger() === true + ? BigInt::compare($result, $max) > 0 + : $result > $max + ); + if ($exceedsMaximum) { + throw new LimitException('Attribute value exceeds maximum limit: '.$max); + } + + $time = DateTime::nowAfter($document->getUpdatedAt()); + $updatedAt = $document->getUpdatedAt(); + $updatedAt = (empty($updatedAt) || ! $this->datePreservation()->get()) ? $time : DateTime::setTimezone($updatedAt); + if ($max !== null) { + $max = $numericAttribute?->isInteger() === true + ? BigInt::subtract($max, $value) + : $this->getNativeNumber($max) - $this->getNativeNumber($value); + } + + $this->adapter->increaseDocumentAttribute( + $collection, + $id, + $attribute, + $numericAttribute?->isInteger() === true + ? BigInt::toNative($value) + : $this->getNativeNumber($value), + $updatedAt, + max: $max + ); + + $this->purgeCachedDocumentInternal($collection->getId(), $id); + $this->queueDocumentPurge($collection->getId(), $id); + + return $document->setAttribute($attribute, $result); + }); + + $listeners = $this->listens(Event::DocumentIncrease); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Increased($collection->getId(), $document, $attribute), $listeners); + } + + return $document; + } + + /** + * @throws TypeException + */ + private function assertPositiveChange(int|float|string $value): void + { + if (! \is_numeric($value) || (\is_string($value) && BigInt::isIntegerString($value) + ? BigInt::compare($value, 0) <= 0 + : (float) $value <= 0)) { + throw new TypeException('Value must be numeric and greater than 0'); + } + } + + /** + * Decrease a document attribute by a value. + * + * @param string $collection The collection identifier + * @param string $id The document identifier + * @param string $attribute The attribute to decrease + * @param int|float|string $value The value to decrease the attribute by, a number greater than 0 + * @param int|float|string|null $min The minimum value the attribute can reach, null means no limit + * @return Document The updated document + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws TypeException When $value is not a number greater than 0 + */ + public function decreaseDocumentAttribute( + string $collection, + string $id, + string $attribute, + int|float|string $value = 1, + int|float|string|null $min = null + ): Document { + $this->assertPositiveChange($value); + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $numericAttribute = $this->numericAttribute($collection, $attribute); + + if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { + $this->assertIntegerChange($value); + if ($min !== null) { + $min = $this->integerBound($min, 'Min'); + } + } + + $cacheTarget = $collection->getId() === self::METADATA + ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) + : $collection->getId(); + $document = $this->withMutation(Event::DocumentDecrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $min, $numericAttribute) { + /** @var Document $document */ + $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->readDocument($collection, $id, forUpdate: true))); // Skip ensures user does not need read permission for this + + if ($document->isEmpty()) { + throw new NotFoundException('Document not found'); + } + + if ($collection->getId() !== self::METADATA) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + + if (! $this->authorization->isValid(new Input(PermissionType::Update, \array_merge( + $collection->getPermissionsByType(PermissionType::Update), + ((bool) $documentSecurity ? $document->getPermissionsByType(PermissionType::Update) : []) + )))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + + $attributeExists = $document->offsetExists($attribute); + $currentDecVal = $document->getAttribute($attribute); + if ($numericAttribute instanceof Attribute) { + $result = $this->getNumericResult($numericAttribute, $currentDecVal, $value, false); + } else { + if (! $attributeExists) { + $currentDecVal = 0; + } + if (! \is_int($currentDecVal) && ! \is_float($currentDecVal)) { + throw new TypeException('Attribute value must be numeric.'); + } + $result = $currentDecVal - $this->getNativeNumber($value); + } + $belowMinimum = ! \is_null($min) && ( + $numericAttribute?->isInteger() === true + ? BigInt::compare($result, $min) < 0 + : $result < $min + ); + if ($belowMinimum) { + throw new LimitException('Attribute value exceeds minimum limit: '.$min); + } + + $time = DateTime::nowAfter($document->getUpdatedAt()); + $updatedAt = $document->getUpdatedAt(); + $updatedAt = (empty($updatedAt) || ! $this->datePreservation()->get()) ? $time : DateTime::setTimezone($updatedAt); + if ($min !== null) { + $min = $numericAttribute?->isInteger() === true + ? BigInt::add($min, $value) + : $this->getNativeNumber($min) + $this->getNativeNumber($value); + } + + $this->adapter->increaseDocumentAttribute( + $collection, + $id, + $attribute, + $numericAttribute?->isInteger() === true + ? BigInt::negate($value) + : $this->getNativeNumber($value) * -1, + $updatedAt, + min: $min + ); + + $this->purgeCachedDocumentInternal($collection->getId(), $id); + $this->queueDocumentPurge($collection->getId(), $id); + + return $document->setAttribute($attribute, $result); + }); + + $listeners = $this->listens(Event::DocumentDecrease); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Decreased($collection->getId(), $document, $attribute), $listeners); + } + + return $document; + } + + /** + * Also fires Event::DocumentUpdate for each document on the other side of a two-way + * relationship that the delete changed, after Event::DocumentDelete. See + * Hook\Relationships::beforeDocumentDelete() for which documents those are, their shape + * and their trust level. + * + * @param string $collection The collection identifier + * @param string $id The document identifier + * @return bool True if the document was deleted successfully + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws RestrictedException + */ + public function deleteDocument(string $collection, string $id): bool + { + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $cacheTarget = $collection->getId() === self::METADATA + ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) + : $collection->getId(); + $updateListeners = $this->listens(Event::DocumentUpdate); + $report = $updateListeners !== []; + $changed = []; + $deleted = $this->withMutation(Event::DocumentDelete, $cacheTarget, function () use ($collection, $id, $report, &$changed): ?Document { + $changed = []; + $document = $this->authorization->skip(fn () => $this->silent( + fn () => $this->readDocument($collection, $id, forUpdate: true) + )); + + if ($document->isEmpty()) { + return null; + } + + if ($collection->getId() !== self::METADATA) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + + if (! $this->authorization->isValid(new Input(PermissionType::Delete, [ + ...$collection->getPermissionsByType(PermissionType::Delete), + ...($documentSecurity ? $document->getPermissionsByType(PermissionType::Delete) : []), + ]))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + + try { + $oldUpdatedAt = new PhpDateTime($document->getUpdatedAt() ?? 'now'); + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + $requestTimestamp = $this->requestTimestamp()->get(); + if ($requestTimestamp !== null && $oldUpdatedAt > $requestTimestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + + if ($this->relationshipHook?->isEnabled()) { + $changed = $this->silent(fn () => $this->relationshipHook->beforeDocumentDelete($collection, $document, $report)); + } + + $result = $this->authorization->skip(fn () => $this->adapter->deleteDocument($collection, $id)); + + $this->purgeCachedDocumentInternal($collection->getId(), $id); + + if ($result) { + $this->queueDocumentPurge($collection->getId(), $id); + } + + return $result ? $document : null; + }); + + if ($deleted === null) { + return false; + } + + $this->dispatchDeleted($collection->getId(), $deleted, $changed, $updateListeners); + + return true; + } + + /** + * The delete's transaction has returned, so a failing hook cannot undo it: every event still + * fires, and the first failure reaches the caller once they have. + * + * @param list $changed + * @param list $updateListeners The hooks the related documents' updates are dispatched to + */ + private function dispatchDeleted(string $collection, Document $document, array $changed, array $updateListeners): void + { + $failure = null; + + try { + $listeners = $this->listens(Event::DocumentDelete); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Deleted($collection, $document), $listeners); + } + } catch (Throwable $error) { + $failure = $error; + } + + foreach ($updateListeners === [] ? [] : $changed as $related) { + try { + $this->dispatch(new Event\Document\Updated($related->getCollection(), $related), $updateListeners); + } catch (Throwable $error) { + $failure ??= $error; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * Deletes all documents which match the given queries, respecting relationship onDelete options. + * + * @param string $collection The collection identifier + * @param array $queries Queries to filter documents for deletion + * @param int $batchSize Number of documents per batch deletion, at most BATCH_SIZE + * @param (callable(Document $document, ?Document $previous): void)|null $onNext Given each deleted document once its + * batch is deleted: the stored document, + * and as $previous a copy of it; an + * exception it throws aborts the call + * @return int The number of documents deleted + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws LimitException When $batchSize is above BATCH_SIZE + * @throws QueryException + * @throws RestrictedException + * @throws Throwable + */ + public function deleteDocuments( + string $collection, + array $queries = [], + int $batchSize = self::BATCH_SIZE, + ?callable $onNext = null, + ): int { + $batchSize = self::batchSize($batchSize); + + $this->rejectJoins($queries, 'Join queries are not supported for bulk deletes'); + + if ($this->adapter->hasSharedTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input(PermissionType::Delete, $collection->getPermissionsByType(PermissionType::Delete))); + + if (! $skipAuth && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $this->checkQueryTypes($queries); + + if ($this->validation()->get()) { + $validator = $this->getQueriesValidator($collection, $queries); + + if (! $validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + + $parsed = Query::groupByType($queries); + $limit = $parsed->limit; + $cursor = $parsed->cursor; + + if (! empty($cursor) && $cursor->getCollection() !== $collection->getId()) { + throw new DatabaseException('Cursor document must be from the same Collection.'); + } + + $originalLimit = $limit; + $last = $cursor; + $modified = 0; + + while (true) { + if ($limit && $limit < $batchSize && $limit > 0) { + $batchSize = $limit; + } elseif (! empty($limit)) { + $limit -= $batchSize; + } + + $new = [ + Query::limit($batchSize), + ]; + + if (! empty($last)) { + $new[] = Query::cursorAfter($last); + } + + /** + * @var array $batch + */ + $batch = $this->silent(fn () => $this->fetch( + $collection, + array_merge($new, $queries), + PermissionType::Delete, + false, + )); + + if (empty($batch)) { + break; + } + + $sequences = []; + $permissionIds = []; + + $cacheTarget = $collection->getId() === self::METADATA ? $batch : $collection->getId(); + $this->withMutation(Event::DocumentsDelete, $cacheTarget, function () use ($collection, $sequences, $permissionIds, $batch) { + foreach ($batch as $document) { + $seq = $document->getSequence(); + if ($seq !== null) { + $sequences[] = $seq; + } + if (! empty($document->getPermissions())) { + $permissionIds[] = $document->getId(); + } + + if ($this->relationshipHook?->isEnabled()) { + $this->silent(fn () => $this->relationshipHook->beforeDocumentDelete( + $collection, + $document + )); + } + + // Check if document was updated after the request timestamp + try { + $oldUpdatedAt = new PhpDateTime($document->getUpdatedAt() ?? 'now'); + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + $requestTimestamp = $this->requestTimestamp()->get(); + if ($requestTimestamp !== null && $oldUpdatedAt > $requestTimestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + } + + $this->adapter->deleteDocuments( + $collection, + $sequences, + $permissionIds + ); + + foreach ($batch as $document) { + $this->withDocumentTenant( + $document, + fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) + ); + } + + $this->queueDocumentPurges($collection->getId(), $batch); + }); + + foreach ($batch as $document) { + if ($onNext !== null) { + $onNext($document, clone $document); + } + $modified++; + } + + if (count($batch) < $batchSize) { + break; + } elseif ($originalLimit && $modified >= $originalLimit) { + break; + } + + $last = \end($batch); + } + + $listeners = $this->listens(Event::DocumentsDelete); + if ($listeners !== []) { + $this->dispatch(new Event\Document\BatchDeleted($collection->getId(), $modified), $listeners); + } + + return $modified; + } + + /** + * Cleans all of the collection's documents from the cache and all related cached documents. + */ + public function purgeCachedCollection(string $collection): void + { + if ($collection === self::METADATA) { + $this->purgeCachedDefinitions(); + $this->queryCache?->invalidateCollection($this->getQueryCacheScope(), $collection); + + return; + } + + [$collectionKey] = $this->getCacheKeys($collection); + + $this->advanceDocumentCacheEpoch($collectionKey, $this->getDefinitionCacheKey($collection)); + $this->queryCache?->invalidateCollection($this->getQueryCacheScope(), $collection); + } + + /** + * Purge a document's cache slot, and once more after the open invalidation scope ends. + * + * @throws Exception + */ + protected function purgeCachedDocumentInternal(string $collectionId, ?string $id): bool + { + if ($id === null) { + return true; + } + + [$collectionKey, $documentKey] = $this->getCacheBaseKeys($collectionId, $id); + + $context = $this->getEventContext(); + if (isset($this->documentCachePurges[$context])) { + $this->documentCachePurges[$context][$documentKey] = $collectionKey; + if ($collectionId !== self::METADATA) { + $this->documentCacheDefinitions[$context][$collectionKey] = $this->getDefinitionCacheKey($collectionId); + } + } + if (isset($this->transactionWrites[$context])) { + $this->transactionWrites[$context][\strtolower($documentKey)] = true; + unset( + $this->transactionDefinitions[$context][\strtolower($documentKey)], + $this->definitionRefills[$context][\strtolower($documentKey)], + ); + } + + $this->cache->purge($documentKey); + + return true; + } + + /** + * A batch write retires the documents of their collection at once; a batch write to `_metadata` + * purges each definition it wrote instead, as a cached definition is checked against no epoch. + */ + private function advanceCollectionCacheEpoch(string $collectionId, string $documentId): bool + { + if ($collectionId === self::METADATA) { + return $this->purgeCachedDocumentInternal(self::METADATA, $documentId); + } + + [$collectionKey] = $this->getCacheBaseKeys($collectionId); + + return $this->advanceDocumentCacheEpoch($collectionKey, $this->getDefinitionCacheKey($collectionId)); + } + + /** + * The cache key of a collection's definition, which carries the epoch its documents are cached under. + */ + private function getDefinitionCacheKey(string $collectionId): string + { + return $this->getCacheBaseKeys(self::METADATA, $collectionId)[1]; + } + + /** + * Cached definitions are checked against no epoch, so each one the database lists is purged. + */ + private function purgeCachedDefinitions(): void + { + $this->silent(fn () => $this->authorization->skip(function (): void { + foreach ($this->cursor(self::METADATA, batchSize: 25) as $definition) { + $this->cache->purge($this->getDefinitionCacheKey($definition->getId())); + } + })); + } + + /** + * Purge the documents a transaction wrote once it has committed or rolled back. A reader + * outside the transaction may have cached the pre-commit row after the purge inside it, so + * when this purge fails the collection's epoch is retired instead, which no such fill survives. + * A collection definition has no epoch of its own: its purge is tried once more. + * + * @param array $documents Collection keys by document key + */ + protected function purgeWrittenDocuments(array $documents): void + { + $definitions = $this->documentCacheDefinitions[$this->getEventContext()] ?? []; + $failure = null; + $retired = []; + foreach ($documents as $documentKey => $collectionKey) { + try { + $this->cache->purge($documentKey); + } catch (Throwable $error) { + $failure ??= $error; + try { + if (! isset($definitions[$collectionKey])) { + $this->cache->purge($documentKey); + } elseif (! isset($retired[$collectionKey])) { + $retired[$collectionKey] = true; + $this->advanceDocumentCacheEpoch($collectionKey, $definitions[$collectionKey]); + } + } catch (Throwable) { + // The purge failure below reaches the caller either way. + } + } + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * The epoch a collection's documents may be cached under, or null while a write to the collection + * is in flight, with the time that write blocked it. A tombstone older than the writer timeout + * lapses into an epoch of its own, which a later activation changes. + */ + private function loadDocumentCacheState(string $collectionKey): Epoch + { + $now = \time(); + + try { + $record = $this->cache->load($collectionKey.'#epoch', self::DOCUMENT_CACHE_PERMANENT); + if (! \is_string($record) || $record === '') { + return $this->restoreDocumentCacheEpoch($collectionKey, $now); + } + + $separator = \strrpos($record, self::DOCUMENT_CACHE_SEPARATOR); + $marker = $separator === false ? $record : \substr($record, 0, $separator); + $stamp = $separator === false ? '' : \substr($record, $separator + 1); + + if (\str_starts_with($record, self::DOCUMENT_CACHE_BLOCKED_PREFIX)) { + $blockedAt = \ctype_digit($stamp) ? (int) $stamp : 0; + if ($blockedAt + $this->cacheWriterTimeout > $now) { + return new Epoch(blockedAt: $blockedAt); + } + + $tombstone = \substr($record, \strlen(self::DOCUMENT_CACHE_BLOCKED_PREFIX)); + $finished = $this->cache->getGeneration($collectionKey.'#finished'); + + return new Epoch(self::DOCUMENT_CACHE_LAPSED_PREFIX.$tombstone.self::DOCUMENT_CACHE_SEPARATOR.$finished); + } + + $started = $this->cache->getGeneration($collectionKey.'#started'); + if ( + ($separator !== false && $started === $stamp) + || $started === $this->cache->getGeneration($collectionKey.'#finished') + ) { + return new Epoch($marker); + } + + return $this->restoreDocumentCacheEpoch($collectionKey, $now); + } catch (Throwable $error) { + Console::warning('Warning: Failed to load document cache epoch: '.$error->getMessage()); + + return new Epoch(blockedAt: $now); + } + } + + /** + * Replace a missing or unusable epoch: with a fresh one when no write is counted in flight, or + * else with a tombstone of its own, so the collection lapses back into the cache after the writer + * timeout even when the write that blocked it left no tombstone behind. + */ + private function restoreDocumentCacheEpoch(string $collectionKey, int $now): Epoch + { + $started = $this->cache->getGeneration($collectionKey.'#started'); + if ($started !== $this->cache->getGeneration($collectionKey.'#finished')) { + $this->cache->save($collectionKey.'#epoch', self::DOCUMENT_CACHE_BLOCKED_PREFIX.$this->createDocumentCacheToken().self::DOCUMENT_CACHE_SEPARATOR.$now); + + return new Epoch(blockedAt: $now); + } + + $epoch = self::DOCUMENT_CACHE_ACTIVE_PREFIX.\bin2hex(\random_bytes(16)); + if ($this->cache->save($collectionKey.'#epoch', $epoch.self::DOCUMENT_CACHE_SEPARATOR.$started) === false) { + return new Epoch(blockedAt: $now); + } + + return new Epoch($epoch); + } + + /** + * A write's token, which records when it was created so a later activation can tell an abandoned write. + */ + private function createDocumentCacheToken(): string + { + return \time().self::DOCUMENT_CACHE_TOKEN_SEPARATOR.\bin2hex(\random_bytes(16)); + } + + private function advanceDocumentCacheEpoch(string $collectionKey, string $definitionKey): bool + { + $context = $this->getEventContext(); + if (isset($this->documentCacheMutations[$context][$collectionKey])) { + return true; + } + + $token = $this->createDocumentCacheToken(); + if (! $this->blockDocumentCacheEpoch($collectionKey, $token, $definitionKey)) { + return true; + } + + if (isset($this->documentCacheMutations[$context])) { + $this->documentCacheMutations[$context][$collectionKey] = $token; + $this->documentCacheDefinitions[$context][$collectionKey] = $definitionKey; + + return true; + } + + $this->activateDocumentCacheEpoch($collectionKey, $token, $definitionKey); + + return true; + } + + /** + * Publish a tombstone before the write, then drop the definition that carried the previous + * epoch, so readers refill it with the tombstone. + */ + private function blockDocumentCacheEpoch(string $collectionKey, string $token, string $definitionKey): bool + { + $epochKey = $collectionKey.'#epoch'; + if (! (new Owners($this->cache))->register($collectionKey, $token)) { + $epoch = $this->cache->load($epochKey, self::DOCUMENT_CACHE_PERMANENT); + if ($epoch === false || $epoch === null) { + return false; + } + + throw new RuntimeException("Failed to register document cache owner '{$token}' for '{$collectionKey}'"); + } + + if ($this->cache->save($epochKey, self::DOCUMENT_CACHE_BLOCKED_PREFIX.$token.self::DOCUMENT_CACHE_SEPARATOR.\time()) === false) { + throw new RuntimeException("Failed to block document cache epoch '{$epochKey}'"); + } + + $this->cache->purge($collectionKey.'#started'); + $this->purgeCachedDefinition($definitionKey); + + return true; + } + + private function purgeCachedDefinition(string $definitionKey): void + { + if ($definitionKey !== '') { + $this->cache->purge($definitionKey); + } + } + + /** + * @param array $tokens + */ + protected function activateDocumentInvalidation(array $tokens): void + { + $context = $this->getEventContext(); + $definitions = $this->documentCacheDefinitions[$context] ?? []; + unset($this->documentCacheDefinitions[$context]); + + $failure = null; + foreach ($tokens as $collectionKey => $token) { + try { + $this->activateDocumentCacheEpoch($collectionKey, $token, $definitions[$collectionKey] ?? ''); + } catch (Throwable $error) { + $failure ??= $error; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * Replace this write's tombstone with a fresh epoch once no other write to the collection is in + * flight. Writes older than the writer timeout no longer count as in flight: their registrations + * are released and the epoch is published. + */ + private function activateDocumentCacheEpoch(string $collectionKey, string $token, string $definitionKey): void + { + $registration = (new Owners($this->cache))->find($collectionKey, $token); + $owner = $this->cache->load($registration->key, self::TTL, $registration->field); + if ($owner !== false && $owner !== null && $owner !== $token) { + throw new RuntimeException("Invalid document cache owner '{$token}' for '{$collectionKey}'"); + } + $owned = $owner === $token; + if ($owned && ! $this->cache->purge($registration->key, $registration->field)) { + $owner = $this->cache->load($registration->key, self::TTL, $registration->field); + if ($owner !== false && $owner !== null) { + throw new RuntimeException("Failed to release document cache owner '{$token}' for '{$collectionKey}'"); + } + $owned = false; + } + + $startedKey = $collectionKey.'#started'; + $finishedKey = $collectionKey.'#finished'; + $started = $this->cache->getGeneration($startedKey); + $finished = $this->cache->getGeneration($finishedKey); + $epochKey = $collectionKey.'#epoch'; + $epoch = $this->cache->load($epochKey, self::DOCUMENT_CACHE_PERMANENT); + $blocked = \is_string($epoch) && \str_starts_with($epoch, self::DOCUMENT_CACHE_BLOCKED_PREFIX); + $ours = $blocked && \str_starts_with($epoch, self::DOCUMENT_CACHE_BLOCKED_PREFIX.$token.self::DOCUMENT_CACHE_SEPARATOR); + + if ($started === $finished) { + if (! $blocked || $ours) { + $this->publishDocumentCacheEpoch($collectionKey, $finished, $definitionKey); + } + + return; + } + + if (! $owned && ! $ours) { + // This token was cleared by a cache flush or released as abandoned. Leave another + // writer's tombstone fail-closed, but retire whatever readers filled while it ran. + if (\is_string($epoch) && ! $blocked) { + $this->publishDocumentCacheEpoch($collectionKey, $started, $definitionKey); + } + + return; + } + + $this->cache->purge($finishedKey); + $nextFinished = $this->cache->getGeneration($finishedKey); + + // A cache flush restarts generations, so an unchanged #finished + // only proves this purge was lost while the epoch read with it is + // still in place; publish the new epoch after this check, not before. + if ( + $nextFinished === $finished + && \is_string($epoch) + && $this->cache->load($epochKey, self::DOCUMENT_CACHE_PERMANENT) === $epoch + ) { + throw new RuntimeException("Failed to finish document cache invalidation '{$epochKey}'"); + } + + $nextStarted = $this->cache->getGeneration($startedKey); + if ($nextStarted === $nextFinished) { + $this->publishDocumentCacheEpoch($collectionKey, $nextFinished, $definitionKey); + + return; + } + + if ($registration->field !== '' && $this->releaseAbandonedDocumentCacheOwners($registration->key)) { + $this->publishDocumentCacheEpoch($collectionKey, $nextStarted, $definitionKey); + + return; + } + + $this->purgeCachedDefinition($definitionKey); + } + + /** + * An epoch carries the started generation it was published at: until the next write starts, it is + * current even while writes judged abandoned still count as unfinished. + */ + private function publishDocumentCacheEpoch(string $collectionKey, string $started, string $definitionKey): void + { + $epochKey = $collectionKey.'#epoch'; + $epoch = self::DOCUMENT_CACHE_ACTIVE_PREFIX.\bin2hex(\random_bytes(16)).self::DOCUMENT_CACHE_SEPARATOR.$started; + if ($this->cache->save($epochKey, $epoch) === false) { + throw new RuntimeException("Failed to activate document cache epoch '{$epochKey}'"); + } + + $this->purgeCachedDefinition($definitionKey); + } + + /** + * Release every other writer still registered when all of them are older than the writer timeout. + * A token without a creation time counts as live. + */ + private function releaseAbandonedDocumentCacheOwners(string $owners): bool + { + $now = \time(); + $abandoned = []; + foreach ($this->cache->list($owners) as $token) { + $separator = \strpos($token, self::DOCUMENT_CACHE_TOKEN_SEPARATOR); + $created = $separator === false ? '' : \substr($token, 0, $separator); + if (! \ctype_digit($created) || (int) $created + $this->cacheWriterTimeout > $now) { + return false; + } + + $abandoned[] = $token; + } + + foreach ($abandoned as $token) { + $this->cache->purge($owners, $token); + } + + return true; + } + + /** + * Run a cache operation under the document's tenant when tenant-per-document is enabled. + * + * @param callable(): mixed $callback + */ + private function withDocumentTenant(Document $document, callable $callback): void + { + $tenant = $document->getTenant(); + + // A tenant of null is not a tenant to switch to. Collection definitions + // are the one kind of row createDocument() lets through without one + // under tenant-per-document, and readers still resolve their cache key + // under the adapter's tenant, so borrowing the document's null here + // purged an epoch no reader ever looks at and left every cached + // _metadata entry - a negative marker above all - live for its full TTL. + if ($this->hasSharedTables() && $this->isTenantPerDocument() && $tenant !== null) { + $this->withTenant($tenant, $callback); + + return; + } + + $callback(); + } + + private function getDocumentIdentity(Document $document): string + { + if (! $this->adapter->isTenantPerDocument()) { + return $document->getId(); + } + + return ($document->getTenant() ?? '').'\0'.$document->getId(); + } + + /** + * Cleans a specific document from cache and triggers Event::DocumentPurge, whose + * lifecycle hook exceptions reach the caller. + * + * Note: Do not retry this method as it triggers events. Use purgeCachedDocumentInternal() with retry instead. + * + * @throws Exception + */ + public function purgeCachedDocument(string $collection, string $id): void + { + $this->purgeCachedDocumentInternal($collection, $id); + + $this->invalidate(Event::DocumentPurge, new Document([ + Document::ID => $id, + Document::COLLECTION => $collection, + ])); + + $listeners = $this->listens(Event::DocumentPurge); + if ($listeners !== []) { + $this->dispatchPropagating(new Event\Document\Purged($collection, $id), $listeners); + } + } + + /** + * Announce the purge of a written document once the outermost transaction of its + * invalidation scope has committed, under the tenant and the hook silences in force + * when it was written. A write the adapter holds no transaction for is already + * durable and announces at once. + */ + private function queueDocumentPurge(string $collectionId, string $id): void + { + $listeners = $this->listens(Event::DocumentPurge); + if ($listeners === []) { + return; + } + + $purged = new Event\Document\Purged($collectionId, $id); + + if (! $this->adapter->inTransaction()) { + $this->dispatchPropagating($purged, $listeners); + + return; + } + + $context = $this->getEventContext(); + $tenant = $this->getTenant(); + $silenced = \array_keys($this->silencedListeners()->get()); + $announce = fn () => $this->dispatchPropagating($purged, $this->listens(Event::DocumentPurge)); + + $this->documentPurgeEvents[$context][] = function () use ($tenant, $silenced, $announce): void { + $this->withTenant( + $tenant, + $silenced === [] ? $announce : fn () => $this->silent($announce, $silenced), + ); + }; + } + + /** + * @param array $documents + */ + private function queueDocumentPurges(string $collectionId, array $documents): void + { + foreach ($documents as $document) { + $this->withDocumentTenant( + $document, + fn () => $this->queueDocumentPurge($collectionId, $document->getId()) + ); + } + } + + /** + * Purge every cached query result of a collection namespace: the find() query + * cache and the caller-owned withCache() region. + */ + public function purgeCachedQueries(string $collection, ?string $namespace = null): bool + { + $collection = $this->silent(fn () => $this->findCollection($collection))?->getId() ?? $collection; + $epochKey = $this->getQueryCacheKey($collection, $namespace).'#epoch'; + + try { + $existing = $this->cache->load($epochKey, self::TTL); + $rotated = ($existing === false || $existing === null || $this->cache->purge($epochKey)) + && $this->cache->save($epochKey, \bin2hex(\random_bytes(16))) !== false; + } catch (Exception $error) { + Console::warning('Warning: Failed to purge the cached queries: '.$error->getMessage()); + $rotated = false; + } + + try { + $this->queryCache?->invalidateCollection($this->getQueryCacheScope($namespace), $collection); + } catch (Exception $error) { + Console::warning('Warning: Failed to purge the query cache: '.$error->getMessage()); + + return false; + } + + return $rotated; + } + + /** + * Execute a callback behind a generation-protected cache-aside lookup. + * + * @template T + * @param callable(): T $callback + * @return T + * + * @throws AuthorizationException + */ + public function withCache( + string $key, + callable $callback, + ?string $hash = '', + ): mixed { + if ($hash === null || $this->adapter->inTransaction()) { + return $callback(); + } + + $epochKey = $key.'#epoch'; + try { + $epoch = $this->cache->load($epochKey, self::TTL); + if ($epoch === false || $epoch === null) { + $epoch = \bin2hex(\random_bytes(16)); + if ($this->cache->save($epochKey, $epoch) === false) { + return $callback(); + } + } + if (! \is_string($epoch) || $epoch === '') { + return $callback(); + } + } catch (Throwable $error) { + Console::warning('Warning: Failed to load cache epoch: '.$error->getMessage()); + + return $callback(); + } + + $physicalKey = $key.'#'.$epoch.':'.$hash; + + $shouldRefreshCache = false; + + try { + $cached = $this->cache->load($physicalKey, self::TTL); + } catch (Throwable $error) { + Console::warning('Warning: Failed to load cache value: '.$error->getMessage()); + $cached = false; + } + + if ($cached !== false && $cached !== null) { + $cachedValue = \is_array($cached) && \array_key_exists('value', $cached) + ? $cached['value'] + : false; + + if ($cachedValue !== false) { + $decoded = $cachedValue; + $collectionId = $cached['collection'] ?? null; + + if (\is_string($collectionId) && $collectionId !== '') { + $collection = $this->silent(fn () => $this->findCollection($collectionId)); + + if ($collection === null) { + $decoded = false; + } else { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getPermissionsByType(PermissionType::Read))); + + if (! $skipAuth && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $type = $cached['type'] ?? null; + $payload = $type === 'document' ? [$cachedValue] : $cachedValue; + + if (! \is_array($payload)) { + $decoded = false; + } else { + $documents = []; + + foreach ($payload as $item) { + if (! \is_array($item)) { + $decoded = false; + break; + } + + /** @var array $item */ + $document = $this->newDocument($collection->getId(), $item); + $document = $this->casting($collection, $document); + + if ($this->isTtlExpired($collection, $document)) { + $decoded = false; + break; + } + + if (! $skipAuth && $documentSecurity && $collection->getId() !== self::METADATA) { + if (! $this->authorization->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read)))) { + if ($type === 'document') { + $decoded = false; + break; + } + + continue; + } + } + + $documents[] = $document; + } + + if ($decoded !== false) { + $decoded = $type === 'document' ? ($documents[0] ?? false) : $documents; + } + } + } + } + + if ($decoded !== false) { + return $decoded; + } + } + + $shouldRefreshCache = true; + } + + if ($shouldRefreshCache) { + try { + $this->cache->purge($physicalKey); + } catch (Throwable $error) { + Console::warning('Warning: Failed to purge rejected cache value: '.$error->getMessage()); + } + } + + $generation = '0'; + try { + $generation = $this->cache->getGeneration($physicalKey); + } catch (Throwable $error) { + Console::warning('Warning: Failed to get cache generation: '.$error->getMessage()); + } + + $callbackValue = $callback(); + + if ($callbackValue !== false) { + try { + $encoded = $this->encodeCacheValue($callbackValue); + + if ($encoded !== false) { + $this->cache->saveWithLease($physicalKey, $encoded, '', $generation); + } + } catch (Throwable $error) { + Console::warning('Warning: Failed to save cache value: '.$error->getMessage()); + } + } + + /** @var T $callbackValue */ + return $callbackValue; + } + + /** + * @return array|false + */ + private function encodeCacheValue(mixed $value): array|false + { + if ($value instanceof Document) { + $collection = $value->getCollection(); + + return $collection === '' ? false : [ + 'collection' => $collection, + 'type' => 'document', + 'value' => $value->getArrayCopy(), + ]; + } + + if (! \is_array($value)) { + return ['value' => $value]; + } + + $collection = null; + $documents = []; + $hasDocuments = false; + $hasNonDocuments = false; + + foreach ($value as $item) { + if (! $item instanceof Document) { + if ($hasDocuments || $this->containsDocument($item)) { + return false; + } + + $hasNonDocuments = true; + continue; + } + + if ($hasNonDocuments) { + return false; + } + + $documentCollection = $item->getCollection(); + if ($documentCollection === '' || ($collection !== null && $collection !== $documentCollection)) { + return false; + } + + $collection = $documentCollection; + $hasDocuments = true; + $documents[] = $item->getArrayCopy(); + } + + return $hasDocuments ? [ + 'collection' => $collection, + 'type' => 'documents', + 'value' => $documents, + ] : ['value' => $value]; + } + + private function containsDocument(mixed $value): bool + { + if ($value instanceof Document) { + return true; + } + + if (! \is_array($value)) { + return false; + } + + foreach ($value as $item) { + if ($this->containsDocument($item)) { + return true; + } + } + + return false; + } + + /** + * Find Documents + * + * @param string $collection The collection identifier + * @param array $queries Queries for filtering, sorting, pagination, and selection; aggregates and groupBy + * are refused (see aggregate()) + * @param PermissionType $forPermission The permission type to check for authorization + * @return array + * + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + public function find(string $collection, array $queries = [], PermissionType $forPermission = PermissionType::Read): array + { + return $this->fetch($this->silent(fn () => $this->getCollection($collection)), $queries, $forPermission, false); + } + + /** + * @param array $queries + * @param bool $aggregate Whether the queries must aggregate (aggregate()) or must not (find()) + * @return array + * + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + private function fetch(Collection $collection, array $queries, PermissionType $forPermission, bool $aggregate): array + { + $queryCacheQueries = $queries; + + $this->checkQueryTypes($queries); + + $joinedCollectionsById = null; + + if ($this->validation()->get()) { + $joinedCollectionsById = $this->resolveJoinedCollections($queries); + $this->validateDocumentsQueries($collection, $queries, $joinedCollectionsById); + } + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $collectionGranted = $this->authorization->isValid(new Input($forPermission, $collection->getPermissionsByType($forPermission))); + + if (! $collectionGranted && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $relationships = self::relationshipAttributes($collection); + + $parsed = Query::groupByType($queries); + $filters = $parsed->filters; + $selects = $parsed->selections; + $aggregations = $parsed->aggregations; + $groupByAttrs = $parsed->groupBy; + $having = $parsed->having; + $joins = $parsed->joins; + // Skipping authorization would also skip the joined collections' permission filters, + // so with joins the main collection's grant travels to the adapter instead. + $skipAuth = $collectionGranted && empty($joins); + $distinct = $parsed->distinct; + $limit = $parsed->limit; + $offset = $parsed->offset; + $orderAttributes = $parsed->orderAttributes; + $orderTypes = $parsed->orderTypes; + $cursor = $parsed->cursor; + $cursorDirection = $parsed->cursorDirection ?? CursorDirection::After; + + $isAggregation = ! empty($aggregations) || ! empty($groupByAttrs); + + if ($isAggregation !== $aggregate) { + throw new QueryException($aggregate + ? 'aggregate() needs an aggregate or groupBy query' + : 'find() does not run aggregate or groupBy queries: use aggregate()'); + } + + if ($isAggregation && ! $this->adapter->supports(Capability::Aggregations)) { + throw new QueryException('Aggregation queries are not supported by this adapter'); + } + + if ($distinct && ! $this->adapter->supports(Capability::Aggregations)) { + throw new QueryException('Distinct queries are not supported by this adapter'); + } + + if (! empty($joins) && ! $this->adapter->supports(Capability::Joins)) { + throw new QueryException('Join queries are not supported by this adapter'); + } + + $this->assertJoinCount($joins); + + $joinedCollectionsById ??= $this->resolveJoinedCollections($joins); + $joinDocumentSecurity = []; + if (! empty($joins)) { + $joinDocumentSecurity = $this->authorizeJoins($joins, $forPermission, $joinedCollectionsById); + } + + $joinedByAlias = $this->joinedCollectionsByAlias($joins, $joinedCollectionsById); + $joinedCollections = $isAggregation ? [] : $joinedByAlias; + + if ($joinedCollections !== [] && $cursor !== null) { + [$orderAttributes, $cursor] = $this->qualifyJoinedOrders($collection, $orderAttributes, $cursor, $joinedCollections); + } + + if (! $isAggregation && ! $distinct) { + [$orderAttributes, $orderTypes] = $this->addTieBreaks($orderAttributes, $orderTypes, $filters, $joins, $joinedCollections, ! empty($cursor), $selects); + } + + if (! empty($cursor)) { + if ($isAggregation) { + throw new QueryException('Cursor pagination is not supported with aggregation queries'); + } + + if ($joins === [] && ! $distinct && $this->validation()->get() && $cursor->getId() === '') { + throw new QueryException('Invalid query: Invalid cursor: '.(new UID($this->adapter->limits()->uidLength))->getDescription()); + } + + if ($distinct) { + $this->assertDistinctCursorOrder($selects, $orderAttributes); + } + + if ($joins !== [] || $distinct) { + $this->assertCursorHasOrderValues($cursor, $orderAttributes); + } + + if ($joins === []) { + foreach ($orderAttributes as $order) { + if ($cursor->getAttribute($order) === null) { + throw new OrderException( + message: "Order attribute '{$order}' is empty", + attribute: $order + ); + } + } + } + } + + if (! empty($cursor) && $cursor->getCollection() !== $collection->getId()) { + throw new DatabaseException('cursor Document must be from the same Collection.'); + } + + if (! empty($cursor)) { + $cursor = $this->encode($collection, clone $cursor); + $cursor = $this->castBefore($collection, $cursor); + $cursor = $this->encodeJoins($cursor, $joinedCollections); + $cursor = $cursor->getArrayCopy(); + } else { + $cursor = []; + } + + $outerJoinIds = $distinct ? [] : $this->outerJoinIdSelections($selects, $joins, $joinedCollections); + + /** @var array $queries */ + $queries = \array_merge( + $selects, + $outerJoinIds === [] ? [] : [Query::select($outerJoinIds)], + $this->convertQueries($collection, \array_merge($filters, $aggregations, $having, $joins), $joinedByAlias), + ); + + if (! empty($groupByAttrs)) { + $queries[] = Query::groupBy($groupByAttrs); + } + + if ($distinct) { + $queries[] = Query::distinct(); + } + + $selections = $this->validateSelections($collection, $selects); + + if ($isAggregation) { + $nestedSelections = []; + } else { + $nestedSelections = $this->relationshipHook?->processQueries($relationships, $queries) ?? []; + } + + // Convert relationship filter queries to SQL-level subqueries + if (! $isAggregation) { + $convertedQueries = $this->relationshipHook !== null + ? $this->relationshipHook->convertQueries($relationships, $queries, $collection) + : $queries; + } else { + $convertedQueries = $queries; + } + + // If conversion returns null, it means no documents can match (relationship filter found no matches) + if ($convertedQueries === null) { + $results = []; + } else { + $queries = $convertedQueries; + + $cacheEntry = null; + $cacheGeneration = ''; + if ( + $this->queryCache !== null + && $collection->getId() !== self::METADATA + && $this->adapter->supports(Capability::Caching) + && ! $this->adapter->inTransaction() + && empty($joins) + ) { + $cacheContext = $skipAuth + ? $this->authorization->skip(fn () => $this->getQueryCacheField($collection, $queryCacheQueries, forPermission: $forPermission)) + : $this->getQueryCacheField($collection, $queryCacheQueries, forPermission: $forPermission); + + if ($cacheContext !== null) { + $cacheQueries = [ + 'input' => \array_map( + fn (Query $query): array => $this->serializeQueryCacheQuery($query), + $queryCacheQueries, + ), + 'queries' => \array_map( + fn (Query $query): array => $this->serializeQueryCacheQuery($query), + $queries, + ), + 'limit' => $limit ?? 25, + 'offset' => $offset ?? 0, + 'orderAttributes' => $orderAttributes, + 'orderTypes' => \array_map( + static fn (\Utopia\Query\OrderDirection $direction): string => $direction->value, + $orderTypes, + ), + 'cursor' => $this->normalizeQueryCacheQueryValue($cursor), + 'cursorDirection' => $cursorDirection->value, + ]; + + try { + $cacheEntry = $this->queryCache->getEntry( + $this->getQueryCacheScope(), + $collection->getId(), + $cacheQueries, + $cacheContext, + ); + + if ($cacheEntry !== null) { + $cached = $this->queryCache->get($cacheEntry); + if ($cached !== null) { + $results = $cached; + $cacheEntry = null; + } else { + $cacheGeneration = $this->queryCache->getGeneration($cacheEntry); + } + } + } catch (Exception $error) { + Console::warning('Warning: Failed to get query results from cache: '.$error->getMessage()); + $cacheEntry = null; + } + } + } + + if (! isset($results)) { + $adapterCollection = $this->withJoinIndexed($this->withJoinAttributes($this->withJoinAuthorization($collection, $joinDocumentSecurity, $collectionGranted), $joins, $joinedCollectionsById), $joins, $joinedCollectionsById); + + $find = fn (): array => $this->adapter->find( + $adapterCollection, + $queries, + $limit ?? 25, + $offset ?? 0, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $forPermission + ); + $results = $skipAuth ? $this->authorization->skip($find) : $find(); + + if ($cacheEntry !== null && $this->queryCache !== null) { + try { + if (! $this->isReadFromReplica()) { + $this->queryCache->set($cacheEntry, $results, $cacheGeneration); + } + } catch (Exception $error) { + Console::warning('Failed to save query results to cache: '.$error->getMessage()); + } + } + } + } + + if ($isAggregation) { + return $results; + } + + $hook = $this->relationshipHook; + if ($hook !== null && ! $hook->isInBatchPopulation() && $hook->isEnabled() && ! empty($relationships) && (empty($selects) || ! empty($nestedSelections))) { + if (count($results) > 0) { + $results = $this->silent(fn () => $hook->populateDocuments($results, $collection, $hook->getFetchDepth(), $nestedSelections)); + } + } + + $collectionId = $collection->getId(); + $hasCustomType = isset($this->documentTypes[$collectionId]); + + foreach ($this->castAfter($collection, $results) as $index => $node) { + $node = $this->casting($collection, $node); + $node = $this->decode($collection, $node, $selections); + if ($joinedCollections !== []) { + $node = $this->decodeJoins($node, $joinedCollections); + foreach ($outerJoinIds as $outerJoinId) { + $node->removeAttribute($outerJoinId); + } + } + + if ($hasCustomType) { + $node = $this->newDocument($collectionId, $node->getArrayCopy()); + } + + if (! $node->isEmpty()) { + $node->setAttribute(Document::COLLECTION, $collectionId); + } + + $results[$index] = $node; + } + + $results = $this->decorateDocuments(Event::DocumentFind, $collection, $results); + + if ($collection->getId() === self::METADATA) { + foreach ($results as $index => $node) { + $results[$index] = $this->toCollection($node); + } + } + + $listeners = $this->listens(Event::DocumentFind); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Found($collection->getId(), \array_values($results)), $listeners); + } + + return $results; + } + + /** + * Execute a raw query bypassing the query builder. The statement runs as written, with no + * permission or tenant scope, so like from(), query() and mutate() it runs only while authorization is + * disabled: inside getAuthorization()->skip(). + * + * @param string $query The raw query string + * @param array $bindings Parameter bindings + * @return array + * + * @throws AuthorizationException While authorization is enabled + * @throws DatabaseException + */ + public function rawQuery(string $query, array $bindings = []): array + { + $this->requireSkippedAuthorization(); + + if (! $this->adapterHasFeature(Feature\RawQuery::class)) { + throw new DatabaseException('Raw queries are not supported by this adapter'); + } + + return $this->adapter->rawQuery($query, $bindings); + } + + /** + * Find a single document matching the given queries. + * + * @param string $collection The collection identifier + * @param array $queries Queries for filtering + * @return Document The matching document, or an empty Document if none found, which fires no event + * + * @throws DatabaseException + */ + public function findOne(string $collection, array $queries = []): Document + { + $results = $this->silent(fn () => $this->find($collection, \array_merge([ + Query::limit(1), + ], $queries))); + + $found = \reset($results); + + if ($found === false) { + return new Document(); + } + + $listeners = $this->listens(Event::DocumentFind); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Found($found->getCollection(), [$found]), $listeners); + } + + return $found; + } + + /** + * Count Documents + * + * Count the number of documents matching the given queries. + * + * @param string $collection The collection identifier + * @param array $queries Queries for filtering + * @param int|null $max The most documents to count, greater than 0, or null for every match + * @return int The document count + * + * @throws DatabaseException + * @throws QueryException When $max is not greater than 0 + */ + public function count(string $collection, array $queries = [], ?int $max = null): int + { + $this->assertMax($max); + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $this->checkQueryTypes($queries); + + $joinedCollections = null; + + if ($this->validation()->get() && $queries !== []) { + $joinedCollections = $this->resolveJoinedCollections($queries); + $this->validateDocumentsQueries($collection, $queries, $joinedCollections); + } + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $collectionGranted = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getPermissionsByType(PermissionType::Read))); + + if (! $collectionGranted && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $relationships = self::relationshipAttributes($collection); + + $prepared = $this->prepareFilterJoinQueries($collection, $queries, $relationships, $collectionGranted, $joinedCollections); + if ($prepared === null) { + return 0; + } + + [$collection, $queries, $skipAuth] = $prepared; + + $getCount = fn () => $this->adapter->count($collection, $queries, $max); + $count = $skipAuth ? $this->authorization->skip($getCount) : $getCount(); + + $listeners = $this->listens(Event::DocumentCount); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Counted($collection->getId(), $count), $listeners); + } + + return $count; + } + + /** + * Sum an attribute + * + * Sum an attribute for all matching documents. + * + * @param string $collection The collection identifier + * @param string $attribute The attribute to sum + * @param array $queries Queries for filtering + * @param int|null $max The most documents to include in the sum, greater than 0, or null for every match + * @return float|int The sum of the attribute values + * + * @throws DatabaseException + * @throws QueryException When $max is not greater than 0 + */ + public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int + { + $this->assertMax($max); + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $this->checkQueryTypes($queries); + + $joinedCollections = null; + + if ($this->validation()->get()) { + $joinedCollections = $this->resolveJoinedCollections($queries); + if ($queries !== []) { + $this->validateDocumentsQueries($collection, $queries, $joinedCollections); + } + $this->validateSumAttribute($collection, $attribute, $queries, $joinedCollections); + } + + if (! \str_contains($attribute, '.') && ! $this->declaresSumAttribute($collection, $attribute)) { + $joinedCollections ??= $this->resolveJoinedCollections($queries); + $attribute = $this->resolveSumAttribute($collection, $attribute, $queries, $joinedCollections); + } + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $collectionGranted = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getPermissionsByType(PermissionType::Read))); + + if (! $collectionGranted && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $relationships = self::relationshipAttributes($collection); + + $prepared = $this->prepareFilterJoinQueries($collection, $queries, $relationships, $collectionGranted, $joinedCollections); + if ($prepared === null) { + return 0; + } + + [$collection, $queries, $skipAuth] = $prepared; + + $getSum = fn () => $this->adapter->sum($collection, $attribute, $queries, $max); + $sum = $skipAuth ? $this->authorization->skip($getSum) : $getSum(); + + $listeners = $this->listens(Event::DocumentSum); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Summed($collection->getId(), $attribute, $sum), $listeners); + } + + return $sum; + } + + /** + * A batch size above BATCH_SIZE is refused; one below 1 reads and writes one document at a time. + * + * @return int<1, max> + * + * @throws LimitException + */ + private static function batchSize(int $batchSize): int + { + if ($batchSize > self::BATCH_SIZE) { + throw new LimitException('Batch size must be at most '.self::BATCH_SIZE.', got '.$batchSize); + } + + return \max(1, $batchSize); + } + + /** + * @throws QueryException + */ + private function assertMax(?int $max): void + { + if ($max !== null && $max <= 0) { + throw new QueryException('Max must be greater than 0'); + } + } + + /** + * A bare name the main collection does not declare reads the attribute of the one aliased join whose + * collection declares it, as an aggregate in find() does. Any other name is returned as given, for the + * validator to accept or refuse. + * + * @param array $queries + * @param array $joinedCollections The collection each join names, by its id + * + * @throws QueryException + */ + private function resolveSumAttribute(Document $collection, string $attribute, array $queries, array $joinedCollections): string + { + try { + return $this->qualifyJoinedAttribute($collection, $attribute, $this->aliasedJoinCollections($queries, $joinedCollections)); + } catch (QueryException $exception) { + throw new QueryException('Invalid query: '.$exception->getMessage(), previous: $exception); + } + } + + /** + * The collection each join reads, by its alias. + * + * @param array $queries + * @param array|null $joinedCollections The collection each join names, by its id + * @return array + */ + private function aliasedJoinCollections(array $queries, ?array $joinedCollections = null): array + { + $joins = \array_values(\array_filter( + $queries, + static fn (Query $query): bool => $query->getMethod()->isJoin(), + )); + + return $this->joinedCollectionsByAlias($joins, $joinedCollections); + } + + /** + * `alias.name` for a bare name the main collection does not declare and exactly one of the joined collections + * declares as a non-relationship attribute; any other name as given. + * + * @param array $joinedCollections The collection each join reads, by its alias + * + * @throws QueryException when several joined collections declare the name + */ + private function qualifyJoinedAttribute(Document $collection, string $attribute, array $joinedCollections): string + { + if (\str_contains($attribute, '.') || $this->declaresSumAttribute($collection, $attribute)) { + return $attribute; + } + + $aliases = []; + foreach ($joinedCollections as $alias => $joined) { + foreach (Collection::fromDocument($joined)->attributes() as $declared) { + if ($declared->key === $attribute && $declared->relationship === null) { + $aliases[] = $alias; + break; + } + } + } + + if (\count($aliases) > 1) { + throw new QueryException('Attribute "'.$attribute.'" is ambiguous across joins; qualify it with a join alias'); + } + + return $aliases === [] ? $attribute : $aliases[0].'.'.$attribute; + } + + private function declaresSumAttribute(Document $collection, string $attribute): bool + { + foreach ([...self::internalAttributesFor(true), ...Collection::fromDocument($collection)->attributes()] as $declared) { + if ($declared->key === $attribute) { + return true; + } + } + + return false; + } + + /** + * sum() adds up what a sum aggregate adds up: a numeric attribute that is not an array, of the + * main collection or, under a join alias, of the collection that join reads. + * + * @param array $queries + * @param array|null $joinedCollections The collection each join names, by its id + * + * @throws QueryException + */ + private function validateSumAttribute(Document $collection, string $attribute, array $queries, ?array $joinedCollections = null): void + { + $attributes = Collection::fromDocument($collection)->attributes(); + + foreach ($attributes as $declared) { + if ($declared->key === $attribute) { + if (! $declared->array && $declared->isNumeric()) { + return; + } + break; + } + } + + $supportForAttributes = $this->adapter->supports(Capability::DefinedAttributes); + + if (! \str_contains($attribute, '.') && $this->declaresSumAttribute($collection, $attribute)) { + $validator = $this->getSumValidator($collection, $attributes, $supportForAttributes); + } else { + $validator = new Aggregate($attributes, $supportForAttributes, $this->adapter->hasSharedTables()); + $joins = []; + foreach ($this->aliasedJoinCollections($queries, $joinedCollections) as $alias => $joined) { + $joins[] = JoinedCollection::of($alias, $joined); + } + $validator->allowJoins($joins); + } + + if (! $validator->isValid(Query::sum($attribute))) { + throw new QueryException('Invalid query: '.$validator->getDescription()); + } + } + + /** + * The aggregate validator of a sum of an attribute the collection declares, built once per + * collection schema like the documents validators. It is never handed joins, so no state of + * one sum reaches the next. + * + * @param list $attributes + */ + private function getSumValidator(Document $collection, array $attributes, bool $supportForAttributes): Aggregate + { + $key = $this->getCollectionMetadataCacheKey($collection->getId()) + .'::'.(int) $supportForAttributes.(int) $this->adapter->hasSharedTables() + .'::'.Collection::fromDocument($collection)->fingerprint(); + + if (isset($this->sumValidatorCache[$key])) { + return $this->sumValidatorCache[$key]; + } + + if (\count($this->sumValidatorCache) >= self::DOCUMENTS_VALIDATOR_CACHE_LIMIT) { + $this->sumValidatorCache = []; + } + + return $this->sumValidatorCache[$key] = new Aggregate($attributes, $supportForAttributes, $this->adapter->hasSharedTables()); + } + + /** + * Yield each document matching the queries, read in batches of $batchSize. A limit in the queries caps the + * iteration, and an offset or a cursorAfter in them positions the first batch only. + * + * @param array $queries + * @return Generator + * + * @throws DatabaseException When the queries hold a cursorBefore + * @throws LimitException When $batchSize is above BATCH_SIZE + */ + public function cursor( + string $collection, + array $queries = [], + int $batchSize = self::CURSOR_BATCH_SIZE, + PermissionType $forPermission = PermissionType::Read, + ): Generator { + $batchSize = self::batchSize($batchSize); + $parsed = Query::groupByType($queries); + + if ($parsed->cursor !== null && $parsed->cursorDirection === CursorDirection::Before) { + throw new DatabaseException('Cursor '.CursorDirection::Before->value.' not supported in this method.'); + } + + $filters = \array_values(\array_filter( + $queries, + static fn (Query $query): bool => ! \in_array($query->getMethod(), [Method::Limit, Method::Offset, Method::CursorAfter, Method::CursorBefore], true), + )); + + return $this->pages($collection, $filters, $batchSize, $forPermission, $parsed->limit, $parsed->offset, $parsed->cursor); + } + + /** + * @param array $queries The queries without their limit, offset and cursor + * @return Generator + * + * @throws DatabaseException + */ + private function pages( + string $collection, + array $queries, + int $batchSize, + PermissionType $forPermission, + ?int $remaining, + ?int $offset, + ?Document $cursor, + ): Generator { + $check = null; + + while ($remaining === null || $remaining > 0) { + $size = $remaining === null ? $batchSize : \min($batchSize, $remaining); + $page = [Query::limit($size)]; + if ($offset !== null) { + $page[] = Query::offset($offset); + } + if ($cursor !== null) { + $page[] = Query::cursorAfter($cursor); + } + + $documents = $this->find($collection, [...$page, ...$queries], $forPermission); + $last = \end($documents); + $pages = $last !== false && \count($documents) === $size && ($remaining === null || $remaining > $size); + + if ($pages) { + $check ??= $this->nextPageCheck($collection, $queries); + $check($last); + } + + foreach ($documents as $document) { + yield $document; + } + + if (! $pages) { + return; + } + + if ($remaining !== null) { + $remaining -= \count($documents); + } + $offset = null; + $cursor = $last; + } + } + + /** + * Run aggregation queries (count, sum, avg, min, max, groupBy) and return one row per group. An aggregate without + * an alias comes back under `_`, or `` for count('*'). + * + * @param array $queries At least one aggregate or groupBy query + * @return list> + * + * @throws DatabaseException + * @throws QueryException When the queries hold no aggregate or groupBy + */ + public function aggregate(string $collection, array $queries): array + { + $rows = []; + foreach ($this->fetch($this->silent(fn () => $this->getCollection($collection)), self::aliasAggregates($queries), PermissionType::Read, true) as $row) { + $rows[] = $row->getArrayCopy(); + } + + $listeners = $this->listens(Event::DocumentAggregate); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Aggregated($collection, $rows), $listeners); + } + + return $rows; + } + + /** + * @param array $queries + * @return array + * + * @throws QueryException When two aggregates are given the same alias, or an aggregate's default alias is already + * taken by another aggregate + */ + private static function aliasAggregates(array $queries): array + { + $taken = []; + foreach ($queries as $query) { + $alias = $query->getAlias(); + if (! $query->getMethod()->isAggregate() || $alias === '') { + continue; + } + + if (isset($taken[$alias])) { + throw new QueryException('Invalid query: Aggregate alias "'.$alias.'" is given to more than one aggregate'); + } + $taken[$alias] = true; + } + + foreach ($queries as $index => $query) { + $method = $query->getMethod(); + if (! $method->isAggregate() || $query->getAlias() !== '') { + continue; + } + + $attribute = $query->getAttribute(); + $alias = $attribute === '*' || $attribute === '' ? $method->value : $method->value.'_'.$attribute; + $alias = \substr((string) \preg_replace('/[^A-Za-z0-9_]/', '_', $alias), 0, Aggregate::MAX_ALIAS_LENGTH); + + if (isset($taken[$alias])) { + throw new QueryException("The default aggregate alias '{$alias}' is used by more than one aggregate; give each of them an explicit alias"); + } + $taken[$alias] = true; + + $queries[$index] = new Query($method, $attribute, $query->getValues(), $alias); + } + + return $queries; + } + + /** + * A vector index answers exactly one sort key, the distance: a tie break behind it makes the ordering unanswerable + * from the index, so tie breaks are only added when a cursor needs a stable page boundary. + * + * A joined `$id` behind the main `$sequence` costs MariaDB and MySQL a sort of the whole join instead of reading + * the main table in index order up to the limit, so it is added only where the rows show the join or a cursor + * pages them (see showsJoinedRows()). + * + * @param array $orderAttributes + * @param array $orderTypes + * @param array $filters + * @param array $joins + * @param array $joinedCollections + * @param array $selects + * @return array{array, array} + */ + private function addTieBreaks(array $orderAttributes, array $orderTypes, array $filters, array $joins, array $joinedCollections, bool $paged, array $selects): array + { + $uniqueOrderBy = \in_array(Document::ID, $orderAttributes, true) || \in_array(Document::SEQUENCE, $orderAttributes, true); + + $vectorSearch = false; + foreach ($filters as $filter) { + if (\in_array($filter->getMethod(), [Method::VectorCosine, Method::VectorDot, Method::VectorEuclidean], true)) { + $vectorSearch = true; + break; + } + } + + if ($vectorSearch && ! $paged) { + return [$orderAttributes, $orderTypes]; + } + + if (! $uniqueOrderBy) { + $leadingAttribute = $orderAttributes[0] ?? null; + $leadingOrderType = $orderTypes[0] ?? OrderDirection::Asc; + + $orderAttributes[] = Document::SEQUENCE; + $orderTypes[] = \in_array($leadingAttribute, [Document::CREATED_AT, Document::UPDATED_AT], true) + ? $leadingOrderType + : OrderDirection::Asc; + } + + if ($joins === [] || (! $paged && ! $this->showsJoinedRows($selects, $joinedCollections))) { + return [$orderAttributes, $orderTypes]; + } + + $aliases = \array_keys($joinedCollections); + if (\count($aliases) === \count($joins)) { + foreach (\array_values($joins) as $position => $join) { + $alias = $aliases[$position]; + $joinedId = $alias.'.'.Document::ID; + if ( + ! $this->joinMatchesAtMostOneRow($join, $alias) + && ! \in_array($joinedId, $orderAttributes, true) + && ! \in_array($alias.'.'.Document::SEQUENCE, $orderAttributes, true) + ) { + $orderAttributes[] = $joinedId; + $orderTypes[] = OrderDirection::Asc; + } + } + } + + return [$orderAttributes, $orderTypes]; + } + + /** + * What find() checks of the cursor it is given for a joined or distinct read, run on a page's last row before the + * page is yielded, so a read that cannot be paged fails before its caller acts on any row. + * + * @param array $queries + * @return Closure(Document): void + */ + private function nextPageCheck(string $collection, array $queries): Closure + { + $parsed = Query::groupByType($queries); + $joins = $parsed->joins; + $distinct = $parsed->distinct; + if (($joins === [] && ! $distinct) || $parsed->aggregations !== [] || $parsed->groupBy !== []) { + return static function (Document $cursor): void { + }; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + $joinedCollections = $this->joinedCollectionsByAlias($joins, $this->resolveJoinedCollections($joins)); + $selects = $parsed->selections; + $filters = $parsed->filters; + $orderAttributes = $parsed->orderAttributes; + $orderTypes = $parsed->orderTypes; + + return function (Document $cursor) use ($collection, $joins, $distinct, $joinedCollections, $selects, $filters, $orderAttributes, $orderTypes): void { + $orders = $orderAttributes; + if ($joinedCollections !== []) { + [$orders, $cursor] = $this->qualifyJoinedOrders($collection, $orders, $cursor, $joinedCollections); + } + + if ($distinct) { + $this->assertDistinctCursorOrder($selects, $orders); + } else { + [$orders] = $this->addTieBreaks($orders, $orderTypes, $filters, $joins, $joinedCollections, true, $selects); + } + + $this->assertCursorHasOrderValues($cursor, $orders); + }; + } + + /** + * Whether a read's rows show anything of its joins. Rows that select only main attributes are equal for every + * joined row they pair the same main document with, so their order is not observable, and they lack the joined + * `$id` a cursor over the join has to carry. + * + * @param array $selects + * @param array $joinedCollections + */ + private function showsJoinedRows(array $selects, array $joinedCollections): bool + { + if ($selects === []) { + return true; + } + + foreach ($selects as $select) { + foreach ($select->getValues() as $value) { + if (! \is_string($value)) { + continue; + } + if ($value === '*') { + return true; + } + $dot = \strpos($value, '.'); + if ($dot !== false && isset($joinedCollections[\substr($value, 0, $dot)])) { + return true; + } + } + } + + return false; + } + + /** + * An inner or left join on the joined `$id` pairs each row it joins onto with at most one joined row, so the rows + * of the read are told apart without the joined id, and ordering by it would only cost the engine a sort. + */ + private function joinMatchesAtMostOneRow(Query $join, string $alias): bool + { + $on = $join->getJoinOnQueries(); + if (! \in_array($join->getMethod(), [Method::Join, Method::LeftJoin], true) || \count($on) !== 1 || $on[0]->getMethod() !== Method::On) { + return false; + } + + [$left, $operator, $right] = \array_pad($on[0]->getValues(), 3, null); + if ($operator !== '=' || ! \is_string($left) || ! \is_string($right)) { + return false; + } + + return ($right === Document::ID || $right === $alias.'.'.Document::ID) && ! \str_starts_with($left, $alias.'.'); + } + + /** + * A distinct row has no id: only its order values tell it from the next one, so the order has to name every + * attribute the read selects. + * + * @param array $selects + * @param array $orderAttributes + * + * @throws QueryException + */ + private function assertDistinctCursorOrder(array $selects, array $orderAttributes): void + { + $selected = []; + foreach ($selects as $select) { + foreach ($select->getValues() as $value) { + if (\is_string($value)) { + $selected[] = $value; + } + } + } + + foreach ($selected as $attribute) { + if (\str_ends_with($attribute, '*')) { + $selected = []; + break; + } + } + + if ($selected === [] || $orderAttributes === []) { + throw new QueryException('A cursor on a distinct() read pages along its orders, so the read needs a select() of named attributes and an order on each of them'); + } + + foreach ($selected as $attribute) { + if (! \in_array($attribute, $orderAttributes, true)) { + throw new QueryException("A cursor on a distinct() read pages along its orders, so the read must order by every selected attribute, and '{$attribute}' is not ordered"); + } + } + } + + /** + * A cursor on a bare order name only one join declares carries the value under `alias.name`, the + * name the adapter orders by (SQL::qualifyJoinedOrders()), so the order is qualified here, before + * the tie keys and the cursor checks read it. A cursor value under the bare name follows it. A + * name several joins declare is refused rather than read from one of them. + * + * @param array $orderAttributes + * @param array $joinedCollections + * @return array{array, Document} + * + * @throws QueryException + */ + private function qualifyJoinedOrders(Document $collection, array $orderAttributes, Document $cursor, array $joinedCollections): array + { + foreach ($orderAttributes as $index => $attribute) { + $qualified = $this->qualifyJoinedAttribute($collection, $attribute, $joinedCollections); + if ($qualified === $attribute) { + continue; + } + + $orderAttributes[$index] = $qualified; + if ($cursor->offsetExists($attribute) && ! $cursor->offsetExists($qualified)) { + $cursor = clone $cursor; + $cursor->setAttribute($qualified, $cursor->getAttribute($attribute)); + } + } + + return [$orderAttributes, $cursor]; + } + + /** + * A cursor names the row it was read from by the values of the read's order, each under the name the read orders + * by. A value it lacks is never taken from an attribute of the same name elsewhere in the document. + * + * @param array $orderAttributes + * + * @throws OrderException + */ + private function assertCursorHasOrderValues(Document $cursor, array $orderAttributes): void + { + $values = $cursor->getArrayCopy(); + foreach ($orderAttributes as $order) { + if (! \array_key_exists($order, $values)) { + throw new OrderException( + message: "Cursor has no value for order attribute '{$order}'. Use a row this read returned as the cursor, and select '{$order}' when the read selects attributes.", + attribute: $order, + ); + } + } + } + + /** + * @param array $queries + * @param array|null $joinedCollections The collection each join names, by its id + * + * @throws QueryException + */ + private function validateDocumentsQueries(Document $collection, array $queries, ?array $joinedCollections = null): void + { + $joinedCollections ??= $this->resolveJoinedCollections($queries); + $validator = $this->getQueriesValidator($collection, $queries, $joinedCollections); + + if ($joinedCollections !== []) { + $validator->setJoinedCollections($joinedCollections); + } + + if (! $validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + + /** + * The collections the join queries name, each loaded once, by the id the join names it with. A + * read resolves them once and hands them to validation, authorization, the adapter and decoding. + * + * @param array $queries + * @return array + * + * @throws QueryException + */ + private function resolveJoinedCollections(array $queries): array + { + $collections = []; + + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } + + $id = $query->getAttribute(); + if ($id === '' || isset($collections[$id])) { + continue; + } + + $collection = $this->silent(fn () => $this->findCollection($id)); + if ($collection === null) { + throw new QueryException("Joined collection '{$id}' not found"); + } + + $collections[$id] = $collection; + } + + return $collections; + } + + /** + * @param array $queries + * @param list $relationships + * @param array|null $joinedCollections The collection each join names, by its id + * @return array{0: Document, 1: array, 2: bool}|null + */ + private function prepareFilterJoinQueries( + Document $collection, + array $queries, + array $relationships, + bool $collectionGranted, + ?array $joinedCollections = null, + ): ?array { + $parsed = Query::groupByType($queries); + $filters = $parsed->filters; + $joins = $parsed->joins; + + if (! empty($joins)) { + if (! $this->adapter->supports(Capability::Joins)) { + throw new QueryException('Join queries are not supported by this adapter'); + } + + $this->assertJoinCount($joins); + + $joinedCollections ??= $this->resolveJoinedCollections($joins); + $collection = $this->withJoinIndexed($this->withJoinAuthorization( + $collection, + $this->authorizeJoins($joins, PermissionType::Read, $joinedCollections), + $collectionGranted, + ), $joins, $joinedCollections); + } + + $queries = \array_merge($filters, $joins); + if ($queries !== []) { + $queries = $this->convertQueries($collection, $queries, $this->joinedCollectionsByAlias($joins, $joinedCollections)); + } + + $convertedQueries = $this->relationshipHook !== null + ? $this->relationshipHook->convertQueries($relationships, $queries, $collection) + : $queries; + + if ($convertedQueries === null) { + return null; + } + + return [$collection, $convertedQueries, $collectionGranted && empty($joins)]; + } + + /** + * The join cap the Join validator enforces holds without validation too: every join is one + * more table the engine plans and the permission filters check. + * + * @param array $joins + * + * @throws QueryException + */ + private function assertJoinCount(array $joins): void + { + $validator = new JoinValidator(); + if (! $validator->isValidCount(\count($joins))) { + throw new QueryException($validator->getDescription()); + } + } + + /** + * Set on the collection handed to the adapter for a join read when the caller + * holds the collection-level permission, so its rows are not filtered per document. + */ + public const string COLLECTION_GRANTED = 'collectionGranted'; + + /** + * Maps each joined table to whether the adapter filters its rows per document. + */ + public const string JOIN_DOCUMENT_SECURITY = 'joinDocumentSecurity'; + + /** + * Each joined collection is authorized once, however many joins read it. + * + * @param array $joins + * @param array|null $joinedCollections The collection each join names, by its id + * @return array + */ + private function authorizeJoins(array $joins, PermissionType $forPermission, ?array $joinedCollections = null): array + { + $joinedCollections ??= $this->resolveJoinedCollections($joins); + $joinDocumentSecurity = []; + $authorized = []; + + foreach ($joins as $joinQuery) { + $joinCollectionId = $joinQuery->getAttribute(); + if (isset($authorized[$joinCollectionId])) { + continue; + } + $authorized[$joinCollectionId] = true; + + $joinCollection = $joinedCollections[$joinCollectionId] ?? null; + + if ($joinCollection === null) { + throw new QueryException("Joined collection '{$joinCollectionId}' not found"); + } + + $granted = $this->authorization->isValid(new Input($forPermission, $joinCollection->getPermissionsByType($forPermission))); + $documentSecurity = (bool) $joinCollection->getAttribute('documentSecurity', false); + + if (! $granted && ! $documentSecurity) { + throw new AuthorizationException("Unauthorized access to joined collection '{$joinCollectionId}'"); + } + + foreach ($this->joinDocumentSecurityKeys($joinCollectionId, $joinCollection) as $key) { + $joinDocumentSecurity[$key] = ! $granted; + } + } + + return $joinDocumentSecurity; + } + + /** + * @return list + */ + private function joinDocumentSecurityKeys(string $joinCollectionId, Document $joinCollection): array + { + $keys = [ + $joinCollectionId, + $joinCollection->getId(), + $this->adapter->filter($joinCollectionId), + $this->adapter->filter($joinCollection->getId()), + ]; + + return \array_values(\array_unique(\array_filter( + $keys, + static fn (string $key): bool => $key !== '', + ))); + } + + /** + * @param array $joinDocumentSecurity + */ + private function withJoinAuthorization(Document $collection, array $joinDocumentSecurity, bool $collectionGranted): Document + { + if ($joinDocumentSecurity === []) { + return $collection; + } + + $adapterCollection = clone $collection; + $adapterCollection->setAttribute(self::COLLECTION_GRANTED, $collectionGranted); + $adapterCollection->setAttribute(self::JOIN_DOCUMENT_SECURITY, $joinDocumentSecurity); + + return $adapterCollection; + } + + /** + * Maps each joined collection, as its join query names it, to the attributes a join without a + * select returns under the join's alias. + */ + public const string JOIN_ATTRIBUTES = 'joinAttributes'; + + /** + * Relationship attributes are left out: only some sides of a relationship have a column, and a + * join does not populate related documents. + * + * @param array $joins + * @param array|null $joinedCollections The collection each join names, by its id + */ + private function withJoinAttributes(Document $collection, array $joins, ?array $joinedCollections = null): Document + { + if ($joins === []) { + return $collection; + } + + $joinedCollections ??= $this->resolveJoinedCollections($joins); + + $joinAttributes = []; + foreach ($joins as $join) { + $joinCollectionId = $join->getAttribute(); + if (isset($joinAttributes[$joinCollectionId])) { + continue; + } + + $keys = []; + if (isset($joinedCollections[$joinCollectionId])) { + foreach (Collection::fromDocument($joinedCollections[$joinCollectionId])->attributes() as $attribute) { + if ($attribute->relationship === null) { + $keys[] = $attribute->key; + } + } + } + $joinAttributes[$joinCollectionId] = $keys; + } + + $adapterCollection = clone $collection; + $adapterCollection->setAttribute(self::JOIN_ATTRIBUTES, $joinAttributes); + + return $adapterCollection; + } + + /** + * Under shared tables, maps each joined collection, as its join query names it, to the attributes one of its key + * or unique indexes leads with: the ones a join can look its rows up by. + */ + public const string JOIN_INDEXED = 'joinIndexed'; + + private const array LOOKUP_INDEX_TYPES = [IndexType::Key, IndexType::Index, IndexType::Unique]; + + /** + * @param array $joins + * @param array|null $joinedCollections The collection each join names, by its id + */ + private function withJoinIndexed(Document $collection, array $joins, ?array $joinedCollections = null): Document + { + if ($joins === [] || ! $this->adapter->hasSharedTables()) { + return $collection; + } + + $joinedCollections ??= $this->resolveJoinedCollections($joins); + + $joinIndexed = []; + foreach ($joins as $join) { + $joinCollectionId = $join->getAttribute(); + if (isset($joinIndexed[$joinCollectionId]) || ! isset($joinedCollections[$joinCollectionId])) { + continue; + } + + $leads = []; + foreach (Collection::fromDocument($joinedCollections[$joinCollectionId])->indexes() as $index) { + if (\in_array($index->type, self::LOOKUP_INDEX_TYPES, true) && isset($index->attributes[0])) { + $leads[] = $index->attributes[0]; + } + } + $joinIndexed[$joinCollectionId] = \array_values(\array_unique($leads)); + } + + $adapterCollection = clone $collection; + $adapterCollection->setAttribute(self::JOIN_INDEXED, $joinIndexed); + + return $adapterCollection; + } + + /** + * The collection each join reads, by the alias its values come back under. + * + * @param array $joins + * @param array|null $joinedCollections The collection each join names, by its id + * @return array + */ + private function joinedCollectionsByAlias(array $joins, ?array $joinedCollections = null): array + { + if ($joins === []) { + return []; + } + + $joinedCollections ??= $this->resolveJoinedCollections($joins); + + $collections = []; + foreach ($joins as $join) { + $collections[$join->getAlias()] = $joinedCollections[$join->getAttribute()] ?? new Document(); + } + + return $collections; + } + + /** + * The `alias.$id` of each join whose attributes a select names without it or `alias.*`, when an + * outer join can leave a joined row unmatched: the joined `$id` is what tells an unmatched row + * from a matched one when the row is decoded, so it is selected for that and left out of the + * result. + * + * @param array $selects + * @param array $joins + * @param array $joinedCollections The collection each join alias reads + * @return list + */ + private function outerJoinIdSelections(array $selects, array $joins, array $joinedCollections): array + { + if ($selects === [] || $joinedCollections === []) { + return []; + } + + $outer = false; + foreach ($joins as $join) { + if (\in_array($join->getMethod(), [Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin], true)) { + $outer = true; + break; + } + } + if (! $outer) { + return []; + } + + $selectedAliases = []; + foreach ($selects as $select) { + foreach ($select->getValues() as $value) { + if (! \is_string($value)) { + continue; + } + if ($value === '*') { + return []; + } + $dot = \strpos($value, '.'); + if ($dot !== false) { + $selectedAliases[\substr($value, 0, $dot)][\substr($value, $dot + 1)] = true; + } + } + } + + $ids = []; + foreach ($selectedAliases as $alias => $attributes) { + if (isset($joinedCollections[$alias]) && ! isset($attributes[Document::ID]) && ! isset($attributes['*'])) { + $ids[] = $alias.'.'.Document::ID; + } + } + + return $ids; + } + + /** + * @param array $queries + * @return array + */ + private function validateSelections(Document $collection, array $queries): array + { + if (empty($queries)) { + return []; + } + + /** @var array $selections */ + $selections = []; + /** @var array $relationshipSelections */ + $relationshipSelections = []; + + foreach ($queries as $query) { + if ($query->getMethod() == Method::Select) { + foreach ($query->getValues() as $value) { + if (! \is_string($value)) { + throw new QueryException('Select queries must contain only string attributes.'); + } + + $strVal = $value; + if (\str_contains($strVal, '.')) { + $relationshipSelections[] = $strVal; + + continue; + } + $selections[] = $strVal; + } + } + } + + // Allow querying internal attributes + /** @var array $keys */ + $keys = \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $this->internalAttributes() + ); + + foreach (Collection::fromDocument($collection)->attributes() as $attribute) { + if ($attribute->relationship === null) { + $keys[] = $attribute->key; + } + } + if ($this->adapter->supports(Capability::DefinedAttributes)) { + $invalid = \array_diff($selections, $keys); + if (! empty($invalid) && ! \in_array('*', $invalid)) { + throw new QueryException('Cannot select attributes: '.\implode(', ', $invalid)); + } + } + + $selections = \array_merge($selections, $relationshipSelections); + + $selections[] = Document::ID; + $selections[] = Document::SEQUENCE; + $selections[] = Document::COLLECTION; + $selections[] = Document::CREATED_AT; + $selections[] = Document::UPDATED_AT; + $selections[] = Document::PERMISSIONS; + + return \array_values(\array_unique($selections)); + } + + /** + * @param array $queries + * + * @throws QueryException + */ + private function checkQueryTypes(array $queries): void + { + foreach ($queries as $query) { + if (! $query instanceof Query) { + throw new QueryException('Invalid query type: "'.\gettype($query).'". Expected instances of "'.Query::class.'"'); + } + + if ($query->isNested()) { + $this->checkQueryTypes($query->getValues()); + } + } + } + + private function castBefore(Document $collection, Document $document): Document + { + if (! $this->adapterHasFeature(Feature\Casting::class)) { + return $document; + } + + return $this->adapter->castBefore($collection, $document); + } + + private function castAfterDocument(Document $collection, Document $document): Document + { + if (! $this->adapterHasFeature(Feature\Casting::class)) { + return $document; + } + + return $this->adapter->castAfter($collection, [$document])[0]; + } + + /** + * @param array $documents + * @return array + */ + private function castAfter(Document $collection, array $documents): array + { + if ($documents === [] || ! $this->adapterHasFeature(Feature\Casting::class)) { + return $documents; + } + + return $this->adapter->castAfter($collection, $documents); + } +} diff --git a/src/Database/Trait/Indexes.php b/src/Database/Trait/Indexes.php new file mode 100644 index 0000000000..62b5b0155c --- /dev/null +++ b/src/Database/Trait/Indexes.php @@ -0,0 +1,527 @@ +storeIndexes($collection, [$index])[0]; + } + + /** + * Every index is validated before any is created, and the metadata is written once. When the engine fails + * part way, the indexes this call already created are dropped again. + * + * @param list $indexes + * @return list The indexes as stored + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws IndexException + * @throws LimitException + * @throws NotFoundException + * @throws RefusedException When the adapter does not create an index + * @throws StructureException + * @throws UniqueException + * @throws Exception + */ + public function createIndexes(string $collection, array $indexes): array + { + if ($indexes === []) { + return []; + } + + $stored = $this->storeIndexes($collection, $indexes); + + $listeners = $this->listens(Event::IndexesCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Index\BatchCreated($collection, $stored), $listeners); + } + + return $stored; + } + + /** + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws NotFoundException + * @throws RefusedException When the adapter does not rename the index + * @throws StructureException + */ + public function renameIndex(string $collection, string $old, string $new): void + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + $indexes = $definition->indexes(); + + $position = self::indexPosition($indexes, $old); + if ($position === null) { + throw new NotFoundException('Index not found'); + } + + if (self::indexPosition($indexes, $new) !== null) { + throw new DuplicateException('Index name already used'); + } + + $renamed = $indexes[$position]->withKey($new); + \array_splice($indexes, $position, 1, [$renamed]); + $this->writeIndexList($definition, $indexes); + + try { + $renamedInSchema = $this->adapter->renameIndex($definition->getId(), $old, $new); + } catch (Throwable $error) { + $this->completePriorIndexRename($definition->getId(), $old, $new, $error); + $renamedInSchema = true; + } + + if (! $renamedInSchema) { + throw new RefusedException("Failed to rename index '{$old}' to '{$new}'"); + } + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->adapter->renameIndex($definition->getId(), $new, $old), + shouldRollback: true, + operationDescription: "index rename '{$old}' to '{$new}'" + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($definition->getId())); + + $listeners = $this->listens(Event::IndexRename); + if ($listeners !== []) { + $this->dispatch(new Event\Index\Renamed($definition->getId(), $old, $renamed), $listeners); + } + } + + /** + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws NotFoundException + * @throws RefusedException When the adapter does not drop the index + * @throws StructureException + */ + public function deleteIndex(string $collection, string $key): void + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + $indexes = $definition->indexes(); + + $position = self::indexPosition($indexes, $key); + if ($position === null) { + throw new NotFoundException('Index not found'); + } + + $deleted = $indexes[$position]; + + $deletedInSchema = $this->deleteIndexFromSchema($definition->getId(), $key); + + unset($indexes[$position]); + $this->writeIndexList($definition, \array_values($indexes)); + + $attributeTypes = self::indexAttributeTypes($deleted, $definition->attributes()); + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->adapter->createIndex($definition->getId(), $deleted, $attributeTypes), + shouldRollback: $deletedInSchema, + operationDescription: "index deletion '{$key}'", + silentRollback: true + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($definition->getId())); + + $listeners = $this->listens(Event::IndexDelete); + if ($listeners !== []) { + $this->dispatch(new Event\Index\Deleted($definition->getId(), $deleted), $listeners); + } + } + + /** + * @param non-empty-list $indexes + * @return non-empty-list + * + * @throws DatabaseException + * @throws DuplicateException + * @throws IndexException + * @throws LimitException + * @throws RefusedException When the adapter does not create an index + * @throws UniqueException + * @throws Exception + */ + private function storeIndexes(string $collection, array $indexes): array + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + $attributes = $definition->attributes(); + $existing = $definition->indexes(); + + $prepared = $this->prepareIndexes($definition, $attributes, $existing, $indexes); + + $created = []; + try { + foreach ($prepared as $index) { + if ($this->createIndexInSchema($definition->getId(), $index, $attributes)) { + $created[] = $index->key; + } + } + } catch (Throwable $error) { + try { + $this->cleanupIndexes($definition->getId(), $created); + } catch (Throwable $cleanupError) { + Console::error('Failed to roll back indexes created before the failure: '.$cleanupError->getMessage()); + } + + throw $error; + } + + $this->writeIndexList($definition, [...$existing, ...$prepared]); + + $keys = \implode("', '", \array_map(static fn (Index $index): string => $index->key, $prepared)); + + $this->updateMetadata( + collection: $definition, + rollbackOperation: fn () => $this->cleanupIndexes($definition->getId(), $created), + shouldRollback: $created !== [], + operationDescription: "index creation '{$keys}'" + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($definition->getId())); + + $listeners = $this->listens(Event::IndexCreate); + if ($listeners !== []) { + foreach ($prepared as $index) { + $this->dispatch(new Event\Index\Created($definition->getId(), $index), $listeners); + } + } + + return $prepared; + } + + /** + * Fits each index to its attributes and validates it against the stored indexes and the ones before it. + * + * @param list $attributes + * @param list $existing + * @param non-empty-list $indexes + * @return non-empty-list + * + * @throws DatabaseException + * @throws DuplicateException + * @throws IndexException + * @throws LimitException + */ + private function prepareIndexes(Collection $definition, array $attributes, array $existing, array $indexes): array + { + $keys = []; + foreach ($existing as $index) { + $keys[\strtolower($index->key)] = true; + } + + foreach ($indexes as $index) { + if ($index->attributes === []) { + throw new DatabaseException('Missing attributes'); + } + + $key = \strtolower($index->key); + if (isset($keys[$key])) { + throw new DuplicateException('Index already exists'); + } + $keys[$key] = true; + } + + if ($this->adapter->getCountOfIndexes($definition) + \count($indexes) > $this->adapter->limits()->indexes) { + throw new LimitException('Index limit reached. Cannot create new index.'); + } + + $prepared = []; + foreach ($indexes as $index) { + $index = $this->fitIndexToAttributes($index, $attributes); + + if ($this->validation()->get()) { + $validator = $this->indexValidator($attributes, [...$existing, ...$prepared]); + if (! $validator->isValid($index)) { + throw new IndexException($validator->getDescription()); + } + } + + $prepared[] = $index; + } + + return $prepared; + } + + /** + * @param list $attributes + * @return bool True when this call created the index, false when the schema already held it + * + * @throws DatabaseException + * @throws DuplicateException + * @throws RefusedException When the adapter does not create the index + * @throws UniqueException When the stored documents violate a unique index + */ + private function createIndexInSchema(string $collection, Index $index, array $attributes): bool + { + if ($this->reconcileSchemaOnlyIndex($collection, $index)) { + return false; + } + + try { + $created = $this->adapter->createIndex($collection, $index, self::indexAttributeTypes($index, $attributes)); + } catch (UniqueException $error) { + throw $error; + } catch (DuplicateException) { + // The metadata holds no index under this key, so the schema's copy is an orphan of a + // partial failure: it is kept and the metadata written for it. + return false; + } + + if (! $created) { + throw new RefusedException('Failed to create index'); + } + + return true; + } + + /** + * @return bool True when this call dropped the index, false when the schema no longer held it + * + * @throws RefusedException When the adapter does not drop the index + */ + private function deleteIndexFromSchema(string $collection, string $key): bool + { + try { + $deleted = $this->adapter->deleteIndex($collection, $key); + } catch (NotFoundException) { + // Already absent from the schema; the metadata is still removed. + return false; + } + + if (! $deleted) { + throw new RefusedException('Failed to delete index'); + } + + return true; + } + + /** + * Index attributes by the type of the attribute they index; a dotted path into an object attribute + * takes the object attribute's type. + * + * @param list $attributes + * @return array + */ + private static function indexAttributeTypes(Index $index, array $attributes): array + { + $types = []; + foreach ($index->attributes as $indexed) { + $base = \explode('.', $indexed, 2)[0]; + foreach ($attributes as $attribute) { + if ($attribute->key === $base) { + $types[$indexed] = $attribute->type->value; + break; + } + } + } + + return $types; + } + + /** + * @param list $indexes + */ + private static function indexPosition(array $indexes, string $key): ?int + { + foreach ($indexes as $position => $index) { + if ($index->key === $key) { + return $position; + } + } + + return null; + } + + /** + * @param list $indexes + */ + private function writeIndexList(Collection $definition, array $indexes): void + { + $definition->setAttribute( + self::COLLECTION_INDEXES, + \array_map(static fn (Index $index): Document => $index->toDocument(), $indexes), + ); + } + + /** + * A failed rename may follow a prior partial failure whose rename reached the schema while its metadata + * update and rollback failed. Renaming back and forth again proves the schema holds the index under the + * new name and completes the rename. + * + * @throws DuplicateException|NotFoundException When the round trip fails after the adapter raised one + * @throws DatabaseException When the round trip fails after any other error, wrapping it + */ + private function completePriorIndexRename(string $collection, string $old, string $new, Throwable $error): void + { + try { + $completed = $this->adapter->renameIndex($collection, $new, $old) + && $this->adapter->renameIndex($collection, $old, $new); + } catch (Throwable) { + $completed = false; + } + + if ($completed) { + return; + } + + if ($error instanceof DuplicateException || $error instanceof NotFoundException) { + throw $error; + } + + throw new DatabaseException("Failed to rename index '{$old}' to '{$new}': ".$error->getMessage(), previous: $error); + } + + /** + * An index in the schema but not in this collection's metadata is reused when its + * definition matches the request, and dropped to be recreated otherwise. Under shared + * tables it serves another tenant's collection, so a mismatch is refused instead. + * + * @return bool True when the existing index is reused + * + * @throws DuplicateException + */ + private function reconcileSchemaOnlyIndex(string $collection, Index $index): bool + { + if (! $this->adapter->supports(Capability::SchemaIntrospection) + || ($this->hasSharedTables() && $this->isMigrating())) { + return false; + } + + $id = \strtolower($this->adapter->filter($index->key)); + foreach ($this->adapter->limits()->internalIndexKeys as $internal) { + if (\strtolower($this->adapter->filter($internal)) === $id) { + return false; + } + } + + foreach ($this->getSchemaIndexes($collection) as $schemaIndex) { + if (\strtolower($schemaIndex->name) !== $id) { + continue; + } + + if ($this->schemaIndexMatches($schemaIndex, $index)) { + return true; + } + + if ($this->hasSharedTables()) { + throw new DuplicateException('Index exists in the shared table with another definition'); + } + + try { + $this->adapter->deleteIndex($collection, $index->key); + } catch (NotFoundException) { + // Already absent from the schema + } + + return false; + } + + return false; + } + + private function schemaIndexMatches(Schema\Index $schemaIndex, Index $index): bool + { + $columns = \array_map(\strtolower(...), $schemaIndex->columns); + $lengths = $schemaIndex->lengths; + + if ($this->hasSharedTables() && ($columns[0] ?? '') === Storage::TENANT) { + \array_shift($columns); + \array_shift($lengths); + } + + if (\count($columns) !== \count($index->attributes)) { + return false; + } + + foreach ($index->attributes as $position => $attribute) { + if ($columns[$position] === '') { + continue; + } + if ($columns[$position] !== \strtolower($this->adapter->filter(Storage::column($attribute)))) { + return false; + } + $length = $lengths[$position] ?? null; + if ($length !== null && $length !== ($index->lengths[$position] ?? 0)) { + return false; + } + } + + return $schemaIndex->type === $this->adapter->getSchemaIndexType($index->type); + } + + /** + * Drops indexes created in the adapter whose metadata could not be stored. + * + * @param list $keys + * + * @throws DatabaseException If a cleanup fails after all retries + */ + private function cleanupIndexes(string $collection, array $keys, int $maxAttempts = 3): void + { + $failure = null; + foreach ($keys as $key) { + try { + $this->cleanup( + fn () => $this->adapter->deleteIndex($collection, $key), + 'index', + $key, + $maxAttempts + ); + } catch (Throwable $error) { + $failure ??= $error; + } + } + + if ($failure !== null) { + throw $failure; + } + } +} diff --git a/src/Database/Trait/Relationships.php b/src/Database/Trait/Relationships.php new file mode 100644 index 0000000000..2755694d00 --- /dev/null +++ b/src/Database/Trait/Relationships.php @@ -0,0 +1,925 @@ +relationshipHook === null) { + return $callback(); + } + + return $this->relationshipHook->withEnabled(false, $callback); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T + */ + public function skipRelationshipsExistCheck(callable $callback): mixed + { + if ($this->relationshipHook === null) { + return $callback(); + } + + return $this->relationshipHook->withCheckExist(false, $callback); + } + + /** + * Drop the storage of a relationship created by createRelationship() whose metadata could not be written. + * + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupRelationship(string $collection, Relationship $relationship, int $maxAttempts = 3): void + { + if (! $this->adapterHasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + $adapter = $this->adapter; + + $this->cleanup( + fn () => $adapter->deleteRelationship($collection, $relationship, RelationshipSide::Parent), + 'relationship', + $relationship->key ?? '', + $maxAttempts + ); + } + + /** + * Create a relationship from $collection, its parent side, to the related collection. + * + * A key left null is derived from the id of the collection on the other side, and stored resolved. + * + * @return Relationship The stored relationship, from the parent side, with both keys resolved + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws LimitException + * @throws NotFoundException + * @throws RefusedException When the adapter does not create the relationship + * @throws StructureException + */ + public function createRelationship(string $collection, Relationship $relationship): Relationship + { + if (! $this->adapterHasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + $adapter = $this->adapter; + + $collection = $this->silent(fn () => $this->findCollection($collection)) + ?? throw new NotFoundException('Collection not found'); + $relatedCollection = $this->silent(fn () => $this->findCollection($relationship->relatedCollection)) + ?? throw new NotFoundException('Related collection not found'); + + $collectionId = $collection->getId(); + $relatedCollectionId = $relatedCollection->getId(); + $key = $relationship->key ?: $adapter->filter($relatedCollectionId); + $twoWayKey = $relationship->twoWayKey ?: $adapter->filter($collectionId); + $relationship = $relationship->apply(new RelationshipUpdate(key: $key, twoWayKey: $twoWayKey)); + $type = $relationship->type; + + foreach ($collection->attributes() as $attribute) { + if (\strtolower($attribute->key) === \strtolower($key)) { + throw new DuplicateException('Attribute already exists'); + } + + $existing = $attribute->relationship; + if ( + $existing !== null + && \strtolower($existing->twoWayKey ?? '') === \strtolower($twoWayKey) + && $existing->relatedCollection === $relatedCollectionId + ) { + throw new DuplicateException('Related attribute already exists'); + } + } + + $parent = Attribute::relationship($key, $relationship, RelationshipSide::Parent); + $child = Attribute::relationship($twoWayKey, $relationship->inverse($collectionId), RelationshipSide::Child); + + $this->checkAttribute($collectionId, $parent); + $this->checkAttribute($relatedCollectionId, $child); + + $junctionCollection = null; + if ($type === RelationshipType::ManyToMany) { + $junctionCollection = '_'.$collection->getSequence().'_'.$relatedCollection->getSequence(); + $junctionAttributes = [ + Attribute::string(key: $key, required: true), + Attribute::string(key: $twoWayKey, required: true), + ]; + $junctionIndexes = [ + Index::key(key: '_index_'.$key, attributes: [$key]), + Index::key(key: '_index_'.$twoWayKey, attributes: [$twoWayKey]), + ]; + try { + $this->silent(fn () => $this->createCollection(Collection::create($junctionCollection, attributes: $junctionAttributes, indexes: $junctionIndexes))); + } catch (DuplicateException) { + try { + $adapter->createCollection($junctionCollection, $junctionAttributes, $junctionIndexes); + } catch (DuplicateException) { + // The junction's metadata and schema both survive a prior partial failure. + } + } + } + + $created = $this->createRelationshipInSchema($collectionId, $relationship, $junctionCollection); + + $collection->setAttribute(self::COLLECTION_ATTRIBUTES, $parent->toDocument(), SetType::Append); + $relatedCollection->setAttribute(self::COLLECTION_ATTRIBUTES, $child->toDocument(), SetType::Append); + + $this->silent(function () use ($collection, $relatedCollection, $relationship, $key, $twoWayKey, $junctionCollection, $created) { + $committedFailure = null; + try { + $this->withRetries(function () use ($collection, $relatedCollection) { + $this->withTransaction(function () use ($collection, $relatedCollection) { + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + }); + } catch (Throwable $error) { + if (! $this->mayHaveCommitted($error)) { + $this->forgetAttribute($collection, $key); + $this->forgetAttribute($relatedCollection, $twoWayKey); + + if ($created) { + $this->cleanupCreatedRelationship($collection->getId(), $relationship, $junctionCollection); + } + + throw new DatabaseException('Failed to create relationship: '.$error->getMessage(), previous: $error); + } + + $committedFailure = $error; + } + + $indexKey = '_index_'.$key; + $twoWayIndexKey = '_index_'.$twoWayKey; + $indexes = match ($relationship->type) { + RelationshipType::OneToOne => $relationship->twoWay + ? [ + [$collection->getId(), Index::unique(key: $indexKey, attributes: [$key])], + [$relatedCollection->getId(), Index::unique(key: $twoWayIndexKey, attributes: [$twoWayKey])], + ] + : [[$collection->getId(), Index::unique(key: $indexKey, attributes: [$key])]], + RelationshipType::OneToMany => [[$relatedCollection->getId(), Index::key(key: $twoWayIndexKey, attributes: [$twoWayKey])]], + RelationshipType::ManyToOne => [[$collection->getId(), Index::key(key: $indexKey, attributes: [$key])]], + RelationshipType::ManyToMany => [], + }; + $indexesCreated = []; + + try { + foreach ($indexes as [$indexCollection, $index]) { + try { + $this->createIndex($indexCollection, $index); + } catch (Throwable $error) { + if (! $this->mayHaveCommitted($error)) { + throw $error; + } + + $committedFailure ??= $error; + } + $indexesCreated[] = [$indexCollection, $index->key]; + } + } catch (Throwable $error) { + foreach ($indexesCreated as [$createdCollection, $createdKey]) { + try { + $this->deleteIndex($createdCollection, $createdKey); + } catch (Throwable $cleanupError) { + Console::error("Failed to cleanup index '{$createdKey}': ".$cleanupError->getMessage()); + } + } + + $definitionsRemoved = true; + try { + $this->withTransaction(function () use ($collection, $relatedCollection, $key, $twoWayKey) { + $this->forgetAttribute($collection, $key); + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + + $this->forgetAttribute($relatedCollection, $twoWayKey); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + } catch (Throwable $cleanupError) { + $definitionsRemoved = $this->failedAfterCommit($cleanupError); + Console::error("Failed to cleanup metadata for relationship '{$key}': ".$cleanupError->getMessage()); + } + + if ($definitionsRemoved) { + $this->cleanupCreatedRelationship($collection->getId(), $relationship, $junctionCollection); + } + + throw new DatabaseException('Failed to create relationship indexes: '.$error->getMessage(), previous: $error); + } + + if ($committedFailure !== null) { + throw $committedFailure; + } + }); + + $listeners = $this->listens(Event::AttributeCreate); + if ($listeners !== []) { + $this->dispatch(new Event\Attribute\Created($collectionId, $parent), $listeners); + } + + return $relationship; + } + + /** + * Update the relationship stored under $key on $collection, from either of its sides. + * + * The update is read from that side: its key renames $key, its twoWayKey the key on the related collection. + * + * @return Relationship The stored relationship, from the side of $collection + * + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws NotFoundException + * @throws RefusedException When the adapter does not rename the relationship's columns + */ + public function updateRelationship(string $collection, string $key, RelationshipUpdate $update): Relationship + { + if (! $this->adapterHasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + $adapter = $this->adapter; + + $collection = $this->getCollection($collection); + $attributes = $collection->attributes(); + + if ($update->key !== null && \in_array($update->key, self::attributeKeys($attributes), true)) { + throw new DuplicateException('Relationship already exists'); + } + + $attribute = self::relationshipAttribute($attributes, $key); + $current = $attribute?->relationship; + $side = $attribute?->side; + if ($attribute === null || $current === null || $side === null) { + throw new NotFoundException('Relationship not found'); + } + + if ($update->key === null && $update->twoWayKey === null && $update->twoWay === null && $update->onDelete === null) { + return $current; + } + + $collectionId = $collection->getId(); + $relatedCollection = $this->getCollection($current->relatedCollection); + $relatedCollectionId = $relatedCollection->getId(); + $relatedAttributes = $relatedCollection->attributes(); + $oldTwoWayKey = $current->twoWayKey ?? ''; + + if ($update->twoWayKey !== null && \in_array($update->twoWayKey, self::attributeKeys($relatedAttributes), true)) { + throw new DuplicateException('Related attribute already exists'); + } + + $inverse = self::relationshipAttribute($relatedAttributes, $oldTwoWayKey); + if ($inverse === null || $inverse->side === null) { + throw new NotFoundException('Attribute not found'); + } + + $updated = $current->apply($update); + $newKey = $update->key ?? $key; + $newTwoWayKey = $update->twoWayKey ?? $oldTwoWayKey; + $altering = $newKey !== $key || $newTwoWayKey !== $oldTwoWayKey; + $renamed = $updated->apply(new RelationshipUpdate(key: $newKey, twoWayKey: $newTwoWayKey)); + $junction = $current->type === RelationshipType::ManyToMany + ? $this->getJunctionCollection($collection, $relatedCollection, $side) + : null; + + $adapterUpdated = false; + if ($altering) { + try { + $adapterUpdated = $adapter->updateRelationship( + $collectionId, + $current, + $side, + new RelationshipUpdate(key: $newKey, twoWayKey: $newTwoWayKey, twoWay: $updated->twoWay), + ); + } catch (Throwable $error) { + $columnRenames = self::relationshipIndexRenames($current->type, $side, $updated->twoWay, $collectionId, $relatedCollectionId, $junction, $key, $newKey, $oldTwoWayKey, $newTwoWayKey); + $remaining = $this->relationshipColumnsLeft($columnRenames); + try { + if ($remaining === null || \count($remaining) === \count($columnRenames)) { + throw $error; + } + + $adapterUpdated = $remaining === [] || $adapter->updateRelationship( + $collectionId, + $current->apply(new RelationshipUpdate( + key: self::renameLeft($remaining, $junction ?? $collectionId, $key, $newKey) ? $key : $newKey, + twoWayKey: self::renameLeft($remaining, $junction ?? $relatedCollectionId, $oldTwoWayKey, $newTwoWayKey) ? $oldTwoWayKey : $newTwoWayKey, + )), + $side, + new RelationshipUpdate(key: $newKey, twoWayKey: $newTwoWayKey, twoWay: $updated->twoWay), + ); + } catch (Throwable $failure) { + if ($failure instanceof DuplicateException || $failure instanceof NotFoundException) { + throw $failure; + } + + throw new DatabaseException("Failed to update relationship '{$key}': ".$failure->getMessage(), previous: $failure); + } + } + + if (! $adapterUpdated) { + throw new RefusedException("Failed to update relationship '{$key}'"); + } + } + + $parentAfter = Attribute::relationship($newKey, $renamed, $side); + $inverseAfter = Attribute::relationship($newTwoWayKey, $renamed->inverse($collectionId), $inverse->side); + + /** @var list $updatedAttributes */ + $updatedAttributes = []; + /** @var list $restores */ + $restores = []; + + try { + $this->replaceAttribute($collectionId, $key, $parentAfter); + $updatedAttributes[] = [$collectionId, $parentAfter]; + $restores[] = fn () => $this->replaceAttribute($collectionId, $newKey, $attribute); + + $this->replaceAttribute($relatedCollectionId, $oldTwoWayKey, $inverseAfter); + $updatedAttributes[] = [$relatedCollectionId, $inverseAfter]; + $restores[] = fn () => $this->replaceAttribute($relatedCollectionId, $newTwoWayKey, $inverse); + + if ($junction !== null) { + $updatedAttributes[] = [$junction, $this->renameStoredAttribute($junction, $key, $newKey)]; + $restores[] = fn () => $this->renameStoredAttribute($junction, $newKey, $key); + $updatedAttributes[] = [$junction, $this->renameStoredAttribute($junction, $oldTwoWayKey, $newTwoWayKey)]; + $restores[] = fn () => $this->renameStoredAttribute($junction, $newTwoWayKey, $oldTwoWayKey); + + $this->withRetries(fn () => $this->purgeCachedCollection($junction)); + } + } catch (Throwable $error) { + self::bestEffort($restores); + + if ($adapterUpdated) { + self::bestEffort([fn () => $adapter->updateRelationship( + $collectionId, + $renamed, + $side, + new RelationshipUpdate(key: $key, twoWayKey: $oldTwoWayKey, twoWay: $updated->twoWay), + )]); + } + + throw $error; + } + + $indexRenamesCompleted = []; + + try { + foreach (self::relationshipIndexRenames($current->type, $side, $updated->twoWay, $collectionId, $relatedCollectionId, $junction, $key, $newKey, $oldTwoWayKey, $newTwoWayKey) as [$indexedCollection, $from, $to]) { + $this->renameRelationshipIndex($indexedCollection, $from, $to); + $indexRenamesCompleted[] = [$indexedCollection, $to, $from]; + } + } catch (Throwable $error) { + if ($adapterUpdated) { + self::bestEffort([fn () => $adapter->updateRelationship( + $collectionId, + $renamed, + $side, + new RelationshipUpdate(key: $key, twoWayKey: $oldTwoWayKey, twoWay: $current->twoWay), + )]); + } + + self::bestEffort(\array_map( + fn (array $rename): callable => fn () => $this->renameRelationshipIndex(...$rename), + \array_reverse($indexRenamesCompleted), + )); + self::bestEffort($restores); + + throw new DatabaseException("Failed to update relationship indexes for '{$key}': ".$error->getMessage(), previous: $error); + } + + $this->withRetries(fn () => $this->purgeCachedCollection($collectionId)); + $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollectionId)); + + $listeners = $this->listens(Event::AttributeUpdate); + if ($listeners !== []) { + foreach ($updatedAttributes as [$updatedCollection, $updatedAttribute]) { + $this->dispatch(new Event\Attribute\Updated($updatedCollection, $updatedAttribute), $listeners); + } + } + + return $renamed; + } + + /** + * Delete the relationship stored under $key on $collection, from either of its sides, and its inverse. + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws NotFoundException + * @throws RefusedException When the adapter does not drop the relationship + * @throws StructureException + */ + public function deleteRelationship(string $collection, string $key): void + { + if (! $this->adapterHasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + $adapter = $this->adapter; + + $collection = $this->silent(fn () => $this->getCollection($collection)); + $attribute = self::relationshipAttribute($collection->attributes(), $key); + $relationship = $attribute?->relationship; + $side = $attribute?->side; + if ($attribute === null || $relationship === null || $side === null) { + throw new NotFoundException('Relationship not found'); + } + + $relatedCollection = $this->silent(fn () => $this->getCollection($relationship->relatedCollection)); + $twoWayKey = $relationship->twoWayKey ?? ''; + + $collectionAttributes = self::attributeDocuments($collection->attributes(), $key); + $relatedCollectionAttributes = self::attributeDocuments($relatedCollection->attributes(), $twoWayKey); + + /** @var list $deletedIndexes */ + $deletedIndexes = []; + $deletedJunction = null; + + $this->silent(function () use ($collection, $relatedCollection, $relationship, $side, $key, $twoWayKey, &$deletedIndexes, &$deletedJunction) { + if ($relationship->type === RelationshipType::ManyToMany) { + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $deletedJunction = $this->silent(fn () => $this->getDocument(self::METADATA, $junction)); + $this->deleteDocument(self::METADATA, $junction); + + return; + } + + foreach (self::relationshipIndexes($relationship->type, $side, $relationship->twoWay, $collection->getId(), $relatedCollection->getId(), $key, $twoWayKey) as [$indexCollection, $index]) { + $this->deleteIndex($indexCollection, $index->key); + $deletedIndexes[] = [$indexCollection, $index]; + } + }); + + $collection = $this->silent(fn () => $this->getCollection($collection->getId())); + $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollection->getId())); + $collection->setAttribute(self::COLLECTION_ATTRIBUTES, $collectionAttributes); + $relatedCollection->setAttribute(self::COLLECTION_ATTRIBUTES, $relatedCollectionAttributes); + + try { + $shouldRollback = $this->deleteRelationshipFromSchema($collection->getId(), $relationship, $side); + } catch (Throwable $error) { + self::bestEffort($this->relationshipDefinitionRestores($deletedIndexes, $deletedJunction)); + + throw $error; + } + + try { + $this->withRetries(function () use ($collection, $relatedCollection) { + $this->silent(function () use ($collection, $relatedCollection) { + $this->withTransaction(function () use ($collection, $relatedCollection) { + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + }); + }); + } catch (Throwable $error) { + $rollbacks = []; + if ($shouldRollback) { + $rollbacks[] = $side === RelationshipSide::Parent + ? fn () => $adapter->createRelationship($collection->getId(), $relationship) + : fn () => $adapter->createRelationship($relatedCollection->getId(), $relationship->inverse($collection->getId())); + } + + self::bestEffort([...$rollbacks, ...$this->relationshipDefinitionRestores($deletedIndexes, $deletedJunction)]); + + throw new DatabaseException( + "Failed to persist metadata after retries for relationship deletion '{$key}': ".$error->getMessage(), + previous: $error + ); + } + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); + + $listeners = $this->listens(Event::AttributeDelete); + if ($listeners !== []) { + $this->dispatch(new Event\Attribute\Deleted($collection->getId(), $attribute), $listeners); + } + } + + /** + * The steps that put back what deleteRelationship() removed before dropping the relationship: its indexes, or + * the definition of its junction collection. + * + * @param list $deletedIndexes + * @return list + */ + private function relationshipDefinitionRestores(array $deletedIndexes, ?Document $deletedJunction): array + { + $restores = []; + foreach ($deletedIndexes as [$indexCollection, $index]) { + $restores[] = fn () => $this->silent(fn () => $this->createIndex($indexCollection, $index)); + } + + if ($deletedJunction !== null && ! $deletedJunction->isEmpty()) { + $restores[] = fn () => $this->silent(fn () => $this->createDocument(self::METADATA, $deletedJunction)); + } + + return $restores; + } + + /** + * @return bool True when this call created the relationship, false when the schema already held it + * + * @throws DatabaseException When the adapter does not support relationships + * @throws RefusedException When the adapter does not create the relationship; its junction collection is dropped + */ + private function createRelationshipInSchema(string $collection, Relationship $relationship, ?string $junctionCollection): bool + { + if (! $this->adapterHasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + $adapter = $this->adapter; + + try { + $created = $adapter->createRelationship($collection, $relationship); + } catch (DuplicateException) { + // The metadata checks found no such relationship, so the schema holds an orphan of a prior partial + // failure: keep it and write the metadata. + return false; + } + + if ($created) { + return true; + } + + if ($junctionCollection !== null) { + try { + $this->silent(fn () => $this->cleanupCollection($junctionCollection)); + } catch (Throwable $error) { + Console::error("Failed to cleanup junction collection '{$junctionCollection}': ".$error->getMessage()); + } + } + + throw new RefusedException('Failed to create relationship'); + } + + /** + * @return bool True when this call dropped the relationship, false when the schema no longer held it + * + * @throws DatabaseException When the adapter does not support relationships + * @throws RefusedException When the adapter does not drop the relationship + */ + private function deleteRelationshipFromSchema(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + if (! $this->adapterHasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + $adapter = $this->adapter; + + try { + $deleted = $adapter->deleteRelationship($collection, $relationship, $side); + } catch (NotFoundException) { + // The relationship is already absent from the schema. + return false; + } + + if (! $deleted) { + throw new RefusedException('Failed to delete relationship'); + } + + return true; + } + + private function getJunctionCollection(Document $collection, Document $relatedCollection, RelationshipSide $side): string + { + return $side === RelationshipSide::Parent + ? '_'.$collection->getSequence().'_'.$relatedCollection->getSequence() + : '_'.$relatedCollection->getSequence().'_'.$collection->getSequence(); + } + + /** + * Drop what createRelationship() created for a relationship whose metadata it then had to remove. + */ + private function cleanupCreatedRelationship(string $collection, Relationship $relationship, ?string $junctionCollection): void + { + try { + $this->cleanupRelationship($collection, $relationship); + } catch (Throwable $cleanupError) { + Console::error("Failed to cleanup relationship '{$relationship->key}': ".$cleanupError->getMessage()); + } + + if ($junctionCollection === null) { + return; + } + + try { + $this->cleanupCollection($junctionCollection); + } catch (Throwable $cleanupError) { + Console::error("Failed to cleanup junction collection '{$junctionCollection}': ".$cleanupError->getMessage()); + } + } + + /** + * The indexes createRelationship() creates for a relationship, by the collection each is on, seen from $side. + * + * @return list + */ + private static function relationshipIndexes( + RelationshipType $type, + RelationshipSide $side, + bool $twoWay, + string $collection, + string $relatedCollection, + string $key, + string $twoWayKey, + ): array { + $parent = $side === RelationshipSide::Parent; + $own = [$collection, '_index_'.$key, $key]; + $other = [$relatedCollection, '_index_'.$twoWayKey, $twoWayKey]; + + [$unique, $keyed] = match ($type) { + RelationshipType::OneToOne => [$twoWay ? ($parent ? [$own, $other] : [$other, $own]) : [$parent ? $own : $other], []], + RelationshipType::OneToMany => [[], [$parent ? $other : $own]], + RelationshipType::ManyToOne => [[], [$parent ? $own : $other]], + RelationshipType::ManyToMany => [[], []], + }; + + $indexes = []; + foreach ($unique as [$indexCollection, $indexKey, $indexed]) { + $indexes[] = [$indexCollection, Index::unique(key: $indexKey, attributes: [$indexed])]; + } + foreach ($keyed as [$indexCollection, $indexKey, $indexed]) { + $indexes[] = [$indexCollection, Index::key(key: $indexKey, attributes: [$indexed])]; + } + + return $indexes; + } + + /** + * The relationship indexes a rename of its keys renames, as [collection, from key, to key]. + * + * @return list + */ + private static function relationshipIndexRenames( + RelationshipType $type, + RelationshipSide $side, + bool $twoWay, + string $collection, + string $relatedCollection, + ?string $junction, + string $key, + string $newKey, + string $twoWayKey, + string $newTwoWayKey, + ): array { + $own = $key !== $newKey ? [[$collection, $key, $newKey]] : []; + $other = $twoWayKey !== $newTwoWayKey ? [[$relatedCollection, $twoWayKey, $newTwoWayKey]] : []; + + return match ($type) { + RelationshipType::OneToOne => match (true) { + $twoWay => [...$own, ...$other], + $side === RelationshipSide::Parent => $own, + default => $other, + }, + RelationshipType::OneToMany => $side === RelationshipSide::Parent ? $other : $own, + RelationshipType::ManyToOne => $side === RelationshipSide::Parent ? $own : $other, + RelationshipType::ManyToMany => $junction === null ? [] : [ + ...($key !== $newKey ? [[$junction, $key, $newKey]] : []), + ...($twoWayKey !== $newTwoWayKey ? [[$junction, $twoWayKey, $newTwoWayKey]] : []), + ], + }; + } + + /** + * Point the relationship index on $key at $newKey and rename it after it. + */ + private function renameRelationshipIndex(string $collection, string $key, string $newKey): void + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + $indexKey = '_index_'.$key; + + $indexes = $definition->indexes(); + $found = false; + foreach ($indexes as $position => $index) { + if ($index->key === $indexKey) { + $indexes[$position] = Index::fromDocument($index->toDocument()->setAttribute(self::INDEX_ATTRIBUTES, [$newKey])); + $found = true; + break; + } + } + + if (! $found) { + throw new NotFoundException('Index not found'); + } + + $definition->setAttribute(self::COLLECTION_INDEXES, \array_map(static fn (Index $index): Document => $index->toDocument(), $indexes)); + $this->updateMetadata( + collection: $definition, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "index metadata update '{$indexKey}'" + ); + $this->withRetries(fn () => $this->purgeCachedCollection($collection)); + + $this->silent(fn () => $this->renameIndex($collection, $indexKey, '_index_'.$newKey)); + } + + /** + * Store $attribute in place of the attribute stored under $key on $collection. + * + * @throws NotFoundException + */ + private function replaceAttribute(string $collection, string $key, Attribute $attribute): Attribute + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + + $attributes = $definition->attributes(); + $found = false; + foreach ($attributes as $position => $stored) { + if ($stored->key === $key) { + $attributes[$position] = $attribute; + $found = true; + break; + } + } + + if (! $found) { + throw new NotFoundException('Attribute not found'); + } + + $definition->setAttribute(self::COLLECTION_ATTRIBUTES, \array_map(static fn (Attribute $stored): Document => $stored->toDocument(), $attributes)); + $this->updateMetadata( + collection: $definition, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "attribute metadata update '{$key}'" + ); + $this->withRetries(fn () => $this->purgeCachedCollection($collection)); + + return $attribute; + } + + /** + * Rename the attribute stored under $key on $collection, a junction collection, to $newKey. + * + * @throws NotFoundException + */ + private function renameStoredAttribute(string $collection, string $key, string $newKey): Attribute + { + $definition = $this->silent(fn () => $this->getCollection($collection)); + foreach ($definition->attributes() as $stored) { + if ($stored->key === $key) { + return $this->replaceAttribute($collection, $key, $stored->apply(new AttributeUpdate(key: $newKey))); + } + } + + throw new NotFoundException('Attribute not found'); + } + + /** + * Remove the attribute stored under $key from the collection's metadata, without writing it. + */ + private function forgetAttribute(Collection $collection, string $key): void + { + $collection->setAttribute(self::COLLECTION_ATTRIBUTES, self::attributeDocuments($collection->attributes(), $key)); + } + + /** + * The stored form of $attributes, the first one under $without left out. + * + * @param list $attributes + * @return list + */ + private static function attributeDocuments(array $attributes, string $without): array + { + $documents = []; + $removed = false; + foreach ($attributes as $attribute) { + if (! $removed && $attribute->key === $without) { + $removed = true; + + continue; + } + $documents[] = $attribute->toDocument(); + } + + return $documents; + } + + /** + * @param list $attributes + */ + private static function relationshipAttribute(array $attributes, string $key): ?Attribute + { + foreach ($attributes as $attribute) { + if ($attribute->key === $key) { + return $attribute->relationship === null ? null : $attribute; + } + } + + return null; + } + + /** + * @param list $attributes + * @return list + */ + private static function attributeKeys(array $attributes): array + { + return \array_map(static fn (Attribute $attribute): string => $attribute->key, $attributes); + } + + /** + * The relationship columns an earlier attempt left under their old names, when each other column is in the + * engine's schema under its new name and no longer under its old one, so a retry renames only what is left; + * null when a column is under both names or neither, or the schema cannot be read. + * + * @param list $renames + * @return list|null + */ + private function relationshipColumnsLeft(array $renames): ?array + { + if ($renames === [] || ! $this->adapter->supports(Capability::SchemaIntrospection)) { + return null; + } + + $left = []; + foreach ($renames as $rename) { + [$renamedCollection, $from, $to] = $rename; + $moved = $this->hasSchemaColumn($renamedCollection, $to); + if ($moved === $this->hasSchemaColumn($renamedCollection, $from)) { + return null; + } + if (! $moved) { + $left[] = $rename; + } + } + + return $left; + } + + /** + * @param list $left + */ + private static function renameLeft(array $left, string $collection, string $from, string $to): bool + { + return \in_array([$collection, $from, $to], $left, true); + } + + private function hasSchemaColumn(string $collection, string $key): bool + { + $filtered = \strtolower($this->adapter->filter($key)); + foreach ($this->getSchemaAttributes($collection) as $column) { + if (\strtolower($column->name) === $filtered) { + return true; + } + } + + return false; + } + + /** + * Run each step of a rollback, carrying on past a step that fails. + * + * @param list $steps + */ + private static function bestEffort(array $steps): void + { + foreach ($steps as $step) { + try { + $step(); + } catch (Throwable) { + // A rollback step that fails leaves the rest to run. + } + } + } +} diff --git a/src/Database/Trait/Transactions.php b/src/Database/Trait/Transactions.php new file mode 100644 index 0000000000..da8eefdeaf --- /dev/null +++ b/src/Database/Trait/Transactions.php @@ -0,0 +1,415 @@ +> */ + protected array $documentCachePurges = []; + + /** @var array> */ + protected array $transactionWrites = []; + + /** @var array>> */ + protected array $transactionDefinitions = []; + + /** @var array>> */ + protected array $definitionRefills = []; + + private bool $definitionFillsFail = false; + + /** @var array> */ + protected array $documentPurgeEvents = []; + + /** @var WeakMap|null */ + private ?WeakMap $committedFailures = null; + + /** @var WeakMap|null */ + private ?WeakMap $transactionFailures = null; + + /** @var WeakMap|null */ + private ?WeakMap $unconfirmedCommits = null; + + /** + * Run a callback inside a transaction. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @throws \Throwable + */ + public function withTransaction(callable $callback): mixed + { + return $this->withInvalidationScope(fn () => $this->withAdapterTransaction($callback)); + } + + /** + * Run a mutation with mandatory cache invalidation ordered safely around it. + * + * A shared tombstone is published after the transaction starts but before + * the mutation. The outer scope activates a fresh epoch only after commit. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @throws \Throwable + */ + protected function withMutation(Event $event, mixed $data, callable $callback): mixed + { + return $this->withInvalidationScope(fn () => $this->withAdapterTransaction(function () use ($event, $data, $callback) { + $this->blockMutation($event, $data); + + return $callback(); + })); + } + + /** + * Run the callback in a savepoint of the open transaction, which the adapter must support, without + * retrying it. When the callback throws, the savepoint is rolled back and the fallback's result is + * returned instead, unless the failure is one the transaction retries (see Adapter::isRetryable()), such + * as a lock conflict whose lock the open transaction holds until it rolls back. + * + * @internal + * + * @template T + * + * @param callable(): T $callback + * @param callable(Throwable): T $fallback + * @return T + * + * @throws Throwable When the savepoint cannot be started or committed, or the callback's failure when the + * transaction retries it or the savepoint cannot be rolled back, as when the engine rolled + * the whole transaction back + */ + public function withSavepoint(callable $callback, callable $fallback): mixed + { + $context = $this->getEventContext(); + $queued = \count($this->documentPurgeEvents[$context] ?? []); + + $this->adapter->startTransaction(); + + try { + $result = $callback(); + } catch (Throwable $error) { + try { + $rolledBack = $this->adapter->rollbackTransaction(); + } catch (Throwable) { + throw $error; + } + + if (! $rolledBack || $this->adapter->isRetryable($error)) { + throw $error; + } + + if (isset($this->documentPurgeEvents[$context])) { + \array_splice($this->documentPurgeEvents[$context], $queued); + } + + return $fallback($error); + } + + $this->adapter->commitTransaction(); + + return $result; + } + + /** + * Block the query cache of the collections a mutation writes, once per invalidation scope. + */ + private function blockMutation(Event $event, mixed $data): void + { + $tokens = $this->getInvalidationTokens($event, $data); + $context = $this->getEventContext(); + $pending = []; + foreach ($tokens as $key => $token) { + if (isset($this->queryCacheMutations[$context][$key])) { + continue; + } + + $pending[$key] = $token; + } + $this->blockInvalidation($pending); + foreach ($pending as $key => $token) { + $this->queryCacheMutations[$context][$key] = $token; + } + } + + /** + * Run the callback in an adapter transaction, dropping the document purge events of every attempt the adapter + * rolls back. Without savepoints a failed nested call is not rolled back, so its events stay queued. The events of + * the last attempt also stay queued when its commit could not be confirmed. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @throws Throwable + */ + private function withAdapterTransaction(callable $callback): mixed + { + $context = $this->getEventContext(); + $queued = \count($this->documentPurgeEvents[$context]); + $discard = function () use ($context, $queued): void { + \array_splice($this->documentPurgeEvents[$context], $queued); + }; + $returned = false; + + try { + return $this->adapter->withTransaction(function () use ($callback, $discard, &$returned): mixed { + $returned = false; + $discard(); + $result = $callback(); + $returned = true; + + return $result; + }); + } catch (Throwable $error) { + if ($returned && $error instanceof UnconfirmedException) { + $this->unconfirmedCommits ??= new WeakMap(); + $this->unconfirmedCommits[$error] = true; + } elseif ($this->adapter->supports(Capability::TransactionNested)) { + $discard(); + } + + $this->transactionFailures ??= new WeakMap(); + $this->transactionFailures[$error] = true; + + throw $error; + } + } + + /** + * Fire every queued document purge event, even after one of them fails. + * + * @param list $events + * @return Throwable|null The first failure + */ + private function announceDocumentPurges(array $events): ?Throwable + { + $failure = null; + foreach ($events as $announce) { + try { + $announce(); + } catch (Throwable $error) { + $failure ??= $error; + } + } + + return $failure; + } + + /** + * Keep all nested mutation tombstones blocked, and purge every written document + * again, once the outer transaction has committed or rolled back. Document purge + * events queued in the scope fire after a commit, even when the invalidation after it + * fails, and after a commit that could not be confirmed, whose failure is still the + * one thrown. A rollback drops them, also when its callback threw Exception\Unconfirmed. + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @throws Throwable + */ + private function withInvalidationScope(callable $callback): mixed + { + $context = $this->getEventContext(); + $outer = ! isset($this->queryCacheMutations[$context]); + if ($outer) { + $this->queryCacheMutations[$context] = []; + $this->documentCacheMutations[$context] = []; + $this->documentCachePurges[$context] = []; + $this->documentPurgeEvents[$context] = []; + if (! $this->adapter->inTransaction()) { + $this->transactionWrites[$context] = []; + } + } + + try { + $result = $callback(); + } catch (Throwable $error) { + if ($outer) { + $queryTokens = $this->queryCacheMutations[$context]; + $documentTokens = $this->documentCacheMutations[$context]; + $documents = $this->documentCachePurges[$context]; + $purgeEvents = $this->endedInUnconfirmedCommit($error) ? $this->documentPurgeEvents[$context] : []; + unset( + $this->queryCacheMutations[$context], + $this->documentCacheMutations[$context], + $this->documentCachePurges[$context], + $this->transactionWrites[$context], + $this->transactionDefinitions[$context], + $this->definitionRefills[$context], + $this->documentPurgeEvents[$context], + ); + try { + $this->purgeWrittenDocuments($documents); + } catch (Throwable) { + // The transaction's own failure is the one thrown. + } + try { + $this->activateDocumentInvalidation($documentTokens); + } catch (Throwable) { + // A failed restore leaves the shared tombstone fail-closed. + } + try { + $this->activateInvalidation($queryTokens); + } catch (Throwable) { + // A failed restore leaves the shared tombstone fail-closed. + } + $this->announceDocumentPurges($purgeEvents); + } + + throw $error; + } + + if ($outer) { + $queryTokens = $this->queryCacheMutations[$context]; + $documentTokens = $this->documentCacheMutations[$context]; + $documents = $this->documentCachePurges[$context]; + $purgeEvents = $this->documentPurgeEvents[$context]; + $refills = $this->definitionRefills[$context] ?? []; + unset( + $this->queryCacheMutations[$context], + $this->documentCacheMutations[$context], + $this->documentCachePurges[$context], + $this->transactionWrites[$context], + $this->transactionDefinitions[$context], + $this->definitionRefills[$context], + $this->documentPurgeEvents[$context], + ); + + $failure = null; + try { + $this->purgeWrittenDocuments($documents); + } catch (Throwable $error) { + $failure = $error; + } + try { + $this->activateDocumentInvalidation($documentTokens); + } catch (Throwable $error) { + $failure ??= $error; + } + try { + $this->activateInvalidation($queryTokens); + } catch (Throwable $error) { + $failure ??= $error; + } + + $announcement = $this->announceDocumentPurges($purgeEvents); + $failure ??= $announcement; + + if ($failure !== null) { + $this->committedFailures ??= new WeakMap(); + $this->committedFailures[$failure] = true; + + throw $failure; + } + + $this->cacheTransactionDefinitions($refills); + } + + return $result; + } + + private function queueDefinitionRefill(string $definitionKey, string $field, string $id): void + { + $tenant = $this->adapter->getTenant(); + $filtering = $this->filtering()->get(); + $exclusions = $this->filterExclusions()->get(); + + $this->definitionRefills[$this->getEventContext()][$definitionKey][$field] = function () use ($tenant, $filtering, $exclusions, $id): void { + $this->withTenant($tenant, fn (): Document => $this->filtering()->with( + $filtering, + fn (): Document => $this->filterExclusions()->with( + $exclusions, + fn (): Document => $this->silent(fn (): Document => $this->getDocument(self::METADATA, $id)), + ), + )); + }; + } + + /** + * @param array> $refills + */ + private function cacheTransactionDefinitions(array $refills): void + { + foreach ($refills as $fields) { + foreach ($fields as $refill) { + if ($this->definitionFillsFail) { + return; + } + + try { + $refill(); + } catch (Throwable $error) { + Console::warning('Warning: Failed to cache collection definition after commit: '.$error->getMessage()); + } + + if ($this->isReadFromReplica()) { + $this->definitionFillsFail = true; + } + } + } + } + + /** + * Whether the error was raised after its outermost transaction committed: the writes it + * reports on are stored, and only the invalidation or the events after the commit failed. + */ + private function failedAfterCommit(Throwable $error): bool + { + return isset($this->committedFailures[$error]); + } + + /** + * Whether the error is the Exception\Unconfirmed of a commit, not one its callback threw: the writes of the + * transaction's last attempt may be stored. It answers once, so a later transaction that rethrows the same error + * from its callback counts as rolled back. + */ + private function endedInUnconfirmedCommit(Throwable $error): bool + { + if (! isset($this->unconfirmedCommits[$error])) { + return false; + } + + unset($this->unconfirmedCommits[$error]); + + return true; + } + + /** + * Whether the writes the error reports on may be stored: it was raised after their outermost transaction + * committed, or the commit could not be confirmed. Undoing what they describe could leave a stored definition + * without its table, column or index. + */ + private function mayHaveCommitted(Throwable $error): bool + { + return $error instanceof UnconfirmedException || $this->failedAfterCommit($error); + } + + /** + * Whether an adapter transaction let the error through although it retries such a failure: it already spent its + * attempts on it, or left it to the outermost transaction that encloses it. Running that transaction again would + * multiply its retries. + */ + private function retriedByTransaction(Throwable $error): bool + { + return isset($this->transactionFailures[$error]) && $this->adapter->isRetryable($error); + } +} diff --git a/src/Database/Unchanged.php b/src/Database/Unchanged.php new file mode 100644 index 0000000000..d68ddf925c --- /dev/null +++ b/src/Database/Unchanged.php @@ -0,0 +1,8 @@ + $attributes - */ - protected array $attributes = []; - - /** - * @var array $schemaAttributes - */ - protected array $schemaAttributes = []; - - /** - * @param array $attributes - * @param array $schemaAttributes - * @param int $maxAttributes - * @param int $maxWidth - * @param int $maxStringLength - * @param int $maxVarcharLength - * @param int $maxIntLength - * @param int $maxBigIntLength - * @param bool $supportForSchemaAttributes - * @param bool $supportForVectors - * @param bool $supportForSpatialAttributes - * @param bool $supportForObject - * @param callable|null $attributeCountCallback - * @param callable|null $attributeWidthCallback - * @param callable|null $filterCallback - * @param bool $isMigrating - * @param bool $sharedTables - */ - public function __construct( - array $attributes, - array $schemaAttributes = [], - protected int $maxAttributes = 0, - protected int $maxWidth = 0, - protected int $maxStringLength = 0, - protected int $maxVarcharLength = 0, - protected int $maxIntLength = 0, - protected int $maxBigIntLength = 0, - protected bool $supportForSchemaAttributes = false, - protected bool $supportForVectors = false, - protected bool $supportForSpatialAttributes = false, - protected bool $supportForObject = false, - protected bool $supportUnsignedBigInt = false, - protected mixed $attributeCountCallback = null, - protected mixed $attributeWidthCallback = null, - protected mixed $filterCallback = null, - protected bool $isMigrating = false, - protected bool $sharedTables = false, - ) { - // Keep backwards compatibility for existing validator construction sites. - if ($this->maxBigIntLength === 0) { - $this->maxBigIntLength = $this->maxIntLength; - } - - foreach ($attributes as $attribute) { - $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); - $this->attributes[$key] = $attribute; - } - foreach ($schemaAttributes as $attribute) { - $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); - $this->schemaAttributes[$key] = $attribute; - } - } - - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ - public function getType(): string - { - return self::TYPE_OBJECT; - } - - /** - * Returns validator description - * @return string - */ - public function getDescription(): string - { - return $this->message; - } - - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ - public function isArray(): bool - { - return false; - } - - /** - * Is valid. - * - * Returns true if attribute is valid. - * @param Document $value - * @return bool - * @throws DatabaseException - * @throws DuplicateException - * @throws LimitException - */ - public function isValid($value): bool - { - if (!$this->checkDuplicateId($value)) { - return false; - } - if (!$this->checkDuplicateInSchema($value)) { - return false; - } - if (!$this->checkRequiredFilters($value)) { - return false; - } - if (!$this->checkFormat($value)) { - return false; - } - if (!$this->checkAttributeLimits($value)) { - return false; - } - if (!$this->checkType($value)) { - return false; - } - if (!$this->checkDefaultValue($value)) { - return false; - } - - return true; - } - - /** - * Check for duplicate attribute ID in collection metadata - * - * @param Document $attribute - * @return bool - * @throws DuplicateException - */ - public function checkDuplicateId(Document $attribute): bool - { - $id = $attribute->getAttribute('key', $attribute->getAttribute('$id')); - - foreach ($this->attributes as $existingAttribute) { - if (\strtolower($existingAttribute->getId()) === \strtolower($id)) { - $this->message = 'Attribute already exists in metadata'; - throw new DuplicateException($this->message); - } - } - - return true; - } - - /** - * Check for duplicate attribute ID in schema - * - * @param Document $attribute - * @return bool - * @throws DuplicateException - */ - public function checkDuplicateInSchema(Document $attribute): bool - { - if (!$this->supportForSchemaAttributes) { - return true; - } - - if ($this->sharedTables && $this->isMigrating) { - return true; - } - - $id = $attribute->getAttribute('key', $attribute->getAttribute('$id')); - - foreach ($this->schemaAttributes as $schemaAttribute) { - $schemaId = $this->filterCallback ? ($this->filterCallback)($schemaAttribute->getId()) : $schemaAttribute->getId(); - if (\strtolower($schemaId) === \strtolower($id)) { - $this->message = 'Attribute already exists in schema'; - throw new DuplicateException($this->message); - } - } - - return true; - } - - /** - * Check if required filters are present for the attribute type - * - * @param Document $attribute - * @return bool - * @throws DatabaseException - */ - public function checkRequiredFilters(Document $attribute): bool - { - $type = $attribute->getAttribute('type'); - $filters = $attribute->getAttribute('filters', []); - - $requiredFilters = $this->getRequiredFilters($type); - if (!empty(\array_diff($requiredFilters, $filters))) { - $this->message = "Attribute of type: $type requires the following filters: " . implode(",", $requiredFilters); - throw new DatabaseException($this->message); - } - - return true; - } - - /** - * Get the list of required filters for each data type - * - * @param string|null $type Type of the attribute - * - * @return array - */ - protected function getRequiredFilters(?string $type): array - { - return match ($type) { - Database::VAR_DATETIME => ['datetime'], - default => [], - }; - } - - /** - * Check if format is valid for the attribute type - * - * @param Document $attribute - * @return bool - * @throws DatabaseException - */ - public function checkFormat(Document $attribute): bool - { - $format = $attribute->getAttribute('format'); - $type = $attribute->getAttribute('type'); - - if ($format && !Structure::hasFormat($format, $type)) { - $this->message = 'Format ("' . $format . '") not available for this attribute type ("' . $type . '")'; - throw new DatabaseException($this->message); - } - - return true; - } - - /** - * Check attribute limits (count and width) - * - * @param Document $attribute - * @return bool - * @throws LimitException - */ - public function checkAttributeLimits(Document $attribute): bool - { - if ($this->attributeCountCallback === null || $this->attributeWidthCallback === null) { - return true; - } - - $attributeCount = ($this->attributeCountCallback)($attribute); - $attributeWidth = ($this->attributeWidthCallback)($attribute); - - if ($this->maxAttributes > 0 && $attributeCount > $this->maxAttributes) { - $this->message = 'Column limit reached. Cannot create new attribute. Current attribute count is ' . $attributeCount . ' but the maximum is ' . $this->maxAttributes . '. Remove some attributes to free up space.'; - throw new LimitException($this->message); - } - - if ($this->maxWidth > 0 && $attributeWidth >= $this->maxWidth) { - $this->message = 'Row width limit reached. Cannot create new attribute. Current row width is ' . $attributeWidth . ' bytes but the maximum is ' . $this->maxWidth . ' bytes. Reduce the size of existing attributes or remove some attributes to free up space.'; - throw new LimitException($this->message); - } - - return true; - } - - /** - * Check attribute type and type-specific constraints - * - * @param Document $attribute - * @return bool - * @throws DatabaseException - */ - public function checkType(Document $attribute): bool - { - $type = $attribute->getAttribute('type'); - $size = $attribute->getAttribute('size', 0); - $signed = $attribute->getAttribute('signed', true); - $array = $attribute->getAttribute('array', false); - $default = $attribute->getAttribute('default'); - - switch ($type) { - case Database::VAR_ID: - break; - - case Database::VAR_STRING: - if ($size > $this->maxStringLength) { - $this->message = 'Max size allowed for string is: ' . number_format($this->maxStringLength); - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_VARCHAR: - if ($size > $this->maxVarcharLength) { - $this->message = 'Max size allowed for varchar is: ' . number_format($this->maxVarcharLength); - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_TEXT: - if ($size > Database::MAX_TEXT_BYTES) { - $this->message = 'Max size allowed for text is: ' . Database::MAX_TEXT_BYTES; - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_MEDIUMTEXT: - if ($size > Database::MAX_MEDIUMTEXT_BYTES) { - $this->message = 'Max size allowed for mediumtext is: ' . Database::MAX_MEDIUMTEXT_BYTES; - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_LONGTEXT: - if ($size > Database::MAX_LONGTEXT_BYTES) { - $this->message = 'Max size allowed for longtext is: ' . Database::MAX_LONGTEXT_BYTES; - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_INTEGER: - $limit = ($signed) ? $this->maxIntLength / 2 : $this->maxIntLength; - if ($size > $limit) { - $this->message = 'Max size allowed for int is: ' . number_format($limit); - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_BIGINT: - break; - - case Database::VAR_FLOAT: - case Database::VAR_BOOLEAN: - case Database::VAR_DATETIME: - case Database::VAR_RELATIONSHIP: - break; - - case Database::VAR_OBJECT: - if (!$this->supportForObject) { - $this->message = 'Object attributes are not supported'; - throw new DatabaseException($this->message); - } - if (!empty($size)) { - $this->message = 'Size must be empty for object attributes'; - throw new DatabaseException($this->message); - } - if (!empty($array)) { - $this->message = 'Object attributes cannot be arrays'; - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_POINT: - case Database::VAR_LINESTRING: - case Database::VAR_POLYGON: - if (!$this->supportForSpatialAttributes) { - $this->message = 'Spatial attributes are not supported'; - throw new DatabaseException($this->message); - } - if (!empty($size)) { - $this->message = 'Size must be empty for spatial attributes'; - throw new DatabaseException($this->message); - } - if (!empty($array)) { - $this->message = 'Spatial attributes cannot be arrays'; - throw new DatabaseException($this->message); - } - break; - - case Database::VAR_VECTOR: - if (!$this->supportForVectors) { - $this->message = 'Vector types are not supported by the current database'; - throw new DatabaseException($this->message); - } - if ($array) { - $this->message = 'Vector type cannot be an array'; - throw new DatabaseException($this->message); - } - if ($size <= 0) { - $this->message = 'Vector dimensions must be a positive integer'; - throw new DatabaseException($this->message); - } - if ($size > Database::MAX_VECTOR_DIMENSIONS) { - $this->message = 'Vector dimensions cannot exceed ' . Database::MAX_VECTOR_DIMENSIONS; - throw new DatabaseException($this->message); - } - - // Validate default value if provided - if ($default !== null) { - if (!is_array($default)) { - $this->message = 'Vector default value must be an array'; - throw new DatabaseException($this->message); - } - if (count($default) !== $size) { - $this->message = 'Vector default value must have exactly ' . $size . ' elements'; - throw new DatabaseException($this->message); - } - foreach ($default as $component) { - if (!is_numeric($component)) { - $this->message = 'Vector default value must contain only numeric elements'; - throw new DatabaseException($this->message); - } - } - } - break; - - default: - $supportedTypes = [ - Database::VAR_STRING, - Database::VAR_VARCHAR, - Database::VAR_TEXT, - Database::VAR_MEDIUMTEXT, - Database::VAR_LONGTEXT, - Database::VAR_INTEGER, - Database::VAR_BIGINT, - Database::VAR_FLOAT, - Database::VAR_BOOLEAN, - Database::VAR_DATETIME, - Database::VAR_RELATIONSHIP - ]; - if ($this->supportForVectors) { - $supportedTypes[] = Database::VAR_VECTOR; - } - if ($this->supportForSpatialAttributes) { - \array_push($supportedTypes, ...Database::SPATIAL_TYPES); - } - if ($this->supportForObject) { - $supportedTypes[] = Database::VAR_OBJECT; - } - $this->message = 'Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes); - throw new DatabaseException($this->message); - } - - return true; - } - - /** - * Check default value constraints and type matching - * - * @param Document $attribute - * @return bool - * @throws DatabaseException - */ - public function checkDefaultValue(Document $attribute): bool - { - $default = $attribute->getAttribute('default'); - $required = $attribute->getAttribute('required', false); - $type = $attribute->getAttribute('type'); - $array = $attribute->getAttribute('array', false); - $signed = $attribute->getAttribute('signed', true); - - if (\is_null($default)) { - return true; - } - - if ($required === true) { - $this->message = 'Cannot set a default value for a required attribute'; - throw new DatabaseException($this->message); - } - - // Reject array defaults for non-array attributes (except vectors, spatial types, and objects which use arrays internally) - if (\is_array($default) && !$array && !\in_array($type, [Database::VAR_VECTOR, Database::VAR_OBJECT, ...Database::SPATIAL_TYPES], true)) { - $this->message = 'Cannot set an array default value for a non-array attribute'; - throw new DatabaseException($this->message); - } - - $this->validateDefaultTypes($type, $default, $signed); - - return true; - } - - /** - * Function to validate if the default value of an attribute matches its attribute type - * - * @param string $type Type of the attribute - * @param mixed $default Default value of the attribute - * @param bool $signed Whether the attribute is signed (relevant for bigint) - * - * @return void - * @throws DatabaseException - */ - protected function validateDefaultTypes(string $type, mixed $default, bool $signed = true): void - { - $defaultType = \gettype($default); - - if ($defaultType === 'NULL') { - // Disable null. No validation required - return; - } - - if ($defaultType === 'array') { - // Spatial types require the array itself - if (!in_array($type, Database::SPATIAL_TYPES) && $type != Database::VAR_OBJECT) { - foreach ($default as $value) { - $this->validateDefaultTypes($type, $value, $signed); - } - } - return; - } - - switch ($type) { - case Database::VAR_STRING: - case Database::VAR_VARCHAR: - case Database::VAR_TEXT: - case Database::VAR_MEDIUMTEXT: - case Database::VAR_LONGTEXT: - if ($defaultType !== 'string') { - $this->message = 'Default value ' . $default . ' does not match given type ' . $type; - throw new DatabaseException($this->message); - } - break; - case Database::VAR_INTEGER: - case Database::VAR_FLOAT: - case Database::VAR_BOOLEAN: - if ($type !== $defaultType) { - $this->message = 'Default value ' . $default . ' does not match given type ' . $type; - throw new DatabaseException($this->message); - } - break; - case Database::VAR_BIGINT: - if ($defaultType !== 'integer' && $defaultType !== 'string') { - $this->message = 'Default value ' . $default . ' does not match given type ' . $type; - throw new DatabaseException($this->message); - } - if ($defaultType === 'string' && !BigInt::isIntegerString($default, $signed)) { - $this->message = 'Default value ' . $default . ' is not a valid integer string for type bigint'; - throw new DatabaseException($this->message); - } - break; - case Database::VAR_DATETIME: - if ($defaultType !== Database::VAR_STRING) { - $this->message = 'Default value ' . $default . ' does not match given type ' . $type; - throw new DatabaseException($this->message); - } - break; - case Database::VAR_VECTOR: - // When validating individual vector components (from recursion), they should be numeric - if ($defaultType !== 'double' && $defaultType !== 'integer') { - $this->message = 'Vector components must be numeric values (float or integer)'; - throw new DatabaseException($this->message); - } - break; - default: - $supportedTypes = [ - Database::VAR_STRING, - Database::VAR_VARCHAR, - Database::VAR_TEXT, - Database::VAR_MEDIUMTEXT, - Database::VAR_LONGTEXT, - Database::VAR_INTEGER, - Database::VAR_BIGINT, - Database::VAR_FLOAT, - Database::VAR_BOOLEAN, - Database::VAR_DATETIME, - Database::VAR_RELATIONSHIP - ]; - if ($this->supportForVectors) { - $supportedTypes[] = Database::VAR_VECTOR; - } - if ($this->supportForSpatialAttributes) { - \array_push($supportedTypes, ...Database::SPATIAL_TYPES); - } - $this->message = 'Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes); - throw new DatabaseException($this->message); - } - } -} diff --git a/src/Database/Validator/AttributeDefinition.php b/src/Database/Validator/AttributeDefinition.php new file mode 100644 index 0000000000..729bedc6cc --- /dev/null +++ b/src/Database/Validator/AttributeDefinition.php @@ -0,0 +1,607 @@ + + */ + protected array $attributes = []; + + /** + * @var list + */ + protected array $schemaAttributes = []; + + /** + * Schema attributes are the engine's physical columns, read only for their names. + * + * @param array $attributes + * @param list $schemaAttributes + * @param (Closure(Document): int)|null $attributeCount + * @param (Closure(Document): int)|null $attributeWidth + * @param (Closure(string): string)|null $filter + */ + public function __construct( + array $attributes, + protected readonly Profile $profile, + array $schemaAttributes = [], + protected readonly ?Closure $attributeCount = null, + protected readonly ?Closure $attributeWidth = null, + protected readonly ?Closure $filter = null, + ) { + foreach ($attributes as $attribute) { + $typed = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); + $this->attributes[\strtolower($typed->key)] = $typed; + } + foreach ($schemaAttributes as $column) { + $this->schemaAttributes[] = $column->name; + } + } + + #[\Override] + public function getType(): string + { + return self::TYPE_OBJECT; + } + + #[\Override] + public function getDescription(): string + { + return $this->message; + } + + #[\Override] + public function isArray(): bool + { + return false; + } + + /** + * @throws DatabaseException + * @throws DuplicateException + * @throws LimitException + */ + #[\Override] + public function isValid(mixed $value): bool + { + $declaration = $value instanceof Document ? $value : null; + if ($declaration !== null) { + try { + $value = Attribute::fromDocument($value); + } catch (StructureException $error) { + $this->message = $error->getMessage(); + throw new DatabaseException($this->message, previous: $error); + } + } + + if (! $value instanceof Attribute) { + $this->message = 'Value must be an attribute'; + + return false; + } + + if (! $this->checkDuplicateId($value)) { + return false; + } + if (! $this->checkDuplicateInSchema($value)) { + return false; + } + if (! $this->checkRequiredFilters($value, self::declaredFilters($declaration))) { + return false; + } + if (! $this->checkFormat($value)) { + return false; + } + if (! $this->checkType($value, self::declaredSize($declaration), self::declaredArray($declaration))) { + return false; + } + if (! $this->checkAttributeLimits($value)) { + return false; + } + if (! $this->checkDefaultValue($value)) { + return false; + } + + return true; + } + + /** + * @throws DuplicateException + */ + public function checkDuplicateId(Attribute $attribute): bool + { + $id = \strtolower($attribute->key); + + foreach ($this->attributes as $existingAttribute) { + if (\strtolower($existingAttribute->key) === $id) { + $this->message = 'Attribute already exists in metadata'; + throw new DuplicateException($this->message); + } + } + + return true; + } + + /** + * @throws DuplicateException + */ + public function checkDuplicateInSchema(Attribute $attribute): bool + { + if (! $this->profile->supports(Capability::SchemaIntrospection)) { + return true; + } + + if ($this->profile->sharedTables && $this->profile->migrating) { + return true; + } + + $id = \strtolower($attribute->key); + + foreach ($this->schemaAttributes as $schemaAttribute) { + $schemaId = $this->filter === null ? $schemaAttribute : ($this->filter)($schemaAttribute); + if (\strtolower($schemaId) === $id) { + $this->message = 'Attribute already exists in schema'; + throw new DuplicateException($this->message); + } + } + + return true; + } + + /** + * @param list|null $filters the declared filters, before the model adds the type filter + * + * @throws DatabaseException + */ + public function checkRequiredFilters(Attribute $attribute, ?array $filters = null): bool + { + $requiredFilters = $this->getRequiredFilters($attribute->type); + if (! empty(\array_diff($requiredFilters, $filters ?? $attribute->filters))) { + $this->message = 'Attribute of type: '.$attribute->type->value.' requires the following filters: '.implode(',', $requiredFilters); + throw new DatabaseException($this->message); + } + + return true; + } + + /** + * @return array + */ + protected function getRequiredFilters(ColumnType $type): array + { + return match ($type) { + ColumnType::Datetime => [Filter::Datetime->value], + default => [], + }; + } + + /** + * @throws DatabaseException + */ + public function checkFormat(Attribute $attribute): bool + { + $format = $attribute->format?->name; + if ($format && ! Structure::hasFormat($format, $attribute->type)) { + $this->message = 'Format ("'.$format.'") not available for this attribute type ("'.$attribute->type->value.'")'; + throw new DatabaseException($this->message); + } + + return true; + } + + /** + * @throws LimitException + */ + public function checkAttributeLimits(Attribute $attribute): bool + { + if ($this->attributeCount === null || $this->attributeWidth === null) { + return true; + } + + $document = $attribute->toDocument(); + $attributeCount = ($this->attributeCount)($document); + $attributeWidth = ($this->attributeWidth)($document); + $maxAttributes = $this->profile->limits->attributes; + $maxWidth = $this->profile->limits->documentSize; + + if ($maxAttributes > 0 && $attributeCount > $maxAttributes) { + $this->message = 'Column limit reached. Cannot create new attribute. Current attribute count is '.$attributeCount.' but the maximum is '.$maxAttributes.'. Remove some attributes to free up space.'; + throw new LimitException($this->message); + } + + if ($maxWidth > 0 && $attributeWidth >= $maxWidth) { + $this->message = 'Row width limit reached. Cannot create new attribute. Current row width is '.$attributeWidth.' bytes but the maximum is '.$maxWidth.' bytes. Reduce the size of existing attributes or remove some attributes to free up space.'; + throw new LimitException($this->message); + } + + return true; + } + + /** + * The size and array checked are the declared ones when given, since the model normalises + * them away for types that take neither. + * + * @throws DatabaseException + */ + public function checkType(Attribute $attribute, ?int $size = null, ?bool $array = null): bool + { + $type = $attribute->type; + $size ??= $attribute->size ?? 0; + $signed = $attribute->signed; + $array ??= $attribute->array; + $default = $attribute->default; + + switch ($type) { + case ColumnType::Id: + break; + + case ColumnType::String: + if ($size > $this->profile->limits->string) { + $this->message = 'Max size allowed for string is: '.number_format($this->profile->limits->string); + throw new DatabaseException($this->message); + } + break; + + case ColumnType::Varchar: + if ($size > $this->profile->limits->varchar) { + $this->message = 'Max size allowed for varchar is: '.number_format($this->profile->limits->varchar); + throw new DatabaseException($this->message); + } + break; + + case ColumnType::Text: + if ($size > 65535) { + $this->message = 'Max size allowed for text is: 65535'; + throw new DatabaseException($this->message); + } + break; + + case ColumnType::MediumText: + if ($size > 16777215) { + $this->message = 'Max size allowed for mediumtext is: 16777215'; + throw new DatabaseException($this->message); + } + break; + + case ColumnType::LongText: + if ($size > 4294967295) { + $this->message = 'Max size allowed for longtext is: 4294967295'; + throw new DatabaseException($this->message); + } + break; + + case ColumnType::Integer: + $limit = $signed ? $this->profile->limits->integer / 2 : $this->profile->limits->integer; + if ($size > $limit) { + $this->message = 'Max size allowed for int is: '.number_format($limit); + throw new DatabaseException($this->message); + } + break; + + case ColumnType::BigInteger: + case ColumnType::Float: + case ColumnType::Double: + case ColumnType::Boolean: + case ColumnType::Datetime: + case ColumnType::Relationship: + break; + + case ColumnType::Object: + if (! $this->profile->supports(Capability::Objects)) { + $this->message = 'Object attributes are not supported'; + throw new DatabaseException($this->message); + } + if (! empty($size)) { + $this->message = 'Size must be empty for object attributes'; + throw new DatabaseException($this->message); + } + if (! empty($array)) { + $this->message = 'Object attributes cannot be arrays'; + throw new DatabaseException($this->message); + } + break; + + case ColumnType::Point: + case ColumnType::Linestring: + case ColumnType::Polygon: + if (! $this->profile->hasFeature(Feature\Spatial::class)) { + $this->message = 'Spatial attributes are not supported'; + throw new DatabaseException($this->message); + } + if (! empty($size)) { + $this->message = 'Size must be empty for spatial attributes'; + throw new DatabaseException($this->message); + } + if (! empty($array)) { + $this->message = 'Spatial attributes cannot be arrays'; + throw new DatabaseException($this->message); + } + break; + + case ColumnType::Vector: + if (! $this->profile->supports(Capability::Vectors)) { + $this->message = 'Vector types are not supported by the current database'; + throw new DatabaseException($this->message); + } + if ($array) { + $this->message = 'Vector type cannot be an array'; + throw new DatabaseException($this->message); + } + if ($size <= 0) { + $this->message = 'Vector dimensions must be a positive integer'; + throw new DatabaseException($this->message); + } + if ($size > Database::MAX_VECTOR_DIMENSIONS) { + $this->message = 'Vector dimensions cannot exceed '.Database::MAX_VECTOR_DIMENSIONS; + throw new DatabaseException($this->message); + } + + if ($default !== null) { + if (! is_array($default)) { + $this->message = 'Vector default value must be an array'; + throw new DatabaseException($this->message); + } + if (count($default) !== $size) { + $this->message = 'Vector default value must have exactly '.$size.' elements'; + throw new DatabaseException($this->message); + } + foreach ($default as $component) { + if (! is_numeric($component)) { + $this->message = 'Vector default value must contain only numeric elements'; + throw new DatabaseException($this->message); + } + } + } + break; + + default: + $this->message = 'Unknown attribute type: '.$type->value.'. Must be one of '.\implode(', ', \array_map( + Attribute::storedType(...), + Attribute::availableTypes($this->profile), + )); + throw new DatabaseException($this->message); + } + + return true; + } + + /** + * @throws DatabaseException + */ + public function checkDefaultValue(Attribute $attribute): bool + { + $default = $attribute->default; + $type = $attribute->type; + $signed = $attribute->signed; + + if (\is_null($default)) { + return true; + } + + if ($attribute->required) { + $this->message = 'Cannot set a default value for a required attribute'; + throw new DatabaseException($this->message); + } + + if ($this->isJsonDocumentDefault($attribute)) { + $this->checkJsonEncodable($attribute); + + return true; + } + + // Vectors, spatial types and objects store their values as arrays. + if (\is_array($default) && ! $attribute->array && ! \in_array($type, [ColumnType::Vector, ColumnType::Object, ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + $this->message = 'Cannot set an array default value for a non-array attribute'; + throw new DatabaseException($this->message); + } + + $this->validateDefaultTypes($type, $default, $signed); + + return true; + } + + private static function declaredSize(?Document $declaration): ?int + { + if ($declaration === null) { + return null; + } + + $size = $declaration->getAttribute(self::SIZE); + + return \is_numeric($size) ? (int) $size : 0; + } + + private static function declaredArray(?Document $declaration): ?bool + { + return $declaration === null ? null : (bool) $declaration->getAttribute(self::ARRAY, false); + } + + /** + * @return list|null + */ + private static function declaredFilters(?Document $declaration): ?array + { + if ($declaration === null) { + return null; + } + + $filters = $declaration->getAttribute(self::FILTERS, []); + if (! \is_array($filters)) { + return []; + } + + /** @var list $filters */ + $filters = \array_values($filters); + + return Filter::names($filters); + } + + /** + * The json filter writes arrays, stdClass objects and documents to a string column as their + * JSON encoding, so such a default is checked for encodability rather than against the column type. + */ + private function isJsonDocumentDefault(Attribute $attribute): bool + { + $default = $attribute->default; + + return \in_array(Filter::Json->value, $attribute->filters, true) + && ! $attribute->array + && \in_array($attribute->type, self::STRING_TYPES, true) + && (\is_array($default) || $default instanceof stdClass || $default instanceof Document); + } + + /** + * @throws DatabaseException + */ + private function checkJsonEncodable(Attribute $attribute): void + { + $default = $attribute->default; + + if (\json_encode($default instanceof Document ? $default->getArrayCopy() : $default) === false) { + $this->message = 'Default value of json attribute "'.$attribute->key.'" is not JSON-encodable: '.\json_last_error_msg(); + throw new DatabaseException($this->message); + } + } + + /** + * @throws DatabaseException + */ + protected function validateDefaultTypes(ColumnType $type, mixed $default, bool $signed = true): void + { + $defaultType = \gettype($default); + + if ($defaultType === 'NULL') { + return; + } + + if ($defaultType === 'array') { + if (\in_array($type, self::SPATIAL_TYPES, true)) { + $spatial = new Spatial($type->value); + if (! $spatial->isValid($default)) { + $this->message = 'Invalid default value: '.$spatial->getDescription(); + throw new DatabaseException($this->message); + } + + return; + } + + if ($type !== ColumnType::Object) { + /** @var array $default */ + foreach ($default as $value) { + $this->validateDefaultTypes($type, $value, $signed); + } + } + + return; + } + + switch ($type) { + case ColumnType::String: + case ColumnType::Varchar: + case ColumnType::Text: + case ColumnType::MediumText: + case ColumnType::LongText: + if ($defaultType !== 'string') { + $this->message = 'Default value '.json_encode($default).' does not match given type '.Attribute::storedType($type); + throw new DatabaseException($this->message); + } + break; + case ColumnType::Integer: + case ColumnType::Boolean: + if ($type->value !== $defaultType) { + $this->message = 'Default value '.json_encode($default).' does not match given type '.Attribute::storedType($type); + throw new DatabaseException($this->message); + } + break; + case ColumnType::BigInteger: + if (! (new BigInt($signed, $this->profile->supports(Capability::UnsignedBigInt)))->isValid($default)) { + $this->message = 'Default value '.json_encode($default).' does not match given type '.Attribute::storedType($type); + throw new DatabaseException($this->message); + } + break; + case ColumnType::Float: + case ColumnType::Double: + if ($defaultType !== 'double') { + $this->message = 'Default value '.json_encode($default).' does not match given type '.Attribute::storedType($type); + throw new DatabaseException($this->message); + } + break; + case ColumnType::Datetime: + if ($defaultType !== 'string') { + $this->message = 'Default value '.json_encode($default).' does not match given type '.Attribute::storedType($type); + throw new DatabaseException($this->message); + } + break; + case ColumnType::Vector: + if ($defaultType !== 'double' && $defaultType !== 'integer') { + $this->message = 'Vector components must be numeric values (float or integer)'; + throw new DatabaseException($this->message); + } + break; + default: + $supportedTypes = [ + ColumnType::String->value, + ColumnType::Varchar->value, + ColumnType::Text->value, + ColumnType::MediumText->value, + ColumnType::LongText->value, + ColumnType::Integer->value, + Attribute::storedType(ColumnType::BigInteger), + ColumnType::Float->value, + ColumnType::Double->value, + ColumnType::Boolean->value, + ColumnType::Datetime->value, + ColumnType::Relationship->value, + ]; + if ($this->profile->supports(Capability::Vectors)) { + $supportedTypes[] = ColumnType::Vector->value; + } + if ($this->profile->hasFeature(Feature\Spatial::class)) { + \array_push($supportedTypes, ColumnType::Point->value, ColumnType::Linestring->value, ColumnType::Polygon->value); + } + $this->message = 'Unknown attribute type: '.$type->value.'. Must be one of '.implode(', ', $supportedTypes); + throw new DatabaseException($this->message); + } + } +} diff --git a/src/Database/Validator/Authorization.php b/src/Database/Validator/Authorization.php index 5f5ac179b2..18273f06a2 100644 --- a/src/Database/Validator/Authorization.php +++ b/src/Database/Validator/Authorization.php @@ -2,101 +2,112 @@ namespace Utopia\Database\Validator; +use Utopia\Database\State\Group; +use Utopia\Database\State\Value; use Utopia\Database\Validator\Authorization\Input; use Utopia\Validator; +/** + * The status and the roles are shared by every caller, except inside skip() and withRoles(): those + * scopes belong to the calling coroutine and the coroutines it starts (see {@see Value}). The status and the roles + * share one {@see Group}, so any of those scopes keeps the status and role changes of a coroutine cut off from it, + * because a coroutine between them has returned, local to that coroutine. + */ class Authorization extends Validator { /** - * @var bool + * @var Value */ - protected bool $status = true; + private Value $status; /** - * Default value in case we need - * to reset Authorization status - * - * @var bool + * @var Value> */ - protected bool $statusDefault = true; + private Value $roles; - /** - * @var array - */ - private array $roles = [ - 'any' => true - ]; - - /** - * @var string - */ protected string $message = 'Authorization Error'; /** - * Get Description. - * - * Returns validator description - * - * @return string + * @param bool $defaultStatus The status the handle starts with, which reset() restores */ + public function __construct(protected readonly bool $defaultStatus = true) + { + $group = new Group(); + $this->status = new Value($defaultStatus, $group); + + /** @var Value> $roles */ + $roles = new Value(['any' => true], $group); + $this->roles = $roles; + } + + public function __clone() + { + $group = new Group(); + $this->status = new Value($this->status->get(), $group); + $this->roles = new Value($this->roles->get(), $group); + } + + #[\Override] public function getDescription(): string { return $this->message; } - /* - * Validation - * - * Returns true if valid or false if not. - */ - public function isValid(mixed $input): bool + /** + * Validate that the given Authorization\Input has the required permissions for the current roles. + */ + #[\Override] + public function isValid(mixed $value): bool { - if (!($input instanceof Input)) { + if (! ($value instanceof Input)) { $this->message = 'Invalid input provided'; + return false; } - $permissions = $input->getPermissions(); - $action = $input->getAction(); + $permissions = $value->getPermissions(); + $action = $value->getAction(); - if (!$this->status) { + if (! $this->status->get()) { return true; } if (empty($permissions)) { $this->message = 'No permissions provided for action \''.$action.'\''; + return false; } $permission = '-'; + $roles = $this->roles->get(); foreach ($permissions as $permission) { - if (\array_key_exists($permission, $this->roles)) { + if (\array_key_exists($permission, $roles)) { return true; } } $this->message = 'Missing "'.$action.'" permission for role "'.$permission.'". Only "'.\json_encode($this->getRoles()).'" scopes are allowed and "'.\json_encode($permissions).'" was given.'; + return false; } - /** - * @param string $role - * @return void - */ - public function addRole(string $role): void + public function addRole(string $role): static { - $this->roles[$role] = true; + $roles = $this->roles->get(); + $roles[$role] = true; + $this->roles->set($roles); + + return $this; } - /** - * @param string $role - * - * @return void - */ - public function removeRole(string $role): void + public function removeRole(string $role): static { - unset($this->roles[$role]); + $roles = $this->roles->get(); + unset($roles[$role]); + $this->roles->set($roles); + + return $this; } /** @@ -104,131 +115,105 @@ public function removeRole(string $role): void */ public function getRoles(): array { - return \array_keys($this->roles); + return \array_keys($this->roles->get()); } /** - * @return void + * Run the callback with exactly these roles for the calling coroutine and the coroutines it starts. Roles + * added or removed inside the callback change that scope only. + * + * @template T + * + * @param array $roles + * @param callable(): T $callback + * @return T */ - public function cleanRoles(): void + public function withRoles(array $roles, callable $callback): mixed { - $this->roles = []; + return $this->roles->with(\array_fill_keys($roles, true), $callback); + } + + public function cleanRoles(): static + { + $this->roles->set([]); + + return $this; } - /** - * @param string $role - * - * @return bool - */ public function hasRole(string $role): bool { - return (\array_key_exists($role, $this->roles)); + return \array_key_exists($role, $this->roles->get()); } - /** - * Change default status. - * This will be used for the - * value set on the $this->reset() method - * @param bool $status - * @return void - */ - public function setDefaultStatus(bool $status): void + public function setStatus(bool $status): static { - $this->statusDefault = $status; - $this->status = $status; + $this->status->set($status); + + return $this; } - /** - * Change status - * - * @param bool $status - * @return void - */ - public function setStatus(bool $status): void + public function getStatus(): bool { - $this->status = $status; + return $this->status->get(); } /** - * Get status + * @template T * - * @return bool + * @param callable(): T $callback + * @return T */ - public function getStatus(): bool + public function skip(callable $callback): mixed { - return $this->status; + return $this->status->with(false, $callback); } /** - * Skip Authorization + * Run the callback with exactly this status and these roles for the calling coroutine and the coroutines it + * starts, as Database::withSnapshot() restores them. * - * Skips authorization for the code to be executed inside the callback + * @internal * * @template T - * @param callable(): T $callback + * + * @param array $roles + * @param callable(): T $callback * @return T */ - public function skip(callable $callback): mixed + public function restore(bool $status, array $roles, callable $callback): mixed { - $initialStatus = $this->status; - $this->disable(); - - try { - return $callback(); - } finally { - $this->status = $initialStatus; - } + return $this->status->with($status, fn (): mixed => $this->withRoles($roles, $callback)); } - /** - * Enable Authorization checks - * - * @return void - */ - public function enable(): void + public function enable(): static { - $this->status = true; + $this->status->set(true); + + return $this; } - /** - * Disable Authorization checks - * - * @return void - */ - public function disable(): void + public function disable(): static { - $this->status = false; + $this->status->set(false); + + return $this; } /** - * Disable Authorization checks - * - * @return void + * Restore the status the handle was constructed with. */ public function reset(): void { - $this->status = $this->statusDefault; + $this->status->set($this->defaultStatus); } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_ARRAY; diff --git a/src/Database/Validator/Authorization/Input.php b/src/Database/Validator/Authorization/Input.php index 8db9e80585..bbe93db64a 100644 --- a/src/Database/Validator/Authorization/Input.php +++ b/src/Database/Validator/Authorization/Input.php @@ -2,35 +2,44 @@ namespace Utopia\Database\Validator\Authorization; +use Utopia\Database\PermissionType; + +/** + * Encapsulates the action and permissions used as input for authorization validation. + */ class Input { /** - * @var array $permissions + * @var array */ protected array $permissions; + protected string $action; /** - * @param string[] $permissions + * @param PermissionType|string $action A built-in permission type, or a consumer-defined action such as 'execute' + * @param array $permissions The permissions that grant the action */ - public function __construct(string $action, array $permissions) + public function __construct(PermissionType|string $action, array $permissions) { $this->permissions = $permissions; - $this->action = $action; + $this->action = $action instanceof PermissionType ? $action->value : $action; } /** - * @param string[] $permissions + * @param array $permissions */ - public function setPermissions(array $permissions): self + public function setPermissions(array $permissions): static { $this->permissions = $permissions; + return $this; } - public function setAction(string $action): self + public function setAction(PermissionType|string $action): static { - $this->action = $action; + $this->action = $action instanceof PermissionType ? $action->value : $action; + return $this; } diff --git a/src/Database/Validator/BigInt.php b/src/Database/Validator/BigInt.php index fc560694a2..c6b33719f8 100644 --- a/src/Database/Validator/BigInt.php +++ b/src/Database/Validator/BigInt.php @@ -2,14 +2,15 @@ namespace Utopia\Database\Validator; -use Utopia\Database\Database; +use InvalidArgumentException; +use Utopia\Database\OperatorType; use Utopia\Validator; class BigInt extends Validator { - public const SIGNED_MIN = '-9223372036854775808'; - public const SIGNED_MAX = '9223372036854775807'; - public const UNSIGNED_MAX = '18446744073709551615'; + public const string SIGNED_MIN = '-9223372036854775808'; + public const string SIGNED_MAX = '9223372036854775807'; + public const string UNSIGNED_MAX = '18446744073709551615'; public function __construct( private readonly bool $signed, @@ -17,6 +18,7 @@ public function __construct( ) { } + #[\Override] public function getDescription(): string { if ($this->signed) { @@ -30,16 +32,19 @@ public function getDescription(): string self::formatIntegerString($max); } + #[\Override] public function isArray(): bool { return false; } + #[\Override] public function getType(): string { - return Database::VAR_BIGINT; + return \Utopia\Query\Schema\ColumnType::BigInteger->value; } + #[\Override] public function isValid(mixed $value): bool { if (\is_int($value)) { @@ -123,6 +128,355 @@ public static function compareUnsignedStrings(string $a, string $b): int return $a < $b ? -1 : 1; } + public static function normalizeInteger(mixed $value): string + { + if (\is_int($value)) { + return (string) $value; + } + if (\is_float($value)) { + if (! \is_finite($value) || \floor($value) !== $value || $value < \PHP_INT_MIN || $value > \PHP_INT_MAX) { + throw new InvalidArgumentException('Value must be an integer.'); + } + $value = (int) $value; + } + if (! \is_int($value) && ! \is_string($value)) { + throw new InvalidArgumentException('Value must be an integer.'); + } + $value = (string) $value; + if (! self::isIntegerString($value)) { + throw new InvalidArgumentException('Value must be an integer.'); + } + + $negative = \str_starts_with($value, '-'); + $digits = self::normalizeUnsignedString($negative ? \substr($value, 1) : $value); + + return $negative && $digits !== '0' ? '-'.$digits : $digits; + } + + public static function toNative(mixed $value): int|string + { + if (\is_int($value)) { + return $value; + } + + $value = self::normalizeInteger($value); + + return self::fitsPhpInt($value) ? (int) $value : $value; + } + + public static function compare(int|float|string $a, int|float|string $b): int + { + if (\is_int($a) && \is_int($b)) { + return $a <=> $b; + } + + $a = self::normalizeInteger($a); + $b = self::normalizeInteger($b); + $aNegative = \str_starts_with($a, '-'); + $bNegative = \str_starts_with($b, '-'); + + if ($aNegative !== $bNegative) { + return $aNegative ? -1 : 1; + } + + $comparison = self::compareUnsignedStrings(\ltrim($a, '-'), \ltrim($b, '-')); + + return $aNegative ? -$comparison : $comparison; + } + + public static function add(int|float|string $a, int|float|string $b): int|string + { + if (\is_int($a) && \is_int($b) && ($b >= 0 ? $a <= \PHP_INT_MAX - $b : $a >= \PHP_INT_MIN - $b)) { + return $a + $b; + } + + $a = self::normalizeInteger($a); + $b = self::normalizeInteger($b); + $aNegative = \str_starts_with($a, '-'); + $bNegative = \str_starts_with($b, '-'); + $aDigits = \ltrim($a, '-'); + $bDigits = \ltrim($b, '-'); + + if ($aNegative === $bNegative) { + $result = self::addUnsignedStrings($aDigits, $bDigits); + if ($aNegative && $result !== '0') { + $result = '-'.$result; + } + + return self::toNative($result); + } + + $comparison = self::compareUnsignedStrings($aDigits, $bDigits); + if ($comparison === 0) { + return 0; + } + + $aLarger = $comparison > 0; + $result = self::subtractUnsignedStrings( + $aLarger ? $aDigits : $bDigits, + $aLarger ? $bDigits : $aDigits, + ); + if (($aLarger ? $aNegative : $bNegative) && $result !== '0') { + $result = '-'.$result; + } + + return self::toNative($result); + } + + public static function subtract(int|float|string $a, int|float|string $b): int|string + { + return self::add($a, self::negate($b)); + } + + public static function negate(int|float|string $value): int|string + { + if (\is_int($value) && $value !== \PHP_INT_MIN) { + return -$value; + } + + $value = self::normalizeInteger($value); + if ($value === '0') { + return 0; + } + + return self::toNative(\str_starts_with($value, '-') ? \substr($value, 1) : '-'.$value); + } + + public static function multiply(int|float|string $a, int|float|string $b): int|string + { + $a = self::normalizeInteger($a); + $b = self::normalizeInteger($b); + $negative = \str_starts_with($a, '-') !== \str_starts_with($b, '-'); + $result = self::multiplyUnsignedStrings(\ltrim($a, '-'), \ltrim($b, '-')); + if ($negative && $result !== '0') { + $result = '-'.$result; + } + + return self::toNative($result); + } + + public static function divide(int|float|string $a, int|float|string $b): int|string + { + $a = self::normalizeInteger($a); + $b = self::normalizeInteger($b); + if ($b === '0') { + throw new InvalidArgumentException('Division by zero is not allowed.'); + } + + $negative = \str_starts_with($a, '-') !== \str_starts_with($b, '-'); + [$quotient] = self::divideUnsignedStrings(\ltrim($a, '-'), \ltrim($b, '-')); + if ($negative && $quotient !== '0') { + $quotient = '-'.$quotient; + } + + return self::toNative($quotient); + } + + public static function modulo(int|float|string $a, int|float|string $b): int|string + { + $a = self::normalizeInteger($a); + $b = self::normalizeInteger($b); + if ($b === '0') { + throw new InvalidArgumentException('Modulo by zero is not allowed.'); + } + + [, $remainder] = self::divideUnsignedStrings(\ltrim($a, '-'), \ltrim($b, '-')); + if (\str_starts_with($a, '-') && $remainder !== '0') { + $remainder = '-'.$remainder; + } + + return self::toNative($remainder); + } + + public static function power(int|float|string $base, int|float|string $exponent): int|string + { + $base = self::normalizeInteger($base); + $exponent = self::normalizeInteger($exponent); + if (\str_starts_with($exponent, '-')) { + throw new InvalidArgumentException('Integer power exponent must not be negative.'); + } + if ($exponent === '0') { + return 1; + } + if ($base === '0' || $base === '1') { + return (int) $base; + } + if ($base === '-1') { + return ((int) \substr($exponent, -1)) % 2 === 0 ? 1 : -1; + } + if (! self::fitsPhpInt($exponent, false) || (int) $exponent > 64) { + return \str_starts_with($base, '-') && ((int) \substr($exponent, -1)) % 2 !== 0 + ? '-'.self::UNSIGNED_MAX.'0' + : self::UNSIGNED_MAX.'0'; + } + + $result = 1; + $factor = $base; + $remaining = (int) $exponent; + while ($remaining > 0) { + if ($remaining % 2 === 1) { + $result = self::multiply($result, $factor); + } + $remaining = \intdiv($remaining, 2); + if ($remaining > 0) { + $factor = self::multiply($factor, $factor); + } + } + + return $result; + } + + public static function calculate(OperatorType $method, int|float|string $current, int|float|string $operand): int|string + { + return match ($method) { + OperatorType::Increment => self::add($current, $operand), + OperatorType::Decrement => self::subtract($current, $operand), + OperatorType::Multiply => self::multiply($current, $operand), + OperatorType::Divide => self::divide($current, $operand), + OperatorType::Modulo => self::modulo($current, $operand), + OperatorType::Power => self::power($current, $operand), + default => throw new InvalidArgumentException('Operator must be numeric.'), + }; + } + + public static function calculateOutsideNative(OperatorType $method, mixed $current, mixed $operand): int|string|null + { + if (! self::isIntegerValue($current) || ! self::isIntegerValue($operand)) { + return null; + } + + try { + $result = self::calculate($method, $current, $operand); + } catch (InvalidArgumentException) { + return null; + } + + $currentOutside = \is_string($current) && ! self::fitsPhpInt($current); + $operandOutside = \is_string($operand) && ! self::fitsPhpInt($operand); + + return $currentOutside || $operandOutside || \is_string($result) ? $result : null; + } + + /** + * The exact integer a whole number stands for: an integer, an integer string, a string with only + * zero decimals, or a finite float without a fractional part (beyond PHP's int range as an integer + * string). Null for anything fractional or not finite. + */ + public static function integralValue(int|float|string $value): int|string|null + { + if (self::isIntegerValue($value)) { + return self::toNative($value); + } + + if (\is_string($value)) { + return \preg_match('/^(-?\d+)\.0+$/', $value, $matches) === 1 ? self::toNative($matches[1]) : null; + } + + if (! \is_float($value) || ! \is_finite($value) || \floor($value) !== $value) { + return null; + } + + if ($value >= (float) \PHP_INT_MIN && $value < -(float) \PHP_INT_MIN) { + return (int) $value; + } + + return self::toNative(\sprintf('%.0F', $value)); + } + + /** + * @phpstan-assert-if-true =int|string $value + */ + public static function isIntegerValue(mixed $value): bool + { + return \is_int($value) || (\is_string($value) && self::isIntegerString($value)); + } + + private static function addUnsignedStrings(string $a, string $b): string + { + $aIndex = \strlen($a) - 1; + $bIndex = \strlen($b) - 1; + $carry = 0; + $result = ''; + + while ($aIndex >= 0 || $bIndex >= 0 || $carry > 0) { + $sum = ($aIndex >= 0 ? (int) $a[$aIndex--] : 0) + + ($bIndex >= 0 ? (int) $b[$bIndex--] : 0) + + $carry; + $result = (string) ($sum % 10).$result; + $carry = \intdiv($sum, 10); + } + + return self::normalizeUnsignedString($result); + } + + private static function subtractUnsignedStrings(string $a, string $b): string + { + $aIndex = \strlen($a) - 1; + $bIndex = \strlen($b) - 1; + $borrow = 0; + $result = ''; + + while ($aIndex >= 0) { + $digit = (int) $a[$aIndex--] - $borrow - ($bIndex >= 0 ? (int) $b[$bIndex--] : 0); + if ($digit < 0) { + $digit += 10; + $borrow = 1; + } else { + $borrow = 0; + } + $result = (string) $digit.$result; + } + + return self::normalizeUnsignedString($result); + } + + private static function multiplyUnsignedStrings(string $a, string $b): string + { + $a = self::normalizeUnsignedString($a); + $b = self::normalizeUnsignedString($b); + if ($a === '0' || $b === '0') { + return '0'; + } + + $digits = \array_fill(0, \strlen($a) + \strlen($b), 0); + for ($aIndex = \strlen($a) - 1; $aIndex >= 0; $aIndex--) { + for ($bIndex = \strlen($b) - 1; $bIndex >= 0; $bIndex--) { + $position = $aIndex + $bIndex + 1; + $product = (int) $a[$aIndex] * (int) $b[$bIndex] + $digits[$position]; + $digits[$position] = $product % 10; + $digits[$position - 1] += \intdiv($product, 10); + } + } + + return self::normalizeUnsignedString(\implode('', $digits)); + } + + /** + * @return array{string, string} + */ + private static function divideUnsignedStrings(string $dividend, string $divisor): array + { + $dividend = self::normalizeUnsignedString($dividend); + $divisor = self::normalizeUnsignedString($divisor); + if ($divisor === '0') { + throw new InvalidArgumentException('Division by zero is not allowed.'); + } + + $quotient = ''; + $remainder = '0'; + for ($index = 0, $length = \strlen($dividend); $index < $length; $index++) { + $remainder = self::normalizeUnsignedString($remainder.$dividend[$index]); + $digit = 0; + while (self::compareUnsignedStrings($remainder, $divisor) >= 0) { + $remainder = self::subtractUnsignedStrings($remainder, $divisor); + $digit++; + } + $quotient .= (string) $digit; + } + + return [self::normalizeUnsignedString($quotient), $remainder]; + } + public static function formatIntegerString(string $value): string { $negative = \str_starts_with($value, '-'); diff --git a/src/Database/Validator/ByteLength.php b/src/Database/Validator/ByteLength.php index 337dba74c4..29bbb5b38d 100644 --- a/src/Database/Validator/ByteLength.php +++ b/src/Database/Validator/ByteLength.php @@ -23,21 +23,25 @@ public function __construct(int $max) $this->max = $max; } + #[\Override] public function getDescription(): string { return 'Value must be a valid string no longer than ' . $this->max . ' bytes'; } + #[\Override] public function isArray(): bool { return false; } + #[\Override] public function getType(): string { return self::TYPE_STRING; } + #[\Override] public function isValid(mixed $value): bool { if (!\is_string($value)) { diff --git a/src/Database/Validator/Datetime.php b/src/Database/Validator/Datetime.php index 7950b1e078..4e765a93b6 100644 --- a/src/Database/Validator/Datetime.php +++ b/src/Database/Validator/Datetime.php @@ -2,71 +2,73 @@ namespace Utopia\Database\Validator; +use DateTime as PhpDateTime; +use Exception; use Utopia\Validator; class Datetime extends Validator { - public const PRECISION_DAYS = 'days'; - public const PRECISION_HOURS = 'hours'; - public const PRECISION_MINUTES = 'minutes'; - public const PRECISION_SECONDS = 'seconds'; - public const PRECISION_ANY = 'any'; + public const string PRECISION_DAYS = 'days'; + + public const string PRECISION_HOURS = 'hours'; + + public const string PRECISION_MINUTES = 'minutes'; + + public const string PRECISION_SECONDS = 'seconds'; + + public const string PRECISION_ANY = 'any'; /** - * @throws \Exception + * @throws Exception */ public function __construct( - private readonly \DateTime $min = new \DateTime('0000-01-01'), - private readonly \DateTime $max = new \DateTime('9999-12-31'), + private readonly PhpDateTime $min = new PhpDateTime('0000-01-01'), + private readonly PhpDateTime $max = new PhpDateTime('9999-12-31'), private readonly bool $requireDateInFuture = false, private readonly string $precision = self::PRECISION_ANY, private readonly int $offset = 0, ) { if ($offset < 0) { - throw new \Exception('Offset must be a positive integer.'); + throw new Exception('Offset must be a positive integer.'); } } - /** - * Validator Description. - * @return string - */ + #[\Override] public function getDescription(): string { $message = 'Value must be valid date'; if ($this->offset > 0) { - $message .= " at least " . $this->offset . " seconds in the future and"; + $message .= ' at least '.$this->offset.' seconds in the future and'; } elseif ($this->requireDateInFuture) { - $message .= " in the future and"; + $message .= ' in the future and'; } if ($this->precision !== self::PRECISION_ANY) { - $message .= " with " . $this->precision . " precision"; + $message .= ' with '.$this->precision.' precision'; } $min = $this->min->format('Y-m-d H:i:s'); $max = $this->max->format('Y-m-d H:i:s'); $message .= " between {$min} and {$max}."; + return $message; } /** - * Is valid. - * Returns true if valid or false if not. - * @param mixed $value - * @return bool + * @param mixed $value */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { if (empty($value) || ! is_string($value)) { return false; } try { - $date = new \DateTime($value); - $now = new \DateTime(); + $date = new PhpDateTime($value); + $now = new PhpDateTime(); if ($this->requireDateInFuture === true && $date < $now) { return false; @@ -80,38 +82,29 @@ public function isValid($value): bool } // Constants from: https://www.php.net/manual/en/datetime.format.php - $denyConstants = []; - - switch ($this->precision) { - case self::PRECISION_DAYS: - $denyConstants = [ 'H', 'i', 's', 'v' ]; - break; - case self::PRECISION_HOURS: - $denyConstants = [ 'i', 's', 'v' ]; - break; - case self::PRECISION_MINUTES: - $denyConstants = [ 's', 'v' ]; - break; - case self::PRECISION_SECONDS: - $denyConstants = [ 'v' ]; - break; - } + $denyConstants = match ($this->precision) { + self::PRECISION_DAYS => ['H', 'i', 's', 'v'], + self::PRECISION_HOURS => ['i', 's', 'v'], + self::PRECISION_MINUTES => ['s', 'v'], + self::PRECISION_SECONDS => ['v'], + default => [], + }; foreach ($denyConstants as $constant) { if (\intval($date->format($constant)) !== 0) { return false; } } - } catch (\Exception) { + } catch (Exception) { return false; } // Custom year validation to account for PHP allowing year overflow $matches = []; if (preg_match('/(?min->format('Y'); - $maxYear = (int)$this->max->format('Y'); + $year = (int) $matches[1]; + $minYear = (int) $this->min->format('Y'); + $maxYear = (int) $this->max->format('Y'); if ($year < $minYear || $year > $maxYear) { return false; } @@ -126,25 +119,13 @@ public function isValid($value): bool return true; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_STRING; diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php deleted file mode 100644 index 8b07db2ce1..0000000000 --- a/src/Database/Validator/Index.php +++ /dev/null @@ -1,863 +0,0 @@ - $attributes - */ - protected array $attributes; - - /** - * @param array $attributes - * @param array $indexes - * @param int $maxLength - * @param array $reservedKeys - * @param bool $supportForArrayIndexes - * @param bool $supportForSpatialIndexNull - * @param bool $supportForSpatialIndexOrder - * @param bool $supportForVectorIndexes - * @param bool $supportForAttributes - * @param bool $supportForMultipleFulltextIndexes - * @param bool $supportForIdenticalIndexes - * @param bool $supportForObjectIndexes - * @param bool $supportForTrigramIndexes - * @param bool $supportForSpatialIndexes - * @param bool $supportForKeyIndexes - * @param bool $supportForUniqueIndexes - * @param bool $supportForFulltextIndexes - * @param bool $supportForObjects - * @throws DatabaseException - */ - public function __construct( - array $attributes, - protected array $indexes, - protected int $maxLength, - protected array $reservedKeys = [], - protected bool $supportForArrayIndexes = false, - protected bool $supportForSpatialIndexNull = false, - protected bool $supportForSpatialIndexOrder = false, - protected bool $supportForVectorIndexes = false, - protected bool $supportForAttributes = true, - protected bool $supportForMultipleFulltextIndexes = true, - protected bool $supportForIdenticalIndexes = true, - protected bool $supportForObjectIndexes = false, - protected bool $supportForTrigramIndexes = false, - protected bool $supportForSpatialIndexes = false, - protected bool $supportForKeyIndexes = true, - protected bool $supportForUniqueIndexes = true, - protected bool $supportForFulltextIndexes = true, - protected bool $supportForTTLIndexes = false, - protected bool $supportForObjects = false - ) { - foreach ($attributes as $attribute) { - $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); - $this->attributes[$key] = $attribute; - } - foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { - $key = \strtolower($attribute['$id']); - $this->attributes[$key] = new Document($attribute); - } - } - - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ - public function getType(): string - { - return self::TYPE_OBJECT; - } - - /** - * Returns validator description - * @return string - */ - public function getDescription(): string - { - return $this->message; - } - - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ - public function isArray(): bool - { - return false; - } - - /** - * Is valid. - * - * Returns true index if valid. - * @param Document $value - * @return bool - * @throws DatabaseException - */ - public function isValid($value): bool - { - if (!$this->checkValidIndex($value)) { - return false; - } - if (!$this->checkValidAttributes($value)) { - return false; - } - if (!$this->checkEmptyIndexAttributes($value)) { - return false; - } - if (!$this->checkDuplicatedAttributes($value)) { - return false; - } - if (!$this->checkMultipleFulltextIndexes($value)) { - return false; - } - if (!$this->checkFulltextIndexNonString($value)) { - return false; - } - if (!$this->checkArrayIndexes($value)) { - return false; - } - if (!$this->checkIndexLengths($value)) { - return false; - } - if (!$this->checkReservedNames($value)) { - return false; - } - if (!$this->checkSpatialIndexes($value)) { - return false; - } - if (!$this->checkNonSpatialIndexOnSpatialAttributes($value)) { - return false; - } - if (!$this->checkVectorIndexes($value)) { - return false; - } - if (!$this->checkIdenticalIndexes($value)) { - return false; - } - if (!$this->checkObjectIndexes($value)) { - return false; - } - if (!$this->checkTrigramIndexes($value)) { - return false; - } - if (!$this->checkKeyUniqueFulltextSupport($value)) { - return false; - } - if (!$this->checkTTLIndexes($value)) { - return false; - } - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkValidIndex(Document $index): bool - { - $type = $index->getAttribute('type'); - if ($this->supportForObjects) { - // getting dotted attributes not present in schema - $dottedAttributes = array_filter($index->getAttribute('attributes'), fn ($attr) => !isset($this->attributes[\strtolower($attr)]) && $this->isDottedAttribute($attr)); - if (\count($dottedAttributes)) { - foreach ($dottedAttributes as $attribute) { - $baseAttribute = $this->getBaseAttributeFromDottedAttribute($attribute); - if (isset($this->attributes[\strtolower($baseAttribute)]) && $this->attributes[\strtolower($baseAttribute)]->getAttribute('type') != Database::VAR_OBJECT) { - $this->message = 'Index attribute "' . $attribute . '" is only supported on object attributes'; - return false; - }; - } - } - } - - switch ($type) { - case Database::INDEX_KEY: - if (!$this->supportForKeyIndexes) { - $this->message = 'Key index is not supported'; - return false; - } - break; - - case Database::INDEX_UNIQUE: - if (!$this->supportForUniqueIndexes) { - $this->message = 'Unique index is not supported'; - return false; - } - break; - - case Database::INDEX_FULLTEXT: - if (!$this->supportForFulltextIndexes) { - $this->message = 'Fulltext index is not supported'; - return false; - } - break; - - case Database::INDEX_SPATIAL: - if (!$this->supportForSpatialIndexes) { - $this->message = 'Spatial indexes are not supported'; - return false; - } - if (!empty($index->getAttribute('orders')) && !$this->supportForSpatialIndexOrder) { - $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; - return false; - } - break; - - case Database::INDEX_HNSW_EUCLIDEAN: - case Database::INDEX_HNSW_COSINE: - case Database::INDEX_HNSW_DOT: - if (!$this->supportForVectorIndexes) { - $this->message = 'Vector indexes are not supported'; - return false; - } - break; - - case Database::INDEX_OBJECT: - if (!$this->supportForObjectIndexes) { - $this->message = 'Object indexes are not supported'; - return false; - } - break; - - case Database::INDEX_TRIGRAM: - if (!$this->supportForTrigramIndexes) { - $this->message = 'Trigram indexes are not supported'; - return false; - } - break; - - case Database::INDEX_TTL: - if (!$this->supportForTTLIndexes) { - $this->message = 'TTL indexes are not supported'; - return false; - } - break; - - default: - $this->message = 'Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT . ', '.Database::INDEX_TRIGRAM . ', '.Database::INDEX_TTL; - return false; - } - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkValidAttributes(Document $index): bool - { - if (!$this->supportForAttributes) { - return true; - } - foreach ($index->getAttribute('attributes', []) as $attribute) { - // attribute is part of the attributes - // or object indexes supported and its a dotted attribute with base present in the attributes - if (!isset($this->attributes[\strtolower($attribute)])) { - if ($this->supportForObjects) { - $baseAttribute = $this->getBaseAttributeFromDottedAttribute($attribute); - if (isset($this->attributes[\strtolower($baseAttribute)])) { - continue; - } - } - $this->message = 'Invalid index attribute "' . $attribute . '" not found'; - return false; - } - } - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkEmptyIndexAttributes(Document $index): bool - { - if (empty($index->getAttribute('attributes', []))) { - $this->message = 'No attributes provided for index'; - return false; - } - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkDuplicatedAttributes(Document $index): bool - { - $attributes = $index->getAttribute('attributes', []); - $stack = []; - foreach ($attributes as $attribute) { - $value = \strtolower($attribute); - - if (\in_array($value, $stack)) { - $this->message = 'Duplicate attributes provided'; - return false; - } - - $stack[] = $value; - } - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkFulltextIndexNonString(Document $index): bool - { - if (!$this->supportForAttributes) { - return true; - } - if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) { - foreach ($index->getAttribute('attributes', []) as $attribute) { - $attribute = $this->attributes[\strtolower($attribute)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); - $validFulltextTypes = [ - Database::VAR_STRING, - Database::VAR_VARCHAR, - Database::VAR_TEXT, - Database::VAR_MEDIUMTEXT, - Database::VAR_LONGTEXT - ]; - if (!in_array($attributeType, $validFulltextTypes)) { - $this->message = 'Attribute "' . $attribute->getAttribute('key', $attribute->getAttribute('$id')) . '" cannot be part of a fulltext index, must be of type string'; - return false; - } - } - } - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkArrayIndexes(Document $index): bool - { - if (!$this->supportForAttributes) { - return true; - } - $attributes = $index->getAttribute('attributes', []); - $orders = $index->getAttribute('orders', []); - $lengths = $index->getAttribute('lengths', []); - - $arrayAttributes = []; - foreach ($attributes as $attributePosition => $attributeName) { - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - - if ($attribute->getAttribute('array', false)) { - // Database::INDEX_UNIQUE Is not allowed! since mariaDB VS MySQL makes the unique Different on values - if ($index->getAttribute('type') != Database::INDEX_KEY) { - $this->message = '"' . ucfirst($index->getAttribute('type')) . '" index is forbidden on array attributes'; - return false; - } - - if (empty($lengths[$attributePosition])) { - $this->message = 'Index length for array not specified'; - return false; - } - - $arrayAttributes[] = $attribute->getAttribute('key', ''); - if (count($arrayAttributes) > 1) { - $this->message = 'An index may only contain one array attribute'; - return false; - } - - $direction = $orders[$attributePosition] ?? ''; - if (!empty($direction)) { - $this->message = 'Invalid index order "' . $direction . '" on array attribute "' . $attribute->getAttribute('key', '') . '"'; - return false; - } - - if ($this->supportForArrayIndexes === false) { - $this->message = 'Indexing an array attribute is not supported'; - return false; - } - } elseif (!in_array($attribute->getAttribute('type'), [ - Database::VAR_STRING, - Database::VAR_VARCHAR, - Database::VAR_TEXT, - Database::VAR_MEDIUMTEXT, - Database::VAR_LONGTEXT - ]) && !empty($lengths[$attributePosition])) { - $this->message = 'Cannot set a length on "' . $attribute->getAttribute('type') . '" attributes'; - return false; - } - } - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkIndexLengths(Document $index): bool - { - if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) { - return true; - } - - if (!$this->supportForAttributes) { - return true; - } - - $total = 0; - $lengths = $index->getAttribute('lengths', []); - $attributes = $index->getAttribute('attributes', []); - if (count($lengths) > count($attributes)) { - $this->message = 'Invalid index lengths. Count of lengths must be equal or less than the number of attributes.'; - return false; - } - foreach ($attributes as $attributePosition => $attributeName) { - if ($this->supportForObjects && !isset($this->attributes[\strtolower($attributeName)])) { - $attributeName = $this->getBaseAttributeFromDottedAttribute($attributeName); - } - $attribute = $this->attributes[\strtolower($attributeName)]; - - switch ($attribute->getAttribute('type')) { - case Database::VAR_STRING: - case Database::VAR_VARCHAR: - case Database::VAR_TEXT: - case Database::VAR_MEDIUMTEXT: - case Database::VAR_LONGTEXT: - $attributeSize = $attribute->getAttribute('size', 0); - $indexLength = !empty($lengths[$attributePosition]) ? $lengths[$attributePosition] : $attributeSize; - break; - case Database::VAR_FLOAT: - $attributeSize = 2; // 8 bytes / 4 mb4 - $indexLength = 2; - break; - default: - $attributeSize = 1; // 4 bytes / 4 mb4 - $indexLength = 1; - break; - } - if ($indexLength < 0) { - $this->message = 'Negative index length provided for ' . $attributeName; - return false; - } - - if ($attribute->getAttribute('array', false)) { - $attributeSize = Database::MAX_ARRAY_INDEX_LENGTH; - $indexLength = Database::MAX_ARRAY_INDEX_LENGTH; - } - - if ($indexLength > $attributeSize) { - $this->message = 'Index length ' . $indexLength . ' is larger than the size for ' . $attributeName . ': ' . $attributeSize . '"'; - return false; - } - - $total += $indexLength; - } - - if ($total > $this->maxLength && $this->maxLength > 0) { - $this->message = 'Index length is longer than the maximum: ' . $this->maxLength; - return false; - } - - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkReservedNames(Document $index): bool - { - $key = $index->getAttribute('key', $index->getAttribute('$id')); - - foreach ($this->reservedKeys as $reserved) { - if (\strtolower($key) === \strtolower($reserved)) { - $this->message = 'Index key name is reserved'; - return false; - } - } - - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkSpatialIndexes(Document $index): bool - { - $type = $index->getAttribute('type'); - - if ($type !== Database::INDEX_SPATIAL) { - return true; - } - - if ($this->supportForSpatialIndexes === false) { - $this->message = 'Spatial indexes are not supported'; - return false; - } - - $attributes = $index->getAttribute('attributes', []); - $orders = $index->getAttribute('orders', []); - - if (\count($attributes) !== 1) { - $this->message = 'Spatial index must have exactly one attribute'; - return false; - } - - foreach ($attributes as $attributeName) { - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); - - if (!\in_array($attributeType, Database::SPATIAL_TYPES, true)) { - $this->message = 'Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; - return false; - } - - $required = (bool)$attribute->getAttribute('required', false); - if (!$required && !$this->supportForSpatialIndexNull) { - $this->message = 'Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'; - return false; - } - } - - if (!empty($orders) && !$this->supportForSpatialIndexOrder) { - $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; - return false; - } - - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkNonSpatialIndexOnSpatialAttributes(Document $index): bool - { - $type = $index->getAttribute('type'); - - // Skip check for spatial indexes - if ($type === Database::INDEX_SPATIAL) { - return true; - } - - $attributes = $index->getAttribute('attributes', []); - - foreach ($attributes as $attributeName) { - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); - - if (\in_array($attributeType, Database::SPATIAL_TYPES, true)) { - $this->message = 'Cannot create ' . $type . ' index on spatial attribute "' . $attributeName . '". Spatial attributes require spatial indexes.'; - return false; - } - } - - return true; - } - - /** - * @param Document $index - * @return bool - * @throws DatabaseException - */ - public function checkVectorIndexes(Document $index): bool - { - $type = $index->getAttribute('type'); - - if ( - $type !== Database::INDEX_HNSW_DOT && - $type !== Database::INDEX_HNSW_COSINE && - $type !== Database::INDEX_HNSW_EUCLIDEAN - ) { - return true; - } - - if ($this->supportForVectorIndexes === false) { - $this->message = 'Vector indexes are not supported'; - return false; - } - - $attributes = $index->getAttribute('attributes', []); - - if (\count($attributes) !== 1) { - $this->message = 'Vector index must have exactly one attribute'; - return false; - } - - $attribute = $this->attributes[\strtolower($attributes[0])] ?? new Document(); - if ($attribute->getAttribute('type') !== Database::VAR_VECTOR) { - $this->message = 'Vector index can only be created on vector attributes'; - return false; - } - - $orders = $index->getAttribute('orders', []); - $lengths = $index->getAttribute('lengths', []); - if (!empty($orders) || \count(\array_filter($lengths)) > 0) { - $this->message = 'Vector indexes do not support orders or lengths'; - return false; - } - - return true; - } - - /** - * @param Document $index - * @return bool - * @throws DatabaseException - */ - public function checkTrigramIndexes(Document $index): bool - { - $type = $index->getAttribute('type'); - - if ($type !== Database::INDEX_TRIGRAM) { - return true; - } - - if ($this->supportForTrigramIndexes === false) { - $this->message = 'Trigram indexes are not supported'; - return false; - } - - $attributes = $index->getAttribute('attributes', []); - - $validStringTypes = [ - Database::VAR_STRING, - Database::VAR_VARCHAR, - Database::VAR_TEXT, - Database::VAR_MEDIUMTEXT, - Database::VAR_LONGTEXT - ]; - - foreach ($attributes as $attributeName) { - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - if (!in_array($attribute->getAttribute('type', ''), $validStringTypes)) { - $this->message = 'Trigram index can only be created on string type attributes'; - return false; - } - } - - $orders = $index->getAttribute('orders', []); - $lengths = $index->getAttribute('lengths', []); - if (!empty($orders) || \count(\array_filter($lengths)) > 0) { - $this->message = 'Trigram indexes do not support orders or lengths'; - return false; - } - - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkKeyUniqueFulltextSupport(Document $index): bool - { - $type = $index->getAttribute('type'); - - if ($type === Database::INDEX_KEY && $this->supportForKeyIndexes === false) { - $this->message = 'Key index is not supported'; - return false; - } - - if ($type === Database::INDEX_UNIQUE && $this->supportForUniqueIndexes === false) { - $this->message = 'Unique index is not supported'; - return false; - } - - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkMultipleFulltextIndexes(Document $index): bool - { - if ($this->supportForMultipleFulltextIndexes) { - return true; - } - - if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) { - foreach ($this->indexes as $existingIndex) { - if ($existingIndex->getId() === $index->getId()) { - continue; - } - if ($existingIndex->getAttribute('type') === Database::INDEX_FULLTEXT) { - $this->message = 'There is already a fulltext index in the collection'; - return false; - } - } - } - - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkIdenticalIndexes(Document $index): bool - { - if ($this->supportForIdenticalIndexes) { - return true; - } - - $indexAttributes = $index->getAttribute('attributes', []); - $indexOrders = $index->getAttribute('orders', []); - $indexType = $index->getAttribute('type', ''); - - foreach ($this->indexes as $existingIndex) { - $existingAttributes = $existingIndex->getAttribute('attributes', []); - $existingOrders = $existingIndex->getAttribute('orders', []); - $existingType = $existingIndex->getAttribute('type', ''); - - $attributesMatch = false; - if (empty(\array_diff($existingAttributes, $indexAttributes)) && - empty(\array_diff($indexAttributes, $existingAttributes))) { - $attributesMatch = true; - } - - $ordersMatch = false; - if (empty(\array_diff($existingOrders, $indexOrders)) && - empty(\array_diff($indexOrders, $existingOrders))) { - $ordersMatch = true; - } - - if ($attributesMatch && $ordersMatch) { - // Allow fulltext + key/unique combinations (different purposes) - $regularTypes = [Database::INDEX_KEY, Database::INDEX_UNIQUE]; - $isRegularIndex = \in_array($indexType, $regularTypes); - $isRegularExisting = \in_array($existingType, $regularTypes); - - // Only reject if both are regular index types (key or unique) - if ($isRegularIndex && $isRegularExisting) { - $this->message = 'There is already an index with the same attributes and orders'; - return false; - } - } - } - - return true; - } - - /** - * @param Document $index - * @return bool - */ - public function checkObjectIndexes(Document $index): bool - { - $type = $index->getAttribute('type'); - - $attributes = $index->getAttribute('attributes', []); - $orders = $index->getAttribute('orders', []); - - if ($type !== Database::INDEX_OBJECT) { - return true; - } - - if (!$this->supportForObjectIndexes) { - $this->message = 'Object indexes are not supported'; - return false; - } - - if (count($attributes) !== 1) { - $this->message = 'Object index can be created on a single object attribute'; - return false; - } - - if (!empty($orders)) { - $this->message = 'Object index do not support explicit orders. Remove the orders to create this index.'; - return false; - } - - $attributeName = $attributes[0] ?? ''; - - // Object indexes are only allowed on the top-level object attribute, - // not on nested paths like "data.key.nestedKey". - if (\strpos($attributeName, '.') !== false) { - $this->message = 'Object index can only be created on a top-level object attribute'; - return false; - } - - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); - - if ($attributeType !== Database::VAR_OBJECT) { - $this->message = 'Object index can only be created on object attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; - return false; - } - - return true; - } - - public function checkTTLIndexes(Document $index): bool - { - $type = $index->getAttribute('type'); - - $attributes = $index->getAttribute('attributes', []); - $orders = $index->getAttribute('orders', []); - $ttl = $index->getAttribute('ttl', 0); - if ($type !== Database::INDEX_TTL) { - return true; - } - - if (count($attributes) !== 1) { - $this->message = 'TTL indexes must be created on a single datetime attribute.'; - return false; - } - - $attributeName = $attributes[0] ?? ''; - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); - - if ($this->supportForAttributes && $attributeType !== Database::VAR_DATETIME) { - $this->message = 'TTL index can only be created on datetime attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; - return false; - } - - if ($ttl < 1) { - $this->message = 'TTL must be at least 1 second'; - return false; - } - - // Check if there's already a TTL index in this collection - foreach ($this->indexes as $existingIndex) { - if ($existingIndex->getId() === $index->getId()) { - continue; - } - - // Check if existing index is also a TTL index - if ($existingIndex->getAttribute('type') === Database::INDEX_TTL) { - $this->message = 'There can be only one TTL index in a collection'; - return false; - } - } - - return true; - } - - private function isDottedAttribute(string $attribute): bool - { - return \str_contains($attribute, '.'); - } - - private function getBaseAttributeFromDottedAttribute(string $attribute): string - { - return $this->isDottedAttribute($attribute) ? \explode('.', $attribute, 2)[0] ?? '' : $attribute; - } -} diff --git a/src/Database/Validator/IndexDefinition.php b/src/Database/Validator/IndexDefinition.php new file mode 100644 index 0000000000..48264c711e --- /dev/null +++ b/src/Database/Validator/IndexDefinition.php @@ -0,0 +1,962 @@ + + */ + protected array $attributes; + + /** + * @var array + */ + protected array $indexes; + + /** + * @param array $attributes + * @param array $indexes + * + * @throws DatabaseException + */ + public function __construct( + array $attributes, + array $indexes, + protected readonly Profile $profile, + ) { + $this->attributes = []; + foreach ($attributes as $attribute) { + $typed = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); + $this->attributes[\strtolower($typed->key)] = $typed; + } + foreach (Database::internalAttributesFor(true) as $attribute) { + $key = \strtolower($attribute->key); + $this->attributes[$key] = $attribute; + } + + $this->indexes = []; + foreach ($indexes as $index) { + $this->indexes[] = $index instanceof Index ? $index : Index::fromDocument($index); + } + } + + #[\Override] + public function getType(): string + { + return self::TYPE_OBJECT; + } + + #[\Override] + public function getDescription(): string + { + return $this->message; + } + + #[\Override] + public function isArray(): bool + { + return false; + } + + /** + * An index document is checked for a known type and a TTL before it is hydrated, since + * Index::fromDocument() reads an unknown stored type as a key index. + * + * @param mixed $value + * + * @throws DatabaseException + */ + #[\Override] + public function isValid(mixed $value): bool + { + if ($value instanceof Document) { + if (! $this->checkStoredDefinition($value)) { + return false; + } + $value = Index::fromDocument($value); + } + + if (! $value instanceof Index) { + $this->message = 'Value must be an index'; + + return false; + } + + $index = $value; + + if (! $this->checkValidIndex($index)) { + return false; + } + if (! $this->checkValidAttributes($index)) { + return false; + } + if (! $this->checkEmptyIndexAttributes($index)) { + return false; + } + if (! $this->checkDuplicatedAttributes($index)) { + return false; + } + if (! $this->checkMultipleFulltextIndexes($index)) { + return false; + } + if (! $this->checkFulltextIndexNonString($index)) { + return false; + } + if (! $this->checkArrayIndexes($index)) { + return false; + } + if (! $this->checkIndexLengths($index)) { + return false; + } + if (! $this->checkReservedNames($index)) { + return false; + } + if (! $this->checkSpatialIndexes($index)) { + return false; + } + if (! $this->checkNonSpatialIndexOnSpatialAttributes($index)) { + return false; + } + if (! $this->checkVectorIndexes($index)) { + return false; + } + if (! $this->checkIdenticalIndexes($index)) { + return false; + } + if (! $this->checkObjectIndexes($index)) { + return false; + } + if (! $this->checkTrigramIndexes($index)) { + return false; + } + if (! $this->checkKeyUniqueFulltextSupport($index)) { + return false; + } + if (! $this->checkTtlIndexes($index)) { + return false; + } + + return true; + } + + /** + * Check that the index type is supported by the current adapter. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkValidIndex(Index $index): bool + { + $type = $index->type; + if ($this->profile->supports(Capability::Objects)) { + $dottedAttributes = array_filter($index->attributes, fn (string $name) => ! isset($this->attributes[\strtolower($name)]) && $this->isDottedAttribute($name)); + if (\count($dottedAttributes)) { + foreach ($dottedAttributes as $attribute) { + $baseAttribute = $this->getBaseAttributeFromDottedAttribute($attribute); + if (isset($this->attributes[\strtolower($baseAttribute)])) { + $baseType = $this->attributes[\strtolower($baseAttribute)]->type; + if ($baseType !== ColumnType::Object) { + $this->message = 'Index attribute "'.$attribute.'" is only supported on object attributes'; + + return false; + } + } + } + } + } + + switch ($type) { + case IndexType::Key: + if (! $this->profile->supports(Capability::IndexKey)) { + $this->message = 'Key index is not supported'; + + return false; + } + break; + + case IndexType::Unique: + if (! $this->profile->supports(Capability::IndexUnique)) { + $this->message = 'Unique index is not supported'; + + return false; + } + break; + + case IndexType::Fulltext: + if (! $this->profile->supports(Capability::IndexFulltext)) { + $this->message = 'Fulltext index is not supported'; + + return false; + } + break; + + case IndexType::Spatial: + if (! $this->profile->hasFeature(Feature\Spatial::class)) { + $this->message = 'Spatial indexes are not supported'; + + return false; + } + if (! empty($index->orders) && ! $this->profile->supports(Capability::IndexSpatialOrder)) { + $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; + + return false; + } + break; + + case IndexType::HnswEuclidean: + case IndexType::HnswCosine: + case IndexType::HnswDot: + if (! $this->profile->supports(Capability::Vectors)) { + $this->message = 'Vector indexes are not supported'; + + return false; + } + break; + + case IndexType::Object: + if (! $this->profile->supports(Capability::IndexObject)) { + $this->message = 'Object indexes are not supported'; + + return false; + } + break; + + case IndexType::Trigram: + if (! $this->profile->supports(Capability::IndexTrigram)) { + $this->message = 'Trigram indexes are not supported'; + + return false; + } + break; + + case IndexType::Ttl: + if (! $this->profile->supports(Capability::IndexTtl)) { + $this->message = 'TTL indexes are not supported'; + + return false; + } + break; + + default: + $this->message = self::unknownTypeMessage($type->value); + + return false; + } + + return true; + } + + private function checkStoredDefinition(Document $index): bool + { + $type = $index->getAttribute('type'); + if ($type instanceof IndexType) { + $type = $type->value; + } + + if (! \is_string($type) || IndexType::tryFrom($type) === null) { + $this->message = self::unknownTypeMessage(\is_string($type) ? $type : ''); + + return false; + } + + if ($type === IndexType::Ttl->value && $index->getAttribute('ttl') === null) { + $this->message = 'TTL must be at least 1 second'; + + return false; + } + + return true; + } + + private static function unknownTypeMessage(string $type): string + { + return 'Unknown index type: '.$type.'. Must be one of '.IndexType::Key->value.', '.IndexType::Unique->value.', '.IndexType::Fulltext->value.', '.IndexType::Spatial->value.', '.IndexType::Object->value.', '.IndexType::HnswEuclidean->value.', '.IndexType::HnswCosine->value.', '.IndexType::HnswDot->value.', '.IndexType::Trigram->value.', '.IndexType::Ttl->value; + } + + /** + * Check that all index attributes exist in the collection schema. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkValidAttributes(Index $index): bool + { + if (! $this->profile->supports(Capability::DefinedAttributes)) { + return true; + } + foreach ($index->attributes as $attribute) { + if (! isset($this->attributes[\strtolower($attribute)])) { + if ($this->profile->supports(Capability::Objects)) { + $baseAttribute = $this->getBaseAttributeFromDottedAttribute($attribute); + if (isset($this->attributes[\strtolower($baseAttribute)])) { + continue; + } + } + $this->message = 'Invalid index attribute "'.$attribute.'" not found'; + + return false; + } + } + + return true; + } + + /** + * Check that the index has at least one attribute. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkEmptyIndexAttributes(Index $index): bool + { + if (empty($index->attributes)) { + $this->message = 'No attributes provided for index'; + + return false; + } + + return true; + } + + /** + * Check that the index does not contain duplicate attributes. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkDuplicatedAttributes(Index $index): bool + { + $stack = []; + foreach ($index->attributes as $attribute) { + $value = \strtolower($attribute); + + if (\in_array($value, $stack)) { + $this->message = 'Duplicate attributes provided'; + + return false; + } + + $stack[] = $value; + } + + return true; + } + + /** + * Check that fulltext indexes only reference string-type attributes. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkFulltextIndexNonString(Index $index): bool + { + if (! $this->profile->supports(Capability::DefinedAttributes)) { + return true; + } + if ($index->type === IndexType::Fulltext) { + foreach ($index->attributes as $attributeName) { + $attribute = $this->findAttribute($attributeName); + if (! $this->isStringAttribute($attribute)) { + $key = $attribute === null ? $attributeName : $attribute->key; + $this->message = 'Attribute "'.$key.'" cannot be part of a fulltext index, must be of type string'; + + return false; + } + } + } + + return true; + } + + /** + * Check constraints for indexes on array attributes including type, length, and count limits. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkArrayIndexes(Index $index): bool + { + if (! $this->profile->supports(Capability::DefinedAttributes)) { + return true; + } + + $arrayAttributes = []; + $indexType = $index->type; + $lengths = $index->lengths; + $orders = $index->orders; + foreach ($index->attributes as $attributePosition => $attributeName) { + $attribute = $this->findAttribute($attributeName); + + if ($attribute !== null && $attribute->array) { + // MariaDB and MySQL disagree on what makes array values unique, so an array attribute takes only a key index + if ($indexType !== IndexType::Key) { + $this->message = '"'.ucfirst($indexType->value).'" index is forbidden on array attributes'; + + return false; + } + + if (empty($lengths[$attributePosition])) { + $this->message = 'Index length for array not specified'; + + return false; + } + + $arrayAttributes[] = $attribute->key; + if (count($arrayAttributes) > 1) { + $this->message = 'An index may only contain one array attribute'; + + return false; + } + + $direction = $orders[$attributePosition] ?? null; + if ($direction !== null) { + $this->message = 'Invalid index order "'.$direction->value.'" on array attribute "'.$attribute->key.'"'; + + return false; + } + + if (! $this->profile->supports(Capability::IndexArray)) { + $this->message = 'Indexing an array attribute is not supported'; + + return false; + } + } elseif (! $this->isStringAttribute($attribute) && ! empty($lengths[$attributePosition])) { + $type = $attribute === null ? '' : $attribute->type->value; + $this->message = 'Cannot set a length on "'.$type.'" attributes'; + + return false; + } + } + + return true; + } + + /** + * Check that index lengths are valid and do not exceed the maximum allowed total. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkIndexLengths(Index $index): bool + { + if ($index->type === IndexType::Fulltext) { + return true; + } + + if (! $this->profile->supports(Capability::DefinedAttributes)) { + return true; + } + + $total = 0; + $lengths = $index->lengths; + $indexedAttributes = $index->attributes; + if (count($lengths) > count($indexedAttributes)) { + $this->message = 'Invalid index lengths. Count of lengths must be equal or less than the number of attributes.'; + + return false; + } + foreach ($indexedAttributes as $attributePosition => $attributeName) { + if ($this->profile->supports(Capability::Objects) && ! isset($this->attributes[\strtolower($attributeName)])) { + $attributeName = $this->getBaseAttributeFromDottedAttribute($attributeName); + } + $attribute = $this->attributes[\strtolower($attributeName)]; + + $attributeType = $attribute->type; + $resolvedSize = $attribute->resolvedSize(); + [$attributeSize, $indexLength] = match ($attributeType) { + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText => [ + $resolvedSize, + ! empty($lengths[$attributePosition]) ? $lengths[$attributePosition] : $resolvedSize, + ], + ColumnType::Float, + ColumnType::Double, + ColumnType::BigInteger, + ColumnType::Id => [2, 2], + ColumnType::Integer => $resolvedSize >= self::BIG_INTEGER_SIZE ? [2, 2] : [1, 1], + default => [1, 1], + }; + if ($indexLength < 0) { + $this->message = 'Negative index length provided for '.$attributeName; + + return false; + } + + if ($attribute->array) { + $attributeSize = Database::MAX_ARRAY_INDEX_LENGTH; + $indexLength = Database::MAX_ARRAY_INDEX_LENGTH; + } + + if ($indexLength > $attributeSize) { + $this->message = 'Index length '.$indexLength.' is larger than the size for '.$attributeName.': '.$attributeSize.'"'; + + return false; + } + + $total += $indexLength; + } + + if ($total > $this->profile->limits->indexLength && $this->profile->limits->indexLength > 0) { + $this->message = 'Index length is longer than the maximum: '.$this->profile->limits->indexLength; + + return false; + } + + return true; + } + + /** + * Check that the index key name is not a reserved name. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkReservedNames(Index $index): bool + { + $key = \strtolower($index->key); + + foreach ($this->profile->limits->internalIndexKeys as $reserved) { + if ($key === \strtolower($reserved)) { + $this->message = 'Index key name is reserved'; + + return false; + } + } + + return true; + } + + /** + * Check spatial index constraints including attribute type and nullability. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkSpatialIndexes(Index $index): bool + { + $type = $index->type; + + if ($type !== IndexType::Spatial) { + return true; + } + + if (! $this->profile->hasFeature(Feature\Spatial::class)) { + $this->message = 'Spatial indexes are not supported'; + + return false; + } + + if (\count($index->attributes) !== 1) { + $this->message = 'Spatial index must have exactly one attribute'; + + return false; + } + + foreach ($index->attributes as $attributeName) { + $attribute = $this->findAttribute($attributeName); + $attributeType = $attribute->type ?? ColumnType::String; + + if (! \in_array($attributeType, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + $this->message = 'Spatial index can only be created on spatial attributes (point, linestring, polygon). Attribute "'.$attributeName.'" is of type "'.$attributeType->value.'"'; + + return false; + } + + if (! ($attribute->required ?? false) && ! $this->profile->supports(Capability::IndexSpatialNull)) { + $this->message = 'Spatial indexes do not allow null values. Mark the attribute "'.$attributeName.'" as required or create the index on a column with no null values.'; + + return false; + } + } + + if (! empty($index->orders) && ! $this->profile->supports(Capability::IndexSpatialOrder)) { + $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; + + return false; + } + + return true; + } + + /** + * Check that non-spatial index types are not applied to spatial attributes. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkNonSpatialIndexOnSpatialAttributes(Index $index): bool + { + $type = $index->type; + + if ($type === IndexType::Spatial) { + return true; + } + + foreach ($index->attributes as $attributeName) { + $attribute = $this->findAttribute($attributeName); + $attributeType = $attribute->type ?? ColumnType::String; + + if (\in_array($attributeType, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + $this->message = 'Cannot create '.$type->value.' index on spatial attribute "'.$attributeName.'". Spatial attributes require spatial indexes.'; + + return false; + } + } + + return true; + } + + /** + * @throws DatabaseException + */ + public function checkVectorIndexes(Index $index): bool + { + $type = $index->type; + + if ( + $type !== IndexType::HnswDot && + $type !== IndexType::HnswCosine && + $type !== IndexType::HnswEuclidean + ) { + return true; + } + + if (! $this->profile->supports(Capability::Vectors)) { + $this->message = 'Vector indexes are not supported'; + + return false; + } + + if (\count($index->attributes) !== 1) { + $this->message = 'Vector index must have exactly one attribute'; + + return false; + } + + if ($this->findAttribute($index->attributes[0])?->type !== ColumnType::Vector) { + $this->message = 'Vector index can only be created on vector attributes'; + + return false; + } + + if (! empty($index->orders) || \count(\array_filter($index->lengths)) > 0) { + $this->message = 'Vector indexes do not support orders or lengths'; + + return false; + } + + return true; + } + + /** + * @throws DatabaseException + */ + public function checkTrigramIndexes(Index $index): bool + { + $type = $index->type; + + if ($type !== IndexType::Trigram) { + return true; + } + + if (! $this->profile->supports(Capability::IndexTrigram)) { + $this->message = 'Trigram indexes are not supported'; + + return false; + } + + foreach ($index->attributes as $attributeName) { + if (! $this->isStringAttribute($this->findAttribute($attributeName))) { + $this->message = 'Trigram index can only be created on string type attributes'; + + return false; + } + } + + if (! empty($index->orders) || \count(\array_filter($index->lengths)) > 0) { + $this->message = 'Trigram indexes do not support orders or lengths'; + + return false; + } + + return true; + } + + /** + * Check that key and unique index types are supported by the current adapter. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkKeyUniqueFulltextSupport(Index $index): bool + { + $type = $index->type; + + if ($type === IndexType::Key && ! $this->profile->supports(Capability::IndexKey)) { + $this->message = 'Key index is not supported'; + + return false; + } + + if ($type === IndexType::Unique && ! $this->profile->supports(Capability::IndexUnique)) { + $this->message = 'Unique index is not supported'; + + return false; + } + + return true; + } + + /** + * Check that multiple fulltext indexes are not created when unsupported. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkMultipleFulltextIndexes(Index $index): bool + { + if ($this->profile->supports(Capability::IndexFulltextMultiple)) { + return true; + } + + if ($index->type === IndexType::Fulltext) { + $key = $index->key; + foreach ($this->indexes as $existingIndex) { + if ($existingIndex->key === $key) { + continue; + } + if ($existingIndex->type === IndexType::Fulltext) { + $this->message = 'There is already a fulltext index in the collection'; + + return false; + } + } + } + + return true; + } + + /** + * Check that identical indexes (same attributes and orders) are not created when unsupported. + * The index itself is skipped, so revalidating an existing index does not compare it with itself. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkIdenticalIndexes(Index $index): bool + { + if ($this->profile->supports(Capability::IndexIdentical)) { + return true; + } + + $key = \strtolower($index->key); + $indexedAttributes = $index->attributes; + $incomingOrders = self::orderValues($index); + $regularTypes = [IndexType::Key, IndexType::Unique]; + $isRegularIndex = \in_array($index->type, $regularTypes); + + foreach ($this->indexes as $existingIndex) { + if (\strtolower($existingIndex->key) === $key) { + continue; + } + + $existingAttributes = $existingIndex->attributes; + $attributesMatch = false; + if (empty(\array_diff($existingAttributes, $indexedAttributes)) && + empty(\array_diff($indexedAttributes, $existingAttributes))) { + $attributesMatch = true; + } + + $ordersMatch = false; + $existingOrders = self::orderValues($existingIndex); + if (empty(\array_diff($existingOrders, $incomingOrders)) && + empty(\array_diff($incomingOrders, $existingOrders))) { + $ordersMatch = true; + } + + if ($attributesMatch && $ordersMatch) { + // Allow fulltext + key/unique combinations (different purposes) + $isRegularExisting = \in_array($existingIndex->type, $regularTypes); + + if ($isRegularIndex && $isRegularExisting) { + $this->message = 'There is already an index with the same attributes and orders'; + + return false; + } + } + } + + return true; + } + + /** + * Check object index constraints including single-attribute and top-level requirements. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkObjectIndexes(Index $index): bool + { + $type = $index->type; + + if ($type !== IndexType::Object) { + return true; + } + + if (! $this->profile->supports(Capability::IndexObject)) { + $this->message = 'Object indexes are not supported'; + + return false; + } + + if (count($index->attributes) !== 1) { + $this->message = 'Object index can be created on a single object attribute'; + + return false; + } + + if (! empty($index->orders)) { + $this->message = 'Object index do not support explicit orders. Remove the orders to create this index.'; + + return false; + } + + $attributeName = $index->attributes[0]; + + // Object indexes are only allowed on the top-level object attribute, + // not on nested paths like "data.key.nestedKey". + if (\strpos($attributeName, '.') !== false) { + $this->message = 'Object index can only be created on a top-level object attribute'; + + return false; + } + + $attribute = $this->findAttribute($attributeName); + $attributeType = $attribute->type ?? ColumnType::String; + + if ($attributeType !== ColumnType::Object) { + $this->message = 'Object index can only be created on object attributes. Attribute "'.$attributeName.'" is of type "'.$attributeType->value.'"'; + + return false; + } + + return true; + } + + /** + * Check TTL index constraints including single-attribute, datetime type, and uniqueness requirements. + * + * @param Index $index The index to validate + * @return bool + */ + public function checkTtlIndexes(Index $index): bool + { + $type = $index->type; + + if ($type !== IndexType::Ttl) { + return true; + } + + if (count($index->attributes) !== 1) { + $this->message = 'TTL indexes must be created on a single datetime attribute.'; + + return false; + } + + $attributeName = $index->attributes[0]; + $attribute = $this->findAttribute($attributeName); + $attributeType = $attribute->type ?? ColumnType::String; + + if ($this->profile->supports(Capability::DefinedAttributes) && $attributeType !== ColumnType::Datetime) { + $this->message = 'TTL index can only be created on datetime attributes. Attribute "'.$attributeName.'" is of type "'.$attributeType->value.'"'; + + return false; + } + + if (($index->ttl ?? 0) < 1) { + $this->message = 'TTL must be at least 1 second'; + + return false; + } + + $key = $index->key; + foreach ($this->indexes as $existingIndex) { + if ($existingIndex->key === $key) { + continue; + } + + if ($existingIndex->type === IndexType::Ttl) { + $this->message = 'There can be only one TTL index in a collection'; + + return false; + } + } + + return true; + } + + /** + * Returns null for names outside the schema, such as a dotted path into an object attribute, + * so guards that only accept declared types reject them instead of treating them as a blank + * attribute of the default type. + */ + private function findAttribute(string $name): ?Attribute + { + return $this->attributes[\strtolower($name)] ?? null; + } + + private function isStringAttribute(?Attribute $attribute): bool + { + return $attribute !== null && \in_array($attribute->type, self::STRING_TYPES, true); + } + + private function isDottedAttribute(string $attribute): bool + { + return \str_contains($attribute, '.'); + } + + private function getBaseAttributeFromDottedAttribute(string $attribute): string + { + return $this->isDottedAttribute($attribute) ? \explode('.', $attribute, 2)[0] : $attribute; + } + + /** + * @return list + */ + private static function orderValues(Index $index): array + { + return \array_map( + static fn (?OrderDirection $order): ?string => $order?->value, + $index->orders, + ); + } +} diff --git a/src/Database/Validator/IndexDependency.php b/src/Database/Validator/IndexDependency.php index 7e8453b830..969fb4de7e 100644 --- a/src/Database/Validator/IndexDependency.php +++ b/src/Database/Validator/IndexDependency.php @@ -2,7 +2,9 @@ namespace Utopia\Database\Validator; +use Utopia\Database\Attribute; use Utopia\Database\Document; +use Utopia\Database\Index; use Utopia\Validator; class IndexDependency extends Validator @@ -12,49 +14,49 @@ class IndexDependency extends Validator protected bool $castIndexSupport; /** - * @var array + * @var list */ protected array $indexes; /** - * @param array $indexes - * @param bool $castIndexSupport + * @param array $indexes */ public function __construct(array $indexes, bool $castIndexSupport) { $this->castIndexSupport = $castIndexSupport; - $this->indexes = $indexes; + $this->indexes = []; + foreach ($indexes as $index) { + $this->indexes[] = $index instanceof Index ? $index : Index::fromDocument($index); + } } - /** - * Returns validator description - */ + #[\Override] public function getDescription(): string { return $this->message; } /** - * Is valid. - * - * @param Document $value + * @param Attribute|Document $value */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { if (! $this->castIndexSupport) { return true; } - if (! $value->getAttribute('array', false)) { + $attribute = $value instanceof Attribute ? $value : Attribute::fromDocument($value); + + if (! $attribute->array) { return true; } - $key = \strtolower($value->getAttribute('key', $value->getAttribute('$id'))); + $key = \strtolower($attribute->key); foreach ($this->indexes as $index) { - $attributes = $index->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($key === \strtolower($attribute)) { + foreach ($index->attributes as $indexedAttribute) { + if ($key === \strtolower($indexedAttribute)) { return false; } } @@ -63,21 +65,13 @@ public function isValid($value): bool return true; } - /** - * Is array - * - * Function will return true if object is array. - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - */ + #[\Override] public function getType(): string { return self::TYPE_OBJECT; diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php deleted file mode 100644 index a24e0d21da..0000000000 --- a/src/Database/Validator/IndexedQueries.php +++ /dev/null @@ -1,147 +0,0 @@ - - */ - protected array $attributes = []; - - /** - * @var array - */ - protected array $indexes = []; - - /** - * Expression constructor - * - * This Queries Validator filters indexes for only available indexes - * - * @param array $attributes - * @param array $indexes - * @param array $validators - * @throws Exception - */ - public function __construct(array $attributes = [], array $indexes = [], array $validators = []) - { - $this->attributes = $attributes; - - $this->indexes[] = new Document([ - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['$id'] - ]); - - $this->indexes[] = new Document([ - 'type' => Database::INDEX_KEY, - 'attributes' => ['$createdAt'] - ]); - - $this->indexes[] = new Document([ - 'type' => Database::INDEX_KEY, - 'attributes' => ['$updatedAt'] - ]); - - foreach ($indexes as $index) { - $this->indexes[] = $index; - } - - parent::__construct($validators); - } - - /** - * Count vector queries across entire query tree - * - * @param array $queries - * @return int - */ - private function countVectorQueries(array $queries): int - { - $count = 0; - - foreach ($queries as $query) { - if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $count++; - } - - if ($query->isNested()) { - $count += $this->countVectorQueries($query->getValues()); - } - } - - return $count; - } - - /** - * @param mixed $value - * @return bool - * @throws Exception - */ - public function isValid($value): bool - { - if (!parent::isValid($value)) { - return false; - } - $queries = []; - foreach ($value as $query) { - if (! $query instanceof Query) { - try { - $query = Query::parse($query); - } catch (\Throwable $e) { - $this->message = 'Invalid query: '.$e->getMessage(); - - return false; - } - } - - if ($query->isNested()) { - if (! self::isValid($query->getValues())) { - return false; - } - } - - $queries[] = $query; - } - - $vectorQueryCount = $this->countVectorQueries($queries); - if ($vectorQueryCount > 1) { - $this->message = 'Cannot use multiple vector queries in a single request'; - return false; - } - - $grouped = Query::groupByType($queries); - $filters = $grouped['filters']; - - foreach ($filters as $filter) { - if ( - $filter->getMethod() === Query::TYPE_SEARCH || - $filter->getMethod() === Query::TYPE_NOT_SEARCH - ) { - $matched = false; - - foreach ($this->indexes as $index) { - if ( - $index->getAttribute('type') === Database::INDEX_FULLTEXT - && $index->getAttribute('attributes') === [$filter->getAttribute()] - ) { - $matched = true; - } - } - - if (!$matched) { - $this->message = "Searching by attribute \"{$filter->getAttribute()}\" requires a fulltext index."; - return false; - } - } - } - - return true; - } -} diff --git a/src/Database/Validator/Key.php b/src/Database/Validator/Key.php index 8434446777..52b722036f 100644 --- a/src/Database/Validator/Key.php +++ b/src/Database/Validator/Key.php @@ -3,45 +3,30 @@ namespace Utopia\Database\Validator; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Validator; class Key extends Validator { protected string $message; - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ + #[\Override] public function getDescription(): string { return $this->message; } - /** - * Expression constructor - */ public function __construct( protected readonly bool $allowInternal = false, protected readonly int $maxLength = Database::MAX_UID_DEFAULT_LENGTH, ) { - $this->message = 'Parameter must contain at most ' . $this->maxLength . ' chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char'; + $this->message = 'Parameter must contain at most '.$this->maxLength.' chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char'; } - /** - * Is valid. - * - * Returns true if valid or false if not. - * - * @param $value - * @return bool - */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!\is_string($value)) { + if (! \is_string($value)) { return false; } @@ -49,7 +34,6 @@ public function isValid($value): bool return false; } - // No leading special characters $leading = \mb_substr($value, 0, 1); if ($leading === '_' || $leading === '.' || $leading === '-') { return false; @@ -57,14 +41,13 @@ public function isValid($value): bool $isInternal = $leading === '$'; - if ($isInternal && !$this->allowInternal) { + if ($isInternal && ! $this->allowInternal) { return false; } if ($isInternal) { - $allowList = [ '$id', '$createdAt', '$updatedAt' ]; + $allowList = [Document::ID, Document::CREATED_AT, Document::UPDATED_AT]; - // If exact match, no need for any further checks return \in_array($value, $allowList); } @@ -73,7 +56,6 @@ public function isValid($value): bool return false; } - // At most maxLength chars if (\mb_strlen($value) > $this->maxLength) { return false; } @@ -81,25 +63,13 @@ public function isValid($value): bool return true; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_STRING; diff --git a/src/Database/Validator/Label.php b/src/Database/Validator/Label.php index cf09be0b19..42096a5263 100644 --- a/src/Database/Validator/Label.php +++ b/src/Database/Validator/Label.php @@ -6,26 +6,26 @@ class Label extends Key { + /** + * @param bool $allowInternal Whether to allow internal attribute names starting with $ + * @param int $maxLength Maximum allowed string length + */ public function __construct( bool $allowInternal = false, int $maxLength = Database::MAX_UID_DEFAULT_LENGTH ) { parent::__construct($allowInternal, $maxLength); - $this->message = 'Value must be a valid string between 1 and ' . $this->maxLength . ' chars containing only alphanumeric chars'; + $this->message = 'Value must be a valid string between 1 and '.$this->maxLength.' chars containing only alphanumeric chars'; } - /** - * Is valid. - * - * Returns true if valid or false if not. - * - * @param $value - * - * @return bool - */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!parent::isValid($value)) { + if (! parent::isValid($value)) { + return false; + } + + if (! \is_string($value)) { return false; } diff --git a/src/Database/Validator/ObjectPath.php b/src/Database/Validator/ObjectPath.php new file mode 100644 index 0000000000..73131e2e81 --- /dev/null +++ b/src/Database/Validator/ObjectPath.php @@ -0,0 +1,48 @@ +> + * @var array */ protected array $attributes = []; @@ -21,26 +27,23 @@ class Operator extends Validator protected ?Document $currentDocument = null; /** - * Constructor - * - * @param Document $collection - * @param Document|null $currentDocument Current document for runtime validation (e.g., array bounds checking) + * @param Document|null $currentDocument Current document for runtime validation (e.g., array bounds checking) */ - public function __construct(Document $collection, ?Document $currentDocument = null) - { + public function __construct( + Document $collection, + ?Document $currentDocument = null, + private readonly bool $supportUnsignedBigInt = true, + ) { $this->collection = $collection; $this->currentDocument = $currentDocument; - foreach ($collection->getAttribute('attributes', []) as $attribute) { - $this->attributes[$attribute->getAttribute('key', $attribute->getId())] = $attribute; + foreach (Collection::fromDocument($collection)->attributes() as $attribute) { + $this->attributes[$attribute->key] = $attribute; } } /** * Check if a value is a valid relationship reference (string ID or Document) - * - * @param mixed $item - * @return bool */ private function isValidRelationshipValue(mixed $item): bool { @@ -49,65 +52,156 @@ private function isValidRelationshipValue(mixed $item): bool /** * Check if a relationship attribute represents a "many" side (returns array of documents) - * - * @param Document|array $attribute - * @return bool */ - private function isRelationshipArray(Document|array $attribute): bool + private function isRelationshipArray(Attribute $attribute): bool { - $options = $attribute instanceof Document - ? $attribute->getAttribute('options', []) - : ($attribute['options'] ?? []); + return match ($attribute->relationship?->type) { + RelationshipType::ManyToMany => true, + RelationshipType::OneToMany => $attribute->side === RelationshipSide::Parent, + RelationshipType::ManyToOne => $attribute->side === RelationshipSide::Child, + default => false, + }; + } - $relationType = $options['relationType'] ?? ''; - $side = $options['side'] ?? ''; + private function isNumericValueInBounds(mixed $value, Attribute $attribute): bool + { + $bounds = $attribute->bounds(); + if ($bounds === null) { + return false; + } - // Many-to-many is always an array on both sides - if ($relationType === Database::RELATION_MANY_TO_MANY) { - return true; + if ($attribute->isInteger()) { + $integer = $this->getIntegerValue($value); + + return $integer !== null + && BigInt::compare($integer, $bounds->min) >= 0 + && BigInt::compare($integer, $bounds->max) <= 0; } - // One-to-many: array on parent side, single on child side - if ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) { - return true; + $numeric = $this->getNumericValue($value); + if ($numeric === null) { + return false; } - // Many-to-one: array on child side, single on parent side - if ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) { - return true; + if (\is_float($numeric) && ! \is_finite($numeric)) { + return false; } - return false; + return $numeric >= $bounds->min && $numeric <= $bounds->max; } - /** - * Get Description - * - * Returns validator description - * - * @return string - */ + private function isValidLimit(mixed $limit, Attribute $attribute, DatabaseOperator $operator): bool + { + $methodName = $operator->getMethod()->value; + $finite = \is_int($limit) || (\is_float($limit) && \is_finite($limit)) || (\is_string($limit) && \is_numeric($limit)); + + if (! $attribute->isInteger() || ! $finite) { + if ($this->isNumericValueInBounds($limit, $attribute)) { + return true; + } + + $this->message = "Cannot apply {$methodName} operator: max/min limit must be numeric, got ".\gettype($limit); + + return false; + } + + /** @var int|float|string $limit */ + $integral = BigInt::integralValue($limit); + if ($integral === null) { + $this->message = "Cannot apply {$methodName} operator: max/min limit must be a whole number for integer attribute '{$operator->getAttribute()}', got {$limit}"; + + return false; + } + + $bounds = $attribute->bounds(); + if ($bounds === null) { + $this->message = "Cannot apply {$methodName} operator: max/min limit must be numeric, got ".\gettype($limit); + + return false; + } + + if (BigInt::compare($integral, $bounds->min) < 0 || BigInt::compare($integral, $bounds->max) > 0) { + $this->message = "Cannot apply {$methodName} operator: max/min limit must be between {$bounds->min} and {$bounds->max}"; + + return false; + } + + return true; + } + + private function getIntegerValue(mixed $value): int|string|null + { + if (\is_int($value)) { + return $value; + } + if (\is_string($value) && BigInt::isIntegerString($value)) { + return BigInt::toNative($value); + } + if (\is_float($value) && \is_finite($value) && \floor($value) === $value && $value >= \PHP_INT_MIN && $value <= \PHP_INT_MAX) { + return (int) $value; + } + + return null; + } + + private function getNumericValue(mixed $value): int|float|null + { + if (\is_int($value) || \is_float($value)) { + return $value; + } + + if (! \is_string($value) || ! \is_numeric($value)) { + return null; + } + + return BigInt::fitsPhpInt($value) ? (int) $value : (float) $value; + } + + private function setNumericRangeMessage(OperatorType $method, Attribute $attribute, int|float|string $result): bool + { + $bounds = $attribute->bounds(); + if ($bounds === null) { + return false; + } + + $aboveMaximum = $attribute->isInteger() + ? BigInt::compare($result, $bounds->max) > 0 + : $result > $bounds->max; + if ($aboveMaximum) { + $this->message = "Cannot apply {$method->value} operator: would overflow maximum value of {$bounds->max}"; + + return false; + } + + $belowMinimum = $attribute->isInteger() + ? BigInt::compare($result, $bounds->min) < 0 + : $result < $bounds->min; + if ($belowMinimum) { + $this->message = "Cannot apply {$method->value} operator: would underflow minimum value of {$bounds->min}"; + + return false; + } + + return true; + } + + #[\Override] public function getDescription(): string { return $this->message; } - /** - * Is valid - * - * Returns true if valid or false if not. - * - * @param $value - * - * @return bool - */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!$value instanceof DatabaseOperator) { + if (! $value instanceof DatabaseOperator) { try { - $value = DatabaseOperator::parse($value); - } catch (\Throwable $e) { - $this->message = 'Invalid operator: ' . $e->getMessage(); + /** @var string $valueStr */ + $valueStr = $value; + $value = DatabaseOperator::parse($valueStr); + } catch (Throwable $e) { + $this->message = 'Invalid operator: '.$e->getMessage(); + return false; } } @@ -115,40 +209,29 @@ public function isValid($value): bool $method = $value->getMethod(); $attribute = $value->getAttribute(); - // Check if method is valid - if (!DatabaseOperator::isMethod($method)) { - $this->message = "Invalid operator method: {$method}"; - return false; - } - - // Check if attribute exists in collection $attributeConfig = $this->attributes[$attribute] ?? null; if ($attributeConfig === null) { $this->message = "Attribute '{$attribute}' does not exist in collection"; + return false; } - // Validate operator against attribute type return $this->validateOperatorForAttribute($value, $attributeConfig); } /** * Validate operator against attribute configuration - * - * @param DatabaseOperator $operator - * @param Document|array $attribute - * @return bool */ private function validateOperatorForAttribute( DatabaseOperator $operator, - Document|array $attribute + Attribute $attribute ): bool { $method = $operator->getMethod(); + $methodName = $method->value; $values = $operator->getValues(); - // Handle both Document objects and arrays - $type = $attribute instanceof Document ? $attribute->getAttribute('type') : $attribute['type']; - $isArray = $attribute instanceof Document ? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); + $type = $attribute->type; + $isArray = $attribute->array; // Array operators that carry a caller-supplied value list are capped to guard against // memory exhaustion. Enforced here so every adapter rejects an oversized list the same way. @@ -156,11 +239,11 @@ private function validateOperatorForAttribute( // operators normalize with), so measure whichever the operator will actually process. if ( \in_array($method, [ - DatabaseOperator::TYPE_ARRAY_APPEND, - DatabaseOperator::TYPE_ARRAY_PREPEND, - DatabaseOperator::TYPE_ARRAY_INTERSECT, - DatabaseOperator::TYPE_ARRAY_DIFF, - DatabaseOperator::TYPE_ARRAY_REMOVE, + OperatorType::ArrayAppend, + OperatorType::ArrayPrepend, + OperatorType::ArrayIntersect, + OperatorType::ArrayDiff, + OperatorType::ArrayRemove, ], true) ) { $payload = (isset($values[0]) && \is_array($values[0])) ? $values[0] : $values; @@ -171,346 +254,383 @@ private function validateOperatorForAttribute( } switch ($method) { - case DatabaseOperator::TYPE_INCREMENT: - case DatabaseOperator::TYPE_DECREMENT: - case DatabaseOperator::TYPE_MULTIPLY: - case DatabaseOperator::TYPE_DIVIDE: - case DatabaseOperator::TYPE_MODULO: - case DatabaseOperator::TYPE_POWER: - // Numeric operations only work on numeric types - if (!\in_array($type, [Database::VAR_INTEGER, Database::VAR_FLOAT])) { - $this->message = "Cannot apply {$method} operator to non-numeric field '{$operator->getAttribute()}'"; + case OperatorType::Increment: + case OperatorType::Decrement: + case OperatorType::Multiply: + case OperatorType::Divide: + case OperatorType::Modulo: + case OperatorType::Power: + if (! $attribute->isNumeric()) { + $this->message = "Cannot apply {$methodName} operator to non-numeric field '{$operator->getAttribute()}'"; + + return false; + } + + if (! $attribute->signed + && $type === ColumnType::BigInteger + && ! $this->supportUnsignedBigInt) { + $this->message = "Cannot apply {$methodName} operator: unsigned 64-bit arithmetic is not supported by this adapter"; + + return false; + } + + if (! isset($values[0]) || ! $this->isNumericValueInBounds($values[0], $attribute)) { + $this->message = "Cannot apply {$methodName} operator: value must be numeric, got ".gettype($operator->getValue()); + return false; } - // Validate the numeric value and optional max/min - if (!isset($values[0]) || !\is_numeric($values[0])) { - $this->message = "Cannot apply {$method} operator: value must be numeric, got " . gettype($operator->getValue()); + $integerType = $attribute->isInteger(); + $operatorValue = $integerType + ? $this->getIntegerValue($values[0]) + : $this->getNumericValue($values[0]); + if ($operatorValue === null) { return false; } - // Special validation for divide/modulo by zero - if (($method === DatabaseOperator::TYPE_DIVIDE || $method === DatabaseOperator::TYPE_MODULO) && (float)$values[0] === 0.0) { - $this->message = "Cannot apply {$method} operator: " . ($method === DatabaseOperator::TYPE_DIVIDE ? "division" : "modulo") . " by zero"; + if (($method === OperatorType::Divide || $method === OperatorType::Modulo) && ($operatorValue === 0 || $operatorValue === 0.0)) { + $this->message = "Cannot apply {$methodName} operator: ".($method === OperatorType::Divide ? 'division' : 'modulo').' by zero'; + return false; } - // Validate max/min if provided - if (\count($values) > 1 && $values[1] !== null && !\is_numeric($values[1])) { - $this->message = "Cannot apply {$method} operator: max/min limit must be numeric, got " . \gettype($values[1]); + if (\count($values) > 1 && $values[1] !== null && ! $this->isValidLimit($values[1], $attribute, $operator)) { return false; } - if ($this->currentDocument !== null && $type === Database::VAR_INTEGER && !isset($values[1])) { - $currentValue = $this->currentDocument->getAttribute($operator->getAttribute()) ?? 0; - $operatorValue = $values[0]; + if ($this->currentDocument !== null && $integerType && ! isset($values[1])) { + $currentValue = $this->getIntegerValue($this->currentDocument->getAttribute($operator->getAttribute()) ?? 0); + + if ($currentValue === null || ! $this->isNumericValueInBounds($currentValue, $attribute)) { + $this->message = "Cannot apply {$methodName} operator: current value is outside the attribute range"; - // Compute predicted result - $predictedResult = match ($method) { - DatabaseOperator::TYPE_INCREMENT => $currentValue + $operatorValue, - DatabaseOperator::TYPE_DECREMENT => $currentValue - $operatorValue, - DatabaseOperator::TYPE_MULTIPLY => $currentValue * $operatorValue, - DatabaseOperator::TYPE_DIVIDE => $currentValue / $operatorValue, - DatabaseOperator::TYPE_MODULO => $currentValue % $operatorValue, - DatabaseOperator::TYPE_POWER => $currentValue ** $operatorValue, - }; + return false; + } + + try { + $predictedResult = BigInt::calculate($method, $currentValue, $operatorValue); + } catch (\InvalidArgumentException) { + $this->message = "Cannot apply {$methodName} operator: result is outside the attribute range"; - if ($predictedResult > Database::MAX_INT) { - $this->message = "Cannot apply {$method} operator: would overflow maximum value of " . Database::MAX_INT; return false; } - if ($predictedResult < Database::MIN_INT) { - $this->message = "Cannot apply {$method} operator: would underflow minimum value of " . Database::MIN_INT; + if (! $this->setNumericRangeMessage($method, $attribute, $predictedResult)) { return false; } } break; - case DatabaseOperator::TYPE_ARRAY_APPEND: - case DatabaseOperator::TYPE_ARRAY_PREPEND: - // For relationships, check if it's a "many" side - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isRelationshipArray($attribute)) { - $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + case OperatorType::ArrayAppend: + case OperatorType::ArrayPrepend: + if ($type === ColumnType::Relationship) { + if (! $this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$methodName} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; } foreach ($values as $item) { - if (!$this->isValidRelationshipValue($item)) { - $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + if (! $this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$methodName} operator: relationship values must be document IDs (strings) or Document objects"; + return false; } } - } elseif (!$isArray) { - $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + } elseif (! $isArray) { + $this->message = "Cannot apply {$methodName} operator to non-array field '{$operator->getAttribute()}'"; + return false; } - if (!empty($values) && $type === Database::VAR_INTEGER) { + if (! empty($values) && $attribute->isInteger()) { $newItems = \is_array($values[0]) ? $values[0] : $values; foreach ($newItems as $item) { - if (\is_numeric($item) && ($item > Database::MAX_INT || $item < Database::MIN_INT)) { - $this->message = "Cannot apply {$method} operator: array items must be between " . Database::MIN_INT . " and " . Database::MAX_INT; + if (\is_numeric($item) && ! $this->isNumericValueInBounds($item, $attribute)) { + $bounds = $attribute->bounds(); + if ($bounds === null) { + return false; + } + $this->message = "Cannot apply {$methodName} operator: array items must be between {$bounds->min} and {$bounds->max}"; + return false; } } } break; - case DatabaseOperator::TYPE_ARRAY_UNIQUE: - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isRelationshipArray($attribute)) { - $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + case OperatorType::ArrayUnique: + if ($type === ColumnType::Relationship) { + if (! $this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$methodName} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; } - } elseif (!$isArray) { - $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + } elseif (! $isArray) { + $this->message = "Cannot apply {$methodName} operator to non-array field '{$operator->getAttribute()}'"; + return false; } break; - case DatabaseOperator::TYPE_ARRAY_INSERT: - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isRelationshipArray($attribute)) { - $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + case OperatorType::ArrayInsert: + if ($type === ColumnType::Relationship) { + if (! $this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$methodName} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; } - } elseif (!$isArray) { - $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + } elseif (! $isArray) { + $this->message = "Cannot apply {$methodName} operator to non-array field '{$operator->getAttribute()}'"; + return false; } if (\count($values) !== 2) { - $this->message = "Cannot apply {$method} operator: requires exactly 2 values (index and value)"; + $this->message = "Cannot apply {$methodName} operator: requires exactly 2 values (index and value)"; + return false; } $index = $values[0]; - if (!\is_int($index) || $index < 0) { - $this->message = "Cannot apply {$method} operator: index must be a non-negative integer"; + if (! \is_int($index) || $index < 0) { + $this->message = "Cannot apply {$methodName} operator: index must be a non-negative integer"; + return false; } $insertValue = $values[1]; - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isValidRelationshipValue($insertValue)) { - $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + if ($type === ColumnType::Relationship) { + if (! $this->isValidRelationshipValue($insertValue)) { + $this->message = "Cannot apply {$methodName} operator: relationship values must be document IDs (strings) or Document objects"; + return false; } } - if ($type === Database::VAR_INTEGER && \is_numeric($insertValue)) { - if ($insertValue > Database::MAX_INT || $insertValue < Database::MIN_INT) { - $this->message = "Cannot apply {$method} operator: array items must be between " . Database::MIN_INT . " and " . Database::MAX_INT; + if ($attribute->isInteger() && \is_numeric($insertValue)) { + if (! $this->isNumericValueInBounds($insertValue, $attribute)) { + $bounds = $attribute->bounds(); + if ($bounds === null) { + return false; + } + $this->message = "Cannot apply {$methodName} operator: array items must be between {$bounds->min} and {$bounds->max}"; + return false; } } - // Runtime validation: Check if index is within bounds if ($this->currentDocument !== null) { $currentArray = $this->currentDocument->getAttribute($operator->getAttribute()); if (\is_array($currentArray)) { $arrayLength = \count($currentArray); // Valid indices are 0 to length (inclusive, as we can append) if ($index > $arrayLength) { - $this->message = "Cannot apply {$method} operator: index {$index} is out of bounds for array of length {$arrayLength}"; + $this->message = "Cannot apply {$methodName} operator: index {$index} is out of bounds for array of length {$arrayLength}"; + return false; } } } break; - case DatabaseOperator::TYPE_ARRAY_REMOVE: - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isRelationshipArray($attribute)) { - $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + case OperatorType::ArrayRemove: + if ($type === ColumnType::Relationship) { + if (! $this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$methodName} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; } $toValidate = \is_array($values[0]) ? $values[0] : $values; foreach ($toValidate as $item) { - if (!$this->isValidRelationshipValue($item)) { - $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + if (! $this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$methodName} operator: relationship values must be document IDs (strings) or Document objects"; + return false; } } - } elseif (!$isArray) { - $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + } elseif (! $isArray) { + $this->message = "Cannot apply {$methodName} operator to non-array field '{$operator->getAttribute()}'"; + return false; } if (empty($values)) { - $this->message = "Cannot apply {$method} operator: requires a value to remove"; + $this->message = "Cannot apply {$methodName} operator: requires a value to remove"; + return false; } break; - case DatabaseOperator::TYPE_ARRAY_INTERSECT: - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isRelationshipArray($attribute)) { - $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + case OperatorType::ArrayIntersect: + if ($type === ColumnType::Relationship) { + if (! $this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$methodName} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; } - } elseif (!$isArray) { - $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; + } elseif (! $isArray) { + $this->message = "Cannot use {$methodName} operator on non-array attribute '{$operator->getAttribute()}'"; + return false; } if (empty($values)) { - $this->message = "{$method} operator requires a non-empty array value"; + $this->message = "{$methodName} operator requires a non-empty array value"; + return false; } - if ($type === Database::VAR_RELATIONSHIP) { + if ($type === ColumnType::Relationship) { foreach ($values as $item) { - if (!$this->isValidRelationshipValue($item)) { - $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + if (! $this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$methodName} operator: relationship values must be document IDs (strings) or Document objects"; + return false; } } } break; - case DatabaseOperator::TYPE_ARRAY_DIFF: - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isRelationshipArray($attribute)) { - $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + case OperatorType::ArrayDiff: + if ($type === ColumnType::Relationship) { + if (! $this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$methodName} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; } foreach ($values as $item) { - if (!$this->isValidRelationshipValue($item)) { - $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + if (! $this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$methodName} operator: relationship values must be document IDs (strings) or Document objects"; + return false; } } - } elseif (!$isArray) { - $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; + } elseif (! $isArray) { + $this->message = "Cannot use {$methodName} operator on non-array attribute '{$operator->getAttribute()}'"; + return false; } break; - case DatabaseOperator::TYPE_ARRAY_FILTER: - if ($type === Database::VAR_RELATIONSHIP) { - if (!$this->isRelationshipArray($attribute)) { - $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + case OperatorType::ArrayFilter: + if ($type === ColumnType::Relationship) { + if (! $this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$methodName} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; } - } elseif (!$isArray) { - $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + } elseif (! $isArray) { + $this->message = "Cannot apply {$methodName} operator to non-array field '{$operator->getAttribute()}'"; + return false; } if (\count($values) < 1 || \count($values) > 2) { - $this->message = "Cannot apply {$method} operator: requires 1 or 2 values (condition and optional comparison value)"; + $this->message = "Cannot apply {$methodName} operator: requires 1 or 2 values (condition and optional comparison value)"; + return false; } - if (!\is_string($values[0])) { - $this->message = "Cannot apply {$method} operator: condition must be a string"; + if (! \is_string($values[0])) { + $this->message = "Cannot apply {$methodName} operator: condition must be a string"; + return false; } - if (!\in_array($values[0], DatabaseOperator::ARRAY_FILTER_CONDITIONS, true)) { - $this->message = "Invalid array filter condition '{$values[0]}'. Must be one of: " . \implode(', ', DatabaseOperator::ARRAY_FILTER_CONDITIONS); + if (! \in_array($values[0], DatabaseOperator::ARRAY_FILTER_CONDITIONS, true)) { + $this->message = "Invalid array filter condition '{$values[0]}'. Must be one of: ".\implode(', ', DatabaseOperator::ARRAY_FILTER_CONDITIONS); + return false; } break; - case DatabaseOperator::TYPE_STRING_CONCAT: - if (!in_array($type, Database::STRING_TYPES) || $isArray) { - $this->message = "Cannot apply {$method} operator to non-string field '{$operator->getAttribute()}'"; + case OperatorType::StringConcat: + if (! \in_array($type, [ColumnType::String, ColumnType::Varchar, ColumnType::Text, ColumnType::MediumText, ColumnType::LongText]) || $isArray) { + $this->message = "Cannot apply {$methodName} operator to non-string field '{$operator->getAttribute()}'"; + return false; } - if (empty($values) || !\is_string($values[0])) { - $this->message = "Cannot apply {$method} operator: requires a string value"; + if (empty($values) || ! \is_string($values[0])) { + $this->message = "Cannot apply {$methodName} operator: requires a string value"; + return false; } - if ($this->currentDocument !== null && in_array($type, Database::STRING_TYPES)) { + if ($this->currentDocument !== null && \in_array($type, [ColumnType::String, ColumnType::Varchar, ColumnType::Text, ColumnType::MediumText, ColumnType::LongText])) { + /** @var string $currentString */ $currentString = $this->currentDocument->getAttribute($operator->getAttribute()) ?? ''; $concatValue = $values[0]; - $predictedLength = strlen($currentString) + strlen($concatValue); + $predictedLength = strlen($currentString) + strlen((string) $concatValue); - $maxSize = $attribute instanceof Document - ? $attribute->getAttribute('size', 0) - : ($attribute['size'] ?? 0); + $maxSize = $attribute->size ?? 0; if ($maxSize > 0 && $predictedLength > $maxSize) { - $this->message = "Cannot apply {$method} operator: result would exceed maximum length of {$maxSize} characters"; + $this->message = "Cannot apply {$methodName} operator: result would exceed maximum length of {$maxSize} characters"; + return false; } } break; - case DatabaseOperator::TYPE_STRING_REPLACE: - // Replace only works on string types - if (!in_array($type, Database::STRING_TYPES)) { - $this->message = "Cannot apply {$method} operator to non-string field '{$operator->getAttribute()}'"; + case OperatorType::StringReplace: + if (! \in_array($type, [ColumnType::String, ColumnType::Varchar, ColumnType::Text, ColumnType::MediumText, ColumnType::LongText])) { + $this->message = "Cannot apply {$methodName} operator to non-string field '{$operator->getAttribute()}'"; + return false; } - if (\count($values) !== 2 || !\is_string($values[0]) || !\is_string($values[1])) { - $this->message = "Cannot apply {$method} operator: requires exactly 2 string values (search and replace)"; + if (\count($values) !== 2 || ! \is_string($values[0]) || ! \is_string($values[1])) { + $this->message = "Cannot apply {$methodName} operator: requires exactly 2 string values (search and replace)"; + return false; } break; - case DatabaseOperator::TYPE_TOGGLE: - // Toggle only works on boolean types - if ($type !== Database::VAR_BOOLEAN) { - $this->message = "Cannot apply {$method} operator to non-boolean field '{$operator->getAttribute()}'"; + case OperatorType::Toggle: + if ($type !== ColumnType::Boolean) { + $this->message = "Cannot apply {$methodName} operator to non-boolean field '{$operator->getAttribute()}'"; + return false; } break; - case DatabaseOperator::TYPE_DATE_ADD_DAYS: - case DatabaseOperator::TYPE_DATE_SUB_DAYS: - if ($type !== Database::VAR_DATETIME) { - $this->message = "Cannot apply {$method} operator to non-datetime field '{$operator->getAttribute()}'"; + case OperatorType::DateAddDays: + case OperatorType::DateSubDays: + if ($type !== ColumnType::Datetime) { + $this->message = "Cannot apply {$methodName} operator to non-datetime field '{$operator->getAttribute()}'"; + return false; } - if (empty($values) || !\is_int($values[0])) { - $this->message = "Cannot apply {$method} operator: requires an integer number of days"; + if (empty($values) || ! \is_int($values[0])) { + $this->message = "Cannot apply {$methodName} operator: requires an integer number of days"; + return false; } break; - case DatabaseOperator::TYPE_DATE_SET_NOW: - if ($type !== Database::VAR_DATETIME) { - $this->message = "Cannot apply {$method} operator to non-datetime field '{$operator->getAttribute()}'"; + case OperatorType::DateSetNow: + if ($type !== ColumnType::Datetime) { + $this->message = "Cannot apply {$methodName} operator to non-datetime field '{$operator->getAttribute()}'"; + return false; } break; - default: - $this->message = "Cannot apply {$method} operator: unsupported operator method"; - return false; } return true; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_OBJECT; diff --git a/src/Database/Validator/PartialStructure.php b/src/Database/Validator/PartialStructure.php index a386a1fa4b..b300414695 100644 --- a/src/Database/Validator/PartialStructure.php +++ b/src/Database/Validator/PartialStructure.php @@ -7,53 +7,39 @@ class PartialStructure extends Structure { - /** - * Is valid. - * - * Returns true if valid or false if not. - * - * @param mixed $document - * - * @return bool - */ - public function isValid($document): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!$document instanceof Document) { + if (! $value instanceof Document) { $this->message = 'Value must be an instance of Document'; + return false; } - if (empty($this->collection->getId()) || Database::METADATA !== $this->collection->getCollection()) { + if (empty($this->collection->getId()) || $this->collection->getCollection() !== Database::METADATA) { $this->message = 'Collection not found'; + return false; } - $keys = []; - $structure = $document->getArrayCopy(); - $attributes = \array_merge($this->attributes, $this->collection->getAttribute('attributes', [])); + $structure = $value->getArrayCopy(); + $definitions = $this->definitions(); - foreach ($attributes as $attribute) { - $name = $attribute['$id'] ?? ''; - $keys[$name] = $attribute; - } - /** - * @var array $requiredAttributes - */ - $requiredAttributes = []; - foreach ($this->attributes as $attribute) { - if ($attribute['required'] === true && $document->offsetExists($attribute['$id'])) { - $requiredAttributes[] = $attribute; + $required = []; + foreach (self::internalAttributes() as $attribute) { + if ($attribute->required && $value->offsetExists($attribute->key)) { + $required[] = $attribute; } } - if (!$this->checkForAllRequiredValues($structure, $requiredAttributes, $keys)) { + if (! $this->checkForAllRequiredValues($structure, $required)) { return false; } - if (!$this->checkForUnknownAttributes($structure, $keys)) { + if (! $this->checkForUnknownAttributes($structure, $definitions)) { return false; } - if (!$this->checkForInvalidAttributeValues($document, $structure, $keys)) { + if (! $this->checkForInvalidAttributeValues($value, $structure, $definitions)) { return false; } diff --git a/src/Database/Validator/Permissions.php b/src/Database/Validator/Permissions.php index 13e7372050..bcb96f493f 100644 --- a/src/Database/Validator/Permissions.php +++ b/src/Database/Validator/Permissions.php @@ -2,78 +2,71 @@ namespace Utopia\Database\Validator; -use Utopia\Database\Database; -use Utopia\Database\Helpers\Permission; +use Exception; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; class Permissions extends Roles { + #[\Override] protected string $message = 'Permissions Error'; /** * @var array */ + #[\Override] protected array $allowed; + #[\Override] protected int $length; /** - * Permissions constructor. - * - * @param int $length maximum amount of permissions. 0 means unlimited. - * @param array $allowed allowed permissions. Defaults to all available. + * @param int $length maximum amount of permissions. 0 means unlimited. + * @param array $allowed allowed permissions. Defaults to all available. */ - public function __construct(int $length = 0, array $allowed = [...Database::PERMISSIONS, Database::PERMISSION_WRITE]) + public function __construct(int $length = 0, array $allowed = [PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete, PermissionType::Write]) { $this->length = $length; - $this->allowed = $allowed; + $this->allowed = \array_map(fn (PermissionType $p) => $p->value, $allowed); } - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ + #[\Override] public function getDescription(): string { return $this->message; } - /** - * Is valid. - * - * Returns true if valid or false if not. - * - * @param mixed $permissions - * - * @return bool - */ - public function isValid($permissions): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!\is_array($permissions)) { + if (! \is_array($value)) { $this->message = 'Permissions must be an array of strings.'; + return false; } - if ($this->length && \count($permissions) > $this->length) { - $this->message = 'You can only provide up to ' . $this->length . ' permissions.'; + if ($this->length && \count($value) > $this->length) { + $this->message = 'You can only provide up to '.$this->length.' permissions.'; + return false; } - foreach ($permissions as $permission) { - if (!\is_string($permission)) { + foreach ($value as $permission) { + if (! \is_string($permission)) { $this->message = 'Every permission must be of type string.'; + return false; } if ($permission === '*') { $this->message = 'Wildcard permission "*" has been replaced. Use "any" instead.'; + return false; } if (\str_contains($permission, 'role:')) { $this->message = 'Permissions using the "role:" prefix have been replaced. Use "users", "guests", or "any" instead.'; + return false; } @@ -84,15 +77,17 @@ public function isValid($permissions): bool break; } } - if (!$isAllowed) { - $this->message = 'Permission "' . $permission . '" is not allowed. Must be one of: ' . \implode(', ', $this->allowed) . '.'; + if (! $isAllowed) { + $this->message = 'Permission "'.$permission.'" is not allowed. Must be one of: '.\implode(', ', $this->allowed).'.'; + return false; } try { $permission = Permission::parse($permission); - } catch (\Exception $e) { + } catch (Exception $e) { $this->message = $e->getMessage(); + return false; } @@ -100,32 +95,21 @@ public function isValid($permissions): bool $identifier = $permission->getIdentifier(); $dimension = $permission->getDimension(); - if (!$this->isValidRole($role, $identifier, $dimension)) { + if (! $this->isValidRole($role, $identifier, $dimension)) { return false; } } + return true; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_ARRAY; diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php deleted file mode 100644 index 4f91251828..0000000000 --- a/src/Database/Validator/Queries.php +++ /dev/null @@ -1,179 +0,0 @@ - - */ - protected array $validators; - - /** - * @var int - */ - protected int $length; - - /** - * Queries constructor - * - * @param array $validators - */ - public function __construct(array $validators = [], int $length = 0) - { - $this->validators = $validators; - $this->length = $length; - } - - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ - public function getDescription(): string - { - return $this->message; - } - - /** - * @param array $value - * @return bool - */ - public function isValid($value): bool - { - if (!is_array($value)) { - $this->message = 'Queries must be an array'; - return false; - } - - if ($this->length && \count($value) > $this->length) { - return false; - } - - foreach ($value as $query) { - if (!$query instanceof Query) { - try { - $query = Query::parse($query); - } catch (\Throwable $e) { - $this->message = 'Invalid query: ' . $e->getMessage(); - return false; - } - } - - if ($query->isNested()) { - if (!self::isValid($query->getValues())) { - return false; - } - } - - $method = $query->getMethod(); - $methodType = match ($method) { - Query::TYPE_SELECT => Base::METHOD_TYPE_SELECT, - Query::TYPE_LIMIT => Base::METHOD_TYPE_LIMIT, - Query::TYPE_OFFSET => Base::METHOD_TYPE_OFFSET, - Query::TYPE_CURSOR_AFTER, - Query::TYPE_CURSOR_BEFORE => Base::METHOD_TYPE_CURSOR, - Query::TYPE_ORDER_ASC, - Query::TYPE_ORDER_DESC, - Query::TYPE_ORDER_RANDOM => Base::METHOD_TYPE_ORDER, - Query::TYPE_EQUAL, - Query::TYPE_NOT_EQUAL, - Query::TYPE_LESSER, - Query::TYPE_LESSER_EQUAL, - Query::TYPE_GREATER, - Query::TYPE_GREATER_EQUAL, - Query::TYPE_SEARCH, - Query::TYPE_NOT_SEARCH, - Query::TYPE_IS_NULL, - Query::TYPE_IS_NOT_NULL, - Query::TYPE_BETWEEN, - Query::TYPE_NOT_BETWEEN, - Query::TYPE_STARTS_WITH, - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_CONTAINS, - Query::TYPE_CONTAINS_ANY, - Query::TYPE_NOT_CONTAINS, - Query::TYPE_AND, - Query::TYPE_OR, - Query::TYPE_CONTAINS_ALL, - Query::TYPE_ELEM_MATCH, - Query::TYPE_CROSSES, - Query::TYPE_NOT_CROSSES, - Query::TYPE_DISTANCE_EQUAL, - Query::TYPE_DISTANCE_NOT_EQUAL, - Query::TYPE_DISTANCE_GREATER_THAN, - Query::TYPE_DISTANCE_LESS_THAN, - Query::TYPE_INTERSECTS, - Query::TYPE_NOT_INTERSECTS, - Query::TYPE_OVERLAPS, - Query::TYPE_NOT_OVERLAPS, - Query::TYPE_TOUCHES, - Query::TYPE_NOT_TOUCHES, - Query::TYPE_VECTOR_DOT, - Query::TYPE_VECTOR_COSINE, - Query::TYPE_VECTOR_EUCLIDEAN, - Query::TYPE_REGEX, - Query::TYPE_EXISTS, - Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER, - default => '', - }; - - $methodIsValid = false; - foreach ($this->validators as $validator) { - if ($validator->getMethodType() !== $methodType) { - continue; - } - if (!$validator->isValid($query)) { - $this->message = 'Invalid query: ' . $validator->getDescription(); - return false; - } - - $methodIsValid = true; - } - - if (!$methodIsValid) { - $this->message = 'Invalid query method: ' . $method; - return false; - } - } - - return true; - } - - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ - public function isArray(): bool - { - return true; - } - - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ - public function getType(): string - { - return self::TYPE_OBJECT; - } -} diff --git a/src/Database/Validator/Queries/Base.php b/src/Database/Validator/Queries/Base.php new file mode 100644 index 0000000000..c48c1c488a --- /dev/null +++ b/src/Database/Validator/Queries/Base.php @@ -0,0 +1,466 @@ + + */ + protected array $validators; + + protected int $length; + + /** + * @var array + */ + protected array $joinedCollections = []; + + /** + * @param array $validators + */ + public function __construct(array $validators = [], int $length = 0) + { + $this->validators = $validators; + $this->length = $length; + } + + #[\Override] + public function getDescription(): string + { + return $this->message; + } + + /** + * The collections the query sets may join. A column under a join alias has to be valid on the + * alias's collection for its query type, a bare aggregate or groupBy attribute resolves + * through the one join whose collection declares it, and a search on a join alias needs a + * fulltext index on that collection. A join whose collection is not given declares nothing, + * and accepts any plain column under its alias. + * + * @param array $collections + */ + public function setJoinedCollections(array $collections): void + { + $this->joinedCollections = []; + foreach ($collections as $collection) { + $this->joinedCollections[$collection->getId()] = $collection; + } + } + + protected function getJoinedCollection(string $id): ?Document + { + return $this->joinedCollections[$id] ?? null; + } + + /** + * Validate an array of queries, checking each against registered method-type validators. + * + * @param mixed $value Array of Query objects or query strings + */ + #[\Override] + public function isValid(mixed $value): bool + { + if (! \is_array($value)) { + $this->message = 'Queries must be an array'; + + return false; + } + /** @var array $value */ + if (! $this->isValidLength($value)) { + return false; + } + + // One pass over the validators: clear aliases left over from a previous + // call, and note whether a filter validator is registered. Order + // validators persist across requests in pooled / long-lived processes, + // so letting aliases accumulate leaks state and lets an unrelated query + // order by a stale alias. + $hasFilterValidator = false; + foreach ($this->validators as $validator) { + if ($validator instanceof Order) { + $validator->resetAggregationAliases(); + } + if ( + $validator instanceof Select + || $validator instanceof Filter + || $validator instanceof Order + || $validator instanceof Aggregate + || $validator instanceof GroupBy + || $validator instanceof Join + ) { + $validator->resetJoinAliases(); + } + if ($validator->getMethodType() === QueryBase::METHOD_TYPE_FILTER) { + $hasFilterValidator = true; + } + } + + // One pass over the input: parse each raw string, and collect the + // aliases the order, select and filter validators have to know about + // before dispatch starts. A method can report as both aggregate and + // join, so both are tested independently rather than as a chain. + /** @var list $parsedQueries */ + $parsedQueries = []; + /** @var array $aggregationAliases */ + $aggregationAliases = []; + $joinAliases = []; + $hasJoins = false; + foreach ($value as $q) { + if (! $q instanceof Query) { + try { + $q = Query::parse($q); + } catch (Throwable $e) { + $this->message = 'Invalid query: '.$e->getMessage(); + + return false; + } + } + + $parsedQueries[] = $q; + + $method = $q->getMethod(); + + if ($method->isAggregate() && $q->getAlias() !== '') { + $aggregationAliases[] = $q->getAlias(); + } + + if ($method->isJoin()) { + $hasJoins = true; + $joinAliases[] = $q->getAlias(); + } + } + + if ($hasJoins) { + $joins = $this->joins($parsedQueries); + + foreach ($this->validators as $validator) { + if ( + $validator instanceof Select + || $validator instanceof Filter + || $validator instanceof Order + || $validator instanceof Aggregate + || $validator instanceof GroupBy + || $validator instanceof Join + ) { + $validator->allowJoins($joins); + } + } + } + + if ($aggregationAliases !== [] || $joinAliases !== []) { + foreach ($this->validators as $validator) { + if ($aggregationAliases !== [] && $validator instanceof Order) { + $validator->addAggregationAliases($aggregationAliases); + } + if ( + $joinAliases !== [] + && ( + $validator instanceof Select + || $validator instanceof Filter + || $validator instanceof Order + || $validator instanceof Aggregate + || $validator instanceof GroupBy + ) + ) { + $validator->allowJoinAliases($joinAliases); + } + } + } + + if (! $this->isValidJoinCount($parsedQueries)) { + return false; + } + + $this->prepareAggregations($parsedQueries); + + // Same pass: nested and/or children must keep the join aliases collected above. + $pending = $parsedQueries; + while ($pending !== []) { + $query = \array_shift($pending); + + if ($query->isNested() && ! $this->isValidLength($query->getValues(), $query->getMethod())) { + return false; + } + + if (\in_array($query->getMethod(), Query::LOGICAL_TYPES, true)) { + foreach ($query->getValues() as $nested) { + if (! $nested instanceof Query) { + if (! \is_string($nested)) { + $this->message = 'Invalid query: nested query must be a string'; + + return false; + } + try { + $nested = Query::parse($nested); + } catch (Throwable $e) { + $this->message = 'Invalid query: '.$e->getMessage(); + + return false; + } + } + $pending[] = $nested; + } + } + + if ($hasFilterValidator && $query->getMethod()->isJoin() && $query->isNestedJoin()) { + foreach ($query->getJoinOnQueries() as $onQuery) { + if ($onQuery->getMethod() === Method::On) { + continue; + } + $pending[] = $onQuery; + } + } + + $method = $query->getMethod(); + + if ($method->isAggregate()) { + $methodType = QueryBase::METHOD_TYPE_AGGREGATE; + } else { + $methodType = match ($method) { + Method::Select => QueryBase::METHOD_TYPE_SELECT, + Method::Limit => QueryBase::METHOD_TYPE_LIMIT, + Method::Offset => QueryBase::METHOD_TYPE_OFFSET, + Method::CursorAfter, + Method::CursorBefore => QueryBase::METHOD_TYPE_CURSOR, + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom => QueryBase::METHOD_TYPE_ORDER, + Method::Equal, + Method::NotEqual, + Method::LessThan, + Method::LessThanEqual, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::Search, + Method::NotSearch, + Method::IsNull, + Method::IsNotNull, + Method::Between, + Method::NotBetween, + Method::StartsWith, + Method::NotStartsWith, + Method::EndsWith, + Method::NotEndsWith, + Method::Contains, + Method::ContainsAny, + Method::NotContains, + Method::And, + Method::Or, + Method::ContainsAll, + Method::ElemMatch, + Method::Crosses, + Method::NotCrosses, + Method::DistanceEqual, + Method::DistanceNotEqual, + Method::DistanceGreaterThan, + Method::DistanceLessThan, + Method::Intersects, + Method::NotIntersects, + Method::Overlaps, + Method::NotOverlaps, + Method::Touches, + Method::NotTouches, + Method::Covers, + Method::NotCovers, + Method::SpatialEquals, + Method::NotSpatialEquals, + Method::VectorDot, + Method::VectorCosine, + Method::VectorEuclidean, + Method::Regex, + Method::Exists, + Method::NotExists => QueryBase::METHOD_TYPE_FILTER, + Method::Distinct => QueryBase::METHOD_TYPE_DISTINCT, + Method::GroupBy => QueryBase::METHOD_TYPE_GROUP_BY, + Method::Having => QueryBase::METHOD_TYPE_HAVING, + Method::Join, + Method::LeftJoin, + Method::RightJoin, + Method::CrossJoin, + Method::FullOuterJoin, + Method::NaturalJoin => QueryBase::METHOD_TYPE_JOIN, + default => '', + }; + } + + $methodIsValid = false; + foreach ($this->validators as $validator) { + if ($validator->getMethodType() !== $methodType) { + continue; + } + if (! $validator->isValid($query)) { + $this->message = 'Invalid query: '.$validator->getDescription(); + + return false; + } + + $methodIsValid = true; + } + + if (! $methodIsValid) { + $this->message = 'Invalid query method: '.$method->value; + + return false; + } + } + + return true; + } + + /** + * The collection each join of the query set reads, for the joins whose collection is given. + * + * @param list $queries + * @return list + */ + private function joins(array $queries): array + { + $joins = []; + + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } + + $collection = $this->getJoinedCollection($query->getAttribute()); + if ($collection !== null) { + $joins[] = Collection::of($query->getAlias(), $collection); + } + } + + return $joins; + } + + #[\Override] + public function isArray(): bool + { + return true; + } + + #[\Override] + public function getType(): string + { + return self::TYPE_OBJECT; + } + + /** + * A group of queries, the whole set or the children of one nested query, may hold at most + * `length` queries. + * + * @param array $queries + */ + private function isValidLength(array $queries, ?Method $group = null): bool + { + if ($this->length === 0 || \count($queries) <= $this->length) { + return true; + } + + $this->message = $group === null + ? 'Too many queries: at most '.$this->length.' are allowed' + : 'Too many queries in '.$group->value.': at most '.$this->length.' are allowed'; + + return false; + } + + /** + * @param list $queries + */ + private function isValidJoinCount(array $queries): bool + { + $count = 0; + foreach ($queries as $query) { + if ($query->getMethod()->isJoin()) { + $count++; + } + } + + foreach ($this->validators as $validator) { + if ($validator instanceof Join && ! $validator->isValidCount($count)) { + $this->message = $validator->getDescription(); + + return false; + } + } + + return true; + } + + /** + * Hand each having, aggregate and select validator the aggregates and groupBy attributes of this + * query set, and each having validator the filter rules, replacing those of the previous one. + * Without an aggregate or having validator an aggregate is itself an invalid query, so the select + * validators are left alone. + * + * @param list $queries + */ + private function prepareAggregations(array $queries): void + { + $filter = null; + $having = []; + $aggregates = []; + $selects = []; + $orders = []; + foreach ($this->validators as $validator) { + if ($validator instanceof Filter) { + $filter ??= $validator; + } elseif ($validator instanceof Having) { + $having[] = $validator; + } elseif ($validator instanceof Aggregate) { + $aggregates[] = $validator; + } elseif ($validator instanceof Select) { + $selects[] = $validator; + } elseif ($validator instanceof Order) { + $orders[] = $validator; + } + } + + if ($having === [] && $aggregates === []) { + return; + } + + $aggregations = []; + $groupBy = []; + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method->isAggregate()) { + $aggregations[] = $query; + } elseif ($method === Method::GroupBy) { + \array_push($groupBy, ...\array_values($query->getValues())); + } + } + + foreach ($having as $validator) { + $validator->setFilter($filter); + $validator->setAggregations($aggregations); + $validator->setGroupBy($groupBy); + } + + foreach ($aggregates as $validator) { + $validator->setAggregations($aggregations); + $validator->setGroupBy($groupBy); + } + + foreach ([...$selects, ...$orders] as $validator) { + $validator->setAggregations($aggregations); + $validator->setGroupBy($groupBy); + } + } +} diff --git a/src/Database/Validator/Queries/Document.php b/src/Database/Validator/Queries/Document.php index 5907c50e79..69feeb8087 100644 --- a/src/Database/Validator/Queries/Document.php +++ b/src/Database/Validator/Queries/Document.php @@ -3,42 +3,109 @@ namespace Utopia\Database\Validator\Queries; use Exception; -use Utopia\Database\Database; -use Utopia\Database\Validator\Queries; +use Utopia\Database\Adapter\Profile; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Document as BaseDocument; +use Utopia\Database\Query; +use Utopia\Database\Validator\Query\Filter; +use Utopia\Database\Validator\Query\Join; use Utopia\Database\Validator\Query\Select; -class Document extends Queries +/** + * Validates queries for single document retrieval: selections of the document's attributes, and, + * unless turned off, joins whose conditions meet the filter rules a listing applies to them. + */ +class Document extends Base { /** - * @param array $attributes - * @param bool $supportForAttributes + * @var array + */ + private readonly array $attributes; + + private ?Base $conditions = null; + + /** + * @param array $attributes + * * @throws Exception */ - public function __construct(array $attributes, bool $supportForAttributes = true) + public function __construct( + array $attributes, + private readonly Profile $profile, + private readonly int $maxValuesCount = 5000, + ) { + $attributes = [ + ...\array_map( + static fn (Attribute|BaseDocument $attribute): Attribute => $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute), + $attributes, + ), + ...Documents::internalAttributes(), + ]; + + $this->attributes = $attributes; + + $supportForAttributes = $profile->supports(Capability::DefinedAttributes); + $validators = [new Select($attributes, $supportForAttributes, $profile->sharedTables)]; + + if ($profile->supports(Capability::Joins)) { + $validators[] = new Join($attributes, $supportForAttributes); + } + + parent::__construct($validators); + } + + /** + * Filters stay invalid at the top level of a document read, but the conditions of its joins + * are checked as a listing checks them. + * + * @param mixed $value + */ + #[\Override] + public function isValid(mixed $value): bool { - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$id', - 'key' => '$id', - 'type' => Database::VAR_STRING, - 'array' => false, - ]); - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$createdAt', - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, - ]); - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$updatedAt', - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, + if (! parent::isValid($value)) { + return false; + } + + /** @var array $value */ + $joins = []; + $nested = false; + foreach ($value as $query) { + $query = $query instanceof Query ? $query : Query::parse($query); + + if ($query->getMethod()->isJoin()) { + $joins[] = $query; + $nested = $nested || $query->isNestedJoin(); + } + } + + if (! $nested) { + return true; + } + + $supportForAttributes = $this->profile->supports(Capability::DefinedAttributes); + $limits = $this->profile->limits; + $conditions = $this->conditions ??= new Base([ + new Filter( + $this->attributes, + $limits->idType->value, + $this->maxValuesCount, + $limits->minDateTime, + $limits->maxDateTime, + $supportForAttributes, + $this->profile->supports(Capability::UnsignedBigInt), + ), + new Join($this->attributes, $supportForAttributes), ]); + $conditions->setJoinedCollections(\array_values($this->joinedCollections)); - $validators = [ - new Select($attributes, $supportForAttributes), - ]; + if (! $conditions->isValid($joins)) { + $this->message = $conditions->getDescription(); - parent::__construct($validators); + return false; + } + + return true; } } diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index 4959a062cf..469a5a1441 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -2,81 +2,106 @@ namespace Utopia\Database\Validator\Queries; -use Utopia\Database\Database; +use Utopia\Database\Adapter\Profile; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; use Utopia\Database\Document; -use Utopia\Database\Validator\IndexedQueries; +use Utopia\Database\Index; +use Utopia\Database\Validator\Query\Aggregate; use Utopia\Database\Validator\Query\Cursor; +use Utopia\Database\Validator\Query\Distinct; use Utopia\Database\Validator\Query\Filter; +use Utopia\Database\Validator\Query\GroupBy; +use Utopia\Database\Validator\Query\Having; +use Utopia\Database\Validator\Query\Join; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; use Utopia\Database\Validator\Query\Select; -class Documents extends IndexedQueries +/** + * Validates queries for document listing: filters, ordering, selection and pagination, plus joins + * and aggregations (aggregate functions, group by, having and distinct) when enabled. + */ +class Documents extends Indexed { /** - * @param array $attributes - * @param array $indexes - * @param string $idAttributeType - * @param int $maxValuesCount - * @param \DateTime $minAllowedDate - * @param \DateTime $maxAllowedDate - * @param bool $supportForAttributes + * @var list|null + */ + private static ?array $internalAttributes = null; + + /** + * @param array $attributes + * @param array $indexes + * * @throws \Utopia\Database\Exception */ public function __construct( array $attributes, array $indexes, - string $idAttributeType, + Profile $profile, int $maxValuesCount = 5000, - int $maxUIDLength = 36, - \DateTime $minAllowedDate = new \DateTime('0000-01-01'), - \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), - bool $supportForAttributes = true, - bool $supportUnsignedBigInt = true ) { - $attributes[] = new Document([ - '$id' => '$id', - 'key' => '$id', - 'type' => Database::VAR_STRING, - 'array' => false, - ]); - $attributes[] = new Document([ - '$id' => '$sequence', - 'key' => '$sequence', - 'type' => Database::VAR_ID, - 'array' => false, - ]); - $attributes[] = new Document([ - '$id' => '$createdAt', - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, - ]); - $attributes[] = new Document([ - '$id' => '$updatedAt', - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'array' => false, - ]); + $attributes = [ + ...\array_map( + static fn (Attribute|Document $attribute): Attribute => $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute), + $attributes, + ), + ...self::internalAttributes(), + ]; + + $supportForAttributes = $profile->supports(Capability::DefinedAttributes); + $limits = $profile->limits; $validators = [ new Limit(), new Offset(), - new Cursor($maxUIDLength), + new Cursor($limits->uidLength), new Filter( $attributes, - $idAttributeType, + $limits->idType->value, $maxValuesCount, - $minAllowedDate, - $maxAllowedDate, + $limits->minDateTime, + $limits->maxDateTime, $supportForAttributes, - $supportUnsignedBigInt + $profile->supports(Capability::UnsignedBigInt), ), - new Order($attributes, $supportForAttributes), - new Select($attributes, $supportForAttributes), + new Order($attributes, $supportForAttributes, $profile->supports(Capability::OrderRandom)), + new Select($attributes, $supportForAttributes, $profile->sharedTables), ]; + if ($profile->supports(Capability::Joins)) { + $validators[] = new Join($attributes, $supportForAttributes); + } + + if ($profile->supports(Capability::Aggregations)) { + \array_push( + $validators, + new Aggregate($attributes, $supportForAttributes, $profile->sharedTables), + new GroupBy($attributes, $supportForAttributes, $profile->sharedTables), + new Having(), + new Distinct(), + ); + } + parent::__construct($attributes, $indexes, $validators); } + + /** + * The attributes every collection holds besides its own that queries may name like the collection's + * attributes: the document id, sequence and timestamps. + * + * @internal + * + * @return list + */ + public static function internalAttributes(): array + { + return self::$internalAttributes ??= [ + Attribute::string(Document::ID), + Attribute::id(Document::SEQUENCE), + Attribute::datetime(Document::CREATED_AT), + Attribute::datetime(Document::UPDATED_AT), + ]; + } } diff --git a/src/Database/Validator/Queries/Indexed.php b/src/Database/Validator/Queries/Indexed.php new file mode 100644 index 0000000000..73a0aa4ffa --- /dev/null +++ b/src/Database/Validator/Queries/Indexed.php @@ -0,0 +1,204 @@ +|null + */ + private static ?array $internalIndexes = null; + + /** + * @var list + */ + protected array $attributes = []; + + /** + * @var list + */ + protected array $indexes = []; + + /** + * @param array $attributes + * @param array $indexes + * @param array $validators + * + * @throws Exception + */ + public function __construct(array $attributes = [], array $indexes = [], array $validators = []) + { + foreach ($attributes as $attribute) { + $this->attributes[] = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); + } + + $this->indexes = self::$internalIndexes ??= [ + Index::unique(self::UID_INDEX, [Document::ID]), + Index::key(self::CREATED_AT_INDEX, [Document::CREATED_AT]), + Index::key(self::UPDATED_AT_INDEX, [Document::UPDATED_AT]), + ]; + + foreach ($indexes as $index) { + $this->indexes[] = $index instanceof Index ? $index : Index::fromDocument($index); + } + + parent::__construct($validators); + } + + /** + * Count vector queries across entire query tree + * + * @param array $queries + */ + private function countVectorQueries(array $queries): int + { + $count = 0; + + foreach ($queries as $query) { + if (in_array($query->getMethod(), [Method::VectorDot, Method::VectorCosine, Method::VectorEuclidean])) { + $count++; + } + + if ($query->isNestedJoin()) { + $count += $this->countVectorQueries($query->getJoinOnQueries()); + } elseif ($query->isNested()) { + /** @var array $nestedValues */ + $nestedValues = $query->getValues(); + $count += $this->countVectorQueries($nestedValues); + } + } + + return $count; + } + + /** + * @param array $queries + * @return array> The indexes of the collection each join alias names + */ + private function joinIndexes(array $queries): array + { + $indexes = []; + + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } + + $collection = $this->getJoinedCollection($query->getAttribute()); + + $indexes[$query->getAlias()] = $collection === null ? [] : Collection::fromDocument($collection)->indexes(); + } + + return $indexes; + } + + /** + * @param mixed $value + * + * @throws Exception + */ + #[\Override] + public function isValid(mixed $value): bool + { + /** @var array $value */ + if (! parent::isValid($value)) { + return false; + } + $queries = []; + foreach ($value as $query) { + if (! $query instanceof Query) { + try { + $query = Query::parse((string) $query); + } catch (Throwable $e) { + $this->message = 'Invalid query: '.$e->getMessage(); + + return false; + } + } + + $queries[] = $query; + } + + $vectorQueryCount = $this->countVectorQueries($queries); + if ($vectorQueryCount > 1) { + $this->message = 'Cannot use multiple vector queries in a single request'; + + return false; + } + + return $this->validateSearchIndexes($queries, $this->joinIndexes($queries)); + } + + /** + * @param array $queries + * @param array> $joinIndexes + */ + private function validateSearchIndexes(array $queries, array $joinIndexes): bool + { + foreach ($queries as $query) { + if ( + $query->getMethod() === Method::Search || + $query->getMethod() === Method::NotSearch + ) { + $attribute = $query->getAttribute(); + $column = $attribute; + $indexes = $this->indexes; + + $dot = \strpos($attribute, '.'); + if ($dot !== false && isset($joinIndexes[\substr($attribute, 0, $dot)])) { + $column = \substr($attribute, $dot + 1); + $indexes = $joinIndexes[\substr($attribute, 0, $dot)]; + } + + $matched = false; + + foreach ($indexes as $index) { + if ( + $index->type === IndexType::Fulltext + && $index->attributes === [$column] + ) { + $matched = true; + } + } + + if (! $matched) { + $this->message = "Searching by attribute \"{$attribute}\" requires a fulltext index."; + + return false; + } + } + + if ($query->isNestedJoin()) { + if (! $this->validateSearchIndexes($query->getJoinOnQueries(), $joinIndexes)) { + return false; + } + } elseif ($query->isNested()) { + /** @var array $nested */ + $nested = $query->getValues(); + if (! $this->validateSearchIndexes($nested, $joinIndexes)) { + return false; + } + } + } + + return true; + } +} diff --git a/src/Database/Validator/Queries/Narrow.php b/src/Database/Validator/Queries/Narrow.php new file mode 100644 index 0000000000..f5db2719d2 --- /dev/null +++ b/src/Database/Validator/Queries/Narrow.php @@ -0,0 +1,228 @@ +value => QueryBase::METHOD_TYPE_FILTER, + Method::NotEqual->value => QueryBase::METHOD_TYPE_FILTER, + Method::LessThan->value => QueryBase::METHOD_TYPE_FILTER, + Method::LessThanEqual->value => QueryBase::METHOD_TYPE_FILTER, + Method::GreaterThan->value => QueryBase::METHOD_TYPE_FILTER, + Method::GreaterThanEqual->value => QueryBase::METHOD_TYPE_FILTER, + Method::Between->value => QueryBase::METHOD_TYPE_FILTER, + Method::NotBetween->value => QueryBase::METHOD_TYPE_FILTER, + Method::StartsWith->value => QueryBase::METHOD_TYPE_FILTER, + Method::NotStartsWith->value => QueryBase::METHOD_TYPE_FILTER, + Method::EndsWith->value => QueryBase::METHOD_TYPE_FILTER, + Method::NotEndsWith->value => QueryBase::METHOD_TYPE_FILTER, + Method::Contains->value => QueryBase::METHOD_TYPE_FILTER, + Method::ContainsAny->value => QueryBase::METHOD_TYPE_FILTER, + Method::ContainsAll->value => QueryBase::METHOD_TYPE_FILTER, + Method::NotContains->value => QueryBase::METHOD_TYPE_FILTER, + Method::IsNull->value => QueryBase::METHOD_TYPE_FILTER, + Method::IsNotNull->value => QueryBase::METHOD_TYPE_FILTER, + Method::Regex->value => QueryBase::METHOD_TYPE_FILTER, + Method::Limit->value => QueryBase::METHOD_TYPE_LIMIT, + Method::Offset->value => QueryBase::METHOD_TYPE_OFFSET, + Method::CursorAfter->value => QueryBase::METHOD_TYPE_CURSOR, + Method::CursorBefore->value => QueryBase::METHOD_TYPE_CURSOR, + Method::OrderAsc->value => QueryBase::METHOD_TYPE_ORDER, + Method::OrderDesc->value => QueryBase::METHOD_TYPE_ORDER, + Method::OrderRandom->value => QueryBase::METHOD_TYPE_ORDER, + ]; + + /** + * @var array + */ + private array $byType = []; + + /** + * @param array $validators + */ + private function __construct(array $validators) + { + parent::__construct($validators); + + foreach ($validators as $validator) { + $this->byType[$validator->getMethodType()] = $validator; + } + } + + /** + * A narrow list reaches none of the alias, join or aggregation handling of a query list and + * holds no nested query, so each query only goes to the validator of its method type, in order, + * once the order validator has dropped what an earlier list registered: of that, only joins + * and aggregate aliases reach an undotted attribute. Any other value is checked as by any query + * list validator. + * + * @param mixed $value + */ + #[\Override] + public function isValid(mixed $value): bool + { + if (! \is_array($value) || ! self::accepts($value)) { + return parent::isValid($value); + } + + $order = $this->byType[QueryBase::METHOD_TYPE_ORDER] ?? null; + if ($order instanceof Order) { + $order->resetAggregationAliases(); + $order->resetJoinAliases(); + } + + /** @var array $value */ + foreach ($value as $query) { + $method = $query->getMethod(); + $validator = $this->byType[self::METHOD_TYPES[$method->value] ?? ''] ?? null; + + if ($validator === null) { + $this->message = 'Invalid query method: '.$method->value; + + return false; + } + + if (! $validator->isValid($query)) { + $this->message = 'Invalid query: '.$validator->getDescription(); + + return false; + } + } + + return true; + } + + /** + * Whether the list is narrow: every value a Query of a method listed here, every filter and + * every attribute order on an attribute without a dot. + * + * @param array $queries + */ + public static function accepts(array $queries): bool + { + foreach ($queries as $query) { + if (self::methodType($query) === null) { + return false; + } + } + + return true; + } + + /** + * The validator of a narrow query list, or null when the list is not narrow. + * + * @param array $queries + * @param list $attributes The collection's attributes + */ + public static function of( + array $queries, + array $attributes, + Profile $profile, + int $maxValuesCount, + ): ?self { + $types = []; + $filtered = []; + $ordered = []; + foreach ($queries as $query) { + $type = self::methodType($query); + if ($type === null || ! $query instanceof Query) { + return null; + } + $types[$type] = true; + + if ($type === QueryBase::METHOD_TYPE_FILTER) { + $filtered[$query->getAttribute()] = true; + } elseif ($type === QueryBase::METHOD_TYPE_ORDER && $query->getMethod() !== Method::OrderRandom) { + $ordered[$query->getAttribute()] = true; + } + } + + $filterAttributes = []; + $orderAttributes = []; + if ($filtered !== [] || $ordered !== []) { + foreach ([...$attributes, ...Documents::internalAttributes()] as $attribute) { + if (isset($filtered[$attribute->key])) { + $filterAttributes[] = $attribute; + } + if (isset($ordered[$attribute->key])) { + $orderAttributes[] = $attribute; + } + } + } + + $supportForAttributes = $profile->supports(Capability::DefinedAttributes); + $limits = $profile->limits; + $validators = []; + if (isset($types[QueryBase::METHOD_TYPE_LIMIT])) { + $validators[] = new Limit(); + } + if (isset($types[QueryBase::METHOD_TYPE_OFFSET])) { + $validators[] = new Offset(); + } + if (isset($types[QueryBase::METHOD_TYPE_CURSOR])) { + $validators[] = new Cursor($limits->uidLength); + } + if (isset($types[QueryBase::METHOD_TYPE_FILTER])) { + $validators[] = new Filter( + $filterAttributes, + $limits->idType->value, + $maxValuesCount, + $limits->minDateTime, + $limits->maxDateTime, + $supportForAttributes, + $profile->supports(Capability::UnsignedBigInt), + ); + } + if (isset($types[QueryBase::METHOD_TYPE_ORDER])) { + $validators[] = new Order($orderAttributes, $supportForAttributes, $profile->supports(Capability::OrderRandom)); + } + + return new self($validators); + } + + private static function methodType(mixed $query): ?string + { + if (! $query instanceof Query) { + return null; + } + + $method = $query->getMethod(); + $type = self::METHOD_TYPES[$method->value] ?? null; + if ($type === null) { + return null; + } + + if (($type === QueryBase::METHOD_TYPE_FILTER || $method === Method::OrderAsc || $method === Method::OrderDesc) && \str_contains($query->getAttribute(), '.')) { + return null; + } + + return $type; + } +} diff --git a/src/Database/Validator/Query/Aggregate.php b/src/Database/Validator/Query/Aggregate.php new file mode 100644 index 0000000000..c64b5ea538 --- /dev/null +++ b/src/Database/Validator/Query/Aggregate.php @@ -0,0 +1,329 @@ + + */ + protected array $schema = []; + + /** + * The type of each attribute that holds a single number. + * + * @var array + */ + protected array $numeric = []; + + /** + * The attributes whose values min() and max() cannot order: arrays and the unordered types. + * + * @var array + */ + protected array $unordered = []; + + /** + * Every attribute of the collection, and whether it holds a column. + * + * @var array + */ + protected array $columns = []; + + /** + * How many aggregates of the query set carry each alias. + * + * @var array + */ + protected array $aliases = []; + + /** + * The attribute each group of the query set is returned under, keyed by the name it takes. + * + * @var array + */ + protected array $groups = []; + + /** + * @param array $attributes + * @param bool $sharedTables Whether the tables hold `$tenant`, as they do under shared tables + */ + public function __construct(array $attributes = [], protected bool $supportForAttributes = true, bool $sharedTables = false) + { + $attributes = \array_map( + static fn (Attribute|Document $attribute): Attribute => $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute), + $attributes, + ); + + foreach ($attributes as $attribute) { + $this->schema[$attribute->key] = true; + + if (! $attribute->array && $attribute->isNumeric()) { + $this->numeric[$attribute->key] = $attribute->type; + } + + if (! self::isOrdered($attribute->type, $attribute->array)) { + $this->unordered[$attribute->key] = true; + } + } + + $this->schema += self::internalColumns($sharedTables); + $this->columns = Collection::columns($attributes); + } + + /** + * The aggregates of the query set: an alias names one column of the result, so no two of them + * can share it. + * + * @param array $aggregations + */ + public function setAggregations(array $aggregations): void + { + $this->aliases = []; + + foreach ($aggregations as $aggregation) { + $alias = $aggregation->getAlias(); + if ($alias !== '') { + $this->aliases[$alias] = ($this->aliases[$alias] ?? 0) + 1; + } + } + } + + /** + * The attributes the query set groups by. Each group comes back under its column's name, the + * attribute's own name or, for an internal attribute, its column, so an alias cannot take it. + * + * @param array $attributes + */ + public function setGroupBy(array $attributes): void + { + $this->groups = []; + + foreach ($attributes as $attribute) { + if (! \is_string($attribute) || $attribute === '') { + continue; + } + + $dot = \strpos($attribute, '.'); + $name = $dot === false ? $attribute : \substr($attribute, $dot + 1); + $this->groups[$name] ??= $attribute; + $this->groups[Storage::column($name)] ??= $attribute; + } + } + + #[\Override] + public function getMethodType(): string + { + return self::METHOD_TYPE_AGGREGATE; + } + + #[\Override] + protected function isValidQuery(Query $query): bool + { + $attribute = $query->getAttribute(); + + if ($attribute === '*' && $query->getMethod() !== Method::Count) { + $this->message = 'Only count can aggregate "*"'; + + return false; + } + + if ( + $attribute !== '*' + && $this->supportForAttributes + && ! isset($this->schema[$attribute]) + && ! $this->isJoinedAttribute($attribute) + ) { + return false; + } + + if (($this->columns[$attribute] ?? true) === false) { + $this->message = 'Cannot aggregate virtual relationship attribute: '.$attribute; + + return false; + } + + if (! $this->isValidOperand($query) || ! $this->isValidExtremum($query)) { + return false; + } + + $alias = $query->getAlias(); + if ($alias === '') { + return true; + } + + if (\preg_match(self::ALIAS_PATTERN, $alias) !== 1) { + $this->message = 'Invalid aggregate alias'; + + return false; + } + + if (\strlen($alias) > self::MAX_ALIAS_LENGTH) { + $this->message = 'Aggregate alias is too long: at most '.self::MAX_ALIAS_LENGTH.' characters are allowed'; + + return false; + } + + if (($this->aliases[$alias] ?? 0) > 1) { + $this->message = 'Aggregate alias "'.$alias.'" is given to more than one aggregate'; + + return false; + } + + if (isset($this->groups[$alias])) { + $this->message = 'Aggregate alias "'.$alias.'" is the name the groupBy attribute "'.$this->groups[$alias].'" is returned under'; + + return false; + } + + return true; + } + + /** + * Arithmetic aggregates need a number and the bitwise ones an integer, as the collection the + * attribute resolves to declares it: this one, or the join it names. An attribute that + * collection does not declare, a schemaless one or one of a join whose collection is unknown, + * has no type to check here. + */ + private function isValidOperand(Query $query): bool + { + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + $bitwise = \in_array($method, self::BITWISE_METHODS, true); + + if (! $bitwise && ! \in_array($method, self::NUMERIC_METHODS, true)) { + return true; + } + + if (isset($this->schema[$attribute])) { + $type = $this->numeric[$attribute] ?? null; + } else { + $join = $this->joinOf($attribute); + $dot = \strpos($attribute, '.'); + $column = $dot === false ? $attribute : \substr($attribute, $dot + 1); + + if ($join !== null && isset($join->attributes[$column])) { + $type = $join->numeric[$column] ?? null; + } elseif ($join !== null && $this->isJoinedInternalAttribute($column)) { + $type = $this->numeric[$column] ?? null; + } else { + return true; + } + } + + if ($type === null) { + $this->message = 'Aggregate '.$method->value.' requires a numeric attribute that is not an array: '.$attribute; + + return false; + } + + if ($bitwise && $type !== ColumnType::Integer && $type !== ColumnType::BigInteger) { + $this->message = 'Aggregate '.$method->value.' requires an integer attribute that is not an array: '.$attribute; + + return false; + } + + return true; + } + + /** + * min() and max() need values the engine can order, as the collection the attribute resolves to + * declares them. An attribute with no known definition has no type to check here. + */ + private function isValidExtremum(Query $query): bool + { + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + + if (! \in_array($method, self::EXTREMUM_METHODS, true)) { + return true; + } + + if (isset($this->schema[$attribute])) { + $ordered = ! isset($this->unordered[$attribute]); + } else { + $join = $this->joinOf($attribute); + $dot = \strpos($attribute, '.'); + $column = $dot === false ? $attribute : \substr($attribute, $dot + 1); + $definition = $join?->schema[$column] ?? null; + + if ($definition === null) { + return true; + } + + $type = $definition['type'] ?? null; + $ordered = self::isOrdered($type instanceof ColumnType ? $type : null, (bool) ($definition['array'] ?? false)); + } + + if (! $ordered) { + $this->message = 'Aggregate '.$method->value.' requires an attribute whose values are ordered, not an array, object, boolean, spatial or vector one: '.$attribute; + + return false; + } + + return true; + } + + private static function isOrdered(?ColumnType $type, bool $array): bool + { + return ! $array && ! \in_array($type, self::UNORDERED_TYPES, true); + } + + #[\Override] + protected function acceptsMainAttribute(string $attribute): bool + { + return isset($this->schema[$attribute]); + } +} diff --git a/src/Database/Validator/Query/Base.php b/src/Database/Validator/Query/Base.php index a37fdd65a9..50c74cbdd4 100644 --- a/src/Database/Validator/Query/Base.php +++ b/src/Database/Validator/Query/Base.php @@ -2,57 +2,94 @@ namespace Utopia\Database\Validator\Query; +use Utopia\Database\Query; use Utopia\Validator; +/** + * Abstract base class for query method validators, providing shared constants and common methods. + */ abstract class Base extends Validator { - public const METHOD_TYPE_LIMIT = 'limit'; - public const METHOD_TYPE_OFFSET = 'offset'; - public const METHOD_TYPE_CURSOR = 'cursor'; - public const METHOD_TYPE_ORDER = 'order'; - public const METHOD_TYPE_FILTER = 'filter'; - public const METHOD_TYPE_SELECT = 'select'; + public const string METHOD_TYPE_LIMIT = 'limit'; + + public const string METHOD_TYPE_OFFSET = 'offset'; + + public const string METHOD_TYPE_CURSOR = 'cursor'; + + public const string METHOD_TYPE_ORDER = 'order'; + + public const string METHOD_TYPE_FILTER = 'filter'; + + public const string METHOD_TYPE_SELECT = 'select'; + + public const string METHOD_TYPE_JOIN = 'join'; + + public const string METHOD_TYPE_AGGREGATE = 'aggregate'; + + public const string METHOD_TYPE_GROUP_BY = 'groupBy'; + + public const string METHOD_TYPE_HAVING = 'having'; + + public const string METHOD_TYPE_DISTINCT = 'distinct'; protected string $message = 'Invalid query'; - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ + #[\Override] public function getDescription(): string { return $this->message; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } + #[\Override] + public function getType(): string + { + return self::TYPE_OBJECT; + } + /** - * Get Type + * Rejects anything that is not a Query, then defers to the subclass rule. * - * Returns validator type. + * Subclasses that validate a Query further override isValidQuery(), not + * this, so the not-a-Query message stays the same for every method that + * uses it. * - * @return string + * @param mixed $value */ - public function getType(): string + #[\Override] + public function isValid(mixed $value): bool { - return self::TYPE_OBJECT; + if (! $value instanceof Query) { + $this->message = 'Value must be a Query'; + + return false; + } + + return $this->isValidQuery($value); } /** - * Returns what type of query this Validator is for + * Validate a Query beyond its type. A method with no further rule inherits this. */ + protected function isValidQuery(Query $query): bool + { + return true; + } + + /** + * The column half of an `alias.column` reference must be a single plain + * identifier, so a dotted path cannot smuggle extra segments past the + * alias check. + */ + protected function isAllowedJoinColumn(string $column): bool + { + return $column !== '' && \preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $column) === 1; + } + abstract public function getMethodType(): string; } diff --git a/src/Database/Validator/Query/Cursor.php b/src/Database/Validator/Query/Cursor.php index 58053fe606..b620370034 100644 --- a/src/Database/Validator/Query/Cursor.php +++ b/src/Database/Validator/Query/Cursor.php @@ -6,35 +6,41 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\UID; +use Utopia\Query\Method; class Cursor extends Base { + /** + * @param int $maxLength Maximum allowed UID length for cursor values + */ public function __construct(private readonly int $maxLength = Database::MAX_UID_DEFAULT_LENGTH) { } /** - * Is valid. - * - * Returns true if method is cursorBefore or cursorAfter and value is not null + * A cursorAfter or cursorBefore query whose value is a document ID, or a document holding one. A document + * without an ID is a row a join or a distinct read returned; the read decides whether its values name a row. + * Any other value, an array included, is refused. * - * Otherwise, returns false - * - * @param Query $value - * @return bool + * @param mixed $value */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!$value instanceof Query) { + if (! $value instanceof Query) { return false; } $method = $value->getMethod(); - if ($method === Query::TYPE_CURSOR_AFTER || $method === Query::TYPE_CURSOR_BEFORE) { + if ($method === Method::CursorAfter || $method === Method::CursorBefore) { $cursor = $value->getValue(); if ($cursor instanceof Document) { + if ($cursor->getId() === '') { + return true; + } + $cursor = $cursor->getId(); } @@ -42,13 +48,15 @@ public function isValid($value): bool if ($validator->isValid($cursor)) { return true; } - $this->message = 'Invalid cursor: ' . $validator->getDescription(); + $this->message = 'Invalid cursor: '.$validator->getDescription(); + return false; } return false; } + #[\Override] public function getMethodType(): string { return self::METHOD_TYPE_CURSOR; diff --git a/src/Database/Validator/Query/Distinct.php b/src/Database/Validator/Query/Distinct.php new file mode 100644 index 0000000000..06a523aeba --- /dev/null +++ b/src/Database/Validator/Query/Distinct.php @@ -0,0 +1,12 @@ + */ protected array $schema = []; /** - * @param array $attributes - * @param int $maxValuesCount - * @param \DateTime $minAllowedDate - * @param \DateTime $maxAllowedDate + * @param array $attributes */ public function __construct( array $attributes, private readonly string $idAttributeType, private readonly int $maxValuesCount = 5000, - private readonly \DateTime $minAllowedDate = new \DateTime('0000-01-01'), - private readonly \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), - private readonly bool $supportForAttributes = true, + private readonly DateTime $minAllowedDate = new DateTime('0000-01-01'), + private readonly DateTime $maxAllowedDate = new DateTime('9999-12-31'), + private bool $supportForAttributes = true, private readonly bool $supportUnsignedBigInt = true ) { foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getId())] = $attribute->getArrayCopy(); + $attribute = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); + $this->schema[$attribute->key] = ['type' => $attribute->type] + $attribute->toDocument()->getArrayCopy(); } } - /** - * @param string $attribute - * @return bool - */ protected function isValidAttribute(string $attribute): bool { + /** @var array $attributeSchema */ + $attributeSchema = $this->schema[$attribute] ?? []; + /** @var array $filters */ + $filters = $attributeSchema['filters'] ?? []; if ( - \in_array('encrypt', $this->schema[$attribute]['filters'] ?? []) + \in_array('encrypt', $filters) ) { - $this->message = 'Cannot query encrypted attribute: ' . $attribute; + $this->message = 'Cannot query encrypted attribute: '.$attribute; + return false; } - if (\str_contains($attribute, '.')) { - // Check for special symbol `.` + $dot = \strpos($attribute, '.'); + if ($dot !== false) { if (isset($this->schema[$attribute])) { return true; } + $alias = \substr($attribute, 0, $dot); + $column = \substr($attribute, $dot + 1); + + if ($this->isJoinColumnReference($alias, $column)) { + if (isset($this->joinsByAlias[$alias]->encrypted[$column])) { + $this->message = 'Cannot query encrypted attribute: '.$attribute; + + return false; + } + + return ! $this->supportForAttributes || $this->isJoinedColumn($alias, $column); + } + + if (! $this->isValidObjectPath($alias, $attribute)) { + return false; + } + // For relationships, just validate the top level. // will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; + $attribute = $alias; + } + + if ($this->supportForAttributes && ! isset($this->schema[$attribute])) { + $this->message = 'Attribute not found in schema: '.$attribute; + + return false; + } + + return true; + } + + private function isValidObjectPath(string $base, string $path): bool + { + /** @var array $definition */ + $definition = $this->schema[$base] ?? []; + if (($definition['type'] ?? null) !== ColumnType::Object) { + return true; } - // Search for attribute in schema - if ($this->supportForAttributes && !isset($this->schema[$attribute])) { - $this->message = 'Attribute not found in schema: ' . $attribute; + $validator = new ObjectPath(); + if (! $validator->isValid($path)) { + $this->message = 'Invalid object path "'.$path.'": '.$validator->getDescription(); + return false; } @@ -74,231 +117,270 @@ protected function isValidAttribute(string $attribute): bool } /** - * @param string $attribute - * @param array $values - * @param string $method - * @return bool + * @param array $values */ - protected function isValidAttributeAndValues(string $attribute, array $values, string $method): bool + protected function isValidAttributeAndValues(string $attribute, array $values, Method $method): bool { - if (!$this->isValidAttribute($attribute)) { + if (! $this->isValidAttribute($attribute)) { return false; } $originalAttribute = $attribute; - // isset check if for special symbols "." in the attribute name - // same for nested path on object - if (\str_contains($attribute, '.') && !isset($this->schema[$attribute])) { - // For relationships, just validate the top level. - // Utopia will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; - } + /** @var array|null $attributeSchema */ + $attributeSchema = null; + $joined = false; + $dot = \strpos($attribute, '.'); + if ($dot !== false && ! isset($this->schema[$attribute])) { + $alias = \substr($attribute, 0, $dot); + $column = \substr($attribute, $dot + 1); + + if ($this->isJoinColumnReference($alias, $column)) { + if ($method->isVector()) { + $this->message = 'Vector queries cannot be used on a joined attribute: '.$attribute; - // exists and notExists queries don't require values, just attribute validation - if (in_array($method, [Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS])) { - // Validate attribute (handles encrypted attributes, schemaless mode, etc.) - return $this->isValidAttribute($attribute); - } + return false; + } - if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { - // First check maxValuesCount guard for any IN-style value arrays - if (count($values) > $this->maxValuesCount) { - $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; - return false; + $joined = true; + $attributeSchema = $this->joinedSchema($alias, $column); + + if ($attributeSchema === null) { + if (count($values) > $this->maxValuesCount) { + $this->message = 'Query on attribute has greater than '.$this->maxValuesCount.' values: '.$attribute; + + return false; + } + + return true; + } + } else { + // For relationships, just validate the top level. + // Utopia will validate each nested level during the recursive calls. + $attribute = $alias; } + } - return true; + if ($attributeSchema === null) { + if (! $this->supportForAttributes && ! isset($this->schema[$attribute])) { + if (count($values) > $this->maxValuesCount) { + $this->message = 'Query on attribute has greater than '.$this->maxValuesCount.' values: '.$attribute; + + return false; + } + + return true; + } + /** @var array $attributeSchema */ + $attributeSchema = $this->schema[$attribute]; } - $attributeSchema = $this->schema[$attribute]; // Skip value validation for nested relationship queries (e.g., author.age) // The values will be validated when querying the related collection - if ($attributeSchema['type'] === Database::VAR_RELATIONSHIP && $originalAttribute !== $attribute) { + /** @var ColumnType|null $schemaType */ + $schemaType = $attributeSchema['type'] ?? null; + if ($schemaType === ColumnType::Relationship && $originalAttribute !== $attribute) { return true; } if (count($values) > $this->maxValuesCount) { - $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; - return false; - } + $this->message = 'Query on attribute has greater than '.$this->maxValuesCount.' values: '.$attribute; - if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { - return true; + return false; } - $attributeSchema = $this->schema[$attribute]; - $attributeType = $attributeSchema['type']; + /** @var ColumnType|null $attributeType */ + $attributeType = $attributeSchema['type'] ?? null; - $isDottedOnObject = \str_contains($originalAttribute, '.') && $attributeType === Database::VAR_OBJECT; + $isDottedOnObject = ! $joined && \str_contains($originalAttribute, '.') && $attributeType === ColumnType::Object; - // If the query method is spatial-only, the attribute must be a spatial type $query = new Query($method); - if ($query->isSpatialQuery() && !in_array($attributeType, Database::SPATIAL_TYPES, true)) { - $this->message = 'Spatial query "' . $method . '" cannot be applied on non-spatial attribute: ' . $attribute; + if ($query->isSpatialQuery() && ! in_array($attributeType, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + $this->message = 'Spatial query "'.$method->value.'" cannot be applied on non-spatial attribute: '.$attribute; + return false; } - foreach ($values as $value) { - $validator = null; - + // The validator depends only on the column's declared type, which does + // not change between values, so build it once instead of once per + // value. Object containment, spatial and vector checks read the value + // itself and stay in the loop below. + // + // Guarded on a non-empty value list because the unknown-type refusal + // used to sit inside the loop, so a query with no values never reached + // it. Hoisting it unguarded would start rejecting those. + $validator = null; + + if ($values !== []) { switch ($attributeType) { - case Database::VAR_ID: - $validator = new Sequence($this->idAttributeType, $attribute === '$sequence'); + case ColumnType::Id: + $validator = new Sequence($this->idAttributeType, $attribute === Document::SEQUENCE); break; - case Database::VAR_STRING: - case Database::VAR_VARCHAR: - case Database::VAR_TEXT: - case Database::VAR_MEDIUMTEXT: - case Database::VAR_LONGTEXT: + case ColumnType::String: + case ColumnType::Varchar: + case ColumnType::Text: + case ColumnType::MediumText: + case ColumnType::LongText: $validator = new Text(0, 0); break; - case Database::VAR_INTEGER: + case ColumnType::Integer: + /** @var int $size */ $size = $attributeSchema['size'] ?? 4; + /** @var bool $signed */ $signed = $attributeSchema['signed'] ?? true; $bits = $size >= 8 ? 64 : 32; // For 64-bit unsigned, use signed since PHP doesn't support true 64-bit unsigned - $unsigned = !$signed && $bits < 64; + $unsigned = ! $signed && $bits < 64; $validator = new Integer(false, $bits, $unsigned); break; - case Database::VAR_BIGINT: + case ColumnType::BigInteger: + /** @var bool $signed */ $signed = $attributeSchema['signed'] ?? true; $validator = new BigInt($signed, $this->supportUnsignedBigInt); break; - case Database::VAR_FLOAT: + case ColumnType::Float: + case ColumnType::Double: $validator = new FloatValidator(); break; - case Database::VAR_BOOLEAN: + case ColumnType::Boolean: $validator = new Boolean(); break; - case Database::VAR_DATETIME: + case ColumnType::Datetime: $validator = new DatetimeValidator( min: $this->minAllowedDate, max: $this->maxAllowedDate ); break; - case Database::VAR_RELATIONSHIP: + case ColumnType::Relationship: $validator = new Text(255, 0); // The query is always on uid break; - case Database::VAR_OBJECT: + case ColumnType::Object: // For dotted attributes on objects, validate as string (path queries) if ($isDottedOnObject) { $validator = new Text(0, 0); + } + break; + + case ColumnType::Point: + case ColumnType::Linestring: + case ColumnType::Polygon: + case ColumnType::Vector: + break; + + default: + $this->message = 'Unknown Data type'; + + return false; + } + } + + foreach ($values as $value) { + switch ($attributeType) { + case ColumnType::Object: + if ($isDottedOnObject) { break; } // object containment queries on the base object attribute - elseif (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS], true) - && !$this->isValidObjectQueryValues($value)) { - $this->message = 'Invalid object query structure for attribute "' . $attribute . '"'; + if (\in_array($method, [Method::Equal, Method::NotEqual, Method::Contains, Method::ContainsAny, Method::ContainsAll, Method::NotContains], true) + && ! $this->isValidObjectQueryValues($value)) { + $this->message = 'Invalid object query structure for attribute "'.$attribute.'"'; + return false; } continue 2; - case Database::VAR_POINT: - case Database::VAR_LINESTRING: - case Database::VAR_POLYGON: - if (!is_array($value)) { + + case ColumnType::Point: + case ColumnType::Linestring: + case ColumnType::Polygon: + if (! is_array($value)) { $this->message = 'Spatial data must be an array'; + return false; } + continue 2; - case Database::VAR_VECTOR: - // For vector queries, validate that the value is an array of floats - if (!is_array($value)) { + case ColumnType::Vector: + if (! is_array($value)) { $this->message = 'Vector query value must be an array'; + return false; } foreach ($value as $component) { - if (!is_numeric($component)) { + if (! is_numeric($component)) { $this->message = 'Vector query value must contain only numeric values'; + return false; } } - // Check size match + /** @var int $expectedSize */ $expectedSize = $attributeSchema['size'] ?? 0; if (count($value) !== $expectedSize) { $this->message = "Vector query value must have {$expectedSize} elements"; + return false; } - continue 2; - default: - $this->message = 'Unknown Data type'; - return false; - } - if (!$validator->isValid($value)) { - $this->message = 'Query value is invalid for attribute "' . $attribute . '"'; - return false; + continue 2; } - } - if ($attributeSchema['type'] === 'relationship') { - /** - * We can not disable relationship query since we have logic that use it, - * so instead we validate against the relation type - */ - $options = $attributeSchema['options']; - - if ($options['relationType'] === Database::RELATION_ONE_TO_ONE && $options['twoWay'] === false && $options['side'] === Database::RELATION_SIDE_CHILD) { - $this->message = 'Cannot query on virtual relationship attribute'; - return false; - } + if ($validator !== null && ! $validator->isValid($value)) { + $this->message = 'Query value is invalid for attribute "'.$attribute.'"'; - if ($options['relationType'] === Database::RELATION_ONE_TO_MANY && $options['side'] === Database::RELATION_SIDE_PARENT) { - $this->message = 'Cannot query on virtual relationship attribute'; return false; } + } - if ($options['relationType'] === Database::RELATION_MANY_TO_ONE && $options['side'] === Database::RELATION_SIDE_CHILD) { - $this->message = 'Cannot query on virtual relationship attribute'; - return false; - } + if ($attributeType === ColumnType::Relationship && \in_array(false, Collection::columns([new Document($attributeSchema)]), true)) { + $this->message = 'Cannot query on virtual relationship attribute'; - if ($options['relationType'] === Database::RELATION_MANY_TO_MANY) { - $this->message = 'Cannot query on virtual relationship attribute'; - return false; - } + return false; } + /** @var bool $array */ $array = $attributeSchema['array'] ?? false; if ( - !$array && - in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS]) && - !in_array($attributeSchema['type'], Database::STRING_TYPES) && - $attributeSchema['type'] !== Database::VAR_OBJECT && - !in_array($attributeSchema['type'], Database::SPATIAL_TYPES) + ! $array && + in_array($method, [Method::Contains, Method::ContainsAny, Method::ContainsAll, Method::NotContains]) && + ! in_array($attributeType, [ColumnType::String, ColumnType::Varchar, ColumnType::Text, ColumnType::MediumText, ColumnType::LongText]) && + $attributeType !== ColumnType::Object && + ! in_array($attributeType, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon]) ) { - $queryType = $method === Query::TYPE_NOT_CONTAINS ? 'notContains' : 'contains'; - $this->message = 'Cannot query ' . $queryType . ' on attribute "' . $attribute . '" because it is not an array, string, or object.'; + $queryType = $method === Method::NotContains ? 'notContains' : 'contains'; + $this->message = 'Cannot query '.$queryType.' on attribute "'.$attribute.'" because it is not an array, string, or object.'; + return false; } if ( $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS]) + ! in_array($method, [Method::Contains, Method::ContainsAny, Method::ContainsAll, Method::NotContains, Method::IsNull, Method::IsNotNull, Method::Exists, Method::NotExists]) ) { - $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; + $this->message = 'Cannot query '.$method->value.' on attribute "'.$attribute.'" because it is an array.'; + return false; } // Vector queries can only be used on vector attributes (not arrays) - if (\in_array($method, Query::VECTOR_TYPES)) { - if ($attributeSchema['type'] !== Database::VAR_VECTOR) { + if (\in_array($method, [Method::VectorDot, Method::VectorCosine, Method::VectorEuclidean])) { + if ($attributeType !== ColumnType::Vector) { $this->message = 'Vector queries can only be used on vector attributes'; + return false; } if ($array) { $this->message = 'Vector queries cannot be used on array attributes'; + return false; } } @@ -307,8 +389,27 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s } /** - * @param array $values - * @return bool + * The definition a joined column is checked against: the joined collection's own, or the main + * collection's for an internal attribute, which every collection declares alike. Null when the + * joined collection is unknown to the validator, as under a bare alias or without attributes. + * + * @return array|null + */ + private function joinedSchema(string $alias, string $column): ?array + { + /** @var array|null $schema */ + $schema = isset($this->joinsByAlias[$alias]) ? ($this->joinsByAlias[$alias]->schema[$column] ?? null) : null; + + if ($schema === null && \str_starts_with($column, '$')) { + /** @var array|null $schema */ + $schema = $this->schema[$column] ?? null; + } + + return $schema; + } + + /** + * @param array $values */ protected function isEmpty(array $values): bool { @@ -333,13 +434,10 @@ protected function isEmpty(array $values): bool * ['a' => [1, 2], 'b' => [212]] // multiple top-level paths * ['projects' => [[...]]] // list of objects * ['role' => ['name' => [...], 'ex' => [...]]] // multiple nested paths - * - * @param mixed $values - * @return bool */ private function isValidObjectQueryValues(mixed $values): bool { - if (!is_array($values)) { + if (! is_array($values)) { return true; } @@ -359,7 +457,7 @@ private function isValidObjectQueryValues(mixed $values): bool } foreach ($values as $value) { - if (!$this->isValidObjectQueryValues($value)) { + if (! $this->isValidObjectQueryValues($value)) { return false; } } @@ -368,151 +466,221 @@ private function isValidObjectQueryValues(mixed $values): bool } /** - * Is valid. - * - * Returns true if method is a filter method, attribute exists, and value matches attribute type - * - * Otherwise, returns false - * - * @param Query $value - * @return bool + * @param Query $value */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { $method = $value->getMethod(); $attribute = $value->getAttribute(); switch ($method) { - case Query::TYPE_EQUAL: - case Query::TYPE_CONTAINS: - case Query::TYPE_CONTAINS_ANY: - case Query::TYPE_NOT_CONTAINS: - case Query::TYPE_CONTAINS_ALL: - case Query::TYPE_EXISTS: - case Query::TYPE_NOT_EXISTS: + case Method::Equal: + case Method::Contains: + case Method::ContainsAny: + case Method::NotContains: + case Method::ContainsAll: if ($this->isEmpty($value->getValues())) { - $this->message = \ucfirst($method) . ' queries require at least one value.'; + $this->message = \ucfirst($method->value).' queries require at least one value.'; + return false; } return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - case Query::TYPE_DISTANCE_EQUAL: - case Query::TYPE_DISTANCE_NOT_EQUAL: - case Query::TYPE_DISTANCE_GREATER_THAN: - case Query::TYPE_DISTANCE_LESS_THAN: - if (count($value->getValues()) !== 1 || !is_array($value->getValues()[0]) || count($value->getValues()[0]) !== 3) { + case Method::Exists: + case Method::NotExists: + if ($this->isEmpty($value->getValues())) { + $this->message = \ucfirst($method->value).' queries require at least one value.'; + + return false; + } + + return $this->isValidExists($value); + + case Method::DistanceEqual: + case Method::DistanceNotEqual: + case Method::DistanceGreaterThan: + case Method::DistanceLessThan: + if (count($value->getValues()) !== 1 || ! is_array($value->getValues()[0]) || count($value->getValues()[0]) !== 3) { $this->message = 'Distance query requires [[geometry, distance]] parameters'; + return false; } + + $distance = $value->getValues()[0][1]; + if (! \is_numeric($distance) || ! \is_finite((float) $distance)) { + $this->message = 'Distance query requires a finite distance'; + + return false; + } + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - case Query::TYPE_NOT_EQUAL: - case Query::TYPE_LESSER: - case Query::TYPE_LESSER_EQUAL: - case Query::TYPE_GREATER: - case Query::TYPE_GREATER_EQUAL: - case Query::TYPE_SEARCH: - case Query::TYPE_NOT_SEARCH: - case Query::TYPE_STARTS_WITH: - case Query::TYPE_NOT_STARTS_WITH: - case Query::TYPE_ENDS_WITH: - case Query::TYPE_NOT_ENDS_WITH: - case Query::TYPE_REGEX: + case Method::NotEqual: + case Method::LessThan: + case Method::LessThanEqual: + case Method::GreaterThan: + case Method::GreaterThanEqual: + case Method::Search: + case Method::NotSearch: + case Method::StartsWith: + case Method::NotStartsWith: + case Method::EndsWith: + case Method::NotEndsWith: + case Method::Regex: if (count($value->getValues()) != 1) { - $this->message = \ucfirst($method) . ' queries require exactly one value.'; + $this->message = \ucfirst($method->value).' queries require exactly one value.'; + return false; } return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - case Query::TYPE_BETWEEN: - case Query::TYPE_NOT_BETWEEN: + case Method::Between: + case Method::NotBetween: if (count($value->getValues()) != 2) { - $this->message = \ucfirst($method) . ' queries require exactly two values.'; + $this->message = \ucfirst($method->value).' queries require exactly two values.'; + return false; } return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: + case Method::IsNull: + case Method::IsNotNull: return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - case Query::TYPE_VECTOR_DOT: - case Query::TYPE_VECTOR_COSINE: - case Query::TYPE_VECTOR_EUCLIDEAN: - // Validate that the attribute is a vector type - if (!$this->isValidAttribute($attribute)) { + case Method::VectorDot: + case Method::VectorCosine: + case Method::VectorEuclidean: + if (! $this->isValidAttribute($attribute)) { return false; } - // Handle dotted attributes (relationships) $attributeKey = $attribute; - if (\str_contains($attributeKey, '.') && !isset($this->schema[$attributeKey])) { - $attributeKey = \explode('.', $attributeKey)[0]; + $dot = \strpos($attributeKey, '.'); + if ($dot !== false && ! isset($this->schema[$attributeKey])) { + $alias = \substr($attributeKey, 0, $dot); + $column = \substr($attributeKey, $dot + 1); + + if ($this->isJoinColumnReference($alias, $column)) { + if (count($value->getValues()) != 1) { + $this->message = \ucfirst($method->value).' queries require exactly one vector value.'; + + return false; + } + + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); + } + + $attributeKey = $alias; } + /** @var array $attributeSchema */ $attributeSchema = $this->schema[$attributeKey]; - if ($attributeSchema['type'] !== Database::VAR_VECTOR) { + /** @var ColumnType|null $vectorAttrType */ + $vectorAttrType = $attributeSchema['type'] ?? null; + if ($vectorAttrType !== ColumnType::Vector) { $this->message = 'Vector queries can only be used on vector attributes'; + return false; } if (count($value->getValues()) != 1) { - $this->message = \ucfirst($method) . ' queries require exactly one vector value.'; + $this->message = \ucfirst($method->value).' queries require exactly one vector value.'; + return false; } return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); - case Query::TYPE_OR: - case Query::TYPE_AND: - $filters = Query::groupByType($value->getValues())['filters']; + case Method::Or: + case Method::And: + /** @var list $andOrValues */ + $andOrValues = $value->getValues(); + $nestedQueries = []; + foreach ($andOrValues as $nested) { + if (! $nested instanceof Query) { + $this->message = \ucfirst($method->value).' queries can only contain filter queries'; + + return false; + } + $nestedQueries[] = $nested; + } + + $filters = BaseQuery::groupByType($nestedQueries)->filters; + + if (count($nestedQueries) !== count($filters)) { + $this->message = \ucfirst($method->value).' queries can only contain filter queries'; - if (count($value->getValues()) !== count($filters)) { - $this->message = \ucfirst($method) . ' queries can only contain filter queries'; return false; } if (count($filters) < 2) { - $this->message = \ucfirst($method) . ' queries require at least two queries'; + $this->message = \ucfirst($method->value).' queries require at least two queries'; + return false; } + foreach ($nestedQueries as $nested) { + if (! $this->isValid($nested)) { + return false; + } + } + return true; - case Query::TYPE_ELEM_MATCH: - // elemMatch is not supported when adapter supports attributes (schema mode) + case Method::ElemMatch: if ($this->supportForAttributes) { $this->message = 'elemMatch is not supported by the database'; + return false; } - // Validate that the attribute (array field) exists - if (!$this->isValidAttribute($attribute)) { + if (! $this->isValidAttribute($attribute)) { return false; } - // For schemaless mode, allow elemMatch on any attribute - // Validate nested queries are filter queries - $filters = Query::groupByType($value->getValues())['filters']; - if (count($value->getValues()) !== count($filters)) { + /** @var list $elemMatchValues */ + $elemMatchValues = $value->getValues(); + $nestedQueries = []; + foreach ($elemMatchValues as $nested) { + if (! $nested instanceof Query) { + $this->message = 'elemMatch queries can only contain filter queries'; + + return false; + } + $nestedQueries[] = $nested; + } + + $filters = BaseQuery::groupByType($nestedQueries)->filters; + if (count($nestedQueries) !== count($filters)) { $this->message = 'elemMatch queries can only contain filter queries'; + return false; } if (count($filters) < 1) { $this->message = 'elemMatch queries require at least one query'; + return false; } + + foreach ($nestedQueries as $nested) { + if (! $this->isValid($nested)) { + return false; + } + } + return true; default: - // Handle spatial query types and any other query types if ($value->isSpatialQuery()) { if ($this->isEmpty($value->getValues())) { - $this->message = \ucfirst($method) . ' queries require at least one value.'; + $this->message = \ucfirst($method->value).' queries require at least one value.'; + return false; } + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); } @@ -520,11 +688,75 @@ public function isValid($value): bool } } + /** + * exists() and notExists() test the attributes their values name, so each value has to name + * what a filter could name, and on a collection with defined attributes a column: an attribute + * of this collection or an `alias.column` of a join. The query's own attribute is empty in the + * documented form, and is checked like a filter's when it is given. + */ + private function isValidExists(Query $query): bool + { + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + + if ($attribute !== '' && ! $this->isValidAttribute($attribute)) { + return false; + } + + foreach ($query->getValues() as $value) { + if (! \is_string($value) || $value === '') { + $this->message = \ucfirst($method->value).' queries take attribute names'; + + return false; + } + + if (! $this->isValidAttribute($value) || ($this->supportForAttributes && ! $this->isExistsColumn($value, $method))) { + return false; + } + } + + return true; + } + + private function isExistsColumn(string $attribute, Method $method): bool + { + $dot = \strpos($attribute, '.'); + if ($dot !== false && ! isset($this->schema[$attribute])) { + if ($this->isJoinColumnReference(\substr($attribute, 0, $dot), \substr($attribute, $dot + 1))) { + return true; + } + + $this->message = \ucfirst($method->value).' queries take attributes of the collection or of a join alias: '.$attribute; + + return false; + } + + /** @var array|null $definition */ + $definition = $this->schema[$attribute] ?? null; + if ($definition !== null && (Collection::columns([new Document($definition)])[$attribute] ?? true) === false) { + $this->message = 'Cannot query on virtual relationship attribute'; + + return false; + } + + return true; + } + + #[\Override] + protected function acceptsMainAttribute(string $attribute): bool + { + return isset($this->schema[$attribute]); + } + + /** + * Get the maximum number of values allowed in a single filter query. + */ public function getMaxValuesCount(): int { return $this->maxValuesCount; } + #[\Override] public function getMethodType(): string { return self::METHOD_TYPE_FILTER; diff --git a/src/Database/Validator/Query/GroupBy.php b/src/Database/Validator/Query/GroupBy.php new file mode 100644 index 0000000000..4048d58532 --- /dev/null +++ b/src/Database/Validator/Query/GroupBy.php @@ -0,0 +1,93 @@ + + */ + protected array $schema = []; + + /** + * Every attribute of the collection, and whether it holds a column. + * + * @var array + */ + protected array $columns = []; + + /** + * @param array $attributes + * @param bool $sharedTables Whether the tables hold `$tenant`, as they do under shared tables + */ + public function __construct(array $attributes = [], protected bool $supportForAttributes = true, bool $sharedTables = false) + { + $attributes = \array_map( + static fn (Attribute|Document $attribute): Attribute => $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute), + $attributes, + ); + + foreach ($attributes as $attribute) { + $this->schema[$attribute->key] = true; + } + + $this->schema += self::internalColumns($sharedTables); + $this->columns = Collection::columns($attributes); + } + + #[\Override] + public function getMethodType(): string + { + return self::METHOD_TYPE_GROUP_BY; + } + + #[\Override] + protected function isValidQuery(Query $query): bool + { + $columns = $query->getValues(); + + if (empty($columns)) { + $this->message = 'GroupBy requires at least one attribute'; + + return false; + } + + foreach ($columns as $column) { + if (! \is_string($column) || $column === '') { + $this->message = 'GroupBy attributes must be non-empty strings'; + + return false; + } + + if ( + $this->supportForAttributes + && ! isset($this->schema[$column]) + && ! $this->isJoinedAttribute($column) + ) { + return false; + } + + if (($this->columns[$column] ?? true) === false) { + $this->message = 'Cannot group by virtual relationship attribute: '.$column; + + return false; + } + } + + return true; + } + + #[\Override] + protected function acceptsMainAttribute(string $attribute): bool + { + return isset($this->schema[$attribute]); + } +} diff --git a/src/Database/Validator/Query/Having.php b/src/Database/Validator/Query/Having.php new file mode 100644 index 0000000000..5361f90e20 --- /dev/null +++ b/src/Database/Validator/Query/Having.php @@ -0,0 +1,250 @@ + + */ + private array $aggregations = []; + + /** + * @var array + */ + private array $groupBy = []; + + #[\Override] + public function getMethodType(): string + { + return self::METHOD_TYPE_HAVING; + } + + /** + * The filter rules every condition has to meet. Without one, only the shape of the having + * query is checked. + */ + public function setFilter(?Filter $filter): void + { + $this->filter = $filter; + } + + /** + * @param array $aggregations the aggregate queries of the query set + */ + public function setAggregations(array $aggregations): void + { + $this->aggregations = []; + + foreach ($aggregations as $aggregation) { + $alias = $aggregation->getAlias(); + if ($alias !== '') { + $this->aggregations[$alias] = $aggregation; + } + } + } + + /** + * @param array $attributes the groupBy attributes of the query set + */ + public function setGroupBy(array $attributes): void + { + $this->groupBy = []; + + foreach ($attributes as $attribute) { + if (\is_string($attribute) && $attribute !== '') { + $this->groupBy[$attribute] = true; + } + } + } + + /** + * Validate a having query has at least one condition, each a Query that meets the filter rules. + */ + #[\Override] + protected function isValidQuery(Query $query): bool + { + $conditions = $query->getValues(); + if (empty($conditions)) { + $this->message = 'Having requires at least one condition'; + + return false; + } + + $queries = []; + foreach ($conditions as $condition) { + if (! $condition instanceof Query) { + $this->message = 'Having conditions must be Query instances'; + + return false; + } + + $queries[] = $condition; + } + + if ($this->filter === null) { + return true; + } + + foreach ($queries as $condition) { + if (! $this->isValidCondition($condition, $this->filter, nested: false)) { + return false; + } + } + + return true; + } + + private function isValidCondition(Query $condition, Filter $filter, bool $nested): bool + { + $method = $condition->getMethod(); + + if (! $this->isFilterMethod($method)) { + $this->message = 'Having conditions must be filter queries'; + + return false; + } + + if ($method === Method::And || $method === Method::Or) { + foreach ($condition->getValues() as $child) { + if ($child instanceof Query && ! $this->isValidCondition($child, $filter, nested: true)) { + return false; + } + } + + return $this->isValidFilter($condition, $filter); + } + + $attribute = $condition->getAttribute(); + $aggregation = $this->aggregations[$attribute] ?? null; + + if ($aggregation !== null) { + if ($nested) { + $this->message = 'Aggregate alias "'.$attribute.'" can only be compared at the top level of having'; + + return false; + } + + return $this->isValidAliasCondition($condition, $aggregation, $filter); + } + + if (! isset($this->groupBy[$attribute])) { + $this->message = 'Having can only compare an aggregate alias or a groupBy attribute: '.$attribute; + + return false; + } + + return $this->isValidFilter($condition, $filter); + } + + private function isValidAliasCondition(Query $condition, BaseQuery $aggregation, Filter $filter): bool + { + $method = $condition->getMethod(); + $alias = $condition->getAttribute(); + + if (! \in_array($method, self::ALIAS_METHODS, true)) { + $this->message = 'Aggregate alias "'.$alias.'" cannot be compared with '.$method->value; + + return false; + } + + $values = $condition->getValues(); + + if (\in_array($aggregation->getMethod(), self::EXTREMA, true)) { + return $this->isValidFilter(new Query($method, $aggregation->getAttribute(), $values), $filter); + } + + if (! $this->isValidValueCount($method, $values)) { + return false; + } + + if (\count($values) > $filter->getMaxValuesCount()) { + $this->message = 'Query on aggregate alias has greater than '.$filter->getMaxValuesCount().' values: '.$alias; + + return false; + } + + $number = new FloatValidator(); + foreach ($values as $value) { + if (! $number->isValid($value)) { + $this->message = 'Query value is invalid for aggregate alias "'.$alias.'"'; + + return false; + } + } + + return true; + } + + /** + * @param array $values + */ + private function isValidValueCount(Method $method, array $values): bool + { + $message = match ($method) { + Method::Equal => $values === [] ? 'require at least one value.' : null, + Method::Between, Method::NotBetween => \count($values) !== 2 ? 'require exactly two values.' : null, + Method::IsNull, Method::IsNotNull => null, + default => \count($values) !== 1 ? 'require exactly one value.' : null, + }; + + if ($message !== null) { + $this->message = \ucfirst($method->value).' queries '.$message; + + return false; + } + + return true; + } + + private function isValidFilter(Query $condition, Filter $filter): bool + { + if (! $filter->isValid($condition)) { + $this->message = $filter->getDescription(); + + return false; + } + + return true; + } + + private function isFilterMethod(Method $method): bool + { + return $method->isFilter() + || $method->isSpatial() + || $method->isVector() + || \in_array($method, [Method::And, Method::Or, Method::ContainsAll, Method::ElemMatch], true); + } +} diff --git a/src/Database/Validator/Query/Join.php b/src/Database/Validator/Query/Join.php new file mode 100644 index 0000000000..e971974586 --- /dev/null +++ b/src/Database/Validator/Query/Join.php @@ -0,0 +1,369 @@ +', '<=', '>=', '<>']; + + /** + * What a join's ON list may hold: the on() conditions and the plain filters the builder compiles + * into it. + */ + private const array CONDITION_METHODS = [ + Method::On, + Method::Equal, + Method::NotEqual, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::LessThan, + Method::LessThanEqual, + Method::Between, + Method::NotBetween, + Method::IsNull, + Method::IsNotNull, + Method::Contains, + Method::ContainsAny, + Method::NotContains, + Method::StartsWith, + Method::NotStartsWith, + Method::EndsWith, + Method::NotEndsWith, + Method::And, + Method::Or, + ]; + + /** + * The internal attributes every table holds a column for that a join condition can compare, as a + * filter compares them. + */ + private const array INTERNAL_COLUMNS = [Document::ID, Document::SEQUENCE, Document::CREATED_AT, Document::UPDATED_AT]; + + /** + * The main collection's attributes and whether each holds a column, or null when they are not known. + * + * @var array|null + */ + private readonly ?array $columns; + + /** + * The keys of the main collection's relationship attributes. The relationship hook reads an + * `alias.attribute` whose first segment is one of them as a related document's attribute. + * + * @var array + */ + private readonly array $relationships; + + /** + * The joins of the query set whose collection is known. + * + * @var list + */ + private array $joins = []; + + /** + * The aliases the joins validated so far in this query set declared, each with the collection it + * joins when that is known. + * + * @var array + */ + private array $declared = []; + + /** + * @param array|null $attributes The main collection's attributes, or null when they are not known + */ + public function __construct(?array $attributes = null, private readonly bool $supportForAttributes = true) + { + $this->columns = $attributes === null ? null : Collection::columns($attributes); + + $relationships = []; + foreach ($attributes ?? [] as $attribute) { + $attribute = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); + if ($attribute->key !== '' && $attribute->relationship !== null) { + $relationships[$attribute->key] = true; + } + } + $this->relationships = $relationships; + } + + /** + * Declare the joins of the query set whose collection is known, so the columns of their + * conditions can be checked against it. + * + * @param list $joins + */ + public function allowJoins(array $joins): void + { + $this->joins = $joins; + } + + /** + * Start a new query set: its joins are checked in query order, each against the joins before it. + */ + public function resetJoinAliases(): void + { + $this->joins = []; + $this->declared = []; + } + + /** + * Why a join alias cannot be used, or null when it can. The reserved alias is matched without + * case: SQLite, and MySQL on case-insensitive file systems, treat `x` and `X` as one alias. + */ + public static function describeInvalidAlias(string $alias): ?string + { + if ($alias === '') { + return 'Join alias is required'; + } + + if (\preg_match(self::ALIAS_PATTERN, $alias) !== 1) { + return 'Join alias must start with a letter or an underscore and contain only letters, digits and underscores'; + } + + if (\strcasecmp($alias, Query::DEFAULT_ALIAS) === 0) { + return "Join alias \"{$alias}\" is reserved for the main collection"; + } + + return null; + } + + /** + * Get the method type this validator handles. + * + * @return string + */ + #[\Override] + public function getMethodType(): string + { + return self::METHOD_TYPE_JOIN; + } + + public const int MAX_PER_QUERY = 8; + + /** + * Validate how many joins one query set declares. + */ + public function isValidCount(int $count): bool + { + if ($count > self::MAX_PER_QUERY) { + $this->message = 'Too many joins: at most '.self::MAX_PER_QUERY.' are allowed'; + + return false; + } + + return true; + } + + /** + * Validate a join query names a table, and that its conditions compare columns the tables have. + */ + #[\Override] + protected function isValidQuery(Query $query): bool + { + if ($query->getMethod() === Method::NaturalJoin) { + $this->message = 'Natural joins are not supported'; + + return false; + } + + $table = $query->getAttribute(); + if (empty($table)) { + $this->message = 'Join requires a table name'; + + return false; + } + + $alias = $query->getAlias(); + $invalidAlias = self::describeInvalidAlias($alias); + if ($invalidAlias !== null) { + $this->message = $invalidAlias; + + return false; + } + + if (isset($this->relationships[$alias])) { + $this->message = "Join alias \"{$alias}\" is the key of the relationship attribute \"{$alias}\": give the join another alias"; + + return false; + } + + $join = $this->joinOf($query); + + if ($query->getMethod() !== Method::CrossJoin && ! $this->isValidConditions($query, $alias, $join)) { + return false; + } + + $this->declared[$alias] = $join; + + return true; + } + + private function isValidConditions(Query $query, string $alias, ?Collection $join): bool + { + $onQueries = $query->getJoinOnQueries(); + if ($onQueries === []) { + $this->message = 'Join ON requires at least one condition'; + + return false; + } + + foreach ($onQueries as $onQuery) { + if (! $this->isCondition($onQuery)) { + return false; + } + + if ($onQuery->getMethod() !== Method::On) { + continue; + } + + $values = $onQuery->getValues(); + if (! $this->isValidCondition($values[0] ?? null, $values[1] ?? '=', $values[2] ?? null, $alias, $join)) { + return false; + } + } + + return true; + } + + private function isCondition(BaseQuery $query): bool + { + $method = $query->getMethod(); + if (! \in_array($method, self::CONDITION_METHODS, true)) { + $this->message = 'Unsupported join ON condition: '.$method->value; + + return false; + } + + if ($method !== Method::And && $method !== Method::Or) { + return true; + } + + foreach ($query->getValues() as $child) { + if ($child instanceof BaseQuery && ! $this->isCondition($child)) { + return false; + } + } + + return true; + } + + private function isValidCondition(mixed $left, mixed $operator, mixed $right, string $alias, ?Collection $join): bool + { + if (! \is_string($left) || $left === '' || ! \is_string($right) || $right === '') { + $this->message = 'Join ON requires left and right columns'; + + return false; + } + + if (! \is_string($operator) || ! \in_array($operator, self::OPERATORS, true)) { + $this->message = 'Invalid join operator: '.(\is_string($operator) ? $operator : \gettype($operator)); + + return false; + } + + return $this->isValidLeftColumn($left) && $this->isValidRightColumn($right, $alias, $join); + } + + /** + * The left column belongs to the main collection, or under its alias to a join declared before + * this one. + */ + private function isValidLeftColumn(string $column): bool + { + $dot = \strpos($column, '.'); + if ($dot === false) { + return $this->isColumn($this->columns, $column, $column); + } + + $alias = \substr($column, 0, $dot); + if (! \array_key_exists($alias, $this->declared)) { + $this->message = 'The left column of a join condition must belong to the main collection or to a join declared before it: '.$column; + + return false; + } + + return $this->isColumn($this->columnsOf($this->declared[$alias]), \substr($column, $dot + 1), $column); + } + + /** + * The right column belongs to the collection the join reads, bare or under the join's alias. + */ + private function isValidRightColumn(string $column, string $alias, ?Collection $join): bool + { + $name = $column; + $dot = \strpos($column, '.'); + if ($dot !== false) { + if (\substr($column, 0, $dot) !== $alias) { + $this->message = 'The right column of a join condition must belong to the joined collection: '.$column; + + return false; + } + + $name = \substr($column, $dot + 1); + } + + return $this->isColumn($this->columnsOf($join), $name, $column); + } + + /** + * @param array|null $columns A collection's attributes and whether each holds a column, or null when they are not known + */ + private function isColumn(?array $columns, string $name, string $column): bool + { + if ($name !== '' && (! $this->supportForAttributes || $columns === null || \in_array($name, self::INTERNAL_COLUMNS, true))) { + return true; + } + + $holdsColumn = $columns[$name] ?? null; + + if ($holdsColumn === false) { + $this->message = 'Cannot join on virtual relationship attribute: '.$column; + + return false; + } + + if ($holdsColumn === null) { + $this->message = 'Attribute not found in schema: '.$column; + + return false; + } + + return true; + } + + /** + * @return array|null + */ + private function columnsOf(?Collection $join): ?array + { + if ($join === null) { + return null; + } + + return \array_fill_keys(\array_keys($join->attributes), true) + $join->columns; + } + + private function joinOf(Query $query): ?Collection + { + foreach ($this->joins as $join) { + if ($join->collection === $query->getAttribute() && $join->alias === $query->getAlias()) { + return $join; + } + } + + return null; + } +} diff --git a/src/Database/Validator/Query/Joined/Attributes.php b/src/Database/Validator/Query/Joined/Attributes.php new file mode 100644 index 0000000000..71ad9b4605 --- /dev/null +++ b/src/Database/Validator/Query/Joined/Attributes.php @@ -0,0 +1,216 @@ + + */ + protected array $joins = []; + + /** + * The known join of each alias. + * + * @var array + */ + protected array $joinsByAlias = []; + + /** + * @var array + */ + protected array $joinAliases = []; + + /** + * Declare the joins of the query set whose collection is known: an `alias.column` under one of + * their aliases has to name a column of that collection, and a bare name may resolve to one. + * + * @param list $joins + */ + public function allowJoins(array $joins): void + { + $this->joins = $joins; + $this->joinsByAlias = []; + + foreach ($joins as $join) { + if ($join->alias !== '') { + $this->joinsByAlias[$join->alias] ??= $join; + $this->joinAliases[$join->alias] = true; + } + } + } + + /** + * Declare join aliases of the query set. Under an alias whose collection is not known, any + * plain column is accepted. + * + * @param array $aliases + */ + public function allowJoinAliases(array $aliases): void + { + foreach ($aliases as $alias) { + if ($alias !== '') { + $this->joinAliases[$alias] = true; + } + } + } + + public function resetJoinAliases(): void + { + $this->joins = []; + $this->joinsByAlias = []; + $this->joinAliases = []; + } + + /** + * Whether `alias.column` refers to a join: the alias is one the query set declared, and the + * column a plain identifier. + */ + protected function isJoinColumnReference(string $alias, string $column): bool + { + return isset($this->joinAliases[$alias]) && $this->isAllowedJoinColumn($column); + } + + /** + * A column under a join alias is valid exactly when it would be valid unaliased on the + * collection the alias joins: one of its attributes, or an internal attribute this validator + * accepts on the main collection. Sets the message when the column is not valid. + */ + protected function isJoinedColumn(string $alias, string $column): bool + { + $join = $this->joinsByAlias[$alias] ?? null; + + if ($join === null || isset($join->attributes[$column]) || $this->isJoinedInternalAttribute($column)) { + return true; + } + + $this->message = 'Attribute not found in schema: '.$alias.'.'.$column; + + return false; + } + + /** + * An `alias.column` reference has to name a join alias the query set declared and a column of + * the collection it joins, so a typo in either is still rejected. A bare name has to be + * declared by exactly one join: by none it is not found, and by several it would silently + * pick one of them. Sets the message when the attribute does not resolve. + */ + protected function isJoinedAttribute(string $attribute): bool + { + $dot = \strpos($attribute, '.'); + + if ($dot === false) { + $joins = 0; + foreach ($this->joins as $join) { + if (isset($join->attributes[$attribute])) { + $joins++; + } + } + + if ($joins === 1) { + return true; + } + + if ($joins > 1) { + $this->message = 'Attribute "'.$attribute.'" is ambiguous across joins; qualify it with a join alias'; + + return false; + } + } else { + $alias = \substr($attribute, 0, $dot); + $column = \substr($attribute, $dot + 1); + + if ($this->isJoinColumnReference($alias, $column)) { + return $this->isJoinedColumn($alias, $column); + } + } + + $this->message = 'Attribute not found in schema: '.$attribute; + + return false; + } + + /** + * The known join an attribute this collection does not declare resolves to: the join of its + * alias, or the join that declares a bare name. + */ + protected function joinOf(string $attribute): ?Collection + { + $dot = \strpos($attribute, '.'); + + if ($dot !== false) { + return $this->joinsByAlias[\substr($attribute, 0, $dot)] ?? null; + } + + foreach ($this->joins as $join) { + if (isset($join->attributes[$attribute])) { + return $join; + } + } + + return null; + } + + /** + * Internal attributes are the same on every collection, except `$collection`: a read derives + * it from the collection it reads, and a joined row has no column for it. + */ + protected function isJoinedInternalAttribute(string $column): bool + { + return \str_starts_with($column, '$') + && $column !== Document::COLLECTION + && $this->acceptsMainAttribute($column); + } + + /** + * The internal attributes a table holds a column for: every one but `$collection`, which a read + * derives from the collection it reads, and `$tenant` only under shared tables. + * + * @return array + */ + protected static function internalColumns(bool $sharedTables): array + { + $columns = []; + foreach (Database::internalAttributesFor($sharedTables) as $attribute) { + if ($attribute->key !== Document::COLLECTION) { + $columns[$attribute->key] = true; + } + } + + return $columns; + } + + abstract protected function isAllowedJoinColumn(string $column): bool; + + /** + * The column an attribute names: a bare name the collection does not declare is the column of + * the one join that declares it, any other name is its own. + */ + protected function column(string $attribute): string + { + if (\str_contains($attribute, '.') || $this->acceptsMainAttribute($attribute)) { + return $attribute; + } + + $declaring = \array_values(\array_filter( + $this->joins, + static fn (Collection $join): bool => isset($join->attributes[$attribute]), + )); + + return \count($declaring) === 1 ? $declaring[0]->alias.'.'.$attribute : $attribute; + } + + /** + * Whether this validator accepts the attribute unaliased on the main collection. + */ + abstract protected function acceptsMainAttribute(string $attribute): bool; +} diff --git a/src/Database/Validator/Query/Joined/Collection.php b/src/Database/Validator/Query/Joined/Collection.php new file mode 100644 index 0000000000..1172abf2bd --- /dev/null +++ b/src/Database/Validator/Query/Joined/Collection.php @@ -0,0 +1,141 @@ + $attributes The attributes the collection declares, relationships left out + * @param array $numeric The type of each attribute that holds a single number + * @param array $encrypted The attributes whose values are stored encrypted + * @param array $columns Every attribute the collection declares, and whether it holds a column a join condition can compare + * @param string $collection The id of the collection the join reads + * @param array> $schema The definition of each attribute in $attributes, its type a ColumnType, as Filter holds the main collection's + */ + public function __construct( + public string $alias, + public array $attributes, + public array $numeric, + public array $encrypted = [], + public array $columns = [], + public string $collection = '', + public array $schema = [], + ) { + } + + /** + * The collection a join reads, under the alias the join declares. + */ + public static function of(string $alias, Document $collection): self + { + $definitions = DatabaseCollection::fromDocument($collection)->attributes(); + + $attributes = []; + $numeric = []; + $encrypted = []; + $columns = []; + $schema = []; + foreach ($definitions as $definition) { + $key = $definition->key; + if ($key === '') { + continue; + } + + $columns[$key] = self::storesColumn($definition); + + if ($definition->relationship === null) { + $attributes[$key] = true; + $schema[$key] = ['type' => $definition->type] + $definition->toDocument()->getArrayCopy(); + } + + if (! $definition->array && $definition->isNumeric()) { + $numeric[$key] = $definition->type; + } + + if (\in_array(self::ENCRYPT, $definition->filters, true)) { + $encrypted[$key] = true; + } + } + + return new self( + $alias, + $attributes, + $numeric, + $encrypted, + $columns, + $collection->getId(), + $schema, + ); + } + + /** + * Whether the collection's table holds a column for the attribute: every attribute but a + * relationship does, and a relationship does on the side that stores the related document's id. + */ + public function holdsColumn(string $attribute): bool + { + return $this->columns[$attribute] ?? false; + } + + /** + * Every attribute of a collection, and whether its table holds a column for it: every attribute + * but a relationship does, and a relationship does on the side that stores the related + * document's id. + * + * @param array $attributes + * @return array + */ + public static function columns(array $attributes): array + { + $columns = []; + foreach ($attributes as $attribute) { + if ($attribute instanceof Document) { + $key = $attribute->getAttribute('key', $attribute->getId()); + if (! \is_string($key) || $key === '') { + continue; + } + + $columns[$key] = ! Attribute::isRelationship($attribute) || self::storesColumn(Attribute::fromDocument($attribute)); + + continue; + } + + if ($attribute->key !== '') { + $columns[$attribute->key] = self::storesColumn($attribute); + } + } + + return $columns; + } + + private static function storesColumn(Attribute $attribute): bool + { + $relationship = $attribute->relationship; + if ($relationship === null) { + return true; + } + + $parent = $attribute->side === RelationshipSide::Parent; + + return match ($relationship->type) { + RelationshipType::OneToOne => $parent || $relationship->twoWay, + RelationshipType::OneToMany => ! $parent, + RelationshipType::ManyToOne => $parent, + RelationshipType::ManyToMany => false, + }; + } +} diff --git a/src/Database/Validator/Query/Limit.php b/src/Database/Validator/Query/Limit.php index facc266d75..da30c38126 100644 --- a/src/Database/Validator/Query/Limit.php +++ b/src/Database/Validator/Query/Limit.php @@ -3,6 +3,7 @@ namespace Utopia\Database\Validator\Query; use Utopia\Database\Query; +use Utopia\Query\Method; use Utopia\Validator\Numeric; use Utopia\Validator\Range; @@ -12,8 +13,6 @@ class Limit extends Base /** * Query constructor - * - * @param int $maxLimit */ public function __construct(int $maxLimit = PHP_INT_MAX) { @@ -21,41 +20,48 @@ public function __construct(int $maxLimit = PHP_INT_MAX) } /** - * Is valid. - * * Returns true if method is limit values are within range. * - * @param Query $value - * @return bool + * @param mixed $value */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!$value instanceof Query) { + if (! $value instanceof Query) { return false; } - if ($value->getMethod() !== Query::TYPE_LIMIT) { - $this->message = 'Invalid query method: ' . $value->getMethod(); + if ($value->getMethod() !== Method::Limit) { + $this->message = 'Invalid query method: '.$value->getMethod()->value; + return false; } $limit = $value->getValue(); $validator = new Numeric(); - if (!$validator->isValid($limit)) { - $this->message = 'Invalid limit: ' . $validator->getDescription(); + if (! $validator->isValid($limit)) { + $this->message = 'Invalid limit: '.$validator->getDescription(); + return false; } $validator = new Range(1, $this->maxLimit); - if (!$validator->isValid($limit)) { - $this->message = 'Invalid limit: ' . $validator->getDescription(); + if (! $validator->isValid($limit)) { + $this->message = 'Invalid limit: '.$validator->getDescription(); + return false; } return true; } + /** + * Get the method type this validator handles. + * + * @return string + */ + #[\Override] public function getMethodType(): string { return self::METHOD_TYPE_LIMIT; diff --git a/src/Database/Validator/Query/Offset.php b/src/Database/Validator/Query/Offset.php index 8d59be4d0d..90102168bc 100644 --- a/src/Database/Validator/Query/Offset.php +++ b/src/Database/Validator/Query/Offset.php @@ -3,6 +3,7 @@ namespace Utopia\Database\Validator\Query; use Utopia\Database\Query; +use Utopia\Query\Method; use Utopia\Validator\Numeric; use Utopia\Validator\Range; @@ -11,7 +12,7 @@ class Offset extends Base protected int $maxOffset; /** - * @param int $maxOffset + * @param int $maxOffset Maximum allowed offset value */ public function __construct(int $maxOffset = PHP_INT_MAX) { @@ -19,39 +20,46 @@ public function __construct(int $maxOffset = PHP_INT_MAX) } /** - * @param Query $value + * Validate that the value is a valid offset query within the allowed range. + * + * @param mixed $value The query to validate * @return bool */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!$value instanceof Query) { + if (! $value instanceof Query) { return false; } $method = $value->getMethod(); - if ($method !== Query::TYPE_OFFSET) { - $this->message = 'Query method invalid: ' . $method; + if ($method !== Method::Offset) { + $this->message = 'Query method invalid: '.$method->value; + return false; } $offset = $value->getValue(); $validator = new Numeric(); - if (!$validator->isValid($offset)) { - $this->message = 'Invalid limit: ' . $validator->getDescription(); + if (! $validator->isValid($offset)) { + $this->message = 'Invalid offset: '.$validator->getDescription(); + return false; } $validator = new Range(0, $this->maxOffset); - if (!$validator->isValid($offset)) { - $this->message = 'Invalid offset: ' . $validator->getDescription(); + if (! $validator->isValid($offset)) { + $this->message = 'Invalid offset: '.$validator->getDescription(); + return false; } return true; } + #[\Override] public function getMethodType(): string { return self::METHOD_TYPE_OFFSET; diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index c14a985391..f3ea45c0af 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -2,52 +2,93 @@ namespace Utopia\Database\Validator\Query; +use Utopia\Database\Attribute; use Utopia\Database\Document; use Utopia\Database\Query; +use Utopia\Database\Validator\Query\Joined\Attributes; +use Utopia\Query\Method; +use Utopia\Query\Query as BaseQuery; class Order extends Base { + use Attributes; + /** * @var array */ protected array $schema = []; /** - * @param array $attributes - * @param bool $supportForAttributes + * Transient aggregation aliases registered by Queries::isValid for the + * current validation pass. Kept separate from $schema so it can be reset + * per-call without clobbering the real attribute schema — prior versions + * mutated $schema directly, leaking aliases across requests in long-lived + * processes (Swoole) and pooled validator instances. + * + * @var array */ - public function __construct(array $attributes = [], protected bool $supportForAttributes = true) - { + protected array $aggregationAliases = []; + + private bool $aggregates = false; + + /** + * @var list + */ + private array $groupBy = []; + + /** + * @param array $attributes + */ + public function __construct( + array $attributes = [], + protected bool $supportForAttributes = true, + protected bool $supportForOrderRandom = true, + ) { foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = true; + $attribute = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); + $this->schema[$attribute->key] = true; } } - /** - * @param string $attribute - * @return bool - */ protected function isValidAttribute(string $attribute): bool { - if (\str_contains($attribute, '.')) { - // Check for special symbol `.` + $dot = \strpos($attribute, '.'); + if ($dot !== false) { if (isset($this->schema[$attribute])) { return true; } + $alias = \substr($attribute, 0, $dot); + $column = \substr($attribute, $dot + 1); + + if ($this->isJoinColumnReference($alias, $column)) { + $join = $this->joinsByAlias[$alias] ?? null; + + return ! $this->supportForAttributes || $join?->holdsColumn($column) === true || $this->isJoinedColumn($alias, $column); + } + // For relationships, just validate the top level. // Will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; + $attribute = $alias; if (isset($this->schema[$attribute])) { - $this->message = 'Cannot order by nested attribute: ' . $attribute; + $this->message = 'Cannot order by nested attribute: '.$attribute; + return false; } } - // Search for attribute in schema - if ($this->supportForAttributes && !isset($this->schema[$attribute])) { - $this->message = 'Attribute not found in schema: ' . $attribute; + if (isset($this->aggregationAliases[$attribute])) { + return true; + } + + if ($this->supportForAttributes && ! isset($this->schema[$attribute])) { + if ($dot === false && $this->joins !== []) { + return $this->isJoinedAttribute($attribute); + } + + $this->message = 'Attribute not found in schema: '.$attribute; + return false; } @@ -55,35 +96,106 @@ protected function isValidAttribute(string $attribute): bool } /** - * Is valid. - * - * Returns true if method is ORDER_ASC or ORDER_DESC and attributes are valid - * - * Otherwise, returns false - * - * @param Query $value - * @return bool + * @param mixed $value */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!$value instanceof Query) { + if (! $value instanceof Query) { return false; } $method = $value->getMethod(); $attribute = $value->getAttribute(); - if ($method === Query::TYPE_ORDER_ASC || $method === Query::TYPE_ORDER_DESC) { - return $this->isValidAttribute($attribute); + if ($method === Method::OrderAsc || $method === Method::OrderDesc) { + return $this->isValidAttribute($attribute) && $this->isGroupedOrder($attribute); + } + + if ($method === Method::OrderRandom) { + if (! $this->supportForOrderRandom) { + $this->message = 'Random order is not supported by this adapter'; + + return false; + } + + return true; + } + + return false; + } + + #[\Override] + protected function acceptsMainAttribute(string $attribute): bool + { + return isset($this->schema[$attribute]); + } + + /** + * Register aggregation aliases that become valid order targets for the + * current validation pass. Callers (see Queries::isValid) must invoke + * resetAggregationAliases() before the pass to avoid cross-call leakage. + * + * @param array $aliases + */ + public function addAggregationAliases(array $aliases): void + { + foreach ($aliases as $alias) { + $this->aggregationAliases[$alias] = true; } + } - if ($method === Query::TYPE_ORDER_RANDOM) { - return true; // orderRandom doesn't need an attribute + /** + * Clear any aggregation aliases added by a previous validation pass. + */ + public function resetAggregationAliases(): void + { + $this->aggregationAliases = []; + } + + /** + * The aggregates of the query set. With one, or with a groupBy, the query returns a row per + * group, so an order can name only an aggregate alias or an attribute the query groups by. + * + * @param array $aggregations + */ + public function setAggregations(array $aggregations): void + { + $this->aggregates = $aggregations !== []; + } + + /** + * @param array $attributes the groupBy attributes of the query set + */ + public function setGroupBy(array $attributes): void + { + $this->groupBy = []; + + foreach ($attributes as $attribute) { + if (\is_string($attribute) && $attribute !== '') { + $this->groupBy[] = $attribute; + } } + } + + private function isGroupedOrder(string $attribute): bool + { + if ((! $this->aggregates && $this->groupBy === []) || isset($this->aggregationAliases[$attribute])) { + return true; + } + + foreach ($this->groupBy as $group) { + if ($this->column($group) === $this->column($attribute)) { + return true; + } + } + + $this->message = 'Cannot order by "'.$attribute.'": an aggregation query can only order by its groups and aggregates'; return false; } + #[\Override] public function getMethodType(): string { return self::METHOD_TYPE_ORDER; diff --git a/src/Database/Validator/Query/Select.php b/src/Database/Validator/Query/Select.php index 27daa79ab1..551ec1dc9e 100644 --- a/src/Database/Validator/Query/Select.php +++ b/src/Database/Validator/Query/Select.php @@ -2,112 +2,252 @@ namespace Utopia\Database\Validator\Query; +use Utopia\Database\Attribute; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; - +use Utopia\Database\Validator\Query\Joined\Attributes; +use Utopia\Query\Method; +use Utopia\Query\Query as BaseQuery; + +/** + * Validates select query methods ensuring referenced attributes exist in the schema, are not duplicated and, in an + * aggregation query, are grouped. + */ class Select extends Base { + use Attributes; + /** * @var array */ protected array $schema = []; /** - * List of internal attributes + * The relationship attributes of the collection. * - * @var array + * @var array */ - protected const INTERNAL_ATTRIBUTES = [ - '$id', - '$sequence', - '$createdAt', - '$updatedAt', - '$permissions', - '$collection', - ]; + private array $relationships = []; /** - * @param array $attributes - * @param bool $supportForAttributes + * Whether the query set holds an aggregate. */ - public function __construct(array $attributes = [], protected bool $supportForAttributes = true) + private bool $aggregates = false; + + /** + * The attributes the query set groups by. + * + * @var list + */ + private array $groupBy = []; + + /** + * @param array $attributes + * @param bool $sharedTables Whether the tables hold `$tenant`, as they do under shared tables + */ + public function __construct(array $attributes = [], protected bool $supportForAttributes = true, protected bool $sharedTables = false) { foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = true; + $attribute = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); + $this->schema[$attribute->key] = true; + + if ($attribute->relationship !== null) { + $this->relationships[$attribute->key] = true; + } } } /** - * Is valid. - * - * Returns true if method is TYPE_SELECT selections are valid - * - * Otherwise, returns false + * The aggregates of the query set. With one, or with a groupBy, the query returns a row per + * group, so a select can name only an attribute the query groups by. * - * @param Query $value - * @return bool + * @param array $aggregations + */ + public function setAggregations(array $aggregations): void + { + $this->aggregates = $aggregations !== []; + } + + /** + * @param array $attributes the groupBy attributes of the query set */ - public function isValid($value): bool + public function setGroupBy(array $attributes): void { - if (!$value instanceof Query) { + $this->groupBy = []; + + foreach ($attributes as $attribute) { + if (\is_string($attribute) && $attribute !== '') { + $this->groupBy[] = $attribute; + } + } + } + + /** + * @param mixed $value + */ + #[\Override] + public function isValid(mixed $value): bool + { + if (! $value instanceof Query) { return false; } - if ($value->getMethod() !== Query::TYPE_SELECT) { + if ($value->getMethod() !== Method::Select) { return false; } - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - Database::INTERNAL_ATTRIBUTES - ); + $internalKeys = $this->internalKeys(); if (\count($value->getValues()) === 0) { $this->message = 'No attributes selected'; + return false; } // Before the duplicate check: array_unique() stringifies every array element // to "Array", so two nested values collapse into one and report a misleading // duplicate instead of the type error that is actually there. + $attributes = []; foreach ($value->getValues() as $attribute) { if (!\is_string($attribute)) { $this->message = 'Attribute selection must be a string, got ' . \get_debug_type($attribute); return false; } + $attributes[] = $attribute; } - if (\count($value->getValues()) !== \count(\array_unique($value->getValues()))) { + if (\count($attributes) !== \count(\array_unique($attributes))) { $this->message = 'Duplicate attributes selected'; + return false; } - foreach ($value->getValues() as $attribute) { - if (\str_contains($attribute, '.')) { - //special symbols with `dots` + foreach ($value->getValues() as $attributeValue) { + /** @var string $attribute */ + $attribute = $attributeValue; + $dot = \strpos($attribute, '.'); + if ($dot !== false) { if (isset($this->schema[$attribute])) { continue; } + $alias = \substr($attribute, 0, $dot); + $column = \substr($attribute, $dot + 1); + + if ($column === '*' && isset($this->joinAliases[$alias])) { + if ($this->isAggregation()) { + return $this->rejectUngrouped($attribute); + } + + continue; + } + + if ($this->isJoinColumnReference($alias, $column)) { + $join = $this->joinsByAlias[$alias] ?? null; + if ($this->supportForAttributes && $join?->holdsColumn($column) !== true && ! $this->isJoinedColumn($alias, $column)) { + return false; + } + + continue; + } + + if ($this->isAggregation() && isset($this->joinAliases[$alias])) { + return $this->rejectUngrouped($attribute); + } + // For relationships, just validate the top level. // Will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; + $attribute = $alias; } - // Skip internal attributes if (\in_array($attribute, $internalKeys)) { continue; } - if ($this->supportForAttributes && !isset($this->schema[$attribute]) && $attribute !== '*') { - $this->message = 'Attribute not found in schema: ' . $attribute; + if ($this->supportForAttributes && ! isset($this->schema[$attribute]) && $attribute !== '*') { + $this->message = 'Attribute not found in schema: '.$attribute; + return false; } } + + return ! $this->isAggregation() || $this->isGroupedSelection($attributes); + } + + /** + * Whether the query set aggregates: with an aggregate or a groupBy it returns a row per group. + */ + private function isAggregation(): bool + { + return $this->aggregates || $this->groupBy !== []; + } + + /** + * An aggregation query returns only its groups and aggregates, so every selected attribute has + * to be one it groups by. `*` and relationship wildcards at any depth add nothing to those rows, + * and are accepted. Sets the message when an attribute is not grouped. + * + * @param list $attributes + */ + private function isGroupedSelection(array $attributes): bool + { + $groups = []; + foreach ($this->groupBy as $group) { + $groups[$this->column($group)] = true; + } + + foreach ($attributes as $attribute) { + if ($attribute !== '*' && ! $this->isRelationshipWildcard($attribute) && ! isset($groups[$this->column($attribute)])) { + return $this->rejectUngrouped($attribute); + } + } + return true; } + private function rejectUngrouped(string $attribute): false + { + $this->message = 'Cannot select "'.$attribute.'": an aggregation query can only select the attributes it groups by'; + + return false; + } + + /** + * A wildcard under a relationship of the collection: `key.*`, or a nested `key.related.*`. + * Under a join alias a wildcard names the joined collection's columns instead. + */ + private function isRelationshipWildcard(string $attribute): bool + { + $key = \strstr($attribute, '.', true); + + return $key !== false + && \str_ends_with($attribute, '.*') + && isset($this->relationships[$key]) + && ! isset($this->joinAliases[$key]); + } + + #[\Override] + protected function acceptsMainAttribute(string $attribute): bool + { + return isset($this->schema[$attribute]) || \in_array($attribute, $this->internalKeys(), true); + } + + /** + * The internal attributes a read can select: every one but `$tenant`, which only shared tables + * hold. + * + * @return array + */ + private function internalKeys(): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + Database::internalAttributesFor($this->sharedTables), + ); + } + + #[\Override] public function getMethodType(): string { return self::METHOD_TYPE_SELECT; diff --git a/src/Database/Validator/Roles.php b/src/Database/Validator/Roles.php index 91202191ea..eb1fcc2da7 100644 --- a/src/Database/Validator/Roles.php +++ b/src/Database/Validator/Roles.php @@ -2,21 +2,27 @@ namespace Utopia\Database\Validator; -use Utopia\Database\Helpers\Role; +use Exception; +use Utopia\Database\Role; use Utopia\Validator; class Roles extends Validator { - // Roles - public const ROLE_ANY = 'any'; - public const ROLE_GUESTS = 'guests'; - public const ROLE_USERS = 'users'; - public const ROLE_USER = 'user'; - public const ROLE_TEAM = 'team'; - public const ROLE_MEMBER = 'member'; - public const ROLE_LABEL = 'label'; - - public const ROLES = [ + public const string ROLE_ANY = 'any'; + + public const string ROLE_GUESTS = 'guests'; + + public const string ROLE_USERS = 'users'; + + public const string ROLE_USER = 'user'; + + public const string ROLE_TEAM = 'team'; + + public const string ROLE_MEMBER = 'member'; + + public const string ROLE_LABEL = 'label'; + + public const array ROLES = [ self::ROLE_ANY, self::ROLE_GUESTS, self::ROLE_USERS, @@ -35,7 +41,7 @@ class Roles extends Validator protected int $length; - public const CONFIG = [ + public const array CONFIG = [ self::ROLE_ANY => [ 'identifier' => [ 'allowed' => false, @@ -64,7 +70,7 @@ class Roles extends Validator 'dimension' => [ 'allowed' => true, 'required' => false, - 'options' => self::USER_DIMENSIONS + 'options' => self::USER_DIMENSIONS, ], ], self::ROLE_USER => [ @@ -75,7 +81,7 @@ class Roles extends Validator 'dimension' => [ 'allowed' => true, 'required' => false, - 'options' => self::USER_DIMENSIONS + 'options' => self::USER_DIMENSIONS, ], ], self::ROLE_TEAM => [ @@ -110,20 +116,18 @@ class Roles extends Validator ], ]; - // Dimensions - public const DIMENSION_VERIFIED = 'verified'; - public const DIMENSION_UNVERIFIED = 'unverified'; + public const string DIMENSION_VERIFIED = 'verified'; + + public const string DIMENSION_UNVERIFIED = 'unverified'; - public const USER_DIMENSIONS = [ + public const array USER_DIMENSIONS = [ self::DIMENSION_VERIFIED, self::DIMENSION_UNVERIFIED, ]; /** - * Roles constructor. - * - * @param int $length maximum amount of role. 0 means unlimited. - * @param array $allowed allowed roles. Defaults to all available. + * @param int $length maximum amount of role. 0 means unlimited. + * @param array $allowed allowed roles. Defaults to all available. */ public function __construct(int $length = 0, array $allowed = self::ROLES) { @@ -131,50 +135,41 @@ public function __construct(int $length = 0, array $allowed = self::ROLES) $this->allowed = $allowed; } - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ + #[\Override] public function getDescription(): string { return $this->message; } - /** - * Is valid. - * - * Returns true if valid or false if not. - * - * @param mixed $roles - * - * @return bool - */ - public function isValid($roles): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!\is_array($roles)) { + if (! \is_array($value)) { $this->message = 'Roles must be an array of strings.'; + return false; } - if ($this->length && \count($roles) > $this->length) { - $this->message = 'You can only provide up to ' . $this->length . ' roles.'; + if ($this->length && \count($value) > $this->length) { + $this->message = 'You can only provide up to '.$this->length.' roles.'; + return false; } - foreach ($roles as $role) { - if (!\is_string($role)) { + foreach ($value as $role) { + if (! \is_string($role)) { $this->message = 'Every role must be of type string.'; + return false; } if ($role === '*') { $this->message = 'Wildcard role "*" has been replaced. Use "any" instead.'; + return false; } if (\str_contains($role, 'role:')) { $this->message = 'Roles using the "role:" prefix have been removed. Use "users", "guests", or "any" instead.'; + return false; } @@ -185,15 +180,17 @@ public function isValid($roles): bool break; } } - if (!$isAllowed) { - $this->message = 'Role "' . $role . '" is not allowed. Must be one of: ' . \implode(', ', $this->allowed) . '.'; + if (! $isAllowed) { + $this->message = 'Role "'.$role.'" is not allowed. Must be one of: '.\implode(', ', $this->allowed).'.'; + return false; } try { $role = Role::parse($role); - } catch (\Exception $e) { + } catch (Exception $e) { $this->message = $e->getMessage(); + return false; } @@ -201,32 +198,21 @@ public function isValid($roles): bool $identifier = $role->getIdentifier(); $dimension = $role->getDimension(); - if (!$this->isValidRole($roleName, $identifier, $dimension)) { + if (! $this->isValidRole($roleName, $identifier, $dimension)) { return false; } } + return true; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_ARRAY; @@ -250,60 +236,60 @@ protected function isValidRole( $config = self::CONFIG[$role] ?? null; if (empty($config)) { - $this->message = 'Role "' . $role . '" is not allowed. Must be one of: ' . \implode(', ', self::ROLES) . '.'; + $this->message = 'Role "'.$role.'" is not allowed. Must be one of: '.\implode(', ', self::ROLES).'.'; + return false; } - // Process identifier configuration $allowed = $config['identifier']['allowed']; $required = $config['identifier']['required']; - // Not allowed and has an identifier - if (!$allowed && !empty($identifier)) { - $this->message = 'Role "' . $role . '"' . ' can not have an ID value.'; + if (! $allowed && ! empty($identifier)) { + $this->message = 'Role "'.$role.'"'.' can not have an ID value.'; + return false; } - // Required and has no identifier if ($allowed && $required && empty($identifier)) { - $this->message = 'Role "' . $role . '"' . ' must have an ID value.'; + $this->message = 'Role "'.$role.'"'.' must have an ID value.'; + return false; } - // Allowed and has an invalid identifier - if ($allowed && !empty($identifier) && !$identifierValidator->isValid($identifier)) { - $this->message = 'Role "' . $role . '"' . ' identifier value is invalid: ' . $identifierValidator->getDescription(); + if ($allowed && ! empty($identifier) && ! $identifierValidator->isValid($identifier)) { + $this->message = 'Role "'.$role.'"'.' identifier value is invalid: '.$identifierValidator->getDescription(); + return false; } - // Process dimension configuration + /** @var bool $allowed */ $allowed = $config['dimension']['allowed']; + /** @var bool $required */ $required = $config['dimension']['required']; $options = $config['dimension']['options'] ?? [$dimension]; - // Not allowed and has a dimension - if (!$allowed && !empty($dimension)) { - $this->message = 'Role "' . $role . '"' . ' can not have a dimension value.'; + if (! $allowed && ! empty($dimension)) { + $this->message = 'Role "'.$role.'"'.' can not have a dimension value.'; + return false; } - // Required and has no dimension - // PHPStan complains because there are currently no dimensions that are required, but there might be in future - // @phpstan-ignore-next-line + // Required and has no dimension (no current dimensions are required, but this guards future additions) if ($allowed && $required && empty($dimension)) { - $this->message = 'Role "' . $role . '"' . ' must have a dimension value.'; + $this->message = 'Role "'.$role.'"'.' must have a dimension value.'; + return false; } - if ($allowed && !empty($dimension)) { - // Allowed and dimension is not an allowed option - if (!\in_array($dimension, $options)) { - $this->message = 'Role "' . $role . '"' . ' dimension value is invalid. Must be one of: ' . \implode(', ', $options) . '.'; + if ($allowed && ! empty($dimension)) { + if (! \in_array($dimension, $options)) { + $this->message = 'Role "'.$role.'"'.' dimension value is invalid. Must be one of: '.\implode(', ', $options).'.'; + return false; } - // Allowed and dimension is not a valid key - if (!$dimensionValidator->isValid($dimension)) { - $this->message = 'Role "' . $role . '"' . ' dimension value is invalid: ' . $dimensionValidator->getDescription(); + if (! $dimensionValidator->isValid($dimension)) { + $this->message = 'Role "'.$role.'"'.' dimension value is invalid: '.$dimensionValidator->getDescription(); + return false; } } diff --git a/src/Database/Validator/Sequence.php b/src/Database/Validator/Sequence.php index 7e3ebca271..59153747a2 100644 --- a/src/Database/Validator/Sequence.php +++ b/src/Database/Validator/Sequence.php @@ -3,39 +3,48 @@ namespace Utopia\Database\Validator; use Utopia\Database\Database; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator; use Utopia\Validator\Range; class Sequence extends Validator { private string $idAttributeType; + private bool $primary; + #[\Override] public function getDescription(): string { return 'Invalid sequence value'; } - /** - * Expression constructor - */ public function __construct(string $idAttributeType, bool $primary) { $this->primary = $primary; $this->idAttributeType = $idAttributeType; } + #[\Override] public function isArray(): bool { return false; } + #[\Override] public function getType(): string { return self::TYPE_STRING; } - public function isValid($value): bool + /** + * Validate a sequence value against the configured ID attribute type. + * + * @param mixed $value The value to validate + * @return bool + */ + #[\Override] + public function isValid(mixed $value): bool { if ($this->primary && empty($value)) { return false; @@ -45,23 +54,20 @@ public function isValid($value): bool return true; } - if (!\is_string($value) && !\is_int($value)) { + if (! \is_string($value) && ! \is_int($value)) { return false; } - if (!$this->primary) { + if (! $this->primary) { return true; } - switch ($this->idAttributeType) { - case Database::VAR_UUID7: - return \is_string($value) && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1; - case Database::VAR_INTEGER: - $validator = new Range(1, Database::MAX_BIG_INT, Database::VAR_INTEGER); - return $validator->isValid($value); + $idType = ColumnType::tryFrom($this->idAttributeType); - default: - return false; - } + return match ($idType) { + ColumnType::Uuid7 => \is_string($value) && preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1, + ColumnType::Integer => (new Range(1, Database::MAX_BIG_INT, ColumnType::Integer->value))->isValid($value), + default => false, + }; } } diff --git a/src/Database/Validator/Spatial.php b/src/Database/Validator/Spatial.php index 912f05b2b2..eaa3be1e49 100644 --- a/src/Database/Validator/Spatial.php +++ b/src/Database/Validator/Spatial.php @@ -2,14 +2,18 @@ namespace Utopia\Database\Validator; -use Utopia\Database\Database; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator; class Spatial extends Validator { private string $spatialType; + protected string $message = ''; + /** + * @param string $spatialType The spatial type to validate (point, linestring, polygon) + */ public function __construct(string $spatialType) { $this->spatialType = $spatialType; @@ -18,50 +22,54 @@ public function __construct(string $spatialType) /** * Validate POINT data * - * @param array $value - * @return bool + * @param array $value */ protected function validatePoint(array $value): bool { if (count($value) !== 2) { $this->message = 'Point must be an array of two numeric values [x, y]'; + return false; } - if (!is_numeric($value[0]) || !is_numeric($value[1])) { + if (! is_numeric($value[0]) || ! is_numeric($value[1])) { $this->message = 'Point coordinates must be numeric values'; + return false; } - return $this->isValidCoordinate((float)$value[0], (float) $value[1]); + return $this->isValidCoordinate((float) $value[0], (float) $value[1]); } /** * Validate LINESTRING data * - * @param array $value - * @return bool + * @param array $value */ protected function validateLineString(array $value): bool { if (count($value) < 2) { $this->message = 'LineString must contain at least two points'; + return false; } foreach ($value as $pointIndex => $point) { - if (!is_array($point) || count($point) !== 2) { + if (! is_array($point) || count($point) !== 2) { $this->message = 'Each point in LineString must be an array of two values [x, y]'; + return false; } - if (!is_numeric($point[0]) || !is_numeric($point[1])) { + if (! is_numeric($point[0]) || ! is_numeric($point[1])) { $this->message = 'Each point in LineString must have numeric coordinates'; + return false; } - if (!$this->isValidCoordinate((float)$point[0], (float)$point[1])) { + if (! $this->isValidCoordinate((float) $point[0], (float) $point[1])) { $this->message = "Invalid coordinates at point #{$pointIndex}: {$this->message}"; + return false; } } @@ -72,13 +80,13 @@ protected function validateLineString(array $value): bool /** * Validate POLYGON data * - * @param array $value - * @return bool + * @param array $value */ protected function validatePolygon(array $value): bool { if (empty($value)) { $this->message = 'Polygon must contain at least one ring'; + return false; } @@ -92,36 +100,41 @@ protected function validatePolygon(array $value): bool } foreach ($value as $ringIndex => $ring) { - if (!is_array($ring) || empty($ring)) { + if (! is_array($ring) || empty($ring)) { $this->message = "Ring #{$ringIndex} must be an array of points"; + return false; } if (count($ring) < 4) { $this->message = "Ring #{$ringIndex} must contain at least 4 points to form a closed polygon"; + return false; } foreach ($ring as $pointIndex => $point) { - if (!is_array($point) || count($point) !== 2) { + if (! is_array($point) || count($point) !== 2) { $this->message = "Point #{$pointIndex} in ring #{$ringIndex} must be an array of two values [x, y]"; + return false; } - if (!is_numeric($point[0]) || !is_numeric($point[1])) { + if (! is_numeric($point[0]) || ! is_numeric($point[1])) { $this->message = "Coordinates of point #{$pointIndex} in ring #{$ringIndex} must be numeric"; + return false; } - if (!$this->isValidCoordinate((float)$point[0], (float)$point[1])) { + if (! $this->isValidCoordinate((float) $point[0], (float) $point[1])) { $this->message = "Invalid coordinates at point #{$pointIndex} in ring #{$ringIndex}: {$this->message}"; + return false; } } - // Check that the ring is closed (first point == last point) if ($ring[0] !== $ring[count($ring) - 1]) { $this->message = "Ring #{$ringIndex} must be closed (first point must equal last point)"; + return false; } } @@ -130,24 +143,31 @@ protected function validatePolygon(array $value): bool } /** - * Check if a value is valid WKT string + * Check if a value is a valid WKT (Well-Known Text) string. + * + * @param string $value The string to check + * @return bool */ - public static function isWKTString(string $value): bool + public static function isWktString(string $value): bool { $value = trim($value); + return (bool) preg_match('/^(POINT|LINESTRING|POLYGON)\s*\(/i', $value); } + #[\Override] public function getDescription(): string { - return 'Value must be a valid ' . $this->spatialType . ": {$this->message}"; + return 'Value must be a valid '.$this->spatialType.": {$this->message}"; } + #[\Override] public function isArray(): bool { return false; } + #[\Override] public function getType(): string { return self::TYPE_ARRAY; @@ -159,36 +179,43 @@ public function getSpatialType(): string } /** - * Main validation entrypoint + * Validate a spatial value as an array of coordinates or a WKT string. + * + * @param mixed $value The spatial data to validate + * @return bool */ - public function isValid($value): bool + #[\Override] + public function isValid(mixed $value): bool { if (is_null($value)) { return true; } if (is_string($value)) { - return self::isWKTString($value); + return self::isWktString($value); } if (is_array($value)) { - switch ($this->spatialType) { - case Database::VAR_POINT: + $spatialColumnType = ColumnType::tryFrom($this->spatialType); + switch ($spatialColumnType) { + case ColumnType::Point: return $this->validatePoint($value); - case Database::VAR_LINESTRING: + case ColumnType::Linestring: return $this->validateLineString($value); - case Database::VAR_POLYGON: + case ColumnType::Polygon: return $this->validatePolygon($value); default: - $this->message = 'Unknown spatial type: ' . $this->spatialType; + $this->message = 'Unknown spatial type: '.$this->spatialType; + return false; } } $this->message = 'Spatial value must be array or WKT string'; + return false; } @@ -196,11 +223,13 @@ private function isValidCoordinate(int|float $x, int|float $y): bool { if ($x < -180 || $x > 180) { $this->message = "Longitude (x) must be between -180 and 180, got {$x}"; + return false; } if ($y < -90 || $y > 90) { $this->message = "Latitude (y) must be between -90 and 90, got {$y}"; + return false; } diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 22f31a57f7..c249ce8c69 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -4,12 +4,17 @@ use Closure; use Exception; +use Utopia\Database\Adapter\Profile; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Operator; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\Operator as OperatorValidator; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator; use Utopia\Validator\Boolean; use Utopia\Validator\FloatValidator; @@ -20,136 +25,70 @@ class Structure extends Validator { /** - * @var array> + * @var list|null */ - protected array $attributes = [ - [ - '$id' => '$id', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$sequence', - 'type' => Database::VAR_ID, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$collection', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$tenant', - 'type' => Database::VAR_ID, - 'size' => 0, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$permissions', - 'type' => Database::VAR_STRING, - 'size' => 67000, // medium text - 'required' => false, - 'signed' => true, - 'array' => true, - 'filters' => [], - ], - [ - '$id' => '$createdAt', - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'required' => true, - 'signed' => false, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => '$updatedAt', - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'required' => true, - 'signed' => false, - 'array' => false, - 'filters' => [], - ] - ]; + private static ?array $internalAttributes = null; /** * @var array */ protected static array $formats = []; - /** - * @var string - */ protected string $message = 'General Error'; /** - * Structure constructor. + * Internal and collection attributes by key, built on first use. A validator is built for one schema: + * construct a new one after the collection's attributes change. * + * @var array|null */ - public function __construct( - protected readonly Document $collection, - private readonly string $idAttributeType, - private readonly \DateTime $minAllowedDate = new \DateTime('0000-01-01'), - private readonly \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), - private bool $supportForAttributes = true, - private readonly bool $supportUnsignedBigInt = true, - private readonly ?Document $currentDocument = null - ) { - } + private ?array $definitions = null; /** - * Remove a Validator - * - * @return array + * @var array> */ - public static function getFormats(): array - { - return self::$formats; + private array $formatDefinitions = []; + + /** + * @var array + */ + private readonly array $storedAttributes; + + private readonly bool $supportForAttributes; + + private readonly bool $supportUnsignedBigInt; + + /** + * @param list $storedAttributes Attributes whose values are the stored ones, unchanged by the + * write: they are not validated again, as the rules may have + * tightened since those values were stored. + */ + public function __construct( + protected readonly Document $collection, + private readonly Profile $profile, + private readonly ?Document $currentDocument = null, + array $storedAttributes = [], + ) { + $this->storedAttributes = \array_fill_keys($storedAttributes, true); + $this->supportForAttributes = $profile->supports(Capability::DefinedAttributes); + $this->supportUnsignedBigInt = $profile->supports(Capability::UnsignedBigInt); } /** - * Add a new Validator - * Stores a callback and required params to create Validator - * - * @param string $name - * @param Closure $callback Callback that accepts $params in order and returns \Utopia\Validator - * @param string $type Primitive data type for validation + * @param Closure(array): Validator $callback + * @param ColumnType $type Primitive data type for validation */ - public static function addFormat(string $name, Closure $callback, string $type): void + public static function addFormat(string $name, Closure $callback, ColumnType $type): void { self::$formats[$name] = [ 'callback' => $callback, - 'type' => $type, + 'type' => $type->value, ]; } - /** - * Check if validator has been added - * - * @param string $name - * - * @return bool - */ - public static function hasFormat(string $name, string $type): bool + public static function hasFormat(string $name, ColumnType $type): bool { - if (isset(self::$formats[$name]) && self::$formats[$name]['type'] === $type) { + if (isset(self::$formats[$name]) && self::$formats[$name]['type'] === $type->value) { return true; } @@ -157,19 +96,15 @@ public static function hasFormat(string $name, string $type): bool } /** - * Get a Format array to create Validator - * - * @param string $name - * @param string $type - * * @return array{callback: callable, type: string} + * * @throws Exception */ - public static function getFormat(string $name, string $type): array + public static function getFormat(string $name, ColumnType $type): array { if (isset(self::$formats[$name])) { - if (self::$formats[$name]['type'] !== $type) { - throw new DatabaseException('Format "'.$name.'" not available for attribute type "'.$type.'"'); + if (self::$formats[$name]['type'] !== $type->value) { + throw new DatabaseException('Format "'.$name.'" not available for attribute type "'.$type->value.'"'); } return self::$formats[$name]; @@ -178,67 +113,50 @@ public static function getFormat(string $name, string $type): array throw new DatabaseException('Unknown format validator "'.$name.'"'); } - /** - * Remove a Validator - * - * @param string $name - */ public static function removeFormat(string $name): void { unset(self::$formats[$name]); } - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ + #[\Override] public function getDescription(): string { return 'Invalid document structure: '.$this->message; } - /** - * Is valid. - * - * Returns true if valid or false if not. - * - * @param mixed $document - * - * @return bool - */ - public function isValid($document): bool + #[\Override] + public function isValid(mixed $value): bool { - if (!$document instanceof Document) { + if (! $value instanceof Document) { $this->message = 'Value must be an instance of Document'; + return false; } - if (empty($document->getCollection())) { - $this->message = 'Missing collection attribute $collection'; + if (empty($value->getCollection())) { + $this->message = 'Missing collection attribute '.Document::COLLECTION; + return false; } - if (empty($this->collection->getId()) || Database::METADATA !== $this->collection->getCollection()) { + if (empty($this->collection->getId()) || $this->collection->getCollection() !== Database::METADATA) { $this->message = 'Collection not found'; + return false; } - $keys = []; - $structure = $document->getArrayCopy(); - $attributes = \array_merge($this->attributes, $this->collection->getAttribute('attributes', [])); + $structure = $value->getArrayCopy(); + $definitions = $this->definitions(); - if (!$this->checkForAllRequiredValues($structure, $attributes, $keys)) { + if (! $this->checkForAllRequiredValues($structure, $definitions)) { return false; } - if (!$this->checkForUnknownAttributes($structure, $keys)) { + if (! $this->checkForUnknownAttributes($structure, $definitions)) { return false; } - if (!$this->checkForInvalidAttributeValues($document, $structure, $keys)) { + if (! $this->checkForInvalidAttributeValues($value, $structure, $definitions)) { return false; } @@ -246,33 +164,57 @@ public function isValid($document): bool } /** - * Check for all required values - * - * @param array $structure - * @param array $attributes - * @param array $keys - * - * @return bool + * @return list */ - protected function checkForAllRequiredValues(array $structure, array $attributes, array &$keys): bool + protected static function internalAttributes(): array { - if (!$this->supportForAttributes) { - return true; + return self::$internalAttributes ??= [ + Attribute::string(Document::ID, 255), + Attribute::id(Document::SEQUENCE), + Attribute::string(Document::COLLECTION, 255, required: true), + Attribute::id(Document::TENANT), + Attribute::string(Document::PERMISSIONS, 67000, array: true), + Attribute::datetime(Document::CREATED_AT, required: true), + Attribute::datetime(Document::UPDATED_AT, required: true), + ]; + } + + /** + * @return array + */ + protected function definitions(): array + { + if ($this->definitions !== null) { + return $this->definitions; } - foreach ($attributes as $attribute) { // Check all required attributes are set - $name = $attribute['$id'] ?? ''; - $required = $attribute['required'] ?? false; + $collection = Collection::fromDocument($this->collection); - $keys[$name] = $attribute; // List of allowed attributes to help find unknown ones + $definitions = []; + foreach (self::internalAttributes() as $attribute) { + $definitions[$attribute->key] = $attribute; + } + foreach ($collection->attributes() as $attribute) { + $definitions[$attribute->key] = $attribute; + } - if ($required && !isset($structure[$name])) { - // Documents stored before the attribute became required hold null, and may keep it - if ($this->currentDocument !== null && $this->currentDocument->getAttribute($name) === null) { - continue; - } + return $this->definitions = $definitions; + } + + /** + * @param array $structure + * @param array $attributes + */ + protected function checkForAllRequiredValues(array $structure, array $attributes): bool + { + if (! $this->supportForAttributes) { + return true; + } + + foreach ($attributes as $attribute) { + if ($attribute->required && ! isset($structure[$attribute->key]) && ! $this->isStoredAsNull($attribute->key)) { + $this->message = 'Missing required attribute "'.$attribute->key.'"'; - $this->message = 'Missing required attribute "'.$name.'"'; return false; } } @@ -281,21 +223,26 @@ protected function checkForAllRequiredValues(array $structure, array $attributes } /** - * Check for Unknown Attributes - * - * @param array $structure - * @param array $keys - * - * @return bool + * A document stored before the attribute became required holds null for it, and may keep it. */ - protected function checkForUnknownAttributes(array $structure, array $keys): bool + private function isStoredAsNull(string $key): bool { - if (!$this->supportForAttributes) { + return $this->currentDocument !== null && $this->currentDocument->getAttribute($key) === null; + } + + /** + * @param array $structure + * @param array $definitions + */ + protected function checkForUnknownAttributes(array $structure, array $definitions): bool + { + if (! $this->supportForAttributes) { return true; } foreach ($structure as $key => $value) { - if (!array_key_exists($key, $keys)) { // Check no unknown attributes are set + if (! isset($definitions[$key])) { $this->message = 'Unknown attribute: "'.$key.'"'; + return false; } } @@ -304,175 +251,179 @@ protected function checkForUnknownAttributes(array $structure, array $keys): boo } /** - * Check for invalid attribute values - * - * @param array $structure - * @param array $keys - * - * @return bool + * @param array $structure + * @param array $definitions */ - protected function checkForInvalidAttributeValues(Document $document, array $structure, array $keys): bool + protected function checkForInvalidAttributeValues(Document $document, array $structure, array $definitions): bool { foreach ($structure as $key => $value) { if (Operator::isOperator($value)) { - // Set the attribute name on the operator for validation + /** @var Operator $value */ $value->setAttribute($key); - $operatorValidator = new OperatorValidator($this->collection, $this->currentDocument); - if (!$operatorValidator->isValid($value)) { + $operatorValidator = new OperatorValidator( + $this->collection, + $this->currentDocument, + $this->supportUnsignedBigInt, + ); + if (! $operatorValidator->isValid($value)) { $this->message = $operatorValidator->getDescription(); + return false; } + continue; } - $attribute = $keys[$key] ?? []; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; - $format = $attribute['format'] ?? ''; - $required = $attribute['required'] ?? false; - $size = $attribute['size'] ?? 0; - $signed = $attribute['signed'] ?? true; + if (isset($this->storedAttributes[$key])) { + continue; + } - if ($required === false && is_null($value)) { // Allow null value to optional params + $attribute = $definitions[$key] ?? null; + if ($attribute === null) { continue; } - if (is_null($value) && $this->currentDocument !== null && $this->currentDocument->getAttribute($key) === null) { + $type = $attribute->type; + $size = $attribute->size ?? 0; + $signed = $attribute->signed; + $required = $attribute->required; + + if (is_null($value) && ($required === false || $this->isStoredAsNull($key))) { continue; } - if ($type === Database::VAR_RELATIONSHIP) { + if ($type === ColumnType::Relationship) { continue; } - // BIGINT accepts both PHP int and numeric strings. - // If the numeric string is within PHP's int range, normalize it to an int - // so downstream code gets a numeric value without precision loss. - if ($type === Database::VAR_BIGINT && \is_string($value) && BigInt::fitsPhpInt($value, $signed)) { - $normalized = (int)$value; - $document->setAttribute($key, $normalized); - $value = $normalized; + if ($type === ColumnType::BigInteger && \is_string($value) && BigInt::fitsPhpInt($value, $signed)) { + $value = (int) $value; + $document->setAttribute($key, $value); } $validators = []; switch ($type) { - case Database::VAR_ID: - $validators[] = new Sequence($this->idAttributeType, $attribute['$id'] === '$sequence'); + case ColumnType::Id: + $validators[] = new Sequence($this->profile->limits->idType->value, $key === Document::SEQUENCE); break; - case Database::VAR_TEXT: + case ColumnType::Text: $validators[] = new ByteLength($size); $validators[] = new ByteLength(Database::MAX_TEXT_BYTES); break; - case Database::VAR_MEDIUMTEXT: + case ColumnType::MediumText: $validators[] = new ByteLength($size); $validators[] = new ByteLength(Database::MAX_MEDIUMTEXT_BYTES); break; - case Database::VAR_LONGTEXT: + case ColumnType::LongText: $validators[] = new ByteLength($size); $validators[] = new ByteLength(Database::MAX_LONGTEXT_BYTES); break; - case Database::VAR_VARCHAR: - case Database::VAR_STRING: + case ColumnType::Varchar: + case ColumnType::String: $validators[] = new Text($size, min: 0); break; - case Database::VAR_INTEGER: - // Determine bit size based on attribute size in bytes - // BIGINT is always 64-bit in SQL adapters; VAR_INTEGER uses size to decide. - $bits = $size >= 8 ? 64 : 32; + case ColumnType::Integer: + $bits = $size >= 8 ? 64 : 32; // For 64-bit unsigned, use signed since PHP doesn't support true 64-bit unsigned // The Range validator will restrict to positive values only - $unsigned = !$signed && $bits < 64; + $unsigned = ! $signed && $bits < 64; $validators[] = new Integer(false, $bits, $unsigned); $max = $bits === 64 ? Database::MAX_BIG_INT : Database::MAX_INT; $min = $signed ? -$max : 0; - $validators[] = new Range($min, $max, Database::VAR_INTEGER); + $validators[] = new Range($min, $max, ColumnType::Integer->value); break; - case Database::VAR_BIGINT: + case ColumnType::BigInteger: $validators[] = new BigInt($signed, $this->supportUnsignedBigInt); break; - case Database::VAR_FLOAT: + case ColumnType::Float: + case ColumnType::Double: // We need both Float and Range because Range implicitly casts non-numeric values $validators[] = new FloatValidator(); $min = $signed ? -Database::MAX_DOUBLE : 0; - $validators[] = new Range($min, Database::MAX_DOUBLE, Database::VAR_FLOAT); + $validators[] = new Range($min, Database::MAX_DOUBLE, ColumnType::Double->value); break; - case Database::VAR_BOOLEAN: + case ColumnType::Boolean: $validators[] = new Boolean(); break; - case Database::VAR_DATETIME: + case ColumnType::Datetime: $validators[] = new DatetimeValidator( - min: $this->minAllowedDate, - max: $this->maxAllowedDate + min: $this->profile->limits->minDateTime, + max: $this->profile->limits->maxDateTime ); break; - case Database::VAR_OBJECT: - $validators[] = new ObjectValidator(); + case ColumnType::Object: + $validators[] = new ObjectValue(); break; - case Database::VAR_POINT: - case Database::VAR_LINESTRING: - case Database::VAR_POLYGON: - $validators[] = new Spatial($type); + case ColumnType::Point: + case ColumnType::Linestring: + case ColumnType::Polygon: + $validators[] = new Spatial($type->value); break; - case Database::VAR_VECTOR: - $validators[] = new Vector($attribute['size'] ?? 0); + case ColumnType::Vector: + $validators[] = new Vector($size); break; default: if ($this->supportForAttributes) { - $this->message = 'Unknown attribute type "'.$type.'"'; + $this->message = 'Unknown attribute type "'.$type->value.'"'; + return false; } } - /** Error message label, either 'format' or 'type' */ - $label = ($format) ? 'format' : 'type'; + $format = $attribute->format?->name; + $label = $format !== null ? 'format' : 'type'; - if ($format) { - // Format encoded as json string containing format name and relevant format options - $format = self::getFormat($format, $type); - $validators[] = $format['callback']($attribute); + if ($format !== null) { + $definition = self::getFormat($format, $type); + /** @var Validator $formatValidator */ + $formatValidator = $definition['callback']($this->formatDefinitions[$key] ??= $attribute->toDocument()->getArrayCopy()); + $validators[] = $formatValidator; } - if ($array) { // Validate attribute type for arrays - format for arrays handled separately - if (!$required && ((is_array($value) && empty($value)) || is_null($value))) { // Allow both null and [] for optional arrays + if ($attribute->array) { + if (! $required && ((is_array($value) && empty($value)) || is_null($value))) { continue; } - if (!\is_array($value) || !\array_is_list($value)) { + if (! \is_array($value) || ! \array_is_list($value)) { $this->message = 'Attribute "'.$key.'" must be an array'; + return false; } foreach ($value as $x => $child) { - if (!$required && is_null($child)) { // Allow null value to optional params + if (! $required && is_null($child)) { continue; } foreach ($validators as $validator) { - if (!$validator->isValid($child)) { + if (! $validator->isValid($child)) { $this->message = 'Attribute "'.$key.'[\''.$x.'\']" has invalid '.$label.'. '.$validator->getDescription(); + return false; } } } } else { foreach ($validators as $validator) { - if (!$validator->isValid($value)) { + if (! $validator->isValid($value)) { $this->message = 'Attribute "'.$key.'" has invalid '.$label.'. '.$validator->getDescription(); + return false; } } @@ -482,25 +433,13 @@ protected function checkForInvalidAttributeValues(Document $document, array $str return true; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_ARRAY; diff --git a/src/Database/Validator/UID.php b/src/Database/Validator/UID.php index 743adbcde1..7ee9d6e8d1 100644 --- a/src/Database/Validator/UID.php +++ b/src/Database/Validator/UID.php @@ -6,23 +6,14 @@ class UID extends Key { - /** - * Expression constructor - */ public function __construct(int $maxLength = Database::MAX_UID_DEFAULT_LENGTH) { parent::__construct(false, $maxLength); } - /** - * Get Description. - * - * Returns validator description - * - * @return string - */ + #[\Override] public function getDescription(): string { - return 'UID must contain at most ' . $this->maxLength . ' chars. Valid chars are a-z, A-Z, 0-9, and underscore. Can\'t start with a leading underscore'; + return 'UID must contain at most '.$this->maxLength.' chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a leading period, hyphen, or underscore'; } } diff --git a/src/Database/Validator/Vector.php b/src/Database/Validator/Vector.php index b81d0b3aa7..c40d742c61 100644 --- a/src/Database/Validator/Vector.php +++ b/src/Database/Validator/Vector.php @@ -9,50 +9,38 @@ class Vector extends Validator protected int $size; /** - * Vector constructor. - * - * @param int $size The size (number of elements) the vector should have + * @param int $size The size (number of elements) the vector should have */ public function __construct(int $size) { $this->size = $size; } - /** - * Get Description - * - * Returns validator description - * - * @return string - */ + #[\Override] public function getDescription(): string { return "Value must be an array of {$this->size} numeric values"; } /** - * Is valid - * * Validation will pass when $value is a valid vector array or JSON string - * - * @param mixed $value - * @return bool */ + #[\Override] public function isValid(mixed $value): bool { if (is_string($value)) { $decoded = json_decode($value, true); - if (!is_array($decoded)) { + if (! is_array($decoded)) { return false; } $value = $decoded; } - if (!is_array($value)) { + if (! is_array($value)) { return false; } - if (!\array_is_list($value)) { + if (! \array_is_list($value)) { return false; } @@ -60,9 +48,8 @@ public function isValid(mixed $value): bool return false; } - // Check that all values are int or float (not strings, booleans, null, arrays, objects) foreach ($value as $component) { - if (!\is_int($component) && !\is_float($component)) { + if (! \is_int($component) && ! \is_float($component)) { return false; } } @@ -70,25 +57,13 @@ public function isValid(mixed $value): bool return true; } - /** - * Is array - * - * Function will return true if object is array. - * - * @return bool - */ + #[\Override] public function isArray(): bool { return false; } - /** - * Get Type - * - * Returns validator type. - * - * @return string - */ + #[\Override] public function getType(): string { return self::TYPE_ARRAY; diff --git a/stubs/Swoole/Database/DetectsLostConnections.stub.php b/stubs/Swoole/Database/DetectsLostConnections.stub.php new file mode 100644 index 0000000000..4070b4f036 --- /dev/null +++ b/stubs/Swoole/Database/DetectsLostConnections.stub.php @@ -0,0 +1,12 @@ + $arguments */ + public function __call(string $name, array $arguments): mixed + { + } + + public function __getObject(): PDO + { + } + + public function beginTransaction(): bool + { + } + + public function commit(): bool + { + } + + public function exec(string $statement): int|false + { + } + + public function getAttribute(int $attribute): mixed + { + } + + public function getRound(): int + { + } + + public function inTransaction(): bool + { + } + + public function lastInsertId(?string $name = null): string|false + { + } + + /** @param array $options */ + public function prepare(string $query, array $options = []): PDOStatementProxy|false + { + } + + public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatementProxy|false + { + } + + public function quote(string $string, int $type = PDO::PARAM_STR): string|false + { + } + + public function reconnect(): void + { + } + + public function reset(): void + { + } + + public function rollBack(): bool + { + } + + public function setAttribute(int $attribute, mixed $value): bool + { + } +} diff --git a/stubs/Swoole/Database/PDOStatementProxy.stub.php b/stubs/Swoole/Database/PDOStatementProxy.stub.php new file mode 100644 index 0000000000..a5ceddd97f --- /dev/null +++ b/stubs/Swoole/Database/PDOStatementProxy.stub.php @@ -0,0 +1,86 @@ + $arguments */ + public function __call(string $name, array $arguments): mixed + { + } + + public function bindColumn( + int|string $column, + mixed &$variable, + int $type = PDO::PARAM_STR, + int $maxLength = 0, + mixed $driverOptions = null, + ): bool { + } + + public function bindParam( + int|string $param, + mixed &$variable, + int $type = PDO::PARAM_STR, + int $maxLength = 0, + mixed $driverOptions = null, + ): bool { + } + + public function bindValue(int|string $param, mixed $value, int $type = PDO::PARAM_STR): bool + { + } + + public function closeCursor(): bool + { + } + + /** @param array|null $params */ + public function execute(?array $params = null): bool + { + } + + public function fetch( + int $mode = PDO::FETCH_DEFAULT, + int $cursorOrientation = PDO::FETCH_ORI_NEXT, + int $cursorOffset = 0, + ): mixed { + } + + /** @return array */ + public function fetchAll(int $mode = PDO::FETCH_DEFAULT, mixed ...$args): array + { + } + + public function fetchColumn(int $column = 0): mixed + { + } + + public function getAttribute(int $attribute): mixed + { + } + + public function rowCount(): int + { + } + + public function setAttribute(int $attribute, mixed $value): bool + { + } + + public function setFetchMode(int $mode, mixed ...$args): bool + { + } +} diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 4baeba35b0..7e8058f9c0 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -2,84 +2,192 @@ namespace Tests\E2E\Adapter; +use PDO; use PHPUnit\Framework\TestCase; +use Tests\E2E\Adapter\Scopes\AggregationTests; use Tests\E2E\Adapter\Scopes\AttributeTests; use Tests\E2E\Adapter\Scopes\CollectionTests; use Tests\E2E\Adapter\Scopes\CustomDocumentTypeTests; +use Tests\E2E\Adapter\Scopes\DatabaseTests; use Tests\E2E\Adapter\Scopes\DocumentTests; use Tests\E2E\Adapter\Scopes\GeneralTests; use Tests\E2E\Adapter\Scopes\IndexTests; +use Tests\E2E\Adapter\Scopes\JoinComboTests; +use Tests\E2E\Adapter\Scopes\JoinTests; +use Tests\E2E\Adapter\Scopes\MetadataCacheTests; use Tests\E2E\Adapter\Scopes\ObjectAttributeTests; use Tests\E2E\Adapter\Scopes\OperatorTests; use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; use Tests\E2E\Adapter\Scopes\SchemalessTests; +use Tests\E2E\Adapter\Scopes\SchemaReconciliationTests; use Tests\E2E\Adapter\Scopes\SpatialTests; use Tests\E2E\Adapter\Scopes\VectorTests; +use Utopia\Database\Adapter; +use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Adapter\Memory; +use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Adapter\MySQL; +use Utopia\Database\Adapter\Pool; +use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Adapter\SQL; +use Utopia\Database\Adapter\SQLite; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Hook\Permissions; +use Utopia\Database\Hook\Relationships; use Utopia\Database\Validator\Authorization; \ini_set('memory_limit', '2048M'); abstract class Base extends TestCase { + use AggregationTests; + use AttributeTests; use CollectionTests; use CustomDocumentTypeTests; + use DatabaseTests; use DocumentTests; - use AttributeTests; + use GeneralTests; use IndexTests; + use JoinComboTests; + use JoinTests; + use MetadataCacheTests; + use ObjectAttributeTests; use OperatorTests; use PermissionTests; use RelationshipTests; - use SpatialTests; + use SchemaReconciliationTests; use SchemalessTests; - use ObjectAttributeTests; + use SpatialTests; use VectorTests; - use GeneralTests; - - protected static string $namespace; /** - * @var Authorization + * @var array */ + protected const array PDO_ATTRIBUTES = [ + PDO::ATTR_TIMEOUT => 3, + PDO::ATTR_PERSISTENT => true, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + + protected static string $namespace; + protected static ?Authorization $authorization = null; - /** - * @return Database - */ abstract protected function getDatabase(): Database; - /** - * @param string $collection - * @param string $column - * - * @return bool - */ abstract protected function deleteColumn(string $collection, string $column): bool; - /** - * @param string $collection - * @param string $index - * - * @return bool - */ abstract protected function deleteIndex(string $collection, string $index): bool; - public function setUp(): void + #[\Override] + protected function setUp(): void { + $this->testDatabase = 'utopiaTests_'.static::getTestToken(); + if (is_null(self::$authorization)) { self::$authorization = new Authorization(); } self::$authorization->addRole('any'); + + $this->getDatabase() + ->removeHook(Relationships::class)->addHook(new Relationships()) + ->removeHook(Permissions::class)->addHook(new Permissions()); } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { - self::$authorization->setDefaultStatus(true); + self::$authorization?->reset(); } protected string $testDatabase = 'utopiaTests'; + protected static function getTestToken(): string + { + return getenv('TEST_TOKEN') ?: getenv('UNIQUE_TEST_TOKEN') ?: (string) getmypid(); + } + + /** + * @param list> $rows + * @return list + */ + protected static function rowDocuments(array $rows): array + { + return \array_map(static fn (array $row): Document => new Document($row), $rows); + } + + /** + * Whether the tests run on one of the engines, for the few assertions that differ by engine. Test-only: a Pool + * lends one adapter per call and setNamespace() hands that adapter back, which only a plain Pool allows; a + * ReadWritePool would route setNamespace() as a write. + * + * @param class-string ...$engines + */ + private function engineIs(string ...$engines): bool + { + $adapter = $this->getDatabase()->getAdapter(); + if ($adapter instanceof Pool) { + $adapter = $adapter->delegate('setNamespace', [$adapter->getNamespace()]); + } + + foreach ($engines as $engine) { + if ($adapter instanceof $engine) { + return true; + } + } + + return false; + } + + /** + * Engine properties the 7.x capabilities BoundaryInclusive, BatchOperations, AtomicTransactions, + * CacheSkipOnFailure, BitwiseAggregates, MultiDimensionDistance, OptionalSpatial and POSIX declared; 8.0 deleted + * them (DEC-15), so each reads the engine with the truth table those declarations had. + */ + private function spatialIncludesBoundaries(): bool + { + return $this->engineIs(MariaDB::class, Postgres::class, SQLite::class, Memory::class) && ! $this->engineIs(MySQL::class); + } + + private function supportsBulkWrites(): bool + { + return ! $this->engineIs(Mongo::class); + } + + private function supportsAtomicTransactions(): bool + { + return $this->engineIs(SQL::class, Memory::class); + } + + private function skipsCacheOnFailure(): bool + { + return $this->engineIs(SQL::class); + } + + private function supportsBitwiseAggregates(): bool + { + return $this->engineIs(SQL::class) && ! $this->engineIs(SQLite::class); + } + + private function supportsMultiDimensionDistance(): bool + { + return $this->engineIs(MySQL::class, Postgres::class); + } + + private function supportsOptionalSpatial(): bool + { + return $this->engineIs(MariaDB::class) && ! $this->engineIs(MySQL::class); + } + + private function usesPosixRegex(): bool + { + return $this->engineIs(Postgres::class); + } } diff --git a/tests/e2e/Adapter/MariaDBTest.php b/tests/e2e/Adapter/MariaDBTest.php index 923de242ed..dbea51b47f 100644 --- a/tests/e2e/Adapter/MariaDBTest.php +++ b/tests/e2e/Adapter/MariaDBTest.php @@ -2,25 +2,40 @@ namespace Tests\E2E\Adapter; +use PDO as PhpPDO; use Redis; +use Swoole\Coroutine; +use Swoole\Runtime; +use Throwable; +use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Exception\Contention as ContentionException; +use Utopia\Database\Hook\Permissions; use Utopia\Database\PDO; +use Utopia\Database\Permission; +use Utopia\Database\Role; + +use function Swoole\Coroutine\run; class MariaDBTest extends Base { protected static ?Database $database = null; + protected static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; - /** - * @return Database - */ + #[\Override] public function getDatabase(bool $fresh = false): Database { - if (!is_null(self::$database) && !$fresh) { + if (! is_null(self::$database) && ! $fresh) { return self::$database; } @@ -29,18 +44,19 @@ public function getDatabase(bool $fresh = false): Database $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); + $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(0); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); $database = new Database(new MariaDB($pdo), $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setDatabase($this->testDatabase) + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); if ($database->exists()) { $database->delete(); @@ -49,24 +65,133 @@ public function getDatabase(bool $fresh = false): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + /** + * Two transactions lock the same missing row range FOR UPDATE, then each inserts its own row through a nested + * transaction, as cloud's concurrent billing aggregations do. InnoDB rolls the deadlock's loser back whole, + * savepoint included. Nothing of the loser's attempt is stored, so it runs again and both rows commit. + */ + public function testTransactionsDeadlockedOnALockedGapBothCommit(): void + { + if (! \extension_loaded('swoole')) { + $this->markTestSkipped('Concurrent transactions need Swoole coroutines.'); + } + + $database = $this->getDatabase(); + $collection = 'deadlockedGap'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::integer(key: 'count', required: false)], + permissions: [Permission::read(Role::any()), Permission::create(Role::any())], + )); + + $hooks = Runtime::getHookFlags(); + $options = Coroutine::getOptions()['hook_flags'] ?? SWOOLE_HOOK_ALL; + /** @var int $arrived */ + $arrived = 0; + /** @var array $attempts */ + $attempts = []; + /** @var list $conflicts */ + $conflicts = []; + /** @var array $failures */ + $failures = []; + + Coroutine::set(['hook_flags' => SWOOLE_HOOK_ALL]); + + try { + run(function () use ($collection, &$arrived, &$attempts, &$conflicts, &$failures): void { + foreach (['starter', 'pro'] as $id) { + Coroutine::create(function () use ($collection, $id, &$arrived, &$attempts, &$conflicts, &$failures): void { + try { + $connection = $this->connect(); + $attempts[$id] = 0; + $connection->withTransaction(function () use ($connection, $collection, $id, &$arrived, &$attempts, &$conflicts): void { + $attempts[$id]++; + $connection->getDocument($collection, $id, forUpdate: true); + + if ($attempts[$id] === 1) { + $arrived++; + for ($waited = 0; $arrived < 2 && $waited < 500; $waited++) { + Coroutine::sleep(0.01); + } + } + + try { + $connection->createDocument($collection, new Document(['$id' => $id, 'count' => 1])); + } catch (ContentionException $error) { + $conflicts[] = $error->getMessage(); + + throw $error; + } + }); + } catch (Throwable $error) { + $failures[$id] = $error::class.': '.$error->getMessage(); + } + }); + } + }); + } finally { + Coroutine::set(['hook_flags' => $options]); + Runtime::setHookFlags($hooks); + } + + try { + $this->assertSame([], $failures, 'Both transactions must commit'); + $this->assertSame(['Deadlock detected'], $conflicts, 'Exactly one transaction must lose the deadlock'); + $counts = \array_values($attempts); + \sort($counts); + $this->assertSame([1, 2], $counts, 'Only the loser runs again'); + $this->assertFalse($database->getDocument($collection, 'starter')->isEmpty()); + $this->assertFalse($database->getDocument($collection, 'pro')->isEmpty()); + } finally { + $database->deleteCollection($collection); + } + } + + private function connect(): Database + { + $pdo = new PDO( + 'mysql:host=mariadb;port=3306;charset=utf8mb4', + 'root', + 'password', + [PhpPDO::ATTR_PERSISTENT => false] + self::PDO_ATTRIBUTES, + ); + + $main = $this->getDatabase(); + $database = new Database(new MariaDB($pdo), new Cache(new NoCache())); + assert(self::$authorization !== null); + $database + ->setAuthorization(self::$authorization) + ->setDatabase($main->getDatabase()) + ->setNamespace($main->getNamespace()); + + $database->addHook(new Permissions()); + + return $database; + } + + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "DROP INDEX `{$index}` ON {$sqlTable}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/MemoryTest.php b/tests/e2e/Adapter/MemoryTest.php index ec0140ce88..1d63dfbf79 100644 --- a/tests/e2e/Adapter/MemoryTest.php +++ b/tests/e2e/Adapter/MemoryTest.php @@ -6,31 +6,36 @@ use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; /** * E2E tests for the in-memory adapter. Inherits the standard adapter scopes * from Base so it is exercised against the same scenarios as MariaDB/MySQL/ * Postgres. Scope tests that depend on features Memory does not implement - * (relationships, operators, vectors, spatial, fulltext, schemaless, - * object attributes) self-skip via the adapter's getSupportFor* flags. + * (upserts, operators, vectors, spatial, fulltext, schemaless, object + * attributes) self-skip via Feature instanceof / Capability checks. Memory + * implements relationships. * * The test methods declared directly on this class are Memory-specific * regressions for behaviour that is not exercised — or not exercised in the - * same way — by the inherited scopes (transaction nesting semantics, raw - * adapter store layout after attribute operations, tenancy on the in-process - * map, etc.). + * same way — by the inherited scopes (transaction nesting semantics, the + * cascades an attribute rename or drop leaves behind, tenancy on the + * in-process map, etc.). */ class MemoryTest extends Base { public static ?Database $database = null; + #[\Override] protected static string $namespace; public static function getAdapterName(): string @@ -38,6 +43,7 @@ public static function getAdapterName(): string return 'memory'; } + #[\Override] public function getDatabase(): Database { if (!is_null(self::$database)) { @@ -50,9 +56,10 @@ public function getDatabase(): Database $cache = new Cache(new RedisAdapter($redis)); $database = new Database(new Memory(), $cache); + $authorization = self::$authorization ?? throw new \RuntimeException('Authorization not initialised'); $database - ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') + ->setAuthorization($authorization) + ->setDatabase($this->testDatabase) ->setNamespace(static::$namespace = 'memory_' . uniqid()); if ($database->exists()) { @@ -64,6 +71,7 @@ public function getDatabase(): Database return self::$database = $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { // Memory has no out-of-band schema mutation path; tests that exercise @@ -71,6 +79,7 @@ protected function deleteColumn(string $collection, string $column): bool return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { return true; @@ -88,18 +97,20 @@ private function freshDatabase(): Database $cache = new Cache(new RedisAdapter($redis)); $database = new Database(new Memory(), $cache); + $authorization = self::$authorization ?? throw new \RuntimeException('Authorization not initialised'); $database - ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') + ->setAuthorization($authorization) + ->setDatabase($this->testDatabase) ->setNamespace('memory_iso_' . uniqid()); $database->create(); return $database; } /** - * The inherited scope test does not gate on getSupportForUpserts(); skip - * here because Memory throws on upsert by design. + * The inherited scope test does not gate on instanceof Feature\Upserts; + * skip here because Memory throws on upsert by design. */ + #[\Override] public function testUpsertWithJSONFilters(): void { $this->markTestSkipped('Memory adapter does not implement upserts.'); @@ -107,60 +118,39 @@ public function testUpsertWithJSONFilters(): void /** * Operator scope tests that combine upserts with operators only gate on - * getSupportForOperators() — Memory doesn't implement upserts, so we + * Capability::Operators — Memory doesn't implement Feature\Upserts, so we * skip the upsert variants explicitly. */ + #[\Override] public function testBulkUpsertWithOperatorsCallbackReceivesFreshData(): void { $this->markTestSkipped('Memory adapter does not implement upserts.'); } + #[\Override] public function testSingleUpsertWithOperators(): void { $this->markTestSkipped('Memory adapter does not implement upserts.'); } + #[\Override] public function testUpsertOperatorsOnNewDocuments(): void { $this->markTestSkipped('Memory adapter does not implement upserts.'); } + #[\Override] public function testUpsertDocumentsWithAllOperators(): void { $this->markTestSkipped('Memory adapter does not implement upserts.'); } - /** - * Inherited test creates a self-relationship; Memory has no relationships. - */ - public function testAttributeNamesWithDots(): void - { - $this->markTestSkipped('Memory adapter does not implement relationships.'); - } - - /** - * Inherited test asserts permission cascade through a relationship. - * - * @return array - */ - public function testCollectionPermissionsRelationships(): array - { - $this->markTestSkipped('Memory adapter does not implement relationships.'); - } - - /** - * Inherited test asserts cursor ordering across a relationship join. - */ - public function testOrderAndCursorWithRelationshipQueries(): void - { - $this->markTestSkipped('Memory adapter does not implement relationships.'); - } - /** * Inherited test depends on PDO's automatic int->string coercion when an * INTEGER column is altered to VARCHAR. Memory keeps native PHP scalars, * so the historical int payload remains an int after the type change. */ + #[\Override] public function testUpdateAttributeStructure(): void { $this->markTestSkipped( @@ -173,6 +163,7 @@ public function testUpdateAttributeStructure(): void * Inherited test exercises VARCHAR truncation when shrinking a column * that holds oversize data. Memory does not enforce string sizes on disk. */ + #[\Override] public function testUpdateAttributeSize(): void { $this->markTestSkipped( @@ -185,6 +176,7 @@ public function testUpdateAttributeSize(): void * Memory has no reserved keyword list; the inherited test then has no * keywords to iterate over and is flagged risky. */ + #[\Override] public function testKeywords(): void { $this->markTestSkipped('Memory has no reserved keywords.'); @@ -192,14 +184,12 @@ public function testKeywords(): void /** * Memory does not implement upserts. Inherited scope tests that rely on - * upserts skip themselves via getSupportForUpserts(). + * upserts skip themselves via instanceof Feature\Upserts. */ public function testUpsertIsNotImplemented(): void { - $collection = new Document(['$id' => 'any']); - $this->expectException(\Utopia\Database\Exception::class); - $this->freshDatabase()->getAdapter()->upsertDocuments($collection, '', []); + $this->freshDatabase()->upsertDocuments('any', []); } /** @@ -210,20 +200,12 @@ public function testNestedTransactionRollbackOnlyDiscardsInner(): void { $database = $this->freshDatabase(); - $database->createCollection('nested', [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + $database->createCollection(Collection::create(id: 'nested', attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ]); + ])); $adapter = $database->getAdapter(); $adapter->startTransaction(); @@ -256,20 +238,12 @@ public function testArrayAttributeRoundTrip(): void { $database = $this->freshDatabase(); - $database->createCollection('lists', [ - new Document([ - '$id' => 'tags', - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => false, - 'signed' => true, - 'array' => true, - 'filters' => [], - ]), - ], [], [ + $database->createCollection(Collection::create(id: 'lists', attributes: [ + Attribute::string(key: 'tags', size: 64, array: true), + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ]); + ])); $database->createDocument('lists', new Document([ '$id' => 'l1', @@ -281,11 +255,6 @@ public function testArrayAttributeRoundTrip(): void $this->assertSame(['php', 'memory', 'adapter'], $fetched->getAttribute('tags')); } - /** - * Regression: CREATE UNIQUE INDEX on a collection that already contains - * duplicate values must surface UniqueException at the adapter layer - * (matches MariaDB errno 1062). - */ public function testCreateUniqueIndexRejectsExistingDuplicates(): void { $adapter = new Memory(); @@ -301,7 +270,8 @@ public function testCreateUniqueIndexRejectsExistingDuplicates(): void ); $this->expectException(UniqueException::class); - $adapter->createIndex('emails', 'unique_addr', Database::INDEX_UNIQUE, ['addr'], [], []); + $this->expectExceptionMessage(UniqueException::MESSAGE); + $adapter->createIndex('emails', Index::unique(key: 'unique_addr', attributes: ['addr'])); } /** @@ -312,26 +282,14 @@ public function testUniqueIndexAllowsMultipleNulls(): void { $database = $this->freshDatabase(); - $database->createCollection('optional', [ - new Document([ - '$id' => 'token', - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [ - new Document([ - '$id' => 'unique_token', - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['token'], - ]), - ], [ + $database->createCollection(Collection::create(id: 'optional', attributes: [ + Attribute::string(key: 'token', size: 64), + ], indexes: [ + Index::unique(key: 'unique_token', attributes: ['token']), + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ]); + ])); $database->createDocument('optional', new Document([ '$id' => 'a', @@ -348,65 +306,125 @@ public function testUniqueIndexAllowsMultipleNulls(): void } /** - * Regression: updateAttribute applies metadata after a rename — the new - * key carries the new size, the old key is gone. + * Regression: updateAttribute given a new key renames the attribute, so + * the stored values answer to the new key and no longer to the old one. */ - public function testUpdateAttributeAppliesMetadataAfterRename(): void + public function testUpdateAttributeMovesStoredValuesToTheNewKey(): void { $adapter = new Memory(); $adapter->setNamespace('rename_' . \uniqid()); $adapter->createCollection('renames', [], []); - $adapter->createAttribute('renames', 'old', Database::VAR_STRING, 64); + $adapter->createAttribute('renames', Attribute::string(key: 'old', size: 64)); - $adapter->updateAttribute('renames', 'old', Database::VAR_STRING, 256, true, false, 'fresh'); + $collection = new Document(['$id' => 'renames']); + $adapter->createDocument($collection, new Document([ + '$id' => 'row', + '$permissions' => [], + 'old' => 'value', + ])); - $store = (new \ReflectionClass($adapter))->getProperty('data')->getValue($adapter); - $key = $adapter->getDatabase() . '.' . $adapter->getNamespace() . '_renames'; + $adapter->updateAttribute('renames', 'old', Attribute::string(key: 'fresh', size: 256)); - $this->assertArrayHasKey('fresh', $store[$key]['attributes']); - $this->assertArrayNotHasKey('old', $store[$key]['attributes']); - $this->assertEquals(256, $store[$key]['attributes']['fresh']['size']); + $renamed = $adapter->getDocument($collection, 'row'); + $this->assertSame('value', $renamed->getAttribute('fresh')); + $this->assertNull($renamed->getAttribute('old')); } /** - * Regression: renameAttribute cascades the rename into any indexes that - * referenced the old name. + * Regression: renameAttribute carries the values, the indexes and the + * attribute's own registration onto the new name. An index left pointing + * at the old key reads null out of every row, and a null component drops + * the row out of the unique signature — the index silently stops + * rejecting duplicates. A registration left on the old key makes the new + * key undeletable: deleteAttribute is a no-op for a key the adapter never + * registered, so the stored field survives the drop. */ - public function testRenameAttributeUpdatesIndexReferences(): void + public function testRenameAttributeCarriesValuesIndexesAndRegistration(): void { $adapter = new Memory(); $adapter->setNamespace('idxrn_' . \uniqid()); $adapter->createCollection('indexed', [], []); - $adapter->createAttribute('indexed', 'name', Database::VAR_STRING, 64); - $adapter->createIndex('indexed', 'idx_name', Database::INDEX_KEY, ['name'], [], []); + $adapter->createAttribute('indexed', Attribute::string(key: 'name', size: 64)); + $adapter->createIndex('indexed', Index::unique(key: 'unique_name', attributes: ['name'])); + + $collection = new Document(['$id' => 'indexed']); + $adapter->createDocument($collection, new Document([ + '$id' => 'first', + '$permissions' => [], + 'name' => 'taken', + ])); $adapter->renameAttribute('indexed', 'name', 'title'); - $store = (new \ReflectionClass($adapter))->getProperty('data')->getValue($adapter); - $key = $adapter->getDatabase() . '.' . $adapter->getNamespace() . '_indexed'; + $stored = $adapter->getDocument($collection, 'first'); + $this->assertSame('taken', $stored->getAttribute('title')); + $this->assertNull($stored->getAttribute('name')); + + $adapter->createDocument($collection, new Document([ + '$id' => 'free', + '$permissions' => [], + 'title' => 'available', + ])); - $this->assertEquals(['title'], $store[$key]['indexes']['idx_name']['attributes']); + $threw = false; + try { + $adapter->createDocument($collection, new Document([ + '$id' => 'second', + '$permissions' => [], + 'title' => 'taken', + ])); + } catch (DuplicateException) { + $threw = true; + } + + $this->assertTrue($threw, 'the unique index should still reject a duplicate under the new name'); + + $adapter->deleteAttribute('indexed', 'title'); + + $this->assertNull($adapter->getDocument($collection, 'first')->getAttribute('title')); } /** - * Regression: deleteAttribute strips the attribute from any composite - * index that referenced it. + * Regression: deleteAttribute strips the dropped key out of the composite + * indexes that referenced it, so a two-column unique index narrows to the + * column that is left. An index still naming the dropped key reads null + * for it and stops rejecting duplicates altogether. */ - public function testDeleteAttributeRemovesFromIndex(): void + public function testDeleteAttributeNarrowsCompositeUniqueIndex(): void { $adapter = new Memory(); $adapter->setNamespace('idxdrop_' . \uniqid()); $adapter->createCollection('drops', [], []); - $adapter->createAttribute('drops', 'a', Database::VAR_STRING, 64); - $adapter->createAttribute('drops', 'b', Database::VAR_STRING, 64); - $adapter->createIndex('drops', 'idx_ab', Database::INDEX_KEY, ['a', 'b'], [], []); + $adapter->createAttribute('drops', Attribute::string(key: 'a', size: 64)); + $adapter->createAttribute('drops', Attribute::string(key: 'b', size: 64)); + $adapter->createIndex('drops', Index::unique(key: 'unique_ab', attributes: ['a', 'b'])); + + $collection = new Document(['$id' => 'drops']); + $adapter->createDocument($collection, new Document([ + '$id' => 'existing', + '$permissions' => [], + 'a' => 'one', + 'b' => 'kept', + ])); $adapter->deleteAttribute('drops', 'a'); - $store = (new \ReflectionClass($adapter))->getProperty('data')->getValue($adapter); - $key = $adapter->getDatabase() . '.' . $adapter->getNamespace() . '_drops'; + $stored = $adapter->getDocument($collection, 'existing'); + $this->assertNull($stored->getAttribute('a')); + $this->assertSame('kept', $stored->getAttribute('b')); + + $adapter->createDocument($collection, new Document([ + '$id' => 'first', + '$permissions' => [], + 'b' => 'shared', + ])); - $this->assertEquals(['b'], $store[$key]['indexes']['idx_ab']['attributes']); + $this->expectException(DuplicateException::class); + $adapter->createDocument($collection, new Document([ + '$id' => 'second', + '$permissions' => [], + 'b' => 'shared', + ])); } /** @@ -417,27 +435,15 @@ public function testBatchUpdateEnforcesUniqueIndexes(): void { $database = $this->freshDatabase(); - $database->createCollection('handles', [ - new Document([ - '$id' => 'handle', - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [ - new Document([ - '$id' => 'unique_handle', - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['handle'], - ]), - ], [ + $database->createCollection(Collection::create(id: 'handles', attributes: [ + Attribute::string(key: 'handle', size: 64, required: true), + ], indexes: [ + Index::unique(key: 'unique_handle', attributes: ['handle']), + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); + ])); $database->createDocument('handles', new Document([ '$id' => 'h1', @@ -474,27 +480,15 @@ public function testBatchUpdateRejectsSiblingCollision(): void { $database = $this->freshDatabase(); - $database->createCollection('siblings', [ - new Document([ - '$id' => 'handle', - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [ - new Document([ - '$id' => 'unique_handle', - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['handle'], - ]), - ], [ + $database->createCollection(Collection::create(id: 'siblings', attributes: [ + Attribute::string(key: 'handle', size: 64, required: true), + ], indexes: [ + Index::unique(key: 'unique_handle', attributes: ['handle']), + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ]); + ])); $database->createDocument('siblings', new Document([ '$id' => 's1', @@ -522,27 +516,21 @@ public function testBatchUpdateRejectsSiblingCollision(): void } /** - * Regression: bulk delete clears the in-memory permissions index for the - * affected collection. + * Regression: bulk delete clears the permission entries of the rows it + * removed. Entries left behind keep granting access under the deleted + * document's id, so re-using that id inherits the grant it never asked + * for. */ public function testBulkDeleteRemovesPermissions(): void { $database = $this->freshDatabase(); - $database->createCollection('cleanup', [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + $database->createCollection(Collection::create(id: 'cleanup', attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + ], permissions: [ Permission::create(Role::any()), Permission::delete(Role::any()), - ]); + ])); for ($i = 0; $i < 3; $i++) { $database->createDocument('cleanup', new Document([ @@ -552,13 +540,19 @@ public function testBulkDeleteRemovesPermissions(): void ])); } + $this->assertCount(3, $database->find('cleanup')); + $database->deleteDocuments('cleanup'); - $adapter = $database->getAdapter(); - $permissions = (new \ReflectionClass($adapter))->getProperty('permissions')->getValue($adapter); - $key = $database->getDatabase() . '.' . $database->getNamespace() . '_cleanup'; + $this->assertCount(0, $database->find('cleanup')); - $this->assertEmpty($permissions[$key] ?? []); + $database->createDocument('cleanup', new Document([ + '$id' => 'c0', + '$permissions' => [Permission::delete(Role::any())], + 'name' => 'restricted', + ])); + + $this->assertCount(0, $database->find('cleanup')); } /** @@ -604,12 +598,13 @@ public function testSharedTablesIsolatesTenants(): void * millisecond between the two writes to keep the inherited * assertion honest without changing semantics for slower adapters. */ + #[\Override] public function testSingleDocumentDateOperations(): void { $database = $this->getDatabase(); $collection = 'single_date_operations_memory'; - $database->createCollection($collection); - $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false); + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'string', size: 128)); $database->setPreserveDates(true); $created = $database->createDocument($collection, new Document([ @@ -644,8 +639,8 @@ public function testSharedTablesUniqueIndexPerTenant(): void $adapter->setSharedTables(true); $adapter->setTenant(1); $adapter->createCollection('emails', [], []); - $adapter->createAttribute('emails', 'addr', Database::VAR_STRING, 128, true, false, true); - $adapter->createIndex('emails', 'unique_addr', Database::INDEX_UNIQUE, ['addr'], [], []); + $adapter->createAttribute('emails', Attribute::string(key: 'addr', size: 128, required: true)); + $adapter->createIndex('emails', Index::unique(key: 'unique_addr', attributes: ['addr'])); $collection = new Document(['$id' => 'emails']); @@ -709,7 +704,7 @@ public function testDeleteDocumentThrowsWhenCollectionMissing(): void $adapter->setNamespace('missing_' . \uniqid()); $this->expectException(NotFoundException::class); - $adapter->deleteDocument('ghost', 'x'); + $adapter->deleteDocument(new Document(['$id' => 'ghost']), 'x'); } public function testDeleteDocumentReturnsFalseForMissingDoc(): void @@ -719,7 +714,7 @@ public function testDeleteDocumentReturnsFalseForMissingDoc(): void $adapter->createCollection('here', [], []); // Collection exists, document does not — mirrors MariaDB rowCount() == 0. - $this->assertFalse($adapter->deleteDocument('here', 'never-created')); + $this->assertFalse($adapter->deleteDocument(new Document(['$id' => 'here']), 'never-created')); } public function testDeleteDocumentsThrowsWhenCollectionMissing(): void @@ -728,7 +723,7 @@ public function testDeleteDocumentsThrowsWhenCollectionMissing(): void $adapter->setNamespace('missing_' . \uniqid()); $this->expectException(NotFoundException::class); - $adapter->deleteDocuments('ghost', [], []); + $adapter->deleteDocuments(new Document(['$id' => 'ghost']), [], []); } public function testDeleteDocumentsHonoursTenantBoundary(): void @@ -755,7 +750,7 @@ public function testDeleteDocumentsHonoursTenantBoundary(): void ])); $adapter->setTenant(1); - $deleted = $adapter->deleteDocuments('box', ['1'], []); + $deleted = $adapter->deleteDocuments($collection, ['1'], []); $this->assertEquals(1, $deleted); @@ -792,7 +787,7 @@ public function testGetSequencesUsesDocumentTenant(): void // empty — this assertion would fail. The discriminating signal is // that the doc resolves *against* the current adapter tenant. $probe = new Document(['$id' => 'tenant1-only', '$tenant' => 1]); - [$result] = $adapter->getSequences('box', [$probe]); + [$result] = $adapter->getSequences($collection, [$probe]); $this->assertSame((string) $tenant1Doc->getSequence(), $result->getSequence()); } @@ -805,26 +800,14 @@ public function testUniqueIndexNormalizesBool(): void { $database = $this->freshDatabase(); - $database->createCollection('flags', [ - new Document([ - '$id' => 'active', - 'type' => Database::VAR_BOOLEAN, - 'size' => 0, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [ - new Document([ - '$id' => 'unique_active', - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['active'], - ]), - ], [ + $database->createCollection(Collection::create(id: 'flags', attributes: [ + Attribute::boolean(key: 'active', required: true), + ], indexes: [ + Index::unique(key: 'unique_active', attributes: ['active']), + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ]); + ])); $database->createDocument('flags', new Document([ '$id' => 'first', @@ -851,8 +834,8 @@ public function testUniqueIndexNormalizesNumericString(): void $adapter = new Memory(); $adapter->setNamespace('numstr_' . \uniqid()); $adapter->createCollection('codes', [], []); - $adapter->createAttribute('codes', 'code', Database::VAR_STRING, 16, true, false, true); - $adapter->createIndex('codes', 'unique_code', Database::INDEX_UNIQUE, ['code'], [], []); + $adapter->createAttribute('codes', Attribute::string(key: 'code', size: 16, required: true)); + $adapter->createIndex('codes', Index::unique(key: 'unique_code', attributes: ['code'])); $collection = new Document(['$id' => 'codes']); @@ -880,35 +863,16 @@ public function testNegationOperatorsExcludeNullRows(): void { $database = $this->freshDatabase(); - $database->createCollection('nullable', [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => false, - ]), - new Document([ - '$id' => 'score', - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false, - ]), - new Document([ - '$id' => 'bio', - 'type' => Database::VAR_STRING, - 'size' => 1024, - 'required' => false, - ]), - ], [ - new Document([ - '$id' => 'bio_ft', - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['bio'], - ]), - ], [ + $database->createCollection(Collection::create(id: 'nullable', attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'score'), + Attribute::string(key: 'bio', size: 1024), + ], indexes: [ + Index::fulltext(key: 'bio_ft', attributes: ['bio']), + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ]); + ])); $database->createDocument('nullable', new Document([ '$id' => 'with_value', @@ -927,7 +891,7 @@ public function testNegationOperatorsExcludeNullRows(): void ])); $assertOnlyValueRow = function (string $operator, array $results) { - $ids = \array_map(fn (Document $d) => $d->getId(), $results); + $ids = \array_map(fn (mixed $d): string => $d instanceof Document ? $d->getId() : '', $results); $this->assertSame(['with_value'], $ids, $operator . ' should exclude null-valued rows'); }; diff --git a/tests/e2e/Adapter/MirrorTest.php b/tests/e2e/Adapter/MirrorTest.php index de73d7be86..4b34a99a1b 100644 --- a/tests/e2e/Adapter/MirrorTest.php +++ b/tests/e2e/Adapter/MirrorTest.php @@ -6,6 +6,9 @@ use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; +use Utopia\Database\CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; @@ -14,28 +17,34 @@ use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Limit; use Utopia\Database\Exception\Structure; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Mirror; use Utopia\Database\PDO; +use Utopia\Database\Permission; +use Utopia\Database\Role; class MirrorTest extends Base { protected static ?Mirror $database = null; + protected static ?PDO $destinationPdo = null; + protected static ?PDO $sourcePdo = null; + protected static Database $source; + protected static Database $destination; + #[\Override] protected static string $namespace; /** * @throws \RedisException * @throws Exception */ + #[\Override] protected function getDatabase(bool $fresh = false): Mirror { - if (!is_null(self::$database) && !$fresh) { + if (! is_null(self::$database) && ! $fresh) { return self::$database; } @@ -44,12 +53,12 @@ protected function getDatabase(bool $fresh = false): Mirror $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); + $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis'); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(5); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); self::$sourcePdo = $pdo; self::$source = new Database(new MariaDB($pdo), $cache); @@ -59,47 +68,55 @@ protected function getDatabase(bool $fresh = false): Mirror $mirrorUser = 'root'; $mirrorPass = 'password'; - $mirrorPdo = new PDO("mysql:host={$mirrorHost};port={$mirrorPort};charset=utf8mb4", $mirrorUser, $mirrorPass, MariaDB::getPDOAttributes()); + $mirrorPdo = new PDO("mysql:host={$mirrorHost};port={$mirrorPort};charset=utf8mb4", $mirrorUser, $mirrorPass, self::PDO_ATTRIBUTES); $mirrorRedis = new Redis(); $mirrorRedis->connect('redis-mirror'); - $mirrorRedis->flushAll(); - $mirrorCache = new Cache(new RedisAdapter($mirrorRedis)); + $mirrorRedis->select(5); + $mirrorCache = new Cache((new RedisAdapter($mirrorRedis))->setMaxRetries(3)); self::$destinationPdo = $mirrorPdo; self::$destination = new Database(new MariaDB($mirrorPdo), $mirrorCache); $database = new Mirror(self::$source, self::$destination); + $token = static::getTestToken(); $schemas = [ - 'utopiaTests', - 'schema1', - 'schema2', - 'sharedTables', - 'sharedTablesTenantPerDocument' + $this->testDatabase, + 'schema1_'.$token, + 'schema2_'.$token, + 'sharedTables_'.$token, + 'sharedTablesTenantPerDocument_'.$token, ]; /** * Handle cases where the source and destination databases are not in sync because of previous tests */ + assert(self::$authorization !== null); foreach ($schemas as $schema) { if ($database->getSource()->exists($schema)) { $database->getSource()->setAuthorization(self::$authorization); $database->getSource()->setDatabase($schema)->delete(); } - if ($database->getDestination()->exists($schema)) { - $database->getDestination()->setAuthorization(self::$authorization); - $database->getDestination()->setDatabase($schema)->delete(); + $destination = $database->getDestination(); + if ($destination !== null && $destination->exists($schema)) { + $destination->setAuthorization(self::$authorization); + $destination->setDatabase($schema)->delete(); } } $database - ->setDatabase('utopiaTests') + ->setDatabase($this->testDatabase) ->setAuthorization(self::$authorization) - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); $database->create(); + $destination = $database->getDestination(); + if ($destination === null || ! $destination->collectionExists(Database::METADATA, $this->testDatabase)) { + throw new Exception('Mirror destination is missing _metadata after create'); + } + return self::$database = $database; } @@ -107,11 +124,10 @@ protected function getDatabase(bool $fresh = false): Mirror * @throws Exception * @throws \RedisException */ - public function testGetMirrorSource(): void + public function test_get_mirror_source(): void { $database = $this->getDatabase(); $source = $database->getSource(); - $this->assertInstanceOf(Database::class, $source); $this->assertEquals(self::$source, $source); } @@ -119,7 +135,7 @@ public function testGetMirrorSource(): void * @throws Exception * @throws \RedisException */ - public function testGetMirrorDestination(): void + public function test_get_mirror_destination(): void { $database = $this->getDatabase(); $destination = $database->getDestination(); @@ -133,15 +149,17 @@ public function testGetMirrorDestination(): void * @throws Exception * @throws \RedisException */ - public function testCreateMirroredCollection(): void + public function test_create_mirrored_collection(): void { $database = $this->getDatabase(); - $database->createCollection('testCreateMirroredCollection'); + $database->createCollection(Collection::create(id: 'testCreateMirroredCollection')); // Assert collection exists in both databases - $this->assertFalse($database->getSource()->getCollection('testCreateMirroredCollection')->isEmpty()); - $this->assertFalse($database->getDestination()->getCollection('testCreateMirroredCollection')->isEmpty()); + $this->assertNotNull($database->getSource()->findCollection('testCreateMirroredCollection')); + $destination = $database->getDestination(); + $this->assertNotNull($destination); + $this->assertNotNull($destination->findCollection('testCreateMirroredCollection')); } /** @@ -151,22 +169,24 @@ public function testCreateMirroredCollection(): void * @throws Conflict * @throws Exception */ - public function testUpdateMirroredCollection(): void + public function test_update_mirrored_collection(): void { $database = $this->getDatabase(); - $database->createCollection('testUpdateMirroredCollection', permissions: [ + $database->createCollection(Collection::create(id: 'testUpdateMirroredCollection', permissions: [ Permission::read(Role::any()), - ]); + ])); $collection = $database->getCollection('testUpdateMirroredCollection'); $database->updateCollection( 'testUpdateMirroredCollection', - [ - Permission::read(Role::users()), - ], - $collection->getAttribute('documentSecurity') + new CollectionUpdate( + permissions: [ + Permission::read(Role::users()), + ], + documentSecurity: (bool) $collection->getAttribute('documentSecurity'), + ), ); // Asset both databases have updated the collection @@ -175,23 +195,27 @@ public function testUpdateMirroredCollection(): void $database->getSource()->getCollection('testUpdateMirroredCollection')->getPermissions() ); + $destination = $database->getDestination(); + $this->assertNotNull($destination); $this->assertEquals( [Permission::read(Role::users())], - $database->getDestination()->getCollection('testUpdateMirroredCollection')->getPermissions() + $destination->getCollection('testUpdateMirroredCollection')->getPermissions() ); } - public function testDeleteMirroredCollection(): void + public function test_delete_mirrored_collection(): void { $database = $this->getDatabase(); - $database->createCollection('testDeleteMirroredCollection'); + $database->createCollection(Collection::create(id: 'testDeleteMirroredCollection')); $database->deleteCollection('testDeleteMirroredCollection'); // Assert collection is deleted in both databases - $this->assertTrue($database->getSource()->getCollection('testDeleteMirroredCollection')->isEmpty()); - $this->assertTrue($database->getDestination()->getCollection('testDeleteMirroredCollection')->isEmpty()); + $this->assertNull($database->getSource()->findCollection('testDeleteMirroredCollection')); + $destination = $database->getDestination(); + $this->assertNotNull($destination); + $this->assertNull($destination->findCollection('testDeleteMirroredCollection')); } /** @@ -202,25 +226,20 @@ public function testDeleteMirroredCollection(): void * @throws Structure * @throws Exception */ - public function testCreateMirroredDocument(): void + public function test_create_mirrored_document(): void { $database = $this->getDatabase(); - $database->createCollection('testCreateMirroredDocument', attributes: [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'required' => true, - 'size' => Database::LENGTH_KEY, - ]), + $database->createCollection(Collection::create(id: 'testCreateMirroredDocument', attributes: [ + Attribute::string(key: 'name', required: true), ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ], documentSecurity: false); + ], documentSecurity: false)); $document = $database->createDocument('testCreateMirroredDocument', new Document([ 'name' => 'Jake', - '$permissions' => [] + '$permissions' => [], ])); // Assert document is created in both databases @@ -229,9 +248,11 @@ public function testCreateMirroredDocument(): void $database->getSource()->getDocument('testCreateMirroredDocument', $document->getId()) ); + $destination = $database->getDestination(); + $this->assertNotNull($destination); $this->assertEquals( $document, - $database->getDestination()->getDocument('testCreateMirroredDocument', $document->getId()) + $destination->getDocument('testCreateMirroredDocument', $document->getId()) ); } @@ -244,26 +265,21 @@ public function testCreateMirroredDocument(): void * @throws Structure * @throws Exception */ - public function testUpdateMirroredDocument(): void + public function test_update_mirrored_document(): void { $database = $this->getDatabase(); - $database->createCollection('testUpdateMirroredDocument', attributes: [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'required' => true, - 'size' => Database::LENGTH_KEY, - ]), + $database->createCollection(Collection::create(id: 'testUpdateMirroredDocument', attributes: [ + Attribute::string(key: 'name', required: true), ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), - ], documentSecurity: false); + ], documentSecurity: false)); $document = $database->createDocument('testUpdateMirroredDocument', new Document([ 'name' => 'Jake', - '$permissions' => [] + '$permissions' => [], ])); $document = $database->updateDocument( @@ -278,39 +294,86 @@ public function testUpdateMirroredDocument(): void $database->getSource()->getDocument('testUpdateMirroredDocument', $document->getId()) ); + $destination = $database->getDestination(); + $this->assertNotNull($destination); $this->assertEquals( $document, - $database->getDestination()->getDocument('testUpdateMirroredDocument', $document->getId()) + $destination->getDocument('testUpdateMirroredDocument', $document->getId()) ); } - public function testDeleteMirroredDocument(): void + public function testUpdateMirroredDocumentIgnoresSourceSequence(): void { $database = $this->getDatabase(); + $collection = 'mirrorSequenceTargeting'; - $database->createCollection('testDeleteMirroredDocument', attributes: [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'required' => true, - 'size' => Database::LENGTH_KEY, - ]), + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], documentSecurity: false)); + + $destination = $database->getDestination(); + $this->assertNotNull($destination); + + // The destination holds a row the mirror never wrote, so its sequence counter runs + // ahead of the source's and the two sides disagree about every subsequent $sequence. + $bystander = $destination->createDocument($collection, new Document([ + Document::ID => 'bystander', + 'name' => 'untouched', + '$permissions' => [], + ])); + + $onSource = $database->getSource()->createDocument($collection, new Document([ + Document::ID => 'shared', + 'name' => 'before', + '$permissions' => [], + ])); + $onDestination = $destination->createDocument($collection, new Document([ + Document::ID => 'shared', + 'name' => 'before', + '$permissions' => [], + ])); + + $this->assertSame($bystander->getSequence(), $onSource->getSequence()); + $this->assertNotSame($onSource->getSequence(), $onDestination->getSequence()); + + $database->updateDocument($collection, 'shared', new Document(['name' => 'after'])); + + $this->assertSame('untouched', $destination->getDocument($collection, 'bystander')->getAttribute('name')); + $this->assertSame('after', $destination->getDocument($collection, 'shared')->getAttribute('name')); + $this->assertSame( + 'after', + $database->getSource()->getDocument($collection, 'shared')->getAttribute('name') + ); + } + + public function test_delete_mirrored_document(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: 'testDeleteMirroredDocument', attributes: [ + Attribute::string(key: 'name', required: true), ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::delete(Role::any()), - ], documentSecurity: false); + ], documentSecurity: false)); $document = $database->createDocument('testDeleteMirroredDocument', new Document([ 'name' => 'Jake', - '$permissions' => [] + '$permissions' => [], ])); $database->deleteDocument('testDeleteMirroredDocument', $document->getId()); // Assert document is deleted in both databases $this->assertTrue($database->getSource()->getDocument('testDeleteMirroredDocument', $document->getId())->isEmpty()); - $this->assertTrue($database->getDestination()->getDocument('testDeleteMirroredDocument', $document->getId())->isEmpty()); + $destination = $database->getDestination(); + $this->assertNotNull($destination); + $this->assertTrue($destination->getDocument('testDeleteMirroredDocument', $document->getId())->isEmpty()); } public function testCreateDocumentsSkipDuplicatesBackfillsDestination(): void @@ -318,20 +381,15 @@ public function testCreateDocumentsSkipDuplicatesBackfillsDestination(): void $database = $this->getDatabase(); $collection = 'mirrorSkipDup'; - $database->createCollection($collection, attributes: [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'required' => true, - 'size' => Database::LENGTH_KEY, - ]), + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', required: true), ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - ], documentSecurity: false); + ], documentSecurity: false)); // Seed the SOURCE only (bypass the mirror) with the row we want to - // skipDuplicates over later. Destination intentionally does NOT have it — + // ignoreDuplicates over later. Destination intentionally does NOT have it — // this simulates an in-flight backfill where the collection is marked // 'upgraded' (schema mirrored) but not every row has reached destination. $database->getSource()->createDocument($collection, new Document([ @@ -343,15 +401,18 @@ public function testCreateDocumentsSkipDuplicatesBackfillsDestination(): void ], ])); + $destination = $database->getDestination(); + $this->assertNotNull($destination); + $this->assertSame( 'Original', $database->getSource()->getDocument($collection, 'dup')->getAttribute('name') ); $this->assertTrue( - $database->getDestination()->getDocument($collection, 'dup')->isEmpty() + $destination->getDocument($collection, 'dup')->isEmpty() ); - $database->skipDuplicates(fn () => $database->createDocuments($collection, [ + $database->ignoreDuplicates(fn () => $database->createDocuments($collection, [ new Document([ '$id' => 'dup', 'name' => 'WouldBe', @@ -385,40 +446,46 @@ public function testCreateDocumentsSkipDuplicatesBackfillsDestination(): void // destination is still catching up on rows that already exist on source. $this->assertSame( 'WouldBe', - $database->getDestination()->getDocument($collection, 'dup')->getAttribute('name'), + $destination->getDocument($collection, 'dup')->getAttribute('name'), 'Source-skipped doc must still insert on destination when absent there' ); $this->assertSame( 'Fresh', - $database->getDestination()->getDocument($collection, 'fresh')->getAttribute('name') + $destination->getDocument($collection, 'fresh')->getAttribute('name') ); } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . self::$source->getDatabase() . "`.`" . self::$source->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.self::$source->getDatabase().'`.`'.self::$source->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$sourcePdo !== null); self::$sourcePdo->exec($sql); - $sqlTable = "`" . self::$destination->getDatabase() . "`.`" . self::$destination->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.self::$destination->getDatabase().'`.`'.self::$destination->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$destinationPdo !== null); self::$destinationPdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $sqlTable = "`" . self::$source->getDatabase() . "`.`" . self::$source->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.self::$source->getDatabase().'`.`'.self::$source->getNamespace().'_'.$collection.'`'; $sql = "DROP INDEX `{$index}` ON {$sqlTable}"; + assert(self::$sourcePdo !== null); self::$sourcePdo->exec($sql); - $sqlTable = "`" . self::$destination->getDatabase() . "`.`" . self::$destination->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.self::$destination->getDatabase().'`.`'.self::$destination->getNamespace().'_'.$collection.'`'; $sql = "DROP INDEX `{$index}` ON {$sqlTable}"; + assert(self::$destinationPdo !== null); self::$destinationPdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index 1c7eb9237e..15886f82d5 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -4,43 +4,55 @@ use Exception; use Redis; +use Tests\E2E\Adapter\Scopes\MongoReadFilterTests; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Index; +use Utopia\Database\Permission; +use Utopia\Database\Query; +use Utopia\Database\Role; +use Utopia\Database\Storage; use Utopia\Mongo\Client; class MongoDBTest extends Base { + use MongoReadFilterTests; + public static ?Database $database = null; + + #[\Override] protected static string $namespace; /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "mongodb"; + return 'mongodb'; } /** - * @return Database * @throws Exception */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(4); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); - $schema = 'utopiaTests'; // same as $this->testDatabase + $schema = $this->testDatabase; $client = new Client( $schema, 'mongo', @@ -51,11 +63,12 @@ public function getDatabase(): Database ); $database = new Database(new Mongo($client), $cache); - $database->getAdapter()->setSupportForAttributes(true); + $database->setSchemaless(false); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) ->setDatabase($schema) - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); if ($database->exists()) { $database->delete(); @@ -69,42 +82,306 @@ public function getDatabase(): Database /** * @throws Exception */ + #[\Override] public function testCreateExistsDelete(): void { - // Mongo creates databases on the fly, so exists would always pass. So we override this test to remove the exists check. - $this->assertNotNull($this->getDatabase()->create()); - $this->assertEquals(true, $this->getDatabase()->delete($this->testDatabase)); - $this->assertEquals(true, $this->getDatabase()->create()); - $this->assertEquals($this->getDatabase(), $this->getDatabase()->setDatabase($this->testDatabase)); + $database = $this->getDatabase(); + + $this->assertTrue($database->create()); + $this->assertTrue($database->exists($this->testDatabase)); + $this->assertFalse($database->exists($this->testDatabase.'Absent')); + $this->assertTrue($database->delete($this->testDatabase)); + $this->assertFalse($database->exists($this->testDatabase)); + $this->assertTrue($database->create()); + $this->assertTrue($database->exists($this->testDatabase)); + $this->assertSame($database, $database->setDatabase($this->testDatabase)); + } + + public function testCollectionGrantsAuthorizeWritesWithoutReadPermission(): void + { + $database = $this->getDatabase(); + $collection = 'collectionGrantedWrites'; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::integer(key: 'count', required: true)], + permissions: [ + Permission::read(Role::any()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + documentSecurity: false, + )); + + $database->getAuthorization()->skip(function () use ($database, $collection): void { + foreach (['first', 'second', 'third'] as $id) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [], + 'count' => 0, + ])); + } + }); + + $this->actAs('bob', function () use ($database, $collection): void { + $database->increaseDocumentAttribute($collection, 'first', 'count', 5); + $this->assertSame(5, $database->getDocument($collection, 'first')->getAttribute('count')); + + $database->decreaseDocumentAttribute($collection, 'first', 'count', 2); + $this->assertSame(3, $database->getDocument($collection, 'first')->getAttribute('count')); + + $this->assertSame(3, $database->updateDocuments($collection, new Document(['count' => 42]))); + $this->assertSame( + [42, 42, 42], + \array_map(fn (Document $document) => $document->getAttribute('count'), $database->find($collection)), + ); + + $this->assertSame(3, $database->deleteDocuments($collection)); + $this->assertSame(0, $database->count($collection)); + }); + } + + public function testDocumentGrantsAuthorizeWritesWithoutReadPermission(): void + { + $database = $this->getDatabase(); + $collection = 'documentGrantedWrites'; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::integer(key: 'count', required: true)], + permissions: [], + documentSecurity: true, + )); + + $database->getAuthorization()->skip(function () use ($database, $collection): void { + foreach (['first', 'second'] as $id) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [ + Permission::update(Role::user('bob')), + Permission::delete(Role::user('bob')), + ], + 'count' => 0, + ])); + } + }); + + $stored = fn (): array => $database->getAuthorization()->skip(fn () => \array_map( + fn (Document $document) => $document->getAttribute('count'), + $database->find($collection), + )); + + $this->actAs('bob', fn () => $database->increaseDocumentAttribute($collection, 'first', 'count', 5)); + $this->assertSame([5, 0], $stored()); + + $this->actAs('bob', fn () => $this->assertSame(2, $database->updateDocuments($collection, new Document(['count' => 42])))); + $this->assertSame([42, 42], $stored()); + + $this->actAs('bob', fn () => $this->assertSame(2, $database->deleteDocuments($collection))); + $this->assertSame([], $stored()); } - public function testRenameAttribute(): void + public function testDeniedReaderDoesNotHideADocumentFromItsReader(): void { - $this->assertTrue(true); + $database = $this->getDatabase(); + $collection = 'deniedReaderProfiles'; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::user('alice'))], + documentSecurity: false, + )); + + $database->getAuthorization()->skip(fn () => $database->createDocument($collection, new Document([ + '$id' => 'alice', + '$permissions' => [Permission::read(Role::user('alice'))], + 'name' => 'Alice', + ]))); + + $this->actAs('bob', fn () => $this->assertTrue($database->getDocument($collection, 'alice')->isEmpty())); + + $this->actAs('alice', fn () => $this->assertSame( + 'Alice', + $database->getDocument($collection, 'alice')->getAttribute('name'), + 'A reader denied the document must not leave a negative cache entry for a reader who may see it', + )); } - public function testRenameAttributeExisting(): void + public function testListCollectionsReturnsOnlyReadableDefinitions(): void { - $this->assertTrue(true); + $database = $this->getDatabase(); + $definitions = ['adminDefinition', 'listedDefinition', 'unlistedDefinition']; + + $database->createCollection(Collection::create(id: 'listedDefinition', permissions: [Permission::read(Role::any())])); + $database->createCollection(Collection::create(id: 'adminDefinition', permissions: [Permission::read(Role::user('admin'))])); + $database->createCollection(Collection::create(id: 'unlistedDefinition', permissions: [Permission::create(Role::any())])); + + $listed = fn (): array => \array_values(\array_intersect( + $definitions, + \array_map(fn (Collection $collection) => $collection->getId(), $database->listCollections(100)), + )); + $queries = [Query::equal('$id', $definitions)]; + + $this->actAs('bob', function () use ($database, $listed, $queries): void { + $this->assertSame(['listedDefinition'], $listed()); + $this->assertCount(1, $database->find(Database::METADATA, $queries)); + $this->assertSame(1, $database->count(Database::METADATA, $queries), 'count() and find() must agree on the metadata collection'); + }); + + $this->actAs('admin', function () use ($database, $listed, $queries): void { + $this->assertSame(['adminDefinition', 'listedDefinition'], $listed()); + $this->assertSame(2, $database->count(Database::METADATA, $queries)); + }); + + $this->assertSame($definitions, $database->getAuthorization()->skip($listed)); } - public function testUpdateAttributeStructure(): void + public function testUpdateMovesEveryCollectionIntoTheNewDatabase(): void { - $this->assertTrue(true); + $suffix = \substr(\uniqid(), -6); + $source = $this->testDatabase.'_from'.$suffix; + $target = $this->testDatabase.'_to'.$suffix; + $occupied = $this->testDatabase.'_taken'.$suffix; + $collection = 'renamedBooks'; + + $sourceDatabase = $this->databaseBoundTo($source); + $targetDatabase = $this->databaseBoundTo($target); + $occupiedDatabase = $this->databaseBoundTo($occupied); + $authorization = $sourceDatabase->getAuthorization(); + + try { + $sourceDatabase->create(); + $authorization->skip(function () use ($sourceDatabase, $collection): void { + $sourceDatabase->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'title', size: 64)], + indexes: [Index::unique(key: 'byTitle', attributes: ['title'])], + permissions: [], + documentSecurity: true, + )); + $sourceDatabase->createDocument($collection, new Document([ + '$id' => 'hobbit', + 'title' => 'The Hobbit', + '$permissions' => [Permission::read(Role::user('reader'))], + ])); + }); + $this->assertNotNull($sourceDatabase->findCollection($collection)); + + $this->assertTrue($sourceDatabase->update($source, $target)); + + $sourceDatabase->setDatabase($source); + $this->assertNull($sourceDatabase->findCollection($collection), 'The definition cached under the old name must not outlive the rename'); + + $moved = $targetDatabase->findCollection($collection); + $this->assertNotNull($moved); + $this->assertContains('byTitle', \array_map(static fn (Index $index): string => $index->key, $moved->indexes())); + $this->assertSame('The Hobbit', $authorization->skip(fn (): Document => $targetDatabase->getDocument($collection, 'hobbit'))->getAttribute('title')); + $this->actAs('stranger', function () use ($targetDatabase, $collection): void { + $this->assertSame([], $targetDatabase->find($collection)); + }); + $this->actAs('reader', function () use ($targetDatabase, $collection): void { + $this->assertSame(['hobbit'], \array_map(static fn (Document $book): string => $book->getId(), $targetDatabase->find($collection))); + }); + + $occupiedDatabase->create(); + $this->expectException(DuplicateException::class); + $occupiedDatabase->update($occupied, $target); + } finally { + $sourceDatabase->delete($source); + $targetDatabase->delete($target); + $occupiedDatabase->delete($occupied); + } } - public function testKeywords(): void + private function databaseBoundTo(string $name): Database { - $this->assertTrue(true); + $adapter = new Mongo(new Client($name, 'mongo', 27017, 'root', 'password', false)); + $adapter->setSchemaless(false); + $database = new Database($adapter, $this->getDatabase()->getCache()); + assert(self::$authorization !== null); + $database + ->setAuthorization(self::$authorization) + ->setDatabase($name) + ->setNamespace(static::$namespace); + + return $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { return true; } + + private function actAs(string $user, callable $callback): void + { + $authorization = $this->getDatabase()->getAuthorization(); + $roles = $authorization->getRoles(); + + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::users()->toString()); + $authorization->addRole(Role::user($user)->toString()); + + try { + $callback(); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + } + } + + public function testReadsDropAStoredNonStringPermission(): void + { + $database = $this->getDatabase(); + $collection = 'lenientReads'; + $permissions = [Permission::read(Role::any())]; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + $database->createDocument($collection, new Document([ + '$id' => 'note', + '$permissions' => $permissions, + 'title' => 'stored', + ])); + + $client = $database->getAdapter()->getDriver(); + $this->assertInstanceOf(Client::class, $client); + $client->update( + $database->getNamespace().'_'.$collection, + [Storage::UID => 'note'], + ['$set' => [Storage::PERMISSIONS => [Permission::read(Role::any()), 42, null]]], + ); + $database->purgeCachedDocument($collection, 'note'); + + $this->assertSame($permissions, $database->getDocument($collection, 'note')->getPermissions()); + $this->assertSame( + [$permissions], + \array_map(fn (Document $document): array => $document->getPermissions(), $database->find($collection)), + ); + + $this->assertSame(1, $database->updateDocuments($collection, new Document(['title' => 'bulk']))); + $this->assertSame('bulk', $database->getDocument($collection, 'note')->getAttribute('title')); + + $updated = $database->updateDocument($collection, 'note', new Document(['title' => 'single'])); + $this->assertSame('single', $updated->getAttribute('title')); + $this->assertSame($permissions, $updated->getPermissions()); + } } diff --git a/tests/e2e/Adapter/MySQLTest.php b/tests/e2e/Adapter/MySQLTest.php index 8e92bb216b..5eaf3d034e 100644 --- a/tests/e2e/Adapter/MySQLTest.php +++ b/tests/e2e/Adapter/MySQLTest.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Adapter; use Redis; +use Tests\E2E\Adapter\Scopes\MySQLJoinPlanTests; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MySQL; @@ -14,19 +15,24 @@ class MySQLTest extends Base { + use MySQLJoinPlanTests; + public static ?Database $database = null; + protected static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; /** - * @return Database * @throws Duplicate * @throws Exception * @throws Limit */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -35,18 +41,19 @@ public function getDatabase(): Database $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); + $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(1); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); $database = new Database(new MySQL($pdo), $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setDatabase($this->testDatabase) + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); if ($database->exists()) { $database->delete(); @@ -55,24 +62,29 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "DROP INDEX `{$index}` ON {$sqlTable}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/PoolTest.php b/tests/e2e/Adapter/PoolTest.php index 2164ba391c..99a6de45e7 100644 --- a/tests/e2e/Adapter/PoolTest.php +++ b/tests/e2e/Adapter/PoolTest.php @@ -3,20 +3,21 @@ namespace Tests\E2E\Adapter; use Redis; -use ReflectionClass; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Adapter\Pool; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Limit; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\PDO; +use Utopia\Database\Permission; +use Utopia\Database\Role; use Utopia\Pools\Adapter\Stack; use Utopia\Pools\Pool as UtopiaPool; @@ -28,24 +29,26 @@ class PoolTest extends Base * @var UtopiaPool */ protected static UtopiaPool $pool; + + #[\Override] protected static string $namespace; /** - * @return Database * @throws Exception * @throws Duplicate * @throws Limit */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(6); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); $pool = new UtopiaPool(new Stack(), 'mysql', 10, function () { $dbHost = 'mysql'; @@ -57,16 +60,16 @@ public function getDatabase(): Database dsn: "mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", username: $dbUser, password: $dbPass, - config: MySQL::getPDOAttributes(), + config: self::PDO_ATTRIBUTES, )); }, timeout: 0.0); $database = new Database(new Pool($pool), $cache); - + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setDatabase($this->testDatabase) + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); if ($database->exists()) { $database->delete(); @@ -79,34 +82,30 @@ public function getDatabase(): Database return self::$database = $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; self::$pool->use(function (Adapter $adapter) use ($sql) { - // Hack to get adapter PDO reference - $class = new ReflectionClass($adapter); - $property = $class->getProperty('pdo'); - $property->setAccessible(true); - $pdo = $property->getValue($adapter); + $pdo = $adapter->getDriver(); + assert($pdo instanceof PDO); $pdo->exec($sql); }); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "DROP INDEX `{$index}` ON {$sqlTable}"; self::$pool->use(function (Adapter $adapter) use ($sql) { - // Hack to get adapter PDO reference - $class = new ReflectionClass($adapter); - $property = $class->getProperty('pdo'); - $property->setAccessible(true); - $pdo = $property->getValue($adapter); + $pdo = $adapter->getDriver(); + assert($pdo instanceof PDO); $pdo->exec($sql); }); @@ -114,23 +113,20 @@ protected function deleteIndex(string $collection, string $index): bool } /** - * Execute raw SQL via the pool using reflection to access the adapter's PDO. + * Execute raw SQL via the pool on the adapter's driver. * - * @param string $sql - * @param array $binds + * @param array $binds */ private function execRawSQL(string $sql, array $binds = []): void { self::$pool->use(function (Adapter $adapter) use ($sql, $binds) { - $class = new ReflectionClass($adapter); - $property = $class->getProperty('pdo'); - $property->setAccessible(true); - $pdo = $property->getValue($adapter); - $stmt = $pdo->prepare($sql); + $pdo = $adapter->getDriver(); + assert($pdo instanceof PDO); + $statement = $pdo->prepare($sql); foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value); + $statement->bindValue($key, $value); } - $stmt->execute(); + $statement->execute(); }); } @@ -139,13 +135,13 @@ private function execRawSQL(string $sql, array $binds = []): void * don't block document recreation. The createDocument method should * clean up orphaned perms and retry. */ - public function testOrphanedPermissionsRecovery(): void + public function test_orphaned_permissions_recovery(): void { $database = $this->getDatabase(); $collection = 'orphanedPermsRecovery'; - $database->createCollection($collection); - $database->createAttribute($collection, 'title', Database::VAR_STRING, 128, true); + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'title', size: 128, required: true)); // Step 1: Create a document with permissions $doc = $database->createDocument($collection, new Document([ diff --git a/tests/e2e/Adapter/PostgresTest.php b/tests/e2e/Adapter/PostgresTest.php index 14afc6db74..7cb7280019 100644 --- a/tests/e2e/Adapter/PostgresTest.php +++ b/tests/e2e/Adapter/PostgresTest.php @@ -6,25 +6,32 @@ use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Database\PDO; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; class PostgresTest extends Base { public static ?Database $database = null; + protected static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; /** * @reture Adapter */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -33,17 +40,18 @@ public function getDatabase(): Database $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};", $dbUser, $dbPass, Postgres::getPDOAttributes()); + $pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};", $dbUser, $dbPass, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(2); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); $database = new Database(new Postgres($pdo), $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setDatabase($this->testDatabase) + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); if ($database->exists()) { $database->delete(); @@ -52,30 +60,68 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = '"' . $this->getDatabase()->getDatabase(). '"."' . $this->getDatabase()->getNamespace() . '_' . $collection . '"'; + $sqlTable = '"'.$this->getDatabase()->getDatabase().'"."'.$this->getDatabase()->getNamespace().'_'.$collection.'"'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN \"{$column}\""; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $key = "\"".$this->getDatabase()->getNamespace()."_".$this->getDatabase()->getTenant()."_{$collection}_{$index}\""; + $key = '"'.$this->getDatabase()->getNamespace().'_'.$this->getDatabase()->getTenant()."_{$collection}_{$index}\""; - $sql = "DROP INDEX \"".$this->getDatabase()->getDatabase()."\".{$key}"; + $sql = 'DROP INDEX "'.$this->getDatabase()->getDatabase()."\".{$key}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + public function testCreateCollectionWithMongoSequenceShapedId(): void + { + $database = $this->getDatabase(); + $collection = 'database_507f1f77bcf86cd799439012_collection_507f1f77bcf86cd799439013'; + + $this->assertGreaterThan( + Postgres::MAX_IDENTIFIER_NAME, + \strlen($database->getNamespace().'_'.$collection), + 'The fixture must exceed the Postgres identifier limit, or this test no longer covers long table names' + ); + + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 128, required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $document = $database->createDocument($collection, new Document([ + '$id' => 'vector-doc', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'embeddings', + ])); + + $this->assertSame('vector-doc', $document->getId()); + $this->assertSame('embeddings', $database->getDocument($collection, 'vector-doc')->getAttribute('name')); + $this->assertTrue($database->collectionExists($collection)); + $database->deleteCollection($collection); + } + /** * Reading must be answerable from the row alone. The permissions table holds the same fact, * but reaching it needs a join, and a join has to be resolved before anything can be ordered, @@ -85,13 +131,14 @@ protected function deleteIndex(string $collection, string $index): bool public function testReadDoesNotTouchThePermissionsTable(): void { $database = $this->getDatabase(); + $pdo = self::$pdo; + $this->assertNotNull($pdo); - // no collection level read, so the permission is enforced per document - $database->createCollection('permsPlan', permissions: [ + $database->createCollection(Collection::create(id: 'permsPlan', attributes: [ + Attribute::string(key: 'title', size: 64, required: true), + ], permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); - - $database->createAttribute('permsPlan', 'title', Database::VAR_STRING, 64, true); + ], documentSecurity: true)); foreach (['visible' => Role::any(), 'hidden' => Role::user('nobody')] as $title => $role) { $database->createDocument('permsPlan', new Document([ @@ -100,16 +147,16 @@ public function testReadDoesNotTouchThePermissionsTable(): void ])); } - $table = $database->getNamespace() . '_permsPlan_perms'; + $table = $database->getNamespace().'_permsPlan_perms'; - $scans = function () use ($table): int { - self::$pdo->query('SELECT pg_stat_force_next_flush()'); - self::$pdo->query('SELECT pg_stat_clear_snapshot()'); + $scans = function () use ($pdo, $table): int { + $pdo->query('SELECT pg_stat_force_next_flush()'); + $pdo->query('SELECT pg_stat_clear_snapshot()'); - $statement = self::$pdo->prepare('SELECT COALESCE(SUM(seq_scan + COALESCE(idx_scan, 0)), 0) FROM pg_stat_user_tables WHERE relname = :table'); + $statement = $pdo->prepare('SELECT COALESCE(SUM(seq_scan + COALESCE(idx_scan, 0)), 0) FROM pg_stat_user_tables WHERE relname = :table'); $statement->execute([':table' => $table]); - return (int)$statement->fetchColumn(); + return (int) $statement->fetchColumn(); }; $before = $scans(); @@ -140,14 +187,17 @@ public function testReadDoesNotTouchThePermissionsTable(): void public function testVectorSearchUsesTheIndex(): void { $database = $this->getDatabase(); - - $database->createCollection('vectorPlan', permissions: [ + $pdo = self::$pdo; + $this->assertNotNull($pdo); + + $database->createCollection(Collection::create(id: 'vectorPlan', attributes: [ + Attribute::vector(key: 'embedding', dimensions: 3, required: true), + ], indexes: [ + Index::hnswCosine(key: 'idx_cosine', attribute: 'embedding'), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ], documentSecurity: false); - - $database->createAttribute('vectorPlan', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createIndex('vectorPlan', 'idx_cosine', Database::INDEX_HNSW_COSINE, ['embedding']); + ], documentSecurity: false)); for ($i = 0; $i < 50; $i++) { $database->createDocument('vectorPlan', new Document([ @@ -156,21 +206,21 @@ public function testVectorSearchUsesTheIndex(): void ])); } - $index = $database->getNamespace() . '_' . $database->getTenant() . '_vectorPlan_idx_cosine'; + $index = $database->getNamespace().'_'.$database->getTenant().'_vectorPlan_idx_cosine'; - $scans = function () use ($index): int { - self::$pdo->query('SELECT pg_stat_force_next_flush()'); - self::$pdo->query('SELECT pg_stat_clear_snapshot()'); + $scans = function () use ($pdo, $index): int { + $pdo->query('SELECT pg_stat_force_next_flush()'); + $pdo->query('SELECT pg_stat_clear_snapshot()'); - $statement = self::$pdo->prepare('SELECT COALESCE(SUM(idx_scan), 0) FROM pg_stat_user_indexes WHERE indexrelname = :index'); + $statement = $pdo->prepare('SELECT COALESCE(SUM(idx_scan), 0) FROM pg_stat_user_indexes WHERE indexrelname = :index'); $statement->execute([':index' => $index]); - return (int)$statement->fetchColumn(); + return (int) $statement->fetchColumn(); }; $before = $scans(); - self::$pdo->exec('SET enable_seqscan = off'); + $pdo->exec('SET enable_seqscan = off'); try { $results = $database->find('vectorPlan', [ @@ -178,11 +228,11 @@ public function testVectorSearchUsesTheIndex(): void Query::limit(10), ]); } finally { - self::$pdo->exec('RESET enable_seqscan'); + $pdo->exec('RESET enable_seqscan'); } $this->assertCount(10, $results); - $this->assertEqualsWithDelta(0.0, $results[0]->getAttribute(Database::VECTOR_DISTANCE), 0.001); + $this->assertEqualsWithDelta(0.0, $results[0]->getAttribute(Document::DISTANCE), 0.001); $this->assertGreaterThan( $before, diff --git a/tests/e2e/Adapter/RedisTest.php b/tests/e2e/Adapter/RedisTest.php index 23d779db0f..99f84f6148 100644 --- a/tests/e2e/Adapter/RedisTest.php +++ b/tests/e2e/Adapter/RedisTest.php @@ -6,15 +6,28 @@ use Utopia\Cache\Adapter\None as NoneCacheAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Redis as RedisAdapter; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; - +use Utopia\Database\Document; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\Role; + +/** + * Paratest's `--functional` mode invokes `setUpBeforeClass`/`tearDownAfterClass` + * between every test method, not just at suite boundaries, while inherited + * fixture statics (`$moviesFixtureInit`, `$documentsFixtureInit`, etc.) stay + * set across methods within the same worker process. Scrubbing the namespace + * or recreating the `Database` between tests would leave the cached fixture + * metadata pointing at collections that no longer exist. The CI Redis + * container is ephemeral, so leaking keys to process exit is safe. + */ class RedisTest extends Base { public static ?Database $database = null; public static ?Redis $redisClient = null; public static string $redisNamespace = ''; - /** @var array Adapter-keyspace SCAN patterns the run owns, scrubbed in tearDownAfterClass. */ - protected static array $keyPatterns = []; public static function getAdapterName(): string { @@ -32,6 +45,7 @@ protected function configureDatabase(Database $database): void // Default: per-run unique namespace, no shared tables. } + #[\Override] public function getDatabase(): Database { if (self::$database !== null) { @@ -42,7 +56,7 @@ public function getDatabase(): Database self::$authorization = new \Utopia\Database\Validator\Authorization(); } - $host = \getenv('REDIS_HOST') ?: 'redis-mirror'; + $host = \getenv('REDIS_HOST') ?: 'redis'; $port = (int) (\getenv('REDIS_PORT') ?: 6379); $client = new Redis(); @@ -61,18 +75,11 @@ public function getDatabase(): Database $database = new Database($adapter, $cache); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') + ->setDatabase($this->testDatabase) ->setNamespace(self::$redisNamespace); $this->configureDatabase($database); - // Track every adapter-keyspace pattern this run owns so - // tearDownAfterClass can scrub without a global FLUSH. The - // configureDatabase() call above may have mutated the namespace - // (shared-tables uses ''), so capture the post-configure namespace - // too. - self::$keyPatterns = self::buildKeyPatterns(self::$redisNamespace, $database->getNamespace(), $database->getDatabase()); - if ($database->exists()) { $database->delete(); } @@ -82,33 +89,14 @@ public function getDatabase(): Database return self::$database = $database; } - /** - * Build SCAN MATCH patterns covering the adapter keyspace for every - * namespace this test class actually wrote to. The two-namespace form - * (initial + post-configure) covers the shared-tables case where - * setNamespace('') is applied before create(). - * - * @return array - */ - protected static function buildKeyPatterns(string $initialNamespace, string $effectiveNamespace, string $database): array - { - $patterns = []; - $namespaces = \array_unique([$initialNamespace, $effectiveNamespace]); - foreach ($namespaces as $namespace) { - // Adapter writes: `KEY_PREFIX:{namespace}:{database}:*`. Empty - // namespace produces a literal double-colon, which is a valid - // SCAN pattern. - $patterns[] = RedisAdapter::KEY_PREFIX . ':' . $namespace . ':' . $database . ':*'; - } - return \array_values(\array_unique($patterns)); - } - + #[\Override] protected function deleteColumn(string $collection, string $column): bool { // Redis keeps no out-of-band schema; raw column drops do not apply. return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { return true; @@ -119,6 +107,7 @@ protected function deleteIndex(string $collection, string $index): bool * to VARCHAR. Redis stores documents as JSON; type changes do not * retroactively recast existing values the way PDO string returns do. */ + #[\Override] public function testUpdateAttributeStructure(): void { $this->markTestSkipped( @@ -130,6 +119,7 @@ public function testUpdateAttributeStructure(): void * Inherited test exercises VARCHAR truncation when shrinking a column * that holds oversize data. Redis does not enforce string sizes on disk. */ + #[\Override] public function testUpdateAttributeSize(): void { $this->markTestSkipped( @@ -137,36 +127,80 @@ public function testUpdateAttributeSize(): void ); } - public static function tearDownAfterClass(): void + public function testReadsDropAStoredNonStringPermission(): void { - try { - if (self::$keyPatterns !== [] && self::$redisClient instanceof Redis) { - self::scrubKeys(self::$redisClient, self::$keyPatterns); - } - } finally { - self::$database = null; - self::$redisClient = null; - self::$redisNamespace = ''; - self::$keyPatterns = []; - parent::tearDownAfterClass(); + $database = $this->getDatabase(); + $collection = 'lenientReads'; + $id = Id::unique(); + $permissions = [Permission::read(Role::any())]; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'title' => 'stored', + ])); + + $client = self::$redisClient; + $this->assertNotNull($client); + $payloads = $this->findStoredPayloads($client, $id); + $this->assertNotEmpty($payloads, 'The created document must be stored as a JSON string value.'); + foreach ($payloads as $key => $stored) { + $stored[Document::PERMISSIONS] = [Permission::read(Role::any()), 42, null]; + $client->set($key, \json_encode($stored, JSON_THROW_ON_ERROR)); } + + $this->assertSame($permissions, $database->getDocument($collection, $id)->getPermissions()); + $this->assertSame( + [$permissions], + \array_map(fn (Document $document): array => $document->getPermissions(), $database->find($collection)), + ); + + $this->assertSame(1, $database->updateDocuments($collection, new Document(['title' => 'bulk']))); + $this->assertSame('bulk', $database->getDocument($collection, $id)->getAttribute('title')); + + $updated = $database->updateDocument($collection, $id, new Document(['title' => 'single'])); + $this->assertSame('single', $updated->getAttribute('title')); + $this->assertSame($permissions, $updated->getPermissions()); } /** - * @param array $patterns + * @return array> */ - private static function scrubKeys(Redis $client, array $patterns): void + private function findStoredPayloads(Redis $client, string $id): array { - foreach ($patterns as $pattern) { - $iterator = null; - while (($keys = $client->scan($iterator, $pattern, 500)) !== false) { - if (\is_array($keys) && \count($keys) > 0) { - $client->del($keys); + $payloads = []; + $iterator = null; + do { + $keys = $client->scan($iterator, null, 1000, 'string'); + if (! \is_array($keys) || $keys === []) { + continue; + } + $keys = \array_values(\array_filter($keys, \is_string(...))); + $values = $client->mGet($keys); + if (! \is_array($values)) { + continue; + } + foreach (\array_combine($keys, \array_values($values)) as $key => $value) { + if (! \is_string($value) || ! \str_contains($value, $id)) { + continue; } - if ($iterator === 0) { - break; + $decoded = \json_decode($value, true); + if (\is_array($decoded) && ($decoded[Document::ID] ?? null) === $id) { + $payloads[$key] = $decoded; } } - } + } while ($iterator > 0); + + return $payloads; } } diff --git a/tests/e2e/Adapter/SQLiteTest.php b/tests/e2e/Adapter/SQLiteTest.php index 6a6945e502..4060d3efb1 100644 --- a/tests/e2e/Adapter/SQLiteTest.php +++ b/tests/e2e/Adapter/SQLiteTest.php @@ -6,47 +6,57 @@ use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\PDO; +use Utopia\Database\Permission; +use Utopia\Database\Query; +use Utopia\Database\Role; class SQLiteTest extends Base { public static ?Database $database = null; + protected static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; - /** - * @return Database - */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } - $db = __DIR__."/database.sql"; + $db = __DIR__.'/database_'.static::getTestToken().'.sql'; if (file_exists($db)) { unlink($db); } $dsn = $db; - //$dsn = 'memory'; // Overwrite for fast tests - $pdo = new PDO("sqlite:" . $dsn, null, null, SQLite::getPDOAttributes()); + // $dsn = 'memory'; // Overwrite for fast tests + $pdo = new PDO('sqlite:'.$dsn, null, null, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(3); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); - $adapter = new SQLite($pdo); - $adapter->setEmulateMySQL(true); + $adapter = new class ($pdo) extends SQLite { + #[\Override] + protected bool $emulateMySQL = true; + }; $database = new Database($adapter, $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setDatabase($this->testDatabase) + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); if ($database->exists()) { $database->delete(); @@ -55,24 +65,68 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + public function testPatternQueriesMatchWildcardCharactersLiterally(): void + { + $database = $this->getDatabase(); + $collection = 'likeEscape'; + + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + foreach (['a_b', 'axb', 'c%d', 'cxxd', 'e\\f', 'e\\\\f'] as $name) { + $database->createDocument($collection, new Document(['name' => $name])); + } + + $cases = [ + [Query::containsString('name', ['a_b']), ['a_b']], + [Query::containsAny('name', ['c%d', 'e\\f']), ['c%d', 'e\\f']], + [Query::containsAll('name', ['c%', '%d']), ['c%d']], + [Query::notContains('name', ['_', '\\']), ['axb', 'c%d', 'cxxd']], + [Query::startsWith('name', 'e\\f'), ['e\\f']], + [Query::endsWith('name', '_b'), ['a_b']], + [Query::notStartsWith('name', 'c%'), ['a_b', 'axb', 'cxxd', 'e\\f', 'e\\\\f']], + [Query::notEndsWith('name', '\\\\f'), ['a_b', 'axb', 'c%d', 'cxxd', 'e\\f']], + ]; + + foreach ($cases as [$query, $expected]) { + $names = \array_map( + fn (Document $document): mixed => $document->getAttribute('name'), + $database->find($collection, [$query]), + ); + \sort($names); + \sort($expected); + + $this->assertSame($expected, $names, $query->toString()); + } + } + + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $index = "`".$this->getDatabase()->getNamespace()."_".$this->getDatabase()->getTenant()."_{$collection}_{$index}`"; + $index = '`'.$this->getDatabase()->getNamespace().'_'.$this->getDatabase()->getTenant()."_{$collection}_{$index}`"; $sql = "DROP INDEX {$index}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/Schemaless/MongoDBTest.php b/tests/e2e/Adapter/Schemaless/MongoDBTest.php index 04ebd79f98..a48d59fbd5 100644 --- a/tests/e2e/Adapter/Schemaless/MongoDBTest.php +++ b/tests/e2e/Adapter/Schemaless/MongoDBTest.php @@ -5,6 +5,7 @@ use Exception; use Redis; use Tests\E2E\Adapter\Base; +use Tests\E2E\Adapter\Scopes\MongoReadFilterTests; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; @@ -13,35 +14,37 @@ class MongoDBTest extends Base { + use MongoReadFilterTests; + public static ?Database $database = null; + + #[\Override] protected static string $namespace; /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "mongodb"; + return 'mongodb'; } /** - * @return Database * @throws Exception */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(12); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); - $schema = 'utopiaTests'; // same as $this->testDatabase + $schema = $this->testDatabase; $client = new Client( $schema, 'mongo', @@ -52,17 +55,17 @@ public function getDatabase(): Database ); $database = new Database(new Mongo($client), $cache); - $database->getAdapter()->setSupportForAttributes(false); + $database->setSchemaless(true); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) ->setDatabase($schema) - ->setNamespace(static::$namespace = 'myapp_' . uniqid()); + ->setNamespace(static::$namespace = 'myapp_'.uniqid()); if ($database->exists()) { $database->delete(); } - $database->create(); return self::$database = $database; @@ -71,40 +74,28 @@ public function getDatabase(): Database /** * @throws Exception */ + #[\Override] public function testCreateExistsDelete(): void { - // Mongo creates databases on the fly, so exists would always pass. So we override this test to remove the exists check. - $this->assertNotNull(static::getDatabase()->create()); - $this->assertEquals(true, $this->getDatabase()->delete($this->testDatabase)); - $this->assertEquals(true, $this->getDatabase()->create()); - $this->assertEquals($this->getDatabase(), $this->getDatabase()->setDatabase($this->testDatabase)); - } - - public function testRenameAttribute(): void - { - $this->assertTrue(true); - } - - public function testRenameAttributeExisting(): void - { - $this->assertTrue(true); - } - - public function testUpdateAttributeStructure(): void - { - $this->assertTrue(true); - } - - public function testKeywords(): void - { - $this->assertTrue(true); + $database = $this->getDatabase(); + + $this->assertTrue($database->create()); + $this->assertTrue($database->exists($this->testDatabase)); + $this->assertFalse($database->exists($this->testDatabase.'Absent')); + $this->assertTrue($database->delete($this->testDatabase)); + $this->assertFalse($database->exists($this->testDatabase)); + $this->assertTrue($database->create()); + $this->assertTrue($database->exists($this->testDatabase)); + $this->assertSame($database, $database->setDatabase($this->testDatabase)); } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { return true; diff --git a/tests/e2e/Adapter/Scopes/AggregationTests.php b/tests/e2e/Adapter/Scopes/AggregationTests.php new file mode 100644 index 0000000000..be7c392cd3 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/AggregationTests.php @@ -0,0 +1,2763 @@ + */ + private static array $createdProductCollections = []; + private static string $aggWorkerSuffix = ''; + + private function getAggSuffix(): string + { + if (self::$aggWorkerSuffix === '') { + self::$aggWorkerSuffix = '_' . substr(uniqid(), -6); + } + + return self::$aggWorkerSuffix; + } + + /** + * @param Document|array $row + */ + private function numericAttribute(Document|array $row, string $key): float + { + $value = $row[$key] ?? null; + $this->assertIsNumeric($value); + + return (float) $value; + } + + /** + * @param Document|array $row + */ + private function intAttribute(Document|array $row, string $key): int + { + $value = $row[$key] ?? null; + $this->assertIsNumeric($value); + + return (int) $value; + } + + private function createProducts(Database $database, string $collection = 'agg_products'): void + { + if (isset(self::$createdProductCollections[$collection])) { + return; + } + + if ($database->collectionExists($collection)) { + self::$createdProductCollections[$collection] = true; + return; + } + + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 100, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'category', size: 50, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'price', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'stock', required: true)); + $database->createAttribute($collection, Attribute::double(key: 'rating', default: 0.0)); + + $products = [ + ['$id' => 'laptop', 'name' => 'Laptop', 'category' => 'electronics', 'price' => 1200, 'stock' => 50, 'rating' => 4.5], + ['$id' => 'phone', 'name' => 'Phone', 'category' => 'electronics', 'price' => 800, 'stock' => 100, 'rating' => 4.2], + ['$id' => 'tablet', 'name' => 'Tablet', 'category' => 'electronics', 'price' => 500, 'stock' => 75, 'rating' => 3.8], + ['$id' => 'shirt', 'name' => 'Shirt', 'category' => 'clothing', 'price' => 30, 'stock' => 200, 'rating' => 4.0], + ['$id' => 'pants', 'name' => 'Pants', 'category' => 'clothing', 'price' => 50, 'stock' => 150, 'rating' => 3.5], + ['$id' => 'jacket', 'name' => 'Jacket', 'category' => 'clothing', 'price' => 120, 'stock' => 80, 'rating' => 4.7], + ['$id' => 'novel', 'name' => 'Novel', 'category' => 'books', 'price' => 15, 'stock' => 300, 'rating' => 4.8], + ['$id' => 'textbook', 'name' => 'Textbook', 'category' => 'books', 'price' => 60, 'stock' => 40, 'rating' => 3.2], + ['$id' => 'comic', 'name' => 'Comic', 'category' => 'books', 'price' => 10, 'stock' => 500, 'rating' => 4.1], + ]; + + foreach ($products as $product) { + $database->createDocument($collection, new Document(array_merge($product, [ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]))); + } + } + + private function createOrders(Database $database, string $collection = 'agg_orders'): void + { + if ($database->collectionExists($collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'product_uid', required: true)); + $database->createAttribute($collection, Attribute::string(key: 'customer_uid', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'quantity', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'total', required: true)); + $database->createAttribute($collection, Attribute::string(key: 'status', size: 20, required: true)); + + $orders = [ + ['$id' => 'ord1', 'product_uid' => 'laptop', 'customer_uid' => 'alice', 'quantity' => 1, 'total' => 1200, 'status' => 'completed'], + ['$id' => 'ord2', 'product_uid' => 'phone', 'customer_uid' => 'alice', 'quantity' => 2, 'total' => 1600, 'status' => 'completed'], + ['$id' => 'ord3', 'product_uid' => 'shirt', 'customer_uid' => 'alice', 'quantity' => 3, 'total' => 90, 'status' => 'pending'], + ['$id' => 'ord4', 'product_uid' => 'laptop', 'customer_uid' => 'bob', 'quantity' => 1, 'total' => 1200, 'status' => 'completed'], + ['$id' => 'ord5', 'product_uid' => 'novel', 'customer_uid' => 'bob', 'quantity' => 5, 'total' => 75, 'status' => 'completed'], + ['$id' => 'ord6', 'product_uid' => 'tablet', 'customer_uid' => 'charlie', 'quantity' => 1, 'total' => 500, 'status' => 'cancelled'], + ['$id' => 'ord7', 'product_uid' => 'jacket', 'customer_uid' => 'charlie', 'quantity' => 2, 'total' => 240, 'status' => 'completed'], + ['$id' => 'ord8', 'product_uid' => 'phone', 'customer_uid' => 'diana', 'quantity' => 1, 'total' => 800, 'status' => 'pending'], + ['$id' => 'ord9', 'product_uid' => 'pants', 'customer_uid' => 'diana', 'quantity' => 4, 'total' => 200, 'status' => 'completed'], + ['$id' => 'ord10', 'product_uid' => 'comic', 'customer_uid' => 'diana', 'quantity' => 10, 'total' => 100, 'status' => 'completed'], + ]; + + foreach ($orders as $order) { + $database->createDocument($collection, new Document(array_merge($order, [ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]))); + } + } + + private function createCustomers(Database $database, string $collection = 'agg_customers'): void + { + if ($database->collectionExists($collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 100, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'email', size: 200, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'country', size: 50, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'tier', size: 20, required: true)); + + $customers = [ + ['$id' => 'alice', 'name' => 'Alice', 'email' => 'alice@test.com', 'country' => 'US', 'tier' => 'premium'], + ['$id' => 'bob', 'name' => 'Bob', 'email' => 'bob@test.com', 'country' => 'US', 'tier' => 'basic'], + ['$id' => 'charlie', 'name' => 'Charlie', 'email' => 'charlie@test.com', 'country' => 'UK', 'tier' => 'vip'], + ['$id' => 'diana', 'name' => 'Diana', 'email' => 'diana@test.com', 'country' => 'UK', 'tier' => 'premium'], + ['$id' => 'eve', 'name' => 'Eve', 'email' => 'eve@test.com', 'country' => 'DE', 'tier' => 'basic'], + ]; + + foreach ($customers as $customer) { + $database->createDocument($collection, new Document(array_merge($customer, [ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]))); + } + } + + private function createReviews(Database $database, string $collection = 'agg_reviews'): void + { + if ($database->collectionExists($collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'product_uid', required: true)); + $database->createAttribute($collection, Attribute::string(key: 'customer_uid', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'score', required: true)); + $database->createAttribute($collection, Attribute::string(key: 'comment', size: 500, default: '')); + + $reviews = [ + ['product_uid' => 'laptop', 'customer_uid' => 'alice', 'score' => 5, 'comment' => 'Excellent'], + ['product_uid' => 'laptop', 'customer_uid' => 'bob', 'score' => 4, 'comment' => 'Good'], + ['product_uid' => 'laptop', 'customer_uid' => 'charlie', 'score' => 3, 'comment' => 'Average'], + ['product_uid' => 'phone', 'customer_uid' => 'alice', 'score' => 4, 'comment' => 'Nice'], + ['product_uid' => 'phone', 'customer_uid' => 'diana', 'score' => 5, 'comment' => 'Great'], + ['product_uid' => 'shirt', 'customer_uid' => 'bob', 'score' => 2, 'comment' => 'Poor fit'], + ['product_uid' => 'shirt', 'customer_uid' => 'charlie', 'score' => 4, 'comment' => 'Nice fabric'], + ['product_uid' => 'novel', 'customer_uid' => 'diana', 'score' => 5, 'comment' => 'Loved it'], + ['product_uid' => 'novel', 'customer_uid' => 'alice', 'score' => 5, 'comment' => 'Must read'], + ['product_uid' => 'novel', 'customer_uid' => 'eve', 'score' => 4, 'comment' => 'Good story'], + ['product_uid' => 'jacket', 'customer_uid' => 'charlie', 'score' => 5, 'comment' => 'Perfect'], + ['product_uid' => 'textbook', 'customer_uid' => 'eve', 'score' => 1, 'comment' => 'Boring'], + ]; + + foreach ($reviews as $review) { + $database->createDocument($collection, new Document(array_merge($review, [ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]))); + } + } + + /** + * @param array $collections + */ + private function cleanupAggCollections(Database $database, array $collections): void + { + foreach ($collections as $col) { + if ($database->collectionExists($col)) { + $database->deleteCollection($col); + } + } + } + + public function testCountAll(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'cnt_all'); + $results = $database->aggregate('cnt_all', [Query::count('*', 'total')]); + $this->assertCount(1, $results); + $this->assertEquals(9, $results[0]['total']); + $database->deleteCollection('cnt_all'); + } + + public function testCountWithAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'cnt_alias'); + $results = $database->aggregate('cnt_alias', [Query::count('*', 'num_products')]); + $this->assertCount(1, $results); + $this->assertEquals(9, $results[0]['num_products']); + $database->deleteCollection('cnt_alias'); + } + + public function testCountWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'cnt_filter'); + + $results = $database->aggregate('cnt_filter', [ + Query::equal('category', ['electronics']), + Query::count('*', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]['total']); + + $results = $database->aggregate('cnt_filter', [ + Query::equal('category', ['clothing']), + Query::count('*', 'total'), + ]); + $this->assertEquals(3, $results[0]['total']); + + $results = $database->aggregate('cnt_filter', [ + Query::greaterThan('price', 100), + Query::count('*', 'total'), + ]); + $this->assertEquals(4, $results[0]['total']); + + $database->deleteCollection('cnt_filter'); + } + + public function testCountEmptyCollection(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = 'cnt_empty'; + if ($database->collectionExists($col)) { + $database->deleteCollection($col); + } + $database->createCollection(Collection::create(id: $col, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($col, Attribute::integer(key: 'value', required: true)); + + $results = $database->aggregate($col, [Query::count('*', 'total')]); + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]['total']); + + $database->deleteCollection($col); + } + + public function testCountWithMultipleFilters(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'cnt_multi'); + + $results = $database->aggregate('cnt_multi', [ + Query::equal('category', ['electronics']), + Query::greaterThan('price', 600), + Query::count('*', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]['total']); + + $database->deleteCollection('cnt_multi'); + } + + public function testCountDistinct(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'cnt_distinct'); + $results = $database->aggregate('cnt_distinct', [Query::countDistinct('category', 'unique_cats')]); + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]['unique_cats']); + $database->deleteCollection('cnt_distinct'); + } + + public function testCountDistinctWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'cnt_dist_f'); + $results = $database->aggregate('cnt_dist_f', [ + Query::greaterThan('price', 50), + Query::countDistinct('category', 'unique_cats'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]['unique_cats']); + $database->deleteCollection('cnt_dist_f'); + } + + public function testSumAll(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'sum_all'); + $results = $database->aggregate('sum_all', [Query::sum('price', 'total_price')]); + $this->assertCount(1, $results); + $this->assertEquals(2785, $results[0]['total_price']); + $database->deleteCollection('sum_all'); + } + + public function testSumWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'sum_filt'); + $results = $database->aggregate('sum_filt', [ + Query::equal('category', ['electronics']), + Query::sum('price', 'total'), + ]); + $this->assertEquals(2500, $results[0]['total']); + $database->deleteCollection('sum_filt'); + } + + public function testSumEmptyResult(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'sum_empty'); + $results = $database->aggregate('sum_empty', [ + Query::equal('category', ['nonexistent']), + Query::sum('price', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertNull($results[0]['total']); + $database->deleteCollection('sum_empty'); + } + + public function testSumOfStock(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'sum_stock'); + $results = $database->aggregate('sum_stock', [Query::sum('stock', 'total_stock')]); + $this->assertEquals(1495, $results[0]['total_stock']); + $database->deleteCollection('sum_stock'); + } + + public function testAvgAll(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'avg_all'); + $results = $database->aggregate('avg_all', [Query::avg('price', 'avg_price')]); + $this->assertCount(1, $results); + $this->assertEqualsWithDelta(309.44, $this->numericAttribute($results[0], 'avg_price'), 1.0); + $database->deleteCollection('avg_all'); + } + + public function testAvgWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'avg_filt'); + $results = $database->aggregate('avg_filt', [ + Query::equal('category', ['electronics']), + Query::avg('price', 'avg_price'), + ]); + $this->assertEqualsWithDelta(833.33, $this->numericAttribute($results[0], 'avg_price'), 1.0); + $database->deleteCollection('avg_filt'); + } + + public function testAvgOfRating(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'avg_rating'); + $results = $database->aggregate('avg_rating', [Query::avg('rating', 'avg_rating')]); + $this->assertEqualsWithDelta(4.09, $this->numericAttribute($results[0], 'avg_rating'), 0.1); + $database->deleteCollection('avg_rating'); + } + + public function testMinAll(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'min_all'); + $results = $database->aggregate('min_all', [Query::min('price', 'min_price')]); + $this->assertEquals(10, $results[0]['min_price']); + $database->deleteCollection('min_all'); + } + + public function testMinWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'min_filt'); + $results = $database->aggregate('min_filt', [ + Query::equal('category', ['electronics']), + Query::min('price', 'cheapest'), + ]); + $this->assertEquals(500, $results[0]['cheapest']); + $database->deleteCollection('min_filt'); + } + + public function testMaxAll(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'max_all'); + $results = $database->aggregate('max_all', [Query::max('price', 'max_price')]); + $this->assertEquals(1200, $results[0]['max_price']); + $database->deleteCollection('max_all'); + } + + public function testMaxWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'max_filt'); + $results = $database->aggregate('max_filt', [ + Query::equal('category', ['books']), + Query::max('price', 'expensive'), + ]); + $this->assertEquals(60, $results[0]['expensive']); + $database->deleteCollection('max_filt'); + } + + public function testMinMaxTogether(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'minmax'); + $results = $database->aggregate('minmax', [ + Query::min('price', 'cheapest'), + Query::max('price', 'priciest'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(10, $results[0]['cheapest']); + $this->assertEquals(1200, $results[0]['priciest']); + $database->deleteCollection('minmax'); + } + + public function testMultipleAggregationsTogether(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'multi_agg'); + $results = $database->aggregate('multi_agg', [ + Query::count('*', 'total_count'), + Query::sum('price', 'total_price'), + Query::avg('price', 'avg_price'), + Query::min('price', 'min_price'), + Query::max('price', 'max_price'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(9, $results[0]['total_count']); + $this->assertEquals(2785, $results[0]['total_price']); + $this->assertEqualsWithDelta(309.44, $this->numericAttribute($results[0], 'avg_price'), 1.0); + $this->assertEquals(10, $results[0]['min_price']); + $this->assertEquals(1200, $results[0]['max_price']); + $database->deleteCollection('multi_agg'); + } + + public function testMultipleAggregationsWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'multi_agg_f'); + $results = $database->aggregate('multi_agg_f', [ + Query::equal('category', ['clothing']), + Query::count('*', 'cnt'), + Query::sum('price', 'total'), + Query::avg('stock', 'avg_stock'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]['cnt']); + $this->assertEquals(200, $results[0]['total']); + $this->assertEqualsWithDelta(143.33, $this->numericAttribute($results[0], 'avg_stock'), 1.0); + $database->deleteCollection('multi_agg_f'); + } + + public function testGroupBySingleColumn(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'grp_single'); + $results = $database->aggregate('grp_single', [ + Query::count('*', 'cnt'), + Query::groupBy(['category']), + ]); + + $this->assertCount(3, $results); + $mapped = []; + foreach ($results as $doc) { + $category = $doc['category']; + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(3, $mapped['electronics']['cnt']); + $this->assertEquals(3, $mapped['clothing']['cnt']); + $this->assertEquals(3, $mapped['books']['cnt']); + $database->deleteCollection('grp_single'); + } + + public function testGroupByWithSum(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'grp_sum'); + $results = $database->aggregate('grp_sum', [ + Query::sum('price', 'total_price'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc['category']; + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(2500, $mapped['electronics']['total_price']); + $this->assertEquals(200, $mapped['clothing']['total_price']); + $this->assertEquals(85, $mapped['books']['total_price']); + $database->deleteCollection('grp_sum'); + } + + public function testGroupByWithAvg(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'grp_avg'); + $results = $database->aggregate('grp_avg', [ + Query::avg('price', 'avg_price'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc['category']; + $this->assertIsString($category); + $value = $doc['avg_price']; + $this->assertIsNumeric($value); + $mapped[$category] = (float) $value; + } + $this->assertEqualsWithDelta(833.33, $mapped['electronics'], 1.0); + $this->assertEqualsWithDelta(66.67, $mapped['clothing'], 1.0); + $this->assertEqualsWithDelta(28.33, $mapped['books'], 1.0); + $database->deleteCollection('grp_avg'); + } + + public function testGroupByWithMinMax(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'grp_minmax'); + $results = $database->aggregate('grp_minmax', [ + Query::min('price', 'cheapest'), + Query::max('price', 'priciest'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc['category']; + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(500, $mapped['electronics']['cheapest']); + $this->assertEquals(1200, $mapped['electronics']['priciest']); + $this->assertEquals(30, $mapped['clothing']['cheapest']); + $this->assertEquals(120, $mapped['clothing']['priciest']); + $this->assertEquals(10, $mapped['books']['cheapest']); + $this->assertEquals(60, $mapped['books']['priciest']); + $database->deleteCollection('grp_minmax'); + } + + public function testGroupByWithMultipleAggregations(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'grp_multi'); + $results = $database->aggregate('grp_multi', [ + Query::count('*', 'cnt'), + Query::sum('price', 'total'), + Query::avg('rating', 'avg_rating'), + Query::min('stock', 'min_stock'), + Query::max('stock', 'max_stock'), + Query::groupBy(['category']), + ]); + + $this->assertCount(3, $results); + $mapped = []; + foreach ($results as $doc) { + $category = $doc['category']; + $this->assertIsString($category); + $mapped[$category] = $doc; + } + + $this->assertEquals(3, $mapped['electronics']['cnt']); + $this->assertEquals(2500, $mapped['electronics']['total']); + $this->assertEquals(50, $mapped['electronics']['min_stock']); + $this->assertEquals(100, $mapped['electronics']['max_stock']); + + $this->assertEquals(3, $mapped['books']['cnt']); + $this->assertEquals(85, $mapped['books']['total']); + $this->assertEquals(40, $mapped['books']['min_stock']); + $this->assertEquals(500, $mapped['books']['max_stock']); + + $database->deleteCollection('grp_multi'); + } + + public function testGroupByWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'grp_filt'); + $results = $database->aggregate('grp_filt', [ + Query::greaterThan('price', 50), + Query::count('*', 'cnt'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc['category']; + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(3, $mapped['electronics']['cnt']); + $this->assertEquals(1, $mapped['clothing']['cnt']); + $this->assertEquals(1, $mapped['books']['cnt']); + $database->deleteCollection('grp_filt'); + } + + public function testGroupByOrdersStatus(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createOrders($database, 'grp_status'); + $results = $database->aggregate('grp_status', [ + Query::count('*', 'cnt'), + Query::sum('total', 'revenue'), + Query::groupBy(['status']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $status = $doc['status']; + $this->assertIsString($status); + $mapped[$status] = $doc; + } + $this->assertEquals(7, $mapped['completed']['cnt']); + $this->assertEquals(2, $mapped['pending']['cnt']); + $this->assertEquals(1, $mapped['cancelled']['cnt']); + $database->deleteCollection('grp_status'); + } + + public function testGroupByCustomerOrders(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createOrders($database, 'grp_cust'); + $results = $database->aggregate('grp_cust', [ + Query::count('*', 'order_count'), + Query::sum('total', 'total_spent'), + Query::avg('total', 'avg_order'), + Query::groupBy(['customer_uid']), + ]); + + $this->assertCount(4, $results); + $mapped = []; + foreach ($results as $doc) { + $customer_uid = $doc['customer_uid']; + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(3, $mapped['alice']['order_count']); + $this->assertEquals(2890, $mapped['alice']['total_spent']); + $this->assertEquals(2, $mapped['bob']['order_count']); + $this->assertEquals(1275, $mapped['bob']['total_spent']); + $database->deleteCollection('grp_cust'); + } + + public function testHavingGreaterThan(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'having_gt'); + $results = $database->aggregate('having_gt', [ + Query::sum('price', 'total_price'), + Query::groupBy(['category']), + Query::having([Query::greaterThan('total_price', 100)]), + ]); + + $this->assertCount(2, $results); + $categories = array_map(fn ($d) => $d['category'], $results); + $this->assertContains('electronics', $categories); + $this->assertContains('clothing', $categories); + $this->assertNotContains('books', $categories); + $database->deleteCollection('having_gt'); + } + + public function testHavingLessThan(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'having_lt'); + $results = $database->aggregate('having_lt', [ + Query::count('*', 'cnt'), + Query::sum('price', 'total'), + Query::groupBy(['category']), + Query::having([Query::lessThan('total', 500)]), + ]); + + $this->assertCount(2, $results); + $categories = array_map(fn ($d) => $d['category'], $results); + $this->assertContains('clothing', $categories); + $this->assertContains('books', $categories); + $database->deleteCollection('having_lt'); + } + + public function testHavingWithCount(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createReviews($database, 'having_cnt'); + $results = $database->aggregate('having_cnt', [ + Query::count('*', 'review_count'), + Query::groupBy(['product_uid']), + Query::having([Query::greaterThanEqual('review_count', 3)]), + ]); + + $productIds = array_map(fn ($d) => $d['product_uid'], $results); + $this->assertContains('laptop', $productIds); + $this->assertContains('novel', $productIds); + $this->assertNotContains('jacket', $productIds); + $database->deleteCollection('having_cnt'); + } + + public function testInnerJoinBasic(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createOrders($database, 'ij_orders'); + $this->createCustomers($database, 'ij_customers'); + + $results = $database->aggregate('ij_orders', [ + Query::join('ij_customers', 'j0', [Query::on('customer_uid', '$id')]), + Query::count('*', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(10, $results[0]['total']); + + $this->cleanupAggCollections($database, ['ij_orders', 'ij_customers']); + } + + public function testInnerJoinWithGroupBy(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createOrders($database, 'ij_grp_o'); + $this->createCustomers($database, 'ij_grp_c'); + + $results = $database->aggregate('ij_grp_o', [ + Query::join('ij_grp_c', 'j0', [Query::on('customer_uid', '$id')]), + Query::sum('total', 'total_spent'), + Query::count('*', 'order_count'), + Query::groupBy(['customer_uid']), + ]); + + $this->assertCount(4, $results); + $mapped = []; + foreach ($results as $doc) { + $customer_uid = $doc['customer_uid']; + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(2890, $mapped['alice']['total_spent']); + $this->assertEquals(3, $mapped['alice']['order_count']); + $this->assertEquals(1275, $mapped['bob']['total_spent']); + $this->assertEquals(2, $mapped['bob']['order_count']); + + $this->cleanupAggCollections($database, ['ij_grp_o', 'ij_grp_c']); + } + + public function testInnerJoinWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createOrders($database, 'ij_filt_o'); + $this->createCustomers($database, 'ij_filt_c'); + + $results = $database->aggregate('ij_filt_o', [ + Query::join('ij_filt_c', 'j0', [Query::on('customer_uid', '$id')]), + Query::equal('status', ['completed']), + Query::sum('total', 'revenue'), + Query::groupBy(['customer_uid']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $customer_uid = $doc['customer_uid']; + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(2800, $mapped['alice']['revenue']); + $this->assertEquals(1275, $mapped['bob']['revenue']); + $this->assertEquals(240, $mapped['charlie']['revenue']); + $this->assertEquals(300, $mapped['diana']['revenue']); + + $this->cleanupAggCollections($database, ['ij_filt_o', 'ij_filt_c']); + } + + public function testInnerJoinWithHaving(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createOrders($database, 'ij_hav_o'); + $this->createCustomers($database, 'ij_hav_c'); + + $results = $database->aggregate('ij_hav_o', [ + Query::join('ij_hav_c', 'j0', [Query::on('customer_uid', '$id')]), + Query::sum('total', 'total_spent'), + Query::groupBy(['customer_uid']), + Query::having([Query::greaterThan('total_spent', 1000)]), + ]); + + $this->assertCount(3, $results); + $customerIds = array_map(fn ($d) => $d['customer_uid'], $results); + $this->assertContains('alice', $customerIds); + $this->assertContains('bob', $customerIds); + $this->assertContains('diana', $customerIds); + + $this->cleanupAggCollections($database, ['ij_hav_o', 'ij_hav_c']); + } + + public function testInnerJoinProductReviewStats(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'ij_prs_p'); + $this->createReviews($database, 'ij_prs_r'); + + $results = $database->aggregate('ij_prs_p', [ + Query::join('ij_prs_r', 'j0', [Query::on('$id', 'product_uid')]), + Query::count('*', 'review_count'), + Query::avg('score', 'avg_score'), + Query::groupBy(['name']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $name = $doc['name']; + $this->assertIsString($name); + $mapped[$name] = $doc; + } + + $this->assertEquals(3, $mapped['Laptop']['review_count']); + $this->assertEqualsWithDelta(4.0, $this->numericAttribute($mapped['Laptop'], 'avg_score'), 0.1); + $this->assertEquals(3, $mapped['Novel']['review_count']); + $this->assertEqualsWithDelta(4.67, $this->numericAttribute($mapped['Novel'], 'avg_score'), 0.1); + + $this->cleanupAggCollections($database, ['ij_prs_p', 'ij_prs_r']); + } + + public function testLeftJoinBasic(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'lj_basic_p'); + $this->createReviews($database, 'lj_basic_r'); + + $results = $database->aggregate('lj_basic_p', [ + Query::leftJoin('lj_basic_r', 'j0', [Query::on('$id', 'product_uid')]), + Query::count('*', 'review_count'), + Query::groupBy(['name']), + ]); + + $this->assertCount(9, $results); + + $mapped = []; + foreach ($results as $doc) { + $name = $doc['name']; + $this->assertIsString($name); + $mapped[$name] = $doc; + } + + $this->assertEquals(3, $mapped['Laptop']['review_count']); + $this->assertEquals(2, $mapped['Phone']['review_count']); + $this->assertEquals(1, $mapped['Tablet']['review_count']); + $this->assertEquals(1, $mapped['Comic']['review_count']); + + $this->cleanupAggCollections($database, ['lj_basic_p', 'lj_basic_r']); + } + + public function testLeftJoinWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createProducts($database, 'lj_filt_p'); + $this->createOrders($database, 'lj_filt_o'); + + $results = $database->aggregate('lj_filt_p', [ + Query::leftJoin('lj_filt_o', 'j0', [Query::on('$id', 'product_uid')]), + Query::equal('category', ['electronics']), + Query::count('*', 'order_count'), + Query::sum('quantity', 'total_qty'), + Query::groupBy(['name']), + ]); + + $this->assertCount(3, $results); + + $mapped = []; + foreach ($results as $doc) { + $name = $doc['name']; + $this->assertIsString($name); + $mapped[$name] = $doc; + } + $this->assertEquals(2, $mapped['Laptop']['order_count']); + $this->assertEquals(2, $mapped['Phone']['order_count']); + + $this->cleanupAggCollections($database, ['lj_filt_p', 'lj_filt_o']); + } + + public function testLeftJoinCustomerOrderSummary(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->createCustomers($database, 'lj_cos_c'); + $this->createOrders($database, 'lj_cos_o'); + + $results = $database->aggregate('lj_cos_c', [ + Query::leftJoin('lj_cos_o', 'j0', [Query::on('$id', 'customer_uid')]), + Query::count('*', 'order_count'), + Query::groupBy(['name']), + ]); + + $this->assertCount(5, $results); + + $mapped = []; + foreach ($results as $doc) { + $name = $doc['name']; + $this->assertIsString($name); + $mapped[$name] = $doc; + } + + $this->assertEquals(3, $mapped['Alice']['order_count']); + $this->assertEquals(2, $mapped['Bob']['order_count']); + $this->assertEquals(2, $mapped['Charlie']['order_count']); + $this->assertEquals(3, $mapped['Diana']['order_count']); + $this->assertEquals(1, $mapped['Eve']['order_count']); + + $this->cleanupAggCollections($database, ['lj_cos_c', 'lj_cos_o']); + } + + public function testJoinAggregationWithPermissionsGrouped(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $cols = ['jp_apg_o', 'jp_apg_c']; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: 'jp_apg_c', permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::read(Role::user('viewer'))])); + $database->createAttribute('jp_apg_c', Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(Collection::create(id: 'jp_apg_o', permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute('jp_apg_o', Attribute::string(key: 'customer_uid', required: true)); + $database->createAttribute('jp_apg_o', Attribute::integer(key: 'amount', required: true)); + + foreach (['u1', 'u2'] as $uid) { + $database->createDocument('jp_apg_c', new Document([ + '$id' => $uid, 'name' => 'User ' . $uid, + '$permissions' => [Permission::read(Role::any()), Permission::read(Role::user('viewer'))], + ])); + } + + $database->createDocument('jp_apg_o', new Document([ + 'customer_uid' => 'u1', 'amount' => 100, + '$permissions' => [Permission::read(Role::user('viewer'))], + ])); + $database->createDocument('jp_apg_o', new Document([ + 'customer_uid' => 'u1', 'amount' => 200, + '$permissions' => [Permission::read(Role::user('viewer'))], + ])); + $database->createDocument('jp_apg_o', new Document([ + 'customer_uid' => 'u2', 'amount' => 500, + '$permissions' => [Permission::read(Role::user('admin'))], + ])); + $database->createDocument('jp_apg_o', new Document([ + 'customer_uid' => 'u2', 'amount' => 50, + '$permissions' => [Permission::read(Role::user('viewer'))], + ])); + + $database->getAuthorization()->cleanRoles(); + $database->getAuthorization()->addRole(Role::user('viewer')->toString()); + + $results = $database->aggregate('jp_apg_o', [ + Query::join('jp_apg_c', 'j0', [Query::on('customer_uid', '$id')]), + Query::sum('amount', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['customer_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $customer_uid = $doc['customer_uid']; + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(300, $mapped['u1']['total']); + $this->assertEquals(2, $mapped['u1']['cnt']); + $this->assertEquals(50, $mapped['u2']['total']); + $this->assertEquals(1, $mapped['u2']['cnt']); + + $database->getAuthorization()->cleanRoles(); + $database->getAuthorization()->addRole('any'); + + $this->cleanupAggCollections($database, $cols); + } + + public function testLeftJoinPermissionFiltered(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $cols = ['jp_ljpf_p', 'jp_ljpf_r']; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: 'jp_ljpf_p', permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute('jp_ljpf_p', Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(Collection::create(id: 'jp_ljpf_r', permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::read(Role::user('tester'))])); + $database->createAttribute('jp_ljpf_r', Attribute::string(key: 'product_uid', required: true)); + $database->createAttribute('jp_ljpf_r', Attribute::integer(key: 'score', required: true)); + + $database->createDocument('jp_ljpf_p', new Document([ + '$id' => 'visible', 'name' => 'Visible Product', + '$permissions' => [Permission::read(Role::user('tester'))], + ])); + $database->createDocument('jp_ljpf_p', new Document([ + '$id' => 'hidden', 'name' => 'Hidden Product', + '$permissions' => [Permission::read(Role::user('admin'))], + ])); + + foreach (['visible', 'visible', 'hidden'] as $pid) { + $database->createDocument('jp_ljpf_r', new Document([ + 'product_uid' => $pid, 'score' => 5, + '$permissions' => [Permission::read(Role::any()), Permission::read(Role::user('tester'))], + ])); + } + + $database->getAuthorization()->cleanRoles(); + $database->getAuthorization()->addRole(Role::user('tester')->toString()); + + $results = $database->aggregate('jp_ljpf_p', [ + Query::leftJoin('jp_ljpf_r', 'j0', [Query::on('$id', 'product_uid')]), + Query::count('*', 'review_count'), + Query::groupBy(['name']), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('Visible Product', $results[0]['name']); + $this->assertEquals(2, $results[0]['review_count']); + + $database->getAuthorization()->cleanRoles(); + $database->getAuthorization()->addRole('any'); + + $this->cleanupAggCollections($database, $cols); + } + + /** + * @return array, int|float}> + */ + public static function singleAggregationProvider(): array + { + return [ + 'count all products' => ['cnt', 'count', '*', 'total', [], 9], + 'count electronics' => ['cnt', 'count', '*', 'total', [Query::equal('category', ['electronics'])], 3], + 'count clothing' => ['cnt', 'count', '*', 'total', [Query::equal('category', ['clothing'])], 3], + 'count books' => ['cnt', 'count', '*', 'total', [Query::equal('category', ['books'])], 3], + 'count price > 100' => ['cnt', 'count', '*', 'total', [Query::greaterThan('price', 100)], 4], + 'count price <= 50' => ['cnt', 'count', '*', 'total', [Query::lessThanEqual('price', 50)], 4], + 'sum all prices' => ['sum', 'sum', 'price', 'total', [], 2785], + 'sum electronics' => ['sum', 'sum', 'price', 'total', [Query::equal('category', ['electronics'])], 2500], + 'sum clothing' => ['sum', 'sum', 'price', 'total', [Query::equal('category', ['clothing'])], 200], + 'sum books' => ['sum', 'sum', 'price', 'total', [Query::equal('category', ['books'])], 85], + 'sum stock' => ['sum', 'sum', 'stock', 'total', [], 1495], + 'sum stock electronics' => ['sum', 'sum', 'stock', 'total', [Query::equal('category', ['electronics'])], 225], + 'min all price' => ['min', 'min', 'price', 'val', [], 10], + 'min electronics price' => ['min', 'min', 'price', 'val', [Query::equal('category', ['electronics'])], 500], + 'min clothing price' => ['min', 'min', 'price', 'val', [Query::equal('category', ['clothing'])], 30], + 'min books price' => ['min', 'min', 'price', 'val', [Query::equal('category', ['books'])], 10], + 'min stock' => ['min', 'min', 'stock', 'val', [], 40], + 'max all price' => ['max', 'max', 'price', 'val', [], 1200], + 'max electronics price' => ['max', 'max', 'price', 'val', [Query::equal('category', ['electronics'])], 1200], + 'max clothing price' => ['max', 'max', 'price', 'val', [Query::equal('category', ['clothing'])], 120], + 'max books price' => ['max', 'max', 'price', 'val', [Query::equal('category', ['books'])], 60], + 'max stock' => ['max', 'max', 'stock', 'val', [], 500], + 'count distinct categories' => ['cntd', 'countDistinct', 'category', 'val', [], 3], + 'count distinct price > 50' => ['cntd', 'countDistinct', 'category', 'val', [Query::greaterThan('price', 50)], 3], + ]; + } + + /** + * @param array $filters + */ + #[DataProvider('singleAggregationProvider')] + public function testSingleAggregation(string $collSuffix, string $method, string $attribute, string $alias, array $filters, int|float $expected): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = 'dp_agg_' . $collSuffix . $this->getAggSuffix(); + $this->createProducts($database, $col); + + $aggQuery = match ($method) { + 'count' => Query::count($attribute, $alias), + 'sum' => Query::sum($attribute, $alias), + 'avg' => Query::avg($attribute, $alias), + 'min' => Query::min($attribute, $alias), + 'max' => Query::max($attribute, $alias), + 'countDistinct' => Query::countDistinct($attribute, $alias), + default => throw new \InvalidArgumentException('Unknown aggregation method: '.$method), + }; + + $queries = array_merge($filters, [$aggQuery]); + $results = $database->aggregate($col, $queries); + $this->assertCount(1, $results); + + if ($method === 'avg') { + $this->assertEqualsWithDelta($expected, $this->numericAttribute($results[0], $alias), 1.0); + } else { + $this->assertEquals($expected, $results[0][$alias]); + } + } + + /** + * @return array, int}> + */ + public static function groupByCountProvider(): array + { + return [ + 'group by category no filter' => ['category', [], 3], + 'group by category price > 50' => ['category', [Query::greaterThan('price', 50)], 3], + 'group by category price > 200' => ['category', [Query::greaterThan('price', 200)], 1], + ]; + } + + /** + * @param array $filters + */ + #[DataProvider('groupByCountProvider')] + public function testGroupByCount(string $groupCol, array $filters, int $expectedGroups): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = 'dp_grpby' . $this->getAggSuffix(); + $this->createProducts($database, $col); + + $queries = array_merge($filters, [ + Query::count('*', 'cnt'), + Query::groupBy([$groupCol]), + ]); + $results = $database->aggregate($col, $queries); + $this->assertCount($expectedGroups, $results); + } + + /** + * @return array + */ + public static function orderStatusAggProvider(): array + { + return [ + 'completed orders revenue' => ['completed', 4615], + 'pending orders revenue' => ['pending', 890], + 'cancelled orders revenue' => ['cancelled', 500], + ]; + } + + #[DataProvider('orderStatusAggProvider')] + public function testOrderStatusAggregation(string $status, int $expectedRevenue): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = 'dp_osa_' . $status; + $this->createOrders($database, $col); + + $results = $database->aggregate($col, [ + Query::equal('status', [$status]), + Query::sum('total', 'revenue'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals($expectedRevenue, $results[0]['revenue']); + $database->deleteCollection($col); + } + + /** + * @return array + */ + public static function categoryAggProvider(): array + { + return [ + 'electronics count' => ['electronics', 'count', 3], + 'electronics sum' => ['electronics', 'sum', 2500], + 'electronics min' => ['electronics', 'min', 500], + 'electronics max' => ['electronics', 'max', 1200], + 'clothing count' => ['clothing', 'count', 3], + 'clothing sum' => ['clothing', 'sum', 200], + 'clothing min' => ['clothing', 'min', 30], + 'clothing max' => ['clothing', 'max', 120], + 'books count' => ['books', 'count', 3], + 'books sum' => ['books', 'sum', 85], + 'books min' => ['books', 'min', 10], + 'books max' => ['books', 'max', 60], + ]; + } + + #[DataProvider('categoryAggProvider')] + public function testCategoryAggregation(string $category, string $method, int|float $expected): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = 'dp_cat_' . $category . '_' . $method; + $this->createProducts($database, $col); + + $aggQuery = match ($method) { + 'count' => Query::count('*', 'val'), + 'sum' => Query::sum('price', 'val'), + 'min' => Query::min('price', 'val'), + 'max' => Query::max('price', 'val'), + default => throw new \InvalidArgumentException('Unknown aggregation method: '.$method), + }; + + $results = $database->aggregate($col, [ + Query::equal('category', [$category]), + $aggQuery, + ]); + $this->assertEquals($expected, $results[0]['val']); + $database->deleteCollection($col); + } + + /** + * @return array + */ + public static function reviewCountProvider(): array + { + return [ + 'laptop reviews' => ['laptop', 3], + 'phone reviews' => ['phone', 2], + 'shirt reviews' => ['shirt', 2], + 'novel reviews' => ['novel', 3], + 'jacket reviews' => ['jacket', 1], + 'textbook reviews' => ['textbook', 1], + ]; + } + + #[DataProvider('reviewCountProvider')] + public function testReviewCounts(string $productId, int $expectedCount): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = 'dp_rc_' . $productId; + $this->createReviews($database, $col); + + $results = $database->aggregate($col, [ + Query::equal('product_uid', [$productId]), + Query::count('*', 'cnt'), + ]); + $this->assertEquals($expectedCount, $results[0]['cnt']); + $database->deleteCollection($col); + } + + /** + * @return array + */ + public static function priceRangeCountProvider(): array + { + return [ + 'price 0-20' => [0, 20, 2], + 'price 0-50' => [0, 50, 4], + 'price 0-100' => [0, 100, 5], + 'price 50-200' => [50, 200, 3], + 'price 100-500' => [100, 500, 2], + 'price 500-1500' => [500, 1500, 3], + 'price 0-10000' => [0, 10000, 9], + ]; + } + + #[DataProvider('priceRangeCountProvider')] + public function testPriceRangeCount(int $min, int $max, int $expected): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = 'dp_prc_' . $min . '_' . $max; + $this->createProducts($database, $col); + + $results = $database->aggregate($col, [ + Query::between('price', $min, $max), + Query::count('*', 'cnt'), + ]); + $this->assertEquals($expected, $results[0]['cnt']); + $database->deleteCollection($col); + } + + /** + * stddev() and variance() are the POPULATION statistic on every adapter. + * + * Bare SQL `STDDEV` / `VARIANCE` are population on MySQL and MariaDB and + * sample on PostgreSQL, so the same query answered different numbers per + * engine. The adapters now emit `STDDEV_POP` / `VAR_POP` explicitly, so + * these must equal the stddevPop / varPop cases below over the same rows. + */ + public function testStddevAndVarianceArePopulationOnEveryAdapter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'stat_contract'); + + $stddev = $database->aggregate('stat_contract', [Query::stddev('price', 'result')]); + $this->assertCount(1, $stddev); + $this->assertEqualsWithDelta(406.87456737949, $this->numericAttribute($stddev[0], 'result'), 0.5); + $this->assertNotEqualsWithDelta(431.55564852957, $this->numericAttribute($stddev[0], 'result'), 0.5); + + $variance = $database->aggregate('stat_contract', [Query::variance('price', 'result')]); + $this->assertCount(1, $variance); + $this->assertEqualsWithDelta(165546.91358025, $this->numericAttribute($variance[0], 'result'), 1.0); + $this->assertNotEqualsWithDelta(186240.27777778, $this->numericAttribute($variance[0], 'result'), 1.0); + + $database->deleteCollection('stat_contract'); + } + + public function testStddevPopOfPrice(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'stddev_pop'); + $results = $database->aggregate('stddev_pop', [Query::stddevPop('price', 'result')]); + $this->assertCount(1, $results); + $this->assertEqualsWithDelta(406.87456737949, $this->numericAttribute($results[0], 'result'), 0.5); + $database->deleteCollection('stddev_pop'); + } + + public function testStddevSampOfPrice(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'stddev_samp'); + $results = $database->aggregate('stddev_samp', [Query::stddevSamp('price', 'result')]); + $this->assertCount(1, $results); + $this->assertEqualsWithDelta(431.55564852957, $this->numericAttribute($results[0], 'result'), 0.5); + $database->deleteCollection('stddev_samp'); + } + + public function testVarPopOfPrice(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'var_pop'); + $results = $database->aggregate('var_pop', [Query::varPop('price', 'result')]); + $this->assertCount(1, $results); + $this->assertEqualsWithDelta(165546.91358025, $this->numericAttribute($results[0], 'result'), 1.0); + $database->deleteCollection('var_pop'); + } + + public function testVarSampOfPrice(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'var_samp'); + $results = $database->aggregate('var_samp', [Query::varSamp('price', 'result')]); + $this->assertCount(1, $results); + $this->assertEqualsWithDelta(186240.27777778, $this->numericAttribute($results[0], 'result'), 1.0); + $database->deleteCollection('var_samp'); + } + + public function testBitAndOfIntegerColumn(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'bit_and'); + $results = $database->aggregate('bit_and', [Query::bitAnd('price', 'result')]); + $this->assertCount(1, $results); + $this->assertSame(0, $this->intAttribute($results[0], 'result')); + $database->deleteCollection('bit_and'); + } + + public function testBitOrOfIntegerColumn(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'bit_or'); + $results = $database->aggregate('bit_or', [Query::bitOr('price', 'result')]); + $this->assertCount(1, $results); + $this->assertSame(2047, $this->intAttribute($results[0], 'result')); + $database->deleteCollection('bit_or'); + } + + public function testBitXorOfIntegerColumn(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->createProducts($database, 'bit_xor'); + $results = $database->aggregate('bit_xor', [Query::bitXor('price', 'result')]); + $this->assertCount(1, $results); + $this->assertSame(1545, $this->intAttribute($results[0], 'result')); + $database->deleteCollection('bit_xor'); + } + + private function assertRejectedAsQueryShape(callable $call, string $message): void + { + $error = null; + try { + $call(); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(QueryException::class, $error, $error === null ? 'the query shape was accepted' : $error::class.': '.$error->getMessage()); + $this->assertSame($message, $error->getMessage()); + } + + public function testJoinCountIsCappedAtEight(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'join_cap'; + if ($database->collectionExists($collection)) { + $database->deleteCollection($collection); + } + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'label', size: 20, required: true)); + $database->createDocument($collection, new Document([ + 'label' => 'only', + '$permissions' => [Permission::read(Role::any())], + ])); + + $joins = fn (int $count): array => \array_map(fn (int $index): Query => Query::crossJoin($collection, 'joined'.$index), \range(1, $count)); + + $this->assertCount(1, $database->find($collection, $joins(8))); + $this->assertSame(1, $database->count($collection, $joins(8))); + + $this->assertRejectedAsQueryShape(fn () => $database->find($collection, $joins(9)), 'Too many joins: at most 8 are allowed'); + $this->assertRejectedAsQueryShape(fn () => $database->find($collection, [...$joins(62), Query::limit(1)]), 'Too many joins: at most 8 are allowed'); + $this->assertRejectedAsQueryShape(fn () => $database->count($collection, $joins(9)), 'Too many joins: at most 8 are allowed'); + + $database->deleteCollection($collection); + } + + public function testHavingConditionsFollowTheFilterRules(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'having_filter_rules'; + $this->createProducts($database, $collection); + + $rejected = [ + 'Searching by attribute "name" requires a fulltext index.' => [Query::count('*', 'rows'), Query::groupBy(['name']), Query::having([Query::search('name', 'Laptop')])], + 'Invalid query: Having can only compare an aggregate alias or a groupBy attribute: no_such_attribute' => [Query::sum('price', 'total'), Query::groupBy(['category']), Query::having([Query::equal('no_such_attribute', ['x'])])], + 'Invalid query: Having can only compare an aggregate alias or a groupBy attribute: name' => [Query::sum('price', 'total'), Query::groupBy(['category']), Query::having([Query::equal('name', ['Laptop'])])], + 'Invalid query: Aggregate alias "total" can only be compared at the top level of having' => [Query::sum('price', 'total'), Query::groupBy(['category']), Query::having([Query::or([Query::greaterThan('total', 1000), Query::lessThan('total', 100)])])], + 'Invalid query: Query value is invalid for aggregate alias "total"' => [Query::sum('price', 'total'), Query::groupBy(['category']), Query::having([Query::greaterThan('total', 'abc')])], + 'Invalid query: Query value is invalid for attribute "name"' => [Query::max('name', 'last'), Query::groupBy(['category']), Query::having([Query::greaterThan('last', 5)])], + ]; + foreach ($rejected as $message => $queries) { + $this->assertRejectedAsQueryShape(fn () => $database->aggregate($collection, $queries), $message); + } + + $results = $database->aggregate($collection, [ + Query::sum('price', 'total'), + Query::groupBy(['category']), + Query::having([ + Query::greaterThan('total', 100), + Query::equal('category', ['electronics', 'books']), + ]), + ]); + $this->assertCount(1, $results); + $this->assertSame('electronics', $results[0]['category']); + $this->assertSame(2500, $this->intAttribute($results[0], 'total')); + + $database->deleteCollection($collection); + } + + public function testNumericAggregatesRejectAttributesThatAreNotNumbers(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'numeric_aggregate_operands'; + $this->createProducts($database, $collection); + + $rejected = [ + 'sum' => Query::sum('category', 'result'), + 'avg' => Query::avg('name', 'result'), + 'stddev' => Query::stddev('category', 'result'), + 'variance' => Query::variance('category', 'result'), + 'bitAnd' => Query::bitAnd('name', 'result'), + 'bitOr' => Query::bitOr('category', 'result'), + ]; + foreach ($rejected as $method => $query) { + $this->assertRejectedAsQueryShape( + fn () => $database->aggregate($collection, [$query]), + 'Invalid query: Aggregate '.$method.' requires a numeric attribute that is not an array: '.$query->getAttribute(), + ); + } + + $this->assertRejectedAsQueryShape( + fn () => $database->aggregate($collection, [Query::bitXor('rating', 'result')]), + 'Invalid query: Aggregate bitXor requires an integer attribute that is not an array: rating', + ); + + $results = $database->aggregate($collection, [Query::min('category', 'first'), Query::max('name', 'last'), Query::countDistinct('category', 'categories')]); + $this->assertSame('books', $results[0]['first']); + $this->assertSame('Textbook', $results[0]['last']); + $this->assertSame(3, $this->intAttribute($results[0], 'categories')); + + $database->deleteCollection($collection); + } + + public function testEmptySetAggregatesAreZeroForCountsAndNullOtherwise(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'empty_set_aggregates'; + $this->createProducts($database, $collection); + + $others = [ + 'total' => Query::sum('price', 'total'), + 'mean' => Query::avg('price', 'mean'), + 'least' => Query::min('price', 'least'), + 'most' => Query::max('price', 'most'), + ]; + if (! $database->getAdapter() instanceof SQLite) { + $others += [ + 'spread' => Query::stddev('price', 'spread'), + 'spread_population' => Query::stddevPop('price', 'spread_population'), + 'spread_sample' => Query::stddevSamp('price', 'spread_sample'), + 'variance' => Query::variance('price', 'variance'), + 'variance_population' => Query::varPop('price', 'variance_population'), + 'variance_sample' => Query::varSamp('price', 'variance_sample'), + 'all_bits' => Query::bitAnd('price', 'all_bits'), + 'any_bits' => Query::bitOr('price', 'any_bits'), + 'odd_bits' => Query::bitXor('price', 'odd_bits'), + ]; + } + + $results = $database->aggregate($collection, [ + Query::equal('category', ['nonexistent']), + Query::count('*', 'rows'), + Query::countDistinct('category', 'categories'), + ...\array_values($others), + ]); + + $this->assertCount(1, $results); + $this->assertSame(0, $this->intAttribute($results[0], 'rows')); + $this->assertSame(0, $this->intAttribute($results[0], 'categories')); + foreach (\array_keys($others) as $alias) { + $this->assertArrayHasKey($alias, $results[0], $alias.' must be returned'); + $this->assertNull($results[0][$alias], $alias.' over no rows must be null, got '.\var_export($results[0][$alias], true)); + } + + $expected = ['rows', 'categories', ...\array_keys($others)]; + $returned = \array_keys($results[0]); + \sort($expected); + \sort($returned); + $this->assertSame($expected, $returned, 'only the requested aliases may be returned'); + + $database->deleteCollection($collection); + } + + public function testBitwiseAggregatesOfOnlyNullValuesAreNull(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations) || $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'null_bitwise_inputs'; + if ($database->collectionExists($collection)) { + $database->deleteCollection($collection); + } + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'kind', size: 20, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'flags', required: false)); + + foreach ([['empty', null], ['empty', null], ['set', 6], ['set', 3]] as [$kind, $flags]) { + $database->createDocument($collection, new Document([ + 'kind' => $kind, + 'flags' => $flags, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $bitwise = [Query::bitAnd('flags', 'all_bits'), Query::bitOr('flags', 'any_bits'), Query::bitXor('flags', 'odd_bits')]; + + $ungrouped = $database->aggregate($collection, [Query::equal('kind', ['empty']), ...$bitwise]); + $this->assertCount(1, $ungrouped); + foreach (['all_bits', 'any_bits', 'odd_bits'] as $alias) { + $this->assertNull($ungrouped[0][$alias], $alias.' of only null values must be null, got '.\var_export($ungrouped[0][$alias], true)); + } + + $grouped = $database->aggregate($collection, [...$bitwise, Query::groupBy(['kind']), Query::orderAsc('kind')]); + $this->assertCount(2, $grouped); + $this->assertSame('empty', $grouped[0]['kind']); + foreach (['all_bits', 'any_bits', 'odd_bits'] as $alias) { + $this->assertNull($grouped[0][$alias], $alias.' of a group of null values must be null'); + } + $this->assertSame('set', $grouped[1]['kind']); + $this->assertSame(2, $this->intAttribute($grouped[1], 'all_bits')); + $this->assertSame(7, $this->intAttribute($grouped[1], 'any_bits')); + $this->assertSame(5, $this->intAttribute($grouped[1], 'odd_bits')); + + $database->deleteCollection($collection); + } + + public function testUnknownColumnsAreAttributeNotFound(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || $this->engineIs(SQLite::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'unknown_column'; + $this->createScores($database, $collection); + + $this->assertAttributeNotFound(fn () => $database->skipValidation( + fn () => $database->find($collection, [Query::equal('no_such_attribute', ['x'])]), + )); + + $this->deleteColumn($collection, 'score'); + $this->assertAttributeNotFound(fn () => $database->find($collection, [Query::greaterThan('score', 1)])); + + $database->deleteCollection($collection); + } + + public function testSearchWithoutAFulltextIndexIsAnInvalidQuery(): void + { + $database = static::getDatabase(); + if (! $this->engineIs(MariaDB::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'search_unindexed'; + $this->createScores($database, $collection); + + $search = [Query::search('name', 'alpha')]; + $this->assertRejectedAsQueryShape( + fn () => $database->find($collection, $search), + 'Searching by attribute "name" requires a fulltext index.', + ); + $this->assertRejectedAsQueryShape( + fn () => $database->skipValidation(fn () => $database->find($collection, $search)), + 'Searching requires a fulltext index on the searched attributes', + ); + + $database->deleteCollection($collection); + } + + public function testMoreTablesThanTheEngineCanJoinIsAnInvalidQuery(): void + { + $database = static::getDatabase(); + if (! $this->engineIs(MariaDB::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'join_tables'; + $this->createScores($database, $collection); + + $joins = \array_map(fn (int $index): Query => Query::crossJoin($collection, 'joined'.$index), \range(1, 61)); + $this->assertRejectedAsQueryShape( + fn () => $database->find($collection, $joins), + 'Too many joins: at most 8 are allowed', + ); + $this->assertRejectedAsQueryShape( + fn () => $database->skipValidation(fn () => $database->find($collection, [...$joins, Query::limit(1)])), + 'Too many joins: at most 8 are allowed', + ); + + $database->deleteCollection($collection); + } + + public function testAggregateAliasesAreLimitedToSixtyThreeCharacters(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'alias_length'; + $this->createProducts($database, $collection); + + $longest = \str_repeat('a', 63); + $tooLong = \str_repeat('a', 64); + + $this->assertRejectedAsQueryShape( + fn () => $database->aggregate($collection, [Query::sum('price', $tooLong)]), + 'Invalid query: Aggregate alias is too long: at most 63 characters are allowed', + ); + $this->assertRejectedAsQueryShape( + fn () => $database->aggregate($collection, [Query::count('*', $tooLong), Query::groupBy(['category'])]), + 'Invalid query: Aggregate alias is too long: at most 63 characters are allowed', + ); + + $total = $database->aggregate($collection, [Query::sum('price', $longest)]); + $this->assertCount(1, $total); + $this->assertSame([$longest], \array_keys($total[0])); + $this->assertSame(2785, $this->intAttribute($total[0], $longest)); + + if ($this->supportsBitwiseAggregates()) { + $grouped = $database->aggregate($collection, [Query::bitOr('price', $longest), Query::groupBy(['category']), Query::orderAsc('category')]); + $this->assertSame( + [['books', 63], ['clothing', 126], ['electronics', 2036]], + \array_map(fn (array $row): array => [$row['category'], $this->intAttribute($row, $longest)], $grouped), + ); + + $none = $database->aggregate($collection, [Query::equal('category', ['nonexistent']), Query::bitAnd('price', $longest)]); + $this->assertCount(1, $none); + $this->assertSame([$longest], \array_keys($none[0])); + $this->assertNull($none[0][$longest]); + } + + $database->deleteCollection($collection); + } + + public function testDistinctOrderedByAnUnselectedAttributeIsRejectedWhereTheEngineRefusesIt(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'distinct_order'; + $this->createScores($database, $collection); + + $queries = [Query::distinct(), Query::select(['name']), Query::orderAsc('score')]; + + if ($this->engineIs(Postgres::class, MySQL::class)) { + $this->assertRejectedAsQueryShape( + fn () => $database->find($collection, $queries), + 'A distinct() query can only be ordered by a selected attribute on this database', + ); + } else { + $this->assertSame(['beta', 'gamma', 'alpha'], $this->namesOf($database->find($collection, $queries))); + } + + $this->assertSame( + ['beta', 'gamma', 'alpha'], + $this->namesOf($database->find($collection, [Query::distinct(), Query::select(['name', 'score']), Query::orderAsc('score')])), + ); + + $database->deleteCollection($collection); + } + + private function createScores(Database $database, string $collection): void + { + if ($database->collectionExists($collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 20, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'score', required: true)); + + foreach ([['alpha', 3], ['alpha', 3], ['beta', 1], ['gamma', 2]] as [$name, $score]) { + $database->createDocument($collection, new Document([ + 'name' => $name, + 'score' => $score, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + + private function assertAttributeNotFound(callable $call): void + { + $error = null; + try { + $call(); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(NotFoundException::class, $error, $error === null ? 'the unknown column was accepted' : $error::class.': '.$error->getMessage()); + $this->assertSame('Attribute not found', $error->getMessage()); + } + + /** + * @param array $rows + * @return list + */ + private function namesOf(array $rows): array + { + return \array_values(\array_map(fn (Document $row): mixed => $row->getAttribute('name'), $rows)); + } + + public function testMainAttributeAggregatedUnderItsOwnNameOverAJoinIsReadFromTheMainTable(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins) || ! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $main = 'own_name_main'; + $joined = 'own_name_joined'; + $this->cleanupAggCollections($database, [$main, $joined]); + foreach ([$main, $joined] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'link', size: 16, required: true), Attribute::integer(key: 'score', required: true)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + foreach ([[$main, '1', 10], [$main, '2', 20], [$joined, '1', 1], [$joined, '3', 3]] as [$collection, $link, $score]) { + $database->createDocument($collection, new Document([ + 'link' => $link, + 'score' => $score, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + foreach ([ + 'inner join' => [Query::join($joined, 'other', [Query::on('link', 'link')]), 10, 1], + 'left join' => [Query::leftJoin($joined, 'other', [Query::on('link', 'link')]), 30, 2], + 'right join' => [Query::rightJoin($joined, 'other', [Query::on('link', 'link')]), 10, 2], + 'full outer join' => [Query::fullOuterJoin($joined, 'other', [Query::on('link', 'link')]), 30, 3], + ] as $type => [$join, $total, $rows]) { + $own = $database->aggregate($main, [$join, Query::sum('score', 'score')]); + $this->assertCount(1, $own, $type); + $this->assertSame($total, $this->intAttribute($own[0], 'score'), $type); + + $other = $database->aggregate($main, [$join, Query::sum('score', 'total'), Query::count('*', 'score')]); + $this->assertCount(1, $other, $type); + $this->assertSame($total, $this->intAttribute($other[0], 'total'), $type); + $this->assertSame($rows, $this->intAttribute($other[0], 'score'), $type); + } + + $this->cleanupAggCollections($database, [$main, $joined]); + } + + public function testAggregateAliasNamingAnotherResultColumnIsAnInvalidQuery(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'alias_result_columns'; + $this->createProducts($database, $collection); + + foreach ([ + 'Invalid query: Aggregate alias "category" is the name the groupBy attribute "category" is returned under' => [Query::count('*', 'category'), Query::groupBy(['category'])], + 'Invalid query: Aggregate alias "total" is given to more than one aggregate' => [Query::count('*', 'total'), Query::sum('price', 'total')], + ] as $message => $queries) { + $this->assertRejectedAsQueryShape(fn () => $database->aggregate($collection, $queries), $message); + } + + $results = $database->aggregate($collection, [Query::count('*', 'products'), Query::sum('price', 'total'), Query::groupBy(['category']), Query::orderAsc('category')]); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (array $row): mixed => $row['category'], $results)); + $this->assertSame([3, 3, 3], \array_map(fn (array $row): int => $this->intAttribute($row, 'products'), $results)); + $this->assertSame([85, 200, 2500], \array_map(fn (array $row): int => $this->intAttribute($row, 'total'), $results)); + + $database->deleteCollection($collection); + } + + public function testBitwiseAggregateUnderALongAliasLeavesAnotherAggregateItsValue(): void + { + $database = static::getDatabase(); + if (! $this->supportsBitwiseAggregates()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'long_bitwise_alias'; + $this->createProducts($database, $collection); + $alias = \str_repeat('b', 60); + $prefix = \substr($alias, 0, 55); + + $results = $database->aggregate($collection, [ + Query::equal('category', ['nonexistent']), + Query::bitAnd('price', $alias), + Query::count('*', $prefix), + ]); + + $this->assertCount(1, $results); + $this->assertNull($results[0][$alias], 'a bitwise aggregate over no rows is null'); + $this->assertSame(0, $this->intAttribute($results[0], $prefix), 'an aggregate named like the start of the bitwise alias keeps its value'); + + $database->deleteCollection($collection); + } + + public function testSelectNextToAnAggregateMustNameAGroupedAttribute(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_select_ungrouped'; + $this->createProducts($database, $collection); + + foreach ([ + ['name', [Query::count('*', 'total'), Query::select(['name'])]], + ['$id', [Query::sum('price', 'total'), Query::select(['$id'])]], + ['$collection', [Query::count('*', 'total'), Query::select(['$collection'])]], + ['name', [Query::count('*', 'total'), Query::groupBy(['category']), Query::select(['category', 'name'])]], + ['name', [Query::groupBy(['category']), Query::select(['name'])]], + ] as [$attribute, $queries]) { + $this->assertRejectedAsQueryShape(fn () => $database->aggregate($collection, $queries), $this->ungroupedSelectMessage($attribute)); + } + + if ($database->getAdapter()->supports(Capability::Joins)) { + $orders = 'agg_select_ungrouped_orders'; + $this->createOrders($database, $orders); + $product = Query::join($collection, 'product', [Query::on('product_uid', '$id')]); + + foreach ([ + ['product.name', [$product, Query::count('*', 'total'), Query::groupBy(['status']), Query::select(['product.name'])]], + ['status', [$product, Query::count('*', 'total'), Query::groupBy(['product.category']), Query::select(['status'])]], + ['product.*', [$product, Query::count('*', 'total'), Query::select(['product.*'])]], + ] as [$attribute, $queries]) { + $this->assertRejectedAsQueryShape(fn () => $database->aggregate($orders, $queries), $this->ungroupedSelectMessage($attribute)); + } + + $database->deleteCollection($orders); + } + + $database->deleteCollection($collection); + } + + public function testWildcardSelectNextToAnAggregateReturnsOnlyTheAggregates(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_select_wildcard'; + $this->createProducts($database, $collection); + + $totals = $database->aggregate($collection, [Query::count('*', 'total'), Query::sum('price', 'revenue'), Query::select(['*'])]); + $this->assertCount(1, $totals); + $this->assertSame(['revenue', 'total'], $this->sortedAttributeNames($totals[0])); + $this->assertSame(9, $this->intAttribute($totals[0], 'total')); + $this->assertSame(2785, $this->intAttribute($totals[0], 'revenue')); + + $groups = $database->aggregate($collection, [Query::count('*', 'total'), Query::groupBy(['category']), Query::select(['*']), Query::orderAsc('category')]); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (array $group): mixed => $group['category'], $groups)); + foreach ($groups as $group) { + $this->assertSame(['category', 'total'], $this->sortedAttributeNames($group)); + $this->assertSame(3, $this->intAttribute($group, 'total')); + } + + $database->deleteCollection($collection); + } + + public function testRelationshipWildcardsNextToAnAggregateAddNothingToTheRows(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || ! $adapter->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $customers = 'agg_select_rel_customers'; + $accounts = 'agg_select_rel_accounts'; + $regions = 'agg_select_rel_regions'; + $this->cleanupAggCollections($database, [$customers, $accounts, $regions]); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $regions, attributes: [Attribute::string(key: 'code', size: 16, required: true)], permissions: $permissions)); + $database->createCollection(Collection::create(id: $accounts, attributes: [Attribute::string(key: 'plan', size: 16, required: true)], permissions: $permissions)); + $database->createCollection(Collection::create(id: $customers, attributes: [Attribute::string(key: 'status', size: 16, required: true)], permissions: $permissions)); + $database->createRelationship($customers, Relationship::oneToOne(relatedCollection: $accounts, key: 'account', twoWayKey: 'customer')); + $database->createRelationship($accounts, Relationship::manyToOne(relatedCollection: $regions, key: 'region', twoWayKey: 'accounts')); + + $read = [Permission::read(Role::any())]; + $database->createDocument($regions, new Document(['$id' => 'eu', 'code' => 'eu', '$permissions' => $read])); + $database->createDocument($accounts, new Document(['$id' => 'pro', 'plan' => 'pro', 'region' => 'eu', '$permissions' => $read])); + $database->createDocument($customers, new Document(['$id' => 'c1', 'status' => 'active', 'account' => 'pro', '$permissions' => $read])); + $database->createDocument($customers, new Document(['$id' => 'c2', 'status' => 'active', '$permissions' => $read])); + $database->createDocument($customers, new Document(['$id' => 'c3', 'status' => 'closed', '$permissions' => $read])); + + foreach ([['*', 'account.*'], ['*', 'account.*', 'account.region.*']] as $selects) { + $totals = $database->aggregate($customers, [Query::count('*', 'total'), Query::select($selects)]); + $this->assertCount(1, $totals); + $this->assertSame(['total'], $this->sortedAttributeNames($totals[0])); + $this->assertSame(3, $this->intAttribute($totals[0], 'total')); + + $groups = $database->aggregate($customers, [Query::count('*', 'total'), Query::groupBy(['status']), Query::select($selects), Query::orderAsc('status')]); + $this->assertSame(['active', 'closed'], \array_map(fn (array $group): mixed => $group['status'], $groups)); + $this->assertSame([2, 1], \array_map(fn (array $group): int => $this->intAttribute($group, 'total'), $groups)); + $this->assertSame(['status', 'total'], $this->sortedAttributeNames($groups[0])); + + if ($adapter->supports(Capability::Joins)) { + $joined = $database->aggregate($customers, [Query::fullOuterJoin($accounts, 'owned', [Query::on('account', '$id')]), Query::count('*', 'total'), Query::select($selects)]); + $this->assertCount(1, $joined); + $this->assertSame(['total'], $this->sortedAttributeNames($joined[0])); + $this->assertSame(3, $this->intAttribute($joined[0], 'total')); + } + } + + $this->cleanupAggCollections($database, [$customers, $accounts, $regions]); + } + + public function testGroupedSelectReturnsEachGroupOnceWithItsAggregate(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_select_grouped'; + $this->createProducts($database, $collection); + + foreach ([['category'], ['*', 'category']] as $selects) { + $groups = $database->aggregate($collection, [Query::count('*', 'total'), Query::groupBy(['category']), Query::select($selects), Query::orderAsc('category')]); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (array $group): mixed => $group['category'], $groups)); + foreach ($groups as $group) { + $this->assertSame(['category', 'total'], $this->sortedAttributeNames($group)); + $this->assertSame(3, $this->intAttribute($group, 'total')); + } + } + + $categories = $database->aggregate($collection, [Query::groupBy(['category']), Query::select(['category']), Query::orderAsc('category')]); + $this->assertSame( + [['category' => 'books'], ['category' => 'clothing'], ['category' => 'electronics']], + $categories, + ); + + if ($database->getAdapter()->supports(Capability::Joins)) { + $orders = 'agg_select_grouped_orders'; + $this->createOrders($database, $orders); + + foreach ([ + 'inner join' => [Query::join($collection, 'product', [Query::on('product_uid', '$id')]), [2, 3, 5]], + 'full outer join' => [Query::fullOuterJoin($collection, 'product', [Query::on('product_uid', '$id')]), [3, 3, 5]], + ] as $type => [$product, $totals]) { + $groups = $database->aggregate($orders, [$product, Query::count('*', 'total'), Query::groupBy(['product.category']), Query::select(['product.category']), Query::orderAsc('product.category')]); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (array $group): mixed => $group['category'], $groups), $type); + $this->assertSame($totals, \array_map(fn (array $group): int => $this->intAttribute($group, 'total'), $groups), $type); + $this->assertSame(['category', 'total'], $this->sortedAttributeNames($groups[0]), $type); + } + + $database->deleteCollection($orders); + } + + $database->deleteCollection($collection); + } + + private function ungroupedSelectMessage(string $attribute): string + { + return 'Invalid query: Cannot select "'.$attribute.'": an aggregation query can only select the attributes it groups by'; + } + + /** + * @param Document|array $row + * @return list + */ + private function sortedAttributeNames(Document|array $row): array + { + $names = \array_map(strval(...), \array_keys($row instanceof Document ? $row->getArrayCopy() : $row)); + \sort($names); + + return $names; + } + + public function testAggregateNextToASearchWithoutAnOrderCountsTheMatches(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || ! $adapter->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_search_relevance'; + $this->createProducts($database, $collection); + $this->createSearchMatches($database, $collection, ['sleeve' => ['Laptop Sleeve', 'clothing']]); + + $totals = $database->aggregate($collection, [Query::count('*', 'total'), Query::search('name', 'Laptop')]); + $this->assertSame([['total']], \array_map($this->sortedAttributeNames(...), $totals)); + $this->assertSame(2, $this->intAttribute($totals[0], 'total')); + + $groups = $database->aggregate($collection, [Query::count('*', 'total'), Query::groupBy(['category']), Query::search('name', 'Laptop')]); + $this->assertSame([['category', 'total'], ['category', 'total']], \array_map($this->sortedAttributeNames(...), $groups)); + $this->assertSame(['clothing' => 1, 'electronics' => 1], $this->totalsByCategory($groups)); + + $categories = $database->aggregate($collection, [Query::groupBy(['category']), Query::search('name', 'Laptop')]); + $this->assertSame([['category'], ['category']], \array_map($this->sortedAttributeNames(...), $categories)); + $names = \array_map(fn (array $group): mixed => $group['category'], $categories); + \sort($names); + $this->assertSame(['clothing', 'electronics'], $names); + + $database->deleteCollection($collection); + } + + public function testAggregateNextToAVectorQueryWithoutAnOrderCountsTheMatches(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || ! $adapter->supports(Capability::IndexFulltext) || ! $adapter->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_vector_distance'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 100, required: true), + Attribute::string(key: 'category', size: 50, required: true), + Attribute::vector(key: 'embedding', dimensions: 3, required: true), + ], + indexes: [Index::fulltext(key: 'name_search', attributes: ['name'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + foreach ([ + ['Laptop', 'electronics', [1.0, 0.0, 0.0]], + ['Laptop Sleeve', 'clothing', [0.0, 1.0, 0.0]], + ['Phone', 'electronics', [0.0, 0.0, 1.0]], + ] as [$name, $category, $embedding]) { + $database->createDocument($collection, new Document([ + 'name' => $name, + 'category' => $category, + 'embedding' => $embedding, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $totals = $database->aggregate($collection, [Query::count('*', 'total'), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + $this->assertSame([['total']], \array_map($this->sortedAttributeNames(...), $totals)); + $this->assertSame(3, $this->intAttribute($totals[0], 'total')); + + $groups = $database->aggregate($collection, [Query::count('*', 'total'), Query::groupBy(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + $this->assertSame([['category', 'total'], ['category', 'total']], \array_map($this->sortedAttributeNames(...), $groups)); + $this->assertSame(['clothing' => 1, 'electronics' => 2], $this->totalsByCategory($groups)); + + $matches = $database->aggregate($collection, [Query::count('*', 'total'), Query::search('name', 'Laptop'), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + $this->assertSame([['total']], \array_map($this->sortedAttributeNames(...), $matches)); + $this->assertSame(2, $this->intAttribute($matches[0], 'total')); + + $database->deleteCollection($collection); + } + + /** + * @param list> $groups + * @return array + */ + private function totalsByCategory(array $groups): array + { + $totals = []; + foreach ($groups as $group) { + $category = $group['category'] ?? null; + $this->assertIsString($category); + $totals[$category] = $this->intAttribute($group, 'total'); + } + \ksort($totals); + + return $totals; + } + + public function testDistinctNextToASearchReturnsEachSelectionOnce(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || ! $adapter->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'distinct_search_relevance'; + $this->createProducts($database, $collection); + $this->createSearchMatches($database, $collection, ['dock' => ['Laptop Laptop Dock', 'electronics'], 'sleeve' => ['Laptop Sleeve', 'clothing']]); + + $unordered = $database->find($collection, [Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop')]); + $categories = $this->categoriesOf($unordered); + \sort($categories); + $this->assertSame(['clothing', 'electronics'], $categories); + + $ascending = $database->find($collection, [Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop'), Query::orderAsc('category')]); + $this->assertSame(['clothing', 'electronics'], $this->categoriesOf($ascending)); + + $descending = $database->find($collection, [Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop'), Query::orderDesc('category')]); + $this->assertSame(['electronics', 'clothing'], $this->categoriesOf($descending)); + + foreach ([...$unordered, ...$ascending, ...$descending] as $row) { + $this->assertArrayNotHasKey('_relevance', $row->getArrayCopy()); + } + + $database->deleteCollection($collection); + } + + public function testDistinctNextToAVectorQueryReturnsEachSelectionOnce(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || ! $adapter->supports(Capability::IndexFulltext) || ! $adapter->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'distinct_vector_distance'; + $this->createEmbeddedProducts($database, $collection); + + $unordered = $database->find($collection, [Query::distinct(), Query::select(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + $categories = $this->categoriesOf($unordered); + \sort($categories); + $this->assertSame(['books', 'clothing', 'electronics'], $categories); + foreach ($unordered as $row) { + $this->assertArrayNotHasKey(Document::DISTANCE, $row->getArrayCopy()); + } + + $this->assertSame( + ['books', 'clothing', 'electronics'], + $this->categoriesOf($database->find($collection, [Query::distinct(), Query::select(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::orderAsc('category')])), + ); + $this->assertSame( + ['electronics', 'clothing', 'books'], + $this->categoriesOf($database->find($collection, [Query::distinct(), Query::select(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::orderDesc('category')])), + ); + + $matches = $database->find($collection, [Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop'), Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::orderAsc('category')]); + $this->assertSame(['clothing', 'electronics'], $this->categoriesOf($matches)); + foreach ($matches as $row) { + $this->assertArrayNotHasKey('_relevance', $row->getArrayCopy()); + $this->assertArrayNotHasKey(Document::DISTANCE, $row->getArrayCopy()); + } + + $sleeve = $database->getDocument($collection, 'sleeve'); + $nearest = $database->find($collection, [Query::vectorCosine('embedding', [0.0, 0.0, 1.0]), Query::limit(1)]); + $this->assertSame(['sleeve'], $this->idsOf($nearest)); + $this->assertIsFloat($nearest[0]->getAttribute(Document::DISTANCE)); + + foreach (['a cursor without a distance' => $sleeve, 'a cursor with a distance' => $nearest[0]] as $case => $cursor) { + $this->assertSame( + ['electronics'], + $this->categoriesOf($database->find($collection, [Query::distinct(), Query::select(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::orderAsc('category'), Query::cursorAfter($cursor)])), + $case, + ); + } + + $database->deleteCollection($collection); + } + + public function testRowsNextToAVectorQueryAreOrderedByDistance(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::IndexFulltext) || ! $adapter->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'vector_row_distance'; + $this->createEmbeddedProducts($database, $collection); + + $selected = $database->find($collection, [Query::select(['category']), Query::vectorCosine('embedding', [0.0, 1.0, 0.5])]); + $this->assertSame(['electronics', 'books', 'clothing', 'electronics'], $this->categoriesOf($selected)); + + $matches = $database->find($collection, [Query::search('name', 'Laptop'), Query::vectorCosine('embedding', [0.0, 0.5, 1.0])]); + $this->assertSame(['sleeve', 'dock', 'laptop'], $this->idsOf($matches)); + + $distances = []; + foreach ($matches as $row) { + $this->assertArrayNotHasKey('_relevance', $row->getArrayCopy()); + $distance = $row->getAttribute(Document::DISTANCE); + $this->assertIsFloat($distance); + $distances[] = $distance; + } + $ascending = $distances; + \sort($ascending); + $this->assertSame($ascending, $distances); + + $database->deleteCollection($collection); + } + + private function createEmbeddedProducts(Database $database, string $collection): void + { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 100, required: true), + Attribute::string(key: 'category', size: 50, required: true), + Attribute::vector(key: 'embedding', dimensions: 3, required: true), + ], + indexes: [Index::fulltext(key: 'name_search', attributes: ['name'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + foreach ([ + ['laptop', 'Laptop', 'electronics', [1.0, 0.0, 0.0]], + ['dock', 'Laptop Laptop Dock', 'electronics', [0.0, 1.0, 0.0]], + ['sleeve', 'Laptop Sleeve', 'clothing', [0.0, 0.0, 1.0]], + ['novel', 'Novel', 'books', [1.0, 1.0, 0.0]], + ] as [$id, $name, $category, $embedding]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + 'name' => $name, + 'category' => $category, + 'embedding' => $embedding, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + + /** + * @param array $rows + * @return list + */ + private function categoriesOf(array $rows): array + { + return \array_values(\array_map(fn (Document $row): mixed => $row->getAttribute('category'), $rows)); + } + + /** + * @param array $rows + * @return list + */ + private function idsOf(array $rows): array + { + return \array_values(\array_map(fn (Document $row): string => $row->getId(), $rows)); + } + + /** + * @param array $matches + */ + private function createSearchMatches(Database $database, string $collection, array $matches): void + { + foreach ($matches as $id => [$name, $category]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + 'name' => $name, + 'category' => $category, + 'price' => 90, + 'stock' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + } + $database->createIndex($collection, Index::fulltext(key: 'name_search', attributes: ['name'])); + } + + public function testSearchReadsFollowTheirOrderInsteadOfARelevanceRanking(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'search_explicit_order'; + $this->createProducts($database, $collection); + $this->createSearchMatches($database, $collection, [ + 'dock' => ['Laptop Laptop Dock', 'electronics'], + 'sleeve' => ['Laptop Sleeve', 'clothing'], + 'bag' => ['Laptop Bag', 'electronics'], + ]); + $search = Query::search('name', 'Laptop'); + + $selected = $database->find($collection, [Query::select(['name']), $search]); + $this->assertSame(['Laptop', 'Laptop Laptop Dock', 'Laptop Sleeve', 'Laptop Bag'], $this->namesOf($selected)); + + $byName = $database->find($collection, [$search, Query::orderAsc('name')]); + $this->assertSame(['laptop', 'bag', 'dock', 'sleeve'], $this->idsOf($byName)); + + foreach ([...$selected, ...$byName] as $row) { + $this->assertArrayNotHasKey('_relevance', $row->getArrayCopy()); + } + + if ($adapter->supports(Capability::Aggregations)) { + foreach ([ + 'descending' => [Query::orderDesc('total'), [['electronics', 3], ['clothing', 1]]], + 'ascending' => [Query::orderAsc('total'), [['clothing', 1], ['electronics', 3]]], + ] as $direction => [$order, $expected]) { + $groups = $database->aggregate($collection, [Query::count('*', 'total'), Query::groupBy(['category']), $search, $order]); + $this->assertSame( + $expected, + \array_map(fn (array $group): array => [$group['category'], $this->intAttribute($group, 'total')], $groups), + $direction, + ); + foreach ($groups as $group) { + $this->assertSame(['category', 'total'], $this->sortedAttributeNames($group), $direction); + } + } + } + + $database->deleteCollection($collection); + } + + public function testSearchPagedWithACursorListsEachMatchOnce(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'search_cursor_pages'; + $this->createProducts($database, $collection); + $this->createSearchMatches($database, $collection, [ + 'dock' => ['Laptop Laptop Dock', 'electronics'], + 'sleeve' => ['Laptop Sleeve', 'electronics'], + 'bag' => ['Laptop Bag', 'electronics'], + ]); + + $matches = ['laptop', 'dock', 'sleeve', 'bag']; + $search = Query::search('name', 'Laptop'); + $after = $this->pageOneByOne($database, $collection, $search, null, Query::cursorAfter(...), \count($matches)); + $before = $this->pageOneByOne($database, $collection, $search, $database->getDocument($collection, 'bag'), Query::cursorBefore(...), \count($matches)); + + $this->assertSame( + ['after' => $matches, 'before' => ['sleeve', 'dock', 'laptop']], + ['after' => $this->idsOf($after), 'before' => $this->idsOf($before)], + ); + foreach ([...$after, ...$before] as $document) { + $this->assertArrayNotHasKey('_relevance', $document->getArrayCopy()); + } + + $unpaged = $database->find($collection, [$search]); + $this->assertSame($matches, $this->idsOf($unpaged)); + + $database->deleteCollection($collection); + } + + /** + * @param callable(Document): Query $cursorQuery + * @return list + */ + private function pageOneByOne(Database $database, string $collection, Query $search, ?Document $cursor, callable $cursorQuery, int $matches): array + { + $documents = []; + while (\count($documents) <= $matches) { + $queries = [$search, Query::limit(1)]; + if ($cursor !== null) { + $queries[] = $cursorQuery($cursor); + } + + $cursor = $database->find($collection, $queries)[0] ?? null; + if ($cursor === null) { + break; + } + + $documents[] = $cursor; + } + + return $documents; + } +} diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 8f68ea8243..5ee79fb156 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -3,7 +3,18 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; use Throwable; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\Memory; +use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Adapter\Redis; +use Utopia\Database\Adapter\SQL; +use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\AttributeUpdate; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -13,57 +24,114 @@ use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Truncate as TruncateException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Filter; +use Utopia\Database\Format; +use Utopia\Database\Id; +use Utopia\Database\Index; +use Utopia\Database\IntegerWidth; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\Role; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\Structure; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator\Range; trait AttributeTests { + private static string $attributesCollection = ''; + + private static string $flowersCollection = ''; + + private static string $colorsCollection = ''; + + protected function getAttributesCollection(): string + { + if (self::$attributesCollection === '') { + self::$attributesCollection = 'attributes_' . uniqid(); + } + return self::$attributesCollection; + } + + protected function getFlowersCollection(): string + { + if (self::$flowersCollection === '') { + self::$flowersCollection = 'flowers_' . uniqid(); + } + return self::$flowersCollection; + } + + protected function getColorsCollection(): string + { + if (self::$colorsCollection === '') { + self::$colorsCollection = 'colors_' . uniqid(); + } + return self::$colorsCollection; + } + private function createRandomString(int $length = 10): string { return \substr(\bin2hex(\random_bytes(\max(1, \intval(($length + 1) / 2)))), 0, $length); } /** - * Using phpunit dataProviders to check that all these combinations of types/defaults throw exceptions - * https://phpunit.de/manual/3.7/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers - * - * @return array> + * @param array $attribute + */ + private function priceRangeFormat(array $attribute): Range + { + $formatOptions = $attribute['formatOptions'] ?? []; + if (! is_array($formatOptions)) { + $formatOptions = []; + } + $min = $formatOptions['min'] ?? 0; + $max = $formatOptions['max'] ?? 0; + if (! is_numeric($min)) { + $min = 0; + } + if (! is_numeric($max)) { + $max = 0; + } + + return new Range((float) $min, (float) $max); + } + + /** + * @return list */ - public function invalidDefaultValues(): array + public static function invalidDefaultValues(): array { return [ - [Database::VAR_STRING, 1], - [Database::VAR_STRING, 1.5], - [Database::VAR_STRING, false], - [Database::VAR_INTEGER, "one"], - [Database::VAR_INTEGER, 1.5], - [Database::VAR_INTEGER, true], - [Database::VAR_FLOAT, 1], - [Database::VAR_FLOAT, "one"], - [Database::VAR_FLOAT, false], - [Database::VAR_BOOLEAN, 0], - [Database::VAR_BOOLEAN, "false"], - [Database::VAR_BOOLEAN, 0.5], - [Database::VAR_VARCHAR, 1], - [Database::VAR_VARCHAR, 1.5], - [Database::VAR_VARCHAR, false], - [Database::VAR_TEXT, 1], - [Database::VAR_TEXT, 1.5], - [Database::VAR_TEXT, true], - [Database::VAR_MEDIUMTEXT, 1], - [Database::VAR_MEDIUMTEXT, 1.5], - [Database::VAR_MEDIUMTEXT, false], - [Database::VAR_LONGTEXT, 1], - [Database::VAR_LONGTEXT, 1.5], - [Database::VAR_LONGTEXT, true], + [ColumnType::String, 1], + [ColumnType::String, 1.5], + [ColumnType::String, false], + [ColumnType::Integer, 'one'], + [ColumnType::Integer, 1.5], + [ColumnType::Integer, true], + [ColumnType::Double, 1], + [ColumnType::Double, 'one'], + [ColumnType::Double, false], + [ColumnType::Boolean, 0], + [ColumnType::Boolean, 'false'], + [ColumnType::Boolean, 0.5], + [ColumnType::Varchar, 1], + [ColumnType::Varchar, 1.5], + [ColumnType::Varchar, false], + [ColumnType::Text, 1], + [ColumnType::Text, 1.5], + [ColumnType::Text, true], + [ColumnType::MediumText, 1], + [ColumnType::MediumText, 1.5], + [ColumnType::MediumText, false], + [ColumnType::LongText, 1], + [ColumnType::LongText, 1.5], + [ColumnType::LongText, true], ]; } @@ -72,173 +140,169 @@ public function testCreateDeleteAttribute(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('attributes'); + $database->createCollection(Collection::create(id: $this->getAttributesCollection())); - $this->assertEquals(true, $database->createAttribute('attributes', 'string1', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'string2', Database::VAR_STRING, 16382 + 1, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'string3', Database::VAR_STRING, 65535 + 1, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'string4', Database::VAR_STRING, 16777215 + 1, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'integer', Database::VAR_INTEGER, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'bigint', Database::VAR_INTEGER, 8, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'float', Database::VAR_FLOAT, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'boolean', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'id', Database::VAR_ID, 0, true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string1', size: 128, required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string2', size: 16382 + 1, required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string3', size: 65535 + 1, required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string4', size: 16777215 + 1, required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::integer(key: 'integer', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::bigInteger(key: 'bigint', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::double(key: 'float', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'boolean', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::id(key: 'id', required: true)); // New string types - $this->assertEquals(true, $database->createAttribute('attributes', 'varchar1', Database::VAR_VARCHAR, 255, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'varchar2', Database::VAR_VARCHAR, 128, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'text1', Database::VAR_TEXT, 65535, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'mediumtext1', Database::VAR_MEDIUMTEXT, 16777215, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'longtext1', Database::VAR_LONGTEXT, 4294967295, true)); - - $this->assertEquals(true, $database->createIndex('attributes', 'id_index', Database::INDEX_KEY, ['id'])); - $this->assertEquals(true, $database->createIndex('attributes', 'string1_index', Database::INDEX_KEY, ['string1'])); - $this->assertEquals(true, $database->createIndex('attributes', 'string2_index', Database::INDEX_KEY, ['string2'], [255])); - $this->assertEquals(true, $database->createIndex('attributes', 'multi_index', Database::INDEX_KEY, ['string1', 'string2', 'string3'], [128, 128, 128])); - $this->assertEquals(true, $database->createIndex('attributes', 'varchar1_index', Database::INDEX_KEY, ['varchar1'])); - $this->assertEquals(true, $database->createIndex('attributes', 'varchar2_index', Database::INDEX_KEY, ['varchar2'])); - $this->assertEquals(true, $database->createIndex('attributes', 'text1_index', Database::INDEX_KEY, ['text1'], [255])); - - $collection = $database->getCollection('attributes'); - $this->assertCount(14, $collection->getAttribute('attributes')); - $this->assertCount(7, $collection->getAttribute('indexes')); + $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar1', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar2', size: 128, required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::text(key: 'text1', size: 65535, required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::mediumText(key: 'mediumtext1', size: 16777215, required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::longText(key: 'longtext1', size: 4294967295, required: true)); + + $database->createIndex($this->getAttributesCollection(), Index::key(key: 'id_index', attributes: ['id'])); + $database->createIndex($this->getAttributesCollection(), Index::key(key: 'string1_index', attributes: ['string1'])); + $database->createIndex($this->getAttributesCollection(), Index::key(key: 'string2_index', attributes: ['string2'], lengths: [255])); + $database->createIndex($this->getAttributesCollection(), Index::key(key: 'multi_index', attributes: ['string1', 'string2', 'string3'], lengths: [128, 128, 128])); + $database->createIndex($this->getAttributesCollection(), Index::key(key: 'varchar1_index', attributes: ['varchar1'])); + $database->createIndex($this->getAttributesCollection(), Index::key(key: 'varchar2_index', attributes: ['varchar2'])); + $database->createIndex($this->getAttributesCollection(), Index::key(key: 'text1_index', attributes: ['text1'], lengths: [255])); + + $collection = $database->getCollection($this->getAttributesCollection()); + $this->assertCount(14, $collection->attributes()); + $this->assertCount(7, $collection->indexes()); // Array - $this->assertEquals(true, $database->createAttribute('attributes', 'string_list', Database::VAR_STRING, 128, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'integer_list', Database::VAR_INTEGER, 0, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'float_list', Database::VAR_FLOAT, 0, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'boolean_list', Database::VAR_BOOLEAN, 0, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'varchar_list', Database::VAR_VARCHAR, 128, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'text_list', Database::VAR_TEXT, 65535, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'mediumtext_list', Database::VAR_MEDIUMTEXT, 16777215, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'longtext_list', Database::VAR_LONGTEXT, 4294967295, true, null, true, true)); - - $collection = $database->getCollection('attributes'); - $this->assertCount(22, $collection->getAttribute('attributes')); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string_list', size: 128, required: true, array: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::integer(key: 'integer_list', required: true, array: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::double(key: 'float_list', required: true, array: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'boolean_list', required: true, array: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar_list', size: 128, required: true, array: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::text(key: 'text_list', size: 65535, required: true, array: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::mediumText(key: 'mediumtext_list', size: 16777215, required: true, array: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::longText(key: 'longtext_list', size: 4294967295, required: true, array: true)); + + $collection = $database->getCollection($this->getAttributesCollection()); + $this->assertCount(22, $collection->attributes()); // Default values - $this->assertEquals(true, $database->createAttribute('attributes', 'string_default', Database::VAR_STRING, 256, false, 'test')); - $this->assertEquals(true, $database->createAttribute('attributes', 'integer_default', Database::VAR_INTEGER, 0, false, 1)); - $this->assertEquals(true, $database->createAttribute('attributes', 'float_default', Database::VAR_FLOAT, 0, false, 1.5)); - $this->assertEquals(true, $database->createAttribute('attributes', 'boolean_default', Database::VAR_BOOLEAN, 0, false, false)); - $this->assertEquals(true, $database->createAttribute('attributes', 'datetime_default', Database::VAR_DATETIME, 0, false, '2000-06-12T14:12:55.000+00:00', true, false, null, [], ['datetime'])); - $this->assertEquals(true, $database->createAttribute('attributes', 'varchar_default', Database::VAR_VARCHAR, 255, false, 'varchar default')); - $this->assertEquals(true, $database->createAttribute('attributes', 'text_default', Database::VAR_TEXT, 65535, false, 'text default')); - $this->assertEquals(true, $database->createAttribute('attributes', 'mediumtext_default', Database::VAR_MEDIUMTEXT, 16777215, false, 'mediumtext default')); - $this->assertEquals(true, $database->createAttribute('attributes', 'longtext_default', Database::VAR_LONGTEXT, 4294967295, false, 'longtext default')); - - $collection = $database->getCollection('attributes'); - $this->assertCount(31, $collection->getAttribute('attributes')); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string_default', size: 256, default: 'test')); + $database->createAttribute($this->getAttributesCollection(), Attribute::integer(key: 'integer_default', default: 1)); + $database->createAttribute($this->getAttributesCollection(), Attribute::double(key: 'float_default', default: 1.5)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'boolean_default', default: false)); + $database->createAttribute($this->getAttributesCollection(), Attribute::datetime(key: 'datetime_default', default: '2000-06-12T14:12:55.000+00:00')); + $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar_default', default: 'varchar default')); + $database->createAttribute($this->getAttributesCollection(), Attribute::text(key: 'text_default', size: 65535, default: 'text default')); + $database->createAttribute($this->getAttributesCollection(), Attribute::mediumText(key: 'mediumtext_default', size: 16777215, default: 'mediumtext default')); + $database->createAttribute($this->getAttributesCollection(), Attribute::longText(key: 'longtext_default', size: 4294967295, default: 'longtext default')); + + $collection = $database->getCollection($this->getAttributesCollection()); + $this->assertCount(31, $collection->attributes()); // Delete - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string1')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string2')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string3')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string4')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'integer')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'bigint')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'float')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'boolean')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'id')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'varchar1')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'varchar2')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'text1')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'mediumtext1')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'longtext1')); - - $collection = $database->getCollection('attributes'); - $this->assertCount(17, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); + $database->deleteAttribute($this->getAttributesCollection(), 'string1'); + $database->deleteAttribute($this->getAttributesCollection(), 'string2'); + $database->deleteAttribute($this->getAttributesCollection(), 'string3'); + $database->deleteAttribute($this->getAttributesCollection(), 'string4'); + $database->deleteAttribute($this->getAttributesCollection(), 'integer'); + $database->deleteAttribute($this->getAttributesCollection(), 'bigint'); + $database->deleteAttribute($this->getAttributesCollection(), 'float'); + $database->deleteAttribute($this->getAttributesCollection(), 'boolean'); + $database->deleteAttribute($this->getAttributesCollection(), 'id'); + $database->deleteAttribute($this->getAttributesCollection(), 'varchar1'); + $database->deleteAttribute($this->getAttributesCollection(), 'varchar2'); + $database->deleteAttribute($this->getAttributesCollection(), 'text1'); + $database->deleteAttribute($this->getAttributesCollection(), 'mediumtext1'); + $database->deleteAttribute($this->getAttributesCollection(), 'longtext1'); + + $collection = $database->getCollection($this->getAttributesCollection()); + $this->assertCount(17, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); // Delete Array - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string_list')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'integer_list')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'float_list')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'boolean_list')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'varchar_list')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'text_list')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'mediumtext_list')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'longtext_list')); - - $collection = $database->getCollection('attributes'); - $this->assertCount(9, $collection->getAttribute('attributes')); + $database->deleteAttribute($this->getAttributesCollection(), 'string_list'); + $database->deleteAttribute($this->getAttributesCollection(), 'integer_list'); + $database->deleteAttribute($this->getAttributesCollection(), 'float_list'); + $database->deleteAttribute($this->getAttributesCollection(), 'boolean_list'); + $database->deleteAttribute($this->getAttributesCollection(), 'varchar_list'); + $database->deleteAttribute($this->getAttributesCollection(), 'text_list'); + $database->deleteAttribute($this->getAttributesCollection(), 'mediumtext_list'); + $database->deleteAttribute($this->getAttributesCollection(), 'longtext_list'); + + $collection = $database->getCollection($this->getAttributesCollection()); + $this->assertCount(9, $collection->attributes()); // Delete default - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'integer_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'float_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'boolean_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'datetime_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'varchar_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'text_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'mediumtext_default')); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'longtext_default')); - - $collection = $database->getCollection('attributes'); - $this->assertCount(0, $collection->getAttribute('attributes')); + $database->deleteAttribute($this->getAttributesCollection(), 'string_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'integer_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'float_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'boolean_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'datetime_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'varchar_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'text_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'mediumtext_default'); + $database->deleteAttribute($this->getAttributesCollection(), 'longtext_default'); + + $collection = $database->getCollection($this->getAttributesCollection()); + $this->assertCount(0, $collection->attributes()); // Test for custom chars in ID - $this->assertEquals(true, $database->createAttribute('attributes', 'as_5dasdasdas', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'as5dasdasdas_', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', '.as5dasdasdas', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', '-as5dasdasdas', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'as-5dasdasdas', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'as5dasdasdas-', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', 'socialAccountForYoutubeSubscribersss', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('attributes', '5f058a89258075f058a89258075f058t9214', Database::VAR_BOOLEAN, 0, true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as_5dasdasdas', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as5dasdasdas_', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: '.as5dasdasdas', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: '-as5dasdasdas', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as-5dasdasdas', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as5dasdasdas-', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'socialAccountForYoutubeSubscribersss', required: true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: '5f058a89258075f058a89258075f058t9214', required: true)); // Test non-shared tables duplicates throw duplicate - $database->createAttribute('attributes', 'duplicate', Database::VAR_STRING, 128, true); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'duplicate', size: 128, required: true)); try { - $database->createAttribute('attributes', 'duplicate', Database::VAR_STRING, 128, true); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'duplicate', size: 128, required: true)); $this->fail('Failed to throw exception'); } catch (Exception $e) { $this->assertInstanceOf(DuplicateException::class, $e); } // Test delete attribute when column does not exist - $this->assertEquals(true, $database->createAttribute('attributes', 'string1', Database::VAR_STRING, 128, true)); + $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string1', size: 128, required: true)); sleep(1); - $this->assertEquals(true, $this->deleteColumn('attributes', 'string1')); + $this->assertEquals(true, $this->deleteColumn($this->getAttributesCollection(), 'string1')); - $collection = $database->getCollection('attributes'); - $attributes = $collection->getAttribute('attributes'); + $collection = $database->getCollection($this->getAttributesCollection()); + $attributes = $collection->attributes(); $attribute = end($attributes); - $this->assertEquals('string1', $attribute->getId()); + $this->assertInstanceOf(Attribute::class, $attribute); + $this->assertEquals('string1', $attribute->key); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string1')); + $database->deleteAttribute($this->getAttributesCollection(), 'string1'); - $collection = $database->getCollection('attributes'); - $attributes = $collection->getAttribute('attributes'); + $collection = $database->getCollection($this->getAttributesCollection()); + $attributes = $collection->attributes(); $attribute = end($attributes); - $this->assertNotEquals('string1', $attribute->getId()); + $this->assertInstanceOf(Attribute::class, $attribute); + $this->assertNotEquals('string1', $attribute->key); - $collection = $database->getCollection('attributes'); + $collection = $database->getCollection($this->getAttributesCollection()); } + /** - * @depends testCreateDeleteAttribute - * @dataProvider invalidDefaultValues + * Sets up the 'attributes' collection for tests that depend on testCreateDeleteAttribute. */ - public function testInvalidDefaultValues(string $type, mixed $default): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + private static bool $attributesCollectionFixtureInit = false; - $this->expectException(\Exception::class); - $this->assertEquals(false, $database->createAttribute('attributes', 'bad_default', $type, 256, true, $default)); - } - /** - * @depends testInvalidDefaultValues - */ - public function testAttributeCaseInsensitivity(): void + protected function initAttributesCollectionFixture(): void { - /** @var Database $database */ + if (self::$attributesCollectionFixtureInit) { + return; + } + $database = $this->getDatabase(); - $this->assertEquals(true, $database->createAttribute('attributes', 'caseSensitive', Database::VAR_STRING, 128, true)); - $this->expectException(DuplicateException::class); - $this->assertEquals(true, $database->createAttribute('attributes', 'CaseSensitive', Database::VAR_STRING, 128, true)); + $database->createCollection(Collection::create(id: $this->getAttributesCollection())); + + self::$attributesCollectionFixtureInit = true; } public function testAttributeKeyWithSymbols(): void @@ -246,15 +310,15 @@ public function testAttributeKeyWithSymbols(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('attributesWithKeys'); + $database->createCollection(Collection::create(id: 'attributesWithKeys')); - $this->assertEquals(true, $database->createAttribute('attributesWithKeys', 'key_with.sym$bols', Database::VAR_STRING, 128, true)); + $database->createAttribute('attributesWithKeys', Attribute::string(key: 'key_with.sym$bols', size: 128, required: true)); $document = $database->createDocument('attributesWithKeys', new Document([ 'key_with.sym$bols' => 'value', '$permissions' => [ Permission::read(Role::any()), - ] + ], ])); $this->assertEquals('value', $document->getAttribute('key_with.sym$bols')); @@ -266,42 +330,31 @@ public function testAttributeKeyWithSymbols(): void public function testAttributeNamesWithDots(): void { + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('dots.parent'); + $database->createCollection(Collection::create(id: 'dots.parent')); - $this->assertTrue($database->createAttribute( - collection: 'dots.parent', - id: 'dots.name', - type: Database::VAR_STRING, - size: 255, - required: false - )); + $database->createAttribute('dots.parent', Attribute::string(key: 'dots.name')); $document = $database->find('dots.parent', [ Query::select(['dots.name']), ]); $this->assertEmpty($document); - $database->createCollection('dots'); + $database->createCollection(Collection::create(id: 'dots')); - $this->assertTrue($database->createAttribute( - collection: 'dots', - id: 'name', - type: Database::VAR_STRING, - size: 255, - required: false - )); + $database->createAttribute('dots', Attribute::string(key: 'name')); - $database->createRelationship( - collection: 'dots.parent', - relatedCollection: 'dots', - type: Database::RELATION_ONE_TO_ONE - ); + $database->createRelationship('dots.parent', Relationship::oneToOne(relatedCollection: 'dots')); $database->createDocument('dots.parent', new Document([ - '$id' => ID::custom('father'), + '$id' => Id::custom('father'), 'dots.name' => 'Bill clinton', '$permissions' => [ Permission::read(Role::any()), @@ -310,14 +363,14 @@ public function testAttributeNamesWithDots(): void Permission::delete(Role::any()), ], 'dots' => [ - '$id' => ID::custom('child'), + '$id' => Id::custom('child'), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - ] + ], ])); $documents = $database->find('dots.parent', [ @@ -327,18 +380,201 @@ public function testAttributeNamesWithDots(): void $this->assertEquals('Bill clinton', $documents[0]['dots.name']); } + public function testDottedAttributeKeysFilterFindCountAndSumAlike(): void + { + $database = $this->getDatabase(); + $collection = $this->createDottedKeyCollection($database); + + $matching = [Query::equal('dots.name', ['v'])]; + $this->assertSame(['a', 'b'], $this->sortedIds($database->find($collection, $matching))); + $this->assertSame(2, $database->count($collection, $matching)); + $this->assertSame(2, $database->count($collection, $matching, 10)); + $this->assertSame(1, $database->count($collection, $matching, 1)); + $this->assertSame(5, $database->sum($collection, 'dots.score', $matching)); + $this->assertSame(5, $database->sum($collection, 'dots.score', $matching, 10)); + $this->assertSame(10, $database->sum($collection, 'dots.score')); + + $missing = [Query::equal('dots.name', ['missing'])]; + $this->assertSame(0, $database->count($collection, $missing)); + $this->assertSame(0, $database->sum($collection, 'dots.score', $missing)); + + $grouped = [Query::or([Query::equal('dots.name', ['w']), Query::greaterThan('dots.score', 2)])]; + $this->assertSame(['b', 'c'], $this->sortedIds($database->find($collection, $grouped))); + $this->assertSame(2, $database->count($collection, $grouped)); + $this->assertSame(8, $database->sum($collection, 'dots.score', $grouped)); + + $ordered = [Query::isNotNull('dots.name'), Query::orderDesc('dots.score')]; + $this->assertSame(3, $database->count($collection, $ordered)); + $this->assertSame(10, $database->sum($collection, 'dots.score', $ordered)); + + $database->deleteCollection($collection); + } + + public function testDottedAttributeKeysInExistsQueries(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + if ($adapter instanceof Memory || $adapter instanceof Redis) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createDottedKeyCollection($database); + + $exists = [Query::exists(['dots.name'])]; + $present = $this->sortedIds($database->find($collection, $exists)); + $this->assertSame(['a', 'b', 'c'], \array_values(\array_intersect($present, ['a', 'b', 'c']))); + $this->assertSame(\count($present), $database->count($collection, $exists)); + $this->assertSame(10, $database->sum($collection, 'dots.score', $exists)); + + $notExists = [Query::notExists(['dots.name'])]; + $absent = $this->sortedIds($database->find($collection, $notExists)); + $this->assertSame([], \array_values(\array_intersect($absent, ['a', 'b', 'c']))); + $this->assertSame(\count($absent), $database->count($collection, $notExists)); + $this->assertSame(4, \count($present) + \count($absent)); + + $database->deleteCollection($collection); + } + + public function testDottedAttributeKeysBesideJoinAliases(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createDottedKeyCollection($database); + $orders = $collection.'_orders'; + $database->createCollection(Collection::create( + id: $orders, + attributes: [ + Attribute::string(key: 'personId', size: 64, required: true), + Attribute::integer(key: 'total', required: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + $database->createDocument($orders, new Document(['$id' => 'o1', 'personId' => 'a', 'total' => 7])); + + $join = Query::join($orders, 'ord', [Query::on('$id', 'personId')]); + $leftJoin = Query::leftJoin($orders, 'ord', [Query::on('$id', 'personId')]); + + $this->assertSame(['a'], $this->sortedIds($database->find($collection, [$join, Query::equal('dots.name', ['v'])]))); + $this->assertSame(1, $database->count($collection, [$join, Query::equal('dots.name', ['v'])])); + $this->assertSame(7, $database->sum($collection, 'ord.total', [$join, Query::equal('dots.name', ['v'])])); + $this->assertSame(2, $database->sum($collection, 'dots.score', [$join, Query::greaterThan('ord.total', 5)])); + $this->assertSame(0, $database->count($collection, [$join, Query::greaterThan('ord.total', 7)])); + + $this->assertSame(['a'], $this->sortedIds($database->find($collection, [$join, Query::exists(['ord.$id'])]))); + $this->assertSame(1, $database->count($collection, [$join, Query::exists(['dots.name'])])); + $this->assertSame(['b', 'c', 'd'], $this->sortedIds($database->find($collection, [$leftJoin, Query::notExists(['ord.$id'])]))); + $this->assertSame(3, $database->count($collection, [$leftJoin, Query::notExists(['ord.$createdAt'])])); + + $database->deleteCollection($orders); + $database->deleteCollection($collection); + } + + public function testDottedAttributeKeysInGroups(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createDottedKeyCollection($database); + + $groups = $database->aggregate($collection, [ + Query::count('*', 'people'), + Query::sum('dots.score', 'score'), + Query::groupBy(['dots.name']), + Query::exists(['dots.name']), + Query::orderDesc('people'), + ]); + $this->assertSame([[2, 5], [1, 5]], \array_map( + fn (array $group): array => [$this->aggregatedNumber($group, 'people'), $this->aggregatedNumber($group, 'score')], + $groups, + )); + + $filtered = $database->aggregate($collection, [ + Query::count('*', 'people'), + Query::groupBy(['dots.name']), + Query::equal('dots.name', ['w']), + ]); + $this->assertCount(1, $filtered); + $this->assertSame(1, $this->aggregatedNumber($filtered[0], 'people')); + + $having = $database->aggregate($collection, [ + Query::count('*', 'people'), + Query::groupBy(['dots.name']), + Query::having([Query::greaterThan('people', 1)]), + ]); + $this->assertCount(1, $having); + $this->assertSame(2, $this->aggregatedNumber($having[0], 'people')); + + $database->deleteCollection($collection); + } + + /** + * @param array $group + */ + private function aggregatedNumber(array $group, string $key): int + { + $value = $group[$key] ?? null; + $this->assertIsNumeric($value, "The aggregate {$key} is a number"); + + return (int) $value; + } + + private function createDottedKeyCollection(Database $database): string + { + $collection = 'dotted_keys_'.\substr(\uniqid(), -6); + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'dots.name', size: 64), + Attribute::integer(key: 'dots.score'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + $database->createDocument($collection, new Document(['$id' => 'a', 'dots.name' => 'v', 'dots.score' => 2])); + $database->createDocument($collection, new Document(['$id' => 'b', 'dots.name' => 'v', 'dots.score' => 3])); + $database->createDocument($collection, new Document(['$id' => 'c', 'dots.name' => 'w', 'dots.score' => 5])); + $database->createDocument($collection, new Document(['$id' => 'd'])); + + return $collection; + } + + /** + * @param array $documents + * @return list + */ + private function sortedIds(array $documents): array + { + $ids = \array_map(static fn (Document $document): string => $document->getId(), $documents); + \sort($ids); + + return $ids; + } public function testUpdateAttributeDefault(): void { /** @var Database $database */ $database = $this->getDatabase(); + $collection = $this->getFlowersCollection(); - $flowers = $database->createCollection('flowers'); - $database->createAttribute('flowers', 'name', Database::VAR_STRING, 128, true); - $database->createAttribute('flowers', 'inStock', Database::VAR_INTEGER, 0, false); - $database->createAttribute('flowers', 'date', Database::VAR_STRING, 128, false); + $flowers = $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'inStock')); + $database->createAttribute($collection, Attribute::string(key: 'date', size: 128)); - $database->createDocument('flowers', new Document([ + $database->createDocument($collection, new Document([ '$id' => 'flowerWithDate', '$permissions' => [ Permission::read(Role::any()), @@ -348,52 +584,53 @@ public function testUpdateAttributeDefault(): void ], 'name' => 'Violet', 'inStock' => 51, - 'date' => '2000-06-12 14:12:55.000' + 'date' => '2000-06-12 14:12:55.000', ])); - $doc = $database->createDocument('flowers', new Document([ + $doc = $database->createDocument($collection, new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Lily' + 'name' => 'Lily', ])); + self::$flowersFixtureInit = true; + $this->assertNull($doc->getAttribute('inStock')); - $database->updateAttributeDefault('flowers', 'inStock', 100); + $database->updateAttribute($this->getFlowersCollection(), 'inStock', new AttributeUpdate(default: 100)); - $doc = $database->createDocument('flowers', new Document([ + $doc = $database->createDocument($this->getFlowersCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Iris' + 'name' => 'Iris', ])); $this->assertIsNumeric($doc->getAttribute('inStock')); $this->assertEquals(100, $doc->getAttribute('inStock')); - $database->updateAttributeDefault('flowers', 'inStock', null); + $database->updateAttribute($this->getFlowersCollection(), 'inStock', new AttributeUpdate(default: null)); } - public function testRenameAttribute(): void { /** @var Database $database */ $database = $this->getDatabase(); - $colors = $database->createCollection('colors'); - $database->createAttribute('colors', 'name', Database::VAR_STRING, 128, true); - $database->createAttribute('colors', 'hex', Database::VAR_STRING, 128, true); + $colors = $database->createCollection(Collection::create(id: $this->getColorsCollection())); + $database->createAttribute($this->getColorsCollection(), Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute($this->getColorsCollection(), Attribute::string(key: 'hex', size: 128, required: true)); - $database->createIndex('colors', 'index1', Database::INDEX_KEY, ['name'], [128], [Database::ORDER_ASC]); + $database->createIndex($this->getColorsCollection(), Index::key(key: 'index1', attributes: ['name'], lengths: [128], orders: [OrderDirection::Asc])); - $database->createDocument('colors', new Document([ + $database->createDocument($this->getColorsCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -401,70 +638,197 @@ public function testRenameAttribute(): void Permission::delete(Role::any()), ], 'name' => 'black', - 'hex' => '#000000' + 'hex' => '#000000', ])); - $attribute = $database->renameAttribute('colors', 'name', 'verbose'); - - $this->assertTrue($attribute); + $database->renameAttribute($this->getColorsCollection(), 'name', 'verbose'); - $colors = $database->getCollection('colors'); - $this->assertEquals('hex', $colors->getAttribute('attributes')[1]['$id']); - $this->assertEquals('verbose', $colors->getAttribute('attributes')[0]['$id']); - $this->assertCount(2, $colors->getAttribute('attributes')); + $colors = $database->getCollection($this->getColorsCollection()); + $this->assertEquals('hex', $colors->attributes()[1]->key); + $this->assertEquals('verbose', $colors->attributes()[0]->key); + $this->assertCount(2, $colors->attributes()); // Attribute in index is renamed automatically on adapter-level. What we need to check is if metadata is properly updated - $this->assertEquals('verbose', $colors->getAttribute('indexes')[0]->getAttribute("attributes")[0]); - $this->assertCount(1, $colors->getAttribute('indexes')); + $this->assertEquals('verbose', $colors->indexes()[0]->attributes[0]); + $this->assertCount(1, $colors->indexes()); // Document should be there if adapter migrated properly - $document = $database->findOne('colors'); + $document = $database->findOne($this->getColorsCollection()); $this->assertFalse($document->isEmpty()); $this->assertEquals('black', $document->getAttribute('verbose')); $this->assertEquals('#000000', $document->getAttribute('hex')); $this->assertEquals(null, $document->getAttribute('name')); - } + self::$colorsFixtureInit = true; + } /** - * @depends testUpdateAttributeDefault + * Sets up the 'flowers' collection for tests that depend on testUpdateAttributeDefault. */ + private static bool $flowersFixtureInit = false; + + protected function initFlowersFixture(): void + { + if (self::$flowersFixtureInit) { + return; + } + + $database = $this->getDatabase(); + + $collection = $this->getFlowersCollection(); + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'inStock')); + $database->createAttribute($collection, Attribute::string(key: 'date', size: 128)); + + $database->createDocument($collection, new Document([ + '$id' => 'flowerWithDate', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Violet', + 'inStock' => 51, + 'date' => '2000-06-12 14:12:55.000', + ])); + + $database->createDocument($collection, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Lily', + ])); + + self::$flowersFixtureInit = true; + } + public function testUpdateAttributeRequired(): void { + $this->initFlowersFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $database->updateAttributeRequired('flowers', 'inStock', true); + $database->updateAttribute($this->getFlowersCollection(), 'inStock', new AttributeUpdate(required: true)); $this->expectExceptionMessage('Invalid document structure: Missing required attribute "inStock"'); - $doc = $database->createDocument('flowers', new Document([ + $doc = $database->createDocument($this->getFlowersCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Lily With Missing Stocks' + 'name' => 'Lily With Missing Stocks', ])); } - /** - * @depends testUpdateAttributeDefault - */ + public function testUnstorableColumnTypesAreRejectedUpFront(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collection = 'unstorable_column_types'; + $database->createCollection(Collection::create(id: $collection)); + + foreach ([ColumnType::Json, ColumnType::Timestamp, ColumnType::BigSerial] as $type) { + $message = 'Unknown attribute type: '.$type->value; + $inline = $collection.'_'.$type->value; + + try { + $database->createAttribute($collection, Attribute::fromArray(['key' => 'value', 'type' => $type])); + $this->fail('Expected createAttribute() to reject '.$type->value); + } catch (DatabaseException $error) { + $this->assertStringContainsString($message, $error->getMessage()); + } + + try { + $database->createCollection(Collection::create(id: $inline, attributes: [Attribute::fromArray(['key' => 'value', 'type' => $type])])); + $this->fail('Expected createCollection() to reject '.$type->value); + } catch (DatabaseException $error) { + $this->assertStringContainsString($message, $error->getMessage()); + } + + $this->assertNull($database->findCollection($inline)); + } + + $this->assertSame([], $database->getCollection($collection)->attributes()); + + $database->deleteCollection($collection); + } + + public function testIdAttributeCanBeUpdated(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collection = 'id_attribute_update'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::id(key: 'reference')); + $database->createDocument($collection, new Document([ + '$id' => 'one', + '$permissions' => [Permission::read(Role::any())], + 'reference' => '7', + ])); + + $updated = $database->updateAttribute($collection, 'reference', new AttributeUpdate(key: 'target')); + + $this->assertSame('target', $updated->key); + $this->assertSame(ColumnType::Id, $updated->type); + $this->assertSame('7', $database->getDocument($collection, 'one')->getAttribute('target')); + + $database->deleteCollection($collection); + } + + public function testRequiredOnlyChangeKeepsDatetimeColumnsWritable(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collection = 'datetime_required_relax'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::datetime(key: 'at', required: true)); + $database->createDocument($collection, new Document([ + '$id' => 'one', + '$permissions' => [Permission::read(Role::any())], + 'at' => '2024-01-01T00:00:00.000+00:00', + ])); + + $updated = $database->updateAttribute($collection, 'at', new AttributeUpdate(required: false)); + $this->assertFalse($updated->required); + + $document = $database->createDocument($collection, new Document([ + '$id' => 'two', + '$permissions' => [Permission::read(Role::any())], + 'at' => null, + ])); + $this->assertNull($document->getAttribute('at')); + + $database->deleteCollection($collection); + } + public function testUpdateAttributeFilter(): void { + $this->initFlowersFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $database->createAttribute('flowers', 'cartModel', Database::VAR_STRING, 2000, false); + $database->createAttribute($this->getFlowersCollection(), Attribute::string(key: 'cartModel', size: 2000)); - $doc = $database->createDocument('flowers', new Document([ + $doc = $database->createDocument($this->getFlowersCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -473,66 +837,67 @@ public function testUpdateAttributeFilter(): void ], 'name' => 'Lily With CartData', 'inStock' => 50, - 'cartModel' => '{"color":"string","size":"number"}' + 'cartModel' => '{"color":"string","size":"number"}', ])); $this->assertIsString($doc->getAttribute('cartModel')); $this->assertEquals('{"color":"string","size":"number"}', $doc->getAttribute('cartModel')); - $database->updateAttributeFilters('flowers', 'cartModel', ['json']); + $database->updateAttribute($this->getFlowersCollection(), 'cartModel', new AttributeUpdate(filters: [Filter::Json])); - $doc = $database->getDocument('flowers', $doc->getId()); + $doc = $database->getDocument($this->getFlowersCollection(), $doc->getId()); $this->assertIsArray($doc->getAttribute('cartModel')); $this->assertCount(2, $doc->getAttribute('cartModel')); $this->assertEquals('string', $doc->getAttribute('cartModel')['color']); $this->assertEquals('number', $doc->getAttribute('cartModel')['size']); } - /** - * @depends testUpdateAttributeDefault - */ public function testUpdateAttributeFormat(): void { + $this->initFlowersFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $database->createAttribute('flowers', 'price', Database::VAR_INTEGER, 0, false); + // Ensure cartModel attribute exists (created by testUpdateAttributeFilter in sequential mode) + try { + $database->createAttribute($this->getFlowersCollection(), Attribute::string(key: 'cartModel', size: 2000)); + } catch (\Exception $e) { + // Already exists + } + + $database->createAttribute($this->getFlowersCollection(), Attribute::integer(key: 'price')); - $doc = $database->createDocument('flowers', new Document([ + $doc = $database->createDocument($this->getFlowersCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - '$id' => ID::custom('LiliPriced'), + '$id' => Id::custom('LiliPriced'), 'name' => 'Lily Priced', 'inStock' => 50, 'cartModel' => '{}', - 'price' => 500 + 'price' => 500, ])); $this->assertIsNumeric($doc->getAttribute('price')); $this->assertEquals(500, $doc->getAttribute('price')); - Structure::addFormat('priceRange', function ($attribute) { - $min = $attribute['formatOptions']['min']; - $max = $attribute['formatOptions']['max']; + Structure::addFormat('priceRange', $this->priceRangeFormat(...), ColumnType::Integer); - return new Range($min, $max); - }, Database::VAR_INTEGER); - - $database->updateAttributeFormat('flowers', 'price', 'priceRange'); - $database->updateAttributeFormatOptions('flowers', 'price', ['min' => 1, 'max' => 10000]); + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRange', ['min' => 1, 'max' => 10000]))); $this->expectExceptionMessage('Invalid document structure: Attribute "price" has invalid format. Value must be a valid range between 1 and 10,000'); - $doc = $database->createDocument('flowers', new Document([ + $doc = $database->createDocument($this->getFlowersCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -542,157 +907,201 @@ public function testUpdateAttributeFormat(): void 'name' => 'Lily Overpriced', 'inStock' => 50, 'cartModel' => '{}', - 'price' => 15000 + 'price' => 15000, ])); } /** - * @depends testUpdateAttributeDefault - * @depends testUpdateAttributeFormat + * Sets up the 'flowers' collection with price attribute and priceRange format + * as testUpdateAttributeFormat would leave it. */ + private static bool $flowersWithPriceFixtureInit = false; + + protected function initFlowersWithPriceFixture(): void + { + if (self::$flowersWithPriceFixtureInit) { + return; + } + + $this->initFlowersFixture(); + + $database = $this->getDatabase(); + + // Add cartModel attribute (from testUpdateAttributeFilter) + try { + $database->createAttribute($this->getFlowersCollection(), Attribute::string(key: 'cartModel', size: 2000)); + } catch (\Exception $e) { + // Already exists + } + + // Add price attribute and set format (from testUpdateAttributeFormat) + try { + $database->createAttribute($this->getFlowersCollection(), Attribute::integer(key: 'price')); + } catch (\Exception $e) { + // Already exists + } + + // Create LiliPriced document if it doesn't exist + try { + $database->createDocument($this->getFlowersCollection(), new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + '$id' => Id::custom('LiliPriced'), + 'name' => 'Lily Priced', + 'inStock' => 50, + 'cartModel' => '{}', + 'price' => 500, + ])); + } catch (\Exception $e) { + // Already exists + } + + Structure::addFormat('priceRange', $this->priceRangeFormat(...), ColumnType::Integer); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRange', ['min' => 1, 'max' => 10000]))); + + self::$flowersWithPriceFixtureInit = true; + } + public function testUpdateAttributeStructure(): void { + $this->initFlowersWithPriceFixture(); + // TODO: When this becomes relevant, add many more tests (from all types to all types, chaging size up&down, switchign between array/non-array... - Structure::addFormat('priceRangeNew', function ($attribute) { - $min = $attribute['formatOptions']['min']; - $max = $attribute['formatOptions']['max']; - return new Range($min, $max); - }, Database::VAR_INTEGER); + Structure::addFormat('priceRangeNew', $this->priceRangeFormat(...), ColumnType::Integer); /** @var Database $database */ $database = $this->getDatabase(); // price attribute - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(null, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals('priceRange', $attribute['format']); - $this->assertEquals(['min' => 1, 'max' => 10000], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', default: 100); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('integer', $attribute['type']); - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(100, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals('priceRange', $attribute['format']); - $this->assertEquals(['min' => 1, 'max' => 10000], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', format: 'priceRangeNew'); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('integer', $attribute['type']); - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(100, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals('priceRangeNew', $attribute['format']); - $this->assertEquals(['min' => 1, 'max' => 10000], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', format: ''); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('integer', $attribute['type']); - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(100, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals('', $attribute['format']); - $this->assertEquals(['min' => 1, 'max' => 10000], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', formatOptions: ['min' => 1, 'max' => 999]); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('integer', $attribute['type']); - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(100, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals('', $attribute['format']); - $this->assertEquals(['min' => 1, 'max' => 999], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', formatOptions: []); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('integer', $attribute['type']); - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(100, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals('', $attribute['format']); - $this->assertEquals([], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', signed: false); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('integer', $attribute['type']); - $this->assertEquals(false, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(100, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals('', $attribute['format']); - $this->assertEquals([], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', required: true); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('integer', $attribute['type']); - $this->assertEquals(false, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(null, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(true, $attribute['required']); - $this->assertEquals('', $attribute['format']); - $this->assertEquals([], $attribute['formatOptions']); - - $database->updateAttribute('flowers', 'price', type: Database::VAR_STRING, size: Database::LENGTH_KEY, format: ''); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; - $this->assertEquals('string', $attribute['type']); - $this->assertEquals(false, $attribute['signed']); - $this->assertEquals(255, $attribute['size']); - $this->assertEquals(null, $attribute['default']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals(true, $attribute['required']); - $this->assertEquals('', $attribute['format']); - $this->assertEquals([], $collection->getAttribute('attributes')[4]['formatOptions']); - - // Date attribute - $attribute = $collection->getAttribute('attributes')[2]; - $this->assertEquals('date', $attribute['key']); - $this->assertEquals('string', $attribute['type']); - $this->assertEquals(null, $attribute['default']); - - $database->updateAttribute('flowers', 'date', type: Database::VAR_DATETIME, size: 0, filters: ['datetime']); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[2]; - $this->assertEquals('datetime', $attribute['type']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(null, $attribute['default']); - $this->assertEquals(false, $attribute['required']); - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(false, $attribute['array']); - $this->assertEquals('', $attribute['format']); - $this->assertEquals([], $attribute['formatOptions']); - - $doc = $database->getDocument('flowers', 'LiliPriced'); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(null, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(false, $attribute->required); + $this->assertEquals('priceRange', $attribute->format?->name); + $this->assertEquals(['min' => 1, 'max' => 10000], $attribute->format->options ?? []); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(default: 100)); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::Integer, $attribute->type); + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(100, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(false, $attribute->required); + $this->assertEquals('priceRange', $attribute->format?->name); + $this->assertEquals(['min' => 1, 'max' => 10000], $attribute->format->options ?? []); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRangeNew', ['min' => 1, 'max' => 10000]))); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::Integer, $attribute->type); + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(100, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(false, $attribute->required); + $this->assertEquals('priceRangeNew', $attribute->format?->name); + $this->assertEquals(['min' => 1, 'max' => 10000], $attribute->format->options ?? []); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRangeNew', ['min' => 1, 'max' => 999]))); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::Integer, $attribute->type); + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(100, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(false, $attribute->required); + $this->assertEquals('priceRangeNew', $attribute->format?->name); + $this->assertEquals(['min' => 1, 'max' => 999], $attribute->format->options ?? []); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRangeNew'))); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::Integer, $attribute->type); + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(100, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(false, $attribute->required); + $this->assertEquals('priceRangeNew', $attribute->format?->name); + $this->assertEquals([], $attribute->format->options ?? []); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: null)); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::Integer, $attribute->type); + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(100, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(false, $attribute->required); + $this->assertNull($attribute->format); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(signed: false)); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::Integer, $attribute->type); + $this->assertEquals(false, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(100, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(false, $attribute->required); + $this->assertNull($attribute->format); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(required: true)); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::Integer, $attribute->type); + $this->assertEquals(false, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(null, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(true, $attribute->required); + $this->assertNull($attribute->format); + + $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(type: ColumnType::String, size: Database::LENGTH_KEY, format: null)); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[4]; + $this->assertEquals(ColumnType::String, $attribute->type); + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(255, $attribute->size); + $this->assertEquals(null, $attribute->default); + $this->assertEquals(false, $attribute->array); + $this->assertEquals(true, $attribute->required); + $this->assertNull($attribute->format); + + $attribute = $collection->attributes()[2]; + $this->assertEquals('date', $attribute->key); + $this->assertEquals(ColumnType::String, $attribute->type); + $this->assertEquals(null, $attribute->default); + + $database->updateAttribute($this->getFlowersCollection(), 'date', new AttributeUpdate(type: ColumnType::Datetime, size: 0, filters: [Filter::Datetime])); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes()[2]; + $this->assertEquals(ColumnType::Datetime, $attribute->type); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(null, $attribute->default); + $this->assertEquals(false, $attribute->required); + $this->assertEquals(false, $attribute->signed); + $this->assertEquals(false, $attribute->array); + $this->assertNull($attribute->format); + + $doc = $database->getDocument($this->getFlowersCollection(), 'LiliPriced'); $this->assertIsString($doc->getAttribute('price')); $this->assertEquals('500', $doc->getAttribute('price')); - $doc = $database->getDocument('flowers', 'flowerWithDate'); + $doc = $database->getDocument($this->getFlowersCollection(), 'flowerWithDate'); $this->assertEquals('2000-06-12T14:12:55.000+00:00', $doc->getAttribute('date')); } @@ -701,14 +1110,15 @@ public function testUpdateAttributeRename(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('rename_test'); + $database->createCollection(Collection::create(id: 'rename_test')); - $this->assertEquals(true, $database->createAttribute('rename_test', 'rename_me', Database::VAR_STRING, 128, true)); + $database->createAttribute('rename_test', Attribute::string(key: 'rename_me', size: 128, required: true)); $doc = $database->createDocument('rename_test', new Document([ '$permissions' => [ @@ -717,18 +1127,18 @@ public function testUpdateAttributeRename(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'rename_me' => 'string' + 'rename_me' => 'string', ])); $this->assertEquals('string', $doc->getAttribute('rename_me')); // Create an index to check later - $database->createIndex('rename_test', 'renameIndexes', Database::INDEX_KEY, ['rename_me'], [], [Database::ORDER_DESC, Database::ORDER_DESC]); + $database->createIndex('rename_test', Index::key(key: 'renameIndexes', attributes: ['rename_me'], orders: [OrderDirection::Desc, OrderDirection::Desc])); $database->updateAttribute( collection: 'rename_test', - id: 'rename_me', - newKey: 'renamed', + key: 'rename_me', + update: new AttributeUpdate(key: 'renamed'), ); $doc = $database->getDocument('rename_test', $doc->getId()); @@ -746,37 +1156,38 @@ public function testUpdateAttributeRename(): void // Check collection $collection = $database->getCollection('rename_test'); - $this->assertEquals('renamed', $collection->getAttribute('attributes')[0]['key']); - $this->assertEquals('renamed', $collection->getAttribute('attributes')[0]['$id']); - $this->assertEquals('renamed', $collection->getAttribute('indexes')[0]['attributes'][0]); + $this->assertEquals('renamed', $collection->attributes()[0]->key); + $this->assertEquals('renamed', $collection->attributes()[0]->key); + $this->assertEquals('renamed', $collection->indexes()[0]->attributes[0]); - $supportsIdenticalIndexes = $database->getAdapter()->getSupportForIdenticalIndexes(); + $supportsIdenticalIndexes = $database->getAdapter()->supports(Capability::IndexIdentical); try { - // Check empty newKey doesn't cause issues + // Check an update without a new key doesn't cause issues $database->updateAttribute( collection: 'rename_test', - id: 'renamed', - type: Database::VAR_STRING, + key: 'renamed', + update: new AttributeUpdate(type: ColumnType::String), ); - if (!$supportsIdenticalIndexes) { + if (! $supportsIdenticalIndexes) { $this->fail('Expected exception when getSupportForIdenticalIndexes=false but none was thrown'); } } catch (Throwable $e) { - if (!$supportsIdenticalIndexes) { - $this->assertTrue(true, 'Exception thrown as expected when getSupportForIdenticalIndexes=false'); + if (! $supportsIdenticalIndexes) { + $this->assertNotSame('', $e->getMessage()); + return; // Exit early if exception was expected } else { - $this->fail('Unexpected exception when getSupportForIdenticalIndexes=true: ' . $e->getMessage()); + $this->fail('Unexpected exception when getSupportForIdenticalIndexes=true: '.$e->getMessage()); } } $collection = $database->getCollection('rename_test'); - $this->assertEquals('renamed', $collection->getAttribute('attributes')[0]['key']); - $this->assertEquals('renamed', $collection->getAttribute('attributes')[0]['$id']); - $this->assertEquals('renamed', $collection->getAttribute('indexes')[0]['attributes'][0]); + $this->assertEquals('renamed', $collection->attributes()[0]->key); + $this->assertEquals('renamed', $collection->attributes()[0]->key); + $this->assertEquals('renamed', $collection->indexes()[0]->attributes[0]); $doc = $database->getDocument('rename_test', $doc->getId()); @@ -784,14 +1195,14 @@ public function testUpdateAttributeRename(): void $this->assertArrayNotHasKey('rename_me', $doc->getAttributes()); // Check the metadata was correctly updated - $attribute = $collection->getAttribute('attributes')[0]; - $this->assertEquals('renamed', $attribute['key']); - $this->assertEquals('renamed', $attribute['$id']); + $attribute = $collection->attributes()[0]; + $this->assertEquals('renamed', $attribute->key); + $this->assertEquals('renamed', $attribute->key); // Check the indexes were updated - $index = $collection->getAttribute('indexes')[0]; - $this->assertEquals('renamed', $index->getAttribute('attributes')[0]); - $this->assertEquals(1, count($collection->getAttribute('indexes'))); + $index = $collection->indexes()[0]; + $this->assertEquals('renamed', $index->attributes[0]); + $this->assertEquals(1, count($collection->indexes())); // Try and create new document with new key $doc = $database->createDocument('rename_test', new Document([ @@ -801,7 +1212,7 @@ public function testUpdateAttributeRename(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'renamed' => 'string' + 'renamed' => 'string', ])); $this->assertEquals('string', $doc->getAttribute('renamed')); @@ -815,7 +1226,7 @@ public function testUpdateAttributeRename(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'rename_me' => 'string' + 'rename_me' => 'string', ])); $this->fail('Succeeded creating a document with old key after renaming the attribute'); } catch (\Exception $e) { @@ -825,8 +1236,8 @@ public function testUpdateAttributeRename(): void // Check new key filtering $database->updateAttribute( collection: 'rename_test', - id: 'renamed', - newKey: 'renamed-test', + key: 'renamed', + update: new AttributeUpdate(key: 'renamed-test'), ); $doc = $database->getDocument('rename_test', $doc->getId()); @@ -835,185 +1246,65 @@ public function testUpdateAttributeRename(): void $this->assertArrayNotHasKey('renamed', $doc->getAttributes()); } + /** + * Sets up the 'colors' collection with renamed attributes as testRenameAttribute would leave it. + */ + private static bool $colorsFixtureInit = false; + + protected function initColorsFixture(): void + { + if (self::$colorsFixtureInit) { + return; + } + + $database = $this->getDatabase(); + + $collection = $this->getColorsCollection(); + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'hex', size: 128, required: true)); + $database->createIndex($collection, Index::key(key: 'index1', attributes: ['name'], lengths: [128], orders: [OrderDirection::Asc])); + $database->createDocument($collection, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'black', + 'hex' => '#000000', + ])); + $database->renameAttribute($collection, 'name', 'verbose'); + + self::$colorsFixtureInit = true; + } /** - * @depends testRenameAttribute * @expectedException Exception */ public function textRenameAttributeMissing(): void { + $this->initColorsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); $this->expectExceptionMessage('Attribute not found'); - $database->renameAttribute('colors', 'name2', 'name3'); + $database->renameAttribute($this->getColorsCollection(), 'name2', 'name3'); } /** - * @depends testRenameAttribute - * @expectedException Exception - */ + * @expectedException Exception + */ public function testRenameAttributeExisting(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - - $this->expectExceptionMessage('Attribute name already used'); - $database->renameAttribute('colors', 'verbose', 'hex'); - } - - public function testWidthLimit(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if ($database->getAdapter()->getDocumentSizeLimit() === 0) { - $this->expectNotToPerformAssertions(); - return; - } + $this->initColorsFixture(); - $collection = $database->createCollection('width_limit'); - - $init = $database->getAdapter()->getAttributeWidth($collection); - $this->assertEquals(1067, $init); - - $attribute = new Document([ - '$id' => ID::custom('varchar_100'), - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - $res = $database->getAdapter()->getAttributeWidth($collection->setAttribute('attributes', [$attribute])); - $this->assertEquals(401, $res - $init); // 100 * 4 + 1 (length) - - $attribute = new Document([ - '$id' => ID::custom('json'), - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => true, - 'filters' => [], - ]); - $res = $database->getAdapter()->getAttributeWidth($collection->setAttribute('attributes', [$attribute])); - $this->assertEquals(20, $res - $init); // Pointer of Json / Longtext (mariaDB) - - $attribute = new Document([ - '$id' => ID::custom('text'), - 'type' => Database::VAR_STRING, - 'size' => 20000, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - $res = $database->getAdapter()->getAttributeWidth($collection->setAttribute('attributes', [$attribute])); - $this->assertEquals(20, $res - $init); - - $attribute = new Document([ - '$id' => ID::custom('bigint'), - 'type' => Database::VAR_INTEGER, - 'size' => 8, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - $res = $database->getAdapter()->getAttributeWidth($collection->setAttribute('attributes', [$attribute])); - $this->assertEquals(8, $res - $init); - - $attribute = new Document([ - '$id' => ID::custom('date'), - 'type' => Database::VAR_DATETIME, - 'size' => 8, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - $res = $database->getAdapter()->getAttributeWidth($collection->setAttribute('attributes', [$attribute])); - $this->assertEquals(7, $res - $init); - } - - public function testExceptionAttributeLimit(): void - { /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getLimitForAttributes() === 0) { - $this->expectNotToPerformAssertions(); - return; - } - - $limit = $database->getAdapter()->getLimitForAttributes() - $database->getAdapter()->getCountOfDefaultAttributes(); - - $attributes = []; - - for ($i = 0; $i <= $limit; $i++) { - $attributes[] = new Document([ - '$id' => ID::custom("attr_{$i}"), - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - } - - try { - $database->createCollection('attributes_limit', $attributes); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Attribute limit of 1017 exceeded. Cannot create collection.', $e->getMessage()); - } - - /** - * Remove last attribute - */ - - array_pop($attributes); - - $collection = $database->createCollection('attributes_limit', $attributes); - - $attribute = new Document([ - '$id' => ID::custom('breaking'), - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - - try { - $database->checkAttribute($collection, $attribute); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertInstanceOf(LimitException::class, $e); - $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); - $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); - } - - try { - $database->createAttribute($collection->getId(), 'breaking', Database::VAR_STRING, 100, true); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertInstanceOf(LimitException::class, $e); - $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); - $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); - } + $this->expectExceptionMessage('Attribute name already used'); + $database->renameAttribute($this->getColorsCollection(), 'verbose', 'hex'); } public function testExceptionWidthLimit(): void @@ -1021,37 +1312,20 @@ public function testExceptionWidthLimit(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getDocumentSizeLimit() === 0) { + if ($database->getAdapter()->limits()->documentSize === 0) { $this->expectNotToPerformAssertions(); + return; } $attributes = []; - $attributes[] = new Document([ - '$id' => ID::custom('varchar_16000'), - 'type' => Database::VAR_STRING, - 'size' => 16000, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); + $attributes[] = Attribute::string(key: 'varchar_16000', size: 16000, required: true); - $attributes[] = new Document([ - '$id' => ID::custom('varchar_200'), - 'type' => Database::VAR_STRING, - 'size' => 200, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); + $attributes[] = Attribute::string(key: 'varchar_200', size: 200, required: true); try { - $database->createCollection("attributes_row_size", $attributes); + $database->createCollection(Collection::create(id: 'attributes_row_size', attributes: $attributes)); $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); @@ -1061,24 +1335,14 @@ public function testExceptionWidthLimit(): void /** * Remove last attribute */ - array_pop($attributes); - $collection = $database->createCollection("attributes_row_size", $attributes); - - $attribute = new Document([ - '$id' => ID::custom('breaking'), - 'type' => Database::VAR_STRING, - 'size' => 200, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); + $collection = $database->createCollection(Collection::create(id: 'attributes_row_size', attributes: $attributes)); + + $attribute = Attribute::string(key: 'breaking', size: 200, required: true); try { - $database->checkAttribute($collection, $attribute); + $database->checkAttribute($collection->getId(), $attribute); $this->fail('Failed to throw exception'); } catch (\Exception $e) { $this->assertInstanceOf(LimitException::class, $e); @@ -1088,7 +1352,7 @@ public function testExceptionWidthLimit(): void } try { - $database->createAttribute($collection->getId(), 'breaking', Database::VAR_STRING, 200, true); + $database->createAttribute($collection->getId(), Attribute::string(key: 'breaking', size: 200, required: true)); $this->fail('Failed to throw exception'); } catch (\Throwable $e) { $this->assertInstanceOf(LimitException::class, $e); @@ -1103,23 +1367,24 @@ public function testUpdateAttributeSize(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributeResizing()) { + if (! $database->getAdapter()->supports(Capability::AttributeResizing)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('resize_test'); + $database->createCollection(Collection::create(id: 'resize_test')); - $this->assertEquals(true, $database->createAttribute('resize_test', 'resize_me', Database::VAR_STRING, 128, true)); + $database->createAttribute('resize_test', Attribute::string(key: 'resize_me', size: 128, required: true)); $document = $database->createDocument('resize_test', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'resize_me' => $this->createRandomString(128) + 'resize_me' => $this->createRandomString(128), ])); // Go up in size @@ -1135,41 +1400,41 @@ public function testUpdateAttributeSize(): void // Test going down in size with data that is too big (Expect Failure) try { - $database->updateAttribute('resize_test', 'resize_me', Database::VAR_STRING, 128, true); + $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 128, required: true)); $this->fail('Succeeded updating attribute size to smaller size with data that is too big'); } catch (TruncateException $e) { } // Test going down in size when data isn't too big. $database->updateDocument('resize_test', $document->getId(), $document->setAttribute('resize_me', $this->createRandomString(128))); - $database->updateAttribute('resize_test', 'resize_me', Database::VAR_STRING, 128, true); + $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 128, required: true)); // VARCHAR -> VARCHAR Truncation Test - $database->updateAttribute('resize_test', 'resize_me', Database::VAR_STRING, 1000, true); + $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 1000, required: true)); $database->updateDocument('resize_test', $document->getId(), $document->setAttribute('resize_me', $this->createRandomString(1000))); try { - $database->updateAttribute('resize_test', 'resize_me', Database::VAR_STRING, 128, true); + $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 128, required: true)); $this->fail('Succeeded updating attribute size to smaller size with data that is too big'); } catch (TruncateException $e) { } - if ($database->getAdapter()->getMaxIndexLength() > 0) { - $length = intval($database->getAdapter()->getMaxIndexLength() / 2); + if ($database->getAdapter()->limits()->indexLength > 0) { + $length = intval($database->getAdapter()->limits()->indexLength / 2); - $this->assertEquals(true, $database->createAttribute('resize_test', 'attr1', Database::VAR_STRING, $length, true)); - $this->assertEquals(true, $database->createAttribute('resize_test', 'attr2', Database::VAR_STRING, $length, true)); + $database->createAttribute('resize_test', Attribute::string(key: 'attr1', size: $length, required: true)); + $database->createAttribute('resize_test', Attribute::string(key: 'attr2', size: $length, required: true)); /** * No index length provided, we are able to validate */ - $database->createIndex('resize_test', 'index1', Database::INDEX_KEY, ['attr1', 'attr2']); + $database->createIndex('resize_test', Index::key(key: 'index1', attributes: ['attr1', 'attr2'])); try { - $database->updateAttribute('resize_test', 'attr1', Database::VAR_STRING, 5000); + $database->updateAttribute('resize_test', 'attr1', new AttributeUpdate(type: ColumnType::String, size: 5000)); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->getMaxIndexLength(), $e->getMessage()); + $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); } $database->deleteIndex('resize_test', 'index1'); @@ -1178,18 +1443,18 @@ public function testUpdateAttributeSize(): void * Index lengths are provided, We are able to validate * Index $length === attr1, $length === attr2, so $length is removed, so we are able to validate */ - $database->createIndex('resize_test', 'index1', Database::INDEX_KEY, ['attr1', 'attr2'], [$length, $length]); + $database->createIndex('resize_test', Index::key(key: 'index1', attributes: ['attr1', 'attr2'], lengths: [$length, $length])); $collection = $database->getCollection('resize_test'); - $indexes = $collection->getAttribute('indexes', []); - $this->assertEquals(null, $indexes[0]['lengths'][0]); - $this->assertEquals(null, $indexes[0]['lengths'][1]); + $indexes = $collection->indexes(); + $this->assertEquals(null, $indexes[0]->lengths[0]); + $this->assertEquals(null, $indexes[0]->lengths[1]); try { - $database->updateAttribute('resize_test', 'attr1', Database::VAR_STRING, 5000); + $database->updateAttribute('resize_test', 'attr1', new AttributeUpdate(type: ColumnType::String, size: 5000)); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->getMaxIndexLength(), $e->getMessage()); + $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); } $database->deleteIndex('resize_test', 'index1'); @@ -1198,14 +1463,14 @@ public function testUpdateAttributeSize(): void * Index lengths are provided * We are able to increase size because index length remains 50 */ - $database->createIndex('resize_test', 'index1', Database::INDEX_KEY, ['attr1', 'attr2'], [50, 50]); + $database->createIndex('resize_test', Index::key(key: 'index1', attributes: ['attr1', 'attr2'], lengths: [50, 50])); $collection = $database->getCollection('resize_test'); - $indexes = $collection->getAttribute('indexes', []); - $this->assertEquals(50, $indexes[0]['lengths'][0]); - $this->assertEquals(50, $indexes[0]['lengths'][1]); + $indexes = $collection->indexes(); + $this->assertEquals(50, $indexes[0]->lengths[0]); + $this->assertEquals(50, $indexes[0]->lengths[1]); - $database->updateAttribute('resize_test', 'attr1', Database::VAR_STRING, 5000); + $database->updateAttribute('resize_test', 'attr1', new AttributeUpdate(type: ColumnType::String, size: 5000)); } } @@ -1217,27 +1482,44 @@ public function testEncryptAttributes(): void // Add custom encrypt filter $database->addFilter( 'encrypt', - function (mixed $value) { + function (mixed $value): string { + if (! \is_scalar($value) && $value !== null) { + throw new \InvalidArgumentException('Encrypted filter input must be scalar or null'); + } + return json_encode([ - 'data' => base64_encode($value), + 'data' => base64_encode((string) $value), 'method' => 'base64', 'version' => 'v1', - ]); + ]) ?: throw new \RuntimeException('Failed to encode encrypted filter input'); }, - function (mixed $value) { + function (mixed $value): ?string { if (is_null($value)) { - return; + return null; } + if (! \is_string($value)) { + throw new \InvalidArgumentException('Encrypted filter value must be a string'); + } + $value = json_decode($value, true); - return base64_decode($value['data']); + if (! \is_array($value) || ! \is_string($value['data'] ?? null)) { + throw new \InvalidArgumentException('Encrypted filter payload is invalid'); + } + + $decoded = base64_decode($value['data'], true); + if ($decoded === false) { + throw new \InvalidArgumentException('Encrypted filter payload is not valid base64'); + } + + return $decoded; } ); - $col = $database->createCollection(__FUNCTION__); - $this->assertNotNull($col->getId()); + $col = $database->createCollection(Collection::create(id: __FUNCTION__)); + $this->assertNotSame('', $col->getId()); - $database->createAttribute($col->getId(), 'title', Database::VAR_STRING, 255, true); - $database->createAttribute($col->getId(), 'encrypt', Database::VAR_STRING, 128, true, filters: ['encrypt']); + $database->createAttribute($col->getId(), Attribute::string(key: 'title', required: true)); + $database->createAttribute($col->getId(), Attribute::string(key: 'encrypt', size: 128, required: true, filters: ['encrypt'])); $database->createDocument($col->getId(), new Document([ 'title' => 'Sample Title', @@ -1282,9 +1564,9 @@ function (mixed $value) use (&$calls) { } ); - $database->createCollection('filterSelect'); - $database->createAttribute('filterSelect', 'plain', Database::VAR_STRING, 128, false); - $database->createAttribute('filterSelect', 'kids', Database::VAR_STRING, 128, false, filters: ['subQueryProbe']); + $database->createCollection(Collection::create(id: 'filterSelect')); + $database->createAttribute('filterSelect', Attribute::string(key: 'plain', size: 128)); + $database->createAttribute('filterSelect', Attribute::string(key: 'kids', size: 128, filters: ['subQueryProbe'])); $database->createDocument('filterSelect', new Document([ '$id' => 'doc1', @@ -1337,7 +1619,7 @@ public function updateStringAttributeSize(int $size, Document $document): Docume /** @var Database $database */ $database = $this->getDatabase(); - $database->updateAttribute('resize_test', 'resize_me', Database::VAR_STRING, $size, true); + $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: $size, required: true)); $document = $document->setAttribute('resize_me', $this->createRandomString($size)); @@ -1345,42 +1627,13 @@ public function updateStringAttributeSize(int $size, Document $document): Docume $checkDoc = $database->getDocument('resize_test', $document->getId()); $this->assertEquals($document->getAttribute('resize_me'), $checkDoc->getAttribute('resize_me')); - $this->assertEquals($size, strlen($checkDoc->getAttribute('resize_me'))); + $resized = $checkDoc->getAttribute('resize_me'); + $this->assertIsString($resized); + $this->assertEquals($size, strlen($resized)); return $checkDoc; } - /** - * @depends testAttributeCaseInsensitivity - */ - public function testIndexCaseInsensitivity(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $this->assertEquals(true, $database->createIndex('attributes', 'key_caseSensitive', Database::INDEX_KEY, ['caseSensitive'], [128])); - - try { - $this->assertEquals(true, $database->createIndex('attributes', 'key_CaseSensitive', Database::INDEX_KEY, ['caseSensitive'], [128])); - } catch (Throwable $e) { - self::assertTrue($e instanceof DuplicateException); - } - } - - /** - * Ensure the collection is removed after use - * - * @depends testIndexCaseInsensitivity - */ - public function testCleanupAttributeTests(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $database->deleteCollection('attributes'); - $this->assertEquals(1, 1); - } - /** * @throws AuthorizationException * @throws DuplicateException @@ -1398,103 +1651,45 @@ public function testArrayAttribute(): void $collection = 'json'; $permissions = [Permission::read(Role::any())]; - $database->createCollection($collection, permissions: [ + $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::create(Role::any()), - ]); + ])); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'booleans', - Database::VAR_BOOLEAN, - size: 0, - required: true, - array: true - )); + $database->createAttribute($collection, Attribute::boolean(key: 'booleans', required: true, array: true)); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'names', - Database::VAR_STRING, - size: 255, // Does this mean each Element max is 255? We need to check this on Structure validation? - required: false, - array: true - )); + $database->createAttribute($collection, Attribute::string(key: 'names', array: true)); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'cards', - Database::VAR_STRING, - size: 5000, - required: false, - array: true - )); + $database->createAttribute($collection, Attribute::string(key: 'cards', size: 5000, array: true)); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'numbers', - Database::VAR_INTEGER, - size: 0, - required: false, - array: true - )); + $database->createAttribute($collection, Attribute::integer(key: 'numbers', array: true)); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'age', - Database::VAR_INTEGER, - size: 0, - required: false, - signed: false - )); + $database->createAttribute($collection, Attribute::integer(key: 'age', signed: false)); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'tv_show', - Database::VAR_STRING, - size: $database->getAdapter()->getMaxIndexLength() - 68, - required: false, - signed: false, - )); + $database->createAttribute($collection, Attribute::string(key: 'tv_show', size: $database->getAdapter()->limits()->indexLength - 68)); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'short', - Database::VAR_STRING, - size: 5, - required: false, - signed: false, - array: true - )); + $database->createAttribute($collection, Attribute::string(key: 'short', size: 5, array: true)); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'pref', - Database::VAR_STRING, - size: 16384, - required: false, - signed: false, - filters: ['json'], - )); + $database->createAttribute($collection, Attribute::string(key: 'pref', size: 16384, filters: [Filter::Json])); try { $database->createDocument($collection, new Document([])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertEquals('Invalid document structure: Missing required attribute "booleans"', $e->getMessage()); } } - $database->updateAttribute($collection, 'booleans', required: false); + $database->updateAttribute($collection, 'booleans', new AttributeUpdate(required: false)); $doc = $database->getCollection($collection); - $attribute = $doc->getAttribute('attributes')[0]; - $this->assertEquals('boolean', $attribute['type']); - $this->assertEquals(true, $attribute['signed']); - $this->assertEquals(0, $attribute['size']); - $this->assertEquals(null, $attribute['default']); - $this->assertEquals(true, $attribute['array']); - $this->assertEquals(false, $attribute['required']); + $attribute = $doc->attributes()[0]; + $this->assertEquals(ColumnType::Boolean, $attribute->type); + $this->assertEquals(true, $attribute->signed); + $this->assertEquals(0, $attribute->size); + $this->assertEquals(null, $attribute->default); + $this->assertEquals(true, $attribute->array); + $this->assertEquals(false, $attribute->required); try { $database->createDocument($collection, new Document([ @@ -1502,7 +1697,7 @@ public function testArrayAttribute(): void ])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertEquals('Invalid document structure: Attribute "short[\'0\']" has invalid type. Value must be a valid string and no longer than 5 chars', $e->getMessage()); } } @@ -1513,7 +1708,7 @@ public function testArrayAttribute(): void ])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertEquals('Invalid document structure: Attribute "names[\'1\']" has invalid type. Value must be a valid string and no longer than 255 chars', $e->getMessage()); } } @@ -1524,7 +1719,7 @@ public function testArrayAttribute(): void ])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertEquals('Invalid document structure: Attribute "age" has invalid type. Value must be a valid unsigned 32-bit integer between 0 and 4,294,967,295', $e->getMessage()); } } @@ -1535,7 +1730,7 @@ public function testArrayAttribute(): void ])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertEquals('Invalid document structure: Attribute "age" has invalid type. Value must be a valid unsigned 32-bit integer between 0 and 4,294,967,295', $e->getMessage()); } } @@ -1558,18 +1753,18 @@ public function testArrayAttribute(): void $document = $database->getDocument($collection, 'id1'); - $this->assertEquals(false, $document->getAttribute('booleans')[0]); - $this->assertEquals('Antony', $document->getAttribute('names')[1]); - $this->assertEquals(100, $document->getAttribute('numbers')[1]); + $this->assertEquals(false, $document->getArray('booleans')[0]); + $this->assertEquals('Antony', $document->getArray('names')[1]); + $this->assertEquals(100, $document->getArray('numbers')[1]); - if ($database->getAdapter()->getSupportForIndexArray()) { + if ($database->getAdapter()->supports(Capability::IndexArray)) { /** * Functional index dependency cannot be dropped or rename */ - $database->createIndex($collection, 'idx_cards', Database::INDEX_KEY, ['cards'], [100]); + $database->createIndex($collection, Index::key(key: 'idx_cards', attributes: ['cards'], lengths: [100])); } - if ($database->getAdapter()->getSupportForCastIndexArray()) { + if ($database->getAdapter()->supports(Capability::IndexArrayCast)) { /** * Delete attribute */ @@ -1596,7 +1791,7 @@ public function testArrayAttribute(): void * Update attribute */ try { - $database->updateAttribute($collection, id:'cards', newKey: 'cards_new'); + $database->updateAttribute($collection, key: 'cards', update: new AttributeUpdate(key: 'cards_new')); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertInstanceOf(DependencyException::class, $e); @@ -1604,18 +1799,18 @@ public function testArrayAttribute(): void } } else { - $this->assertTrue($database->renameAttribute($collection, 'cards', 'cards_new')); - $this->assertTrue($database->deleteAttribute($collection, 'cards_new')); + $database->renameAttribute($collection, 'cards', 'cards_new'); + $database->deleteAttribute($collection, 'cards_new'); } - if ($database->getAdapter()->getSupportForIndexArray()) { + if ($database->getAdapter()->supports(Capability::IndexArray)) { try { - $database->createIndex($collection, 'indx', Database::INDEX_FULLTEXT, ['names']); - if ($database->getAdapter()->getSupportForAttributes()) { + $database->createIndex($collection, Index::fulltext(key: 'indx', attributes: ['names'])); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->fail('Failed to throw exception'); } } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForFulltextIndex()) { + if ($database->getAdapter()->supports(Capability::IndexFulltext)) { $this->assertEquals('"Fulltext" index is forbidden on array attributes', $e->getMessage()); } else { $this->assertEquals('Fulltext index is not supported', $e->getMessage()); @@ -1623,12 +1818,12 @@ public function testArrayAttribute(): void } try { - $database->createIndex($collection, 'indx', Database::INDEX_KEY, ['numbers', 'names'], [100,100]); - if ($database->getAdapter()->getSupportForAttributes()) { + $database->createIndex($collection, Index::key(key: 'indx', attributes: ['numbers', 'names'], lengths: [100, 100])); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->fail('Failed to throw exception'); } } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertEquals('An index may only contain one array attribute', $e->getMessage()); } else { $this->assertEquals('Index already exists', $e->getMessage()); @@ -1636,164 +1831,155 @@ public function testArrayAttribute(): void } } - $this->assertEquals(true, $database->createAttribute( - $collection, - 'long_size', - Database::VAR_STRING, - size: 2000, - required: false, - array: true - )); + $database->createAttribute($collection, Attribute::string(key: 'long_size', size: 2000, array: true)); - if ($database->getAdapter()->getSupportForIndexArray()) { - if ($database->getAdapter()->getSupportForAttributes() && $database->getAdapter()->getMaxIndexLength() > 0) { + if ($database->getAdapter()->supports(Capability::IndexArray)) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes) && $database->getAdapter()->limits()->indexLength > 0) { // If getMaxIndexLength() > 0 We clear length for array attributes - $database->createIndex($collection, 'indx1', Database::INDEX_KEY, ['long_size'], [], []); + $database->createIndex($collection, Index::key(key: 'indx1', attributes: ['long_size'])); $database->deleteIndex($collection, 'indx1'); - $database->createIndex($collection, 'indx2', Database::INDEX_KEY, ['long_size'], [1000], []); + $database->createIndex($collection, Index::key(key: 'indx2', attributes: ['long_size'], lengths: [1000])); try { - $database->createIndex($collection, 'indx_numbers', Database::INDEX_KEY, ['tv_show', 'numbers'], [], []); // [700, 255] + $database->createIndex($collection, Index::key(key: 'indx_numbers', attributes: ['tv_show', 'numbers'])); // [700, 255] $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertEquals('Index length is longer than the maximum: ' . $database->getAdapter()->getMaxIndexLength(), $e->getMessage()); + $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); } } try { - if ($database->getAdapter()->getSupportForAttributes()) { - $database->createIndex($collection, 'indx4', Database::INDEX_KEY, ['age', 'names'], [10, 255], []); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $database->createIndex($collection, Index::key(key: 'indx4', attributes: ['age', 'names'], lengths: [10, 255])); $this->fail('Failed to throw exception'); } } catch (Throwable $e) { $this->assertEquals('Cannot set a length on "integer" attributes', $e->getMessage()); } - $this->assertTrue($database->createIndex($collection, 'indx6', Database::INDEX_KEY, ['age', 'names'], [null, 999], [])); - $this->assertTrue($database->createIndex($collection, 'indx7', Database::INDEX_KEY, ['age', 'booleans'], [0, 999], [])); + $database->createIndex($collection, Index::key(key: 'indx6', attributes: ['age', 'names'], lengths: [null, 999])); + $database->createIndex($collection, Index::key(key: 'indx7', attributes: ['age', 'booleans'], lengths: [0, 999])); } - if ($this->getDatabase()->getAdapter()->getSupportForQueryContains()) { - try { - $database->find($collection, [ - Query::equal('names', ['Joe']), - ]); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query equal on attribute "names" because it is an array.', $e->getMessage()); - } - - try { - $database->find($collection, [ - Query::contains('age', [10]) - ]); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query contains on attribute "age" because it is not an array, string, or object.', $e->getMessage()); - } - - $documents = $database->find($collection, [ - Query::isNull('long_size') + try { + $database->find($collection, [ + Query::equal('names', ['Joe']), ]); - $this->assertCount(1, $documents); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('Invalid query: Cannot query equal on attribute "names" because it is an array.', $e->getMessage()); + } - $documents = $database->find($collection, [ - Query::contains('tv_show', ['love']) + try { + $database->find($collection, [ + new Query(Method::Contains, 'age', [10]), ]); - $this->assertCount(1, $documents); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('Invalid query: Cannot query contains on attribute "age" because it is not an array, string, or object.', $e->getMessage()); + } - $documents = $database->find($collection, [ - Query::contains('names', ['Jake', 'Joe']) - ]); - $this->assertCount(1, $documents); + $documents = $database->find($collection, [ + Query::isNull('long_size'), + ]); + $this->assertCount(1, $documents); - $documents = $database->find($collection, [ - Query::contains('numbers', [-1, 0, 999]) - ]); - $this->assertCount(1, $documents); + $documents = $database->find($collection, [ + Query::containsString('tv_show', ['love']), + ]); + $this->assertCount(1, $documents); - $documents = $database->find($collection, [ - Query::contains('booleans', [false, true]) - ]); - $this->assertCount(1, $documents); + $documents = $database->find($collection, [ + new Query(Method::Contains, 'names', ['Jake', 'Joe']), + ]); + $this->assertCount(1, $documents); - // Regular like query on primitive json string data - $documents = $database->find($collection, [ - Query::contains('pref', ['Joe']) - ]); - $this->assertCount(1, $documents); + $documents = $database->find($collection, [ + new Query(Method::Contains, 'numbers', [-1, 0, 999]), + ]); + $this->assertCount(1, $documents); - // containsAny tests — should behave identically to contains + $documents = $database->find($collection, [ + new Query(Method::Contains, 'booleans', [false, true]), + ]); + $this->assertCount(1, $documents); - $documents = $database->find($collection, [ - Query::containsAny('tv_show', ['love']) - ]); - $this->assertCount(1, $documents); + // Regular like query on primitive json string data + $documents = $database->find($collection, [ + Query::containsString('pref', ['Joe']), + ]); + $this->assertCount(1, $documents); - $documents = $database->find($collection, [ - Query::containsAny('names', ['Jake', 'Joe']) - ]); - $this->assertCount(1, $documents); + // containsAny tests — should behave identically to contains - $documents = $database->find($collection, [ - Query::containsAny('numbers', [-1, 0, 999]) - ]); - $this->assertCount(1, $documents); + $documents = $database->find($collection, [ + Query::containsAny('tv_show', ['love']), + ]); + $this->assertCount(1, $documents); - $documents = $database->find($collection, [ - Query::containsAny('booleans', [false, true]) - ]); - $this->assertCount(1, $documents); + $documents = $database->find($collection, [ + Query::containsAny('names', ['Jake', 'Joe']), + ]); + $this->assertCount(1, $documents); - $documents = $database->find($collection, [ - Query::containsAny('pref', ['Joe']) - ]); - $this->assertCount(1, $documents); + $documents = $database->find($collection, [ + Query::containsAny('numbers', [-1, 0, 999]), + ]); + $this->assertCount(1, $documents); - // containsAny with no matching values - $documents = $database->find($collection, [ - Query::containsAny('names', ['Jake', 'Unknown']) - ]); - $this->assertCount(0, $documents); + $documents = $database->find($collection, [ + Query::containsAny('booleans', [false, true]), + ]); + $this->assertCount(1, $documents); - // containsAll tests on array attributes + $documents = $database->find($collection, [ + Query::containsAny('pref', ['Joe']), + ]); + $this->assertCount(1, $documents); - // All values present in names array - $documents = $database->find($collection, [ - Query::containsAll('names', ['Joe', 'Antony']) - ]); - $this->assertCount(1, $documents); + // containsAny with no matching values + $documents = $database->find($collection, [ + Query::containsAny('names', ['Jake', 'Unknown']), + ]); + $this->assertCount(0, $documents); - // One value missing from names array - $documents = $database->find($collection, [ - Query::containsAll('names', ['Joe', 'Jake']) - ]); - $this->assertCount(0, $documents); + // containsAll tests on array attributes - // All values present in numbers array - $documents = $database->find($collection, [ - Query::containsAll('numbers', [0, 100, -1]) - ]); - $this->assertCount(1, $documents); + // All values present in names array + $documents = $database->find($collection, [ + Query::containsAll('names', ['Joe', 'Antony']), + ]); + $this->assertCount(1, $documents); - // One value missing from numbers array - $documents = $database->find($collection, [ - Query::containsAll('numbers', [0, 999]) - ]); - $this->assertCount(0, $documents); + // One value missing from names array + $documents = $database->find($collection, [ + Query::containsAll('names', ['Joe', 'Jake']), + ]); + $this->assertCount(0, $documents); - // Single value containsAll — should match - $documents = $database->find($collection, [ - Query::containsAll('booleans', [false]) - ]); - $this->assertCount(1, $documents); + // All values present in numbers array + $documents = $database->find($collection, [ + Query::containsAll('numbers', [0, 100, -1]), + ]); + $this->assertCount(1, $documents); - // Boolean value not present - $documents = $database->find($collection, [ - Query::containsAll('booleans', [true]) - ]); - $this->assertCount(0, $documents); - } + // One value missing from numbers array + $documents = $database->find($collection, [ + Query::containsAll('numbers', [0, 999]), + ]); + $this->assertCount(0, $documents); + + // Single value containsAll — should match + $documents = $database->find($collection, [ + Query::containsAll('booleans', [false]), + ]); + $this->assertCount(1, $documents); + + // Boolean value not present + $documents = $database->find($collection, [ + Query::containsAll('booleans', [true]), + ]); + $this->assertCount(0, $documents); } public function testCreateDatetime(): void @@ -1801,27 +1987,27 @@ public function testCreateDatetime(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('datetime'); - if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals(true, $database->createAttribute('datetime', 'date', Database::VAR_DATETIME, 0, true, null, true, false, null, [], ['datetime'])); - $this->assertEquals(true, $database->createAttribute('datetime', 'date2', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime'])); + $database->createCollection(Collection::create(id: 'datetime')); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $database->createAttribute('datetime', Attribute::datetime(key: 'date', required: true)); + $database->createAttribute('datetime', Attribute::datetime(key: 'date2')); } try { $database->createDocument('datetime', new Document([ 'date' => ['2020-01-01'], // array ])); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->fail('Failed to throw exception'); } } catch (Exception $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertInstanceOf(StructureException::class, $e); } } $doc = $database->createDocument('datetime', new Document([ - '$id' => ID::custom('id1234'), + '$id' => Id::custom('id1234'), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -1831,17 +2017,21 @@ public function testCreateDatetime(): void 'date' => DateTime::now(), ])); - $this->assertEquals(29, strlen($doc->getCreatedAt())); - $this->assertEquals(29, strlen($doc->getUpdatedAt())); - $this->assertEquals('+00:00', substr($doc->getCreatedAt(), -6)); - $this->assertEquals('+00:00', substr($doc->getUpdatedAt(), -6)); + $createdAt = $doc->getCreatedAt(); + $updatedAt = $doc->getUpdatedAt(); + $this->assertNotNull($createdAt); + $this->assertNotNull($updatedAt); + $this->assertEquals(29, strlen($createdAt)); + $this->assertEquals(29, strlen($updatedAt)); + $this->assertEquals('+00:00', substr($createdAt, -6)); + $this->assertEquals('+00:00', substr($updatedAt, -6)); $this->assertGreaterThan('2020-08-16T19:30:08.363+00:00', $doc->getCreatedAt()); $this->assertGreaterThan('2020-08-16T19:30:08.363+00:00', $doc->getUpdatedAt()); $document = $database->getDocument('datetime', 'id1234'); - $min = $database->getAdapter()->getMinDateTime(); - $max = $database->getAdapter()->getMaxDateTime(); + $min = $database->getAdapter()->limits()->minDateTime; + $max = $database->getAdapter()->limits()->maxDateTime; $dateValidator = new DatetimeValidator($min, $max); $this->assertEquals(null, $document->getAttribute('date2')); $this->assertEquals(true, $dateValidator->isValid($document->getAttribute('date'))); @@ -1861,26 +2051,26 @@ public function testCreateDatetime(): void try { $database->createDocument('datetime', new Document([ '$id' => 'datenew1', - 'date' => "1975-12-06 00:00:61", // 61 seconds is invalid, + 'date' => '1975-12-06 00:00:61', // 61 seconds is invalid, ])); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->fail('Failed to throw exception'); } } catch (Exception $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertInstanceOf(StructureException::class, $e); } } try { $database->createDocument('datetime', new Document([ - 'date' => '+055769-02-14T17:56:18.000Z' + 'date' => '+055769-02-14T17:56:18.000Z', ])); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->fail('Failed to throw exception'); } } catch (Exception $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertInstanceOf(StructureException::class, $e); } } @@ -1888,13 +2078,13 @@ public function testCreateDatetime(): void $invalidDates = [ '+055769-02-14T17:56:18.000Z1', '1975-12-06 00:00:61', - '16/01/2024 12:00:00AM' + '16/01/2024 12:00:00AM', ]; foreach ($invalidDates as $date) { try { $database->find('datetime', [ - Query::equal('$createdAt', [$date]) + Query::equal('$createdAt', [$date]), ]); $this->fail('Failed to throw exception'); } catch (Throwable $e) { @@ -1904,9 +2094,9 @@ public function testCreateDatetime(): void try { $database->find('datetime', [ - Query::equal('date', [$date]) + Query::equal('date', [$date]), ]); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->fail('Failed to throw exception'); } } catch (Throwable $e) { @@ -1916,18 +2106,18 @@ public function testCreateDatetime(): void } $validDates = [ - '2024-12-2509:00:21.891119', - 'Tue Dec 31 2024', + '2024-12-25 09:00:21.891119', + '2024-12-31 00:00:00.000000', ]; foreach ($validDates as $date) { $docs = $database->find('datetime', [ - Query::equal('$createdAt', [$date]) + Query::equal('$createdAt', [$date]), ]); $this->assertCount(0, $docs); $docs = $database->find('datetime', [ - Query::equal('date', [$date]) + Query::equal('date', [$date]), ]); $this->assertCount(0, $docs); @@ -1937,7 +2127,7 @@ public function testCreateDatetime(): void $docs = $database->find('datetime', [ Query::or([ Query::equal('$createdAt', [$date]), - Query::equal('date', [$date]) + Query::equal('date', [$date]), ]), ]); $this->assertCount(0, $docs); @@ -1949,384 +2139,121 @@ public function testCreateDatetimeAddingAutoFilter(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('datetime_auto_filter'); + $collection = 'datetime_auto_filter'; - $this->expectException(Exception::class); - $database->createAttribute('datetime_auto', 'date_auto', Database::VAR_DATETIME, 0, false, filters:['json']); - $collection = $database->getCollection('datetime_auto_filter'); - $attribute = $collection->getAttribute('attributes')[0]; - $this->assertEquals([Database::VAR_DATETIME,'json'], $attribute['filters']); - $database->updateAttribute('datetime_auto', 'date_auto', Database::VAR_DATETIME, 0, false, filters:[]); - $collection = $database->getCollection('datetime_auto_filter'); - $attribute = $collection->getAttribute('attributes')[0]; - $this->assertEquals([Database::VAR_DATETIME,'json'], $attribute['filters']); - $database->deleteCollection('datetime_auto_filter'); - } - /** - * @depends testCreateDeleteAttribute - * @expectedException Exception - */ - public function testUnknownFormat(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // The same attribute through both public creation paths: createCollection() takes it + // inline, createAttribute() adds it to a collection that already exists. Both have to + // attach the datetime filter, or the same value written through one of them is stored + // and returned differently from the other. + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::datetime(key: 'inline')], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); - $this->expectException(\Exception::class); - $this->assertEquals(false, $database->createAttribute('attributes', 'bad_format', Database::VAR_STRING, 256, true, null, true, false, 'url')); - } + $offset = '2024-01-02T03:04:05.000+05:00'; + $database->createDocument($collection, new Document([ + Document::ID => 'offset', + 'inline' => $offset, + ])); + $database->createDocument($collection, new Document([ + Document::ID => 'utc', + 'inline' => '2024-01-01 22:04:05.000', + ])); + // The filter normalises the offset on the way in and restores one on the way out, so + // the two spellings of the same instant come back as one string, carrying its zone. + // createCollection() runs no validator over its attributes, so a missing filter here + // costs the normalisation silently rather than refusing the write. + $this->assertSame( + '2024-01-01T22:04:05.000+00:00', + $database->getDocument($collection, 'offset')->getAttribute('inline') + ); + $this->assertSame( + $database->getDocument($collection, 'offset')->getAttribute('inline'), + $database->getDocument($collection, 'utc')->getAttribute('inline') + ); - // Bulk attribute creation tests - public function testCreateAttributesEmpty(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $database->createAttribute($collection, Attribute::datetime(key: 'added')); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $database->createDocument($collection, new Document([ + Document::ID => 'both', + 'inline' => $offset, + 'added' => $offset, + ])); - $database->createCollection(__FUNCTION__); + $both = $database->getDocument($collection, 'both'); + $this->assertSame($both->getAttribute('inline'), $both->getAttribute('added')); - try { - $database->createAttributes(__FUNCTION__, []); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); + $attributes = $database->getCollection($collection)->attributes(); + $this->assertCount(2, $attributes); + + foreach ($attributes as $attribute) { + $this->assertSame([ColumnType::Datetime->value], $attribute->filters); } + + $database->deleteCollection($collection); } - public function testCreateAttributesMissingId(): void + public function testCreateAttributesAddingAutoFilter(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); + $collection = 'datetime_batch_auto_filter'; - $attributes = [[ - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false - ]]; - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } + $database->createCollection(Collection::create( + id: $collection, + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); - public function testCreateAttributesMissingType(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $database->createAttributes($collection, [Attribute::datetime(key: 'batch')]); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $database->createDocument($collection, new Document([ + Document::ID => 'offset', + 'batch' => '2024-01-02T03:04:05.000+05:00', + ])); - $database->createCollection(__FUNCTION__); + $this->assertSame( + '2024-01-01T22:04:05.000+00:00', + $database->getDocument($collection, 'offset')->getAttribute('batch') + ); - $attributes = [[ - '$id' => 'foo', - 'size' => 10, - 'required' => false - ]]; - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } + $database->deleteCollection($collection); } - public function testCreateAttributesMissingSize(): void + public function testCreateAttributesBigIntIgnoresSizeMetadata(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $collectionName = 'bigint_ignores_size_limit'; + $database->createCollection(Collection::create(id: $collectionName)); - $database->createCollection(__FUNCTION__); + $attributes = [Attribute::fromArray(['key' => 'foo', 'type' => ColumnType::BigInteger, 'size' => 9999])]; - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_STRING, - 'required' => false - ]]; - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - public function testCreateAttributesMissingRequired(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_STRING, - 'size' => 10 - ]]; - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - public function testCreateAttributesDuplicateMetadata(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'dup', Database::VAR_STRING, 10, false); - - $attributes = [[ - '$id' => 'dup', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false - ]]; - - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DuplicateException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - } - } - - public function testCreateAttributesInvalidFilter(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $attributes = [[ - '$id' => 'date', - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'required' => false, - 'filters' => [] - ]]; - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - public function testCreateAttributesInvalidFormat(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false, - 'format' => 'nonexistent' - ]]; - - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - public function testCreateAttributesDefaultOnRequired(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => true, - 'default' => 'bar' - ]]; - - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - public function testCreateAttributesUnknownType(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $attributes = [[ - '$id' => 'foo', - 'type' => 'unknown', - 'size' => 0, - 'required' => false - ]]; - - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - public function testCreateAttributesStringSizeLimit(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $max = $database->getAdapter()->getLimitForString(); - - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_STRING, - 'size' => $max + 1, - 'required' => false - ]]; - - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - public function testCreateAttributesIntegerSizeLimit(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $limit = $database->getAdapter()->getLimitForInt() / 2; - - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_INTEGER, - 'size' => (int)$limit + 1, - 'required' => false - ]]; - - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } - } - - - public function testCreateAttributesBigIntIgnoresSizeMetadata(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collectionName = 'bigint_ignores_size_limit'; - $database->createCollection($collectionName); - - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_BIGINT, - 'size' => 9999, - 'required' => false - ]]; - - $result = $database->createAttributes($collectionName, $attributes); - $this->assertTrue($result); + $database->createAttributes($collectionName, $attributes); $collection = $database->getCollection($collectionName); - $attrs = $collection->getAttribute('attributes'); + $attrs = $collection->attributes(); $this->assertCount(1, $attrs); - $this->assertEquals('foo', $attrs[0]['$id']); - $this->assertEquals(0, $attrs[0]['size']); + $attribute = $attrs[0]; + $this->assertSame('foo', $attribute->key); + $this->assertNull($attribute->size); - $database->updateAttribute($collectionName, 'foo', size: 1); + $database->updateAttribute($collectionName, 'foo', new AttributeUpdate(type: ColumnType::BigInteger, size: 1)); $collection = $database->getCollection($collectionName); - $attrs = $collection->getAttribute('attributes'); - $this->assertEquals(0, $attrs[0]['size']); + $attrs = $collection->attributes(); + $this->assertCount(1, $attrs); + $this->assertNull($attrs[0]->size); } public function testCreateAttributesBigIntValidationSignedUnsignedAndMetadata(): void @@ -2335,92 +2262,106 @@ public function testCreateAttributesBigIntValidationSignedUnsignedAndMetadata(): $database = $this->getDatabase(); $collectionName = 'bigint_attr_validation'; - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); - $this->assertTrue($database->createAttribute( + $database->createAttribute( $collectionName, - 'signed_bigint', - Database::VAR_BIGINT, - 0, - false, - signed: true - )); - $this->assertTrue($database->createAttribute( + Attribute::bigInteger(key: 'signed_bigint'), + ); + $database->createAttribute( $collectionName, - 'unsigned_bigint', - Database::VAR_BIGINT, - 0, - false, - signed: false - )); + Attribute::bigInteger(key: 'unsigned_bigint', signed: false), + ); $collection = $database->getCollection($collectionName); - $attributes = $collection->getAttribute('attributes', []); + $attributes = $collection->attributes(); $signedAttribute = null; $unsignedAttribute = null; foreach ($attributes as $attribute) { - if (($attribute['$id'] ?? '') === 'signed_bigint') { + if ($attribute->key === 'signed_bigint') { $signedAttribute = $attribute; } - if (($attribute['$id'] ?? '') === 'unsigned_bigint') { + if ($attribute->key === 'unsigned_bigint') { $unsignedAttribute = $attribute; } } - $this->assertNotNull($signedAttribute); - $this->assertNotNull($unsignedAttribute); - $this->assertTrue($signedAttribute['signed']); - $this->assertFalse($unsignedAttribute['signed']); - $this->assertEquals(0, $signedAttribute['size']); - $this->assertEquals(0, $unsignedAttribute['size']); + $this->assertInstanceOf(Attribute::class, $signedAttribute); + $this->assertInstanceOf(Attribute::class, $unsignedAttribute); + $this->assertTrue($signedAttribute->signed); + $this->assertFalse($unsignedAttribute->signed); + $this->assertNull($signedAttribute->size); + $this->assertNull($unsignedAttribute->size); - $largeUnsignedAttribute = [[ - '$id' => 'unsigned_bigint_large', - 'type' => Database::VAR_BIGINT, - 'size' => 0, - 'required' => false, - 'signed' => false, - 'default' => '18446744073709551615' - ]]; - $this->assertTrue($database->createAttributes($collectionName, $largeUnsignedAttribute)); + $largeUnsignedAttribute = [Attribute::bigInteger(key: 'unsigned_bigint_large', default: '18446744073709551615', signed: false)]; + if ($database->getAdapter()->supports(Capability::UnsignedBigInt)) { + $database->createAttributes($collectionName, $largeUnsignedAttribute); + } else { + try { + $database->createAttributes($collectionName, $largeUnsignedAttribute); + $this->fail('Expected unsupported unsigned bigint default to be rejected'); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('does not match given type bigint', $exception->getMessage()); + } + } } - public function testCreateAttributesSuccessMultiple(): void + public function testBigIntegerAttributesPersistTheBigintSpelling(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; + $collectionName = 'bigint_persisted_spelling'; + $database->createCollection(Collection::create( + id: $collectionName, + attributes: [Attribute::bigInteger(key: 'inline')], + )); + $database->createAttribute($collectionName, Attribute::bigInteger(key: 'single')); + $expected = ['inline' => 'bigint', 'single' => 'bigint']; + + $database->createAttributes($collectionName, [Attribute::bigInteger(key: 'batch')]); + $expected['batch'] = 'bigint'; + + $database->updateAttribute($collectionName, 'single', new AttributeUpdate(required: true)); + + $stored = $database->skipFilters(fn (): Document => $database->getAuthorization()->skip( + fn (): Document => $database->getDocument(Database::METADATA, $collectionName), + )); + $storedAttributes = $stored->getAttribute('attributes'); + $this->assertIsString($storedAttributes); + + /** @var list> $decoded */ + $decoded = \json_decode($storedAttributes, true, flags: JSON_THROW_ON_ERROR); + $types = []; + foreach ($decoded as $attribute) { + $key = $attribute['key'] ?? null; + $this->assertIsString($key); + $types[$key] = $attribute['type'] ?? null; } + $this->assertSame($expected, $types); - $database->createCollection(__FUNCTION__); + foreach ($database->getCollection($collectionName)->attributes() as $attribute) { + $this->assertSame(ColumnType::BigInteger, $attribute->type, $attribute->key); + } + } - $attributes = [ - [ - '$id' => 'a', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false - ], - [ - '$id' => 'b', - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false - ], - ]; + public function testCreateAttributesSuccessMultiple(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); - $result = $database->createAttributes(__FUNCTION__, $attributes); - $this->assertTrue($result); + $attributes = [Attribute::string(key: 'a', size: 10), Attribute::integer(key: 'b')]; + + $database->createAttributes(__FUNCTION__, $attributes); $collection = $database->getCollection(__FUNCTION__); - $attrs = $collection->getAttribute('attributes'); + $attrs = $collection->attributes(); $this->assertCount(2, $attrs); - $this->assertEquals('a', $attrs[0]['$id']); - $this->assertEquals('b', $attrs[1]['$id']); + $this->assertEquals('a', $attrs[0]->key); + $this->assertEquals('b', $attrs[1]->key); $doc = $database->createDocument(__FUNCTION__, new Document([ 'a' => 'foo', @@ -2436,71 +2377,49 @@ public function testCreateAttributesDelete(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); + $database->createCollection(Collection::create(id: __FUNCTION__)); - $attributes = [ - [ - '$id' => 'a', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false - ], - [ - '$id' => 'b', - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false - ], - ]; + $attributes = [Attribute::string(key: 'a', size: 10), Attribute::integer(key: 'b')]; - $result = $database->createAttributes(__FUNCTION__, $attributes); - $this->assertTrue($result); + $database->createAttributes(__FUNCTION__, $attributes); $collection = $database->getCollection(__FUNCTION__); - $attrs = $collection->getAttribute('attributes'); + $attrs = $collection->attributes(); $this->assertCount(2, $attrs); - $this->assertEquals('a', $attrs[0]['$id']); - $this->assertEquals('b', $attrs[1]['$id']); + $this->assertEquals('a', $attrs[0]->key); + $this->assertEquals('b', $attrs[1]->key); $database->deleteAttribute(__FUNCTION__, 'a'); $collection = $database->getCollection(__FUNCTION__); - $attrs = $collection->getAttribute('attributes'); + $attrs = $collection->attributes(); $this->assertCount(1, $attrs); - $this->assertEquals('b', $attrs[0]['$id']); + $this->assertEquals('b', $attrs[0]->key); } - /** - * @depends testCreateDeleteAttribute - */ public function testStringTypeAttributes(): void { /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('stringTypes'); + $database->createCollection(Collection::create(id: 'stringTypes')); // Create attributes with different string types - $this->assertEquals(true, $database->createAttribute('stringTypes', 'varchar_field', Database::VAR_VARCHAR, 255, false, 'default varchar')); - $this->assertEquals(true, $database->createAttribute('stringTypes', 'text_field', Database::VAR_TEXT, 65535, false)); - $this->assertEquals(true, $database->createAttribute('stringTypes', 'mediumtext_field', Database::VAR_MEDIUMTEXT, 16777215, false)); - $this->assertEquals(true, $database->createAttribute('stringTypes', 'longtext_field', Database::VAR_LONGTEXT, 4294967295, false)); + $database->createAttribute('stringTypes', Attribute::varchar(key: 'varchar_field', default: 'default varchar')); + $database->createAttribute('stringTypes', Attribute::text(key: 'text_field', size: 65535)); + $database->createAttribute('stringTypes', Attribute::mediumText(key: 'mediumtext_field', size: 16777215)); + $database->createAttribute('stringTypes', Attribute::longText(key: 'longtext_field', size: 4294967295)); // Test with array types - $this->assertEquals(true, $database->createAttribute('stringTypes', 'varchar_array', Database::VAR_VARCHAR, 128, false, null, true, true)); - $this->assertEquals(true, $database->createAttribute('stringTypes', 'text_array', Database::VAR_TEXT, 65535, false, null, true, true)); + $database->createAttribute('stringTypes', Attribute::varchar(key: 'varchar_array', size: 128, array: true)); + $database->createAttribute('stringTypes', Attribute::text(key: 'text_array', size: 65535, array: true)); $collection = $database->getCollection('stringTypes'); - $this->assertCount(6, $collection->getAttribute('attributes')); + $this->assertCount(6, $collection->attributes()); // Test VARCHAR with valid data $doc1 = $database->createDocument('stringTypes', new Document([ - '$id' => ID::custom('doc1'), + '$id' => Id::custom('doc1'), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -2520,7 +2439,7 @@ public function testStringTypeAttributes(): void // Test VARCHAR with default value $doc2 = $database->createDocument('stringTypes', new Document([ - '$id' => ID::custom('doc2'), + '$id' => Id::custom('doc2'), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -2534,7 +2453,7 @@ public function testStringTypeAttributes(): void // Test array types $doc3 = $database->createDocument('stringTypes', new Document([ - '$id' => ID::custom('doc3'), + '$id' => Id::custom('doc3'), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -2549,10 +2468,10 @@ public function testStringTypeAttributes(): void $this->assertEquals([\str_repeat('x', 1000), \str_repeat('y', 2000)], $doc3->getAttribute('text_array')); // Test VARCHAR size constraint (should fail) - only for adapters that support attributes - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { try { $database->createDocument('stringTypes', new Document([ - '$id' => ID::custom('doc4'), + '$id' => Id::custom('doc4'), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -2569,7 +2488,7 @@ public function testStringTypeAttributes(): void // Test TEXT size constraint (should fail) try { $database->createDocument('stringTypes', new Document([ - '$id' => ID::custom('doc5'), + '$id' => Id::custom('doc5'), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -2585,10 +2504,10 @@ public function testStringTypeAttributes(): void } // Test querying by VARCHAR field - $this->assertEquals(true, $database->createIndex('stringTypes', 'varchar_index', Database::INDEX_KEY, ['varchar_field'])); + $database->createIndex('stringTypes', Index::key(key: 'varchar_index', attributes: ['varchar_field'])); $results = $database->find('stringTypes', [ - Query::equal('varchar_field', ['This is a varchar field with 255 max length']) + Query::equal('varchar_field', ['This is a varchar field with 255 max length']), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); @@ -2608,4 +2527,604 @@ public function testStringTypeAttributes(): void $updatedDoc = $database->getDocument('stringTypes', 'doc1'); $this->assertEquals('Updated varchar value', $updatedDoc->getAttribute('varchar_field')); } + + #[DataProvider('invalidDefaultValues')] + public function testInvalidDefaultValues(ColumnType $type, mixed $default): void + { + $database = $this->getDatabase(); + $collection = 'bad_default_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection)); + + try { + $database->createAttribute($collection, Attribute::fromArray(['key' => 'bad_default', 'type' => $type, 'size' => 256, 'default' => $default])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('does not match given type', $e->getMessage()); + } finally { + $database->deleteCollection($collection); + } + } + + public function testAttributeAndIndexKeysAreCaseInsensitive(): void + { + $database = $this->getDatabase(); + $collection = 'case_insensitive_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection)); + + $database->createAttribute($collection, Attribute::string(key: 'caseSensitive', size: 128, required: true)); + + try { + $database->createAttribute($collection, Attribute::string(key: 'CaseSensitive', size: 128, required: true)); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + + $database->createIndex($collection, Index::key(key: 'key_caseSensitive', attributes: ['caseSensitive'], lengths: [128])); + + try { + $database->createIndex($collection, Index::key(key: 'key_CaseSensitive', attributes: ['caseSensitive'], lengths: [128])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + + $database->deleteCollection($collection); + } + + public function testUnknownFormat(): void + { + $database = $this->getDatabase(); + $collection = 'unknown_format_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection)); + + try { + $database->createAttribute($collection, Attribute::string(key: 'bad_format', size: 256, required: true, format: new Format('url'))); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } finally { + $database->deleteCollection($collection); + } + } + + public function testExceptionAttributeLimit(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if ($adapter->limits()->attributes === 0) { + $this->expectNotToPerformAssertions(); + + return; + } + + $limit = $adapter->limits()->attributes - $adapter->limits()->defaultAttributes; + + $attributes = []; + for ($i = 0; $i <= $limit; $i++) { + $attributes[] = Attribute::integer(key: "attr_{$i}"); + } + + try { + $database->createCollection(Collection::create(id: 'attributes_limit', attributes: $attributes)); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(LimitException::class, $e); + $this->assertSame('Attribute limit of '.$adapter->limits()->attributes.' exceeded. Cannot create collection.', $e->getMessage()); + } + + array_pop($attributes); + + $collection = $database->createCollection(Collection::create(id: 'attributes_limit', attributes: $attributes)); + + $attribute = Attribute::string(key: 'breaking', size: 100, required: true); + + try { + $database->checkAttribute($collection->getId(), $attribute); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(LimitException::class, $e); + $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); + } + + try { + $database->createAttribute($collection->getId(), $attribute); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(LimitException::class, $e); + $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); + } + + $database->deleteCollection('attributes_limit'); + } + + public function testWidthLimit(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if ($adapter->limits()->documentSize === 0) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $database->createCollection(Collection::create(id: 'width_limit')); + + $init = $adapter->getAttributeWidth($collection); + $this->assertSame(1067, $init); + + $width = fn (Attribute $attribute): int => $adapter->getAttributeWidth(Collection::create(id: 'width_limit', attributes: [$attribute])) - $init; + + $this->assertSame(401, $width(Attribute::string(key: 'varchar_100', size: 100)), 'VARCHAR(100) is 100 * 4 bytes plus a 1 byte length'); + $this->assertSame(20, $width(Attribute::string(key: 'json', size: 100, array: true)), 'An array is stored externally, only the pointer counts'); + $this->assertSame(20, $width(Attribute::string(key: 'text', size: 20000)), 'A string past the varchar limit is stored externally'); + $this->assertSame(8, $width(Attribute::integer(key: 'bigint', width: IntegerWidth::Bits64))); + $this->assertSame(7, $width(Attribute::datetime(key: 'date'))); + + $database->deleteCollection('width_limit'); + } + + public function testCreateAttributesEmpty(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + try { + $database->createAttributes(__FUNCTION__, []); + $this->fail('Expected DatabaseException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesMissingKey(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + try { + $database->createAttributes(__FUNCTION__, [Attribute::string(key: '', size: 10)]); + $this->fail('Expected DatabaseException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertSame('Missing attribute key', $e->getMessage()); + } + } + + public function testCreateAttributesDuplicateMetadata(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'dup', size: 10)); + + try { + $database->createAttributes(__FUNCTION__, [Attribute::string(key: 'dup', size: 10)]); + $this->fail('Expected DuplicateException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + } + + public function testCreateAttributesInvalidFormat(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + try { + $database->createAttributes(__FUNCTION__, [Attribute::string(key: 'foo', size: 10, format: new Format('nonexistent'))]); + $this->fail('Expected DatabaseException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesDefaultOnRequired(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + try { + $database->createAttributes(__FUNCTION__, [Attribute::string(key: 'foo', size: 10, required: true, default: 'bar')]); + $this->fail('Expected DatabaseException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertSame('Cannot set a default value for a required attribute', $e->getMessage()); + } + } + + public function testCreateAttributesStringSizeLimit(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + $max = $database->getAdapter()->limits()->string; + + try { + $database->createAttributes(__FUNCTION__, [Attribute::string(key: 'foo', size: $max + 1)]); + $this->fail('Expected DatabaseException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testWideIntegerBecomesBits64(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + $limit = (int) ($database->getAdapter()->limits()->integer / 2); + + $created = $database->createAttributes(__FUNCTION__, [Attribute::fromArray(['key' => 'foo', 'type' => ColumnType::Integer, 'size' => $limit + 1])]); + + $this->assertSame(IntegerWidth::Bits64, $created[0]->width()); + $this->assertSame(IntegerWidth::Bits64, $database->getCollection(__FUNCTION__)->attributes()[0]->width()); + } + + public function testCreateAttributesSkipsAColumnThatExistsOnlyInTheSchema(): void + { + $database = $this->getDatabase(); + + $collection = 'schemaOnlyColumn'; + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + $database->getAdapter()->createAttribute($collection, Attribute::string(key: 'b', size: 64)); + + $database->createAttributes($collection, [ + Attribute::integer(key: 'a'), + Attribute::string(key: 'b', size: 64), + ]); + + $this->assertSame(['a', 'b'], \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection($collection)->attributes(), + )); + + $database->createDocument($collection, new Document([Document::ID => 'one', 'a' => 1, 'b' => 'kept'])); + $document = $database->getDocument($collection, 'one'); + $this->assertSame(1, $document->getAttribute('a')); + $this->assertSame('kept', $document->getAttribute('b')); + + $database->deleteCollection($collection); + } + + public function testSharedTablesNeverDropAnotherTenantsColumn(): void + { + $database = $this->getDatabase(); + + if (! $database->hasSharedTables()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getIdAttributeType() === ColumnType::Integer; + $first = $integerTenants ? 301 : 'tenant_301'; + $second = $integerTenants ? 302 : 'tenant_302'; + $collection = 'sharedColumn_'.\uniqid(); + $definition = Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false); + + try { + $database->setTenant($first); + $database->createCollection($definition); + $database->createAttribute($collection, Attribute::integer(key: 'age')); + $database->createDocument($collection, new Document([Document::ID => 'first', 'age' => 7])); + + $database->setTenant($second); + $database->createCollection($definition); + + $adapter = $database->getAdapter(); + if ($adapter->supports(Capability::SchemaIntrospection)) { + try { + $database->createAttribute($collection, Attribute::string(key: 'age', size: 64)); + $this->fail('A column another tenant stores with another type must be refused'); + } catch (DuplicateException $error) { + $this->assertSame('Attribute exists in the shared table with another type', $error->getMessage()); + } + + $database->createAttribute($collection, Attribute::integer(key: 'age')); + $this->assertSame(['age'], \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection($collection)->attributes(), + )); + } else { + $database->createAttribute($collection, Attribute::string(key: 'age', size: 64)); + } + + $database->setTenant($first); + $this->assertSame(7, $database->getDocument($collection, 'first')->getAttribute('age')); + } finally { + foreach ([$second, $first] as $tenant) { + try { + $database->setTenant($tenant)->deleteCollection($collection); + } catch (Throwable) { + } + } + $database->setTenant($originalTenant); + } + } + + public function testRenameAttributeCompletesAnOrphanedRename(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + $schemaAttributes = $adapter->supports(Capability::SchemaIntrospection); + + if (! $schemaAttributes && ! $adapter instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'orphanedRename'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'before', size: 64), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + $database->createDocument($collection, new Document([Document::ID => 'one', 'before' => 'kept'])); + + try { + $adapter->renameAttribute($collection, 'before', 'after'); + + $database->renameAttribute($collection, 'before', 'after'); + $this->assertSame(['after'], $this->getAttributeKeys($database, $collection)); + + $document = $database->getDocument($collection, 'one'); + $this->assertSame('kept', $document->getAttribute('after')); + $this->assertFalse($document->offsetExists('before')); + } finally { + $database->deleteCollection($collection); + } + } + + /** + * @return list + */ + private function getAttributeKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection($collection)->attributes(), + ); + } + + public function testSharedTablesTenantsRenameAnAttributeInTurn(): void + { + $this->runSharedRename(function (Database $database, string $collection, int|string $first, int|string $second): void { + foreach ([$first, $second] as $tenant) { + $database->setTenant($tenant); + $database->renameAttribute($collection, 'age', 'years'); + $this->assertSame('fullName', $database->updateAttribute($collection, 'name', new AttributeUpdate(size: 128, key: 'fullName'))->key); + } + + foreach ([$first, $second] as $index => $tenant) { + $database->setTenant($tenant); + $document = $database->getDocument($collection, 'user'); + + $this->assertSame(['years', 'fullName', 'nick'], $this->getAttributeKeys($database, $collection)); + $this->assertSame(($index + 1) * 10, $document->getAttribute('years')); + $this->assertSame('name'.$index, $document->getAttribute('fullName')); + $this->assertFalse($document->offsetExists('age')); + $this->assertFalse($document->offsetExists('name')); + + $database->updateDocument($collection, 'user', new Document(['years' => ($index + 1) * 100])); + $this->assertSame(($index + 1) * 100, $database->getDocument($collection, 'user')->getAttribute('years')); + } + }); + } + + public function testSharedTablesTenantsRenameAnIndexInTurn(): void + { + $this->runSharedRename(function (Database $database, string $collection, int|string $first, int|string $second): void { + foreach ([$first, $second] as $tenant) { + $database->setTenant($tenant); + $database->renameIndex($collection, 'byAge', 'ageIndex'); + } + + $this->assertTenantsFindByTheRenamedIndex($database, $collection, $first, $second); + }); + } + + public function testSharedTablesALaterTenantRenamesAnIndexFirst(): void + { + $this->runSharedRename(function (Database $database, string $collection, int|string $first, int|string $second): void { + foreach ([$second, $first] as $tenant) { + $database->setTenant($tenant); + $database->renameIndex($collection, 'byAge', 'ageIndex'); + } + + $this->assertTenantsFindByTheRenamedIndex($database, $collection, $first, $second); + }); + } + + public function testSharedTablesRenameOfAnIndexNoTenantHasInTheSchemaFails(): void + { + $this->runSharedRename(function (Database $database, string $collection, int|string $first, int|string $second): void { + $database->setTenant($first); + $database->getAdapter()->deleteIndex($collection, 'byAge'); + + $database->setTenant($second); + if ($database->getAdapter() instanceof SQLite) { + $database->renameIndex($collection, 'byAge', 'ageIndex'); + $this->assertSame(['ageIndex'], $this->getIndexKeys($database, $collection)); + + return; + } + + try { + $database->renameIndex($collection, 'byAge', 'ageIndex'); + $this->fail('A rename no tenant\'s index backs must fail'); + } catch (DatabaseException $error) { + $this->assertStringStartsWith("Failed to rename index 'byAge' to 'ageIndex'", $error->getMessage()); + } + + $this->assertSame(['byAge'], $this->getIndexKeys($database, $collection)); + }); + } + + public function testSharedTablesRenameOfAnIndexOnlyAnotherTenantHasFails(): void + { + $this->runSharedRename(function (Database $database, string $collection, int|string $first, int|string $second): void { + if (! $database->getAdapter() instanceof Postgres) { + $this->expectNotToPerformAssertions(); + + return; + } + + foreach ([$first, $second] as $tenant) { + $database->setTenant($tenant); + $database->createIndex($collection, Index::key(key: 'byName', attributes: ['name'])); + } + + $database->setTenant($first); + $database->getAdapter()->deleteIndex($collection, 'byName'); + + try { + $database->renameIndex($collection, 'byName', 'nameIndex'); + $this->fail('A rename backed only by another tenant\'s index must fail'); + } catch (DatabaseException $error) { + $this->assertStringStartsWith("Failed to rename index 'byName' to 'nameIndex'", $error->getMessage()); + } + + $this->assertContains('byName', $this->getIndexKeys($database, $collection)); + }); + } + + private function assertTenantsFindByTheRenamedIndex(Database $database, string $collection, int|string ...$tenants): void + { + foreach (\array_values($tenants) as $index => $tenant) { + $database->setTenant($tenant); + + $this->assertSame(['ageIndex'], $this->getIndexKeys($database, $collection)); + $this->assertSame(['user'], \array_map( + static fn (Document $document): string => $document->getId(), + $database->find($collection, [Query::equal('age', [($index + 1) * 10])]), + )); + } + } + + public function testSharedTablesRenameOfAMissingAttributeIsNotFound(): void + { + $this->runSharedRename(function (Database $database, string $collection, int|string $first, int|string $second): void { + $database->setTenant($first); + $database->renameAttribute($collection, 'age', 'years'); + + $database->setTenant($second); + try { + $database->renameAttribute($collection, 'missing', 'found'); + $this->fail('Renaming an attribute the collection does not have must be refused'); + } catch (NotFoundException $error) { + $this->assertSame('Attribute not found', $error->getMessage()); + } + + $this->assertSame(['age', 'name', 'nick'], $this->getAttributeKeys($database, $collection)); + }); + } + + public function testSharedTablesRenameOntoAnotherTenantsAttributeIsRefused(): void + { + $this->runSharedRename(function (Database $database, string $collection, int|string $first, int|string $second): void { + $database->setTenant($second); + $database->createAttribute($collection, Attribute::string(key: 'title', size: 32)); + $database->updateDocument($collection, 'user', new Document(['title' => 'title1'])); + + $database->setTenant($first); + try { + $database->renameAttribute($collection, 'nick', 'title'); + $this->fail('A rename onto another tenant\'s column must be refused while the old column holds values'); + } catch (DuplicateException $error) { + $this->assertSame('Attribute already exists', $error->getMessage()); + } + + try { + $database->updateAttribute($collection, 'nick', new AttributeUpdate(key: 'title')); + $this->fail('A key update onto another tenant\'s column must be refused while the old column holds values'); + } catch (DuplicateException $error) { + $this->assertSame('Attribute already exists', $error->getMessage()); + } + + $this->assertSame(['age', 'name', 'nick'], $this->getAttributeKeys($database, $collection)); + $this->assertSame('nick0', $database->getDocument($collection, 'user')->getAttribute('nick')); + + $database->setTenant($second); + $document = $database->getDocument($collection, 'user'); + $this->assertSame('nick1', $document->getAttribute('nick')); + $this->assertSame('title1', $document->getAttribute('title')); + }); + } + + /** + * @param callable(Database, string, int|string, int|string): void $scenario + */ + private function runSharedRename(callable $scenario): void + { + $database = $this->getDatabase(); + + if (! $database->hasSharedTables() || ! $database->getAdapter() instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getIdAttributeType() === ColumnType::Integer; + $tenants = $integerTenants ? [501, 502] : ['tenant_501', 'tenant_502']; + $collection = 'sharedRename_'.\uniqid(); + $definition = Collection::create(id: $collection, attributes: [ + Attribute::integer(key: 'age'), + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'nick', size: 64), + ], indexes: [ + Index::key(key: 'byAge', attributes: ['age']), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], documentSecurity: false); + + try { + foreach ($tenants as $index => $tenant) { + $database->setTenant($tenant); + $database->createCollection($definition); + $database->createDocument($collection, new Document([ + Document::ID => 'user', + 'age' => ($index + 1) * 10, + 'name' => 'name'.$index, + 'nick' => 'nick'.$index, + ])); + } + + $scenario($database, $collection, ...$tenants); + } finally { + foreach (\array_reverse($tenants) as $tenant) { + try { + $database->setTenant($tenant)->deleteCollection($collection); + } catch (Throwable) { + } + } + $database->setTenant($originalTenant); + } + } } diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index bcbfbe91af..fa5d5f6407 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -3,32 +3,67 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use Tests\E2E\Adapter\Support\EventRecorder; +use Throwable; use Utopia\Cache\Adapter\None as NoneCache; use Utopia\Cache\Cache; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Adapter\Redis; use Utopia\Database\Adapter\SQL; +use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\AttributeUpdate; +use Utopia\Database\Capability; +use Utopia\Database\Collection; +use Utopia\Database\CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Exception\Type as TypeException; +use Utopia\Database\Hook\Transform; +use Utopia\Database\Id; +use Utopia\Database\Index; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\Role; +use Utopia\Database\Storage; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; trait CollectionTests { + private static string $createdAtCollection = ''; + + protected function getCreatedAtCollection(): string + { + if (self::$createdAtCollection === '') { + self::$createdAtCollection = 'created_at_' . uniqid(); + } + return self::$createdAtCollection; + } + public function testCreateExistsDelete(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSchemas()) { + if (! $database->getAdapter()->supports(Capability::Schemas)) { $this->expectNotToPerformAssertions(); + return; } @@ -38,42 +73,49 @@ public function testCreateExistsDelete(): void $this->assertEquals(true, $database->create()); } - /** - * @depends testCreateExistsDelete - */ public function testCreateListExistsDeleteCollection(): void { /** @var Database $database */ $database = $this->getDatabase(); - $this->assertInstanceOf('Utopia\Database\Document', $database->createCollection('actors', permissions: [ + // Clean up any leftover collections from prior runs + foreach ($database->listCollections(100) as $col) { + try { + $database->deleteCollection($col->getId()); + } catch (\Throwable) { + // ignore + } + } + + $database->createCollection(Collection::create(id: 'actors', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), ])); $this->assertCount(1, $database->listCollections()); - $this->assertEquals(true, $database->exists($this->testDatabase, 'actors')); + $this->assertEquals(true, $database->collectionExists('actors', $this->testDatabase)); // Collection names should not be unique - $this->assertInstanceOf('Utopia\Database\Document', $database->createCollection('actors2', permissions: [ + $database->createCollection(Collection::create(id: 'actors2', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), ])); - $this->assertCount(2, $database->listCollections()); - $this->assertEquals(true, $database->exists($this->testDatabase, 'actors2')); + $this->assertCount(2, $database->listCollections(100)); + $this->assertEquals(true, $database->collectionExists('actors2', $this->testDatabase)); $collection = $database->getCollection('actors2'); $collection->setAttribute('name', 'actors'); // change name to one that exists - $this->assertInstanceOf('Utopia\Database\Document', $database->updateDocument( + $updated = $database->updateDocument( $collection->getCollection(), $collection->getId(), $collection - )); - $this->assertEquals(true, $database->deleteCollection('actors2')); // Delete collection when finished + ); + $this->assertSame('actors', $updated->getAttribute('name')); + $database->deleteCollection('actors2'); // Delete collection when finished $this->assertCount(1, $database->listCollections()); - $this->assertEquals(false, $database->getCollection('actors')->isEmpty()); - $this->assertEquals(true, $database->deleteCollection('actors')); - $this->assertEquals(true, $database->getCollection('actors')->isEmpty()); - $this->assertEquals(false, $database->exists($this->testDatabase, 'actors')); + $this->assertNotNull($database->findCollection('actors')); + $database->deleteCollection('actors'); + $this->assertNull($database->findCollection('actors')); + $this->assertEquals(false, $database->collectionExists('actors', $this->testDatabase)); } public function testDatabaseHostname(): void @@ -81,13 +123,17 @@ public function testDatabaseHostname(): void /** @var Database $database */ $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForHostname()) { + $host = $database->getHostname(); + if ($host === null) { $this->expectNotToPerformAssertions(); return; } - $host = $database->getAdapter()->getHostname(); + if ($this->engineIs(SQLite::class, Redis::class)) { + $this->assertSame('', $host, 'An engine reached without a network host names none'); + return; + } + $this->assertContains($host, ['mysql', 'mariadb', 'postgres', 'mongo']); } @@ -97,290 +143,71 @@ public function testCreateCollectionWithSchema(): void $database = $this->getDatabase(); $attributes = [ - new Document([ - '$id' => ID::custom('attribute1'), - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute2'), - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute3'), - 'type' => Database::VAR_BOOLEAN, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute4'), - 'type' => Database::VAR_ID, - 'size' => 0, - 'required' => false, - 'signed' => false, - 'array' => false, - 'filters' => [], - ]), + Attribute::string(key: 'attribute1', size: 256), + Attribute::integer(key: 'attribute2'), + Attribute::boolean(key: 'attribute3'), + Attribute::id(key: 'attribute4'), ]; $indexes = [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute1'], - 'lengths' => [256], - 'orders' => ['ASC'], - ]), - new Document([ - '$id' => ID::custom('index2'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute2'], - 'lengths' => [], - 'orders' => ['DESC'], - ]), - new Document([ - '$id' => ID::custom('index3'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute3', 'attribute2'], - 'lengths' => [], - 'orders' => ['DESC', 'ASC'], - ]), - new Document([ - '$id' => ID::custom('index4'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute4'], - 'lengths' => [], - 'orders' => ['DESC'], - ]), + Index::key(key: 'index1', attributes: ['attribute1'], lengths: [256], orders: [OrderDirection::Asc]), + Index::key(key: 'index2', attributes: ['attribute2'], orders: [OrderDirection::Desc]), + Index::key(key: 'index3', attributes: ['attribute3', 'attribute2'], orders: [OrderDirection::Desc, OrderDirection::Asc]), + Index::key(key: 'index4', attributes: ['attribute4'], orders: [OrderDirection::Desc]), ]; - $collection = $database->createCollection('withSchema', $attributes, $indexes); - - $this->assertEquals(false, $collection->isEmpty()); - $this->assertEquals('withSchema', $collection->getId()); - - $this->assertIsArray($collection->getAttribute('attributes')); - $this->assertCount(4, $collection->getAttribute('attributes')); - $this->assertEquals('attribute1', $collection->getAttribute('attributes')[0]['$id']); - $this->assertEquals(Database::VAR_STRING, $collection->getAttribute('attributes')[0]['type']); - $this->assertEquals('attribute2', $collection->getAttribute('attributes')[1]['$id']); - $this->assertEquals(Database::VAR_INTEGER, $collection->getAttribute('attributes')[1]['type']); - $this->assertEquals('attribute3', $collection->getAttribute('attributes')[2]['$id']); - $this->assertEquals(Database::VAR_BOOLEAN, $collection->getAttribute('attributes')[2]['type']); - $this->assertEquals('attribute4', $collection->getAttribute('attributes')[3]['$id']); - $this->assertEquals(Database::VAR_ID, $collection->getAttribute('attributes')[3]['type']); - - $this->assertIsArray($collection->getAttribute('indexes')); - $this->assertCount(4, $collection->getAttribute('indexes')); - $this->assertEquals('index1', $collection->getAttribute('indexes')[0]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[0]['type']); - $this->assertEquals('index2', $collection->getAttribute('indexes')[1]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[1]['type']); - $this->assertEquals('index3', $collection->getAttribute('indexes')[2]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[2]['type']); - $this->assertEquals('index4', $collection->getAttribute('indexes')[3]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[3]['type']); - + $collection = $database->createCollection(Collection::create(id: 'withSchema', attributes: $attributes, indexes: $indexes)); + + $this->assertSame('withSchema', $collection->getId()); + + $this->assertCount(4, $collection->attributes()); + $this->assertSame('attribute1', $collection->attributes()[0]->key); + $this->assertSame(ColumnType::String, $collection->attributes()[0]->type); + $this->assertSame('attribute2', $collection->attributes()[1]->key); + $this->assertSame(ColumnType::Integer, $collection->attributes()[1]->type); + $this->assertSame('attribute3', $collection->attributes()[2]->key); + $this->assertSame(ColumnType::Boolean, $collection->attributes()[2]->type); + $this->assertSame('attribute4', $collection->attributes()[3]->key); + $this->assertSame(ColumnType::Id, $collection->attributes()[3]->type); + + $this->assertCount(4, $collection->indexes()); + $this->assertSame('index1', $collection->indexes()[0]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[0]->type); + $this->assertSame('index2', $collection->indexes()[1]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[1]->type); + $this->assertSame('index3', $collection->indexes()[2]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[2]->type); + $this->assertSame('index4', $collection->indexes()[3]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[3]->type); + + $fetched = $database->getCollection('withSchema'); + $this->assertSame('attribute1', $fetched->attributes()[0]->key); $database->deleteCollection('withSchema'); - // Test collection with dash (+attribute +index) - $collection2 = $database->createCollection('with-dash', [ - new Document([ - '$id' => ID::custom('attribute-one'), - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [ - new Document([ - '$id' => ID::custom('index-one'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute-one'], - 'lengths' => [256], - 'orders' => ['ASC'], - ]) - ]); + $collection2 = $database->createCollection(Collection::create(id: 'with-dash', attributes: [ + Attribute::string(key: 'attribute-one', size: 256), + ], indexes: [ + Index::key(key: 'index-one', attributes: ['attribute-one'], lengths: [256], orders: [OrderDirection::Asc]), + ])); - $this->assertEquals(false, $collection2->isEmpty()); - $this->assertEquals('with-dash', $collection2->getId()); - $this->assertIsArray($collection2->getAttribute('attributes')); - $this->assertCount(1, $collection2->getAttribute('attributes')); - $this->assertEquals('attribute-one', $collection2->getAttribute('attributes')[0]['$id']); - $this->assertEquals(Database::VAR_STRING, $collection2->getAttribute('attributes')[0]['type']); - $this->assertIsArray($collection2->getAttribute('indexes')); - $this->assertCount(1, $collection2->getAttribute('indexes')); - $this->assertEquals('index-one', $collection2->getAttribute('indexes')[0]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection2->getAttribute('indexes')[0]['type']); + $this->assertSame('with-dash', $collection2->getId()); + $this->assertCount(1, $collection2->attributes()); + $this->assertSame('attribute-one', $collection2->attributes()[0]->key); + $this->assertSame(ColumnType::String, $collection2->attributes()[0]->type); + $this->assertCount(1, $collection2->indexes()); + $this->assertSame('index-one', $collection2->indexes()[0]->key); + $this->assertSame(IndexType::Key, $collection2->indexes()[0]->type); $database->deleteCollection('with-dash'); } - public function testCreateCollectionValidator(): void - { - $collections = [ - "validatorTest", - "validator-test", - "validator_test", - "validator.test", - ]; - - $attributes = [ - new Document([ - '$id' => ID::custom('attribute1'), - 'type' => Database::VAR_STRING, - 'size' => 2500, // longer than 768 - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute-2'), - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute_3'), - 'type' => Database::VAR_BOOLEAN, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute.4'), - 'type' => Database::VAR_BOOLEAN, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute5'), - 'type' => Database::VAR_STRING, - 'size' => 2500, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]) - ]; - - $indexes = [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute1'], - 'lengths' => [256], - 'orders' => ['ASC'], - ]), - new Document([ - '$id' => ID::custom('index-2'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute-2'], - 'lengths' => [], - 'orders' => ['ASC'], - ]), - new Document([ - '$id' => ID::custom('index_3'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute_3'], - 'lengths' => [], - 'orders' => ['ASC'], - ]), - new Document([ - '$id' => ID::custom('index.4'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute.4'], - 'lengths' => [], - 'orders' => ['ASC'], - ]), - new Document([ - '$id' => ID::custom('index_2_attributes'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute1', 'attribute5'], - 'lengths' => [200, 300], - 'orders' => ['DESC'], - ]), - ]; - - /** @var Database $database */ - $database = $this->getDatabase(); - - foreach ($collections as $id) { - $collection = $database->createCollection($id, $attributes, $indexes); - - $this->assertEquals(false, $collection->isEmpty()); - $this->assertEquals($id, $collection->getId()); - - $this->assertIsArray($collection->getAttribute('attributes')); - $this->assertCount(5, $collection->getAttribute('attributes')); - $this->assertEquals('attribute1', $collection->getAttribute('attributes')[0]['$id']); - $this->assertEquals(Database::VAR_STRING, $collection->getAttribute('attributes')[0]['type']); - $this->assertEquals('attribute-2', $collection->getAttribute('attributes')[1]['$id']); - $this->assertEquals(Database::VAR_INTEGER, $collection->getAttribute('attributes')[1]['type']); - $this->assertEquals('attribute_3', $collection->getAttribute('attributes')[2]['$id']); - $this->assertEquals(Database::VAR_BOOLEAN, $collection->getAttribute('attributes')[2]['type']); - $this->assertEquals('attribute.4', $collection->getAttribute('attributes')[3]['$id']); - $this->assertEquals(Database::VAR_BOOLEAN, $collection->getAttribute('attributes')[3]['type']); - - $this->assertIsArray($collection->getAttribute('indexes')); - $this->assertCount(5, $collection->getAttribute('indexes')); - $this->assertEquals('index1', $collection->getAttribute('indexes')[0]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[0]['type']); - $this->assertEquals('index-2', $collection->getAttribute('indexes')[1]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[1]['type']); - $this->assertEquals('index_3', $collection->getAttribute('indexes')[2]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[2]['type']); - $this->assertEquals('index.4', $collection->getAttribute('indexes')[3]['$id']); - $this->assertEquals(Database::INDEX_KEY, $collection->getAttribute('indexes')[3]['type']); - - $database->deleteCollection($id); - } - } - - - public function testCollectionNotFound(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - try { - $database->find('not_exist', []); - $this->fail('Failed to throw Exception'); - } catch (Exception $e) { - $this->assertEquals('Collection not found', $e->getMessage()); - } - } - public function testSizeCollection(): void { /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('sizeTest1'); - $database->createCollection('sizeTest2'); + $database->createCollection(Collection::create(id: 'sizeTest1')); + $database->createCollection(Collection::create(id: 'sizeTest2')); $size1 = $database->getSizeOfCollection('sizeTest1'); $size2 = $database->getSizeOfCollection('sizeTest2'); @@ -389,24 +216,25 @@ public function testSizeCollection(): void // Therefore asserting with a tolerance of 5000 bytes $byteDifference = 5000; - if (!$database->analyzeCollection('sizeTest2')) { + if (! $database->analyzeCollection('sizeTest2')) { $this->expectNotToPerformAssertions(); + return; } $this->assertLessThan($byteDifference, $sizeDifference); - $database->createAttribute('sizeTest2', 'string1', Database::VAR_STRING, 20000, true); - $database->createAttribute('sizeTest2', 'string2', Database::VAR_STRING, 254 + 1, true); - $database->createAttribute('sizeTest2', 'string3', Database::VAR_STRING, 254 + 1, true); - $database->createIndex('sizeTest2', 'index', Database::INDEX_KEY, ['string1', 'string2', 'string3'], [128, 128, 128]); + $database->createAttribute('sizeTest2', Attribute::string(key: 'string1', size: 20000, required: true)); + $database->createAttribute('sizeTest2', Attribute::string(key: 'string2', size: 254 + 1, required: true)); + $database->createAttribute('sizeTest2', Attribute::string(key: 'string3', size: 254 + 1, required: true)); + $database->createIndex('sizeTest2', Index::key(key: 'index', attributes: ['string1', 'string2', 'string3'], lengths: [128, 128, 128])); $loopCount = 100; for ($i = 0; $i < $loopCount; $i++) { $database->createDocument('sizeTest2', new Document([ - '$id' => 'doc' . $i, - 'string1' => 'string1' . $i . str_repeat('A', 10000), + '$id' => 'doc'.$i, + 'string1' => 'string1'.$i.str_repeat('A', 10000), 'string2' => 'string2', 'string3' => 'string3', ])); @@ -420,7 +248,7 @@ public function testSizeCollection(): void $this->getDatabase()->getAuthorization()->skip(function () use ($loopCount) { for ($i = 0; $i < $loopCount; $i++) { - $this->getDatabase()->deleteDocument('sizeTest2', 'doc' . $i); + $this->getDatabase()->deleteDocument('sizeTest2', 'doc'.$i); } }); @@ -430,13 +258,19 @@ public function testSizeCollection(): void $size3 = $this->getDatabase()->getSizeOfCollection('sizeTest2'); + if ($this->engineIs(Postgres::class)) { + $this->assertLessThanOrEqual($size2, $size3); + + return; + } + $this->assertLessThan($size2, $size3); } public function testSizeCollectionOnDisk(): void { - $this->getDatabase()->createCollection('sizeTestDisk1'); - $this->getDatabase()->createCollection('sizeTestDisk2'); + $this->getDatabase()->createCollection(Collection::create(id: 'sizeTestDisk1')); + $this->getDatabase()->createCollection(Collection::create(id: 'sizeTestDisk2')); $size1 = $this->getDatabase()->getSizeOfCollectionOnDisk('sizeTestDisk1'); $size2 = $this->getDatabase()->getSizeOfCollectionOnDisk('sizeTestDisk2'); @@ -446,18 +280,18 @@ public function testSizeCollectionOnDisk(): void $byteDifference = 5000; $this->assertLessThan($byteDifference, $sizeDifference); - $this->getDatabase()->createAttribute('sizeTestDisk2', 'string1', Database::VAR_STRING, 20000, true); - $this->getDatabase()->createAttribute('sizeTestDisk2', 'string2', Database::VAR_STRING, 254 + 1, true); - $this->getDatabase()->createAttribute('sizeTestDisk2', 'string3', Database::VAR_STRING, 254 + 1, true); - $this->getDatabase()->createIndex('sizeTestDisk2', 'index', Database::INDEX_KEY, ['string1', 'string2', 'string3'], [128, 128, 128]); + $this->getDatabase()->createAttribute('sizeTestDisk2', Attribute::string(key: 'string1', size: 20000, required: true)); + $this->getDatabase()->createAttribute('sizeTestDisk2', Attribute::string(key: 'string2', size: 254 + 1, required: true)); + $this->getDatabase()->createAttribute('sizeTestDisk2', Attribute::string(key: 'string3', size: 254 + 1, required: true)); + $this->getDatabase()->createIndex('sizeTestDisk2', Index::key(key: 'index', attributes: ['string1', 'string2', 'string3'], lengths: [128, 128, 128])); $loopCount = 40; for ($i = 0; $i < $loopCount; $i++) { $this->getDatabase()->createDocument('sizeTestDisk2', new Document([ - 'string1' => 'string1' . $i, - 'string2' => 'string2' . $i, - 'string3' => 'string3' . $i, + 'string1' => 'string1'.$i, + 'string2' => 'string2'.$i, + 'string3' => 'string3'.$i, ])); } @@ -472,27 +306,28 @@ public function testSizeFullText(): void $database = $this->getDatabase(); // SQLite does not support fulltext indexes - if (!$database->getAdapter()->getSupportForFulltextIndex()) { + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('fullTextSizeTest'); + $database->createCollection(Collection::create(id: 'fullTextSizeTest')); $size1 = $database->getSizeOfCollection('fullTextSizeTest'); - $database->createAttribute('fullTextSizeTest', 'string1', Database::VAR_STRING, 128, true); - $database->createAttribute('fullTextSizeTest', 'string2', Database::VAR_STRING, 254, true); - $database->createAttribute('fullTextSizeTest', 'string3', Database::VAR_STRING, 254, true); - $database->createIndex('fullTextSizeTest', 'index', Database::INDEX_KEY, ['string1', 'string2', 'string3'], [128, 128, 128]); + $database->createAttribute('fullTextSizeTest', Attribute::string(key: 'string1', size: 128, required: true)); + $database->createAttribute('fullTextSizeTest', Attribute::string(key: 'string2', size: 254, required: true)); + $database->createAttribute('fullTextSizeTest', Attribute::string(key: 'string3', size: 254, required: true)); + $database->createIndex('fullTextSizeTest', Index::key(key: 'index', attributes: ['string1', 'string2', 'string3'], lengths: [128, 128, 128])); $loopCount = 10; for ($i = 0; $i < $loopCount; $i++) { $database->createDocument('fullTextSizeTest', new Document([ - 'string1' => 'string1' . $i, - 'string2' => 'string2' . $i, - 'string3' => 'string3' . $i, + 'string1' => 'string1'.$i, + 'string2' => 'string2'.$i, + 'string3' => 'string3'.$i, ])); } @@ -500,167 +335,64 @@ public function testSizeFullText(): void $this->assertGreaterThan($size1, $size2); - $database->createIndex('fullTextSizeTest', 'fulltext_index', Database::INDEX_FULLTEXT, ['string1']); + $database->createIndex('fullTextSizeTest', Index::fulltext(key: 'fulltext_index', attributes: ['string1'])); $size3 = $database->getSizeOfCollectionOnDisk('fullTextSizeTest'); $this->assertGreaterThan($size2, $size3); } - public function testPurgeCollectionCache(): void + public function testSchemaAttributes(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - - $database->createCollection('redis'); - - $this->assertEquals(true, $database->createAttribute('redis', 'name', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('redis', 'age', Database::VAR_INTEGER, 0, true)); - - $database->createDocument('redis', new Document([ - '$id' => 'doc1', - 'name' => 'Richard', - 'age' => 15, - '$permissions' => [ - Permission::read(Role::any()), - ] - ])); - - $document = $database->getDocument('redis', 'doc1'); - - $this->assertEquals('Richard', $document->getAttribute('name')); - $this->assertEquals(15, $document->getAttribute('age')); - - $this->assertEquals(true, $database->deleteAttribute('redis', 'age')); - - $document = $database->getDocument('redis', 'doc1'); - $this->assertEquals('Richard', $document->getAttribute('name')); - $this->assertArrayNotHasKey('age', $document); - - $this->assertEquals(true, $database->createAttribute('redis', 'age', Database::VAR_INTEGER, 0, true)); + $db = $this->getDatabase(); + $adapter = $db->getAdapter(); - $document = $database->getDocument('redis', 'doc1'); - $this->assertEquals('Richard', $document->getAttribute('name')); - $this->assertArrayHasKey('age', $document); - } + if (! $adapter->supports(Capability::SchemaIntrospection)) { + $this->assertSame([], $db->getSchemaAttributes('no_such_collection')); - public function testSchemaAttributes(): void - { - if (!$this->getDatabase()->getAdapter()->getSupportForSchemaAttributes()) { - $this->expectNotToPerformAssertions(); return; } $collection = 'schema_attributes'; - $db = $this->getDatabase(); - $this->assertEmpty($db->getSchemaAttributes('no_such_collection')); - - $db->createCollection($collection); - - $db->createAttribute($collection, 'username', Database::VAR_STRING, 128, true); - $db->createAttribute($collection, 'story', Database::VAR_STRING, 20000, true); - $db->createAttribute($collection, 'string_list', Database::VAR_STRING, 128, true, null, true, true); - $db->createAttribute($collection, 'dob', Database::VAR_DATETIME, 0, false, '2000-06-12T14:12:55.000+00:00', true, false, null, [], ['datetime']); - - $attributes = []; - foreach ($db->getSchemaAttributes($collection) as $attribute) { - /** - * @var Document $attribute - */ - - $attributes[$attribute->getId()] = $attribute; - } - - $attribute = $attributes['username']; - $this->assertEquals('username', $attribute['$id']); - $this->assertEquals('varchar', $attribute['dataType']); - $this->assertEquals('varchar(128)', $attribute['columnType']); - $this->assertEquals('128', $attribute['characterMaximumLength']); - $this->assertEquals('YES', $attribute['isNullable']); - - $attribute = $attributes['story']; - $this->assertEquals('story', $attribute['$id']); - $this->assertEquals('text', $attribute['dataType']); - $this->assertEquals('text', $attribute['columnType']); - $this->assertEquals('65535', $attribute['characterMaximumLength']); - - $attribute = $attributes['string_list']; - $this->assertEquals('string_list', $attribute['$id']); - $this->assertTrue(in_array($attribute['dataType'], ['json', 'longtext'])); // mysql vs maria - $this->assertTrue(in_array($attribute['columnType'], ['json', 'longtext'])); - $this->assertTrue(in_array($attribute['characterMaximumLength'], [null, '4294967295'])); - $this->assertEquals('YES', $attribute['isNullable']); - - $attribute = $attributes['dob']; - $this->assertEquals('dob', $attribute['$id']); - $this->assertEquals('datetime', $attribute['dataType']); - $this->assertEquals('datetime(3)', $attribute['columnType']); - $this->assertEquals(null, $attribute['characterMaximumLength']); - $this->assertEquals('3', $attribute['datetimePrecision']); - - if ($db->getSharedTables()) { - $attribute = $attributes['_tenant']; - $this->assertEquals('_tenant', $attribute['$id']); - $this->assertEquals('int', $attribute['dataType']); - $this->assertEquals('10', $attribute['numericPrecision']); - $this->assertTrue(in_array($attribute['columnType'], ['int unsigned', 'int(11) unsigned'])); - } - } + $this->assertSame([], $db->getSchemaAttributes('no_such_collection')); - public function testRowSizeToLarge(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $db->createCollection(Collection::create(id: $collection)); - if ($database->getAdapter()->getDocumentSizeLimit() === 0) { - $this->expectNotToPerformAssertions(); - return; + $attributes = [ + Attribute::string(key: 'username', size: 128, required: true), + Attribute::string(key: 'story', size: 20000, required: true), + Attribute::string(key: 'string_list', size: 128, required: true, array: true), + Attribute::datetime(key: 'dob', default: '2000-06-12T14:12:55.000+00:00'), + ]; + foreach ($attributes as $attribute) { + $db->createAttribute($collection, $attribute); } - /** - * getDocumentSizeLimit = 65535 - * 65535 / 4 = 16383 MB4 - */ - $collection_1 = $database->createCollection('row_size_1'); - $collection_2 = $database->createCollection('row_size_2'); - $this->assertEquals(true, $database->createAttribute($collection_1->getId(), 'attr_1', Database::VAR_STRING, 16000, true)); - - try { - $database->createAttribute($collection_1->getId(), 'attr_2', Database::VAR_STRING, Database::LENGTH_KEY, true); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); + $columns = []; + foreach ($db->getSchemaAttributes($collection) as $column) { + $columns[$column->name] = $column; } - /** - * Relation takes length of Database::LENGTH_KEY so exceeding getDocumentSizeLimit - */ + foreach ($attributes as $attribute) { + $this->assertArrayHasKey($attribute->key, $columns); + $this->assertSame($adapter->getColumnType($attribute), $columns[$attribute->key]->type, $attribute->key); + } - try { - $database->createRelationship( - collection: $collection_2->getId(), - relatedCollection: $collection_1->getId(), - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $this->assertSame(128, $columns['username']->length); + $this->assertTrue($columns['username']->nullable); + $this->assertTrue($columns['string_list']->nullable); + $this->assertNull($columns['dob']->length); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); + foreach ([Storage::SEQUENCE, Storage::UID, Storage::CREATED_AT, Storage::UPDATED_AT, Storage::PERMISSIONS] as $internal) { + $this->assertArrayHasKey($internal, $columns, 'The engine-only column '.$internal.' is read back as a column'); + $this->assertNotSame('', $columns[$internal]->type); } - try { - $database->createRelationship( - collection: $collection_1->getId(), - relatedCollection: $collection_2->getId(), - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); + if ($db->hasSharedTables()) { + $this->assertArrayHasKey(Storage::TENANT, $columns); + $this->assertNull($columns[Storage::TENANT]->length); + $this->assertTrue($columns[Storage::TENANT]->nullable); } } @@ -670,172 +402,75 @@ public function testCreateCollectionWithSchemaIndexes(): void $database = $this->getDatabase(); $attributes = [ - new Document([ - '$id' => ID::custom('username'), - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => false, - 'signed' => true, - 'array' => false, - ]), - new Document([ - '$id' => ID::custom('cards'), - 'type' => Database::VAR_STRING, - 'size' => 5000, - 'required' => false, - 'signed' => true, - 'array' => true, - ]), + Attribute::string(key: 'username', size: 100), + Attribute::string(key: 'cards', size: 5000, array: true), ]; $indexes = [ - new Document([ - '$id' => ID::custom('idx_username'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['username'], - 'lengths' => [100], // Will be removed since equal to attributes size - 'orders' => [], - ]), - new Document([ - '$id' => ID::custom('idx_username_uid'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['username', '$id'], // to solve the same attribute mongo issue - 'lengths' => [99, 200], // Length not equal to attributes length - 'orders' => [Database::ORDER_DESC], - ]), + Index::key(key: 'idx_username', attributes: ['username'], lengths: [100]), + Index::key(key: 'idx_username_uid', attributes: ['username', '$id'], lengths: [99, 200], orders: [OrderDirection::Desc]), ]; - if ($database->getAdapter()->getSupportForIndexArray()) { - $indexes[] = new Document([ - '$id' => ID::custom('idx_cards'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['cards'], - 'lengths' => [500], // Will be changed to Database::ARRAY_INDEX_LENGTH (255) - 'orders' => [Database::ORDER_DESC], - ]); + if ($database->getAdapter()->supports(Capability::IndexArray)) { + $indexes[] = Index::key(key: 'idx_cards', attributes: ['cards'], lengths: [500], orders: [OrderDirection::Desc]); } - $collection = $database->createCollection( - 'collection98', - $attributes, - $indexes, - permissions: [ - Permission::create(Role::any()), - ] - ); + $collection = $database->createCollection(Collection::create(id: 'collection98', attributes: $attributes, indexes: $indexes, permissions: [ + Permission::create(Role::any()), + ])); - $this->assertEquals($collection->getAttribute('indexes')[0]['attributes'][0], 'username'); - $this->assertEquals($collection->getAttribute('indexes')[0]['lengths'][0], null); + $this->assertEquals($collection->indexes()[0]->attributes[0], 'username'); + $this->assertEquals($collection->indexes()[0]->lengths[0], null); - $this->assertEquals($collection->getAttribute('indexes')[1]['attributes'][0], 'username'); - $this->assertEquals($collection->getAttribute('indexes')[1]['lengths'][0], 99); - $this->assertEquals($collection->getAttribute('indexes')[1]['orders'][0], Database::ORDER_DESC); + $this->assertEquals($collection->indexes()[1]->attributes[0], 'username'); + $this->assertEquals($collection->indexes()[1]->lengths[0], 99); + $this->assertEquals($collection->indexes()[1]->orders[0], OrderDirection::Desc); - if ($database->getAdapter()->getSupportForIndexArray()) { - $this->assertEquals($collection->getAttribute('indexes')[2]['attributes'][0], 'cards'); - $this->assertEquals($collection->getAttribute('indexes')[2]['lengths'][0], Database::MAX_ARRAY_INDEX_LENGTH); - $this->assertEquals($collection->getAttribute('indexes')[2]['orders'][0], null); + if ($database->getAdapter()->supports(Capability::IndexArray)) { + $this->assertEquals($collection->indexes()[2]->attributes[0], 'cards'); + $this->assertEquals($collection->indexes()[2]->lengths[0], Database::MAX_ARRAY_INDEX_LENGTH); + $this->assertEquals($collection->indexes()[2]->orders[0], null); } } - public function testCollectionUpdate(): Document + public function testGetCollectionId(): void { /** @var Database $database */ $database = $this->getDatabase(); - $collection = $database->createCollection('collectionUpdate', permissions: [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()) - ], documentSecurity: false); - - $this->assertInstanceOf(Document::class, $collection); - - $collection = $database->getCollection('collectionUpdate'); - - $this->assertFalse($collection->getAttribute('documentSecurity')); - $this->assertIsArray($collection->getPermissions()); - $this->assertCount(4, $collection->getPermissions()); - - $collection = $database->updateCollection('collectionUpdate', [], true); - - $this->assertTrue($collection->getAttribute('documentSecurity')); - $this->assertIsArray($collection->getPermissions()); - $this->assertEmpty($collection->getPermissions()); - - $collection = $database->getCollection('collectionUpdate'); - - $this->assertTrue($collection->getAttribute('documentSecurity')); - $this->assertIsArray($collection->getPermissions()); - $this->assertEmpty($collection->getPermissions()); - - return $collection; - } - - public function testUpdateDeleteCollectionNotFound(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + if (! ($database->getAdapter()->hasFeature(Feature\Connection::class))) { + $this->expectNotToPerformAssertions(); - try { - $database->deleteCollection('not_found'); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Collection not found', $e->getMessage()); + return; } - try { - $database->updateCollection('not_found', [], true); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Collection not found', $e->getMessage()); - } + $id = $database->getConnectionId(); + $this->assertIsString($id); + $this->assertNotSame('', $id); } - public function testGetCollectionId(): void + public function testKeywords(): void { - /** @var Database $database */ $database = $this->getDatabase(); + $keywords = $database->profile()->limits->keywords; - if (!$database->getAdapter()->getSupportForGetConnectionId()) { + if ($keywords === []) { $this->expectNotToPerformAssertions(); + return; } - $this->assertIsString($database->getConnectionId()); - } - - public function testKeywords(): void - { - $database = $this->getDatabase(); - $keywords = $database->getKeywords(); - // Collection name tests $attributes = [ - new Document([ - '$id' => ID::custom('attribute1'), - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + Attribute::string(key: 'attribute1', size: 256), ]; $indexes = [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute1'], - 'lengths' => [256], - 'orders' => ['ASC'], - ]), + Index::key(key: 'index1', attributes: ['attribute1'], lengths: [256], orders: [OrderDirection::Asc]), ]; foreach ($keywords as $keyword) { - $collection = $database->createCollection($keyword, $attributes, $indexes); + $collection = $database->createCollection(Collection::create(id: $keyword, attributes: $attributes, indexes: $indexes)); $this->assertEquals($keyword, $collection->getId()); $document = $database->createDocument($keyword, new Document([ @@ -845,7 +480,7 @@ public function testKeywords(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - '$id' => ID::custom('helloWorld'), + '$id' => Id::custom('helloWorld'), 'attribute1' => 'Hello World', ])); $this->assertEquals('helloWorld', $document->getId()); @@ -857,21 +492,20 @@ public function testKeywords(): void $this->assertCount(1, $documents); $this->assertEquals('helloWorld', $documents[0]->getId()); - $collection = $database->deleteCollection($keyword); - $this->assertTrue($collection); + $database->deleteCollection($keyword); + $this->assertNull($database->findCollection($keyword)); } // TODO: updateCollection name tests // Attribute name tests foreach ($keywords as $keyword) { - $collectionName = 'rk' . $keyword; // rk is shorthand for reserved-keyword. We do this since there are some limits (64 chars max) + $collectionName = 'rk'.$keyword; // rk is shorthand for reserved-keyword. We do this since there are some limits (64 chars max) - $collection = $database->createCollection($collectionName); + $collection = $database->createCollection(Collection::create(id: $collectionName)); $this->assertEquals($collectionName, $collection->getId()); - $attribute = $database->createAttribute($collectionName, $keyword, Database::VAR_STRING, 128, true); - $this->assertEquals(true, $attribute); + $database->createAttribute($collectionName, Attribute::string(key: $keyword, size: 128, required: true)); $document = new Document([ '$permissions' => [ @@ -880,35 +514,35 @@ public function testKeywords(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - '$id' => 'reservedKeyDocument' + '$id' => 'reservedKeyDocument', ]); - $document->setAttribute($keyword, 'Reserved:' . $keyword); + $document->setAttribute($keyword, 'Reserved:'.$keyword); $document = $database->createDocument($collectionName, $document); $this->assertEquals('reservedKeyDocument', $document->getId()); - $this->assertEquals('Reserved:' . $keyword, $document->getAttribute($keyword)); + $this->assertEquals('Reserved:'.$keyword, $document->getAttribute($keyword)); $document = $database->getDocument($collectionName, 'reservedKeyDocument'); $this->assertEquals('reservedKeyDocument', $document->getId()); - $this->assertEquals('Reserved:' . $keyword, $document->getAttribute($keyword)); + $this->assertEquals('Reserved:'.$keyword, $document->getAttribute($keyword)); $documents = $database->find($collectionName); $this->assertCount(1, $documents); $this->assertEquals('reservedKeyDocument', $documents[0]->getId()); - $this->assertEquals('Reserved:' . $keyword, $documents[0]->getAttribute($keyword)); + $this->assertEquals('Reserved:'.$keyword, $documents[0]->getAttribute($keyword)); $documents = $database->find($collectionName, [Query::equal($keyword, ["Reserved:{$keyword}"])]); $this->assertCount(1, $documents); $this->assertEquals('reservedKeyDocument', $documents[0]->getId()); $documents = $database->find($collectionName, [ - Query::orderDesc($keyword) + Query::orderDesc($keyword), ]); $this->assertCount(1, $documents); $this->assertEquals('reservedKeyDocument', $documents[0]->getId()); - $collection = $database->deleteCollection($collectionName); - $this->assertTrue($collection); + $database->deleteCollection($collectionName); + $this->assertNull($database->findCollection($collectionName)); } } @@ -916,11 +550,11 @@ public function testLabels(): void { /** @var Database $database */ $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + $reader = Role::label('reader')->toString(); - $this->assertInstanceOf('Utopia\Database\Document', $database->createCollection( - 'labels_test', - )); - $database->createAttribute('labels_test', 'attr1', Database::VAR_STRING, 10, false); + $database->createCollection(Collection::create(id: 'labels_test')); + $database->createAttribute('labels_test', Attribute::string(key: 'attr1', size: 10)); $database->createDocument('labels_test', new Document([ '$id' => 'doc1', @@ -930,31 +564,26 @@ public function testLabels(): void ], ])); - $documents = $database->find('labels_test'); - - $this->assertEmpty($documents); - - $this->getDatabase()->getAuthorization()->addRole(Role::label('reader')->toString()); - - $documents = $database->find('labels_test'); - - $this->assertCount(1, $documents); - } - - public function testMetadata(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $withoutLabel = $database->find('labels_test'); + $this->assertSame([], $withoutLabel); + $this->assertTrue($database->getDocument('labels_test', 'doc1')->isEmpty()); - $database->setMetadata('key', 'value'); - - $database->createCollection('testers'); + $authorization->addRole($reader); - $this->assertEquals(['key' => 'value'], $database->getMetadata()); + try { + $withLabel = $database->find('labels_test'); + $this->assertCount(1, $withLabel); + $this->assertSame('doc1', $withLabel[0]->getId()); + $this->assertSame('value1', $database->getDocument('labels_test', 'doc1')->getAttribute('attr1')); + } finally { + $authorization->removeRole($reader); + } - $database->resetMetadata(); + $labelRemoved = $database->find('labels_test'); + $this->assertSame([], $labelRemoved); + $this->assertTrue($database->getDocument('labels_test', 'doc1')->isEmpty()); - $this->assertEquals([], $database->getMetadata()); + $database->deleteCollection('labels_test'); } public function testDeleteCollectionDeletesRelationships(): void @@ -962,88 +591,76 @@ public function testDeleteCollectionDeletesRelationships(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('devices'); + // Create 'testers' collection if not already created (was created by testMetadata in sequential mode) + if ($database->findCollection('testers') === null) { + $database->createCollection(Collection::create(id: 'testers')); + } + + $database->createCollection(Collection::create(id: 'devices')); - $database->createRelationship( - collection: 'testers', - relatedCollection: 'devices', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - twoWayKey: 'tester' - ); + $database->createRelationship('testers', Relationship::oneToMany(relatedCollection: 'devices', twoWay: true, twoWayKey: 'tester')); $testers = $database->getCollection('testers'); $devices = $database->getCollection('devices'); - $this->assertEquals(1, \count($testers->getAttribute('attributes'))); - $this->assertEquals(1, \count($devices->getAttribute('attributes'))); - $this->assertEquals(1, \count($devices->getAttribute('indexes'))); + $this->assertEquals(1, \count($testers->attributes())); + $this->assertEquals(1, \count($devices->attributes())); + $this->assertEquals(1, \count($devices->indexes())); $database->deleteCollection('testers'); - $testers = $database->getCollection('testers'); + $testers = $database->findCollection('testers'); $devices = $database->getCollection('devices'); - $this->assertEquals(true, $testers->isEmpty()); - $this->assertEquals(0, \count($devices->getAttribute('attributes'))); - $this->assertEquals(0, \count($devices->getAttribute('indexes'))); + $this->assertNull($testers); + $this->assertEquals(0, \count($devices->attributes())); + $this->assertEquals(0, \count($devices->indexes())); } - public function testCascadeMultiDelete(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('cascadeMultiDelete1'); - $database->createCollection('cascadeMultiDelete2'); - $database->createCollection('cascadeMultiDelete3'); + $database->createCollection(Collection::create(id: 'cascadeMultiDelete1')); + $database->createCollection(Collection::create(id: 'cascadeMultiDelete2')); + $database->createCollection(Collection::create(id: 'cascadeMultiDelete3')); - $database->createRelationship( - collection: 'cascadeMultiDelete1', - relatedCollection: 'cascadeMultiDelete2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - onDelete: Database::RELATION_MUTATE_CASCADE - ); + $database->createRelationship('cascadeMultiDelete1', Relationship::oneToMany(relatedCollection: 'cascadeMultiDelete2', twoWay: true, onDelete: RelationshipDeleteAction::Cascade)); - $database->createRelationship( - collection: 'cascadeMultiDelete2', - relatedCollection: 'cascadeMultiDelete3', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - onDelete: Database::RELATION_MUTATE_CASCADE - ); + $database->createRelationship('cascadeMultiDelete2', Relationship::oneToMany(relatedCollection: 'cascadeMultiDelete3', twoWay: true, onDelete: RelationshipDeleteAction::Cascade)); $root = $database->createDocument('cascadeMultiDelete1', new Document([ '$id' => 'cascadeMultiDelete1', '$permissions' => [ Permission::read(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ], 'cascadeMultiDelete2' => [ [ '$id' => 'cascadeMultiDelete2', '$permissions' => [ Permission::read(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ], 'cascadeMultiDelete3' => [ [ '$id' => 'cascadeMultiDelete3', '$permissions' => [ Permission::read(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ], ], ], @@ -1051,8 +668,9 @@ public function testCascadeMultiDelete(): void ], ])); - $this->assertCount(1, $root->getAttribute('cascadeMultiDelete2')); - $this->assertCount(1, $root->getAttribute('cascadeMultiDelete2')[0]->getAttribute('cascadeMultiDelete3')); + $cascade2 = $root->getDocuments('cascadeMultiDelete2'); + $this->assertCount(1, $cascade2); + $this->assertCount(1, $cascade2[0]->getDocuments('cascadeMultiDelete3')); $this->assertEquals(true, $database->deleteDocument('cascadeMultiDelete1', $root->getId())); @@ -1079,41 +697,48 @@ public function testSharedTables(): void */ /** @var Database $database */ $database = $this->getDatabase(); - $sharedTables = $database->getSharedTables(); + $sharedTables = $database->hasSharedTables(); $namespace = $database->getNamespace(); $schema = $database->getDatabase(); + $tenant = $database->getTenant(); - if (!$database->getAdapter()->getSupportForSchemas()) { + if (! $database->getAdapter()->supports(Capability::Schemas)) { $this->expectNotToPerformAssertions(); + return; } - if ($database->exists('schema1')) { - $database->setDatabase('schema1')->delete(); + $token = static::getTestToken(); + $schema1 = 'schema1_'.$token; + $schema2 = 'schema2_'.$token; + $sharedTablesDb = 'sharedTables_'.$token; + + if ($database->exists($schema1)) { + $database->setDatabase($schema1)->delete(); } - if ($database->exists('schema2')) { - $database->setDatabase('schema2')->delete(); + if ($database->exists($schema2)) { + $database->setDatabase($schema2)->delete(); } - if ($database->exists('sharedTables')) { - $database->setDatabase('sharedTables')->delete(); + if ($database->exists($sharedTablesDb)) { + $database->setDatabase($sharedTablesDb)->delete(); } /** * Schema */ $database - ->setDatabase('schema1') + ->setDatabase($schema1) ->setNamespace('') ->create(); - $this->assertEquals(true, $database->exists('schema1')); + $this->assertEquals(true, $database->exists($schema1)); $database - ->setDatabase('schema2') + ->setDatabase($schema2) ->setNamespace('') ->create(); - $this->assertEquals(true, $database->exists('schema2')); + $this->assertEquals(true, $database->exists($schema2)); /** * Table @@ -1122,52 +747,33 @@ public function testSharedTables(): void $tenant2 = 2; $database - ->setDatabase('sharedTables') + ->setDatabase($sharedTablesDb) ->setNamespace('') ->setSharedTables(true) ->setTenant($tenant1) ->create(); - $this->assertEquals(true, $database->exists('sharedTables')); - - $database->createCollection('people', [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => true, - ]), - new Document([ - '$id' => 'lifeStory', - 'type' => Database::VAR_STRING, - 'size' => 65536, - 'required' => true, - ]) - ], [ - new Document([ - '$id' => 'idx_name', - 'type' => Database::INDEX_KEY, - 'attributes' => ['name'] - ]) - ], [ + $this->assertEquals(true, $database->exists($sharedTablesDb)); + + $database->createCollection(Collection::create(id: 'people', attributes: [ + Attribute::string(key: 'name', size: 128, required: true), + Attribute::string(key: 'lifeStory', size: 65536, required: true), + ], indexes: [ + Index::key(key: 'idx_name', attributes: ['name']), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); $this->assertCount(1, $database->listCollections()); - if ($database->getAdapter()->getSupportForFulltextIndex()) { - $database->createIndex( - collection: 'people', - id: 'idx_lifeStory', - type: Database::INDEX_FULLTEXT, - attributes: ['lifeStory'] - ); + if ($database->getAdapter()->supports(Capability::IndexFulltext)) { + $database->createIndex('people', Index::fulltext(key: 'idx_lifeStory', attributes: ['lifeStory'])); } - $docId = ID::unique(); + $docId = Id::unique(); $database->createDocument('people', new Document([ '$id' => $docId, @@ -1175,7 +781,7 @@ public function testSharedTables(): void Permission::read(Role::any()), ], 'name' => 'Spiderman', - 'lifeStory' => 'Spider-Man is a superhero appearing in American comic books published by Marvel Comics.' + 'lifeStory' => 'Spider-Man is a superhero appearing in American comic books published by Marvel Comics.', ])); $doc = $database->getDocument('people', $docId); @@ -1186,7 +792,7 @@ public function testSharedTables(): void * Remove Permissions */ $doc->setAttribute('$permissions', [ - Permission::read(Role::any()) + Permission::read(Role::any()), ]); $database->updateDocument('people', $docId, $doc); @@ -1228,7 +834,7 @@ public function testSharedTables(): void $this->assertEquals('Collection not found', $e->getMessage()); } - $this->assertCount(0, $database->listCollections()); + $this->assertCount(0, $database->listCollections(100)); // Swap back to tenant 1, allowed $database->setTenant($tenant1); @@ -1251,9 +857,11 @@ public function testSharedTables(): void // Reset state $database ->setSharedTables($sharedTables) + ->setTenant($tenant) ->setNamespace($namespace) ->setDatabase($schema); } + /** * @throws LimitException * @throws DuplicateException @@ -1264,12 +872,12 @@ public function testCreateDuplicates(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('duplicates', permissions: [ - Permission::read(Role::any()) - ]); + $database->createCollection(Collection::create(id: 'duplicates', permissions: [ + Permission::read(Role::any()), + ])); try { - $database->createCollection('duplicates'); + $database->createCollection(Collection::create(id: 'duplicates')); $this->fail('Failed to throw exception'); } catch (Exception $e) { $this->assertInstanceOf(DuplicateException::class, $e); @@ -1279,67 +887,74 @@ public function testCreateDuplicates(): void $database->deleteCollection('duplicates'); } + public function testSharedTablesDuplicates(): void { /** @var Database $database */ $database = $this->getDatabase(); - $sharedTables = $database->getSharedTables(); + $sharedTables = $database->hasSharedTables(); $namespace = $database->getNamespace(); $schema = $database->getDatabase(); + $tenant = $database->getTenant(); - if (!$database->getAdapter()->getSupportForSchemas()) { + if (! $database->getAdapter()->supports(Capability::Schemas)) { $this->expectNotToPerformAssertions(); + return; } - if ($database->exists('sharedTables')) { - $database->setDatabase('sharedTables')->delete(); + $sharedTablesDb = 'sharedTables_'.static::getTestToken(); + + if ($database->exists($sharedTablesDb)) { + $database->setDatabase($sharedTablesDb)->delete(); } $database - ->setDatabase('sharedTables') + ->setDatabase($sharedTablesDb) ->setNamespace('') ->setSharedTables(true) ->setTenant(null) ->create(); // Create collection - $database->createCollection('duplicates', documentSecurity: false); - $database->createAttribute('duplicates', 'name', Database::VAR_STRING, 10, false); - $database->createIndex('duplicates', 'nameIndex', Database::INDEX_KEY, ['name']); + $database->createCollection(Collection::create(id: 'duplicates', documentSecurity: false)); + $database->createAttribute('duplicates', Attribute::string(key: 'name', size: 10)); + $database->createIndex('duplicates', Index::key(key: 'nameIndex', attributes: ['name'])); $database->setTenant(2); try { - $database->createCollection('duplicates', documentSecurity: false); + $database->createCollection(Collection::create(id: 'duplicates', documentSecurity: false)); } catch (DuplicateException) { // Ignore } try { - $database->createAttribute('duplicates', 'name', Database::VAR_STRING, 10, false); + $database->createAttribute('duplicates', Attribute::string(key: 'name', size: 10)); } catch (DuplicateException) { // Ignore } try { - $database->createIndex('duplicates', 'nameIndex', Database::INDEX_KEY, ['name']); + $database->createIndex('duplicates', Index::key(key: 'nameIndex', attributes: ['name'])); } catch (DuplicateException) { // Ignore } $collection = $database->getCollection('duplicates'); - $this->assertEquals(1, \count($collection->getAttribute('attributes'))); - $this->assertEquals(1, \count($collection->getAttribute('indexes'))); + $this->assertEquals(1, \count($collection->attributes())); + $this->assertEquals(1, \count($collection->indexes())); - $database->setTenant(1); + $database->setTenant(null); + $database->purgeCachedCollection('duplicates'); $collection = $database->getCollection('duplicates'); - $this->assertEquals(1, \count($collection->getAttribute('attributes'))); - $this->assertEquals(1, \count($collection->getAttribute('indexes'))); + $this->assertEquals(1, \count($collection->attributes())); + $this->assertEquals(1, \count($collection->indexes())); $database ->setSharedTables($sharedTables) + ->setTenant($tenant) ->setNamespace($namespace) ->setDatabase($schema); } @@ -1348,7 +963,7 @@ public function testSharedTablesMultiTenantCreateCollection(): void { /** @var Database $database */ $database = $this->getDatabase(); - $sharedTables = $database->getSharedTables(); + $sharedTables = $database->hasSharedTables(); $namespace = $database->getNamespace(); $schema = $database->getDatabase(); $originalTenant = $database->getTenant(); @@ -1356,7 +971,7 @@ public function testSharedTablesMultiTenantCreateCollection(): void if ($sharedTables) { // Already in shared-tables mode (SharedTables/* test classes) - } elseif ($database->getAdapter()->getSupportForSchemas()) { + } elseif ($database->getAdapter()->supports(Capability::Schemas)) { $dbName = 'stMultiTenant'; if ($database->exists($dbName)) { $database->setDatabase($dbName)->delete(); @@ -1370,55 +985,49 @@ public function testSharedTablesMultiTenantCreateCollection(): void $createdDb = true; } else { $this->expectNotToPerformAssertions(); + return; } try { - $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 10 : 'tenant_10'; - $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 20 : 'tenant_20'; - $colName = 'multiTenantCol'; + $tenant1 = $database->getIdAttributeType() === ColumnType::Integer ? 10 : 'tenant_10'; + $tenant2 = $database->getIdAttributeType() === ColumnType::Integer ? 20 : 'tenant_20'; + $colName = 'mt_' . uniqid(); $database->setTenant($tenant1); - $database->createCollection($colName, [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => true, - ]), - ]); + $database->createCollection(Collection::create(id: $colName, attributes: [ + Attribute::string(key: 'name', size: 128, required: true), + ])); - $col1 = $database->getCollection($colName); - $this->assertFalse($col1->isEmpty()); - $this->assertEquals(1, \count($col1->getAttribute('attributes'))); + $col1 = $database->findCollection($colName); + $this->assertNotNull($col1); + $this->assertEquals(1, \count($col1->attributes())); $database->setTenant($tenant2); - $database->createCollection($colName, [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => true, - ]), - ]); + $database->createCollection(Collection::create(id: $colName, attributes: [ + Attribute::string(key: 'name', size: 128, required: true), + ])); - $col2 = $database->getCollection($colName); - $this->assertFalse($col2->isEmpty()); - $this->assertEquals(1, \count($col2->getAttribute('attributes'))); + $col2 = $database->findCollection($colName); + $this->assertNotNull($col2); + $this->assertEquals(1, \count($col2->attributes())); $database->setTenant($tenant1); - $col1Again = $database->getCollection($colName); - $this->assertFalse($col1Again->isEmpty()); + $col1Again = $database->findCollection($colName); + $this->assertNotNull($col1Again); if ($createdDb) { $database->delete(); } else { $database->setTenant($tenant1); $database->deleteCollection($colName); - $database->setTenant($tenant2); - $database->deleteCollection($colName); + try { + $database->setTenant($tenant2); + $database->deleteCollection($colName); + } catch (\Throwable) { + } } } finally { $database @@ -1433,14 +1042,14 @@ public function testSharedTablesMultiTenantCreate(): void { /** @var Database $database */ $database = $this->getDatabase(); - $sharedTables = $database->getSharedTables(); + $sharedTables = $database->hasSharedTables(); $namespace = $database->getNamespace(); $schema = $database->getDatabase(); $originalTenant = $database->getTenant(); try { - $tenant1 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 100 : 'tenant_100'; - $tenant2 = $database->getAdapter()->getIdAttributeType() === Database::VAR_INTEGER ? 200 : 'tenant_200'; + $tenant1 = $database->getIdAttributeType() === ColumnType::Integer ? 100 : 'tenant_100'; + $tenant2 = $database->getIdAttributeType() === ColumnType::Integer ? 200 : 'tenant_200'; if ($sharedTables) { // Already in shared-tables mode; create() should be idempotent. @@ -1450,8 +1059,8 @@ public function testSharedTablesMultiTenantCreate(): void $database->create(); $database->setTenant($tenant2); $database->create(); - $this->assertTrue(true); - } elseif ($database->getAdapter()->getSupportForSchemas()) { + $this->assertSame($tenant2, $database->getTenant()); + } elseif ($database->getAdapter()->supports(Capability::Schemas)) { $dbName = 'stMultiCreate'; if ($database->exists($dbName)) { $database->setDatabase($dbName)->delete(); @@ -1469,6 +1078,7 @@ public function testSharedTablesMultiTenantCreate(): void $database->delete(); } else { $this->expectNotToPerformAssertions(); + return; } } finally { @@ -1485,75 +1095,70 @@ public function testEvents(): void $this->getDatabase()->getAuthorization()->skip(function () { $database = $this->getDatabase(); - $events = [ - Database::EVENT_DATABASE_CREATE, - Database::EVENT_DATABASE_LIST, - Database::EVENT_COLLECTION_CREATE, - Database::EVENT_COLLECTION_LIST, - Database::EVENT_COLLECTION_READ, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_ATTRIBUTE_CREATE, - Database::EVENT_ATTRIBUTE_UPDATE, - Database::EVENT_INDEX_CREATE, - Database::EVENT_DOCUMENT_CREATE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_UPDATE, - Database::EVENT_DOCUMENT_READ, - Database::EVENT_DOCUMENT_FIND, - Database::EVENT_DOCUMENT_FIND, - Database::EVENT_DOCUMENT_COUNT, - Database::EVENT_DOCUMENT_SUM, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_INCREASE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_DECREASE, - Database::EVENT_DOCUMENTS_CREATE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENTS_UPDATE, - Database::EVENT_INDEX_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENTS_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_ATTRIBUTE_DELETE, - Database::EVENT_COLLECTION_DELETE, - Database::EVENT_DATABASE_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENTS_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_ATTRIBUTE_DELETE, - Database::EVENT_COLLECTION_DELETE, - Database::EVENT_DATABASE_DELETE + $expected = [ + Event::DatabaseCreate, + Event::DatabaseList, + Event::CollectionCreate, + Event::CollectionList, + Event::CollectionRead, + Event::DocumentPurge, + Event::AttributeCreate, + Event::DocumentPurge, + Event::AttributeUpdate, + Event::IndexCreate, + Event::DocumentCreate, + Event::DocumentPurge, + Event::DocumentUpdate, + Event::DocumentRead, + Event::DocumentFind, + Event::DocumentFind, + Event::DocumentCount, + Event::DocumentSum, + Event::DocumentPurge, + Event::DocumentIncrease, + Event::DocumentPurge, + Event::DocumentDecrease, + Event::DocumentsCreate, + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentsUpdate, + Event::IndexDelete, + Event::DocumentPurge, + Event::DocumentDelete, + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentsDelete, + Event::DocumentPurge, + Event::AttributeDelete, + Event::CollectionDelete, + Event::DatabaseDelete, ]; - $database->on(Database::EVENT_ALL, 'test', function ($event, $data) use (&$events) { - $shifted = array_shift($events); - $this->assertEquals($shifted, $event); - }); + $supportsSchemas = $this->getDatabase()->getAdapter()->supports(Capability::Schemas); + if (! $supportsSchemas) { + \array_shift($expected); + } + $recorder = new EventRecorder('test'); + $database->addHook($recorder); - if ($this->getDatabase()->getAdapter()->getSupportForSchemas()) { + if ($supportsSchemas) { $database->setDatabase('hellodb'); $database->create(); - } else { - \array_shift($events); } $database->list(); $database->setDatabase($this->testDatabase); - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); $database->listCollections(); $database->getCollection($collectionId); - $database->createAttribute($collectionId, 'attr1', Database::VAR_INTEGER, 2, false); - $database->updateAttributeRequired($collectionId, 'attr1', true); - $indexId1 = 'index2_' . uniqid(); - $database->createIndex($collectionId, $indexId1, Database::INDEX_KEY, ['attr1']); + $database->createAttribute($collectionId, Attribute::integer(key: 'attr1')); + $database->updateAttribute($collectionId, 'attr1', new AttributeUpdate(required: true)); + $indexId1 = 'index2_'.uniqid(); + $database->createIndex($collectionId, Index::key(key: $indexId1, attributes: ['attr1'])); $document = $database->createDocument($collectionId, new Document([ '$id' => 'doc1', @@ -1565,10 +1170,8 @@ public function testEvents(): void ], ])); - $executed = false; - $database->on(Database::EVENT_ALL, 'should-not-execute', function ($event, $data) use (&$executed) { - $executed = true; - }); + $silenced = new EventRecorder('should-not-execute'); + $database->addHook($silenced); $database->silent(function () use ($database, $collectionId, $document) { $database->updateDocument($collectionId, 'doc1', $document->setAttribute('attr1', 15)); @@ -1581,7 +1184,7 @@ public function testEvents(): void $database->decreaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); }, ['should-not-execute']); - $this->assertFalse($executed); + $this->assertSame([], $silenced->stop()); $database->createDocuments($collectionId, [ new Document([ @@ -1604,9 +1207,29 @@ public function testEvents(): void $database->deleteCollection($collectionId); $database->delete('hellodb'); - // Remove all listeners - $database->on(Database::EVENT_ALL, 'test', null); - $database->on(Database::EVENT_ALL, 'should-not-execute', null); + $this->assertSame($expected, $recorder->stop()); + }); + } + + public function testSilentNamedListeners(): void + { + $this->getDatabase()->getAuthorization()->skip(function () { + $database = $this->getDatabase(); + $collectionId = Id::unique(); + + $replaced = new EventRecorder('audits'); + $replacement = new EventRecorder('audits'); + $usage = new EventRecorder('usage'); + $database->addHook($replaced)->addHook($usage); + + $database->silent(fn () => $database->createCollection(Collection::create(id: $collectionId)), ['audits']); + $database->silent(fn () => $database->getCollection($collectionId)); + $database->addHook($replacement); + $database->deleteCollection($collectionId); + + $this->assertSame([], $replaced->stop()); + $this->assertSame([Event::CollectionDelete], $replacement->stop()); + $this->assertSame([Event::CollectionCreate, Event::CollectionDelete], $usage->stop()); }); } @@ -1615,10 +1238,11 @@ public function testCreatedAtUpdatedAt(): void /** @var Database $database */ $database = $this->getDatabase(); - $this->assertInstanceOf('Utopia\Database\Document', $database->createCollection('created_at')); - $database->createAttribute('created_at', 'title', Database::VAR_STRING, 100, false); - $document = $database->createDocument('created_at', new Document([ - '$id' => ID::custom('uid123'), + $created = $database->createCollection(Collection::create(id: $this->getCreatedAtCollection())); + $this->assertSame($this->getCreatedAtCollection(), $created->getId()); + $database->createAttribute($this->getCreatedAtCollection(), Attribute::string(key: 'title', size: 100)); + $document = $database->createDocument($this->getCreatedAtCollection(), new Document([ + '$id' => Id::custom('uid123'), '$permissions' => [ Permission::read(Role::any()), @@ -1632,66 +1256,121 @@ public function testCreatedAtUpdatedAt(): void $this->assertNotNull($document->getSequence()); } - /** - * @depends testCreatedAtUpdatedAt - */ public function testCreatedAtUpdatedAtAssert(): void { /** @var Database $database */ $database = $this->getDatabase(); + $collection = $this->initCreatedAtAssertFixture(); - $document = $database->getDocument('created_at', 'uid123'); - $this->assertEquals(true, !$document->isEmpty()); + $document = $database->getDocument($collection, 'uid123'); + $this->assertEquals(true, ! $document->isEmpty()); sleep(1); $document->setAttribute('title', 'new title'); - $database->updateDocument('created_at', 'uid123', $document); - $document = $database->getDocument('created_at', 'uid123'); + $database->updateDocument($collection, 'uid123', $document); + $document = $database->getDocument($collection, 'uid123'); $this->assertGreaterThan($document->getCreatedAt(), $document->getUpdatedAt()); $this->expectException(DuplicateException::class); - $database->createCollection('created_at'); + $database->createCollection(Collection::create(id: $collection)); } - - public function testTransformations(): void + private function initCreatedAtAssertFixture(): string { /** @var Database $database */ $database = $this->getDatabase(); + $collection = Id::unique(); - $database->createCollection('docs', attributes: [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 767, - 'required' => true, - ]) - ]); - - $database->createDocument('docs', new Document([ - '$id' => 'doc1', - 'name' => 'value1', + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'title', size: 100)); + $database->createDocument($collection, new Document([ + '$id' => Id::custom('uid123'), + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], ])); - $database->setMetadata('scope', 'api.users'); + return $collection; + } - $capturedSql = ''; - $database->before(Database::EVENT_DOCUMENT_READ, 'test', function (string $sql) use (&$capturedSql) { - $sql .= ' AND 1=0'; - $capturedSql = $sql; - return $sql; - }); + public function testTransformations(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + // Transform hooks rewrite SQL statements, so only SQL adapters have a query to rewrite. + if (! $database->getAdapter()->hasFeature(Feature\RawQuery::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(Collection::create(id: 'docs', attributes: [ + Attribute::string(key: 'name', size: 767, required: true), + ])); + + $database->createDocument('docs', new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'value1', + ])); + + $this->assertCount(1, $database->find('docs')); + + $database->setMetadata('scope', 'api.users'); + + $hook = new class ($database->getNamespace().'_docs') implements Transform { + public string $query = ''; + + public function __construct(private readonly string $table) + { + } + + #[\Override] + public function transform(Event $event, string $query): string + { + if ($event !== Event::DocumentRead || ! \str_contains($query, $this->table)) { + return $query; + } - $result = $database->getDocument('docs', 'doc1'); + $this->query = $query; - $this->assertTrue($result->isEmpty()); + return $query.' AND 1 = 0'; + } + }; + $database->addHook($hook); + + try { + // getDocument() resolves an uncached collection with a DocumentRead of the metadata table, which the + // transform must leave alone. Evicting the definition makes that read reach the transform on every run. + $database->purgeCachedDocument(Database::METADATA, 'docs'); - if ($database->getAdapter() instanceof SQL) { - $this->assertStringContainsString('/* scope: api.users */', $capturedSql); + $this->assertTrue($database->getDocument('docs', 'doc1')->isEmpty()); + $this->assertStringContainsString('/* scope: api.users */', $hook->query); + } finally { + $database->removeTransform($hook::class); + $database->resetMetadata(); } - $database->before(Database::EVENT_DOCUMENT_READ, 'test', null); - $database->resetMetadata(); + $this->assertCount(1, $database->find('docs')); + } + + /** + * The tenant is the segment before the 'collection' marker. Substring + * matching is unsafe because the namespace is a hex uniqid() that may + * legitimately contain the tenant digits. + */ + private function cacheKeyTenantSegment(string $collectionKey): string + { + $segments = \explode(':', $collectionKey); + $marker = \array_search('collection', $segments, true); + $this->assertIsInt($marker); + $this->assertGreaterThan(0, $marker); + + return $segments[$marker - 1]; } public function testSetGlobalCollection(): void @@ -1714,24 +1393,27 @@ public function testSetGlobalCollection(): void $this->assertNotEmpty($documentKey); $this->assertNotEmpty($hashKey); - if ($db->getSharedTables()) { - $this->assertStringNotContainsString((string)$db->getAdapter()->getTenant(), $collectionKey); + if ($db->hasSharedTables()) { + $this->assertSame('', $this->cacheKeyTenantSegment($collectionKey)); } - // non global collection should containt tenant in the cache key + // non global collection should contain tenant in the cache key $nonGlobalCollectionId = 'nonGlobalCollection'; [$collectionKeyRegular] = $db->getCacheKeys( Database::METADATA, $nonGlobalCollectionId ); - if ($db->getSharedTables()) { - $this->assertStringContainsString((string)$db->getAdapter()->getTenant(), $collectionKeyRegular); + if ($db->hasSharedTables()) { + $this->assertSame( + (string) $db->getAdapter()->getTenant(), + $this->cacheKeyTenantSegment($collectionKeyRegular) + ); } // Non metadata collection should contain tenant in the cache key [$collectionKey, $documentKey, $hashKey] = $db->getCacheKeys( $collectionId, - ID::unique(), + Id::unique(), [] ); @@ -1739,82 +1421,47 @@ public function testSetGlobalCollection(): void $this->assertNotEmpty($documentKey); $this->assertNotEmpty($hashKey); - if ($db->getSharedTables()) { - $this->assertStringContainsString((string)$db->getAdapter()->getTenant(), $collectionKey); + if ($db->hasSharedTables()) { + $this->assertStringContainsString((string) $db->getAdapter()->getTenant(), $collectionKey); } $db->resetGlobalCollections(); $this->assertEmpty($db->getGlobalCollections()); - } public function testCreateCollectionWithLongId(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $collection = '019a91aa-58cd-708d-a55c-5f7725ef937a'; $attributes = [ - new Document([ - '$id' => 'name', - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => true, - 'array' => false, - ]), - new Document([ - '$id' => 'age', - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false, - 'array' => false, - ]), - new Document([ - '$id' => 'isActive', - 'type' => Database::VAR_BOOLEAN, - 'size' => 0, - 'required' => false, - 'array' => false, - ]), + Attribute::string(key: 'name', size: 256, required: true), + Attribute::integer(key: 'age'), + Attribute::boolean(key: 'isActive'), ]; $indexes = [ - new Document([ - '$id' => ID::custom('idx_name'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['name'], - 'lengths' => [128], - 'orders' => ['ASC'], - ]), - new Document([ - '$id' => ID::custom('idx_name_age'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['name', 'age'], - 'lengths' => [128, null], - 'orders' => ['ASC', 'DESC'], - ]), + Index::key(key: 'idx_name', attributes: ['name'], lengths: [128], orders: [OrderDirection::Asc]), + Index::key(key: 'idx_name_age', attributes: ['name', 'age'], lengths: [128, null], orders: [OrderDirection::Asc, OrderDirection::Desc]), ]; - $collectionDocument = $database->createCollection( - $collection, - $attributes, - $indexes, - permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ); + $collectionDocument = $database->createCollection(Collection::create(id: $collection, attributes: $attributes, indexes: $indexes, permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); $this->assertEquals($collection, $collectionDocument->getId()); - $this->assertCount(3, $collectionDocument->getAttribute('attributes')); - $this->assertCount(2, $collectionDocument->getAttribute('indexes')); + $this->assertCount(3, $collectionDocument->attributes()); + $this->assertCount(2, $collectionDocument->indexes()); $document = $database->createDocument($collection, new Document([ '$id' => 'longIdDoc', @@ -1842,7 +1489,7 @@ public function testCreateCollectionWithLongId(): void $fetched = $database->getDocument($collection, 'longIdDoc'); $this->assertEquals('LongId Test', $fetched->getAttribute('name')); - $this->assertTrue($database->deleteCollection($collection)); + $database->deleteCollection($collection); } /** @@ -1860,41 +1507,30 @@ public function testCreateCollectionConcurrentlyKeepsPeerData(): void // A peer process: same database, its own cache, so its writes do not // purge the negative cache entry this process is about to record. + $authorization = self::$authorization ?? throw new \RuntimeException('Authorization not initialised'); $peer = (new Database($database->getAdapter(), new Cache(new NoneCache()))) - ->setAuthorization(self::$authorization); + ->setAuthorization($authorization); - $this->assertTrue($database->getCollection($collection)->isEmpty()); + $this->assertNull($database->findCollection($collection)); - $peer->createCollection($collection, [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - ]), - ], permissions: [ + $name = Attribute::string(key: 'name', size: 128); + + $peer->createCollection(Collection::create(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); $peer->createDocument($collection, new Document([ - '$id' => ID::custom('written'), + '$id' => Id::custom('written'), '$permissions' => [Permission::read(Role::any())], 'name' => 'peer', ])); try { - $database->createCollection($collection, [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - ]), - ], permissions: [ + $database->createCollection(Collection::create(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); $this->fail('Expected DuplicateException for a collection a peer already created'); } catch (DuplicateException) { } @@ -1902,17 +1538,16 @@ public function testCreateCollectionConcurrentlyKeepsPeerData(): void $survivor = $peer->getDocument($collection, 'written'); $this->assertSame('peer', $survivor->getAttribute('name'), 'Peer document was destroyed by the losing creator'); - $metadata = $peer->getCollection($collection); - $this->assertFalse($metadata->isEmpty(), 'Peer collection metadata was destroyed by the losing creator'); + $this->assertNotNull($peer->findCollection($collection), 'Peer collection metadata was destroyed by the losing creator'); // The loser's cache still held the collection as missing from the read // it took before the peer committed, and the peer's purge cannot reach // this instance. Losing the race has to clear it, or the collection // stays invisible here until the entry expires. - $this->assertFalse($database->getCollection($collection)->isEmpty(), 'Losing creator kept a stale empty collection cached'); + $this->assertNotNull($database->findCollection($collection), 'Losing creator kept a stale empty collection cached'); $this->assertSame('peer', $database->getDocument($collection, 'written')->getAttribute('name')); - $this->assertTrue($database->deleteCollection($collection)); + $database->deleteCollection($collection); } /** @@ -1925,19 +1560,14 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSharedTables()) { + if ($database->getAdapter()->hasSharedTables()) { $this->expectNotToPerformAssertions(); return; } $collection = 'preCommitCreate'; - $name = new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - ]); + $name = Attribute::string(key: 'name', size: 128); $database->getAdapter()->createCollection($collection, [$name], []); @@ -1945,7 +1575,7 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void '$id' => $collection, '$collection' => Database::METADATA, 'name' => $collection, - 'attributes' => [$name], + 'attributes' => [$name->toDocument()], 'indexes' => [], 'documentSecurity' => true, '$permissions' => [ @@ -1957,32 +1587,681 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void ]); $database->getAdapter()->createDocument($schema, new Document([ - '$id' => ID::custom('written'), + '$id' => Id::custom('written'), '$permissions' => [Permission::read(Role::any())], 'name' => 'peer', ])); try { - $database->createCollection($collection, [$name], permissions: [ + $database->createCollection(Collection::create(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); + $this->fail('Expected DuplicateException for a collection this process did not create'); } catch (DuplicateException) { - // SQL adapters report the existing table as Duplicate. Mongo's - // createCollection is idempotent, so this process continues and - // claims metadata. Either way the physical collection must stay. } + $this->assertNull($database->findCollection($collection), 'A collection this process did not create was adopted'); $this->assertSame( 'peer', $database->getAdapter()->getDocument($schema, 'written')->getAttribute('name'), 'Physical collection was dropped while metadata was still uncommitted' ); + $database->getAdapter()->deleteCollection($collection); + } + + public function testCollectionNotFound(): void + { + $database = $this->getDatabase(); + + try { + $database->find('not_exist', []); + $this->fail('Failed to throw Exception'); + } catch (Exception $e) { + $this->assertInstanceOf(NotFoundException::class, $e); + $this->assertSame('Collection not found', $e->getMessage()); + } + + try { + $database->count('not_exist'); + $this->fail('Failed to throw Exception'); + } catch (Exception $e) { + $this->assertInstanceOf(NotFoundException::class, $e); + $this->assertSame('Collection not found', $e->getMessage()); + } + + try { + $database->sum('not_exist', 'value'); + $this->fail('Failed to throw Exception'); + } catch (Exception $e) { + $this->assertInstanceOf(NotFoundException::class, $e); + $this->assertSame('Collection not found', $e->getMessage()); + } + + try { + $database->getAuthorization()->skip(fn () => $database->count('not_exist')); + $this->fail('Failed to throw Exception'); + } catch (Exception $e) { + $this->assertInstanceOf(NotFoundException::class, $e); + $this->assertSame('Collection not found', $e->getMessage()); + } + } + + public function testUpdateDeleteCollectionNotFound(): void + { + $database = $this->getDatabase(); + + try { + $database->deleteCollection('not_found'); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(NotFoundException::class, $e); + $this->assertSame('Collection not found', $e->getMessage()); + } + + try { + $database->updateCollection('not_found', new CollectionUpdate(permissions: [], documentSecurity: true)); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(NotFoundException::class, $e); + $this->assertSame('Collection not found', $e->getMessage()); + } + } + + public function testCollectionUpdate(): void + { + $database = $this->getDatabase(); + + $collection = $database->createCollection(Collection::create(id: 'collectionUpdate', permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], documentSecurity: false)); + + $this->assertSame('collectionUpdate', $collection->getId()); + + $collection = $database->getCollection('collectionUpdate'); + + $this->assertFalse($collection->getAttribute('documentSecurity')); + $this->assertCount(4, $collection->getPermissions()); + + $collection = $database->updateCollection('collectionUpdate', new CollectionUpdate(permissions: [], documentSecurity: true)); + + $this->assertTrue($collection->getAttribute('documentSecurity')); + $this->assertSame([], $collection->getPermissions()); + + $collection = $database->getCollection('collectionUpdate'); + + $this->assertTrue($collection->getAttribute('documentSecurity')); + $this->assertSame([], $collection->getPermissions()); + + $database->deleteCollection('collectionUpdate'); + } + + public function testCreateCollectionValidator(): void + { + $database = $this->getDatabase(); + + $collections = [ + 'validatorTest', + 'validator-test', + 'validator_test', + 'validator.test', + ]; + + $attributes = [ + Attribute::string(key: 'attribute1', size: 2500), + Attribute::integer(key: 'attribute-2'), + Attribute::boolean(key: 'attribute_3'), + Attribute::boolean(key: 'attribute.4'), + Attribute::string(key: 'attribute5', size: 2500), + ]; + + $indexes = [ + Index::key(key: 'index1', attributes: ['attribute1'], lengths: [256], orders: [OrderDirection::Asc]), + Index::key(key: 'index-2', attributes: ['attribute-2'], orders: [OrderDirection::Asc]), + Index::key(key: 'index_3', attributes: ['attribute_3'], orders: [OrderDirection::Asc]), + Index::key(key: 'index.4', attributes: ['attribute.4'], orders: [OrderDirection::Asc]), + Index::key(key: 'index_2_attributes', attributes: ['attribute1', 'attribute5'], lengths: [200, 300], orders: [OrderDirection::Desc]), + ]; + + foreach ($collections as $id) { + $collection = $database->createCollection(Collection::create(id: $id, attributes: $attributes, indexes: $indexes)); + + $this->assertSame($id, $collection->getId()); + + $this->assertCount(5, $collection->attributes()); + $this->assertSame('attribute1', $collection->attributes()[0]->key); + $this->assertSame(ColumnType::String, $collection->attributes()[0]->type); + $this->assertSame('attribute-2', $collection->attributes()[1]->key); + $this->assertSame(ColumnType::Integer, $collection->attributes()[1]->type); + $this->assertSame('attribute_3', $collection->attributes()[2]->key); + $this->assertSame(ColumnType::Boolean, $collection->attributes()[2]->type); + $this->assertSame('attribute.4', $collection->attributes()[3]->key); + $this->assertSame(ColumnType::Boolean, $collection->attributes()[3]->type); + + $this->assertCount(5, $collection->indexes()); + $this->assertSame('index1', $collection->indexes()[0]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[0]->type); + $this->assertSame('index-2', $collection->indexes()[1]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[1]->type); + $this->assertSame('index_3', $collection->indexes()[2]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[2]->type); + $this->assertSame('index.4', $collection->indexes()[3]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[3]->type); + + $database->deleteCollection($id); + } + } + + public function testMetadata(): void + { + $database = $this->getDatabase(); + + $database->setMetadata('key', 'value'); + + $database->createCollection(Collection::create(id: 'testers')); + + $this->assertSame(['key' => 'value'], $database->getMetadata()); + + $database->resetMetadata(); + + $this->assertSame([], $database->getMetadata()); + + $database->deleteCollection('testers'); + } + + public function testPurgeCollectionCache(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: 'purgeCache')); + + $database->createAttribute('purgeCache', Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute('purgeCache', Attribute::integer(key: 'age', required: true)); + + $database->createDocument('purgeCache', new Document([ + '$id' => 'doc1', + 'name' => 'Richard', + 'age' => 15, + '$permissions' => [ + Permission::read(Role::any()), + ], + ])); + + $document = $database->getDocument('purgeCache', 'doc1'); + + $this->assertSame('Richard', $document->getAttribute('name')); + $this->assertSame(15, $document->getAttribute('age')); + + $database->deleteAttribute('purgeCache', 'age'); + + $document = $database->getDocument('purgeCache', 'doc1'); + $this->assertSame('Richard', $document->getAttribute('name')); + $this->assertArrayNotHasKey('age', $document); + + $database->createAttribute('purgeCache', Attribute::integer(key: 'age', required: true)); + + $document = $database->getDocument('purgeCache', 'doc1'); + $this->assertSame('Richard', $document->getAttribute('name')); + $this->assertArrayHasKey('age', $document); + + $database->deleteCollection('purgeCache'); + } + + public function testRowSizeToLarge(): void + { + $database = $this->getDatabase(); + + if ($database->getAdapter()->limits()->documentSize === 0) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection1 = $database->createCollection(Collection::create(id: 'row_size_1')); + $collection2 = $database->createCollection(Collection::create(id: 'row_size_2')); + + $database->createAttribute($collection1->getId(), Attribute::string(key: 'attr_1', size: 16000, required: true)); + + try { + $database->createAttribute($collection1->getId(), Attribute::string(key: 'attr_2', size: Database::LENGTH_KEY, required: true)); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(LimitException::class, $e); + } + + if ($database->getAdapter()->hasFeature(Feature\Relationships::class)) { + try { + $database->createRelationship($collection2->getId(), Relationship::oneToOne( + relatedCollection: $collection1->getId(), + twoWay: true, + )); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(LimitException::class, $e, 'A relationship column takes the length of a key and must respect the row size limit'); + } + + try { + $database->createRelationship($collection1->getId(), Relationship::oneToOne( + relatedCollection: $collection2->getId(), + twoWay: true, + )); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(LimitException::class, $e); + } + } + + $database->deleteCollection('row_size_1'); + $database->deleteCollection('row_size_2'); + } + + public function testCollectionWhoseTableIsGoneIsNotFoundAndCanBeDeleted(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->hasFeature(Feature\RawQuery::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'tableGone'; + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::string(key: 'name', size: 64)], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ])); + + $this->dropCollectionTable($database, $collection); + + try { + $database->find($collection); + $this->fail('Expected NotFoundException for a collection whose table is gone'); + } catch (NotFoundException $e) { + $this->assertSame('Collection not found', $e->getMessage()); + } + + $database->deleteCollection($collection); + $this->assertNull($database->findCollection($collection)); + + if ($adapter instanceof Postgres || $adapter instanceof SQLite) { + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::read(Role::any())])); + $database->deleteCollection($collection); + } + } + + public function testIndexOnAColumnTheTableLacksIsAttributeNotFound(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->hasFeature(Feature\RawQuery::class) || $this->engineIs(SQLite::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'indexDrifted'; + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::string(key: 'name', size: 64)], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ])); + + $this->deleteColumn($collection, 'name'); + + try { + $database->createIndex($collection, Index::key(key: 'nameIndex', attributes: ['name'])); + $this->fail('Expected NotFoundException for an index on a column the table lacks'); + } catch (NotFoundException $e) { + $this->assertSame('Attribute not found', $e->getMessage()); + } finally { + $database->deleteCollection($collection); + } + } + + public function testPostgresAggregateOverATypeWithoutTheFunctionIsAQueryError(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $this->engineIs(Postgres::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'maxOverBoolean'; + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::boolean(key: 'active')], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ])); + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'active' => true, + ])); + + try { + $database->skipValidation(fn () => $database->aggregate($collection, [Query::max('active', 'most')])); + $this->fail('Expected QueryException for max() over a boolean attribute'); + } catch (QueryException $e) { + $this->assertSame('Query applies a function or operator the attribute type does not support', $e->getMessage()); + } finally { + $database->deleteCollection($collection); + } + } + + public function testMongoIncrementOfATextValueIsAnInvalidOperation(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $this->engineIs(Mongo::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'incrementText'; + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::string(key: 'name', size: 64)], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ])); + $document = $database->createDocument($collection, new Document([ + '$id' => 'text', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'plain', + ])); + + try { + $adapter->increaseDocumentAttribute($database->getCollection($collection), 'text', 'name', 1, $document->getUpdatedAt() ?? ''); + $this->fail('Expected TypeException for an increment of a text value'); + } catch (TypeException $e) { + $this->assertSame('Invalid operation', $e->getMessage()); + } finally { + $database->deleteCollection($collection); + } + } + + private function dropCollectionTable(Database $database, string $collection): void + { + $table = $database->getNamespace().'_'.$collection; + if (! $database->getAdapter() instanceof SQLite) { + $table = $database->getDatabase().'.'.$table; + } + + $database->getAuthorization()->skip(fn () => $database->schema()->table($table)->drop()->execute()); + } + + public function testAnalyzeCollectionRecordsStatisticsForTheTableAndItsPermissions(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $this->engineIs(SQL::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'analyzed'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: true, + )); + + try { + for ($number = 0; $number < 20; $number++) { + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::user('user'.$number))], + 'name' => 'name'.($number % 4), + ])); + } + + $this->assertTrue($database->analyzeCollection($collection)); + + $tables = [$database->getNamespace().'_'.$collection, $database->getNamespace().'_'.$collection.'_perms']; + + if ($adapter instanceof Postgres) { + $rows = $adapter->rawQuery( + 'SELECT DISTINCT tablename FROM pg_stats WHERE schemaname = ? AND tablename IN (?, ?) ORDER BY tablename', + [$database->getDatabase(), ...$tables], + ); + $this->assertSame($tables, \array_map(static fn (Document $row): mixed => $row->getAttribute('tablename'), $rows)); + } + + if ($adapter instanceof SQLite) { + $rows = $adapter->rawQuery('SELECT DISTINCT tbl FROM sqlite_stat1 WHERE tbl IN (?, ?) ORDER BY tbl', $tables); + $this->assertSame($tables, \array_map(static fn (Document $row): mixed => $row->getAttribute('tbl'), $rows)); + } + } finally { + $database->deleteCollection($collection); + } + } + + public function testRewritingADatetimeColumnKeepsItsValues(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collection = 'datetimeRewrite'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::datetime(key: 'at')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + try { + $database->createDocument($collection, new Document([ + '$id' => 'moment', + 'at' => '2024-05-06T07:08:09.123+00:00', + ])); + + $database->updateAttribute($collection, 'at', new AttributeUpdate(key: 'happenedAt')); + $this->assertSame('2024-05-06T07:08:09.123+00:00', $database->getDocument($collection, 'moment')->getAttribute('happenedAt')); + + $database->updateAttribute($collection, 'happenedAt', new AttributeUpdate(type: ColumnType::Datetime, required: true)); + $this->assertSame('2024-05-06T07:08:09.123+00:00', $database->getDocument($collection, 'moment')->getAttribute('happenedAt')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testIndexOnAnObjectPathAttribute(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter instanceof Postgres || ! $adapter->supports(Capability::Objects)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'objectPathIndex'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::object(key: 'data'), Attribute::string(key: 'status', size: 32)], + indexes: [Index::key(key: 'countryfirst', attributes: ['data.country', 'status'], orders: [OrderDirection::Desc, null])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + try { + $rows = $adapter->rawQuery( + 'SELECT indexdef FROM pg_indexes WHERE schemaname = ? AND tablename = ? AND indexname LIKE ?', + [$database->getDatabase(), $database->getNamespace().'_'.$collection, '%\_countryfirst'], + ); + $this->assertCount(1, $rows); + $definition = $rows[0]->getAttribute('indexdef'); + $this->assertIsString($definition); + $this->assertStringContainsString("((data ->> 'country'::text)) DESC, status)", $definition); + + $database->createDocument($collection, new Document([ + '$id' => 'nz', + 'data' => ['country' => 'NZ'], + 'status' => 'active', + ])); + $this->assertSame(['country' => 'NZ'], $database->getDocument($collection, 'nz')->getAttribute('data')); + } finally { $database->deleteCollection($collection); - } catch (\Throwable) { - $database->getAdapter()->deleteCollection($collection); + } + } + + public function testDeletingACollectionWhoseTableIsGoneDropsItsPermissionsTable(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $this->engineIs(MariaDB::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'mainTableGone'; + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::read(Role::any())])); + $this->assertTrue($database->collectionExists(Storage::permissionsTable($collection))); + + $table = $database->getDatabase().'.'.$database->getNamespace().'_'.$collection; + $database->getAuthorization()->skip(fn () => $database->schema()->table($table)->drop()->execute()); + + $database->deleteCollection($collection); + $this->assertNull($database->findCollection($collection)); + $this->assertFalse($database->collectionExists(Storage::permissionsTable($collection)), 'The permissions table of a collection whose table was gone was left behind'); + } + + public function testPostgresSharedTablesRefuseAnotherTenantsColumnOfAnotherType(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->hasSharedTables() || ! $this->engineIs(Postgres::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getIdAttributeType() === ColumnType::Integer; + $first = $integerTenants ? 401 : 'tenant_401'; + $second = $integerTenants ? 402 : 'tenant_402'; + $collection = 'sharedColumnType'; + $definition = Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ]); + + try { + $database->setTenant($first); + $database->createCollection($definition); + $database->createAttribute($collection, Attribute::integer(key: 'age')); + $database->createDocument($collection, new Document(['$id' => 'first', 'age' => 7])); + + $database->setTenant($second); + $database->createCollection($definition); + + try { + $database->createAttribute($collection, Attribute::string(key: 'age', size: 64)); + $this->fail('A column another tenant stores with another type must be refused'); + } catch (DuplicateException $e) { + $this->assertSame('Attribute exists in the shared table with another type', $e->getMessage()); + } + + try { + $database->createAttributes($collection, [Attribute::string(key: 'label', size: 16), Attribute::string(key: 'age', size: 64)]); + $this->fail('A batch holding a column another tenant stores with another type must be refused'); + } catch (DuplicateException $e) { + $this->assertSame('Attribute exists in the shared table with another type', $e->getMessage()); + } + + $stored = $database->getCollection($collection); + $this->assertSame([], $stored->attributes()); + + $database->createAttribute($collection, Attribute::integer(key: 'age')); + $stored = $database->getCollection($collection); + $this->assertSame(['age'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $stored->attributes())); + + $database->setTenant($first); + $this->assertSame(7, $database->getDocument($collection, 'first')->getAttribute('age')); + } finally { + foreach ([$second, $first] as $tenant) { + try { + $database->setTenant($tenant)->deleteCollection($collection); + } catch (Throwable) { + } + } + $database->setTenant($originalTenant); + } + } + + public function testPostgresSharedTablesReuseAnotherTenantsColumnOfTheSameType(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->hasSharedTables() || ! $this->engineIs(Postgres::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getIdAttributeType() === ColumnType::Integer; + $tenants = $integerTenants ? [411, 412] : ['tenant_411', 'tenant_412']; + $collection = 'sharedColumnSameType'; + $definition = Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ]); + + try { + foreach ($tenants as $tenant) { + $database->setTenant($tenant); + $database->createCollection($definition); + $database->createAttribute($collection, Attribute::integer(key: 'age')); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 64)); + $database->createAttributes($collection, [ + Attribute::datetime(key: 'seen'), + Attribute::string(key: 'bio', size: 20000), + ]); + + $this->assertSame(['age', 'name', 'seen', 'bio'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $database->getCollection($collection)->attributes())); + + $database->createDocument($collection, new Document([ + '$id' => 'own', + 'age' => 7, + 'name' => 'tenant '.$tenant, + 'seen' => '2024-05-06T07:08:09.123+00:00', + 'bio' => 'about '.$tenant, + ])); + } + + foreach ($tenants as $tenant) { + $database->setTenant($tenant); + $document = $database->getDocument($collection, 'own'); + $this->assertSame(7, $document->getAttribute('age')); + $this->assertSame('tenant '.$tenant, $document->getAttribute('name')); + $this->assertSame('2024-05-06T07:08:09.123+00:00', $document->getAttribute('seen')); + $this->assertSame('about '.$tenant, $document->getAttribute('bio')); + } + } finally { + foreach (\array_reverse($tenants) as $tenant) { + try { + $database->setTenant($tenant)->deleteCollection($collection); + } catch (Throwable) { + } + } + $database->setTenant($originalTenant); } } } diff --git a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php index 9953e73e22..9c9260b963 100644 --- a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php +++ b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php @@ -2,103 +2,74 @@ namespace Tests\E2E\Adapter\Scopes; -use Utopia\Database\Database; +use Tests\E2E\Adapter\Support\Post; +use Tests\E2E\Adapter\Support\User; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; -// Test custom document classes -class TestUser extends Document +trait CustomDocumentTypeTests { - public function getEmail(): string + public function testSetDocumentType(): void { - return $this->getAttribute('email', ''); - } + $database = $this->getDatabase(); - public function getName(): string - { - return $this->getAttribute('name', ''); - } + $database->setDocumentType('users', User::class); - public function isActive(): bool - { - return $this->getAttribute('status') === 'active'; - } -} + $this->assertSame(User::class, $database->getDocumentType('users')); -class TestPost extends Document -{ - public function getTitle(): string - { - return $this->getAttribute('title', ''); - } - - public function getContent(): string - { - return $this->getAttribute('content', ''); - } -} + $database->clearDocumentType('users'); -trait CustomDocumentTypeTests -{ - public function testSetDocumentType(): void - { - /** @var Database $database */ - $database = static::getDatabase(); + $database->setDocumentType('users', User::class); + $database->setDocumentType('posts', Post::class); - $database->setDocumentType('users', TestUser::class); + $this->assertSame(User::class, $database->getDocumentType('users')); + $this->assertSame(Post::class, $database->getDocumentType('posts')); - $this->assertEquals( - TestUser::class, - $database->getDocumentType('users') - ); + $database->clearDocumentTypes(); - // Cleanup - $database->clearDocumentType('users'); + $this->assertNull($database->getDocumentType('users')); + $this->assertNull($database->getDocumentType('posts')); } public function testGetDocumentTypeReturnsNull(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); $this->assertNull($database->getDocumentType('nonexistent_collection')); - - // No cleanup needed - no types were set } public function testSetDocumentTypeWithInvalidClass(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); $this->expectException(DatabaseException::class); $this->expectExceptionMessage('does not exist'); - // @phpstan-ignore-next-line - Testing with invalid class name $database->setDocumentType('users', 'NonExistentClass'); - } public function testSetDocumentTypeWithNonDocumentClass(): void + } + + public function testSetDocumentTypeWithNonDocumentClass(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); $this->expectException(DatabaseException::class); $this->expectExceptionMessage('must extend'); - // @phpstan-ignore-next-line - Testing with non-Document class $database->setDocumentType('users', \stdClass::class); } public function testClearDocumentType(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - $database->setDocumentType('users', TestUser::class); - $this->assertEquals(TestUser::class, $database->getDocumentType('users')); + $database->setDocumentType('users', User::class); + $this->assertSame(User::class, $database->getDocumentType('users')); $database->clearDocumentType('users'); $this->assertNull($database->getDocumentType('users')); @@ -106,16 +77,15 @@ public function testClearDocumentType(): void public function testClearAllDocumentTypes(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - $database->setDocumentType('users', TestUser::class); - $database->setDocumentType('posts', TestPost::class); + $database->setDocumentType('users', User::class); + $database->setDocumentType('posts', Post::class); - $this->assertEquals(TestUser::class, $database->getDocumentType('users')); - $this->assertEquals(TestPost::class, $database->getDocumentType('posts')); + $this->assertSame(User::class, $database->getDocumentType('users')); + $this->assertSame(Post::class, $database->getDocumentType('posts')); - $database->clearAllDocumentTypes(); + $database->clearDocumentTypes(); $this->assertNull($database->getDocumentType('users')); $this->assertNull($database->getDocumentType('posts')); @@ -123,148 +93,125 @@ public function testClearAllDocumentTypes(): void public function testMethodChaining(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - $result = $database->setDocumentType('users', TestUser::class); + $result = $database->setDocumentType('users', User::class); - $this->assertInstanceOf(Database::class, $result); + $this->assertSame($database, $result); $database - ->setDocumentType('users', TestUser::class) - ->setDocumentType('posts', TestPost::class); + ->setDocumentType('users', User::class) + ->setDocumentType('posts', Post::class); - $this->assertEquals(TestUser::class, $database->getDocumentType('users')); - $this->assertEquals(TestPost::class, $database->getDocumentType('posts')); + $this->assertSame(User::class, $database->getDocumentType('users')); + $this->assertSame(Post::class, $database->getDocumentType('posts')); - // Cleanup to prevent test pollution - $database->clearAllDocumentTypes(); + $database->clearDocumentTypes(); } public function testCustomDocumentTypeWithGetDocument(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - // Create collection - $database->createCollection('customUsers', permissions: [ + $database->createCollection(Collection::create(id: 'customUsers', attributes: [ + Attribute::string(key: 'email', size: 255, required: true), + Attribute::string(key: 'name', size: 255, required: true), + Attribute::string(key: 'status', size: 50, required: true), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), - ]); - - $database->createAttribute('customUsers', 'email', Database::VAR_STRING, 255, true); - $database->createAttribute('customUsers', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('customUsers', 'status', Database::VAR_STRING, 50, true); + ])); - $database->setDocumentType('customUsers', TestUser::class); + $database->setDocumentType('customUsers', User::class); - /** @var TestUser $created */ $created = $database->createDocument('customUsers', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), 'email' => 'test@example.com', 'name' => 'Test User', 'status' => 'active', '$permissions' => [Permission::read(Role::any())], ])); - // Verify it's a TestUser instance - $this->assertInstanceOf(TestUser::class, $created); - $this->assertEquals('test@example.com', $created->getEmail()); - $this->assertEquals('Test User', $created->getName()); + $this->assertInstanceOf(User::class, $created); + $this->assertSame('test@example.com', $created->getEmail()); + $this->assertSame('Test User', $created->getName()); $this->assertTrue($created->isActive()); - // Get document and verify type - /** @var TestUser $fetched */ $fetched = $database->getDocument('customUsers', $created->getId()); - $this->assertInstanceOf(TestUser::class, $fetched); - $this->assertEquals('test@example.com', $fetched->getEmail()); + $this->assertInstanceOf(User::class, $fetched); + $this->assertSame('test@example.com', $fetched->getEmail()); $this->assertTrue($fetched->isActive()); - // Cleanup $database->deleteCollection('customUsers'); $database->clearDocumentType('customUsers'); } public function testCustomDocumentTypeWithFind(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - // Create collection - $database->createCollection('customPosts', permissions: [ + $database->createCollection(Collection::create(id: 'customPosts', attributes: [ + Attribute::string(key: 'title', size: 255, required: true), + Attribute::string(key: 'content', size: 5000, required: true), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); - - $database->createAttribute('customPosts', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('customPosts', 'content', Database::VAR_STRING, 5000, true); + ])); - // Register custom type - $database->setDocumentType('customPosts', TestPost::class); + $database->setDocumentType('customPosts', Post::class); - // Create multiple documents - $post1 = $database->createDocument('customPosts', new Document([ - '$id' => ID::unique(), + $database->createDocument('customPosts', new Document([ + '$id' => Id::unique(), 'title' => 'First Post', 'content' => 'This is the first post', '$permissions' => [Permission::read(Role::any())], ])); - $post2 = $database->createDocument('customPosts', new Document([ - '$id' => ID::unique(), + $database->createDocument('customPosts', new Document([ + '$id' => Id::unique(), 'title' => 'Second Post', 'content' => 'This is the second post', '$permissions' => [Permission::read(Role::any())], ])); - // Find documents - /** @var TestPost[] $posts */ $posts = $database->find('customPosts', [Query::limit(10)]); $this->assertCount(2, $posts); - $this->assertInstanceOf(TestPost::class, $posts[0]); - $this->assertInstanceOf(TestPost::class, $posts[1]); - $this->assertEquals('First Post', $posts[0]->getTitle()); - $this->assertEquals('Second Post', $posts[1]->getTitle()); + $this->assertInstanceOf(Post::class, $posts[0]); + $this->assertInstanceOf(Post::class, $posts[1]); + $this->assertSame('First Post', $posts[0]->getTitle()); + $this->assertSame('Second Post', $posts[1]->getTitle()); - // Cleanup $database->deleteCollection('customPosts'); $database->clearDocumentType('customPosts'); } public function testCustomDocumentTypeWithUpdateDocument(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - // Create collection - $database->createCollection('customUsersUpdate', permissions: [ + $database->createCollection(Collection::create(id: 'customUsersUpdate', attributes: [ + Attribute::string(key: 'email', size: 255, required: true), + Attribute::string(key: 'name', size: 255, required: true), + Attribute::string(key: 'status', size: 50, required: true), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - ]); - - $database->createAttribute('customUsersUpdate', 'email', Database::VAR_STRING, 255, true); - $database->createAttribute('customUsersUpdate', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('customUsersUpdate', 'status', Database::VAR_STRING, 50, true); + ])); - // Register custom type - $database->setDocumentType('customUsersUpdate', TestUser::class); + $database->setDocumentType('customUsersUpdate', User::class); - // Create document - /** @var TestUser $created */ $created = $database->createDocument('customUsersUpdate', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), 'email' => 'original@example.com', 'name' => 'Original Name', 'status' => 'active', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - // Update document - /** @var TestUser $updated */ $updated = $database->updateDocument('customUsersUpdate', $created->getId(), new Document([ '$id' => $created->getId(), 'email' => 'updated@example.com', @@ -272,42 +219,34 @@ public function testCustomDocumentTypeWithUpdateDocument(): void 'status' => 'inactive', ])); - // Verify it's still TestUser and has updated values - $this->assertInstanceOf(TestUser::class, $updated); - $this->assertEquals('updated@example.com', $updated->getEmail()); - $this->assertEquals('Updated Name', $updated->getName()); + $this->assertInstanceOf(User::class, $updated); + $this->assertSame('updated@example.com', $updated->getEmail()); + $this->assertSame('Updated Name', $updated->getName()); $this->assertFalse($updated->isActive()); - // Cleanup $database->deleteCollection('customUsersUpdate'); $database->clearDocumentType('customUsersUpdate'); } public function testDefaultDocumentForUnmappedCollection(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - // Create collection without custom type - $database->createCollection('unmappedCollection', permissions: [ + $database->createCollection(Collection::create(id: 'unmappedCollection', attributes: [ + Attribute::string(key: 'data', size: 255, required: true), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); - - $database->createAttribute('unmappedCollection', 'data', Database::VAR_STRING, 255, true); + ])); - // Create document $created = $database->createDocument('unmappedCollection', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), 'data' => 'test data', '$permissions' => [Permission::read(Role::any())], ])); - // Should be regular Document, not custom type - $this->assertInstanceOf(Document::class, $created); - $this->assertNotInstanceOf(TestUser::class, $created); + $this->assertSame(Document::class, $created::class); - // Cleanup $database->deleteCollection('unmappedCollection'); } } diff --git a/tests/e2e/Adapter/Scopes/DatabaseTests.php b/tests/e2e/Adapter/Scopes/DatabaseTests.php new file mode 100644 index 0000000000..e41280d261 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/DatabaseTests.php @@ -0,0 +1,297 @@ +getDatabase(); + if (! $this->renamesDatabases($database)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $original = $database->getDatabase(); + [$source, $target] = $this->renameNames(); + + try { + $this->createRenameFixture($database, $source); + $relationships = $database->getAdapter()->hasFeature(Feature\Relationships::class); + + $this->assertTrue($database->update($source, $target)); + + $this->assertSame($target, $database->getDatabase(), 'The renamed current database stays current'); + $this->assertFalse($database->exists($source)); + $this->assertTrue($database->exists($target)); + + $books = $database->getCollection(self::RENAME_BOOKS); + $this->assertContains('byTitle', \array_map(static fn (Index $index): string => $index->key, $books->indexes())); + + $authorization = $database->getAuthorization(); + $author = $authorization->skip(fn (): Document => $database->getDocument(self::RENAME_AUTHORS, 'tolkien')); + $this->assertSame('Tolkien', $author->getAttribute('name')); + if ($relationships) { + $this->assertSame(['hobbit', 'silmarillion'], $this->relatedRenameBookIds($author)); + } + + $hidden = $this->visibleRenameBookIds($database); + $this->assertSame([], $hidden, 'Documents without a read permission for the caller stay hidden'); + $authorization->addRole(Role::user('reader')->toString()); + try { + $visible = $this->visibleRenameBookIds($database); + } finally { + $authorization->removeRole(Role::user('reader')->toString()); + } + $this->assertSame(['hobbit'], $visible, 'Document permissions move with the documents'); + + try { + $authorization->skip(fn (): Document => $database->createDocument(self::RENAME_BOOKS, new Document([ + '$id' => 'copy', + 'title' => 'The Hobbit', + '$permissions' => [], + ]))); + $this->fail('The unique index must move with its table'); + } catch (UniqueException) { + } + } finally { + $this->dropRenameDatabases($database, $original, $source, $target); + } + } + + public function testUpdateToAnExistingDatabaseThrowsDuplicate(): void + { + $database = $this->getDatabase(); + if (! $this->renamesDatabases($database)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $original = $database->getDatabase(); + [$source, $target] = $this->renameNames(); + + try { + $this->createRenameFixture($database, $source); + $database->setDatabase($target)->create(); + + try { + $database->update($source, $target); + $this->fail('Renaming onto an existing database must throw'); + } catch (DuplicateException $error) { + $this->assertNotInstanceOf(UniqueException::class, $error); + } + + $this->assertNull($database->findCollection(self::RENAME_AUTHORS), 'Nothing moved into the existing database'); + $database->setDatabase($source); + $this->assertSame('Tolkien', $database->getAuthorization()->skip(fn (): Document => $database->getDocument(self::RENAME_AUTHORS, 'tolkien'))->getAttribute('name')); + } finally { + $this->dropRenameDatabases($database, $original, $source, $target); + } + } + + public function testUpdateOfAMissingDatabaseThrowsNotFound(): void + { + $database = $this->getDatabase(); + if (! $this->renamesDatabases($database)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$source, $target] = $this->renameNames(); + + $this->expectException(NotFoundException::class); + $database->update($source, $target); + } + + public function testUpdateLeavesNoStaleMetadata(): void + { + $database = $this->getDatabase(); + if (! $this->renamesDatabases($database)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $original = $database->getDatabase(); + [$source, $target] = $this->renameNames(); + + try { + $this->createRenameFixture($database, $source); + $this->assertNotNull($database->findCollection(self::RENAME_AUTHORS)); + $database->getAuthorization()->skip(fn (): Document => $database->getDocument(self::RENAME_AUTHORS, 'tolkien')); + $database->setDatabase($target); + $this->assertNull($this->findCollectionIn($database, self::RENAME_AUTHORS)); + + $database->update($source, $target); + + $this->assertNotNull($database->findCollection(self::RENAME_AUTHORS), 'A miss cached under the new name must not hide the moved collection'); + $database->setDatabase($source)->create(); + $this->assertNull($database->findCollection(self::RENAME_AUTHORS), 'A definition cached under the old name must not outlive the rename'); + } finally { + $this->dropRenameDatabases($database, $original, $source, $target); + } + } + + public function testUpdateIsRefusedUnderSharedTables(): void + { + $database = $this->getDatabase(); + if (! $database->hasSharedTables()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $current = $database->getDatabase(); + + try { + $database->update($current, $current.'Renamed'); + $this->fail('A rename under shared tables must be refused'); + } catch (DatabaseException $error) { + $this->assertNotInstanceOf(DuplicateException::class, $error); + $this->assertNotInstanceOf(NotFoundException::class, $error); + } + + $this->assertSame($current, $database->getDatabase()); + $this->assertFalse($database->exists($current.'Renamed')); + } + + /** + * A database that does not exist holds no collection: SQL engines report its missing table instead of a miss. + */ + private function findCollectionIn(Database $database, string $collection): ?Collection + { + try { + return $database->findCollection($collection); + } catch (NotFoundException) { + return null; + } + } + + /** + * @return list + */ + private function relatedRenameBookIds(Document $author): array + { + $books = $author->getAttribute('books', []); + $ids = []; + foreach (\is_array($books) ? $books : [] as $book) { + $ids[] = $book instanceof Document ? $book->getId() : (\is_string($book) ? $book : ''); + } + \sort($ids); + + return $ids; + } + + /** + * @return list + */ + private function visibleRenameBookIds(Database $database): array + { + $ids = []; + foreach ($database->find(self::RENAME_BOOKS) as $book) { + $ids[] = $book->getId(); + } + + return $ids; + } + + /** + * SQLite keeps no database name, so its rename moves nothing and its exists() is always false: it is skipped by + * name, not only through the Schemas capability it does not declare, and covered by unit tests. + */ + private function renamesDatabases(Database $database): bool + { + return ! $database->hasSharedTables() + && $database->getAdapter()->supports(Capability::Schemas) + && ! $this->engineIs(SQLite::class); + } + + /** + * @return array{string, string} + */ + private function renameNames(): array + { + $suffix = \substr(\uniqid(), -6); + + return [$this->testDatabase.'_from'.$suffix, $this->testDatabase.'_to'.$suffix]; + } + + private function createRenameFixture(Database $database, string $name): void + { + $database->setDatabase($name)->create(); + $relationships = $database->getAdapter()->hasFeature(Feature\Relationships::class); + + $database->getAuthorization()->skip(function () use ($database, $relationships): void { + $database->createCollection(Collection::create( + id: self::RENAME_AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: self::RENAME_BOOKS, + attributes: [Attribute::string(key: 'title', size: 64)], + indexes: [Index::unique(key: 'byTitle', attributes: ['title'])], + permissions: [], + documentSecurity: true, + )); + if ($relationships) { + $database->createRelationship(self::RENAME_AUTHORS, Relationship::oneToMany( + relatedCollection: self::RENAME_BOOKS, + key: 'books', + twoWay: true, + twoWayKey: 'author', + )); + } + + $database->createDocument(self::RENAME_AUTHORS, new Document(['$id' => 'tolkien', 'name' => 'Tolkien'])); + foreach (['hobbit' => 'The Hobbit', 'silmarillion' => 'The Silmarillion'] as $id => $title) { + $database->createDocument(self::RENAME_BOOKS, new Document([ + '$id' => $id, + 'title' => $title, + ...($relationships ? ['author' => 'tolkien'] : []), + '$permissions' => $id === 'hobbit' ? [Permission::read(Role::user('reader'))] : [], + ])); + } + }); + } + + private function dropRenameDatabases(Database $database, string $original, string ...$names): void + { + foreach ($names as $name) { + if ($database->exists($name)) { + $database->delete($name); + } + } + + $database->setDatabase($original); + } +} diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 2b6d378222..1603bc0e9b 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3,10 +3,21 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use PDO; use PDOException; use Throwable; +use Utopia\Cache\Adapter\None as NoneCacheAdapter; +use Utopia\Cache\Cache; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\Mongo; use Utopia\Database\Adapter\SQL; +use Utopia\Database\Attribute; +use Utopia\Database\AttributeUpdate; +use Utopia\Database\Capability; +use Utopia\Database\Collection; +use Utopia\Database\CollectionUpdate; use Utopia\Database\Database; +use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -14,310 +25,420 @@ use Utopia\Database\Exception\Conflict as ConflictException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Filter; +use Utopia\Database\Id; +use Utopia\Database\Index; +use Utopia\Database\IntegerWidth; +use Utopia\Database\PDO as DatabasePDO; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\Role; +use Utopia\Database\SetType; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; trait DocumentTests { - public function testNonUtfChars(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportNonUtfCharacters()) { - $this->expectNotToPerformAssertions(); - return; - } + private static string $moviesCollection = ''; - $database->createCollection(__FUNCTION__); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'title', Database::VAR_STRING, 128, true)); + private static string $documentsCollection = ''; - $nonUtfString = "Hello\x00World\xC3\x28\xFF\xFE\xA0Test\x00End"; + private static string $incDecCollection = ''; - try { - $database->createDocument(__FUNCTION__, new Document([ - 'title' => $nonUtfString, - ])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertTrue($e instanceof CharacterException); + protected function getMoviesCollection(): string + { + if (self::$moviesCollection === '') { + self::$moviesCollection = 'movies_' . uniqid(); } + return self::$moviesCollection; + } - /** - * Convert to UTF-8 and replace invalid bytes with empty string - */ - $nonUtfString = mb_convert_encoding($nonUtfString, 'UTF-8', 'UTF-8'); + protected function getDocumentsCollection(): string + { + if (self::$documentsCollection === '') { + self::$documentsCollection = 'documents_' . uniqid(); + } + return self::$documentsCollection; + } - /** - * Remove null bytes - */ - $nonUtfString = str_replace("\0", '', $nonUtfString); + protected function getIncDecCollection(): string + { + if (self::$incDecCollection === '') { + self::$incDecCollection = 'increase_decrease_' . uniqid(); + } + return self::$incDecCollection; + } - $document = $database->createDocument(__FUNCTION__, new Document([ - 'title' => $nonUtfString, - ])); + private static bool $documentsFixtureInit = false; - $this->assertFalse($document->isEmpty()); - $this->assertEquals('HelloWorld?(???TestEnd', $document->getAttribute('title')); - } + private static ?Document $documentsFixtureDoc = null; - public function testBigintSequence(): void + /** + * Create the $this->getDocumentsCollection() collection with standard attributes and a test document. + * Cached for non-functional mode backward compatibility. + */ + protected function initDocumentsFixture(?string $collection = null): Document { - /** @var Database $database */ + $cache = $collection === null; + if ($cache && self::$documentsFixtureInit && self::$documentsFixtureDoc !== null) { + return clone self::$documentsFixtureDoc; + } + $database = $this->getDatabase(); + $collection ??= $this->getDocumentsCollection(); - $database->createCollection(__FUNCTION__); + $database->createCollection(Collection::create(id: $collection)); - $sequence = 5_000_000_000_000_000; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { - $sequence = '01995753-881b-78cf-9506-2cffecf8f227'; + $database->createAttribute($collection, Attribute::string(key: 'string', size: 128, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'integer_signed', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'integer_unsigned', required: true, signed: false)); + $database->createAttribute($collection, Attribute::integer(key: 'bigint_signed', width: IntegerWidth::Bits64, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'bigint_unsigned', width: IntegerWidth::Bits64, required: true, signed: false)); + $database->createAttribute($collection, Attribute::double(key: 'float_signed', required: true)); + $database->createAttribute($collection, Attribute::double(key: 'float_unsigned', required: true, signed: false)); + $database->createAttribute($collection, Attribute::boolean(key: 'boolean', required: true)); + $database->createAttribute($collection, Attribute::string(key: 'colors', size: 32, required: true, array: true)); + $database->createAttribute($collection, Attribute::string(key: 'empty', size: 32, array: true)); + $database->createAttribute($collection, Attribute::string(key: 'with-dash', size: 128)); + $database->createAttribute($collection, Attribute::id(key: 'id')); + + $sequence = '1000000'; + if ($database->getIdAttributeType() === ColumnType::Uuid7) { + $sequence = '01890dd5-7331-7f3a-9c1b-123456789abc'; } - $document = $database->createDocument(__FUNCTION__, new Document([ - '$sequence' => (string)$sequence, + $document = $database->createDocument($collection, new Document([ '$permissions' => [ Permission::read(Role::any()), + Permission::read(Role::user(Id::custom('1'))), + Permission::read(Role::user(Id::custom('2'))), + Permission::create(Role::any()), + Permission::create(Role::user(Id::custom('1x'))), + Permission::create(Role::user(Id::custom('2x'))), + Permission::update(Role::any()), + Permission::update(Role::user(Id::custom('1x'))), + Permission::update(Role::user(Id::custom('2x'))), + Permission::delete(Role::any()), + Permission::delete(Role::user(Id::custom('1x'))), + Permission::delete(Role::user(Id::custom('2x'))), ], + 'string' => 'text📝', + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, + 'float_signed' => -5.55, + 'float_unsigned' => 5.55, + 'boolean' => true, + 'colors' => ['pink', 'green', 'blue'], + 'empty' => [], + 'with-dash' => 'Works', + 'id' => $sequence, ])); - $this->assertSame((string)$sequence, $document->getSequence()); + if ($cache) { + self::$documentsFixtureInit = true; + self::$documentsFixtureDoc = $document; + } - $document = $database->getDocument(__FUNCTION__, $document->getId()); - $this->assertSame((string)$sequence, $document->getSequence()); + return $document; + } - $document = $database->findOne(__FUNCTION__, [Query::equal('$sequence', [(string)$sequence])]); - $this->assertSame((string)$sequence, $document->getSequence()); + private static bool $moviesFixtureInit = false; - /** - * Query with int $sequence value (supported by SQL adapters, rejected by MongoDB) - */ - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_INTEGER) { - $this->assertTrue($sequence === 5_000_000_000_000_000); - $document = $database->findOne(__FUNCTION__, [Query::equal('$sequence', [$sequence])]); - $this->assertSame((string)$sequence, $document->getSequence()); - } - } + private static ?string $moviesFixtureSequence = null; - public function testCreateDocumentWithBigIntType(): void + /** + * Create the movies collection with standard test data. + */ + protected function initMoviesFixture(): string { - /** @var Database $database */ - $database = $this->getDatabase(); + if (self::$moviesFixtureInit && self::$moviesFixtureSequence !== null) { + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $this->getDatabase()->getAuthorization()->addRole('user:x'); + return self::$moviesFixtureSequence; + } - $database->createCollection(__FUNCTION__); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'bigint_signed', Database::VAR_BIGINT, 0, true)); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'bigint_unsigned', Database::VAR_BIGINT, 0, true, signed: false)); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $this->getDatabase()->getAuthorization()->addRole('user:x'); + $database = $this->getDatabase(); + $collection = $this->getMoviesCollection(); - $document = $database->createDocument(__FUNCTION__, new Document([ - '$id' => 'bigint-type-doc', - '$permissions' => [Permission::read(Role::any())], - 'bigint_signed' => -Database::MAX_BIG_INT, - 'bigint_unsigned' => Database::MAX_BIG_INT, + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::update(Role::users()), ])); - $this->assertIsInt($document->getAttribute('bigint_signed')); - $this->assertEquals(-Database::MAX_BIG_INT, $document->getAttribute('bigint_signed')); - $this->assertIsInt($document->getAttribute('bigint_unsigned')); - $this->assertEquals(Database::MAX_BIG_INT, $document->getAttribute('bigint_unsigned')); - - $results = $database->find(__FUNCTION__, [ - Query::equal('bigint_unsigned', [Database::MAX_BIG_INT]) - ]); - $this->assertCount(1, $results); - $this->assertEquals('bigint-type-doc', $results[0]->getId()); - } + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'director', size: 128, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'year', required: true)); + $database->createAttribute($collection, Attribute::double(key: 'price', required: true)); + $database->createAttribute($collection, Attribute::boolean(key: 'active', required: true)); + $database->createAttribute($collection, Attribute::string(key: 'genres', size: 32, required: true, array: true)); + $database->createAttribute($collection, Attribute::string(key: 'with-dash', size: 128, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'nullable', size: 128)); - public function testBigIntScenariosWithFiltering(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $permissions = [ + Permission::read(Role::any()), + Permission::read(Role::user('1')), + Permission::read(Role::user('2')), + Permission::create(Role::any()), + Permission::create(Role::user('1x')), + Permission::create(Role::user('2x')), + Permission::update(Role::any()), + Permission::update(Role::user('1x')), + Permission::update(Role::user('2x')), + Permission::delete(Role::any()), + Permission::delete(Role::user('1x')), + Permission::delete(Role::user('2x')), + ]; - if (!$database->getAdapter()->getSupportForUnsignedBigInt()) { - $this->markTestSkipped('Adapter does not support unsigned bigint'); - } + $document = $database->createDocument($collection, new Document([ + '$id' => Id::custom('frozen'), + '$permissions' => $permissions, + 'name' => 'Frozen', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + 'price' => 39.50, + 'active' => true, + 'genres' => ['animation', 'kids'], + 'with-dash' => 'Works', + ])); - $collection = 'bigint_scenarios_filters'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'signed_bigint', Database::VAR_BIGINT, 0, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'unsigned_bigint', Database::VAR_BIGINT, 0, true, signed: false)); + $database->createDocument($collection, new Document([ + '$permissions' => $permissions, + 'name' => 'Frozen II', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2019, + 'price' => 39.50, + 'active' => true, + 'genres' => ['animation', 'kids'], + 'with-dash' => 'Works', + ])); - $collectionDoc = $database->getCollection($collection); - $this->assertEquals($collection, $collectionDoc->getId()); - $attributes = $collectionDoc->getAttribute('attributes', []); - $signedAttr = null; - $unsignedAttr = null; - foreach ($attributes as $attribute) { - if (($attribute->getAttribute('$id') ?? '') === 'signed_bigint') { - $signedAttr = $attribute; - } - if (($attribute->getAttribute('$id') ?? '') === 'unsigned_bigint') { - $unsignedAttr = $attribute; - } - } + $database->createDocument($collection, new Document([ + '$permissions' => $permissions, + 'name' => 'Captain America: The First Avenger', + 'director' => 'Joe Johnston', + 'year' => 2011, + 'price' => 25.94, + 'active' => true, + 'genres' => ['science fiction', 'action', 'comics'], + 'with-dash' => 'Works2', + ])); - $this->assertNotNull($signedAttr); - $this->assertNotNull($unsignedAttr); - $this->assertSame(0, $signedAttr->getAttribute('size')); - $this->assertSame(0, $unsignedAttr->getAttribute('size')); + $database->createDocument($collection, new Document([ + '$permissions' => $permissions, + 'name' => 'Captain Marvel', + 'director' => 'Anna Boden & Ryan Fleck', + 'year' => 2019, + 'price' => 25.99, + 'active' => true, + 'genres' => ['science fiction', 'action', 'comics'], + 'with-dash' => 'Works2', + ])); - // "Out of regular int limit" (32-bit) but valid bigint should still normalize to PHP int. - $beyond32Bit = '2147483648'; - $signedMax = (string)\PHP_INT_MAX; - $signedMin = (string)\PHP_INT_MIN; - $unsignedValue = '18446744073709551615'; + $database->createDocument($collection, new Document([ + '$permissions' => $permissions, + 'name' => 'Work in Progress', + 'director' => 'TBD', + 'year' => 2025, + 'price' => 0.0, + 'active' => false, + 'genres' => [], + 'with-dash' => 'Works3', + ])); - $document = $database->createDocument($collection, new Document([ - '$id' => 'bigint-scenarios-doc', + $database->createDocument($collection, new Document([ '$permissions' => [ - Permission::read(Role::any()), + Permission::read(Role::user('x')), + Permission::create(Role::any()), + Permission::create(Role::user('1x')), + Permission::create(Role::user('2x')), Permission::update(Role::any()), + Permission::update(Role::user('1x')), + Permission::update(Role::user('2x')), + Permission::delete(Role::any()), + Permission::delete(Role::user('1x')), + Permission::delete(Role::user('2x')), ], - 'signed_bigint' => $beyond32Bit, - 'unsigned_bigint' => $unsignedValue, - ])); - - $this->assertIsInt($document->getAttribute('signed_bigint')); - $this->assertEquals((int)$beyond32Bit, $document->getAttribute('signed_bigint')); - - // Compare by string representation to stay adapter-agnostic (int/string return type differs). - $this->assertEquals($unsignedValue, (string)$document->getAttribute('unsigned_bigint')); - $this->assertTrue(\is_string($document->getAttribute('unsigned_bigint'))); - - // Read path: fetch document and ensure unsigned bigint round-trips unchanged. - $fetchedDocument = $database->getDocument($collection, $document->getId()); - $this->assertEquals($unsignedValue, (string)$fetchedDocument->getAttribute('unsigned_bigint')); - - // Update path should apply the same normalization for signed bigint numeric strings. - $updated = $database->updateDocument($collection, $document->getId(), new Document([ - 'signed_bigint' => $signedMax, + 'name' => 'Work in Progress 2', + 'director' => 'TBD', + 'year' => 2026, + 'price' => 0.0, + 'active' => false, + 'genres' => [], + 'with-dash' => 'Works3', + 'nullable' => 'Not null', ])); - $this->assertIsInt($updated->getAttribute('signed_bigint')); - $this->assertEquals((int)$signedMax, $updated->getAttribute('signed_bigint')); - // Filtering tests: both int and numeric-string filters should match bigint fields. - $resultIntFilter = $database->find($collection, [ - Query::equal('signed_bigint', [(int)$signedMax]), - ]); - $this->assertCount(1, $resultIntFilter); - $this->assertEquals('bigint-scenarios-doc', $resultIntFilter[0]->getId()); + $sequence = $document->getSequence(); + $this->assertNotNull($sequence); + self::$moviesFixtureInit = true; + self::$moviesFixtureSequence = $sequence; - $resultStringFilter = $database->find($collection, [ - Query::equal('signed_bigint', [$signedMax]), - ]); - $this->assertCount(1, $resultStringFilter); - $this->assertEquals('bigint-scenarios-doc', $resultStringFilter[0]->getId()); + return self::$moviesFixtureSequence; + } - $resultUnsignedFilter = $database->find($collection, [ - Query::equal('unsigned_bigint', [$unsignedValue]), - ]); - $this->assertCount(1, $resultUnsignedFilter); - $this->assertEquals('bigint-scenarios-doc', $resultUnsignedFilter[0]->getId()); + private static bool $incDecFixtureInit = false; - // Lower signed boundary as numeric-string should also normalize to int. - $updatedMin = $database->updateDocument($collection, $document->getId(), new Document([ - 'signed_bigint' => $signedMin, - ])); - $this->assertIsInt($updatedMin->getAttribute('signed_bigint')); - $this->assertEquals((int)$signedMin, $updatedMin->getAttribute('signed_bigint')); - } + private static ?Document $incDecFixtureDoc = null; - public function testWithSingedBigInt(): void + /** + * Create the increase_decrease collection and perform initial operations. + */ + protected function initIncreaseDecreaseFixture(): Document { - /** @var Database $database */ + if (self::$incDecFixtureInit && self::$incDecFixtureDoc !== null) { + return self::$incDecFixtureDoc; + } + $database = $this->getDatabase(); + $collection = $this->getIncDecCollection(); - $collection = 'signed_bigint_only'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'signed_bigint', Database::VAR_BIGINT, 0, true)); + $database->createCollection(Collection::create(id: $collection)); - $signedMin = \PHP_INT_MIN; - $signedMax = \PHP_INT_MAX; + $database->createAttribute($collection, Attribute::integer(key: 'increase', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'decrease', required: true)); + $database->createAttribute($collection, Attribute::string(key: 'increase_text', required: true)); + $database->createAttribute($collection, Attribute::double(key: 'increase_float', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'sizes', width: IntegerWidth::Bits64, array: true)); $document = $database->createDocument($collection, new Document([ - '$id' => 'signed-bigint-doc', + 'increase' => 100, + 'decrease' => 100, + 'increase_float' => 100, + 'increase_text' => 'some text', + 'sizes' => [10, 20, 30], '$permissions' => [ Permission::read(Role::any()), + Permission::create(Role::any()), Permission::update(Role::any()), + Permission::delete(Role::any()), ], - 'signed_bigint' => $signedMax, ])); - $this->assertIsInt($document->getAttribute('signed_bigint')); - $this->assertEquals((int)$signedMax, $document->getAttribute('signed_bigint')); + $database->increaseDocumentAttribute($collection, $document->getId(), 'increase', 1, 101); + $database->decreaseDocumentAttribute($collection, $document->getId(), 'decrease', 1, 98); + $database->increaseDocumentAttribute($collection, $document->getId(), 'increase_float', 5.5, 110); + $database->decreaseDocumentAttribute($collection, $document->getId(), 'increase_float', 1.1, 100); - $updated = $database->updateDocument($collection, $document->getId(), new Document([ - 'signed_bigint' => $signedMin, - ])); + $document = $database->getDocument($collection, $document->getId()); - $this->assertIsInt($updated->getAttribute('signed_bigint')); - $this->assertEquals((int)$signedMin, $updated->getAttribute('signed_bigint')); + self::$incDecFixtureInit = true; + self::$incDecFixtureDoc = $document; - $results = $database->find($collection, [ - Query::equal('signed_bigint', [$signedMin]), - ]); - $this->assertCount(1, $results); - $this->assertEquals('signed-bigint-doc', $results[0]->getId()); + return $document; } - public function testCreateDocument(): Document + public function testDocumentWithANullRequiredAttributeStaysUpdatable(): void { - /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('documents'); - - $this->assertEquals(true, $database->createAttribute('documents', 'string', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('documents', 'integer_signed', Database::VAR_INTEGER, 0, true)); - $this->assertEquals(true, $database->createAttribute('documents', 'integer_unsigned', Database::VAR_INTEGER, 4, true, signed: false)); - $this->assertEquals(true, $database->createAttribute('documents', 'bigint_signed', Database::VAR_INTEGER, 8, true)); - $this->assertEquals(true, $database->createAttribute('documents', 'bigint_unsigned', Database::VAR_INTEGER, 9, true, signed: false)); - $this->assertEquals(true, $database->createAttribute('documents', 'float_signed', Database::VAR_FLOAT, 0, true)); - $this->assertEquals(true, $database->createAttribute('documents', 'float_unsigned', Database::VAR_FLOAT, 0, true, signed: false)); - $this->assertEquals(true, $database->createAttribute('documents', 'boolean', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('documents', 'colors', Database::VAR_STRING, 32, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('documents', 'empty', Database::VAR_STRING, 32, false, null, true, true)); - $this->assertEquals(true, $database->createAttribute('documents', 'with-dash', Database::VAR_STRING, 128, false, null)); - $this->assertEquals(true, $database->createAttribute('documents', 'id', Database::VAR_ID, 0, false, null)); + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); - $sequence = '1000000'; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { - $sequence = '01890dd5-7331-7f3a-9c1b-123456789abc' ; + return; } - $document = $database->createDocument('documents', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user(ID::custom('1'))), - Permission::read(Role::user(ID::custom('2'))), - Permission::create(Role::any()), - Permission::create(Role::user(ID::custom('1x'))), - Permission::create(Role::user(ID::custom('2x'))), - Permission::update(Role::any()), - Permission::update(Role::user(ID::custom('1x'))), - Permission::update(Role::user(ID::custom('2x'))), - Permission::delete(Role::any()), - Permission::delete(Role::user(ID::custom('1x'))), - Permission::delete(Role::user(ID::custom('2x'))), - ], - 'string' => 'text📝', - 'integer_signed' => -Database::MAX_INT, - 'integer_unsigned' => Database::MAX_INT, - 'bigint_signed' => -Database::MAX_BIG_INT, - 'bigint_unsigned' => Database::MAX_BIG_INT, - 'float_signed' => -5.55, - 'float_unsigned' => 5.55, - 'boolean' => true, - 'colors' => ['pink', 'green', 'blue'], - 'empty' => [], - 'with-dash' => 'Works', - 'id' => $sequence, + $collection = 'storedNullRequired'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::string(key: 'note', size: 64), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], documentSecurity: false)); + + try { + $database->createDocument($collection, new Document(['$id' => 'legacy', 'note' => 'first'])); + $database->updateAttribute($collection, 'title', new AttributeUpdate(required: true)); + + $updated = $database->updateDocument($collection, 'legacy', new Document(['note' => 'second'])); + $this->assertSame('second', $updated->getAttribute('note')); + $this->assertNull($updated->getAttribute('title')); + + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $upserted = $database->upsertDocument($collection, new Document(['$id' => 'legacy', 'note' => 'third'])); + $this->assertSame('third', $upserted->getAttribute('note')); + $this->assertNull($upserted->getAttribute('title')); + } + + try { + $database->createDocument($collection, new Document(['$id' => 'fresh', 'note' => 'first'])); + $this->fail('A new document must hold a value for a required attribute'); + } catch (StructureException $error) { + $this->assertSame('Invalid document structure: Missing required attribute "title"', $error->getMessage()); + } + + $database->updateDocument($collection, 'legacy', new Document(['title' => 'named'])); + try { + $database->updateDocument($collection, 'legacy', new Document(['title' => null])); + $this->fail('A stored value of a required attribute must not be cleared'); + } catch (StructureException $error) { + $this->assertSame('Invalid document structure: Missing required attribute "title"', $error->getMessage()); + } + } finally { + $database->deleteCollection($collection); + } + } + + public function testBigintSequence(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + $sequence = 5_000_000_000_000_000; + if ($database->getIdAttributeType() === ColumnType::Uuid7) { + $sequence = '01995753-881b-78cf-9506-2cffecf8f227'; + } + + $document = $database->createDocument(__FUNCTION__, new Document([ + '$sequence' => (string) $sequence, + '$permissions' => [ + Permission::read(Role::any()), + ], ])); + $this->assertSame((string) $sequence, $document->getSequence()); + + $document = $database->getDocument(__FUNCTION__, $document->getId()); + $this->assertSame((string) $sequence, $document->getSequence()); + + $document = $database->findOne(__FUNCTION__, [Query::equal('$sequence', [(string) $sequence])]); + $this->assertSame((string) $sequence, $document->getSequence()); + + if ($database->getIdAttributeType() === ColumnType::Integer) { + $document = $database->findOne(__FUNCTION__, [Query::equal('$sequence', [$sequence])]); + $this->assertSame((string) $sequence, $document->getSequence()); + } + } + + public function testCreateDocument(): void + { + $document = $this->initDocumentsFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $sequence = '1000000'; + if ($database->getIdAttributeType() === ColumnType::Uuid7) { + $sequence = '01890dd5-7331-7f3a-9c1b-123456789abc'; + } + $this->assertNotEmpty($document->getId()); $this->assertIsString($document->getAttribute('string')); $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working @@ -327,7 +448,7 @@ public function testCreateDocument(): Document $this->assertEquals(Database::MAX_INT, $document->getAttribute('integer_unsigned')); $this->assertIsInt($document->getAttribute('bigint_signed')); $this->assertEquals(-Database::MAX_BIG_INT, $document->getAttribute('bigint_signed')); - $this->assertIsInt($document->getAttribute('bigint_signed')); + $this->assertIsInt($document->getAttribute('bigint_unsigned')); $this->assertEquals(Database::MAX_BIG_INT, $document->getAttribute('bigint_unsigned')); $this->assertIsFloat($document->getAttribute('float_signed')); $this->assertEquals(-5.55, $document->getAttribute('float_signed')); @@ -342,29 +463,28 @@ public function testCreateDocument(): Document $this->assertIsString($document->getAttribute('id')); $this->assertEquals($sequence, $document->getAttribute('id')); - $sequence = '56000'; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { - $sequence = '01890dd5-7331-7f3a-9c1b-123456789def' ; + if ($database->getIdAttributeType() === ColumnType::Uuid7) { + $sequence = '01890dd5-7331-7f3a-9c1b-123456789def'; } // Test create document with manual internal id - $manualIdDocument = $database->createDocument('documents', new Document([ + $manualIdDocument = $database->createDocument($this->getDocumentsCollection(), new Document([ '$id' => '56000', '$sequence' => $sequence, '$permissions' => [ Permission::read(Role::any()), - Permission::read(Role::user(ID::custom('1'))), - Permission::read(Role::user(ID::custom('2'))), + Permission::read(Role::user(Id::custom('1'))), + Permission::read(Role::user(Id::custom('2'))), Permission::create(Role::any()), - Permission::create(Role::user(ID::custom('1x'))), - Permission::create(Role::user(ID::custom('2x'))), + Permission::create(Role::user(Id::custom('1x'))), + Permission::create(Role::user(Id::custom('2x'))), Permission::update(Role::any()), - Permission::update(Role::user(ID::custom('1x'))), - Permission::update(Role::user(ID::custom('2x'))), + Permission::update(Role::user(Id::custom('1x'))), + Permission::update(Role::user(Id::custom('2x'))), Permission::delete(Role::any()), - Permission::delete(Role::user(ID::custom('1x'))), - Permission::delete(Role::user(ID::custom('2x'))), + Permission::delete(Role::user(Id::custom('1x'))), + Permission::delete(Role::user(Id::custom('2x'))), ], 'string' => 'text📝', 'integer_signed' => -Database::MAX_INT, @@ -403,7 +523,7 @@ public function testCreateDocument(): Document $this->assertEquals('Works', $manualIdDocument->getAttribute('with-dash')); $this->assertEquals(null, $manualIdDocument->getAttribute('id')); - $manualIdDocument = $database->getDocument('documents', '56000'); + $manualIdDocument = $database->getDocument($this->getDocumentsCollection(), '56000'); $this->assertEquals($sequence, $manualIdDocument->getSequence()); $this->assertNotEmpty($manualIdDocument->getId()); @@ -429,7 +549,7 @@ public function testCreateDocument(): Document $this->assertEquals('Works', $manualIdDocument->getAttribute('with-dash')); try { - $database->createDocument('documents', new Document([ + $database->createDocument($this->getDocumentsCollection(), new Document([ 'string' => '', 'integer_signed' => 0, 'integer_unsigned' => 0, @@ -443,14 +563,14 @@ public function testCreateDocument(): Document ])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertTrue($e instanceof StructureException); $this->assertStringContainsString('Invalid document structure: Attribute "float_unsigned" has invalid type. Value must be a valid range between 0 and', $e->getMessage()); } } try { - $database->createDocument('documents', new Document([ + $database->createDocument($this->getDocumentsCollection(), new Document([ 'string' => '', 'integer_signed' => 0, 'integer_unsigned' => 0, @@ -464,14 +584,14 @@ public function testCreateDocument(): Document ])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertTrue($e instanceof StructureException); $this->assertEquals('Invalid document structure: Attribute "bigint_unsigned" has invalid type. Value must be a valid range between 0 and 9,223,372,036,854,775,807', $e->getMessage()); } } try { - $database->createDocument('documents', new Document([ + $database->createDocument($this->getDocumentsCollection(), new Document([ '$sequence' => '0', '$permissions' => [], 'string' => '', @@ -488,7 +608,7 @@ public function testCreateDocument(): Document ])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->assertTrue($e instanceof StructureException); $this->assertEquals('Invalid document structure: Attribute "$sequence" has invalid type. Invalid sequence value', $e->getMessage()); } @@ -497,8 +617,7 @@ public function testCreateDocument(): Document /** * Insert ID attribute with NULL */ - - $documentIdNull = $database->createDocument('documents', new Document([ + $documentIdNull = $database->createDocument($this->getDocumentsCollection(), new Document([ 'id' => null, '$permissions' => [Permission::read(Role::any())], 'string' => '', @@ -516,25 +635,25 @@ public function testCreateDocument(): Document $this->assertNotEmpty($documentIdNull->getSequence()); $this->assertNull($documentIdNull->getAttribute('id')); - $documentIdNull = $database->getDocument('documents', $documentIdNull->getId()); + $documentIdNull = $database->getDocument($this->getDocumentsCollection(), $documentIdNull->getId()); $this->assertNotEmpty($documentIdNull->getId()); $this->assertNull($documentIdNull->getAttribute('id')); - $documentIdNull = $database->findOne('documents', [ - query::isNull('id') + $documentIdNull = $database->findOne($this->getDocumentsCollection(), [ + query::isNull('id'), ]); $this->assertNotEmpty($documentIdNull->getId()); $this->assertNull($documentIdNull->getAttribute('id')); $sequence = '0'; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { + if ($database->getIdAttributeType() === ColumnType::Uuid7) { $sequence = '01890dd5-7331-7f3a-9c1b-123456789abc'; } /** * Insert ID attribute with '0' */ - $documentId0 = $database->createDocument('documents', new Document([ + $documentId0 = $database->createDocument($this->getDocumentsCollection(), new Document([ 'id' => $sequence, '$permissions' => [Permission::read(Role::any())], 'string' => '', @@ -554,49 +673,17 @@ public function testCreateDocument(): Document $this->assertIsString($documentId0->getAttribute('id')); $this->assertEquals($sequence, $documentId0->getAttribute('id')); - $documentId0 = $database->getDocument('documents', $documentId0->getId()); + $documentId0 = $database->getDocument($this->getDocumentsCollection(), $documentId0->getId()); $this->assertNotEmpty($documentId0->getSequence()); $this->assertIsString($documentId0->getAttribute('id')); $this->assertEquals($sequence, $documentId0->getAttribute('id')); - $documentId0 = $database->findOne('documents', [ - query::equal('id', [$sequence]) + $documentId0 = $database->findOne($this->getDocumentsCollection(), [ + query::equal('id', [$sequence]), ]); $this->assertNotEmpty($documentId0->getSequence()); $this->assertIsString($documentId0->getAttribute('id')); $this->assertEquals($sequence, $documentId0->getAttribute('id')); - - - return $document; - } - - public function testCreateDocumentNumericalId(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $database->createCollection('numericalIds'); - - $this->assertEquals(true, $database->createAttribute('numericalIds', 'name', Database::VAR_STRING, 128, true)); - - // Test creating a document with an entirely numerical ID - $numericalIdDocument = $database->createDocument('numericalIds', new Document([ - '$id' => '123456789', - '$permissions' => [ - Permission::read(Role::any()), - ], - 'name' => 'Test Document with Numerical ID', - ])); - - $this->assertIsString($numericalIdDocument->getId()); - $this->assertEquals('123456789', $numericalIdDocument->getId()); - $this->assertEquals('Test Document with Numerical ID', $numericalIdDocument->getAttribute('name')); - - // Verify we can retrieve the document - $retrievedDocument = $database->getDocument('numericalIds', '123456789'); - $this->assertIsString($retrievedDocument->getId()); - $this->assertEquals('123456789', $retrievedDocument->getId()); - $this->assertEquals('Test Document with Numerical ID', $retrievedDocument->getAttribute('name')); } public function testCreateDocuments(): void @@ -607,11 +694,11 @@ public function testCreateDocuments(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection($collection); + $database->createCollection(Collection::create(id: $collection)); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'integer', Database::VAR_INTEGER, 0, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'bigint', Database::VAR_INTEGER, 8, true)); + $this->assertSame('string', $database->createAttribute($collection, Attribute::string(key: 'string', size: 128, required: true))->key); + $this->assertSame('integer', $database->createAttribute($collection, Attribute::integer(key: 'integer', required: true))->key); + $this->assertSame('bigint', $database->createAttribute($collection, Attribute::integer(key: 'bigint', width: IntegerWidth::Bits64, required: true))->key); // Create an array of documents with random attributes. Don't use the createDocument function $documents = []; @@ -657,7 +744,7 @@ public function testCreateDocuments(): void } $documents = $database->find($collection, [ - Query::orderAsc() + Query::orderAsc(), ]); $this->assertEquals($count, \count($documents)); @@ -677,50 +764,38 @@ public function testCacheEmptyDocument(): void { /** @var Database $database */ $database = $this->getDatabase(); - $cache = $database->getCache(); // The Redis adapter runs with a no-op cache (reads hit Redis directly), // so there is no cache layer to inspect. - if (!$database->getAdapter()->getSupportForCaching()) { + if (!$database->getAdapter()->supports(Capability::Caching)) { $this->expectNotToPerformAssertions(); return; } $collection = 'cacheEmpty'; - $database->createCollection($collection, permissions: [ + $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ], documentSecurity: false); - $this->assertEquals(true, $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, false)); - - // A read of a missing id records a negative ("not found") marker so - // repeated lookups don't keep hitting the adapter. - $this->assertTrue($database->getDocument($collection, 'ghost')->isEmpty()); + ], documentSecurity: false)); + $this->assertSame('name', $database->createAttribute($collection, Attribute::string(key: 'name', size: 128))->key); - [, $documentKey, $hashKey] = $database->getCacheKeys($collection, 'ghost'); - $cached = $cache->load($documentKey, Database::TTL, $hashKey); - $this->assertIsArray($cached); - $this->assertArrayHasKey('$empty', $cached); // Database::CACHE_EMPTY_MARKER + $ghost = fn () => $this->assertTrue($database->getDocument($collection, 'ghost')->isEmpty()); + $ghost(); + $this->assertReadServedFromCache($database, $ghost, 'A repeated read of a missing id must be served from the cache'); - // Creating the id must invalidate that marker so the row is visible. $database->createDocument($collection, new Document([ '$id' => 'ghost', '$permissions' => [Permission::read(Role::any())], 'name' => 'real', ])); - $this->assertFalse($cache->load($documentKey, Database::TTL, $hashKey)); - $document = $database->getDocument($collection, 'ghost'); - $this->assertFalse($document->isEmpty()); + $this->assertFalse($document->isEmpty(), 'Creating the id must retire its cached absence'); $this->assertEquals('real', $document->getAttribute('name')); - // Same guarantee through the batch create path. - $this->assertTrue($database->getDocument($collection, 'batch')->isEmpty()); - [, $batchKey, $batchHash] = $database->getCacheKeys($collection, 'batch'); - $cached = $cache->load($batchKey, Database::TTL, $batchHash); - $this->assertIsArray($cached); - $this->assertArrayHasKey('$empty', $cached); + $batch = fn () => $this->assertTrue($database->getDocument($collection, 'batch')->isEmpty()); + $batch(); + $this->assertReadServedFromCache($database, $batch, 'A repeated read of a missing id must be served from the cache'); $database->createDocuments($collection, [ new Document([ @@ -730,121 +805,84 @@ public function testCacheEmptyDocument(): void ]), ]); - $this->assertFalse($cache->load($batchKey, Database::TTL, $batchHash)); - $this->assertEquals('batched', $database->getDocument($collection, 'batch')->getAttribute('name')); + $this->assertEquals('batched', $database->getDocument($collection, 'batch')->getAttribute('name'), 'A batch create must retire the cached absence of its ids'); - // A locking read must never publish anything to the cache. $this->assertTrue($database->getDocument($collection, 'phantom', forUpdate: true)->isEmpty()); - [, $phantomKey, $phantomHash] = $database->getCacheKeys($collection, 'phantom'); - $this->assertFalse($cache->load($phantomKey, Database::TTL, $phantomHash)); + $this->assertReadReachesAdapter( + $database, + fn () => $this->assertTrue($database->getDocument($collection, 'phantom')->isEmpty()), + 'A locking read must not cache what it read', + ); } public function testCacheEmptyDocumentSelect(): void { /** @var Database $database */ $database = $this->getDatabase(); - $cache = $database->getCache(); // The Redis adapter runs with a no-op cache (reads hit Redis directly), // so there is no cache layer to inspect. - if (!$database->getAdapter()->getSupportForCaching()) { + if (!$database->getAdapter()->supports(Capability::Caching)) { $this->expectNotToPerformAssertions(); return; } $collection = 'cacheEmptySelect'; - $database->createCollection($collection, permissions: [ + $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ], documentSecurity: false); - - $this->assertEquals(true, $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, false)); - - // The document key is select-independent, but the hashKey is not: a - // projection is folded into it. So a projected read and a plain read of - // the same missing id are cached under different slots of the same key. - [, $documentKey, $plainHash] = $database->getCacheKeys($collection, 'ghost'); - - // validateSelections() appends the internal attributes to the user - // selection before it forms the key; mirror that set to address the - // projected slot (getCacheKeys sorts, so order does not matter). - $selects = ['name', '$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']; - [, , $selectHash] = $database->getCacheKeys($collection, 'ghost', $selects); - $this->assertNotEquals($plainHash, $selectHash); - - // Projected read caches its marker under the projected slot only. - $this->assertTrue($database->getDocument($collection, 'ghost', [Query::select(['name'])])->isEmpty()); - $cached = $cache->load($documentKey, Database::TTL, $selectHash); - $this->assertIsArray($cached); - $this->assertArrayHasKey('$empty', $cached); - $this->assertFalse( - $cache->load($documentKey, Database::TTL, $plainHash), - 'A projected read must not populate the no-projection cache slot' - ); + ], documentSecurity: false)); + + $this->assertSame('name', $database->createAttribute($collection, Attribute::string(key: 'name', size: 128))->key); + + $projected = fn () => $this->assertTrue($database->getDocument($collection, 'ghost', [Query::select(['name'])])->isEmpty()); + $plain = fn () => $this->assertTrue($database->getDocument($collection, 'ghost')->isEmpty()); - // Plain read fills the plain slot with its own marker. Both slots of the - // document key now hold an "empty" marker. - $this->assertTrue($database->getDocument($collection, 'ghost')->isEmpty()); - $cached = $cache->load($documentKey, Database::TTL, $plainHash); - $this->assertIsArray($cached); - $this->assertArrayHasKey('$empty', $cached); + $projected(); + $this->assertReadServedFromCache($database, $projected, 'A repeated projected read of a missing id must be served from the cache'); + $this->assertReadReachesAdapter($database, $plain, 'A projected read must not answer a read without the projection'); + $this->assertReadServedFromCache($database, $plain, 'A repeated read of a missing id must be served from the cache'); - // Inserting the id purges the whole document key, so BOTH slots clear. $database->createDocument($collection, new Document([ '$id' => 'ghost', '$permissions' => [Permission::read(Role::any())], 'name' => 'real', ])); - $this->assertFalse($cache->load($documentKey, Database::TTL, $plainHash)); - $this->assertFalse($cache->load($documentKey, Database::TTL, $selectHash)); + $this->assertEquals('real', $database->getDocument($collection, 'ghost')->getAttribute('name')); + $this->assertEquals('real', $database->getDocument($collection, 'ghost', [Query::select(['name'])])->getAttribute('name')); } public function testCacheEmptyGetCollection(): void { /** @var Database $database */ $database = $this->getDatabase(); - $cache = $database->getCache(); - // The Redis adapter runs with a no-op cache (reads hit Redis directly), // so there is no cache layer to inspect. - if (!$database->getAdapter()->getSupportForCaching()) { + if (!$database->getAdapter()->supports(Capability::Caching)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'cacheEmptyCollection'; - // getCollection() reads getDocument(METADATA, id) under the hood, so a - // lookup of a non-existent collection negatively caches its absence - // under the metadata key. - $this->assertTrue($database->getCollection($collectionId)->isEmpty()); - - [, $metaKey, $metaHash] = $database->getCacheKeys(Database::METADATA, $collectionId); - $cached = $cache->load($metaKey, Database::TTL, $metaHash); - $this->assertIsArray($cached); - $this->assertArrayHasKey('$empty', $cached); + $missing = fn () => $this->assertNull($database->findCollection($collectionId)); + $missing(); + $this->assertReadServedFromCache($database, $missing, 'A missing collection must be cached as absent like any other document'); - // createCollection() writes the metadata row via createDocument(METADATA), - // which must purge that marker — otherwise the collection would keep - // reading back as "not found". - $collection = $database->createCollection($collectionId, permissions: [ + $collection = $database->createCollection(Collection::create(id: $collectionId, permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ], documentSecurity: false); + ], documentSecurity: false)); $this->assertFalse($collection->isEmpty()); - $this->assertFalse($cache->load($metaKey, Database::TTL, $metaHash)); - - $fetched = $database->getCollection($collectionId); - $this->assertFalse($fetched->isEmpty()); + $fetched = $database->findCollection($collectionId); + $this->assertNotNull($fetched, 'createCollection() must retire the cached absence of its definition'); $this->assertEquals($collectionId, $fetched->getId()); - // Recreating it must now be rejected as a duplicate. This proves the - // marker was genuinely invalidated: a lingering "not found" would make - // createCollection's own existence check pass and wrongly proceed. + // A lingering "not found" would make createCollection's own existence check pass. try { - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); $this->fail('Expected DuplicateException when recreating an existing collection'); } catch (DuplicateException) { // expected @@ -856,22 +894,18 @@ public function testCacheEmptyDocumentSecurity(): void /** @var Database $database */ $database = $this->getDatabase(); $auth = $database->getAuthorization(); - $cache = $database->getCache(); - // The Redis adapter runs with a no-op cache (reads hit Redis directly), // so there is no cache layer to inspect. - if (!$database->getAdapter()->getSupportForCaching()) { + if (!$database->getAdapter()->supports(Capability::Caching)) { $this->expectNotToPerformAssertions(); return; } $collection = 'cacheEmptyDocSecurity'; - // Document-level security with no collection-wide read: access is - // decided per document. $auth->skip(function () use ($database, $collection) { - $database->createCollection($collection, permissions: [], documentSecurity: true); - $this->assertEquals(true, $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, false)); + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('name', $database->createAttribute($collection, Attribute::string(key: 'name', size: 128))->key); $database->createDocument($collection, new Document([ '$id' => 'secret', '$permissions' => [ @@ -882,23 +916,13 @@ public function testCacheEmptyDocumentSecurity(): void }); try { - // userB cannot read 'secret'. The row exists, so this denial must - // NOT record a negative marker under the shared (user-independent) - // cache key — doing so would hide the row from userA. $auth->cleanRoles(); $auth->addRole(Role::user('userB')->toString()); - $this->assertTrue($database->getDocument($collection, 'secret')->isEmpty()); + $secret = fn () => $this->assertTrue($database->getDocument($collection, 'secret')->isEmpty()); + $secret(); + $this->assertReadReachesAdapter($database, $secret, 'A permission-denied read of an existing document must not be cached as absent'); - [, $documentKey, $hashKey] = $database->getCacheKeys($collection, 'secret'); - $cached = $cache->load($documentKey, Database::TTL, $hashKey); - $this->assertFalse( - \is_array($cached) && isset($cached['$empty']), - 'A permission-denied read of an existing document must not populate the negative cache' - ); - - // userA has read permission and must still see the document, - // proving userB's forbidden read did not poison the cache. $auth->cleanRoles(); $auth->addRole(Role::user('userA')->toString()); @@ -906,13 +930,9 @@ public function testCacheEmptyDocumentSecurity(): void $this->assertFalse($document->isEmpty()); $this->assertEquals('classified', $document->getAttribute('name')); - // A genuinely missing id is user-independent, so it is still safe to - // cache as empty even under document security. - $this->assertTrue($database->getDocument($collection, 'ghost')->isEmpty()); - [, $ghostKey, $ghostHash] = $database->getCacheKeys($collection, 'ghost'); - $ghostCached = $cache->load($ghostKey, Database::TTL, $ghostHash); - $this->assertIsArray($ghostCached); - $this->assertArrayHasKey('$empty', $ghostCached); + $ghost = fn () => $this->assertTrue($database->getDocument($collection, 'ghost')->isEmpty()); + $ghost(); + $this->assertReadServedFromCache($database, $ghost, 'A missing id is cached as absent under document security too'); } finally { $auth->cleanRoles(); $auth->addRole(Role::any()->toString()); @@ -924,19 +944,19 @@ public function testCreateDocumentsWithAutoIncrement(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); + $database->createCollection(Collection::create(id: __FUNCTION__)); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'string', Database::VAR_STRING, 128, true)); + $this->assertSame('string', $database->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128, required: true))->key); /** @var array $documents */ $documents = []; $offset = 1000000; for ($i = $offset; $i <= ($offset + 10); $i++) { - $sequence = (string)$i; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { + $sequence = (string) $i; + if ($database->getIdAttributeType() === ColumnType::Uuid7) { // Replace last 6 digits with $i to make it unique - $suffix = str_pad(substr((string)$i, -6), 6, '0', STR_PAD_LEFT); - $sequence = '01890dd5-7331-7f3a-9c1b-123456' . $suffix; + $suffix = str_pad(substr((string) $i, -6), 6, '0', STR_PAD_LEFT); + $sequence = '01890dd5-7331-7f3a-9c1b-123456'.$suffix; } $hash[$i] = $sequence; @@ -957,7 +977,7 @@ public function testCreateDocumentsWithAutoIncrement(): void $this->assertEquals($count, \count($documents)); $documents = $database->find(__FUNCTION__, [ - Query::orderAsc() + Query::orderAsc(), ]); foreach ($documents as $index => $document) { @@ -974,12 +994,12 @@ public function testCreateDocumentsWithDifferentAttributes(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection($collection); + $database->createCollection(Collection::create(id: $collection)); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'integer', Database::VAR_INTEGER, 0, false)); - $this->assertEquals(true, $database->createAttribute($collection, 'bigint', Database::VAR_INTEGER, 8, false)); - $this->assertEquals(true, $database->createAttribute($collection, 'string_default', Database::VAR_STRING, 128, false, 'default')); + $this->assertSame('string', $database->createAttribute($collection, Attribute::string(key: 'string', size: 128, required: true))->key); + $this->assertSame('integer', $database->createAttribute($collection, Attribute::integer(key: 'integer'))->key); + $this->assertSame('bigint', $database->createAttribute($collection, Attribute::integer(key: 'bigint', width: IntegerWidth::Bits64))->key); + $this->assertSame('string_default', $database->createAttribute($collection, Attribute::string(key: 'string_default', size: 128, default: 'default'))->key); $documents = [ new Document([ @@ -1039,72 +1059,74 @@ public function testCreateDocumentsWithDifferentAttributes(): void $database->deleteCollection($collection); } - public function testSkipPermissions(): void + public function testUpsertSequencesOnMixedBatch(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! $database->getAdapter()->hasFeature(Feature\Upserts::class)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'number', Database::VAR_INTEGER, 0, false); + $collection = 'upsert_mixed_sequences'; + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ])); - $data = []; - for ($i = 1; $i <= 10; $i++) { - $data[] = [ - '$id' => "$i", - 'number' => $i, - ]; + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); } - $documents = array_map(fn ($d) => new Document($d), $data); - - $results = []; - $count = $database->createDocuments(__FUNCTION__, $documents, onNext: function ($doc) use (&$results) { - $results[] = $doc; - }); - - $this->assertEquals($count, \count($results)); - $this->assertEquals(10, \count($results)); - - /** - * Update 1 row - */ - $data[\array_key_last($data)]['number'] = 100; - - /** - * Add 1 row - */ - $data[] = [ - '$id' => "101", - 'number' => 101, + $permissions = [ + Permission::read(Role::any()), + Permission::update(Role::any()), ]; + $existingSequences = []; - $documents = array_map(fn ($d) => new Document($d), $data); + foreach (['existing1', 'existing2'] as $id) { + $created = $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $id, + ])); - $this->getDatabase()->getAuthorization()->disable(); + $this->assertNotEmpty($created->getSequence()); + $existingSequences[$id] = $created->getSequence(); + } - $results = []; - $count = $database->upsertDocuments( - __FUNCTION__, - $documents, - onNext: function ($doc) use (&$results) { - $results[] = $doc; - } + $upserted = []; + $database->upsertDocuments( + $collection, + [ + new Document(['$id' => 'existing1', '$permissions' => $permissions, 'name' => 'existing1 updated']), + new Document(['$id' => 'new1', '$permissions' => $permissions, 'name' => 'new1']), + new Document(['$id' => 'existing2', '$permissions' => $permissions, 'name' => 'existing2 updated']), + new Document(['$id' => 'new2', '$permissions' => $permissions, 'name' => 'new2']), + ], + onNext: function (Document $document) use (&$upserted): void { + $upserted[$document->getId()] = $document->getSequence(); + }, ); - $this->getDatabase()->getAuthorization()->reset(); - - $this->assertEquals(2, \count($results)); - $this->assertEquals(2, $count); + $this->assertCount(4, $upserted); - foreach ($results as $result) { - $this->assertArrayHasKey('$permissions', $result); - $this->assertEquals([], $result->getAttribute('$permissions')); + foreach (['existing1', 'existing2', 'new1', 'new2'] as $id) { + $this->assertNotEmpty($upserted[$id], "No sequence returned for {$id}"); + $this->assertSame( + $database->getDocument($collection, $id)->getSequence(), + $upserted[$id], + "Wrong sequence returned for {$id}", + ); } + + $this->assertSame($existingSequences['existing1'], $upserted['existing1']); + $this->assertSame($existingSequences['existing2'], $upserted['existing2']); + + $database->deleteCollection($collection); } public function testUpsertDocuments(): void @@ -1112,15 +1134,16 @@ public function testUpsertDocuments(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! ($database->getAdapter()->hasFeature(Feature\Upserts::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'string', Database::VAR_STRING, 128, true); - $database->createAttribute(__FUNCTION__, 'integer', Database::VAR_INTEGER, 0, true); - $database->createAttribute(__FUNCTION__, 'bigint', Database::VAR_INTEGER, 8, true); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128, required: true)); + $database->createAttribute(__FUNCTION__, Attribute::integer(key: 'integer', required: true)); + $database->createAttribute(__FUNCTION__, Attribute::integer(key: 'bigint', width: IntegerWidth::Bits64, required: true)); $documents = [ new Document([ @@ -1233,16 +1256,16 @@ public function testTextByteTruncationCreate(): void // Byte-capacity validation relies on attribute metadata, which // schemaless adapters don't store, so there is nothing to enforce. - if (!$database->getAdapter()->getSupportForAttributes()) { + if (!$database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); return; } - $database->createCollection(__FUNCTION__); + $database->createCollection(Collection::create(id: __FUNCTION__)); // A `text` attribute at its maximum allowed size. On MySQL/MariaDB this // maps to a TEXT column, which is limited to 65,535 *bytes*. - $database->createAttribute(__FUNCTION__, 'text', Database::VAR_TEXT, 65535, false); + $database->createAttribute(__FUNCTION__, Attribute::text(key: 'text', size: Database::MAX_TEXT_BYTES)); // The Structure validator caps a TEXT column at its 65,535-byte capacity, // measuring the value's actual byte length. A 20,000-char emoji value is @@ -1277,13 +1300,13 @@ public function testTextByteTruncationValid(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (!$database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); return; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'text', Database::VAR_TEXT, 65535, false); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::text(key: 'text', size: Database::MAX_TEXT_BYTES)); // A value that fills the column's full byte capacity is stored and // round-trips intact. 65,535 ASCII chars are exactly 65,535 bytes, so @@ -1312,13 +1335,13 @@ public function testTextByteTruncationUpdate(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (!$database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); return; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'text', Database::VAR_TEXT, 65535, false); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::text(key: 'text', size: Database::MAX_TEXT_BYTES)); $document = new Document([ '$id' => 'first', @@ -1350,14 +1373,15 @@ public function testUpsertDocumentsInc(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! ($database->getAdapter()->hasFeature(Feature\Upserts::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'string', Database::VAR_STRING, 128, false); - $database->createAttribute(__FUNCTION__, 'integer', Database::VAR_INTEGER, 0, false); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128)); + $database->createAttribute(__FUNCTION__, Attribute::integer(key: 'integer')); $documents = [ new Document([ @@ -1389,9 +1413,9 @@ public function testUpsertDocumentsInc(): void $documents[0]->setAttribute('integer', 1); $documents[1]->setAttribute('integer', 1); - $database->upsertDocumentsWithIncrease( + $database->upsertDocuments( collection: __FUNCTION__, - attribute: 'integer', + increase: 'integer', documents: $documents ); @@ -1404,9 +1428,9 @@ public function testUpsertDocumentsInc(): void $documents[0]->setAttribute('integer', -1); $documents[1]->setAttribute('integer', -1); - $database->upsertDocumentsWithIncrease( + $database->upsertDocuments( collection: __FUNCTION__, - attribute: 'integer', + increase: 'integer', documents: $documents ); @@ -1422,13 +1446,14 @@ public function testUpsertDocumentsPermissions(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! ($database->getAdapter()->hasFeature(Feature\Upserts::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'string', Database::VAR_STRING, 128, true); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128, required: true)); $document = new Document([ '$id' => 'first', @@ -1506,7977 +1531,9251 @@ public function testUpsertDocumentsPermissions(): void $this->assertEquals($newPermissions, $document->getPermissions()); } - public function testUpsertDocumentsAttributeMismatch(): void + public function testUpsertMixedPermissionDelta(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForUpserts()) { + $db = $this->getDatabase(); + if (! ($db->getAdapter()->hasFeature(Feature\Upserts::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__, permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], documentSecurity: false); - $database->createAttribute(__FUNCTION__, 'first', Database::VAR_STRING, 128, true); - $database->createAttribute(__FUNCTION__, 'last', Database::VAR_STRING, 128, false); + $db->createCollection(Collection::create(id: __FUNCTION__)); + $db->createAttribute(__FUNCTION__, Attribute::integer(key: 'v', required: true)); - $existingDocument = $database->createDocument(__FUNCTION__, new Document([ - '$id' => 'first', - 'first' => 'first', - 'last' => 'last', + $d1 = $db->createDocument(__FUNCTION__, new Document([ + '$id' => 'a', + 'v' => 0, + '$permissions' => [ + Permission::update(Role::any()), + ], + ])); + $d2 = $db->createDocument(__FUNCTION__, new Document([ + '$id' => 'b', + 'v' => 0, + '$permissions' => [ + Permission::update(Role::any()), + ], ])); - $newDocument = new Document([ - '$id' => 'second', - 'first' => 'second', + // d1 adds write, d2 removes update + $d1->setAttribute('$permissions', [ + Permission::read(Role::any()), + Permission::update(Role::any()), ]); - - // Ensure missing optionals on new document is allowed - $docs = $database->upsertDocuments(__FUNCTION__, [ - $existingDocument->setAttribute('first', 'updated'), - $newDocument, + $d2->setAttribute('$permissions', [ + Permission::read(Role::any()), ]); - $this->assertEquals(2, $docs); - $this->assertEquals('updated', $existingDocument->getAttribute('first')); - $this->assertEquals('last', $existingDocument->getAttribute('last')); - $this->assertEquals('second', $newDocument->getAttribute('first')); - $this->assertEquals('', $newDocument->getAttribute('last')); + $db->upsertDocuments(__FUNCTION__, [$d1, $d2]); - try { - $database->upsertDocuments(__FUNCTION__, [ - $existingDocument->removeAttribute('first'), - $newDocument - ]); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertTrue($e instanceof StructureException, $e->getMessage()); - } - } + $this->assertEquals([ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], $db->getDocument(__FUNCTION__, 'a')->getPermissions()); - // Ensure missing optionals on existing document is allowed - $docs = $database->upsertDocuments(__FUNCTION__, [ - $existingDocument - ->setAttribute('first', 'first') - ->removeAttribute('last'), - $newDocument - ->setAttribute('last', 'last') - ]); + $this->assertEquals([ + Permission::read(Role::any()), + ], $db->getDocument(__FUNCTION__, 'b')->getPermissions()); + } - $this->assertEquals(2, $docs); - $this->assertEquals('first', $existingDocument->getAttribute('first')); - $this->assertEquals('last', $existingDocument->getAttribute('last')); - $this->assertEquals('second', $newDocument->getAttribute('first')); - $this->assertEquals('last', $newDocument->getAttribute('last')); + public function testGetDocument(): void + { + $document = $this->initDocumentsFixture(); - // Ensure set null on existing document is allowed - $docs = $database->upsertDocuments(__FUNCTION__, [ - $existingDocument - ->setAttribute('first', 'first') - ->setAttribute('last', null), - $newDocument - ->setAttribute('last', 'last') + /** @var Database $database */ + $database = $this->getDatabase(); + + $document = $database->getDocument($this->getDocumentsCollection(), $document->getId()); + + $this->assertNotEmpty($document->getId()); + $this->assertIsString($document->getAttribute('string')); + $this->assertEquals('text📝', $document->getAttribute('string')); + $this->assertIsInt($document->getAttribute('integer_signed')); + $this->assertEquals(-Database::MAX_INT, $document->getAttribute('integer_signed')); + $this->assertIsFloat($document->getAttribute('float_signed')); + $this->assertEquals(-5.55, $document->getAttribute('float_signed')); + $this->assertIsFloat($document->getAttribute('float_unsigned')); + $this->assertEquals(5.55, $document->getAttribute('float_unsigned')); + $this->assertIsBool($document->getAttribute('boolean')); + $this->assertEquals(true, $document->getAttribute('boolean')); + $this->assertIsArray($document->getAttribute('colors')); + $this->assertEquals(['pink', 'green', 'blue'], $document->getAttribute('colors')); + $this->assertEquals('Works', $document->getAttribute('with-dash')); + } + + public function testFind(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $database->createDocument($this->getMoviesCollection(), new Document(['$id' => ['id_as_array']])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('$id must be of type string', $e->getMessage()); + $this->assertInstanceOf(StructureException::class, $e); + } + } + + public function testFindCheckInteger(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * Query with dash attribute + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('with-dash', ['Works']), ]); - $this->assertEquals(1, $docs); - $this->assertEquals('first', $existingDocument->getAttribute('first')); - $this->assertEquals(null, $existingDocument->getAttribute('last')); - $this->assertEquals('second', $newDocument->getAttribute('first')); - $this->assertEquals('last', $newDocument->getAttribute('last')); + $this->assertEquals(2, count($documents)); - $doc3 = new Document([ - '$id' => 'third', - 'last' => 'last', - 'first' => 'third', + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('with-dash', ['Works2', 'Works3']), ]); - $doc4 = new Document([ - '$id' => 'fourth', - 'first' => 'fourth', - 'last' => 'last', + $this->assertEquals(4, count($documents)); + + /** + * Check an Integer condition + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('year', [2019]), ]); - // Ensure mismatch of attribute orders is allowed - $docs = $database->upsertDocuments(__FUNCTION__, [ - $doc3, - $doc4 + $this->assertEquals(2, count($documents)); + $this->assertEquals('Frozen II', $documents[0]['name']); + $this->assertEquals('Captain Marvel', $documents[1]['name']); + } + + public function testFindBoolean(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * Boolean condition + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('active', [true]), ]); - $this->assertEquals(2, $docs); - $this->assertEquals('third', $doc3->getAttribute('first')); - $this->assertEquals('last', $doc3->getAttribute('last')); - $this->assertEquals('fourth', $doc4->getAttribute('first')); - $this->assertEquals('last', $doc4->getAttribute('last')); + $this->assertEquals(4, count($documents)); + } - $doc3 = $database->getDocument(__FUNCTION__, 'third'); - $doc4 = $database->getDocument(__FUNCTION__, 'fourth'); + public function testFindFloat(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals('third', $doc3->getAttribute('first')); - $this->assertEquals('last', $doc3->getAttribute('last')); - $this->assertEquals('fourth', $doc4->getAttribute('first')); - $this->assertEquals('last', $doc4->getAttribute('last')); + /** + * Float condition + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::lessThan('price', 26.00), + Query::greaterThan('price', 25.98), + ]); + + $this->assertEquals(1, count($documents)); } - public function testUpsertDocumentsNoop(): void + public function testFindContains(): void { - if (!$this->getDatabase()->getAdapter()->getSupportForUpserts()) { - $this->expectNotToPerformAssertions(); - return; - } + /** @var Database $database */ + $database = $this->getDatabase(); - $this->getDatabase()->createCollection(__FUNCTION__); - $this->getDatabase()->createAttribute(__FUNCTION__, 'string', Database::VAR_STRING, 128, true); + $this->initMoviesFixture(); - $document = new Document([ - '$id' => 'first', - 'string' => 'text📝', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], + $documents = $database->find($this->getMoviesCollection(), [ + new Query(Method::Contains, 'genres', ['comics']), ]); - $count = $this->getDatabase()->upsertDocuments(__FUNCTION__, [$document]); - $this->assertEquals(1, $count); + $this->assertEquals(2, count($documents)); - // No changes, should return 0 - $count = $this->getDatabase()->upsertDocuments(__FUNCTION__, [$document]); - $this->assertEquals(0, $count); - } + /** + * Array contains OR condition + */ + $documents = $database->find($this->getMoviesCollection(), [ + new Query(Method::Contains, 'genres', ['comics', 'kids']), + ]); - public function testUpsertDuplicateIds(): void - { - $db = $this->getDatabase(); - if (!$db->getAdapter()->getSupportForUpserts()) { - $this->expectNotToPerformAssertions(); - return; - } + $this->assertEquals(4, count($documents)); - $db->createCollection(__FUNCTION__); - $db->createAttribute(__FUNCTION__, 'num', Database::VAR_INTEGER, 0, true); + $documents = $database->find($this->getMoviesCollection(), [ + new Query(Method::Contains, 'genres', ['non-existent']), + ]); - $doc1 = new Document(['$id' => 'dup', 'num' => 1]); - $doc2 = new Document(['$id' => 'dup', 'num' => 2]); + $this->assertEquals(0, count($documents)); try { - $db->upsertDocuments(__FUNCTION__, [$doc1, $doc2]); + $database->find($this->getMoviesCollection(), [ + new Query(Method::Contains, 'price', [10.5]), + ]); $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e, $e->getMessage()); + } catch (Throwable $e) { + $this->assertEquals('Invalid query: Cannot query contains on attribute "price" because it is not an array, string, or object.', $e->getMessage()); + $this->assertTrue($e instanceof DatabaseException); } } - public function testUpsertMixedPermissionDelta(): void + public function testFindFulltext(): void { - $db = $this->getDatabase(); - if (!$db->getAdapter()->getSupportForUpserts()) { - $this->expectNotToPerformAssertions(); - return; - } + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $db->createCollection(__FUNCTION__); - $db->createAttribute(__FUNCTION__, 'v', Database::VAR_INTEGER, 0, true); + /** + * Fulltext search + */ + if ($this->getDatabase()->getAdapter()->supports(Capability::IndexFulltext)) { + $this->assertSame('name', $database->createIndex($this->getMoviesCollection(), Index::fulltext(key: 'name', attributes: ['name']))->key); - $d1 = $db->createDocument(__FUNCTION__, new Document([ - '$id' => 'a', - 'v' => 0, - '$permissions' => [ - Permission::update(Role::any()) - ] - ])); - $d2 = $db->createDocument(__FUNCTION__, new Document([ - '$id' => 'b', - 'v' => 0, - '$permissions' => [ - Permission::update(Role::any()) - ] - ])); + $documents = $database->find($this->getMoviesCollection(), [ + Query::search('name', 'captain'), + ]); - // d1 adds write, d2 removes update - $d1->setAttribute('$permissions', [ - Permission::read(Role::any()), - Permission::update(Role::any()) - ]); - $d2->setAttribute('$permissions', [ - Permission::read(Role::any()) - ]); + $this->assertEquals(2, count($documents)); - $db->upsertDocuments(__FUNCTION__, [$d1, $d2]); + /** + * Fulltext search (wildcard) + */ - $this->assertEquals([ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], $db->getDocument(__FUNCTION__, 'a')->getPermissions()); + // TODO: Looks like the MongoDB implementation is a bit more complex, skipping that for now. + // TODO: I think this needs a changes? how do we distinguish between regular full text and wildcard? - $this->assertEquals([ - Permission::read(Role::any()), - ], $db->getDocument(__FUNCTION__, 'b')->getPermissions()); + if ($this->getDatabase()->getAdapter()->supports(Capability::IndexFulltextWildcard)) { + $documents = $database->find($this->getMoviesCollection(), [ + Query::search('name', 'cap'), + ]); + + $this->assertEquals(2, count($documents)); + } + } + + $this->assertEquals(true, true); // Test must do an assertion } - public function testPreserveSequenceUpsert(): void + public function testFindFulltextSpecialChars(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { $this->expectNotToPerformAssertions(); + return; } - $collectionName = 'preserve_sequence_upsert'; - - $database->createCollection($collectionName); + $collection = 'full_text'; + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::update(Role::users()), + ])); - if ($database->getAdapter()->getSupportForAttributes()) { - $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 128, true); - } + $this->assertSame('ft', $database->createAttribute($collection, Attribute::string(key: 'ft', size: 128, required: true))->key); + $this->assertSame('ft-index', $database->createIndex($collection, Index::fulltext(key: 'ft-index', attributes: ['ft']))->key); - // Create initial documents - $doc1 = $database->createDocument($collectionName, new Document([ - '$id' => 'doc1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Alice', + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'ft' => 'Alf: chapter_4@nasa.com', ])); - $doc2 = $database->createDocument($collectionName, new Document([ - '$id' => 'doc2', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Bob', + $documents = $database->find($collection, [ + Query::search('ft', 'chapter_4'), + ]); + $this->assertEquals(1, count($documents)); + + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'ft' => 'al@ba.io +-*)(<>~', ])); - $originalSeq1 = $doc1->getSequence(); - $originalSeq2 = $doc2->getSequence(); + $documents = $database->find($collection, [ + Query::search('ft', 'al@ba.io'), // tokenized as: al ba io* + ]); - $this->assertNotEmpty($originalSeq1); - $this->assertNotEmpty($originalSeq2); + if ($database->getAdapter()->supports(Capability::IndexFulltextWildcard)) { + $this->assertEquals(0, count($documents)); + } else { + $this->assertEquals(1, count($documents)); + } - // Test: Without preserveSequence (default), $sequence should be ignored - $database->setPreserveSequence(false); + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'ft' => 'donald duck', + ])); - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc1', - '$sequence' => 999, // Try to set a different sequence - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Alice Updated', - ]), + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'ft' => 'donald trump', + ])); + + $documents = $database->find($collection, [ + Query::search('ft', 'donald trump'), + Query::orderAsc('ft'), ]); + $this->assertEquals(2, count($documents)); - $doc1Updated = $database->getDocument($collectionName, 'doc1'); - $this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name')); - $this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged + $documents = $database->find($collection, [ + Query::search('ft', '"donald trump"'), // Exact match + ]); - // Test: With preserveSequence=true, $sequence from document should be used - $database->setPreserveSequence(true); + $this->assertEquals(1, count($documents)); + } - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc2', - '$sequence' => $originalSeq2, // Keep original sequence - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Bob Updated', - ]), - ]); + /** + * Regression: accented characters and non-operator special chars + * previously caused SQLSTATE[42000] syntax error in FTS BOOLEAN MODE. + * + * @see https://appwrite.sentry.io/issues/5628237003 + */ + public function testFindFulltextAccentedAndSpecialChars(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $doc2Updated = $database->getDocument($collectionName, 'doc2'); - $this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name')); - $this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + return; + } - // Test: withPreserveSequence helper - $database->setPreserveSequence(false); + $collection = 'full_text_unicode'; + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::update(Role::users()), + ])); - $doc1 = $database->getDocument($collectionName, 'doc1'); - $currentSeq1 = $doc1->getSequence(); + $this->assertSame('nombre', $database->createAttribute($collection, Attribute::string(key: 'nombre', size: 128, required: true))->key); + $this->assertSame('nombre-ft', $database->createIndex($collection, Index::fulltext(key: 'nombre-ft', attributes: ['nombre']))->key); - $database->withPreserveSequence(function () use ($database, $collectionName, $currentSeq1) { - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc1', - '$sequence' => $currentSeq1, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Alice Final', - ]), - ]); - }); + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'nombre' => 'Luis García' + ])); - $doc1Final = $database->getDocument($collectionName, 'doc1'); - $this->assertEquals('Alice Final', $doc1Final->getAttribute('name')); - $this->assertEquals($currentSeq1, $doc1Final->getSequence()); + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'nombre' => 'Álvaro Yair Cuéllar' + ])); - // Verify flag was reset after withPreserveSequence - $this->assertFalse($database->getPreserveSequence()); + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'nombre' => 'Fernando naïve über' + ])); - // Test: With preserveSequence=true, invalid $sequence should throw error (SQL adapters only) - $database->setPreserveSequence(true); + /** + * Accented characters must not cause FTS parser errors + */ + $documents = $database->find($collection, [ + Query::search('nombre', 'García'), + ]); + $this->assertGreaterThanOrEqual(1, count($documents)); - try { - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc1', - '$sequence' => 'abc', // Invalid sequence value - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Alice Invalid', - ]), - ]); - // Schemaless adapters may not validate sequence type, so only fail for schemaful - if ($database->getAdapter()->getSupportForAttributes()) { - $this->fail('Expected StructureException for invalid sequence'); - } - } catch (Throwable $e) { - if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertInstanceOf(StructureException::class, $e); - $this->assertStringContainsString('sequence', $e->getMessage()); - } - } + $documents = $database->find($collection, [ + Query::search('nombre', 'Álvaro'), + ]); + $this->assertGreaterThanOrEqual(1, count($documents)); - $database->setPreserveSequence(false); - $database->deleteCollection($collectionName); + $documents = $database->find($collection, [ + Query::search('nombre', 'Cuéllar'), + ]); + $this->assertGreaterThanOrEqual(1, count($documents)); + + /** + * Non-operator special chars (! . #) were not stripped by old code, + * producing values like "!!!...###*" that crash MySQL's FTS parser. + */ + $documents = $database->find($collection, [ + Query::search('nombre', '!!!...###'), + ]); + $this->assertEquals(0, count($documents)); + + $documents = $database->find($collection, [ + Query::search('nombre', '$$$%%%^^^'), + ]); + $this->assertEquals(0, count($documents)); + + /** + * FTS operator-only input also must not error + */ + $documents = $database->find($collection, [ + Query::search('nombre', '+-*@<>~'), + ]); + $this->assertEquals(0, count($documents)); + + /** + * Mixed special chars + accented word should still find results + */ + $documents = $database->find($collection, [ + Query::search('nombre', '@García!'), + ]); + $this->assertGreaterThanOrEqual(1, count($documents)); } - /** - * upsertDocuments() carries the sequence of every row it already read across to the - * written document, so the follow-up getSequences() lookup only covers the rows that - * were genuinely new. That leaves the batch it receives interleaved -- some documents - * carry a sequence, some do not -- and the tenant placeholders it binds must line up - * with the ones its SQL declares regardless of where the gaps fall. - */ - public function testUpsertSequencesOnMixedBatch(): void + public function testFindFulltextSeparatorsSplitWords(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { $this->expectNotToPerformAssertions(); + return; } - $collectionName = 'upsert_mixed_sequences'; - - $database->createCollection($collectionName, permissions: [ + $collection = 'full_text_separators'; + $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - Permission::update(Role::any()), - ]); - - if ($database->getAdapter()->getSupportForAttributes()) { - $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 128, true); - } + ])); - $permissions = [ - Permission::read(Role::any()), - Permission::update(Role::any()), + $this->assertSame('text', $database->createAttribute($collection, Attribute::string(key: 'text', size: 128, required: true))->key); + $this->assertSame('text-ft', $database->createIndex($collection, Index::fulltext(key: 'text-ft', attributes: ['text']))->key); + + $texts = [ + 'lunar' => 'lunar', + 'solar' => 'solar', + 'space' => 'lunar solar', + 'slash' => 'lunar/solar', + 'comma' => 'lunar,solar', + 'underscore' => 'lunar_solar', + 'comet' => 'comet', + 'orbit' => 'orbit', ]; - - $existingSequences = []; - foreach (['existing1', 'existing2'] as $id) { - $created = $database->createDocument($collectionName, new Document([ + foreach ($texts as $id => $text) { + $database->createDocument($collection, new Document([ '$id' => $id, - '$permissions' => $permissions, - 'name' => $id, + '$permissions' => [Permission::read(Role::any())], + 'text' => $text, ])); - - $this->assertNotEmpty($created->getSequence()); - $existingSequences[$id] = $created->getSequence(); } - // Existing and new rows interleaved, so the new ones sit at odd indexes in the batch. - $upserted = []; - $database->upsertDocuments( - $collectionName, - [ - new Document(['$id' => 'existing1', '$permissions' => $permissions, 'name' => 'existing1 updated']), - new Document(['$id' => 'new1', '$permissions' => $permissions, 'name' => 'new1']), - new Document(['$id' => 'existing2', '$permissions' => $permissions, 'name' => 'existing2 updated']), - new Document(['$id' => 'new2', '$permissions' => $permissions, 'name' => 'new2']), - ], - onNext: function (Document $document) use (&$upserted) { - $upserted[$document->getId()] = $document->getSequence(); - } - ); + $eitherWord = ['comma', 'lunar', 'slash', 'solar', 'space']; - $this->assertCount(4, $upserted); + foreach (['lunar/solar', 'lunar,solar'] as $term) { + $found = $this->searchedIds($database, $collection, $term); - foreach (['existing1', 'existing2', 'new1', 'new2'] as $id) { - $this->assertNotEmpty($upserted[$id], "No sequence returned for {$id}"); - $this->assertEquals( - $database->getDocument($collectionName, $id)->getSequence(), - $upserted[$id], - "Wrong sequence returned for {$id}" - ); + foreach ($eitherWord as $id) { + $this->assertContains($id, $found, "search('{$term}') finds the document '{$texts[$id]}'"); + } + $this->assertNotContains('comet', $found, "search('{$term}')"); + $this->assertSame($this->searchedIds($database, $collection, 'lunar solar'), $found, "search('{$term}') searches the words as 'lunar solar' does"); } - // An upsert must not move a row that was already there. - $this->assertEquals($existingSequences['existing1'], $upserted['existing1']); - $this->assertEquals($existingSequences['existing2'], $upserted['existing2']); + $found = $this->searchedIds($database, $collection, 'comet, lunar/solar'); + foreach (['comet', ...$eitherWord] as $id) { + $this->assertContains($id, $found, "search('comet, lunar/solar') finds the document '{$texts[$id]}'"); + } + $this->assertNotContains('orbit', $found); + $this->assertSame($this->searchedIds($database, $collection, 'comet lunar solar'), $found); - $database->deleteCollection($collectionName); + $found = $this->searchedIds($database, $collection, 'lunar_solar'); + $this->assertContains('underscore', $found); + $this->assertNotContains('comet', $found); } - public function testRespectNulls(): Document + /** + * @return list + */ + private function searchedIds(Database $database, string $collection, string $term): array { - /** @var Database $database */ - $database = $this->getDatabase(); - - $database->createCollection('documents_nulls'); - - $this->assertEquals(true, $database->createAttribute('documents_nulls', 'string', Database::VAR_STRING, 128, false)); - $this->assertEquals(true, $database->createAttribute('documents_nulls', 'integer', Database::VAR_INTEGER, 0, false)); - $this->assertEquals(true, $database->createAttribute('documents_nulls', 'bigint', Database::VAR_INTEGER, 8, false)); - $this->assertEquals(true, $database->createAttribute('documents_nulls', 'float', Database::VAR_FLOAT, 0, false)); - $this->assertEquals(true, $database->createAttribute('documents_nulls', 'boolean', Database::VAR_BOOLEAN, 0, false)); - - $document = $database->createDocument('documents_nulls', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - ])); + $ids = \array_map( + fn (Document $document): string => $document->getId(), + $database->find($collection, [Query::search('text', $term)]), + ); + \sort($ids); - $this->assertNotEmpty($document->getId()); - $this->assertNull($document->getAttribute('string')); - $this->assertNull($document->getAttribute('integer')); - $this->assertNull($document->getAttribute('bigint')); - $this->assertNull($document->getAttribute('float')); - $this->assertNull($document->getAttribute('boolean')); - return $document; + return $ids; } - public function testCreateDocumentDefaults(): void + public function testFindByID(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('defaults'); + /** + * $id condition + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('$id', ['frozen']), + ]); - $this->assertEquals(true, $database->createAttribute('defaults', 'string', Database::VAR_STRING, 128, false, 'default')); - $this->assertEquals(true, $database->createAttribute('defaults', 'integer', Database::VAR_INTEGER, 0, false, 1)); - $this->assertEquals(true, $database->createAttribute('defaults', 'float', Database::VAR_FLOAT, 0, false, 1.5)); - $this->assertEquals(true, $database->createAttribute('defaults', 'boolean', Database::VAR_BOOLEAN, 0, false, true)); - $this->assertEquals(true, $database->createAttribute('defaults', 'colors', Database::VAR_STRING, 32, false, ['red', 'green', 'blue'], true, true)); - $this->assertEquals(true, $database->createAttribute('defaults', 'datetime', Database::VAR_DATETIME, 0, false, '2000-06-12T14:12:55.000+00:00', true, false, null, [], ['datetime'])); - - $document = $database->createDocument('defaults', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ])); + $this->assertEquals(1, count($documents)); + $this->assertEquals('Frozen', $documents[0]['name']); + } - $document2 = $database->getDocument('defaults', $document->getId()); - $this->assertCount(4, $document2->getPermissions()); - $this->assertEquals('read("any")', $document2->getPermissions()[0]); - $this->assertEquals('create("any")', $document2->getPermissions()[1]); - $this->assertEquals('update("any")', $document2->getPermissions()[2]); - $this->assertEquals('delete("any")', $document2->getPermissions()[3]); + public function testFindByInternalID(): void + { + $sequence = $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertNotEmpty($document->getId()); - $this->assertIsString($document->getAttribute('string')); - $this->assertEquals('default', $document->getAttribute('string')); - $this->assertIsInt($document->getAttribute('integer')); - $this->assertEquals(1, $document->getAttribute('integer')); - $this->assertIsFloat($document->getAttribute('float')); - $this->assertEquals(1.5, $document->getAttribute('float')); - $this->assertIsArray($document->getAttribute('colors')); - $this->assertCount(3, $document->getAttribute('colors')); - $this->assertEquals('red', $document->getAttribute('colors')[0]); - $this->assertEquals('green', $document->getAttribute('colors')[1]); - $this->assertEquals('blue', $document->getAttribute('colors')[2]); - $this->assertEquals('2000-06-12T14:12:55.000+00:00', $document->getAttribute('datetime')); + /** + * Test that internal ID queries are handled correctly + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('$sequence', [$sequence]), + ]); - // cleanup collection - $database->deleteCollection('defaults'); + $this->assertEquals(1, count($documents)); } - /** - * When a document's UID changes on update, its permission rows in the - * collection's _perms table must follow the new UID. Otherwise the old - * rows are orphaned and the renamed document is left with no permissions, - * even when the permission set itself was not changed. - */ - public function testUpdateDocumentChangeIdMigratesPermissions(): void + public function testOrSingleQuery(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $auth = $database->getAuthorization(); - - $collection = 'update_change_id_perms'; - try { - // documentSecurity with no collection-level permissions: reads are - // governed purely by the document's rows in the _perms table. - $database->createCollection($collection, permissions: [], documentSecurity: true); - $this->assertEquals(true, $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, false)); - - // Create a document whose read permission is scoped to a single role, - // so that find() must consult the _perms table to return it. - $document = $auth->skip(fn () => $database->createDocument($collection, new Document([ - '$id' => 'old_id', - 'name' => 'test', - '$permissions' => [ - Permission::read(Role::user('alice')), - Permission::update(Role::user('alice')), - Permission::delete(Role::user('alice')), - ], - ]))); - $this->assertEquals('old_id', $document->getId()); - - // Sanity: as alice the document is visible via the _perms table. - $auth->addRole(Role::user('alice')->toString()); - $this->assertCount(1, $database->find($collection)); - - // Rename the document WITHOUT changing its permission set. - $renamed = $auth->skip(fn () => $database->updateDocument($collection, 'old_id', new Document(\array_merge( - $document->getArrayCopy(), - ['$id' => 'new_id'], - )))); - $this->assertEquals('new_id', $renamed->getId()); - - // The old UID must no longer resolve to a document. - $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'old_id'))->isEmpty()); - - // The new UID must exist and keep its permissions on the main row. - $newDoc = $auth->skip(fn () => $database->getDocument($collection, 'new_id')); - $this->assertFalse($newDoc->isEmpty()); - $this->assertContains(Permission::read(Role::user('alice')), $newDoc->getPermissions()); - - // The crucial check: the permission rows must have migrated to the new - // UID in the _perms table. As alice, find() (which joins _perms) must - // still return exactly the renamed document. With orphaned rows under - // the old UID this returns 0. - $found = $database->find($collection); - $this->assertCount(1, $found); - $this->assertEquals('new_id', $found[0]->getId()); - - /** - * Second scenario: change the UID AND the permission set in the same - * update. Drop alice's access and grant bob instead. The removed rows - * must be gone, the added rows must land under the new UID, and nothing - * may be left orphaned under the old UID. - */ - $rekeyed = $auth->skip(fn () => $database->updateDocument($collection, 'new_id', new Document(\array_merge( - $newDoc->getArrayCopy(), - [ - '$id' => 'final_id', - '$permissions' => [ - Permission::read(Role::user('bob')), - Permission::read(Role::user('bob')), // Duplication check - Permission::update(Role::user('bob')), - Permission::delete(Role::user('bob')), - ], - ], - )))); - $this->assertEquals('final_id', $rekeyed->getId()); - - // The old UID must no longer resolve to a document. - $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'new_id'))->isEmpty()); - - // The main row must reflect the new permission set. - $finalDoc = $auth->skip(fn () => $database->getDocument($collection, 'final_id')); - $this->assertFalse($finalDoc->isEmpty()); - $this->assertContains(Permission::read(Role::user('bob')), $finalDoc->getPermissions()); - $this->assertNotContains(Permission::read(Role::user('alice')), $finalDoc->getPermissions()); - - // alice's permission rows were removed: as alice nothing is returned. - $this->assertCount(0, $database->find($collection)); - - // bob's permission rows landed under the new UID: as bob the renamed - // document is returned via the _perms join. - $auth->addRole(Role::user('bob')->toString()); - $foundAsBob = $database->find($collection); - $this->assertCount(1, $foundAsBob); - $this->assertEquals('final_id', $foundAsBob[0]->getId()); - } finally { - $auth->removeRole(Role::user('alice')->toString()); - $auth->removeRole(Role::user('bob')->toString()); - - $auth->skip(fn () => $database->deleteCollection($collection)); + $database->find($this->getMoviesCollection(), [ + Query::or([ + Query::equal('active', [true]), + ]), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Invalid query: Or queries require at least two queries', $e->getMessage()); } } - public function testIncreaseDecrease(): Document + public function testOrMultipleQueries(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'increase_decrease'; - $database->createCollection($collection); + $queries = [ + Query::or([ + Query::equal('active', [true]), + Query::equal('name', ['Frozen II']), + ]), + ]; + $this->assertCount(4, $database->find($this->getMoviesCollection(), $queries)); + $this->assertEquals(4, $database->count($this->getMoviesCollection(), $queries)); - $this->assertEquals(true, $database->createAttribute($collection, 'increase', Database::VAR_INTEGER, 0, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'decrease', Database::VAR_INTEGER, 0, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'increase_text', Database::VAR_STRING, 255, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'increase_float', Database::VAR_FLOAT, 0, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'sizes', Database::VAR_INTEGER, 8, required: false, array: true)); + $queries = [ + Query::equal('active', [true]), + Query::or([ + Query::equal('name', ['Frozen']), + Query::equal('name', ['Frozen II']), + Query::equal('director', ['Joe Johnston']), + ]), + ]; - $document = $database->createDocument($collection, new Document([ - 'increase' => 100, - 'decrease' => 100, - 'increase_float' => 100, - 'increase_text' => 'some text', - 'sizes' => [10, 20, 30], - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ] - ])); + $this->assertCount(3, $database->find($this->getMoviesCollection(), $queries)); + $this->assertEquals(3, $database->count($this->getMoviesCollection(), $queries)); + } - $updatedAt = $document->getUpdatedAt(); + public function testOrNested(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - \usleep(2000); // Ensure $updatedAt differs when adapter timestamp precision is milliseconds + $queries = [ + Query::select(['director']), + Query::equal('director', ['Joe Johnston']), + Query::or([ + Query::equal('name', ['Frozen']), + Query::or([ + Query::equal('active', [true]), + Query::equal('active', [false]), + ]), + ]), + ]; - $doc = $database->increaseDocumentAttribute($collection, $document->getId(), 'increase', 1, 101); - $this->assertEquals(101, $doc->getAttribute('increase')); + $documents = $database->find($this->getMoviesCollection(), $queries); + $this->assertCount(1, $documents); + $this->assertArrayNotHasKey('name', $documents[0]); - $document = $database->getDocument($collection, $document->getId()); - $this->assertEquals(101, $document->getAttribute('increase')); - $this->assertNotEquals($updatedAt, $document->getUpdatedAt()); + $count = $database->count($this->getMoviesCollection(), $queries); + $this->assertEquals(1, $count); + } - $doc = $database->decreaseDocumentAttribute($collection, $document->getId(), 'decrease', 1, 98); - $this->assertEquals(99, $doc->getAttribute('decrease')); - $document = $database->getDocument($collection, $document->getId()); - $this->assertEquals(99, $document->getAttribute('decrease')); + public function testAndSingleQuery(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $doc = $database->increaseDocumentAttribute($collection, $document->getId(), 'increase_float', 5.5, 110); - $this->assertEquals(105.5, $doc->getAttribute('increase_float')); - $document = $database->getDocument($collection, $document->getId()); - $this->assertEquals(105.5, $document->getAttribute('increase_float')); + try { + $database->find($this->getMoviesCollection(), [ + Query::and([ + Query::equal('active', [true]), + ]), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Invalid query: And queries require at least two queries', $e->getMessage()); + } + } - $doc = $database->decreaseDocumentAttribute($collection, $document->getId(), 'increase_float', 1.1, 100); - $this->assertEquals(104.4, $doc->getAttribute('increase_float')); - $document = $database->getDocument($collection, $document->getId()); - $this->assertEquals(104.4, $document->getAttribute('increase_float')); + public function testAndMultipleQueries(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - return $document; + $queries = [ + Query::and([ + Query::equal('active', [true]), + Query::equal('name', ['Frozen II']), + ]), + ]; + $this->assertCount(1, $database->find($this->getMoviesCollection(), $queries)); + $this->assertEquals(1, $database->count($this->getMoviesCollection(), $queries)); } - public function testCreateUpdateBigIntAndIncrementDecrement(): void + public function testAndNested(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'bigint_update_increase_decrease'; - $database->createCollection($collection); - - $this->assertEquals(true, $database->createAttribute($collection, 'inc', Database::VAR_BIGINT, 8, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'dec', Database::VAR_BIGINT, 8, true)); + $queries = [ + Query::or([ + Query::equal('active', [false]), + Query::and([ + Query::equal('active', [true]), + Query::equal('name', ['Frozen']), + ]), + ]), + ]; - $document = $database->createDocument($collection, new Document([ - 'inc' => 10, - 'dec' => 10, - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ] - ])); + $documents = $database->find($this->getMoviesCollection(), $queries); + $this->assertCount(3, $documents); - $this->assertIsInt($document->getAttribute('inc')); - $this->assertEquals(10, $document->getAttribute('inc')); + $count = $database->count($this->getMoviesCollection(), $queries); + $this->assertEquals(3, $count); + } - // Verify regular update works for bigint attributes - $updated = $database->updateDocument($collection, $document->getId(), new Document([ - 'inc' => 20, - ])); - $this->assertEquals(20, $updated->getAttribute('inc')); + public function testFindNull(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - // Verify atomic increment/decrement supports bigint schema attributes - $afterInc = $database->increaseDocumentAttribute($collection, $document->getId(), 'inc', 5, 30); - $this->assertEquals(25, $afterInc->getAttribute('inc')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::isNull('nullable'), + ]); - $afterDec = $database->decreaseDocumentAttribute($collection, $document->getId(), 'dec', 3, 7); - $this->assertEquals(7, $afterDec->getAttribute('dec')); + $this->assertEquals(5, count($documents)); } - /** - * @depends testIncreaseDecrease - */ - public function testIncreaseLimitMax(Document $document): void + public function testFindNotNull(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $this->expectException(Exception::class); - $this->assertEquals(true, $database->increaseDocumentAttribute('increase_decrease', $document->getId(), 'increase', 10.5, 102.4)); + $documents = $database->find($this->getMoviesCollection(), [ + Query::isNotNull('nullable'), + ]); + + $this->assertEquals(1, count($documents)); } - /** - * @depends testIncreaseDecrease - */ - public function testDecreaseLimitMin(Document $document): void + public function testFindStartsWith(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - try { - $database->decreaseDocumentAttribute( - 'increase_decrease', - $document->getId(), - 'decrease', - 10, - 99 - ); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::startsWith('name', 'Work'), + ]); - try { - $database->decreaseDocumentAttribute( - 'increase_decrease', - $document->getId(), - 'decrease', - 1000, - 0 - ); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); + $this->assertEquals(2, count($documents)); + + if ($this->getDatabase()->getAdapter() instanceof SQL) { + $documents = $database->find($this->getMoviesCollection(), [ + Query::startsWith('name', '%ork'), + ]); + } else { + $documents = $database->find($this->getMoviesCollection(), [ + Query::startsWith('name', '.*ork'), + ]); } + + $this->assertEquals(0, count($documents)); } - /** - * @depends testIncreaseDecrease - */ - public function testIncreaseTextAttribute(Document $document): void + public function testFindStartsWithWords(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - try { - $this->assertEquals(false, $database->increaseDocumentAttribute('increase_decrease', $document->getId(), 'increase_text')); - $this->fail('Expected TypeException not thrown'); - } catch (Exception $e) { - $this->assertInstanceOf(TypeException::class, $e, $e->getMessage()); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::startsWith('name', 'Work in Progress'), + ]); + + $this->assertEquals(2, count($documents)); } - /** - * @depends testIncreaseDecrease - */ - public function testIncreaseArrayAttribute(Document $document): void + public function testFindEndsWith(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - try { - $this->assertEquals(false, $database->increaseDocumentAttribute('increase_decrease', $document->getId(), 'sizes')); - $this->fail('Expected TypeException not thrown'); - } catch (Exception $e) { - $this->assertInstanceOf(TypeException::class, $e); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::endsWith('name', 'Marvel'), + ]); + + $this->assertEquals(1, count($documents)); } - /** - * @depends testIncreaseDecrease - */ - public function testIncreaseDecreasePreserveDates(Document $document): void + public function testFindNotContains(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $database->setPreserveDates(true); + // Test notContains with array attributes - should return documents that don't contain specified genres + $documents = $database->find($this->getMoviesCollection(), [ + Query::notContains('genres', ['comics']), + ]); - try { - $before = $database->getDocument('increase_decrease', $document->getId()); - $updatedAt = $before->getUpdatedAt(); - $increase = $before->getAttribute('increase'); - $decrease = $before->getAttribute('decrease'); + $this->assertEquals(4, count($documents)); // 6 readable movies (user:x role added earlier) minus 2 with 'comics' genre + + // Test notContains with multiple values (AND logic - exclude documents containing ANY of these) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notContains('genres', ['comics', 'kids']), + ]); - $database->increaseDocumentAttribute('increase_decrease', $document->getId(), 'increase', 1); + $this->assertEquals(2, count($documents)); // Only 'Work in Progress' and 'Work in Progress 2' have neither 'comics' nor 'kids' - $after = $database->getDocument('increase_decrease', $document->getId()); - $this->assertSame($increase + 1, $after->getAttribute('increase')); - $this->assertSame($updatedAt, $after->getUpdatedAt()); + // Test notContains with non-existent genre - should return all readable documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notContains('genres', ['non-existent']), + ]); + + $this->assertEquals(6, count($documents)); - $database->decreaseDocumentAttribute('increase_decrease', $document->getId(), 'decrease', 1); + // Test notContains with string attribute (substring search) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notContains('name', ['Captain']), + ]); + $this->assertEquals(4, count($documents)); // 6 readable movies minus 2 containing 'Captain' - $after = $database->getDocument('increase_decrease', $document->getId()); - $this->assertSame($decrease - 1, $after->getAttribute('decrease')); - $this->assertSame($updatedAt, $after->getUpdatedAt()); - } finally { - $database->setPreserveDates(false); + // Test notContains combined with other queries (AND logic) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notContains('genres', ['comics']), + Query::greaterThan('year', 2000), + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of readable movies without 'comics' and after 2000 + + // Test notContains with case sensitivity + $documents = $database->find($this->getMoviesCollection(), [ + Query::notContains('genres', ['COMICS']), // Different case + ]); + $this->assertEquals(6, count($documents)); // All readable movies since case doesn't match + + // Test error handling for invalid attribute type + try { + $database->find($this->getMoviesCollection(), [ + Query::notContains('price', [10.5]), + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array, string, or object.', $e->getMessage()); + $this->assertTrue($e instanceof DatabaseException); } } - /** - * @depends testCreateDocument - */ - public function testGetDocument(Document $document): Document + public function testFindNotSearch(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->getDocument('documents', $document->getId()); + // Only test if fulltext search is supported + if ($this->getDatabase()->getAdapter()->supports(Capability::IndexFulltext)) { + // Ensure fulltext index exists (may already exist from previous tests) + try { + $database->createIndex($this->getMoviesCollection(), Index::fulltext(key: 'name', attributes: ['name'])); + } catch (Throwable $e) { + // Index may already exist, ignore duplicate error + if (! str_contains($e->getMessage(), 'already exists')) { + throw $e; + } + } - $this->assertNotEmpty($document->getId()); - $this->assertIsString($document->getAttribute('string')); - $this->assertEquals('text📝', $document->getAttribute('string')); - $this->assertIsInt($document->getAttribute('integer_signed')); - $this->assertEquals(-Database::MAX_INT, $document->getAttribute('integer_signed')); - $this->assertIsFloat($document->getAttribute('float_signed')); - $this->assertEquals(-5.55, $document->getAttribute('float_signed')); - $this->assertIsFloat($document->getAttribute('float_unsigned')); - $this->assertEquals(5.55, $document->getAttribute('float_unsigned')); - $this->assertIsBool($document->getAttribute('boolean')); - $this->assertEquals(true, $document->getAttribute('boolean')); - $this->assertIsArray($document->getAttribute('colors')); - $this->assertEquals(['pink', 'green', 'blue'], $document->getAttribute('colors')); - $this->assertEquals('Works', $document->getAttribute('with-dash')); + // Test notSearch - should return documents that don't match the search term + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', 'captain'), + ]); - return $document; + $this->assertEquals(4, count($documents)); // 6 readable movies (user:x role added earlier) minus 2 with 'captain' in name + + // Test notSearch with term that doesn't exist - should return all readable documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', 'nonexistent'), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notSearch with partial term + if ($this->getDatabase()->getAdapter()->supports(Capability::IndexFulltextWildcard)) { + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', 'cap'), + ]); + + $this->assertEquals(4, count($documents)); // 6 readable movies minus 2 matching 'cap*' + } + + // Test notSearch with empty string - should return all readable documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', ''), + ]); + $this->assertEquals(6, count($documents)); // All readable movies since empty search matches nothing + + // Test notSearch combined with other filters + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', 'captain'), + Query::lessThan('year', 2010), + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-captain movies before 2010 + + // Test notSearch with special characters + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', '@#$%'), + ]); + $this->assertEquals(6, count($documents)); // All readable movies since special chars don't match + } + + $this->assertEquals(true, true); // Test must do an assertion } - /** - * @depends testCreateDocument - */ - public function testGetDocumentSelect(Document $document): Document + public function testFindNotStartsWith(): void { - $documentId = $document->getId(); - + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->getDocument('documents', $documentId, [ - Query::select(['string', 'integer_signed']), + // Test notStartsWith - should return documents that don't start with 'Work' + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', 'Work'), ]); - $this->assertFalse($document->isEmpty()); - $this->assertIsString($document->getAttribute('string')); - $this->assertEquals('text📝', $document->getAttribute('string')); - $this->assertIsInt($document->getAttribute('integer_signed')); - $this->assertEquals(-Database::MAX_INT, $document->getAttribute('integer_signed')); - $this->assertArrayNotHasKey('float', $document->getAttributes()); - $this->assertArrayNotHasKey('boolean', $document->getAttributes()); - $this->assertArrayNotHasKey('colors', $document->getAttributes()); - $this->assertArrayNotHasKey('with-dash', $document->getAttributes()); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - $this->assertArrayHasKey('$collection', $document); + $this->assertEquals(4, count($documents)); // All movies except the 2 starting with 'Work' - $document = $database->getDocument('documents', $documentId, [ - Query::select(['string', 'integer_signed', '$id']), + // Test notStartsWith with non-existent prefix - should return all documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', 'NonExistent'), ]); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('string', $document); - $this->assertArrayHasKey('integer_signed', $document); - $this->assertArrayNotHasKey('float', $document); + $this->assertEquals(6, count($documents)); - return $document; + // Test notStartsWith with wildcard characters (should treat them literally) + if ($this->getDatabase()->getAdapter() instanceof SQL) { + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', '%ork'), + ]); + } else { + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', '.*ork'), + ]); + } + + $this->assertEquals(6, count($documents)); // Should return all since no movie starts with these patterns + + // Test notStartsWith with empty string - should return no documents (all strings start with empty) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', ''), + ]); + $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string + + // Test notStartsWith with single character + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', 'C'), + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Movies not starting with 'C' + + // Test notStartsWith with case sensitivity (may be case-insensitive depending on DB) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', 'work'), // lowercase vs 'Work' + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // May match case-insensitively + + // Test notStartsWith combined with other queries + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', 'Work'), + Query::equal('year', [2006]), + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 } - /** - * @return array - */ - public function testFind(): array - { - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + public function testFindNotEndsWith(): void + { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('movies', permissions: [ - Permission::create(Role::any()), - Permission::update(Role::users()) + // Test notEndsWith - should return documents that don't end with 'Marvel' + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'Marvel'), ]); - $this->assertEquals(true, $database->createAttribute('movies', 'name', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'director', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'year', Database::VAR_INTEGER, 0, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'price', Database::VAR_FLOAT, 0, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'active', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'genres', Database::VAR_STRING, 32, true, null, true, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'with-dash', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'nullable', Database::VAR_STRING, 128, false)); + $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'Marvel' - try { - $database->createDocument('movies', new Document(['$id' => ['id_as_array']])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertEquals('$id must be of type string', $e->getMessage()); - $this->assertInstanceOf(StructureException::class, $e); - } + // Test notEndsWith with non-existent suffix - should return all documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'NonExistent'), + ]); - $document = $database->createDocument('movies', new Document([ - '$id' => ID::custom('frozen'), - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Frozen', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2013, - 'price' => 39.50, - 'active' => true, - 'genres' => ['animation', 'kids'], - 'with-dash' => 'Works' - ])); + $this->assertEquals(6, count($documents)); - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Frozen II', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2019, - 'price' => 39.50, - 'active' => true, - 'genres' => ['animation', 'kids'], - 'with-dash' => 'Works' - ])); + // Test notEndsWith with partial suffix + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'vel'), + ]); - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Captain America: The First Avenger', - 'director' => 'Joe Johnston', - 'year' => 2011, - 'price' => 25.94, - 'active' => true, - 'genres' => ['science fiction', 'action', 'comics'], - 'with-dash' => 'Works2' - ])); + $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Captain Marvel', - 'director' => 'Anna Boden & Ryan Fleck', - 'year' => 2019, - 'price' => 25.99, - 'active' => true, - 'genres' => ['science fiction', 'action', 'comics'], - 'with-dash' => 'Works2' - ])); + // Test notEndsWith with empty string - should return no documents (all strings end with empty) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', ''), + ]); + $this->assertEquals(0, count($documents)); // No documents since all strings end with empty string - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Work in Progress', - 'director' => 'TBD', - 'year' => 2025, - 'price' => 0.0, - 'active' => false, - 'genres' => [], - 'with-dash' => 'Works3' - ])); + // Test notEndsWith with single character + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'l'), + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::user('x')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Work in Progress 2', - 'director' => 'TBD', - 'year' => 2026, - 'price' => 0.0, - 'active' => false, - 'genres' => [], - 'with-dash' => 'Works3', - 'nullable' => 'Not null' - ])); + // Test notEndsWith with case sensitivity (may be case-insensitive depending on DB) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'marvel'), // lowercase vs 'Marvel' + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // May match case-insensitively - return [ - '$sequence' => $document->getSequence() - ]; + // Test notEndsWith combined with limit + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'Marvel'), + Query::limit(3), + ]); + $this->assertEquals(3, count($documents)); // Limited to 3 results + $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies } - /** - * @depends testFind - */ - public function testFindOne(): void + public function testFindOrderRandom(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->findOne('movies', [ - Query::offset(2), - Query::orderAsc('name') - ]); + if (! $database->getAdapter()->supports(Capability::OrderRandom)) { + $this->expectNotToPerformAssertions(); - $this->assertFalse($document->isEmpty()); - $this->assertEquals('Frozen', $document->getAttribute('name')); + return; + } - $document = $database->findOne('movies', [ - Query::offset(10) + // Test orderRandom with default limit + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), + Query::limit(1), ]); - $this->assertTrue($document->isEmpty()); - } + $this->assertEquals(1, count($documents)); + $this->assertNotEmpty($documents[0]['name']); // Ensure we got a valid document - public function testFindBasicChecks(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test orderRandom with multiple documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), + Query::limit(3), + ]); + $this->assertEquals(3, count($documents)); - $documents = $database->find('movies'); - $movieDocuments = $documents; + // Test that orderRandom returns different results (not guaranteed but highly likely) + $firstSet = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), + Query::limit(3), + ]); + $secondSet = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), + Query::limit(3), + ]); - $this->assertEquals(5, count($documents)); - $this->assertNotEmpty($documents[0]->getId()); - $this->assertEquals('movies', $documents[0]->getCollection()); - $this->assertEquals(['any', 'user:1', 'user:2'], $documents[0]->getRead()); - $this->assertEquals(['any', 'user:1x', 'user:2x'], $documents[0]->getWrite()); - $this->assertEquals('Frozen', $documents[0]->getAttribute('name')); - $this->assertEquals('Chris Buck & Jennifer Lee', $documents[0]->getAttribute('director')); - $this->assertIsString($documents[0]->getAttribute('director')); - $this->assertEquals(2013, $documents[0]->getAttribute('year')); - $this->assertIsInt($documents[0]->getAttribute('year')); - $this->assertEquals(39.50, $documents[0]->getAttribute('price')); - $this->assertIsFloat($documents[0]->getAttribute('price')); - $this->assertEquals(true, $documents[0]->getAttribute('active')); - $this->assertIsBool($documents[0]->getAttribute('active')); - $this->assertEquals(['animation', 'kids'], $documents[0]->getAttribute('genres')); - $this->assertIsArray($documents[0]->getAttribute('genres')); - $this->assertEquals('Works', $documents[0]->getAttribute('with-dash')); - - // Alphabetical order - $sortedDocuments = $movieDocuments; - \usort($sortedDocuments, function ($doc1, $doc2) { - return strcmp($doc1['$id'], $doc2['$id']); - }); + // Extract IDs for comparison + $firstIds = array_map(fn ($doc) => $doc['$id'], $firstSet); + $secondIds = array_map(fn ($doc) => $doc['$id'], $secondSet); - $firstDocumentId = $sortedDocuments[0]->getId(); - $lastDocumentId = $sortedDocuments[\count($sortedDocuments) - 1]->getId(); + // While not guaranteed to be different, with 6 movies and selecting 3, + // the probability of getting the same set in the same order is very low + // We'll just check that we got valid results + $this->assertEquals(3, count($firstIds)); + $this->assertEquals(3, count($secondIds)); - /** - * Check $id: Notice, this orders ID names alphabetically, not by internal numeric ID - */ - $documents = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('$id'), - ]); - $this->assertEquals($lastDocumentId, $documents[0]->getId()); - $documents = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderAsc('$id'), + // Test orderRandom with more than available documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), + Query::limit(10), // We only have 6 movies ]); - $this->assertEquals($firstDocumentId, $documents[0]->getId()); + $this->assertLessThanOrEqual(6, count($documents)); // Should return all available documents - /** - * Check internal numeric ID sorting - */ - $documents = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc(''), + // Test orderRandom with filters + $documents = $database->find($this->getMoviesCollection(), [ + Query::greaterThan('price', 10), + Query::orderRandom(), + Query::limit(2), ]); - $this->assertEquals($movieDocuments[\count($movieDocuments) - 1]->getId(), $documents[0]->getId()); - $documents = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderAsc(''), + $this->assertLessThanOrEqual(2, count($documents)); + foreach ($documents as $document) { + $this->assertGreaterThan(10, $document['price']); + } + + // Test orderRandom without explicit limit (should use default) + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), ]); - $this->assertEquals($movieDocuments[0]->getId(), $documents[0]->getId()); + $this->assertGreaterThan(0, count($documents)); + $this->assertLessThanOrEqual(25, count($documents)); // Default limit is 25 } - public function testFindAttributeNamedAfterInternalKey(): void + public function testSum(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'collection', Database::VAR_STRING, 128, false)); + $this->getDatabase()->getAuthorization()->addRole('user:x'); - $database->createDocument(__FUNCTION__, new Document([ - '$id' => ID::custom('clash'), - '$permissions' => [ - Permission::read(Role::any()), - ], - 'collection' => 'value', - ])); + $sum = $database->sum($this->getMoviesCollection(), 'year', [Query::equal('year', [2019])]); + $this->assertEquals(2019 + 2019, $sum); + $sum = $database->sum($this->getMoviesCollection(), 'year'); + $this->assertEquals(2013 + 2019 + 2011 + 2019 + 2025 + 2026, $sum); + $sum = $database->sum($this->getMoviesCollection(), 'price', [Query::equal('year', [2019])]); + $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); + $sum = $database->sum($this->getMoviesCollection(), 'price', [Query::equal('year', [2019])]); + $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); - $database->createDocument(__FUNCTION__, new Document([ - '$id' => ID::custom('clashNull'), - '$permissions' => [ - Permission::read(Role::any()), - ], - 'collection' => null, - ])); + $sum = $database->sum($this->getMoviesCollection(), 'year', [Query::equal('year', [2019])], 1); + $this->assertEquals(2019, $sum); - $documents = $database->find(__FUNCTION__, [Query::orderAsc('$id')]); + $this->getDatabase()->getAuthorization()->removeRole('user:x'); - $this->assertCount(2, $documents); - $this->assertEquals('value', $documents[0]->getAttribute('collection')); - // getAttribute() reads a dropped key and a null value the same way - $this->assertTrue($documents[1]->offsetExists('collection')); + $sum = $database->sum($this->getMoviesCollection(), 'year', [Query::equal('year', [2019])]); + $this->assertEquals(2019 + 2019, $sum); + $sum = $database->sum($this->getMoviesCollection(), 'year'); + $this->assertEquals(2013 + 2019 + 2011 + 2019 + 2025, $sum); + $sum = $database->sum($this->getMoviesCollection(), 'price', [Query::equal('year', [2019])]); + $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); + $sum = $database->sum($this->getMoviesCollection(), 'price', [Query::equal('year', [2019])]); + $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); + + $this->getDatabase()->getAuthorization()->addRole('user:x'); } - public function testFindAttributeNamedAfterTenantKey(): void + public function testUpdateDocument(): void { + $document = $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); + $document = $database->getDocument($this->getDocumentsCollection(), $document->getId()); - if (!$database->getSharedTables()) { - $this->expectNotToPerformAssertions(); - return; - } + $document + ->setAttribute('string', 'text📝 updated') + ->setAttribute('integer_signed', -6) + ->setAttribute('integer_unsigned', 6) + ->setAttribute('float_signed', -5.56) + ->setAttribute('float_unsigned', 5.56) + ->setAttribute('boolean', false) + ->setAttribute('colors', 'red', SetType::Append) + ->setAttribute('with-dash', 'Works'); - $database->createCollection(__FUNCTION__); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'tenant', Database::VAR_STRING, 128, false)); + $new = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); - $database->createDocument(__FUNCTION__, new Document([ - '$id' => ID::custom('clash'), - '$permissions' => [ - Permission::read(Role::any()), - ], - 'tenant' => 'value', - ])); - - // A select leaves _tenant out of the projection, so $tenant is null at decode - $documents = $database->find(__FUNCTION__, [Query::select(['tenant'])]); + $this->assertNotEmpty($new->getId()); + $this->assertIsString($new->getAttribute('string')); + $this->assertEquals('text📝 updated', $new->getAttribute('string')); + $this->assertIsInt($new->getAttribute('integer_signed')); + $this->assertEquals(-6, $new->getAttribute('integer_signed')); + $this->assertIsInt($new->getAttribute('integer_unsigned')); + $this->assertEquals(6, $new->getAttribute('integer_unsigned')); + $this->assertIsFloat($new->getAttribute('float_signed')); + $this->assertEquals(-5.56, $new->getAttribute('float_signed')); + $this->assertIsFloat($new->getAttribute('float_unsigned')); + $this->assertEquals(5.56, $new->getAttribute('float_unsigned')); + $this->assertIsBool($new->getAttribute('boolean')); + $this->assertEquals(false, $new->getAttribute('boolean')); + $this->assertIsArray($new->getAttribute('colors')); + $this->assertEquals(['pink', 'green', 'blue', 'red'], $new->getAttribute('colors')); + $this->assertEquals('Works', $new->getAttribute('with-dash')); - $this->assertCount(1, $documents); - $this->assertEquals('value', $documents[0]->getAttribute('tenant')); - } + $oldPermissions = $document->getPermissions(); - public function testFindCheckPermissions(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $new + ->setAttribute('$permissions', Permission::read(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::create(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::update(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::delete(Role::guests()), SetType::Append); - /** - * Check Permissions - */ - $this->getDatabase()->getAuthorization()->addRole('user:x'); - $documents = $database->find('movies'); + $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); - $this->assertEquals(6, count($documents)); - } + $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); - public function testFindCheckInteger(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertContains('guests', $new->getPermissionsByType(PermissionType::Read)); + $this->assertContains('guests', $this->writeRoles($new)); + $this->assertContains('guests', $new->getPermissionsByType(PermissionType::Create)); + $this->assertContains('guests', $new->getPermissionsByType(PermissionType::Update)); + $this->assertContains('guests', $new->getPermissionsByType(PermissionType::Delete)); - /** - * Query with dash attribute - */ - $documents = $database->find('movies', [ - Query::equal('with-dash', ['Works']), - ]); + $new->setAttribute('$permissions', $oldPermissions); - $this->assertEquals(2, count($documents)); + $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); - $documents = $database->find('movies', [ - Query::equal('with-dash', ['Works2', 'Works3']), - ]); + $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); - $this->assertEquals(4, count($documents)); + $this->assertNotContains('guests', $new->getPermissionsByType(PermissionType::Read)); + $this->assertNotContains('guests', $this->writeRoles($new)); + $this->assertNotContains('guests', $new->getPermissionsByType(PermissionType::Create)); + $this->assertNotContains('guests', $new->getPermissionsByType(PermissionType::Update)); + $this->assertNotContains('guests', $new->getPermissionsByType(PermissionType::Delete)); - /** - * Check an Integer condition - */ - $documents = $database->find('movies', [ - Query::equal('year', [2019]), - ]); + // Test change document ID + $id = $new->getId(); + $newId = 'new-id'; + $new->setAttribute('$id', $newId); + $new = $this->getDatabase()->updateDocument($new->getCollection(), $id, $new); + $this->assertEquals($newId, $new->getId()); - $this->assertEquals(2, count($documents)); - $this->assertEquals('Frozen II', $documents[0]['name']); - $this->assertEquals('Captain Marvel', $documents[1]['name']); + // Reset ID + $new->setAttribute('$id', $id); + $new = $this->getDatabase()->updateDocument($new->getCollection(), $newId, $new); + $this->assertEquals($id, $new->getId()); } - public function testFindBoolean(): void + public function testDeleteDocument(): void { - /** @var Database $database */ - $database = $this->getDatabase(); + $document = $this->initDocumentsFixture(); + $result = $this->getDatabase()->deleteDocument($document->getCollection(), $document->getId()); + $deleted = $this->getDatabase()->getDocument($document->getCollection(), $document->getId()); - /** - * Boolean condition - */ - $documents = $database->find('movies', [ - Query::equal('active', [true]), - ]); + $this->assertEquals(true, $result); + $this->assertEquals(true, $deleted->isEmpty()); - $this->assertEquals(4, count($documents)); + // Re-create the fixture document so subsequent tests can use it + $recreated = $this->getDatabase()->createDocument($this->getDocumentsCollection(), $document); + self::$documentsFixtureDoc = $recreated; } - public function testFindStringQueryEqual(): void + public function testUpdateDocumentIgnoresCallerSuppliedSequence(): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * String condition - */ - $documents = $database->find('movies', [ - Query::equal('director', ['TBD']), - ]); + $collection = 'updateDocumentSequenceTargeting'; - $this->assertEquals(2, count($documents)); + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 128, required: true)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); - $documents = $database->find('movies', [ - Query::equal('director', ['']), - ]); + $bystander = $database->createDocument($collection, new Document([ + Document::ID => 'bystander', + 'name' => 'untouched', + ])); + $target = $database->createDocument($collection, new Document([ + Document::ID => 'target', + 'name' => 'before', + ])); - $this->assertEquals(0, count($documents)); - } + $this->assertNotEmpty($bystander->getSequence()); + $this->assertNotSame($bystander->getSequence(), $target->getSequence()); + + $updated = $database->updateDocument($collection, 'target', new Document([ + Document::SEQUENCE => $bystander->getSequence(), + 'name' => 'after', + ])); + $reloadedBystander = $database->getDocument($collection, 'bystander'); + $this->assertSame('untouched', $reloadedBystander->getAttribute('name')); + $this->assertSame($bystander->getSequence(), $reloadedBystander->getSequence()); - public function testFindNotEqual(): void + $reloadedTarget = $database->getDocument($collection, 'target'); + $this->assertSame('after', $reloadedTarget->getAttribute('name')); + $this->assertSame($target->getSequence(), $reloadedTarget->getSequence()); + + $this->assertSame('after', $updated->getAttribute('name')); + $this->assertSame($target->getSequence(), $updated->getSequence()); + } + + public function testUpdateDocuments(): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * Not Equal query - */ - $documents = $database->find('movies', [ - Query::notEqual('director', 'TBD'), - ]); - - $this->assertGreaterThan(0, count($documents)); + if (! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); - foreach ($documents as $document) { - $this->assertTrue($document['director'] !== 'TBD'); + return; } - $documents = $database->find('movies', [ - Query::notEqual('director', ''), - ]); + $collection = 'testUpdateDocuments'; + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - $total = $database->count('movies'); + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'string', size: 100), + Attribute::integer(key: 'integer', width: IntegerWidth::Bits64), + Attribute::boolean(key: 'boolean', default: false), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: false)); - $this->assertEquals($total, count($documents)); - } + for ($i = 0; $i < 10; $i++) { + $database->createDocument($collection, new Document([ + '$id' => 'doc'.$i, + 'string' => 'text📝 '.$i, + 'integer' => $i, + 'boolean' => true, + ])); + } - public function testFindBetween(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test Update half of the documents + $results = []; + $count = $database->updateDocuments($collection, new Document([ + 'string' => 'text📝 updated', + ]), [ + Query::greaterThanEqual('integer', 5), + ], onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); - $documents = $database->find('movies', [ - Query::between('price', 25.94, 25.99), - ]); - $this->assertEquals(2, count($documents)); + $this->assertEquals(5, $count); - $documents = $database->find('movies', [ - Query::between('price', 30, 35), - ]); - $this->assertEquals(0, count($documents)); + foreach ($results as $document) { + $this->assertEquals('text📝 updated', $document->getAttribute('string')); + $this->assertTrue($document->getAttribute('boolean')); + } - $documents = $database->find('movies', [ - Query::between('$createdAt', '1975-12-06', '2050-12-06'), + $updatedDocuments = $database->find($collection, [ + Query::greaterThanEqual('integer', 5), ]); - $this->assertEquals(6, count($documents)); - $documents = $database->find('movies', [ - Query::between('$updatedAt', '1975-12-06T07:08:49.733+02:00', '2050-02-05T10:15:21.825+00:00'), - ]); - $this->assertEquals(6, count($documents)); - } + $this->assertCount(5, $updatedDocuments); - public function testFindFloat(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + foreach ($updatedDocuments as $document) { + $this->assertEquals('text📝 updated', $document->getAttribute('string')); + $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); + $this->assertTrue($document->getAttribute('boolean')); + } - /** - * Float condition - */ - $documents = $database->find('movies', [ - Query::lessThan('price', 26.00), - Query::greaterThan('price', 25.98), + $controlDocuments = $database->find($collection, [ + Query::lessThan('integer', 5), ]); - $this->assertEquals(1, count($documents)); - } - - public function testFindContains(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals(count($controlDocuments), 5); - if (!$database->getAdapter()->getSupportForQueryContains()) { - $this->expectNotToPerformAssertions(); - return; + foreach ($controlDocuments as $document) { + $this->assertNotEquals('text📝 updated', $document->getAttribute('string')); } - $documents = $database->find('movies', [ - Query::contains('genres', ['comics']) - ]); - - $this->assertEquals(2, count($documents)); + // Test Update all documents + $this->assertEquals(10, $database->updateDocuments($collection, new Document([ + 'string' => 'text📝 updated all', + ]))); - /** - * Array contains OR condition - */ - $documents = $database->find('movies', [ - Query::contains('genres', ['comics', 'kids']), - ]); + $updatedDocuments = $database->find($collection); - $this->assertEquals(4, count($documents)); + $this->assertEquals(count($updatedDocuments), 10); - $documents = $database->find('movies', [ - Query::contains('genres', ['non-existent']), - ]); + foreach ($updatedDocuments as $document) { + $this->assertEquals('text📝 updated all', $document->getAttribute('string')); + } - $this->assertEquals(0, count($documents)); + // TEST: Can't delete documents in the past + $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); try { - $database->find('movies', [ - Query::contains('price', [10.5]), - ]); + $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () use ($collection, $database) { + $database->updateDocuments($collection, new Document([ + 'string' => 'text📝 updated all', + ])); + }); $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query contains on attribute "price" because it is not an array, string, or object.', $e->getMessage()); - $this->assertTrue($e instanceof DatabaseException); + } catch (ConflictException $e) { + $this->assertEquals('Document was updated after the request timestamp', $e->getMessage()); } - } - - public function testFindFulltext(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - /** - * Fulltext search - */ - if ($this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { - $success = $database->createIndex('movies', 'name', Database::INDEX_FULLTEXT, ['name']); - $this->assertEquals(true, $success); + // Check collection level permissions + $database->updateCollection($collection, new CollectionUpdate(permissions: [ + Permission::read(Role::user('asd')), + Permission::create(Role::user('asd')), + Permission::update(Role::user('asd')), + Permission::delete(Role::user('asd')), + ], documentSecurity: false)); - $documents = $database->find('movies', [ - Query::search('name', 'captain'), - ]); + try { + $database->updateDocuments($collection, new Document([ + 'string' => 'text📝 updated all', + ])); + $this->fail('Failed to throw exception'); + } catch (AuthorizationException $e) { + $this->assertStringStartsWith('Missing "update" permission for role "user:asd".', $e->getMessage()); + } - $this->assertEquals(2, count($documents)); + // Check document level permissions + $database->updateCollection($collection, new CollectionUpdate(permissions: [], documentSecurity: true)); - /** - * Fulltext search (wildcard) - */ - - // TODO: Looks like the MongoDB implementation is a bit more complex, skipping that for now. - // TODO: I think this needs a changes? how do we distinguish between regular full text and wildcard? - - if ($this->getDatabase()->getAdapter()->getSupportForFulltextWildCardIndex()) { - $documents = $database->find('movies', [ - Query::search('name', 'cap'), - ]); - - $this->assertEquals(2, count($documents)); - } - } - - $this->assertEquals(true, true); // Test must do an assertion - } - public function testFindFulltextSpecialChars(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForFulltextIndex()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collection = 'full_text'; - $database->createCollection($collection, permissions: [ - Permission::create(Role::any()), - Permission::update(Role::users()) - ]); + $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { + $database->updateDocument($collection, 'doc0', new Document([ + 'string' => 'text📝 updated all', + '$permissions' => [ + Permission::read(Role::user('asd')), + Permission::create(Role::user('asd')), + Permission::update(Role::user('asd')), + Permission::delete(Role::user('asd')), + ], + ])); + }); - $this->assertTrue($database->createAttribute($collection, 'ft', Database::VAR_STRING, 128, true)); - $this->assertTrue($database->createIndex($collection, 'ft-index', Database::INDEX_FULLTEXT, ['ft'])); + $this->getDatabase()->getAuthorization()->addRole(Role::user('asd')->toString()); - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'ft' => 'Alf: chapter_4@nasa.com' + $database->updateDocuments($collection, new Document([ + 'string' => 'permission text', ])); $documents = $database->find($collection, [ - Query::search('ft', 'chapter_4'), + Query::equal('string', ['permission text']), ]); - $this->assertEquals(1, count($documents)); - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'ft' => 'al@ba.io +-*)(<>~' - ])); + $this->assertCount(1, $documents); - $documents = $database->find($collection, [ - Query::search('ft', 'al@ba.io'), // === al ba io* - ]); + $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { + $unmodifiedDocuments = $database->find($collection, [ + Query::equal('string', ['text📝 updated all']), + ]); - if ($database->getAdapter()->getSupportForFulltextWildcardIndex()) { - $this->assertEquals(0, count($documents)); - } else { - $this->assertEquals(1, count($documents)); - } + $this->assertCount(9, $unmodifiedDocuments); + }); - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'ft' => 'donald duck' - ])); + $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { + $database->updateDocuments($collection, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); + }); - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'ft' => 'donald trump' - ])); + // Test we can update more documents than batchSize + $this->assertEquals(10, $database->updateDocuments($collection, new Document([ + 'string' => 'batchSize Test', + ]), batchSize: 2)); - $documents = $database->find($collection, [ - Query::search('ft', 'donald trump'), - Query::orderAsc('ft'), - ]); - $this->assertEquals(2, count($documents)); + $documents = $database->find($collection); - $documents = $database->find($collection, [ - Query::search('ft', '"donald trump"'), // Exact match - ]); + foreach ($documents as $document) { + $this->assertEquals('batchSize Test', $document->getAttribute('string')); + } - $this->assertEquals(1, count($documents)); + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); } - /** - * Regression: accented characters and non-operator special chars - * previously caused SQLSTATE[42000] syntax error in FTS BOOLEAN MODE. - * - * @see https://appwrite.sentry.io/issues/5628237003 - */ - public function testFindFulltextAccentedAndSpecialChars(): void + public function testUpdateDocumentsWithCallbackSupport(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForFulltextIndex()) { + if (! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $collection = 'full_text_unicode'; - $database->createCollection($collection, permissions: [ - Permission::create(Role::any()), - Permission::update(Role::users()) - ]); - - $this->assertTrue($database->createAttribute($collection, 'nombre', Database::VAR_STRING, 128, true)); - $this->assertTrue($database->createIndex($collection, 'nombre-ft', Database::INDEX_FULLTEXT, ['nombre'])); - - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'nombre' => 'Luis García' - ])); - - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'nombre' => 'Álvaro Yair Cuéllar' - ])); - - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'nombre' => 'Fernando naïve über' - ])); + $collection = 'update_callback'; + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - /** - * Accented characters must not cause FTS parser errors - */ - $documents = $database->find($collection, [ - Query::search('nombre', 'García'), - ]); - $this->assertGreaterThanOrEqual(1, count($documents)); + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'string', size: 100), + Attribute::integer(key: 'integer', width: IntegerWidth::Bits64), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: false)); - $documents = $database->find($collection, [ - Query::search('nombre', 'Álvaro'), - ]); - $this->assertGreaterThanOrEqual(1, count($documents)); + for ($i = 0; $i < 10; $i++) { + $database->createDocument($collection, new Document([ + '$id' => 'doc'.$i, + 'string' => 'text📝 '.$i, + 'integer' => $i, + ])); + } + // Test onNext is throwing the error without the onError + // a non existent document to test the error thrown + try { + $results = []; + $count = $database->updateDocuments($collection, new Document([ + 'string' => 'text📝 updated', + ]), [ + Query::greaterThanEqual('integer', 100), + ], onNext: function ($doc) use (&$results) { + $results[] = $doc; + throw new Exception("Error thrown to test that update doesn't stop and error is caught"); + }); + } catch (Exception $e) { + $this->assertEquals("Error thrown to test that update doesn't stop and error is caught", $e->getMessage()); + } - $documents = $database->find($collection, [ - Query::search('nombre', 'Cuéllar'), - ]); - $this->assertGreaterThanOrEqual(1, count($documents)); + // Test Update half of the documents + $results = []; + $count = $database->updateDocuments($collection, new Document([ + 'string' => 'text📝 updated', + ]), [ + Query::greaterThanEqual('integer', 5), + ], onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); - /** - * Non-operator special chars (! . #) were not stripped by old code, - * producing values like "!!!...###*" that crash MySQL's FTS parser. - */ - $documents = $database->find($collection, [ - Query::search('nombre', '!!!...###'), - ]); - $this->assertEquals(0, count($documents)); + $this->assertEquals(5, $count); - $documents = $database->find($collection, [ - Query::search('nombre', '$$$%%%^^^'), - ]); - $this->assertEquals(0, count($documents)); + foreach ($results as $document) { + $this->assertEquals('text📝 updated', $document->getAttribute('string')); + } - /** - * FTS operator-only input also must not error - */ - $documents = $database->find($collection, [ - Query::search('nombre', '+-*@<>~'), + $updatedDocuments = $database->find($collection, [ + Query::greaterThanEqual('integer', 5), ]); - $this->assertEquals(0, count($documents)); - /** - * Mixed special chars + accented word should still find results - */ - $documents = $database->find($collection, [ - Query::search('nombre', '@García!'), - ]); - $this->assertGreaterThanOrEqual(1, count($documents)); + $this->assertCount(5, $updatedDocuments); } - public function testFindMultipleConditions(): void + public function testReadPermissionsSuccess(): void { + $this->initDocumentsFixture(); + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * Multiple conditions - */ - $documents = $database->find('movies', [ - Query::equal('director', ['TBD']), - Query::equal('year', [2026]), - ]); + $document = $database->createDocument($this->getDocumentsCollection(), new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'string' => 'text📝', + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, + 'float_signed' => -5.55, + 'float_unsigned' => 5.55, + 'boolean' => true, + 'colors' => ['pink', 'green', 'blue'], + ])); - $this->assertEquals(1, count($documents)); + $this->assertEquals(false, $document->isEmpty()); - /** - * Multiple conditions and OR values - */ - $documents = $database->find('movies', [ - Query::equal('name', ['Frozen II', 'Captain Marvel']), - ]); + $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->assertEquals(2, count($documents)); - $this->assertEquals('Frozen II', $documents[0]['name']); - $this->assertEquals('Captain Marvel', $documents[1]['name']); + $document = $database->getDocument($document->getCollection(), $document->getId()); + $this->assertEquals(true, $document->isEmpty()); + + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); } - public function testFindByID(): void + public function testWritePermissionsSuccess(): void { + $this->initDocumentsFixture(); + $this->getDatabase()->getAuthorization()->cleanRoles(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * $id condition - */ - $documents = $database->find('movies', [ - Query::equal('$id', ['frozen']), - ]); - - $this->assertEquals(1, count($documents)); - $this->assertEquals('Frozen', $documents[0]['name']); + $this->expectException(AuthorizationException::class); + $database->createDocument($this->getDocumentsCollection(), new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'string' => 'text📝', + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, + 'float_signed' => -5.55, + 'float_unsigned' => 5.55, + 'boolean' => true, + 'colors' => ['pink', 'green', 'blue'], + ])); } - /** - * @depends testFind - * @param array $data - * @return void - * @throws \Utopia\Database\Exception - */ - public function testFindByInternalID(array $data): void + + public function testWritePermissionsUpdateFailure(): void { + $this->initDocumentsFixture(); + $this->expectException(AuthorizationException::class); + + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * Test that internal ID queries are handled correctly - */ - $documents = $database->find('movies', [ - Query::equal('$sequence', [$data['$sequence']]), - ]); + $document = $database->createDocument($this->getDocumentsCollection(), new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'string' => 'text📝', + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, + 'float_signed' => -5.55, + 'float_unsigned' => 5.55, + 'boolean' => true, + 'colors' => ['pink', 'green', 'blue'], + ])); + + $this->getDatabase()->getAuthorization()->cleanRoles(); + + $document = $database->updateDocument($this->getDocumentsCollection(), $document->getId(), new Document([ + '$id' => Id::custom($document->getId()), + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'string' => 'text📝', + 'integer_signed' => 6, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'float_signed' => -Database::MAX_DOUBLE, + 'float_unsigned' => Database::MAX_DOUBLE, + 'boolean' => true, + 'colors' => ['pink', 'green', 'blue'], + ])); - $this->assertEquals(1, count($documents)); } - public function testFindOrderBy(): void + public function testUniqueIndexDuplicate(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - */ - $documents = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('name') - ]); + $this->assertSame('uniqueIndex', $database->createIndex($this->getMoviesCollection(), Index::unique(key: 'uniqueIndex', attributes: ['name'], lengths: [128], orders: [OrderDirection::Asc]))->key); - $this->assertEquals(6, count($documents)); - $this->assertEquals('Frozen', $documents[0]['name']); - $this->assertEquals('Frozen II', $documents[1]['name']); - $this->assertEquals('Captain Marvel', $documents[2]['name']); - $this->assertEquals('Captain America: The First Avenger', $documents[3]['name']); - $this->assertEquals('Work in Progress', $documents[4]['name']); - $this->assertEquals('Work in Progress 2', $documents[5]['name']); + try { + $database->createDocument($this->getMoviesCollection(), new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user('1')), + Permission::read(Role::user('2')), + Permission::create(Role::any()), + Permission::create(Role::user('1x')), + Permission::create(Role::user('2x')), + Permission::update(Role::any()), + Permission::update(Role::user('1x')), + Permission::update(Role::user('2x')), + Permission::delete(Role::any()), + Permission::delete(Role::user('1x')), + Permission::delete(Role::user('2x')), + ], + 'name' => 'Frozen', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + 'price' => 39.50, + 'active' => true, + 'genres' => ['animation', 'kids'], + 'with-dash' => 'Works4', + ])); + + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + $this->assertInstanceOf(UniqueException::class, $e); + } } - public function testFindOrderByNatural(): void + + public function testUniqueIndexDuplicateUpdate(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY natural - */ - $base = array_reverse($database->find('movies', [ - Query::limit(25), - Query::offset(0), + // Ensure the unique index exists (created in testUniqueIndexDuplicate) + try { + $database->createIndex($this->getMoviesCollection(), Index::unique(key: 'uniqueIndex', attributes: ['name'], lengths: [128], orders: [OrderDirection::Asc])); + } catch (\Throwable) { + // Index may already exist + } + + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + // create document then update to conflict with index + $document = $database->createDocument($this->getMoviesCollection(), new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user('1')), + Permission::read(Role::user('2')), + Permission::create(Role::any()), + Permission::create(Role::user('1x')), + Permission::create(Role::user('2x')), + Permission::update(Role::any()), + Permission::update(Role::user('1x')), + Permission::update(Role::user('2x')), + Permission::delete(Role::any()), + Permission::delete(Role::user('1x')), + Permission::delete(Role::user('2x')), + ], + 'name' => 'Frozen 5', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + 'price' => 39.50, + 'active' => true, + 'genres' => ['animation', 'kids'], + 'with-dash' => 'Works4', ])); - $documents = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc(''), - ]); - $this->assertEquals(6, count($documents)); - $this->assertEquals($base[0]['name'], $documents[0]['name']); - $this->assertEquals($base[1]['name'], $documents[1]['name']); - $this->assertEquals($base[2]['name'], $documents[2]['name']); - $this->assertEquals($base[3]['name'], $documents[3]['name']); - $this->assertEquals($base[4]['name'], $documents[4]['name']); - $this->assertEquals($base[5]['name'], $documents[5]['name']); + try { + $database->updateDocument($this->getMoviesCollection(), $document->getId(), $document->setAttribute('name', 'Frozen')); + + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + $this->assertInstanceOf(UniqueException::class, $e); + } + + $database->deleteDocument($this->getMoviesCollection(), $document->getId()); } - public function testFindOrderByMultipleAttributes(): void + + public function propagateBulkDocuments(string $collection, int $amount = 10, bool $documentSecurity = false): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - Multiple attributes - */ - $documents = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('price'), - Query::orderDesc('name') - ]); - - $this->assertEquals(6, count($documents)); - $this->assertEquals('Frozen II', $documents[0]['name']); - $this->assertEquals('Frozen', $documents[1]['name']); - $this->assertEquals('Captain Marvel', $documents[2]['name']); - $this->assertEquals('Captain America: The First Avenger', $documents[3]['name']); - $this->assertEquals('Work in Progress 2', $documents[4]['name']); - $this->assertEquals('Work in Progress', $documents[5]['name']); + for ($i = 0; $i < $amount; $i++) { + $database->createDocument($collection, new Document( + array_merge([ + '$id' => 'doc'.$i, + 'text' => 'value'.$i, + 'integer' => $i, + ], $documentSecurity ? [ + '$permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ] : []) + )); + } } - public function testFindOrderByCursorAfter(): void + public function testFulltextIndexWithInteger(): void { + $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - After - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - ]); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectException(Exception::class); + if (! $this->getDatabase()->getAdapter()->supports(Capability::IndexFulltext)) { + $this->expectExceptionMessage('Fulltext index is not supported'); + } else { + $this->expectExceptionMessage('Attribute "integer_signed" cannot be part of a fulltext index, must be of type string'); + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorAfter($movies[1]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[2]['name'], $documents[0]['name']); - $this->assertEquals($movies[3]['name'], $documents[1]['name']); + $database->createIndex($this->getDocumentsCollection(), Index::fulltext(key: 'fulltext_integer', attributes: ['string', 'integer_signed'])); + } else { + $this->expectNotToPerformAssertions(); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorAfter($movies[3]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[4]['name'], $documents[0]['name']); - $this->assertEquals($movies[5]['name'], $documents[1]['name']); + return; + } + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorAfter($movies[4]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[5]['name'], $documents[0]['name']); + public function testEnableDisableValidation(): void + { + $database = $this->getDatabase(); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorAfter($movies[5]) - ]); - $this->assertEmpty(count($documents)); + $database->createCollection(Collection::create(id: 'validation', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - /** - * Multiple order by, Test tie-break on year 2019 - */ - $movies = $database->find('movies', [ - Query::orderAsc('year'), - Query::orderAsc('price'), - ]); - - $this->assertEquals(6, count($movies)); - - $this->assertEquals($movies[0]['name'], 'Captain America: The First Avenger'); - $this->assertEquals($movies[0]['year'], 2011); - $this->assertEquals($movies[0]['price'], 25.94); - - $this->assertEquals($movies[1]['name'], 'Frozen'); - $this->assertEquals($movies[1]['year'], 2013); - $this->assertEquals($movies[1]['price'], 39.5); - - $this->assertEquals($movies[2]['name'], 'Captain Marvel'); - $this->assertEquals($movies[2]['year'], 2019); - $this->assertEquals($movies[2]['price'], 25.99); + $database->createAttribute('validation', Attribute::string(key: 'name', size: 10)); - $this->assertEquals($movies[3]['name'], 'Frozen II'); - $this->assertEquals($movies[3]['year'], 2019); - $this->assertEquals($movies[3]['price'], 39.5); + $database->createDocument('validation', new Document([ + '$id' => 'docwithmorethan36charsasitsidentifier', + 'name' => 'value1', + ])); - $this->assertEquals($movies[4]['name'], 'Work in Progress'); - $this->assertEquals($movies[4]['year'], 2025); - $this->assertEquals($movies[4]['price'], 0); + try { + $database->find('validation', queries: [ + Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertNotSame('', $e->getMessage()); + } - $this->assertEquals($movies[5]['name'], 'Work in Progress 2'); - $this->assertEquals($movies[5]['year'], 2026); - $this->assertEquals($movies[5]['price'], 0); + $database->setValidation(false); - $pos = 2; - $documents = $database->find('movies', [ - Query::orderAsc('year'), - Query::orderAsc('price'), - Query::cursorAfter($movies[$pos]) + $database->find('validation', queries: [ + Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), ]); - $this->assertEquals(3, count($documents)); + $database->setValidation(true); - foreach ($documents as $i => $document) { - $this->assertEquals($document['name'], $movies[$i + 1 + $pos]['name']); - $this->assertEquals($document['price'], $movies[$i + 1 + $pos]['price']); - $this->assertEquals($document['year'], $movies[$i + 1 + $pos]['year']); + try { + $database->find('validation', queries: [ + Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertNotSame('', $e->getMessage()); } - } + $database->skipValidation(function () use ($database) { + $database->find('validation', queries: [ + Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), + ]); + }); + + $database->setValidation(true); + } - public function testFindOrderByCursorBefore(): void + public function testExceptionDuplicate(): void { + $document = $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - Before - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - ]); - - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorBefore($movies[5]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[3]['name'], $documents[0]['name']); - $this->assertEquals($movies[4]['name'], $documents[1]['name']); - - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorBefore($movies[3]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[1]['name'], $documents[0]['name']); - $this->assertEquals($movies[2]['name'], $documents[1]['name']); - - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorBefore($movies[2]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $this->assertEquals($movies[1]['name'], $documents[1]['name']); + $document->setAttribute('$id', 'duplicated'); + $document->removeAttribute('$sequence'); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorBefore($movies[1]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $database->createDocument($document->getCollection(), $document); + $document->removeAttribute('$sequence'); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorBefore($movies[0]) - ]); - $this->assertEmpty(count($documents)); + try { + $database->createDocument($document->getCollection(), $document); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } } - public function testFindOrderByAfterNaturalOrder(): void + public function testExceptionCaseInsensitiveDuplicate(): void { + $document = $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - After by natural order - */ - $movies = array_reverse($database->find('movies', [ - Query::limit(25), - Query::offset(0), - ])); - - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorAfter($movies[1]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[2]['name'], $documents[0]['name']); - $this->assertEquals($movies[3]['name'], $documents[1]['name']); + $document->setAttribute('$id', 'caseSensitive'); + $document->removeAttribute('$sequence'); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorAfter($movies[3]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[4]['name'], $documents[0]['name']); - $this->assertEquals($movies[5]['name'], $documents[1]['name']); + $database->createDocument($document->getCollection(), $document); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorAfter($movies[4]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[5]['name'], $documents[0]['name']); + $document->setAttribute('$id', 'CaseSensitive'); + $document->removeAttribute('$sequence'); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorAfter($movies[5]) - ]); - $this->assertEmpty(count($documents)); + try { + $database->createDocument($document->getCollection(), $document); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } } - public function testFindOrderByBeforeNaturalOrder(): void + + public function testEmptyTenant(): void { + $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - Before by natural order - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc(''), - ]); + if ($database->getAdapter()->hasSharedTables()) { + $documents = $database->find( + $this->getDocumentsCollection(), + [Query::select(['*'])] // Mongo bug with Integer UID + ); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorBefore($movies[5]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[3]['name'], $documents[0]['name']); - $this->assertEquals($movies[4]['name'], $documents[1]['name']); + $document = $documents[0]; + $doc = $database->getDocument($document->getCollection(), $document->getId()); + $this->assertEquals($document->getTenant(), $doc->getTenant()); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorBefore($movies[3]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[1]['name'], $documents[0]['name']); - $this->assertEquals($movies[2]['name'], $documents[1]['name']); + return; + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorBefore($movies[2]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $this->assertEquals($movies[1]['name'], $documents[1]['name']); + $doc = $database->createDocument($this->getDocumentsCollection(), new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'string' => 'tenant_test', + 'integer_signed' => 1, + 'integer_unsigned' => 1, + 'bigint_signed' => 1, + 'bigint_unsigned' => 1, + 'float_signed' => 1.0, + 'float_unsigned' => 1.0, + 'boolean' => true, + 'colors' => ['red'], + 'empty' => [], + 'with-dash' => 'test', + ])); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorBefore($movies[1]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertArrayHasKey('$id', $doc); + $this->assertArrayNotHasKey('$tenant', $doc); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorBefore($movies[0]) - ]); - $this->assertEmpty(count($documents)); + $document = $database->getDocument($this->getDocumentsCollection(), $doc->getId()); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayNotHasKey('$tenant', $document); + + $document = $database->updateDocument($this->getDocumentsCollection(), $document->getId(), $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayNotHasKey('$tenant', $document); + + $database->deleteDocument($this->getDocumentsCollection(), $document->getId()); } - public function testFindOrderBySingleAttributeAfter(): void + public function testDateTimeDocument(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - /** - * ORDER BY - Single Attribute After + * @var Database $database */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('year') - ]); - - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorAfter($movies[1]) - ]); + $database = $this->getDatabase(); + $collection = 'create_modify_dates'; + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('string', $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))->key); + $this->assertSame('datetime', $database->createAttribute($collection, Attribute::datetime(key: 'datetime'))->key); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[2]['name'], $documents[0]['name']); - $this->assertEquals($movies[3]['name'], $documents[1]['name']); + $date = '2000-01-01T10:00:00.000+00:00'; + // test - default behaviour of external datetime attribute not changed + $doc = $database->createDocument($collection, new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())], + 'datetime' => '', + ])); + $this->assertNotEmpty($doc->getAttribute('datetime')); + $this->assertNotEmpty($doc->getAttribute('$createdAt')); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorAfter($movies[3]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[4]['name'], $documents[0]['name']); - $this->assertEquals($movies[5]['name'], $documents[1]['name']); + $doc = $database->getDocument($collection, 'doc1'); + $this->assertNotEmpty($doc->getAttribute('datetime')); + $this->assertNotEmpty($doc->getAttribute('$createdAt')); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorAfter($movies[4]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[5]['name'], $documents[0]['name']); + $database->setPreserveDates(true); + // test - modifying $createdAt and $updatedAt + $doc = $database->createDocument($collection, new Document([ + '$id' => 'doc2', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())], + '$createdAt' => $date, + ])); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorAfter($movies[5]) - ]); - $this->assertEmpty(count($documents)); - } + $this->assertEquals($doc->getAttribute('$createdAt'), $date); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); + $doc = $database->getDocument($collection, 'doc2'); - public function testFindOrderBySingleAttributeBefore(): void + $this->assertEquals($doc->getAttribute('$createdAt'), $date); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); + + $database->setPreserveDates(false); + $database->deleteCollection($collection); + } + + public function testUpsertDateOperations(): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - Single Attribute Before - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('year') - ]); - - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorBefore($movies[5]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[3]['name'], $documents[0]['name']); - $this->assertEquals($movies[4]['name'], $documents[1]['name']); + if (! ($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorBefore($movies[3]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[1]['name'], $documents[0]['name']); - $this->assertEquals($movies[2]['name'], $documents[1]['name']); + return; + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorBefore($movies[2]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $this->assertEquals($movies[1]['name'], $documents[1]['name']); + $collection = 'upsert_date_operations'; + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('string', $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))->key); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorBefore($movies[1]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $database->setPreserveDates(true); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorBefore($movies[0]) - ]); - $this->assertEmpty(count($documents)); - } + $createDate = '2000-01-01T10:00:00.000+00:00'; + $updateDate = '2000-02-01T15:30:00.000+00:00'; + $date1 = '2000-01-01T10:00:00.000+00:00'; + $date2 = '2000-02-01T15:30:00.000+00:00'; + $date3 = '2000-03-01T20:45:00.000+00:00'; + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())]; - public function testFindOrderByMultipleAttributeAfter(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test 1: Upsert new document with custom createdAt + $upsertResults = []; + $database->upsertDocuments($collection, [ + new Document([ + '$id' => 'upsert1', + '$permissions' => $permissions, + 'string' => 'upsert1_initial', + '$createdAt' => $createDate, + ]), + ], onNext: function ($doc) use (&$upsertResults) { + $upsertResults[] = $doc; + }); + $upsertDoc1 = $upsertResults[0]; - /** - * ORDER BY - Multiple Attribute After - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year') - ]); + $this->assertEquals($createDate, $upsertDoc1->getAttribute('$createdAt')); + $this->assertNotEquals($createDate, $upsertDoc1->getAttribute('$updatedAt')); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorAfter($movies[1]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[2]['name'], $documents[0]['name']); - $this->assertEquals($movies[3]['name'], $documents[1]['name']); + // Test 2: Upsert existing document with custom updatedAt + $upsertDoc1->setAttribute('string', 'upsert1_updated'); + $upsertDoc1->setAttribute('$updatedAt', $updateDate); + $updatedUpsertResults = []; + $database->upsertDocuments($collection, [$upsertDoc1], onNext: function ($doc) use (&$updatedUpsertResults) { + $updatedUpsertResults[] = $doc; + }); + $updatedUpsertDoc1 = $updatedUpsertResults[0]; - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorAfter($movies[3]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[4]['name'], $documents[0]['name']); - $this->assertEquals($movies[5]['name'], $documents[1]['name']); + $this->assertEquals($createDate, $updatedUpsertDoc1->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $updatedUpsertDoc1->getAttribute('$updatedAt')); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorAfter($movies[4]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[5]['name'], $documents[0]['name']); + // Test 3: Upsert new document with both custom dates + $upsertResults2 = []; + $database->upsertDocuments($collection, [ + new Document([ + '$id' => 'upsert2', + '$permissions' => $permissions, + 'string' => 'upsert2_both_dates', + '$createdAt' => $createDate, + '$updatedAt' => $updateDate, + ]), + ], onNext: function ($doc) use (&$upsertResults2) { + $upsertResults2[] = $doc; + }); + $upsertDoc2 = $upsertResults2[0]; - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorAfter($movies[5]) - ]); - $this->assertEmpty(count($documents)); - } + $this->assertEquals($createDate, $upsertDoc2->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $upsertDoc2->getAttribute('$updatedAt')); - public function testFindOrderByMultipleAttributeBefore(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test 4: Upsert existing document with different dates + $upsertDoc2->setAttribute('string', 'upsert2_updated'); + $upsertDoc2->setAttribute('$createdAt', $date3); + $upsertDoc2->setAttribute('$updatedAt', $date3); + $updatedUpsertResults2 = []; + $database->upsertDocuments($collection, [$upsertDoc2], onNext: function ($doc) use (&$updatedUpsertResults2) { + $updatedUpsertResults2[] = $doc; + }); + $updatedUpsertDoc2 = $updatedUpsertResults2[0]; - /** - * ORDER BY - Multiple Attribute Before - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year') - ]); + $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$createdAt')); + $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$updatedAt')); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[5]) - ]); + // Test 5: Upsert with preserve dates disabled + $database->setPreserveDates(false); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[3]['name'], $documents[0]['name']); - $this->assertEquals($movies[4]['name'], $documents[1]['name']); + $customDate = '2000-01-01T10:00:00.000+00:00'; + $upsertResults3 = []; + $database->upsertDocuments($collection, [ + new Document([ + '$id' => 'upsert3', + '$permissions' => $permissions, + 'string' => 'upsert3_disabled', + '$createdAt' => $customDate, + '$updatedAt' => $customDate, + ]), + ], onNext: function ($doc) use (&$upsertResults3) { + $upsertResults3[] = $doc; + }); + $upsertDoc3 = $upsertResults3[0]; - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[4]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[2]['name'], $documents[0]['name']); - $this->assertEquals($movies[3]['name'], $documents[1]['name']); + $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$createdAt')); + $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$updatedAt')); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[2]) - ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $this->assertEquals($movies[1]['name'], $documents[1]['name']); + // Update with custom dates should also be ignored + $upsertDoc3->setAttribute('string', 'upsert3_updated'); + $upsertDoc3->setAttribute('$createdAt', $customDate); + $upsertDoc3->setAttribute('$updatedAt', $customDate); + $updatedUpsertResults3 = []; + $database->upsertDocuments($collection, [$upsertDoc3], onNext: function ($doc) use (&$updatedUpsertResults3) { + $updatedUpsertResults3[] = $doc; + }); + $updatedUpsertDoc3 = $updatedUpsertResults3[0]; - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[1]) - ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$createdAt')); + $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$updatedAt')); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[0]) - ]); - $this->assertEmpty(count($documents)); - } - public function testFindOrderByAndCursor(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test 6: Bulk upsert operations with custom dates + $database->setPreserveDates(true); - /** - * ORDER BY + CURSOR - */ - $documentsTest = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - ]); - $documents = $database->find('movies', [ - Query::limit(1), - Query::offset(0), - Query::orderDesc('price'), - Query::cursorAfter($documentsTest[0]) - ]); + // Test 7: Bulk upsert with different date configurations + $upsertDocuments = [ + new Document([ + '$id' => 'bulk_upsert1', + '$permissions' => $permissions, + 'string' => 'bulk_upsert1_initial', + '$createdAt' => $createDate, + ]), + new Document([ + '$id' => 'bulk_upsert2', + '$permissions' => $permissions, + 'string' => 'bulk_upsert2_initial', + '$updatedAt' => $updateDate, + ]), + new Document([ + '$id' => 'bulk_upsert3', + '$permissions' => $permissions, + 'string' => 'bulk_upsert3_initial', + '$createdAt' => $createDate, + '$updatedAt' => $updateDate, + ]), + new Document([ + '$id' => 'bulk_upsert4', + '$permissions' => $permissions, + 'string' => 'bulk_upsert4_initial', + ]), + ]; - $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); - } - public function testFindOrderByIdAndCursor(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $bulkUpsertResults = []; + $database->upsertDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { + $bulkUpsertResults[] = $doc; + }); - /** - * ORDER BY ID + CURSOR - */ - $documentsTest = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('$id'), - ]); - $documents = $database->find('movies', [ - Query::limit(1), - Query::offset(0), - Query::orderDesc('$id'), - Query::cursorAfter($documentsTest[0]) - ]); + // Test 8: Verify initial bulk upsert state + foreach (['bulk_upsert1', 'bulk_upsert3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + } - $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); - } + foreach (['bulk_upsert2', 'bulk_upsert3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + } - public function testFindOrderByCreateDateAndCursor(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + foreach (['bulk_upsert4'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt missing for $id"); + $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt missing for $id"); + } - /** - * ORDER BY CREATE DATE + CURSOR - */ - $documentsTest = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('$createdAt'), + // Test 9: Bulk upsert update with custom dates using updateDocuments + $newDate = '2000-04-01T12:00:00.000+00:00'; + $updateUpsertDoc = new Document([ + 'string' => 'bulk_upsert_updated', + '$createdAt' => $newDate, + '$updatedAt' => $newDate, ]); - $documents = $database->find('movies', [ - Query::limit(1), - Query::offset(0), - Query::orderDesc('$createdAt'), - Query::cursorAfter($documentsTest[0]) - ]); + $upsertIds = []; + foreach ($upsertDocuments as $doc) { + $upsertIds[] = $doc->getId(); + } - $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); - } + $database->updateDocuments($collection, $updateUpsertDoc, [ + Query::equal('$id', $upsertIds), + ]); - public function testFindOrderByUpdateDateAndCursor(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + foreach ($upsertIds as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + $this->assertEquals('bulk_upsert_updated', $doc->getAttribute('string'), "string mismatch for $id"); + } - /** - * ORDER BY UPDATE DATE + CURSOR - */ - $documentsTest = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('$updatedAt'), - ]); - $documents = $database->find('movies', [ - Query::limit(1), - Query::offset(0), - Query::orderDesc('$updatedAt'), - Query::cursorAfter($documentsTest[0]) + // Test 10: checking by passing null to each + $updateUpsertDoc = new Document([ + 'string' => 'bulk_upsert_updated', + '$createdAt' => null, + '$updatedAt' => null, ]); - $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); - } - - /** - * A batch insert stamps every row with the same `$createdAt` — createDocuments() takes - * one timestamp for the whole batch — so ordering by that timestamp alone leaves the - * order of those rows to the tie break Database::find() appends. - * - * The tie break follows the direction of the leading timestamp, so a descending order - * returns the batch newest-inserted first. It used to be appended ascending regardless, - * which both reversed the rows inside a tied group and produced a mixed-direction - * ORDER BY that no ascending index can answer. - */ - public function testFindOrderByCreateDateTieBreak(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $upsertIds = []; + foreach ($upsertDocuments as $doc) { + $upsertIds[] = $doc->getId(); + } - $database->createCollection('tieBreak', permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - ], documentSecurity: false); + $database->updateDocuments($collection, $updateUpsertDoc, [ + Query::equal('$id', $upsertIds), + ]); - $this->assertEquals(true, $database->createAttribute('tieBreak', 'name', Database::VAR_STRING, 128, true)); + foreach ($upsertIds as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + } - $documents = []; - foreach (['alpha', 'bravo', 'charlie'] as $name) { - $documents[] = new Document([ - 'name' => $name, - '$permissions' => [Permission::read(Role::any())], - ]); + // Test 11: Bulk upsert operations with upsertDocuments + $upsertUpdateDocuments = []; + foreach ($upsertDocuments as $doc) { + $updatedDoc = clone $doc; + $updatedDoc->setAttribute('string', 'bulk_upsert_updated_via_upsert'); + $updatedDoc->setAttribute('$createdAt', $newDate); + $updatedDoc->setAttribute('$updatedAt', $newDate); + $upsertUpdateDocuments[] = $updatedDoc; } - $this->assertEquals(3, $database->createDocuments('tieBreak', $documents)); + $upsertUpdateResults = []; + $countUpsertUpdate = $database->upsertDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { + $upsertUpdateResults[] = $doc; + }); + $this->assertEquals(4, $countUpsertUpdate); - $created = $database->find('tieBreak', [Query::orderAsc('$sequence')]); - $this->assertCount(3, $created); + foreach ($upsertUpdateResults as $doc) { + $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), 'createdAt mismatch for upsert update'); + $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), 'updatedAt mismatch for upsert update'); + $this->assertEquals('bulk_upsert_updated_via_upsert', $doc->getAttribute('string'), 'string mismatch for upsert update'); + } - // The batch shares one timestamp, otherwise the tie break is never consulted. - $this->assertEquals($created[0]->getCreatedAt(), $created[2]->getCreatedAt()); + // Test 12: Bulk upsert with preserve dates disabled + $database->setPreserveDates(false); - $descending = $database->find('tieBreak', [Query::orderDesc('$createdAt')]); - $this->assertEquals( - [$created[2]->getSequence(), $created[1]->getSequence(), $created[0]->getSequence()], - \array_map(fn (Document $document) => $document->getSequence(), $descending) - ); + $customDate = 'should be ignored anyways so no error'; + $upsertDisabledDocuments = []; + foreach ($upsertDocuments as $doc) { + $disabledDoc = clone $doc; + $disabledDoc->setAttribute('string', 'bulk_upsert_disabled'); + $disabledDoc->setAttribute('$createdAt', $customDate); + $disabledDoc->setAttribute('$updatedAt', $customDate); + $upsertDisabledDocuments[] = $disabledDoc; + } - $ascending = $database->find('tieBreak', [Query::orderAsc('$createdAt')]); - $this->assertEquals( - [$created[0]->getSequence(), $created[1]->getSequence(), $created[2]->getSequence()], - \array_map(fn (Document $document) => $document->getSequence(), $ascending) - ); + $upsertDisabledResults = []; + $countUpsertDisabled = $database->upsertDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { + $upsertDisabledResults[] = $doc; + }); + $this->assertEquals(4, $countUpsertDisabled); - // The tie break sits behind the caller's own order, so `name` still decides the - // rows the timestamp ties — placing a unique key ahead of it would leave it unread. - $byName = $database->find('tieBreak', [ - Query::orderDesc('$createdAt'), - Query::orderAsc('name'), - ]); - $this->assertEquals( - ['alpha', 'bravo', 'charlie'], - \array_map(fn (Document $document) => $document->getAttribute('name'), $byName) - ); + foreach ($upsertDisabledResults as $doc) { + $this->assertNotEquals($customDate, $doc->getAttribute('$createdAt'), 'createdAt should not be custom date when disabled'); + $this->assertNotEquals($customDate, $doc->getAttribute('$updatedAt'), 'updatedAt should not be custom date when disabled'); + $this->assertEquals('bulk_upsert_disabled', $doc->getAttribute('string'), 'string mismatch for disabled upsert'); + } + + $database->setPreserveDates(false); + $database->deleteCollection($collection); } - public function testFindCreatedBefore(): void + public function testUpdateDocumentsCount(): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * Test Query::createdBefore wrapper - */ - $futureDate = '2050-01-01T00:00:00.000Z'; - $pastDate = '1900-01-01T00:00:00.000Z'; - - $documents = $database->find('movies', [ - Query::createdBefore($futureDate), - Query::limit(1) - ]); - - $this->assertGreaterThan(0, count($documents)); - - $documents = $database->find('movies', [ - Query::createdBefore($pastDate), - Query::limit(1) - ]); - - $this->assertEquals(0, count($documents)); - } - - public function testFindCreatedAfter(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - /** - * Test Query::createdAfter wrapper - */ - $futureDate = '2050-01-01T00:00:00.000Z'; - $pastDate = '1900-01-01T00:00:00.000Z'; - - $documents = $database->find('movies', [ - Query::createdAfter($pastDate), - Query::limit(1) - ]); - - $this->assertGreaterThan(0, count($documents)); + if (! ($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); - $documents = $database->find('movies', [ - Query::createdAfter($futureDate), - Query::limit(1) - ]); + return; + } - $this->assertEquals(0, count($documents)); - } + $collectionName = 'update_count'; + $database->createCollection(Collection::create(id: $collectionName)); - public function testFindUpdatedBefore(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $database->createAttribute($collectionName, Attribute::string(key: 'key', size: 60)); + $database->createAttribute($collectionName, Attribute::string(key: 'value', size: 60)); - /** - * Test Query::updatedBefore wrapper - */ - $futureDate = '2050-01-01T00:00:00.000Z'; - $pastDate = '1900-01-01T00:00:00.000Z'; + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())]; - $documents = $database->find('movies', [ - Query::updatedBefore($futureDate), - Query::limit(1) - ]); + $docs = [ + new Document([ + '$id' => 'bulk_upsert1', + '$permissions' => $permissions, + 'key' => 'bulk_upsert1_initial', + ]), + new Document([ + '$id' => 'bulk_upsert2', + '$permissions' => $permissions, + 'key' => 'bulk_upsert2_initial', + ]), + new Document([ + '$id' => 'bulk_upsert3', + '$permissions' => $permissions, + 'key' => 'bulk_upsert3_initial', + ]), + new Document([ + '$id' => 'bulk_upsert4', + '$permissions' => $permissions, + 'key' => 'bulk_upsert4_initial', + ]), + ]; + $upsertUpdateResults = []; + $count = $database->upsertDocuments($collectionName, $docs, onNext: function ($doc) use (&$upsertUpdateResults) { + $upsertUpdateResults[] = $doc; + }); + $this->assertCount(4, $upsertUpdateResults); + $this->assertEquals(4, $count); - $this->assertGreaterThan(0, count($documents)); + $updates = new Document(['value' => 'test']); + $newDocs = []; + $count = $database->updateDocuments($collectionName, $updates, onNext: function ($doc) use (&$newDocs) { + $newDocs[] = $doc; + }); - $documents = $database->find('movies', [ - Query::updatedBefore($pastDate), - Query::limit(1) - ]); + $this->assertCount(4, $newDocs); + $this->assertEquals(4, $count); - $this->assertEquals(0, count($documents)); + $database->deleteCollection($collectionName); } - public function testFindUpdatedAfter(): void + public function testUpsertWithJSONFilters(): void { - /** @var Database $database */ - $database = $this->getDatabase(); + $database = static::getDatabase(); - /** - * Test Query::updatedAfter wrapper - */ - $futureDate = '2050-01-01T00:00:00.000Z'; - $pastDate = '1900-01-01T00:00:00.000Z'; + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); - $documents = $database->find('movies', [ - Query::updatedAfter($pastDate), - Query::limit(1) - ]); + return; + } - $this->assertGreaterThan(0, count($documents)); + // Create collection with JSON filter attribute + $collection = Id::unique(); + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - $documents = $database->find('movies', [ - Query::updatedAfter($futureDate), - Query::limit(1) - ]); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute($collection, Attribute::string(key: 'metadata', size: 4000, required: true, filters: [Filter::Json])); - $this->assertEquals(0, count($documents)); - } + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; - public function testFindCreatedBetween(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test 1: Insertion (createDocument) with JSON filter + $docId1 = 'json-doc-1'; + $initialMetadata = [ + 'version' => '1.0.0', + 'tags' => ['php', 'database'], + 'config' => [ + 'debug' => false, + 'timeout' => 30, + ], + ]; - /** - * Test Query::createdBetween wrapper - */ - $pastDate = '1900-01-01T00:00:00.000Z'; - $futureDate = '2050-01-01T00:00:00.000Z'; - $recentPastDate = '2020-01-01T00:00:00.000Z'; - $nearFutureDate = '2025-01-01T00:00:00.000Z'; + $document1 = $database->createDocument($collection, new Document([ + '$id' => $docId1, + 'name' => 'Initial Document', + 'metadata' => $initialMetadata, + '$permissions' => $permissions, + ])); - // All documents should be between past and future - $documents = $database->find('movies', [ - Query::createdBetween($pastDate, $futureDate), - Query::limit(25) - ]); + $this->assertEquals($docId1, $document1->getId()); + $this->assertEquals('Initial Document', $document1->getAttribute('name')); + $this->assertIsArray($document1->getAttribute('metadata')); + $this->assertEquals('1.0.0', $document1->getAttribute('metadata')['version']); + $this->assertEquals(['php', 'database'], $document1->getAttribute('metadata')['tags']); - $this->assertGreaterThan(0, count($documents)); + // Test 2: Update (updateDocument) with modified JSON filter + $updatedMetadata = [ + 'version' => '2.0.0', + 'tags' => ['php', 'database', 'json'], + 'config' => [ + 'debug' => true, + 'timeout' => 60, + 'cache' => true, + ], + 'updated' => true, + ]; - // No documents should exist in this range - $documents = $database->find('movies', [ - Query::createdBetween($pastDate, $pastDate), - Query::limit(25) - ]); + $document1->setAttribute('name', 'Updated Document'); + $document1->setAttribute('metadata', $updatedMetadata); - $this->assertEquals(0, count($documents)); + $updatedDoc = $database->updateDocument($collection, $docId1, $document1); - // Documents created between recent past and near future - $documents = $database->find('movies', [ - Query::createdBetween($recentPastDate, $nearFutureDate), - Query::limit(25) - ]); + $this->assertEquals($docId1, $updatedDoc->getId()); + $this->assertEquals('Updated Document', $updatedDoc->getAttribute('name')); + $this->assertIsArray($updatedDoc->getAttribute('metadata')); + $metadata = $updatedDoc->getArray('metadata'); + $this->assertEquals('2.0.0', $metadata['version']); + $this->assertEquals(['php', 'database', 'json'], $metadata['tags']); + $config = $metadata['config'] ?? null; + $this->assertIsArray($config); + $this->assertTrue($config['debug']); + $this->assertTrue($metadata['updated']); - $count = count($documents); + // Test 3: Upsert - Create new document (upsertDocument) + $docId2 = 'json-doc-2'; + $newMetadata = [ + 'version' => '1.5.0', + 'tags' => ['javascript', 'node'], + 'config' => [ + 'debug' => false, + 'timeout' => 45, + ], + ]; - // Same count should be returned with expanded range - $documents = $database->find('movies', [ - Query::createdBetween($pastDate, $nearFutureDate), - Query::limit(25) + $document2 = new Document([ + '$id' => $docId2, + 'name' => 'New Upsert Document', + 'metadata' => $newMetadata, + '$permissions' => $permissions, ]); - $this->assertGreaterThanOrEqual($count, count($documents)); - } - - public function testFindUpdatedBetween(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - /** - * Test Query::updatedBetween wrapper - */ - $pastDate = '1900-01-01T00:00:00.000Z'; - $futureDate = '2050-01-01T00:00:00.000Z'; - $recentPastDate = '2020-01-01T00:00:00.000Z'; - $nearFutureDate = '2025-01-01T00:00:00.000Z'; - - // All documents should be between past and future - $documents = $database->find('movies', [ - Query::updatedBetween($pastDate, $futureDate), - Query::limit(25) - ]); + $upsertedDoc = $database->upsertDocument($collection, $document2); - $this->assertGreaterThan(0, count($documents)); + $this->assertEquals($docId2, $upsertedDoc->getId()); + $this->assertEquals('New Upsert Document', $upsertedDoc->getAttribute('name')); + $this->assertIsArray($upsertedDoc->getAttribute('metadata')); + $this->assertEquals('1.5.0', $upsertedDoc->getAttribute('metadata')['version']); - // No documents should exist in this range - $documents = $database->find('movies', [ - Query::updatedBetween($pastDate, $pastDate), - Query::limit(25) + // Test 4: Upsert - Update existing document (upsertDocument) + $document2->setAttribute('name', 'Updated Upsert Document'); + $document2->setAttribute('metadata', [ + 'version' => '2.5.0', + 'tags' => ['javascript', 'node', 'typescript'], + 'config' => [ + 'debug' => true, + 'timeout' => 90, + ], + 'migrated' => true, ]); - $this->assertEquals(0, count($documents)); + $upsertedDoc2 = $database->upsertDocument($collection, $document2); - // Documents updated between recent past and near future - $documents = $database->find('movies', [ - Query::updatedBetween($recentPastDate, $nearFutureDate), - Query::limit(25) - ]); + $this->assertEquals($docId2, $upsertedDoc2->getId()); + $this->assertEquals('Updated Upsert Document', $upsertedDoc2->getAttribute('name')); + $this->assertIsArray($upsertedDoc2->getAttribute('metadata')); + $this->assertEquals('2.5.0', $upsertedDoc2->getAttribute('metadata')['version']); + $this->assertEquals(['javascript', 'node', 'typescript'], $upsertedDoc2->getAttribute('metadata')['tags']); + $this->assertTrue($upsertedDoc2->getAttribute('metadata')['migrated']); - $count = count($documents); + // Test 5: Upsert - Bulk upsertDocuments (create and update) + $docId3 = 'json-doc-3'; + $docId4 = 'json-doc-4'; - // Same count should be returned with expanded range - $documents = $database->find('movies', [ - Query::updatedBetween($pastDate, $nearFutureDate), - Query::limit(25) - ]); - - $this->assertGreaterThanOrEqual($count, count($documents)); - } - - public function testFindLimit(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - /** - * Limit - */ - $documents = $database->find('movies', [ - Query::limit(4), - Query::offset(0), - Query::orderAsc('name') - ]); + $bulkDocuments = [ + new Document([ + '$id' => $docId3, + 'name' => 'Bulk Upsert 1', + 'metadata' => [ + 'version' => '3.0.0', + 'tags' => ['python', 'flask'], + 'config' => ['debug' => false], + ], + '$permissions' => $permissions, + ]), + new Document([ + '$id' => $docId4, + 'name' => 'Bulk Upsert 2', + 'metadata' => [ + 'version' => '3.1.0', + 'tags' => ['go', 'golang'], + 'config' => ['debug' => true], + ], + '$permissions' => $permissions, + ]), + // Update existing document + new Document([ + '$id' => $docId1, + 'name' => 'Bulk Updated Document', + 'metadata' => [ + 'version' => '3.0.0', + 'tags' => ['php', 'database', 'bulk'], + 'config' => [ + 'debug' => false, + 'timeout' => 120, + ], + 'bulkUpdated' => true, + ], + '$permissions' => $permissions, + ]), + ]; - $this->assertEquals(4, count($documents)); - $this->assertEquals('Captain America: The First Avenger', $documents[0]['name']); - $this->assertEquals('Captain Marvel', $documents[1]['name']); - $this->assertEquals('Frozen', $documents[2]['name']); - $this->assertEquals('Frozen II', $documents[3]['name']); - } + $count = $database->upsertDocuments($collection, $bulkDocuments); + $this->assertEquals(3, $count); + // Verify bulk upsert results + $bulkDoc1 = $database->getDocument($collection, $docId3); + $this->assertEquals('Bulk Upsert 1', $bulkDoc1->getAttribute('name')); + $this->assertEquals('3.0.0', $bulkDoc1->getArray('metadata')['version']); - public function testFindLimitAndOffset(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $bulkDoc2 = $database->getDocument($collection, $docId4); + $this->assertEquals('Bulk Upsert 2', $bulkDoc2->getAttribute('name')); + $this->assertEquals('3.1.0', $bulkDoc2->getArray('metadata')['version']); - /** - * Limit + Offset - */ - $documents = $database->find('movies', [ - Query::limit(4), - Query::offset(2), - Query::orderAsc('name') - ]); + $bulkDoc3 = $database->getDocument($collection, $docId1); + $this->assertEquals('Bulk Updated Document', $bulkDoc3->getAttribute('name')); + $bulkMetadata = $bulkDoc3->getArray('metadata'); + $this->assertEquals('3.0.0', $bulkMetadata['version']); + $this->assertTrue($bulkMetadata['bulkUpdated']); - $this->assertEquals(4, count($documents)); - $this->assertEquals('Frozen', $documents[0]['name']); - $this->assertEquals('Frozen II', $documents[1]['name']); - $this->assertEquals('Work in Progress', $documents[2]['name']); - $this->assertEquals('Work in Progress 2', $documents[3]['name']); + // Cleanup + $database->deleteCollection($collection); } - public function testFindOrQueries(): void + public function testFindRegex(): void { /** @var Database $database */ - $database = $this->getDatabase(); - - /** - * Test that OR queries are handled correctly - */ - $documents = $database->find('movies', [ - Query::equal('director', ['TBD', 'Joe Johnston']), - Query::equal('year', [2025]), - ]); - $this->assertEquals(1, count($documents)); - } + $database = static::getDatabase(); - /** - * @depends testUpdateDocument - */ - public function testFindEdgeCases(Document $document): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Determine regex support type + $supportsPCRE = ! $this->usesPosixRegex(); + $supportsPOSIX = $this->usesPosixRegex(); - $collection = 'edgeCases'; + // Determine word boundary pattern based on support + $wordBoundaryPattern = null; + $wordBoundaryPatternPHP = null; + if ($supportsPCRE) { + $wordBoundaryPattern = '\\b'; // PCRE uses \b + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match uses \b + } elseif ($supportsPOSIX) { + $wordBoundaryPattern = '\\y'; // POSIX uses \y + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match still uses \b for verification + } - $database->createCollection($collection); + $database->createCollection(Collection::create(id: 'moviesRegex', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - $this->assertEquals(true, $database->createAttribute($collection, 'value', Database::VAR_STRING, 256, true)); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertSame('name', $database->createAttribute('moviesRegex', Attribute::string(key: 'name', size: 128, required: true))->key); + $this->assertSame('director', $database->createAttribute('moviesRegex', Attribute::string(key: 'director', size: 128, required: true))->key); + $this->assertSame('year', $database->createAttribute('moviesRegex', Attribute::integer(key: 'year', required: true))->key); + } - $values = [ - 'NormalString', - '{"type":"json","somekey":"someval"}', - '{NormalStringInBraces}', - '"NormalStringInDoubleQuotes"', - '{"NormalStringInDoubleQuotesAndBraces"}', - "'NormalStringInSingleQuotes'", - "{'NormalStringInSingleQuotesAndBraces'}", - "SingleQuote'InMiddle", - 'DoubleQuote"InMiddle', - 'Slash/InMiddle', - 'Backslash\InMiddle', - 'Colon:InMiddle', - '"quoted":"colon"' - ]; + if ($database->getAdapter()->supports(Capability::IndexTrigram)) { + $database->createIndex('moviesRegex', Index::trigram(key: 'trigram_name', attributes: ['name'])); + $database->createIndex('moviesRegex', Index::trigram(key: 'trigram_director', attributes: ['director'])); + } - foreach ($values as $value) { - $database->createDocument($collection, new Document([ - '$id' => ID::unique(), + // Create test documents + $database->createDocuments('moviesRegex', [ + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Frozen', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + ]), + new Document([ '$permissions' => [ Permission::read(Role::any()), + Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ], - 'value' => $value - ])); - } + 'name' => 'Frozen II', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2019, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain America: The First Avenger', + 'director' => 'Joe Johnston', + 'year' => 2011, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain Marvel', + 'director' => 'Anna Boden & Ryan Fleck', + 'year' => 2019, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress', + 'director' => 'TBD', + 'year' => 2025, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress 2', + 'director' => 'TBD', + 'year' => 2026, + ]), + ]); + // Helper function to verify regex query completeness /** - * Check Basic + * @param list $queryResults */ - $documents = $database->find($collection); - - $this->assertEquals(count($values), count($documents)); - $this->assertNotEmpty($documents[0]->getId()); - $this->assertEquals($collection, $documents[0]->getCollection()); - $this->assertEquals(['any'], $documents[0]->getRead()); - $this->assertEquals(['any'], $documents[0]->getUpdate()); - $this->assertEquals(['any'], $documents[0]->getDelete()); - $this->assertEquals($values[0], $documents[0]->getAttribute('value')); + $verifyRegexQuery = function (string $attribute, string $regexPattern, array $queryResults) use ($database): void { + /** @var list $queryResults */ + // Convert database regex pattern to PHP regex format. + // POSIX-style word boundary (\y) is not supported by PHP PCRE, so map it to \b. + $normalizedPattern = str_replace('\y', '\b', $regexPattern); + $phpPattern = '/'.str_replace('/', '\/', $normalizedPattern).'/'; - /** - * Check `equals` query - */ - foreach ($values as $value) { - $documents = $database->find($collection, [ - Query::limit(25), - Query::equal('value', [$value]) - ]); + // Get all documents to manually verify + $allDocuments = $database->find('moviesRegex'); - $this->assertEquals(1, count($documents)); - $this->assertEquals($value, $documents[0]->getAttribute('value')); - } - } + // Manually filter documents that match the pattern + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $value = $doc->getAttribute($attribute); + $this->assertIsString($value); + if (preg_match($phpPattern, $value)) { + $expectedMatches[] = $doc->getId(); + } + } - public function testOrSingleQuery(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $actualMatches = []; + foreach ($queryResults as $result) { + $actualMatches[] = $result->getId(); + $value = $result->getAttribute($attribute); + $this->assertIsString($value); + $this->assertTrue( + (bool) preg_match($phpPattern, $value), + "Document '{$result->getId()}' with {$attribute}='{$value}' should match pattern '{$regexPattern}'" + ); + } - try { - $database->find('movies', [ - Query::or([ - Query::equal('active', [true]) - ]) - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Invalid query: Or queries require at least two queries', $e->getMessage()); - } - } - - public function testOrMultipleQueries(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $queries = [ - Query::or([ - Query::equal('active', [true]), - Query::equal('name', ['Frozen II']) - ]) - ]; - $this->assertCount(4, $database->find('movies', $queries)); - $this->assertEquals(4, $database->count('movies', $queries)); - - $queries = [ - Query::equal('active', [true]), - Query::or([ - Query::equal('name', ['Frozen']), - Query::equal('name', ['Frozen II']), - Query::equal('director', ['Joe Johnston']) - ]) - ]; - - $this->assertCount(3, $database->find('movies', $queries)); - $this->assertEquals(3, $database->count('movies', $queries)); - } - - public function testOrNested(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $queries = [ - Query::select(['director']), - Query::equal('director', ['Joe Johnston']), - Query::or([ - Query::equal('name', ['Frozen']), - Query::or([ - Query::equal('active', [true]), - Query::equal('active', [false]), - ]) - ]) - ]; + // Verify all expected documents are returned (no missing) + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$regexPattern}' on attribute '{$attribute}'" + ); + }; - $documents = $database->find('movies', $queries); - $this->assertCount(1, $documents); - $this->assertArrayNotHasKey('name', $documents[0]); + // Test basic regex pattern - match movies starting with 'Captain' + // Note: Pattern format may vary by adapter (MongoDB uses regex strings, SQL uses REGEXP) + $pattern = '/^Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^Captain'), + ]); - $count = $database->count('movies', $queries); - $this->assertEquals(1, $count); - } + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '^Captain', $documents); - public function testAndSingleQuery(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + $this->assertTrue(in_array('Captain Marvel', $names)); - try { - $database->find('movies', [ - Query::and([ - Query::equal('active', [true]) - ]) - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Invalid query: And queries require at least two queries', $e->getMessage()); - } - } + // Test regex pattern - match movies containing 'Frozen' + $pattern = '/Frozen/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Frozen'), + ]); - public function testAndMultipleQueries(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Frozen', $documents); - $queries = [ - Query::and([ - Query::equal('active', [true]), - Query::equal('name', ['Frozen II']) - ]) - ]; - $this->assertCount(1, $database->find('movies', $queries)); - $this->assertEquals(1, $database->count('movies', $queries)); - } + // Test regex pattern - match exact title 'Frozen' + $exactFrozenDocuments = $database->find('moviesRegex', [ + Query::regex('name', '^Frozen$'), + ]); + $verifyRegexQuery('name', '^Frozen$', $exactFrozenDocuments); + $this->assertCount(1, $exactFrozenDocuments, 'Exact ^Frozen$ regex should return only one document'); + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); - public function testAndNested(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test regex pattern - match movies ending with 'Marvel' + $pattern = '/Marvel$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Marvel$'), + ]); - $queries = [ - Query::or([ - Query::equal('active', [false]), - Query::and([ - Query::equal('active', [true]), - Query::equal('name', ['Frozen']), - ]) - ]) - ]; + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Marvel$', $documents); - $documents = $database->find('movies', $queries); - $this->assertCount(3, $documents); + $this->assertEquals(1, count($documents)); // Only Captain Marvel + $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); - $count = $database->count('movies', $queries); - $this->assertEquals(3, $count); - } + // Test regex pattern - match movies with 'Work' in the name + $pattern = '/.*Work.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*Work.*'), + ]); - public function testNestedIDQueries(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*Work.*', $documents); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Work in Progress', $names)); + $this->assertTrue(in_array('Work in Progress 2', $names)); - $database->createCollection('movies_nested_id', permissions: [ - Permission::create(Role::any()), - Permission::update(Role::users()) + // Test regex pattern - match movies with 'Buck' in director + $pattern = '/.*Buck.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('director', '.*Buck.*'), ]); - $this->assertEquals(true, $database->createAttribute('movies_nested_id', 'name', Database::VAR_STRING, 128, true)); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('director', '.*Buck.*', $documents); - $database->createDocument('movies_nested_id', new Document([ - '$id' => ID::custom('1'), - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => '1', - ])); + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); - $database->createDocument('movies_nested_id', new Document([ - '$id' => ID::custom('2'), - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => '2', - ])); + // Test regex with case pattern - adapters may be case-sensitive or case-insensitive + // MySQL/MariaDB REGEXP is case-insensitive by default, MongoDB is case-sensitive + $patternCaseSensitive = '/captain/'; + $patternCaseInsensitive = '/captain/i'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'captain'), // lowercase + ]); - $database->createDocument('movies_nested_id', new Document([ - '$id' => ID::custom('3'), - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => '3', - ])); + // Verify all returned documents match the pattern (case-insensitive check for verification) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); // Verify that returned documents contain 'captain' (case-insensitive check) + $this->assertTrue( + (bool) preg_match($patternCaseInsensitive, $name), + "Document '{$name}' should match pattern 'captain' (case-insensitive check)" + ); + } - $queries = [ - Query::or([ - Query::equal('$id', ["1"]), - Query::equal('$id', ["2"]) - ]) - ]; + // Verify completeness: Check what the database actually returns + // Some adapters (MongoDB) are case-sensitive, others (MySQL/MariaDB) are case-insensitive + // We'll determine expected matches based on case-sensitive matching (pure regex behavior) + // If the adapter is case-insensitive, it will return more documents, which is fine + $allDocuments = $database->find('moviesRegex'); + $expectedMatchesCaseSensitive = []; + $expectedMatchesCaseInsensitive = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + if (preg_match($patternCaseSensitive, $name)) { + $expectedMatchesCaseSensitive[] = $doc->getId(); + } + if (preg_match($patternCaseInsensitive, $name)) { + $expectedMatchesCaseInsensitive[] = $doc->getId(); + } + } - $documents = $database->find('movies_nested_id', $queries); - $this->assertCount(2, $documents); + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($actualMatches); - // Make sure the query was not modified by reference - $this->assertEquals($queries[0]->getValues()[0]->getAttribute(), '$id'); + // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) + // Check which one matches the actual results + sort($expectedMatchesCaseSensitive); + sort($expectedMatchesCaseInsensitive); - $count = $database->count('movies_nested_id', $queries); - $this->assertEquals(2, $count); - } + // Verify that actual results match either case-sensitive or case-insensitive expectations + $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); + $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); - public function testFindNull(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertTrue( + $matchesCaseSensitive || $matchesCaseInsensitive, + 'Query results should match either case-sensitive ('.count($expectedMatchesCaseSensitive).' docs) or case-insensitive ('.count($expectedMatchesCaseInsensitive).' docs) expectations. Got '.count($actualMatches).' documents.' + ); - $documents = $database->find('movies', [ - Query::isNull('nullable'), + // Test regex with case-insensitive pattern (if adapter supports it via flags) + // Test with uppercase to verify case sensitivity + $pattern = '/Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Captain'), // uppercase ]); - $this->assertEquals(5, count($documents)); - } - - public function testFindNotNull(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $documents = $database->find('movies', [ - Query::isNotNull('nullable'), - ]); - - $this->assertEquals(1, count($documents)); - } + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern 'Captain'" + ); + } - public function testFindStartsWith(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify completeness + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + if (preg_match($pattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain'" + ); - $documents = $database->find('movies', [ - Query::startsWith('name', 'Work'), + // Test regex combined with other queries + $pattern = '/^Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^Captain'), + Query::greaterThan('year', 2010), ]); - $this->assertEquals(2, count($documents)); - - if ($this->getDatabase()->getAdapter() instanceof SQL) { - $documents = $database->find('movies', [ - Query::startsWith('name', '%ork'), - ]); - } else { - $documents = $database->find('movies', [ - Query::startsWith('name', '.*ork'), - ]); + // Verify all returned documents match both conditions + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $year = $doc->getAttribute('year'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + $this->assertGreaterThan(2010, $year, "Document '{$name}' should have year > 2010"); } - $this->assertEquals(0, count($documents)); - } - - public function testFindStartsWithWords(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify completeness: manually check all documents that match both conditions + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $year = $doc->getAttribute('year'); + if (preg_match($pattern, $name) && $year > 2010) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching both regex '^Captain' and year > 2010" + ); - $documents = $database->find('movies', [ - Query::startsWith('name', 'Work in Progress'), + // Test regex with limit + $pattern = '/.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*'), // Match all + Query::limit(3), ]); - $this->assertEquals(2, count($documents)); - } + $this->assertEquals(3, count($documents)); - public function testFindEndsWith(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify all returned documents match the pattern (should match all) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + } - $documents = $database->find('movies', [ - Query::endsWith('name', 'Marvel'), - ]); + // Note: With limit, we can't verify completeness, but we can verify all returned match - $this->assertEquals(1, count($documents)); - } + // Test regex with non-matching pattern + $pattern = '/^NonExistentPattern$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^NonExistentPattern$'), + ]); - public function testFindNotContains(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals(0, count($documents)); - if (!$database->getAdapter()->getSupportForQueryContains()) { - $this->expectNotToPerformAssertions(); - return; + // Verify no documents match (double-check by getting all and filtering) + $allDocuments = $database->find('moviesRegex'); + $matchingCount = 0; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + if (preg_match($pattern, $name)) { + $matchingCount++; + } } + $this->assertEquals(0, $matchingCount, "No documents should match pattern '{$pattern}'"); - // Test notContains with array attributes - should return documents that don't contain specified genres - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics']) - ]); - - $this->assertEquals(4, count($documents)); // All movies except the 2 with 'comics' genre + // Verify completeness: no documents should be returned + $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $documents)); - // Test notContains with multiple values (AND logic - exclude documents containing ANY of these) - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics', 'kids']), + // Test regex with special characters (should be escaped or handled properly) + $pattern = '/.*:.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*:.*'), // Match movies with colon ]); - $this->assertEquals(2, count($documents)); // Movies that have neither 'comics' nor 'kids' + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*:.*', $documents); - // Test notContains with non-existent genre - should return all documents - $documents = $database->find('movies', [ - Query::notContains('genres', ['non-existent']), + // Verify expected document is included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + + // ReDOS safety: ensure pathological patterns respond quickly and do not hang + $catastrophicPattern = '(a+)+$'; + $start = microtime(true); + $redosDocs = $database->find('moviesRegex', [ + Query::regex('name', $catastrophicPattern), ]); + $elapsed = microtime(true) - $start; + $this->assertLessThan(1.0, $elapsed, 'Regex evaluation should not be slow or vulnerable to ReDOS'); + $verifyRegexQuery('name', $catastrophicPattern, $redosDocs); + $this->assertCount(0, $redosDocs, 'Pathological regex should not match any movie titles'); - $this->assertEquals(6, count($documents)); + // Test regex search pattern - match movies with word boundaries + // Only test if word boundaries are supported (PCRE or POSIX) + if ($wordBoundaryPattern !== null) { + $dbPattern = $wordBoundaryPattern.'Work'.$wordBoundaryPattern; + $phpPattern = '/'.$wordBoundaryPatternPHP.'Work'.$wordBoundaryPatternPHP.'/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', $dbPattern), + ]); - // Test notContains with string attribute (substring search) - $documents = $database->find('movies', [ - Query::notContains('name', ['Captain']) - ]); - $this->assertEquals(4, count($documents)); // All movies except those containing 'Captain' + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $this->assertTrue( + (bool) preg_match($phpPattern, $name), + "Document '{$name}' should match pattern '{$dbPattern}'" + ); + } - // Test notContains combined with other queries (AND logic) - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics']), - Query::greaterThan('year', 2000) - ]); - $this->assertLessThanOrEqual(4, count($documents)); // Subset of movies without 'comics' and after 2000 + // Verify completeness: manually check all documents + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + if (preg_match($phpPattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$dbPattern}'" + ); + } - // Test notContains with case sensitivity - $documents = $database->find('movies', [ - Query::notContains('genres', ['COMICS']) // Different case + // Test regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' + $pattern1 = '/Captain/'; + $pattern2 = '/Frozen/'; + $documents = $database->find('moviesRegex', [ + Query::or([ + Query::regex('name', 'Captain'), + Query::regex('name', 'Frozen'), + ]), ]); - $this->assertEquals(6, count($documents)); // All movies since case doesn't match - // Test error handling for invalid attribute type - try { - $database->find('movies', [ - Query::notContains('price', [10.5]), - ]); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array, string, or object.', $e->getMessage()); - $this->assertTrue($e instanceof DatabaseException); + // Verify all returned documents match at least one pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $matchesPattern1 = (bool) preg_match($pattern1, $name); + $matchesPattern2 = (bool) preg_match($pattern2, $name); + $this->assertTrue( + $matchesPattern1 || $matchesPattern2, + "Document '{$name}' should match either pattern 'Captain' or 'Frozen'" + ); + } + + // Verify completeness: manually check all documents + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + if (preg_match($pattern1, $name) || preg_match($pattern2, $name)) { + $expectedMatches[] = $doc->getId(); + } } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain' OR 'Frozen'" + ); + $database->deleteCollection('moviesRegex'); } - public function testFindNotSearch(): void + public function testRegexInjection(): void { /** @var Database $database */ - $database = $this->getDatabase(); + $database = static::getDatabase(); - // Only test if fulltext search is supported - if ($this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { - // Ensure fulltext index exists (may already exist from previous tests) - try { - $database->createIndex('movies', 'name', Database::INDEX_FULLTEXT, ['name']); - } catch (Throwable $e) { - // Index may already exist, ignore duplicate error - if (!str_contains($e->getMessage(), 'already exists')) { - throw $e; - } - } + $collectionName = 'injectionTest'; + $database->createCollection(Collection::create(id: $collectionName, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - // Test notSearch - should return documents that don't match the search term - $documents = $database->find('movies', [ - Query::notSearch('name', 'captain'), - ]); - - $this->assertEquals(4, count($documents)); // All movies except the 2 with 'captain' in name - - // Test notSearch with term that doesn't exist - should return all documents - $documents = $database->find('movies', [ - Query::notSearch('name', 'nonexistent'), - ]); - - $this->assertEquals(6, count($documents)); - - // Test notSearch with partial term - if ($this->getDatabase()->getAdapter()->getSupportForFulltextWildCardIndex()) { - $documents = $database->find('movies', [ - Query::notSearch('name', 'cap'), - ]); - - $this->assertEquals(4, count($documents)); // All movies except those matching 'cap' - } - - // Test notSearch with empty string - should return all documents - $documents = $database->find('movies', [ - Query::notSearch('name', ''), - ]); - $this->assertEquals(6, count($documents)); // All movies since empty search matches nothing - - // Test notSearch combined with other filters - $documents = $database->find('movies', [ - Query::notSearch('name', 'captain'), - Query::lessThan('year', 2010) - ]); - $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-captain movies before 2010 - - // Test notSearch with special characters - $documents = $database->find('movies', [ - Query::notSearch('name', '@#$%'), - ]); - $this->assertEquals(6, count($documents)); // All movies since special chars don't match - } - - $this->assertEquals(true, true); // Test must do an assertion - } - - public function testFindNotStartsWith(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - // Test notStartsWith - should return documents that don't start with 'Work' - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'Work'), - ]); - - $this->assertEquals(4, count($documents)); // All movies except the 2 starting with 'Work' - - // Test notStartsWith with non-existent prefix - should return all documents - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'NonExistent'), - ]); - - $this->assertEquals(6, count($documents)); - - // Test notStartsWith with wildcard characters (should treat them literally) - if ($this->getDatabase()->getAdapter() instanceof SQL) { - $documents = $database->find('movies', [ - Query::notStartsWith('name', '%ork'), - ]); - } else { - $documents = $database->find('movies', [ - Query::notStartsWith('name', '.*ork'), - ]); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertSame('text', $database->createAttribute($collectionName, Attribute::string(key: 'text', size: 1000, required: true))->key); } - $this->assertEquals(6, count($documents)); // Should return all since no movie starts with these patterns - - // Test notStartsWith with empty string - should return no documents (all strings start with empty) - $documents = $database->find('movies', [ - Query::notStartsWith('name', ''), - ]); - $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string - - // Test notStartsWith with single character - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'C'), - ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // Movies not starting with 'C' - - // Test notStartsWith with case sensitivity (may be case-insensitive depending on DB) - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'work'), // lowercase vs 'Work' - ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // May match case-insensitively - - // Test notStartsWith combined with other queries - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'Work'), - Query::equal('year', [2006]) - ]); - $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 - } - - public function testFindNotEndsWith(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - // Test notEndsWith - should return documents that don't end with 'Marvel' - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'Marvel'), - ]); + // Create test documents - one that should match, one that shouldn't + $database->createDocument($collectionName, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'text' => 'target', + ])); - $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'Marvel' + $database->createDocument($collectionName, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'text' => 'other', + ])); - // Test notEndsWith with non-existent suffix - should return all documents - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'NonExistent'), - ]); + // SQL injection attempts - these should NOT return the "other" document + $sqlInjectionPatterns = [ + "target') OR '1'='1", // SQL injection attempt + "target' OR 1=1--", // SQL injection with comment + "target' OR 'x'='x", // SQL injection attempt + "target' UNION SELECT *--", // SQL UNION injection + ]; - $this->assertEquals(6, count($documents)); + // MongoDB injection attempts - these should NOT return the "other" document + $mongoInjectionPatterns = [ + 'target" || "1"=="1', // MongoDB injection attempt + 'target" || true', // MongoDB boolean injection + 'target"} || {"text": "other"}', // MongoDB operator injection + ]; - // Test notEndsWith with partial suffix - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'vel'), - ]); + $allInjectionPatterns = array_merge($sqlInjectionPatterns, $mongoInjectionPatterns); - $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') + foreach ($allInjectionPatterns as $pattern) { + try { + $results = $database->find($collectionName, [ + Query::regex('text', $pattern), + ]); - // Test notEndsWith with empty string - should return no documents (all strings end with empty) - $documents = $database->find('movies', [ - Query::notEndsWith('name', ''), - ]); - $this->assertEquals(0, count($documents)); // No documents since all strings end with empty string + // Critical check: if injection succeeded, we might get the "other" document + // which should NOT match a pattern starting with "target" + $foundOther = false; + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + $this->assertIsString($text); + if ($text === 'other') { + $foundOther = true; - // Test notEndsWith with single character - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'l'), - ]); - $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' + // Verify that "other" doesn't actually match the pattern as a regex + $matches = @preg_match('/'.str_replace('/', '\/', $pattern).'/', $text); + if ($matches === 0 || $matches === false) { + // "other" doesn't match the pattern but was returned + // This indicates potential injection vulnerability + $this->fail( + "Potential injection detected: Pattern '{$pattern}' returned document 'other' ". + "which doesn't match the pattern. This suggests SQL/MongoDB injection may have succeeded." + ); + } + } + } - // Test notEndsWith with case sensitivity (may be case-insensitive depending on DB) - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'marvel'), // lowercase vs 'Marvel' - ]); - $this->assertGreaterThanOrEqual(5, count($documents)); // May match case-insensitively + // Additional verification: check that all returned documents actually match the pattern + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + $this->assertIsString($text); + $matches = @preg_match('/'.str_replace('/', '\/', $pattern).'/', $text); - // Test notEndsWith combined with limit - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'Marvel'), - Query::limit(3) - ]); - $this->assertEquals(3, count($documents)); // Limited to 3 results - $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies - } + // If pattern is invalid, skip validation + if ($matches === false) { + continue; + } - public function testFindOrderRandom(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // If document doesn't match but was returned, it's suspicious + if ($matches === 0) { + $this->fail( + "Potential injection: Document '{$text}' was returned for pattern '{$pattern}' ". + "but doesn't match the regex pattern." + ); + } + } - if (!$database->getAdapter()->getSupportForOrderRandom()) { - $this->expectNotToPerformAssertions(); - return; + } catch (\Exception $e) { + $this->assertNotSame('', $e->getMessage()); + } } - // Test orderRandom with default limit - $documents = $database->find('movies', [ - Query::orderRandom(), - Query::limit(1), - ]); - $this->assertEquals(1, count($documents)); - $this->assertNotEmpty($documents[0]['name']); // Ensure we got a valid document - - // Test orderRandom with multiple documents - $documents = $database->find('movies', [ - Query::orderRandom(), - Query::limit(3), - ]); - $this->assertEquals(3, count($documents)); - - // Test that orderRandom returns different results (not guaranteed but highly likely) - $firstSet = $database->find('movies', [ - Query::orderRandom(), - Query::limit(3), - ]); - $secondSet = $database->find('movies', [ - Query::orderRandom(), - Query::limit(3), - ]); + // Test that legitimate regex patterns still work correctly + $legitimatePatterns = [ + 'target', // Should match "target" + '^target', // Should match "target" (anchored) + 'other', // Should match "other" + ]; - // Extract IDs for comparison - $firstIds = array_map(fn ($doc) => $doc['$id'], $firstSet); - $secondIds = array_map(fn ($doc) => $doc['$id'], $secondSet); + foreach ($legitimatePatterns as $pattern) { + try { + $results = $database->find($collectionName, [ + Query::regex('text', $pattern), + ]); - // While not guaranteed to be different, with 6 movies and selecting 3, - // the probability of getting the same set in the same order is very low - // We'll just check that we got valid results - $this->assertEquals(3, count($firstIds)); - $this->assertEquals(3, count($secondIds)); + // Verify each result actually matches + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + $this->assertIsString($text); + $matches = @preg_match('/'.str_replace('/', '\/', $pattern).'/', $text); + if ($matches !== false) { + $this->assertEquals( + 1, + $matches, + "Document '{$text}' should match pattern '{$pattern}'" + ); + } + } + } catch (\Exception $e) { + $this->fail("Legitimate pattern '{$pattern}' should not throw exception: ".$e->getMessage()); + } + } - // Test orderRandom with more than available documents - $documents = $database->find('movies', [ - Query::orderRandom(), - Query::limit(10), // We only have 6 movies - ]); - $this->assertLessThanOrEqual(6, count($documents)); // Should return all available documents + // Cleanup + $database->deleteCollection($collectionName); + } - // Test orderRandom with filters - $documents = $database->find('movies', [ - Query::greaterThan('price', 10), - Query::orderRandom(), - Query::limit(2), - ]); - $this->assertLessThanOrEqual(2, count($documents)); - foreach ($documents as $document) { - $this->assertGreaterThan(10, $document['price']); - } - - // Test orderRandom without explicit limit (should use default) - $documents = $database->find('movies', [ - Query::orderRandom(), - ]); - $this->assertGreaterThan(0, count($documents)); - $this->assertLessThanOrEqual(25, count($documents)); // Default limit is 25 - } + /** + * Test ReDoS (Regular Expression Denial of Service) with timeout protection + * This test verifies that ReDoS patterns either timeout properly or complete quickly, + * preventing denial of service attacks. + */ + // public function testRegexRedos(): void + // { + // /** @var Database $database */ + // $database = static::getDatabase(); + // + // // Skip test if regex is not supported + // if (!$database->getAdapter()->supports(Capability::Regex)) { + // $this->expectNotToPerformAssertions(); + // return; + // } + // + // $collectionName = 'redosTimeoutTest'; + // $database->createCollection($collectionName, permissions: [ + // Permission::create(Role::any()), + // Permission::read(Role::any()), + // Permission::update(Role::any()), + // Permission::delete(Role::any()), + // ]); + // + // if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + // $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::string(key: 'text', size: 1000, required: true))); + // } + // + // // Create documents with strings designed to trigger ReDoS + // // These strings have many 'a's but end with 'c' instead of 'b' + // // This causes catastrophic backtracking with patterns like (a+)+b + // $redosStrings = []; + // for ($i = 15; $i <= 35; $i += 5) { + // $redosStrings[] = str_repeat('a', $i) . 'c'; + // } + // + // // Also add some normal strings + // $normalStrings = [ + // 'normal text', + // 'another string', + // 'test123', + // 'valid data', + // ]; + // + // $documents = []; + // foreach ($redosStrings as $text) { + // $documents[] = new Document([ + // '$permissions' => [ + // Permission::read(Role::any()), + // Permission::create(Role::any()), + // Permission::update(Role::any()), + // Permission::delete(Role::any()), + // ], + // 'text' => $text, + // ]); + // } + // + // foreach ($normalStrings as $text) { + // $documents[] = new Document([ + // '$permissions' => [ + // Permission::read(Role::any()), + // Permission::create(Role::any()), + // Permission::update(Role::any()), + // Permission::delete(Role::any()), + // ], + // 'text' => $text, + // ]); + // } + // + // $database->createDocuments($collectionName, $documents); + // + // // ReDoS patterns that cause exponential backtracking + // $redosPatterns = [ + // '(a+)+b', // Classic ReDoS: nested quantifiers + // '(a|a)*b', // Alternation with quantifier + // '(a+)+$', // Anchored pattern + // '(a*)*b', // Nested star quantifiers + // '(a+)+b+', // Multiple nested quantifiers + // '(.+)+b', // Generic nested quantifiers + // '(.*)+b', // Generic nested quantifiers + // ]; + // + // $supportsTimeout = ($database->getAdapter()->hasFeature(Feature\Timeouts::class)); + // + // if ($supportsTimeout) { + // $database->setTimeout(2000); + // } + // + // foreach ($redosPatterns as $pattern) { + // $startTime = microtime(true); + // + // try { + // $results = $database->find($collectionName, [ + // Query::regex('text', $pattern), + // ]); + // $elapsed = microtime(true) - $startTime; + // // If timeout is supported, the query should either: + // // 1. Complete quickly (< 3 seconds) if ReDoS is mitigated + // // 2. Throw TimeoutException if it takes too long + // if ($supportsTimeout) { + // // If we got here without timeout, it should have completed quickly + // $this->assertLessThan( + // 3.0, + // $elapsed, + // "Regex pattern '{$pattern}' should complete quickly or timeout. Took {$elapsed}s" + // ); + // } else { + // // Without timeout support, we just check it doesn't hang forever + // // Set a reasonable upper bound (15 seconds) for systems without timeout + // $this->assertLessThan( + // 15.0, + // $elapsed, + // "Regex pattern '{$pattern}' should not cause excessive delay. Took {$elapsed}s" + // ); + // } + // + // // Verify results: none of our ReDoS strings should match these patterns + // // (they all end with 'c', not 'b') + // foreach ($results as $doc) { + // $text = $doc->getAttribute('text'); + // // If it matched, verify it's actually a valid match + // $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); + // if ($matches !== false) { + // $this->assertEquals( + // 1, + // $matches, + // "Document with text '{$text}' should actually match pattern '{$pattern}'" + // ); + // } + // } + // + // } catch (TimeoutException $e) { + // // Timeout is expected for ReDoS patterns if not properly mitigated + // $elapsed = microtime(true) - $startTime; + // $this->assertInstanceOf( + // TimeoutException::class, + // $e, + // "Regex pattern '{$pattern}' should timeout if it causes ReDoS. Elapsed: {$elapsed}s" + // ); + // + // // Timeout should happen within reasonable time (not immediately, but not too late) + // // Fast timeouts are actually good - they mean the system is protecting itself quickly + // $this->assertGreaterThan( + // 0.05, + // $elapsed, + // "Timeout should occur after some minimal processing time" + // ); + // + // // Timeout should happen before the timeout limit (with some buffer) + // if ($supportsTimeout) { + // $this->assertLessThan( + // 5.0, + // $elapsed, + // "Timeout should occur within reasonable time (before 5 seconds)" + // ); + // } + // + // } catch (\Exception $e) { + // // Check if this is a query interruption/timeout from MySQL (error 1317) + // // MySQL sometimes throws "Query execution was interrupted" instead of TimeoutException + // $message = $e->getMessage(); + // $isQueryInterrupted = false; + // + // // Check message for interruption keywords + // if (strpos($message, 'Query execution was interrupted') !== false || + // strpos($message, 'interrupted') !== false) { + // $isQueryInterrupted = true; + // } + // + // // Check if it's a PDOException with error code 1317 + // if ($e instanceof PDOException) { + // $errorInfo = $e->errorInfo ?? []; + // // Error 1317 is "Query execution was interrupted" + // if (isset($errorInfo[1]) && $errorInfo[1] === 1317) { + // $isQueryInterrupted = true; + // } + // // Also check SQLSTATE 70100 + // if ($e->getCode() === '70100') { + // $isQueryInterrupted = true; + // } + // } + // + // if ($isQueryInterrupted) { + // // This is effectively a timeout - MySQL interrupted the query + // $elapsed = microtime(true) - $startTime; + // $this->assertGreaterThan( + // 0.05, + // $elapsed, + // "Query interruption should occur after some minimal processing time" + // ); + // // This is acceptable - the query was interrupted due to timeout + // continue; + // } + // + // // Other exceptions are unexpected + // $this->fail("Unexpected exception for pattern '{$pattern}': " . get_class($e) . " - " . $e->getMessage()); + // } + // } + // + // // Test with a pattern that should match quickly (not ReDoS) + // $safePattern = 'normal'; + // $startTime = microtime(true); + // $results = $database->find($collectionName, [ + // Query::regex('text', $safePattern), + // ]); + // $elapsed = microtime(true) - $startTime; + // + // // Safe patterns should complete very quickly + // $this->assertLessThan(1.0, $elapsed, 'Safe regex pattern should complete quickly'); + // $this->assertGreaterThan(0, count($results), 'Safe pattern should match some documents'); + // + // // Verify safe pattern results are correct + // foreach ($results as $doc) { + // $text = $doc->getAttribute('text'); + // $this->assertStringContainsString('normal', $text, "Document '{$text}' should contain 'normal'"); + // } + // + // // Cleanup + // if ($supportsTimeout) { + // $database->clearTimeout(); + // } + // $database->deleteCollection($collectionName); + // } - public function testFindNotBetween(): void + public function testNonUtfChars(): void { /** @var Database $database */ $database = $this->getDatabase(); - // Test notBetween with price range - should return documents outside the range - $documents = $database->find('movies', [ - Query::notBetween('price', 25.94, 25.99), - ]); - $this->assertEquals(4, count($documents)); // All movies except the 2 in the price range - - // Test notBetween with range that includes no documents - should return all documents - $documents = $database->find('movies', [ - Query::notBetween('price', 30, 35), - ]); - $this->assertEquals(6, count($documents)); + if (!$database->getAdapter()->supports(Capability::NonUtfCharacters)) { + $this->expectNotToPerformAssertions(); + return; + } - // Test notBetween with date range - $documents = $database->find('movies', [ - Query::notBetween('$createdAt', '1975-12-06', '2050-12-06'), - ]); - $this->assertEquals(0, count($documents)); // No movies outside this wide date range + $database->createCollection(Collection::create(id: __FUNCTION__)); + $this->assertSame('title', $database->createAttribute(__FUNCTION__, Attribute::string(key: 'title', size: 128, required: true))->key); - // Test notBetween with narrower date range - $documents = $database->find('movies', [ - Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), - ]); - $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range + $nonUtfString = "Hello\x00World\xC3\x28\xFF\xFE\xA0Test\x00End"; - // Test notBetween with updated date range - $documents = $database->find('movies', [ - Query::notBetween('$updatedAt', '2000-01-01T00:00:00.000+00:00', '2001-01-01T00:00:00.000+00:00'), - ]); - $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range + try { + $database->createDocument(__FUNCTION__, new Document([ + 'title' => $nonUtfString, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertTrue($e instanceof CharacterException); + } - // Test notBetween with year range (integer values) - $documents = $database->find('movies', [ - Query::notBetween('year', 2005, 2007), - ]); - $this->assertLessThanOrEqual(6, count($documents)); // Movies outside 2005-2007 range + /** + * Convert to UTF-8 and replace invalid bytes with empty string + */ + $nonUtfString = mb_convert_encoding($nonUtfString, 'UTF-8', 'UTF-8'); - // Test notBetween with reversed range (start > end) - should still work - $documents = $database->find('movies', [ - Query::notBetween('price', 25.99, 25.94), // Note: reversed order - ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // Should handle reversed range gracefully + /** + * Remove null bytes + */ + $nonUtfString = str_replace("\0", '', $nonUtfString); - // Test notBetween with same start and end values - $documents = $database->find('movies', [ - Query::notBetween('year', 2006, 2006), - ]); - $this->assertGreaterThanOrEqual(5, count($documents)); // All movies except those from exactly 2006 + $document = $database->createDocument(__FUNCTION__, new Document([ + 'title' => $nonUtfString, + ])); - // Test notBetween combined with other filters - $documents = $database->find('movies', [ - Query::notBetween('price', 25.94, 25.99), - Query::orderDesc('year'), - Query::limit(2) - ]); - $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range + $this->assertFalse($document->isEmpty()); + $this->assertEquals('HelloWorld?(???TestEnd', $document->getAttribute('title')); + } - // Test notBetween with extreme ranges - $documents = $database->find('movies', [ - Query::notBetween('year', -1000, 1000), // Very wide range - ]); - $this->assertLessThanOrEqual(6, count($documents)); // Movies outside this range + public function testCreateDocumentNumericalId(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - // Test notBetween with float precision - $documents = $database->find('movies', [ - Query::notBetween('price', 25.945, 25.955), // Very narrow range - ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range + $database->createCollection(Collection::create(id: 'numericalIds')); + + $this->assertSame('name', $database->createAttribute('numericalIds', Attribute::string(key: 'name', size: 128, required: true))->key); + + // Test creating a document with an entirely numerical ID + $numericalIdDocument = $database->createDocument('numericalIds', new Document([ + '$id' => '123456789', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'Test Document with Numerical ID', + ])); + + $this->assertNotSame('', $numericalIdDocument->getId()); + $this->assertEquals('123456789', $numericalIdDocument->getId()); + $this->assertEquals('Test Document with Numerical ID', $numericalIdDocument->getAttribute('name')); + + // Verify we can retrieve the document + $retrievedDocument = $database->getDocument('numericalIds', '123456789'); + $this->assertNotSame('', $retrievedDocument->getId()); + $this->assertEquals('123456789', $retrievedDocument->getId()); + $this->assertEquals('Test Document with Numerical ID', $retrievedDocument->getAttribute('name')); } - public function testFindSelect(): void + public function testSkipPermissions(): void { /** @var Database $database */ $database = $this->getDatabase(); - $documents = $database->find('movies', [ - Query::select(['name', 'year']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + if (!($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; } - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$id']) - ]); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::integer(key: 'number')); - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $data = []; + for ($i = 1; $i <= 10; $i++) { + $data[] = [ + '$id' => "$i", + 'number' => $i, + ]; } - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$sequence']) - ]); + $documents = array_map(fn ($d) => new Document($d), $data); - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } + $results = []; + $count = $database->createDocuments(__FUNCTION__, $documents, onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$collection']) - ]); + $this->assertEquals($count, \count($results)); + $this->assertEquals(10, \count($results)); - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } + /** + * Update 1 row + */ + $data[\array_key_last($data)]['number'] = 100; - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$createdAt']) - ]); + /** + * Add 1 row + */ + $data[] = [ + '$id' => "101", + 'number' => 101, + ]; - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } + $documents = array_map(fn ($d) => new Document($d), $data); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$updatedAt']) - ]); + $this->getDatabase()->getAuthorization()->disable(); - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } + $results = []; + $count = $database->upsertDocuments( + __FUNCTION__, + $documents, + onNext: function ($doc) use (&$results) { + $results[] = $doc; + } + ); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$permissions']) - ]); + $this->getDatabase()->getAuthorization()->reset(); - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); + $this->assertEquals(2, \count($results)); + $this->assertEquals(2, $count); + + foreach ($results as $result) { + $this->assertArrayHasKey('$permissions', $result); + $this->assertEquals([], $result->getAttribute('$permissions')); } } - /** @depends testFind */ - public function testForeach(): void + public function testUpsertDocumentsAttributeMismatch(): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * Test, foreach generator on empty collection - */ - $database->createCollection('moviesEmpty'); - $documents = []; - foreach ($database->iterate('moviesEmpty', queries: [Query::limit(2)]) as $document) { - $documents[] = $document; + if (!($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; } - $this->assertEquals(0, \count($documents)); - $this->assertTrue($database->deleteCollection('moviesEmpty')); - /** - * Test, foreach generator - */ - $documents = []; - foreach ($database->iterate('movies', queries: [Query::limit(2)]) as $document) { - $documents[] = $document; - } - $this->assertEquals(6, count($documents)); + $database->createCollection(Collection::create(id: __FUNCTION__, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: false)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'first', size: 128, required: true)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'last', size: 128)); - /** - * Test, foreach goes through all the documents - */ - $documents = []; - $database->foreach('movies', queries: [Query::limit(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - $this->assertEquals(6, count($documents)); + $existingDocument = $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'first', + 'first' => 'first', + 'last' => 'last', + ])); - /** - * Test, foreach with initial cursor - */ - - $first = $documents[0]; - $documents = []; - $database->foreach('movies', queries: [Query::limit(2), Query::cursorAfter($first)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - $this->assertEquals(5, count($documents)); + $newDocument = new Document([ + '$id' => 'second', + 'first' => 'second', + ]); - /** - * Test, foreach with initial offset - */ + // Ensure missing optionals on new document is allowed + $docs = $database->upsertDocuments(__FUNCTION__, [ + $existingDocument->setAttribute('first', 'updated'), + $newDocument, + ]); - $documents = []; - $database->foreach('movies', queries: [Query::limit(2), Query::offset(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - $this->assertEquals(4, count($documents)); + $this->assertEquals(2, $docs); + $this->assertEquals('updated', $existingDocument->getAttribute('first')); + $this->assertEquals('last', $existingDocument->getAttribute('last')); + $this->assertEquals('second', $newDocument->getAttribute('first')); + $this->assertEquals('', $newDocument->getAttribute('last')); - /** - * Test, cursor before throws error - */ try { - $database->foreach('movies', queries: [Query::cursorBefore($documents[0]), Query::offset(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - + $database->upsertDocuments(__FUNCTION__, [ + $existingDocument->removeAttribute('first'), + $newDocument + ]); + $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertEquals('Cursor ' . Database::CURSOR_BEFORE . ' not supported in this method.', $e->getMessage()); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertTrue($e instanceof StructureException, $e->getMessage()); + } } - } + // Ensure missing optionals on existing document is allowed + $docs = $database->upsertDocuments(__FUNCTION__, [ + $existingDocument + ->setAttribute('first', 'first') + ->removeAttribute('last'), + $newDocument + ->setAttribute('last', 'last') + ]); - /** - * @depends testFind - */ - public function testCount(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals(2, $docs); + $this->assertEquals('first', $existingDocument->getAttribute('first')); + $this->assertEquals('last', $existingDocument->getAttribute('last')); + $this->assertEquals('second', $newDocument->getAttribute('first')); + $this->assertEquals('last', $newDocument->getAttribute('last')); - $count = $database->count('movies'); - $this->assertEquals(6, $count); - $count = $database->count('movies', [Query::equal('year', [2019])]); + // Ensure set null on existing document is allowed + $docs = $database->upsertDocuments(__FUNCTION__, [ + $existingDocument + ->setAttribute('first', 'first') + ->setAttribute('last', null), + $newDocument + ->setAttribute('last', 'last') + ]); - $this->assertEquals(2, $count); - $count = $database->count('movies', [Query::equal('with-dash', ['Works'])]); - $this->assertEquals(2, $count); - $count = $database->count('movies', [Query::equal('with-dash', ['Works2', 'Works3'])]); - $this->assertEquals(4, $count); + $this->assertEquals(1, $docs); + $this->assertEquals('first', $existingDocument->getAttribute('first')); + $this->assertEquals(null, $existingDocument->getAttribute('last')); + $this->assertEquals('second', $newDocument->getAttribute('first')); + $this->assertEquals('last', $newDocument->getAttribute('last')); - $this->getDatabase()->getAuthorization()->removeRole('user:x'); - $count = $database->count('movies'); - $this->assertEquals(5, $count); + $doc3 = new Document([ + '$id' => 'third', + 'last' => 'last', + 'first' => 'third', + ]); - $this->getDatabase()->getAuthorization()->disable(); - $count = $database->count('movies'); - $this->assertEquals(6, $count); - $this->getDatabase()->getAuthorization()->reset(); + $doc4 = new Document([ + '$id' => 'fourth', + 'first' => 'fourth', + 'last' => 'last', + ]); - $this->getDatabase()->getAuthorization()->disable(); - $count = $database->count('movies', [], 3); - $this->assertEquals(3, $count); - $this->getDatabase()->getAuthorization()->reset(); + // Ensure mismatch of attribute orders is allowed + $docs = $database->upsertDocuments(__FUNCTION__, [ + $doc3, + $doc4 + ]); - /** - * Test that OR queries are handled correctly - */ - $this->getDatabase()->getAuthorization()->disable(); - $count = $database->count('movies', [ - Query::equal('director', ['TBD', 'Joe Johnston']), - Query::equal('year', [2025]), + $this->assertEquals(2, $docs); + $this->assertEquals('third', $doc3->getAttribute('first')); + $this->assertEquals('last', $doc3->getAttribute('last')); + $this->assertEquals('fourth', $doc4->getAttribute('first')); + $this->assertEquals('last', $doc4->getAttribute('last')); + + $doc3 = $database->getDocument(__FUNCTION__, 'third'); + $doc4 = $database->getDocument(__FUNCTION__, 'fourth'); + + $this->assertEquals('third', $doc3->getAttribute('first')); + $this->assertEquals('last', $doc3->getAttribute('last')); + $this->assertEquals('fourth', $doc4->getAttribute('first')); + $this->assertEquals('last', $doc4->getAttribute('last')); + } + + public function testUpsertDocumentsNoop(): void + { + if (!($this->getDatabase()->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->getDatabase()->createCollection(Collection::create(id: __FUNCTION__)); + $this->getDatabase()->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128, required: true)); + + $document = new Document([ + '$id' => 'first', + 'string' => 'text📝', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], ]); + + $count = $this->getDatabase()->upsertDocuments(__FUNCTION__, [$document]); $this->assertEquals(1, $count); - $this->getDatabase()->getAuthorization()->reset(); + + // No changes, should return 0 + $count = $this->getDatabase()->upsertDocuments(__FUNCTION__, [$document]); + $this->assertEquals(0, $count); } - /** - * @depends testFind - */ - public function testSum(): void + public function testUpsertDocumentsReadsStoredRowsInOneBatch(): void { - /** @var Database $database */ - $database = $this->getDatabase(); + $shared = $this->getDatabase(); + $adapter = $shared->getAdapter(); - $this->getDatabase()->getAuthorization()->addRole('user:x'); + if (! $adapter->hasFeature(Feature\Upserts::class)) { + $this->expectNotToPerformAssertions(); - $sum = $database->sum('movies', 'year', [Query::equal('year', [2019]),]); - $this->assertEquals(2019 + 2019, $sum); - $sum = $database->sum('movies', 'year'); - $this->assertEquals(2013 + 2019 + 2011 + 2019 + 2025 + 2026, $sum); - $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); - $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); - $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); - $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); + return; + } - $sum = $database->sum('movies', 'year', [Query::equal('year', [2019])], 1); - $this->assertEquals(2019, $sum); + if (! $adapter->hasFeature(Feature\RawQuery::class)) { + // The statements an adapter issues are only counted in the SQL base, + // so there is no boundary to measure here. The batching itself lives + // in Database::upsertDocuments, which every adapter runs, + // so the SQL lanes cover the regression for all of them. + $this->markTestSkipped($adapter::class.' issues no countable statements.'); + } - $this->getDatabase()->getAuthorization()->removeRole('user:x'); + // A cache-free handle on the same adapter: the stats workers upsert rows + // no request has read, so every stored row is a real read for them. + $database = new Database($adapter, new Cache(new NoneCacheAdapter())); + $database->setAuthorization($shared->getAuthorization()); - $sum = $database->sum('movies', 'year', [Query::equal('year', [2019]),]); - $this->assertEquals(2019 + 2019, $sum); - $sum = $database->sum('movies', 'year'); - $this->assertEquals(2013 + 2019 + 2011 + 2019 + 2025, $sum); - $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); - $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); - $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); - $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); - } + $collection = 'upsert_batch_read'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::integer(key: 'value', required: true)); - public function testIntegersBeyondInt32(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $documents = []; + for ($index = 0; $index < 20; $index++) { + $documents[] = new Document([ + '$id' => 'document'.$index, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'value' => $index, + ]); + } - $database->createCollection(__FUNCTION__, attributes: [ - new Document([ - '$id' => 'amount', - 'type' => Database::VAR_INTEGER, - 'size' => 8, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => 'amounts', - 'type' => Database::VAR_INTEGER, - 'size' => 8, - 'required' => true, - 'signed' => true, - 'array' => true, - 'filters' => [], - ]), - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], documentSecurity: false); + $this->assertEquals(count($documents), $database->upsertDocuments($collection, $documents)); - // Small values encode as int32, large ones as int64. Mongo hands the - // latter back wrapped, so both widths have to appear in one row. - $database->createDocument(__FUNCTION__, new Document([ - '$id' => 'row1', - 'amount' => 2000000000, - 'amounts' => [-3408048000, -42, 3408048000, Database::MAX_BIG_INT], - ])); - $database->createDocument(__FUNCTION__, new Document([ - '$id' => 'row2', - 'amount' => 2000000000, - 'amounts' => [-42], - ])); + foreach ($documents as $index => $document) { + $document->setAttribute('value', $index + 100); + } - foreach (['getDocument' => $database->getDocument(__FUNCTION__, 'row1'), 'find' => $database->find(__FUNCTION__, [Query::equal('$id', ['row1'])])[0]] as $path => $document) { - $this->assertIsInt($document->getAttribute('amount'), $path . ' returned a non-int scalar'); + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); - $amounts = $document->getAttribute('amounts'); - foreach ($amounts as $index => $amount) { - $this->assertIsInt($amount, $path . ' returned a non-int at amounts[' . $index . ']'); + try { + $profiler->reset(); + $updated = $database->upsertDocuments($collection, $documents); + } finally { + $database->setProfiling(false); + } + + $table = $database->getNamespace().'_'.$collection; + $reads = 0; + foreach ($profiler->getLogs() as $log) { + if (\str_starts_with(\ltrim($log->query), 'SELECT') && \str_contains($log->query, $table)) { + $reads++; } + } - $this->assertSame([-3408048000, -42, 3408048000, Database::MAX_BIG_INT], $amounts); + $this->assertSame(count($documents), $updated); + $this->assertGreaterThan( + 0, + $reads, + 'The upsert issued no read against '.$table.' at all, so the bound below would hold vacuously', + ); + $this->assertLessThan( + count($documents), + $reads, + \sprintf( + 'Upserting %d documents issued %d reads against %s: the stored rows are being read one document at a time.', + count($documents), + $reads, + $table, + ), + ); - // An Int64 wrapper survives assertSame above but serialises as - // {"$numberLong":"..."}, which is what reaches an API client. - $this->assertSame( - '{"amount":2000000000,"amounts":[-3408048000,-42,3408048000,' . Database::MAX_BIG_INT . ']}', - \json_encode([ - 'amount' => $document->getAttribute('amount'), - 'amounts' => $amounts, - ]), - $path . ' did not serialise as plain JSON numbers' - ); + foreach ($documents as $index => $document) { + $this->assertEquals($index + 100, $database->getDocument($collection, $document->getId())->getAttribute('value')); } + } - // sum() declares float|int, so a total past int32 is a return type - // violation unless the adapter hands back a native integer. - $sum = $database->sum(__FUNCTION__, 'amount'); - $this->assertIsInt($sum); - $this->assertSame(4000000000, $sum); + public function testUpsertDuplicateIds(): void + { + $db = $this->getDatabase(); + if (!($db->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $db->createCollection(Collection::create(id: __FUNCTION__)); + $db->createAttribute(__FUNCTION__, Attribute::integer(key: 'num', required: true)); + + $doc1 = new Document(['$id' => 'dup', 'num' => 1]); + $doc2 = new Document(['$id' => 'dup', 'num' => 2]); + + try { + $db->upsertDocuments(__FUNCTION__, [$doc1, $doc2]); + $this->fail('Failed to throw exception'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e, $e->getMessage()); + } } - public function testEncodeDecode(): void + public function testPreserveSequenceUpsert(): void { - $collection = new Document([ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('users'), - 'name' => 'Users', - 'attributes' => [ - [ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('email'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 1024, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('status'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('password'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('passwordUpdate'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => ID::custom('registration'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => ID::custom('emailVerification'), - 'type' => Database::VAR_BOOLEAN, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('reset'), - 'type' => Database::VAR_BOOLEAN, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('prefs'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['json'] - ], - [ - '$id' => ID::custom('sessions'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('tokens'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('memberships'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('roles'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => false, - 'array' => true, - 'filters' => [], - ], - [ - '$id' => ID::custom('tags'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => false, - 'array' => true, - 'filters' => ['json'], - ], - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_email'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['email'], - 'lengths' => [1024], - 'orders' => [Database::ORDER_ASC], - ] - ], - ]); - - $document = new Document([ - '$id' => ID::custom('608fdbe51361a'), - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::user('608fdbe51361a')), - Permission::update(Role::user('608fdbe51361a')), - Permission::delete(Role::user('608fdbe51361a')), - ], - 'email' => 'test@example.com', - 'emailVerification' => false, - 'status' => 1, - 'password' => 'randomhash', - 'passwordUpdate' => '2000-06-12 14:12:55', - 'registration' => '1975-06-12 14:12:55+01:00', - 'reset' => false, - 'name' => 'My Name', - 'prefs' => new \stdClass(), - 'sessions' => [], - 'tokens' => [], - 'memberships' => [], - 'roles' => [ - 'admin', - 'developer', - 'tester', - ], - 'tags' => [ - ['$id' => '1', 'label' => 'x'], - ['$id' => '2', 'label' => 'y'], - ['$id' => '3', 'label' => 'z'], - ], - ]); - /** @var Database $database */ $database = $this->getDatabase(); - $result = $database->encode($collection, $document); - - $this->assertEquals('608fdbe51361a', $result->getAttribute('$id')); - $this->assertContains('read("any")', $result->getAttribute('$permissions')); - $this->assertContains('read("any")', $result->getPermissions()); - $this->assertContains('any', $result->getRead()); - $this->assertContains(Permission::create(Role::user(ID::custom('608fdbe51361a'))), $result->getPermissions()); - $this->assertContains('user:608fdbe51361a', $result->getCreate()); - $this->assertContains('user:608fdbe51361a', $result->getWrite()); - $this->assertEquals('test@example.com', $result->getAttribute('email')); - $this->assertEquals(false, $result->getAttribute('emailVerification')); - $this->assertEquals(1, $result->getAttribute('status')); - $this->assertEquals('randomhash', $result->getAttribute('password')); - $this->assertEquals('2000-06-12 14:12:55.000', $result->getAttribute('passwordUpdate')); - $this->assertEquals('1975-06-12 13:12:55.000', $result->getAttribute('registration')); - $this->assertEquals(false, $result->getAttribute('reset')); - $this->assertEquals('My Name', $result->getAttribute('name')); - $this->assertEquals('{}', $result->getAttribute('prefs')); - $this->assertEquals('[]', $result->getAttribute('sessions')); - $this->assertEquals('[]', $result->getAttribute('tokens')); - $this->assertEquals('[]', $result->getAttribute('memberships')); - $this->assertEquals(['admin', 'developer', 'tester',], $result->getAttribute('roles')); - $this->assertEquals(['{"$id":"1","label":"x"}', '{"$id":"2","label":"y"}', '{"$id":"3","label":"z"}',], $result->getAttribute('tags')); - - $result = $database->decode($collection, $document); - - $this->assertEquals('608fdbe51361a', $result->getAttribute('$id')); - $this->assertContains('read("any")', $result->getAttribute('$permissions')); - $this->assertContains('read("any")', $result->getPermissions()); - $this->assertContains('any', $result->getRead()); - $this->assertContains(Permission::create(Role::user('608fdbe51361a')), $result->getPermissions()); - $this->assertContains('user:608fdbe51361a', $result->getCreate()); - $this->assertContains('user:608fdbe51361a', $result->getWrite()); - $this->assertEquals('test@example.com', $result->getAttribute('email')); - $this->assertEquals(false, $result->getAttribute('emailVerification')); - $this->assertEquals(1, $result->getAttribute('status')); - $this->assertEquals('randomhash', $result->getAttribute('password')); - $this->assertEquals('2000-06-12T14:12:55.000+00:00', $result->getAttribute('passwordUpdate')); - $this->assertEquals('1975-06-12T13:12:55.000+00:00', $result->getAttribute('registration')); - $this->assertEquals(false, $result->getAttribute('reset')); - $this->assertEquals('My Name', $result->getAttribute('name')); - $this->assertEquals([], $result->getAttribute('prefs')); - $this->assertEquals([], $result->getAttribute('sessions')); - $this->assertEquals([], $result->getAttribute('tokens')); - $this->assertEquals([], $result->getAttribute('memberships')); - $this->assertEquals(['admin', 'developer', 'tester',], $result->getAttribute('roles')); - $this->assertEquals([ - new Document(['$id' => '1', 'label' => 'x']), - new Document(['$id' => '2', 'label' => 'y']), - new Document(['$id' => '3', 'label' => 'z']), - ], $result->getAttribute('tags')); - } - /** - * @depends testGetDocument - */ - public function testUpdateDocument(Document $document): Document - { - $document - ->setAttribute('string', 'text📝 updated') - ->setAttribute('integer_signed', -6) - ->setAttribute('integer_unsigned', 6) - ->setAttribute('float_signed', -5.56) - ->setAttribute('float_unsigned', 5.56) - ->setAttribute('boolean', false) - ->setAttribute('colors', 'red', Document::SET_TYPE_APPEND) - ->setAttribute('with-dash', 'Works'); + if (!($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; + } - $new = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); + $collectionName = 'preserve_sequence_upsert'; - $this->assertNotEmpty($new->getId()); - $this->assertIsString($new->getAttribute('string')); - $this->assertEquals('text📝 updated', $new->getAttribute('string')); - $this->assertIsInt($new->getAttribute('integer_signed')); - $this->assertEquals(-6, $new->getAttribute('integer_signed')); - $this->assertIsInt($new->getAttribute('integer_unsigned')); - $this->assertEquals(6, $new->getAttribute('integer_unsigned')); - $this->assertIsFloat($new->getAttribute('float_signed')); - $this->assertEquals(-5.56, $new->getAttribute('float_signed')); - $this->assertIsFloat($new->getAttribute('float_unsigned')); - $this->assertEquals(5.56, $new->getAttribute('float_unsigned')); - $this->assertIsBool($new->getAttribute('boolean')); - $this->assertEquals(false, $new->getAttribute('boolean')); - $this->assertIsArray($new->getAttribute('colors')); - $this->assertEquals(['pink', 'green', 'blue', 'red'], $new->getAttribute('colors')); - $this->assertEquals('Works', $new->getAttribute('with-dash')); + $database->createCollection(Collection::create(id: $collectionName)); - $oldPermissions = $document->getPermissions(); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $database->createAttribute($collectionName, Attribute::string(key: 'name', size: 128, required: true)); + } - $new - ->setAttribute('$permissions', Permission::read(Role::guests()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::create(Role::guests()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::update(Role::guests()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::delete(Role::guests()), Document::SET_TYPE_APPEND); + // Create initial documents + $doc1 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice', + ])); - $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); + $doc2 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob', + ])); - $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); + $originalSeq1 = $doc1->getSequence(); + $originalSeq2 = $doc2->getSequence(); - $this->assertContains('guests', $new->getRead()); - $this->assertContains('guests', $new->getWrite()); - $this->assertContains('guests', $new->getCreate()); - $this->assertContains('guests', $new->getUpdate()); - $this->assertContains('guests', $new->getDelete()); + $this->assertNotEmpty($originalSeq1); + $this->assertNotEmpty($originalSeq2); - $new->setAttribute('$permissions', $oldPermissions); + // Test: Without preserveSequence (default), $sequence should be ignored + $database->setPreserveSequence(false); - $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => 999, // Try to set a different sequence + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Updated', + ]), + ]); - $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); + $doc1Updated = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name')); + $this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged - $this->assertNotContains('guests', $new->getRead()); - $this->assertNotContains('guests', $new->getWrite()); - $this->assertNotContains('guests', $new->getCreate()); - $this->assertNotContains('guests', $new->getUpdate()); - $this->assertNotContains('guests', $new->getDelete()); + // Test: With preserveSequence=true, $sequence from document should be used + $database->setPreserveSequence(true); - // Test change document ID - $id = $new->getId(); - $newId = 'new-id'; - $new->setAttribute('$id', $newId); - $new = $this->getDatabase()->updateDocument($new->getCollection(), $id, $new); - $this->assertEquals($newId, $new->getId()); + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc2', + '$sequence' => $originalSeq2, // Keep original sequence + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob Updated', + ]), + ]); - // Reset ID - $new->setAttribute('$id', $id); - $new = $this->getDatabase()->updateDocument($new->getCollection(), $newId, $new); - $this->assertEquals($id, $new->getId()); + $doc2Updated = $database->getDocument($collectionName, 'doc2'); + $this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name')); + $this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved - return $document; - } + // Test: withPreserveSequence helper + $database->setPreserveSequence(false); + $doc1 = $database->getDocument($collectionName, 'doc1'); + $currentSeq1 = $doc1->getSequence(); - /** - * @depends testUpdateDocument - */ - public function testUpdateDocumentConflict(Document $document): void - { - $document->setAttribute('integer_signed', 7); - $result = $this->getDatabase()->withRequestTimestamp(new \DateTime(), function () use ($document) { - return $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); + $database->withPreserveSequence(true, function () use ($database, $collectionName, $currentSeq1) { + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => $currentSeq1, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Final', + ]), + ]); }); - $this->assertEquals(7, $result->getAttribute('integer_signed')); - $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); - $document->setAttribute('integer_signed', 8); + $doc1Final = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Alice Final', $doc1Final->getAttribute('name')); + $this->assertEquals($currentSeq1, $doc1Final->getSequence()); + + // Verify flag was reset after withPreserveSequence + $this->assertFalse($database->isPreservingSequence()); + + // Test: With preserveSequence=true, invalid $sequence should throw error (SQL adapters only) + $database->setPreserveSequence(true); + try { - $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () use ($document) { - return $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); - }); - $this->fail('Failed to throw exception'); + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => 'abc', // Invalid sequence value + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Invalid', + ]), + ]); + // Schemaless adapters may not validate sequence type, so only fail for schemaful + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->fail('Expected StructureException for invalid sequence'); + } } catch (Throwable $e) { - $this->assertTrue($e instanceof ConflictException); - $this->assertEquals('Document was updated after the request timestamp', $e->getMessage()); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertInstanceOf(StructureException::class, $e); + $this->assertStringContainsString('sequence', $e->getMessage()); + } } - } - /** - * @depends testUpdateDocument - */ - public function testDeleteDocumentConflict(Document $document): void - { - $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); - $this->expectException(ConflictException::class); - $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () use ($document) { - return $this->getDatabase()->deleteDocument($document->getCollection(), $document->getId()); - }); + $database->setPreserveSequence(false); + $database->deleteCollection($collectionName); } - /** - * @depends testGetDocument - */ - public function testUpdateDocumentDuplicatePermissions(Document $document): Document + public function testRespectNulls(): void { - $new = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); - - $new - ->setAttribute('$permissions', Permission::read(Role::guests()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::read(Role::guests()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::create(Role::guests()), Document::SET_TYPE_APPEND) - ->setAttribute('$permissions', Permission::create(Role::guests()), Document::SET_TYPE_APPEND); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); + $database->createCollection(Collection::create(id: 'documents_nulls')); - $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); + $this->assertSame('string', $database->createAttribute('documents_nulls', Attribute::string(key: 'string', size: 128))->key); + $this->assertSame('integer', $database->createAttribute('documents_nulls', Attribute::integer(key: 'integer'))->key); + $this->assertSame('bigint', $database->createAttribute('documents_nulls', Attribute::integer(key: 'bigint', width: IntegerWidth::Bits64))->key); + $this->assertSame('float', $database->createAttribute('documents_nulls', Attribute::double(key: 'float'))->key); + $this->assertSame('boolean', $database->createAttribute('documents_nulls', Attribute::boolean(key: 'boolean'))->key); - $this->assertContains('guests', $new->getRead()); - $this->assertContains('guests', $new->getCreate()); + $document = $database->createDocument('documents_nulls', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user('1')), + Permission::read(Role::user('2')), + Permission::create(Role::any()), + Permission::create(Role::user('1x')), + Permission::create(Role::user('2x')), + Permission::update(Role::any()), + Permission::update(Role::user('1x')), + Permission::update(Role::user('2x')), + Permission::delete(Role::any()), + Permission::delete(Role::user('1x')), + Permission::delete(Role::user('2x')), + ], + ])); - return $document; + $this->assertNotEmpty($document->getId()); + $this->assertNull($document->getAttribute('string')); + $this->assertNull($document->getAttribute('integer')); + $this->assertNull($document->getAttribute('bigint')); + $this->assertNull($document->getAttribute('float')); + $this->assertNull($document->getAttribute('boolean')); } - /** - * @depends testUpdateDocument - */ - public function testDeleteDocument(Document $document): void + public function testCreateDocumentDefaults(): void { - $result = $this->getDatabase()->deleteDocument($document->getCollection(), $document->getId()); - $document = $this->getDatabase()->getDocument($document->getCollection(), $document->getId()); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals(true, $result); - $this->assertEquals(true, $document->isEmpty()); + $database->createCollection(Collection::create(id: 'defaults')); + + $this->assertSame('string', $database->createAttribute('defaults', Attribute::string(key: 'string', size: 128, default: 'default'))->key); + $this->assertSame('integer', $database->createAttribute('defaults', Attribute::integer(key: 'integer', default: 1))->key); + $this->assertSame('float', $database->createAttribute('defaults', Attribute::double(key: 'float', default: 1.5))->key); + $this->assertSame('boolean', $database->createAttribute('defaults', Attribute::boolean(key: 'boolean', default: true))->key); + $this->assertSame('colors', $database->createAttribute('defaults', Attribute::string(key: 'colors', size: 32, default: ['red', 'green', 'blue'], array: true))->key); + $this->assertSame('datetime', $database->createAttribute('defaults', Attribute::datetime(key: 'datetime', default: '2000-06-12T14:12:55.000+00:00'))->key); + + $document = $database->createDocument('defaults', new Document([ + 'string' => null, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); + + $document2 = $database->getDocument('defaults', $document->getId()); + $this->assertCount(4, $document2->getPermissions()); + $this->assertEquals('read("any")', $document2->getPermissions()[0]); + $this->assertEquals('create("any")', $document2->getPermissions()[1]); + $this->assertEquals('update("any")', $document2->getPermissions()[2]); + $this->assertEquals('delete("any")', $document2->getPermissions()[3]); + + $this->assertNotEmpty($document->getId()); + $this->assertIsString($document->getAttribute('string')); + $this->assertEquals('default', $document->getAttribute('string')); + $this->assertIsInt($document->getAttribute('integer')); + $this->assertEquals(1, $document->getAttribute('integer')); + $this->assertIsFloat($document->getAttribute('float')); + $this->assertEquals(1.5, $document->getAttribute('float')); + $this->assertIsArray($document->getAttribute('colors')); + $this->assertCount(3, $document->getAttribute('colors')); + $this->assertEquals('red', $document->getAttribute('colors')[0]); + $this->assertEquals('green', $document->getAttribute('colors')[1]); + $this->assertEquals('blue', $document->getAttribute('colors')[2]); + $this->assertEquals('2000-06-12T14:12:55.000+00:00', $document->getAttribute('datetime')); + + // cleanup collection + $database->deleteCollection('defaults'); } - public function testUpdateDocuments(): void + public function testIncreaseDecrease(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } + $collection = $this->getIncDecCollection(); + $database->createCollection(Collection::create(id: $collection)); - $collection = 'testUpdateDocuments'; - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $this->assertSame('increase', $database->createAttribute($collection, Attribute::integer(key: 'increase', required: true))->key); + $this->assertSame('decrease', $database->createAttribute($collection, Attribute::integer(key: 'decrease', required: true))->key); + $this->assertSame('increase_text', $database->createAttribute($collection, Attribute::string(key: 'increase_text', required: true))->key); + $this->assertSame('increase_float', $database->createAttribute($collection, Attribute::double(key: 'increase_float', required: true))->key); + $this->assertSame('sizes', $database->createAttribute($collection, Attribute::integer(key: 'sizes', width: IntegerWidth::Bits64, array: true))->key); - $database->createCollection($collection, attributes: [ - new Document([ - '$id' => ID::custom('string'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('integer'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 10000, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('boolean'), - 'type' => Database::VAR_BOOLEAN, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'default' => false, // not null - 'array' => false, - 'filters' => [], - ]), - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ], documentSecurity: false); + $document = $database->createDocument($collection, new Document([ + 'increase' => 100, + 'decrease' => 100, + 'increase_float' => 100, + 'increase_text' => 'some text', + 'sizes' => [10, 20, 30], + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ] + ])); - for ($i = 0; $i < 10; $i++) { - $database->createDocument($collection, new Document([ - '$id' => 'doc' . $i, - 'string' => 'text📝 ' . $i, - 'integer' => $i, - 'boolean' => true - ])); - } + $updatedAt = $document->getUpdatedAt(); - // Test Update half of the documents - $results = []; - $count = $database->updateDocuments($collection, new Document([ - 'string' => 'text📝 updated', - ]), [ - Query::greaterThanEqual('integer', 5), - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - }); + $doc = $database->increaseDocumentAttribute($collection, $document->getId(), 'increase', 1, 101); + $this->assertEquals(101, $doc->getAttribute('increase')); - $this->assertEquals(5, $count); + $document = $database->getDocument($collection, $document->getId()); + $this->assertEquals(101, $document->getAttribute('increase')); + $this->assertNotEquals($updatedAt, $document->getUpdatedAt()); - foreach ($results as $document) { - $this->assertEquals('text📝 updated', $document->getAttribute('string')); - $this->assertEquals(true, $document->getAttribute('boolean')); - } + $doc = $database->decreaseDocumentAttribute($collection, $document->getId(), 'decrease', 1, 98); + $this->assertEquals(99, $doc->getAttribute('decrease')); + $document = $database->getDocument($collection, $document->getId()); + $this->assertEquals(99, $document->getAttribute('decrease')); - $updatedDocuments = $database->find($collection, [ - Query::greaterThanEqual('integer', 5), - ]); + $doc = $database->increaseDocumentAttribute($collection, $document->getId(), 'increase_float', 5.5, 110); + $this->assertEquals(105.5, $doc->getAttribute('increase_float')); + $document = $database->getDocument($collection, $document->getId()); + $this->assertEquals(105.5, $document->getAttribute('increase_float')); - $this->assertCount(5, $updatedDocuments); + $doc = $database->decreaseDocumentAttribute($collection, $document->getId(), 'increase_float', 1.1, 100); + $this->assertEquals(104.4, $doc->getAttribute('increase_float')); + $document = $database->getDocument($collection, $document->getId()); + $this->assertEquals(104.4, $document->getAttribute('increase_float')); - foreach ($updatedDocuments as $document) { - $this->assertEquals('text📝 updated', $document->getAttribute('string')); - $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); - $this->assertEquals(true, $document->getAttribute('boolean')); - } + self::$incDecFixtureInit = true; + self::$incDecFixtureDoc = $document; + } - $controlDocuments = $database->find($collection, [ - Query::lessThan('integer', 5), - ]); + public function testIncreaseLimitMax(): void + { + $document = $this->initIncreaseDecreaseFixture(); - $this->assertEquals(count($controlDocuments), 5); + /** @var Database $database */ + $database = $this->getDatabase(); - foreach ($controlDocuments as $document) { - $this->assertNotEquals('text📝 updated', $document->getAttribute('string')); - } + $this->expectException(Exception::class); + $this->assertEquals(true, $database->increaseDocumentAttribute($this->getIncDecCollection(), $document->getId(), 'increase', 10.5, 102.4)); + } + public function testDecreaseLimitMin(): void + { + $document = $this->initIncreaseDecreaseFixture(); - // Test Update all documents - $this->assertEquals(10, $database->updateDocuments($collection, new Document([ - 'string' => 'text📝 updated all', - ]))); - - $updatedDocuments = $database->find($collection); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals(count($updatedDocuments), 10); + try { + $database->decreaseDocumentAttribute( + $this->getIncDecCollection(), + $document->getId(), + 'decrease', + 10, + 99 + ); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(LimitException::class, $e); + } - foreach ($updatedDocuments as $document) { - $this->assertEquals('text📝 updated all', $document->getAttribute('string')); + try { + $database->decreaseDocumentAttribute( + $this->getIncDecCollection(), + $document->getId(), + 'decrease', + 1000, + 0 + ); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(LimitException::class, $e); } + } + public function testIncreaseTextAttribute(): void + { + $document = $this->initIncreaseDecreaseFixture(); - // TEST: Can't delete documents in the past - $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); + /** @var Database $database */ + $database = $this->getDatabase(); try { - $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () use ($collection, $database) { - $database->updateDocuments($collection, new Document([ - 'string' => 'text📝 updated all', - ])); - }); - $this->fail('Failed to throw exception'); - } catch (ConflictException $e) { - $this->assertEquals('Document was updated after the request timestamp', $e->getMessage()); + $this->assertEquals(false, $database->increaseDocumentAttribute($this->getIncDecCollection(), $document->getId(), 'increase_text')); + $this->fail('Expected TypeException not thrown'); + } catch (Exception $e) { + $this->assertInstanceOf(TypeException::class, $e, $e->getMessage()); } + } + public function testIncreaseArrayAttribute(): void + { + $document = $this->initIncreaseDecreaseFixture(); - // Check collection level permissions - $database->updateCollection($collection, permissions: [ - Permission::read(Role::user('asd')), - Permission::create(Role::user('asd')), - Permission::update(Role::user('asd')), - Permission::delete(Role::user('asd')), - ], documentSecurity: false); + /** @var Database $database */ + $database = $this->getDatabase(); try { - $database->updateDocuments($collection, new Document([ - 'string' => 'text📝 updated all', - ])); - $this->fail('Failed to throw exception'); - } catch (AuthorizationException $e) { - $this->assertStringStartsWith('Missing "update" permission for role "user:asd".', $e->getMessage()); + $this->assertEquals(false, $database->increaseDocumentAttribute($this->getIncDecCollection(), $document->getId(), 'sizes')); + $this->fail('Expected TypeException not thrown'); + } catch (Exception $e) { + $this->assertInstanceOf(TypeException::class, $e); } + } + public function testIncreaseDecreasePreserveDates(): void + { + $document = $this->initIncreaseDecreaseFixture(); - // Check document level permissions - $database->updateCollection($collection, permissions: [], documentSecurity: true); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { - $database->updateDocument($collection, 'doc0', new Document([ - 'string' => 'text📝 updated all', - '$permissions' => [ - Permission::read(Role::user('asd')), - Permission::create(Role::user('asd')), - Permission::update(Role::user('asd')), - Permission::delete(Role::user('asd')), - ], - ])); - }); + $database->setPreserveDates(true); - $this->getDatabase()->getAuthorization()->addRole(Role::user('asd')->toString()); + try { + $before = $database->getDocument($this->getIncDecCollection(), $document->getId()); + $updatedAt = $before->getUpdatedAt(); + $increase = $before->getAttribute('increase'); + $decrease = $before->getAttribute('decrease'); + $this->assertIsNumeric($increase); + $this->assertIsNumeric($decrease); - $database->updateDocuments($collection, new Document([ - 'string' => 'permission text', - ])); + $database->increaseDocumentAttribute($this->getIncDecCollection(), $document->getId(), 'increase', 1); - $documents = $database->find($collection, [ - Query::equal('string', ['permission text']), - ]); + $after = $database->getDocument($this->getIncDecCollection(), $document->getId()); + $this->assertSame($increase + 1, $after->getAttribute('increase')); + $this->assertSame($updatedAt, $after->getUpdatedAt()); - $this->assertCount(1, $documents); + $database->decreaseDocumentAttribute($this->getIncDecCollection(), $document->getId(), 'decrease', 1); - $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { - $unmodifiedDocuments = $database->find($collection, [ - Query::equal('string', ['text📝 updated all']), - ]); + $after = $database->getDocument($this->getIncDecCollection(), $document->getId()); + $this->assertSame($decrease - 1, $after->getAttribute('decrease')); + $this->assertSame($updatedAt, $after->getUpdatedAt()); + } finally { + $database->setPreserveDates(false); + } + } + public function testGetDocumentSelect(): void + { + $document = $this->initDocumentsFixture(__FUNCTION__); - $this->assertCount(9, $unmodifiedDocuments); - }); + $documentId = $document->getId(); - $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { - $database->updateDocuments($collection, new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - ])); - }); + /** @var Database $database */ + $database = $this->getDatabase(); - // Test we can update more documents than batchSize - $this->assertEquals(10, $database->updateDocuments($collection, new Document([ - 'string' => 'batchSize Test' - ]), batchSize: 2)); + $document = $database->getDocument($document->getCollection(), $documentId, [ + Query::select(['string', 'integer_signed']), + ]); - $documents = $database->find($collection); + $this->assertFalse($document->isEmpty()); + $this->assertIsString($document->getAttribute('string')); + $this->assertSame('text📝', $document->getAttribute('string')); + $this->assertIsInt($document->getAttribute('integer_signed')); + $this->assertSame(-Database::MAX_INT, $document->getAttribute('integer_signed')); + $this->assertArrayNotHasKey('float', $document->getAttributes()); + $this->assertArrayNotHasKey('boolean', $document->getAttributes()); + $this->assertArrayNotHasKey('colors', $document->getAttributes()); + $this->assertArrayNotHasKey('with-dash', $document->getAttributes()); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayHasKey('$collection', $document); - foreach ($documents as $document) { - $this->assertEquals('batchSize Test', $document->getAttribute('string')); - } + $document = $database->getDocument($document->getCollection(), $documentId, [ + Query::select(['string', 'integer_signed', '$id']), + ]); - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('string', $document); + $this->assertArrayHasKey('integer_signed', $document); + $this->assertArrayNotHasKey('float', $document); } - public function testUpdateDocumentsWithCallbackSupport(): void + public function testDocumentsFixturesCanBeIsolated(): void { - /** @var Database $database */ + $first = $this->initDocumentsFixture('fixtureIsolationFirst'); + $second = $this->initDocumentsFixture('fixtureIsolationSecond'); $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collection = 'update_callback'; - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $database->deleteDocument($first->getCollection(), $first->getId()); - $database->createCollection($collection, attributes: [ - new Document([ - '$id' => ID::custom('string'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('integer'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 10000, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ], documentSecurity: false); + $this->assertSame(true, $database->getDocument($first->getCollection(), $first->getId())->isEmpty()); + $document = $database->getDocument($second->getCollection(), $second->getId()); + $this->assertSame(false, $document->isEmpty()); + $this->assertSame(-Database::MAX_INT, $document->getAttribute('integer_signed')); + } - for ($i = 0; $i < 10; $i++) { - $database->createDocument($collection, new Document([ - '$id' => 'doc' . $i, - 'string' => 'text📝 ' . $i, - 'integer' => $i - ])); - } - // Test onNext is throwing the error without the onError - // a non existent document to test the error thrown - try { - $results = []; - $count = $database->updateDocuments($collection, new Document([ - 'string' => 'text📝 updated', - ]), [ - Query::greaterThanEqual('integer', 100), - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - throw new Exception("Error thrown to test that update doesn't stop and error is caught"); - }); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - $this->assertEquals("Error thrown to test that update doesn't stop and error is caught", $e->getMessage()); - } + public function testFindOne(): void + { + $this->initMoviesFixture(); - // Test Update half of the documents - $results = []; - $count = $database->updateDocuments($collection, new Document([ - 'string' => 'text📝 updated', - ]), [ - Query::greaterThanEqual('integer', 5), - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - throw new Exception("Error thrown to test that update doesn't stop and error is caught"); - }, onError:function ($e) { - $this->assertInstanceOf(Exception::class, $e); - $this->assertEquals("Error thrown to test that update doesn't stop and error is caught", $e->getMessage()); - }); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals(5, $count); + $document = $database->findOne($this->getMoviesCollection(), [ + Query::offset(2), + Query::orderAsc('name') + ]); - foreach ($results as $document) { - $this->assertEquals('text📝 updated', $document->getAttribute('string')); - } + $this->assertFalse($document->isEmpty()); + $this->assertEquals('Frozen', $document->getAttribute('name')); - $updatedDocuments = $database->find($collection, [ - Query::greaterThanEqual('integer', 5), + $document = $database->findOne($this->getMoviesCollection(), [ + Query::offset(10) ]); - - $this->assertCount(5, $updatedDocuments); + $this->assertTrue($document->isEmpty()); } - /** - * @depends testCreateDocument - */ - public function testReadPermissionsSuccess(Document $document): Document + public function testFindBasicChecks(): void { - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->createDocument('documents', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'string' => 'text📝', - 'integer_signed' => -Database::MAX_INT, - 'integer_unsigned' => Database::MAX_INT, - 'bigint_signed' => -Database::MAX_BIG_INT, - 'bigint_unsigned' => Database::MAX_BIG_INT, - 'float_signed' => -5.55, - 'float_unsigned' => 5.55, - 'boolean' => true, - 'colors' => ['pink', 'green', 'blue'], - ])); - - $this->assertEquals(false, $document->isEmpty()); + $this->getDatabase()->getAuthorization()->removeRole('user:x'); - $this->getDatabase()->getAuthorization()->cleanRoles(); + try { + $documents = $database->find($this->getMoviesCollection()); + $movieDocuments = $documents; + + $this->assertEquals(5, count($documents)); + $this->assertNotEmpty($documents[0]->getId()); + $this->assertEquals($this->getMoviesCollection(), $documents[0]->getCollection()); + $this->assertEquals(['any', 'user:1', 'user:2'], $documents[0]->getPermissionsByType(PermissionType::Read)); + $this->assertEquals(['any', 'user:1x', 'user:2x'], $this->writeRoles($documents[0])); + $this->assertEquals('Frozen', $documents[0]->getAttribute('name')); + $this->assertEquals('Chris Buck & Jennifer Lee', $documents[0]->getAttribute('director')); + $this->assertIsString($documents[0]->getAttribute('director')); + $this->assertEquals(2013, $documents[0]->getAttribute('year')); + $this->assertIsInt($documents[0]->getAttribute('year')); + $this->assertEquals(39.50, $documents[0]->getAttribute('price')); + $this->assertIsFloat($documents[0]->getAttribute('price')); + $this->assertEquals(true, $documents[0]->getAttribute('active')); + $this->assertIsBool($documents[0]->getAttribute('active')); + $this->assertEquals(['animation', 'kids'], $documents[0]->getAttribute('genres')); + $this->assertIsArray($documents[0]->getAttribute('genres')); + $this->assertEquals('Works', $documents[0]->getAttribute('with-dash')); + + // Alphabetical order + $sortedDocuments = $movieDocuments; + \usort($sortedDocuments, function (Document $doc1, Document $doc2) { + return strcmp($doc1->getId(), $doc2->getId()); + }); - $document = $database->getDocument($document->getCollection(), $document->getId()); - $this->assertEquals(true, $document->isEmpty()); + $firstDocumentId = $sortedDocuments[0]->getId(); + $lastDocumentId = $sortedDocuments[\count($sortedDocuments) - 1]->getId(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - - return $document; - } - - /** - * @depends testCreateDocument - */ - public function testWritePermissionsSuccess(Document $document): void - { - $this->getDatabase()->getAuthorization()->cleanRoles(); + /** + * Check $id: Notice, this orders ID names alphabetically, not by internal numeric ID + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('$id'), + ]); + $this->assertEquals($lastDocumentId, $documents[0]->getId()); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderAsc('$id'), + ]); + $this->assertEquals($firstDocumentId, $documents[0]->getId()); - /** @var Database $database */ - $database = $this->getDatabase(); + /** + * Check internal numeric ID sorting + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc(''), + ]); + $this->assertEquals($movieDocuments[\count($movieDocuments) - 1]->getId(), $documents[0]->getId()); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderAsc(''), + ]); + $this->assertEquals($movieDocuments[0]->getId(), $documents[0]->getId()); - $this->expectException(AuthorizationException::class); - $database->createDocument('documents', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'string' => 'text📝', - 'integer_signed' => -Database::MAX_INT, - 'integer_unsigned' => Database::MAX_INT, - 'bigint_signed' => -Database::MAX_BIG_INT, - 'bigint_unsigned' => Database::MAX_BIG_INT, - 'float_signed' => -5.55, - 'float_unsigned' => 5.55, - 'boolean' => true, - 'colors' => ['pink', 'green', 'blue'], - ])); + } finally { + $this->getDatabase()->getAuthorization()->addRole('user:x'); + } } - /** - * @depends testCreateDocument - */ - public function testWritePermissionsUpdateFailure(Document $document): Document + public function testFindAttributeNamedAfterInternalKey(): void { - $this->expectException(AuthorizationException::class); - - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->createDocument('documents', new Document([ + $database->createCollection(Collection::create(id: __FUNCTION__)); + $this->assertSame('collection', $database->createAttribute(__FUNCTION__, Attribute::string(key: 'collection', size: 128))->key); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => Id::custom('clash'), '$permissions' => [ Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), ], - 'string' => 'text📝', - 'integer_signed' => -Database::MAX_INT, - 'integer_unsigned' => Database::MAX_INT, - 'bigint_signed' => -Database::MAX_BIG_INT, - 'bigint_unsigned' => Database::MAX_BIG_INT, - 'float_signed' => -5.55, - 'float_unsigned' => 5.55, - 'boolean' => true, - 'colors' => ['pink', 'green', 'blue'], + 'collection' => 'value', ])); - $this->getDatabase()->getAuthorization()->cleanRoles(); - - $document = $database->updateDocument('documents', $document->getId(), new Document([ - '$id' => ID::custom($document->getId()), + $database->createDocument(__FUNCTION__, new Document([ + '$id' => Id::custom('clashNull'), '$permissions' => [ Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), ], - 'string' => 'text📝', - 'integer_signed' => 6, - 'bigint_signed' => -Database::MAX_BIG_INT, - 'float_signed' => -Database::MAX_DOUBLE, - 'float_unsigned' => Database::MAX_DOUBLE, - 'boolean' => true, - 'colors' => ['pink', 'green', 'blue'], + 'collection' => null, ])); - return $document; - } - - /** - * @depends testFind - */ - public function testUniqueIndexDuplicate(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $this->assertEquals(true, $database->createIndex('movies', 'uniqueIndex', Database::INDEX_UNIQUE, ['name'], [128], [Database::ORDER_ASC])); - - try { - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Frozen', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2013, - 'price' => 39.50, - 'active' => true, - 'genres' => ['animation', 'kids'], - 'with-dash' => 'Works4' - ])); + $documents = $database->find(__FUNCTION__, [Query::orderAsc('$id')]); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - $this->assertInstanceOf(UniqueException::class, $e); - } + $this->assertCount(2, $documents); + $this->assertEquals('value', $documents[0]->getAttribute('collection')); + // getAttribute() reads a dropped key and a null value the same way + $this->assertTrue($documents[1]->offsetExists('collection')); } - /** - * Test that duplicate exceptions differentiate between - * document ID duplicates and unique index violations. - */ - public function testDuplicateExceptionMessages(): void + public function testFindAttributeNamedAfterTenantKey(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUniqueIndex()) { + if (! $database->hasSharedTables()) { $this->expectNotToPerformAssertions(); - return; - } - $database->createCollection('duplicateMessages'); - $database->createAttribute('duplicateMessages', 'email', Database::VAR_STRING, 128, true); - $database->createIndex('duplicateMessages', 'emailUnique', Database::INDEX_UNIQUE, ['email'], [128]); - - // Create first document - $database->createDocument('duplicateMessages', new Document([ - '$id' => 'dup_msg_1', - '$permissions' => [ - Permission::read(Role::any()), - ], - 'email' => 'test@example.com', - ])); - - // Test 1: Duplicate document ID should say "Document already exists" - try { - $database->createDocument('duplicateMessages', new Document([ - '$id' => 'dup_msg_1', - '$permissions' => [ - Permission::read(Role::any()), - ], - 'email' => 'different@example.com', - ])); - $this->fail('Expected DuplicateException for duplicate document ID'); - } catch (DuplicateException $e) { - $this->assertNotInstanceOf(UniqueException::class, $e); - $this->assertStringContainsString('Document already exists', $e->getMessage()); + return; } - // Test 2: Unique index violation should use UniqueException - try { - $database->createDocument('duplicateMessages', new Document([ - '$id' => 'dup_msg_2', - '$permissions' => [ - Permission::read(Role::any()), - ], - 'email' => 'test@example.com', - ])); - $this->fail('Expected DuplicateException for unique index violation'); - } catch (DuplicateException $e) { - $this->assertInstanceOf(UniqueException::class, $e); - $this->assertStringContainsString('Unique index violation', $e->getMessage()); - } + $database->createCollection(Collection::create(id: __FUNCTION__)); + $this->assertSame('tenant', $database->createAttribute(__FUNCTION__, Attribute::string(key: 'tenant', size: 128))->key); - // Test 3: A conflicting value containing "_uid" must not be mistaken - // for a document identifier conflict - $database->createDocument('duplicateMessages', new Document([ - '$id' => 'dup_msg_3', + $database->createDocument(__FUNCTION__, new Document([ + '$id' => Id::custom('clash'), '$permissions' => [ Permission::read(Role::any()), ], - 'email' => 'prefix_uid_suffix@example.com', + 'tenant' => 'value', ])); - try { - $database->createDocument('duplicateMessages', new Document([ - '$id' => 'dup_msg_4', - '$permissions' => [ - Permission::read(Role::any()), - ], - 'email' => 'prefix_uid_suffix@example.com', - ])); - $this->fail('Expected DuplicateException for unique index violation'); - } catch (DuplicateException $e) { - $this->assertInstanceOf(UniqueException::class, $e); - $this->assertStringContainsString('Unique index violation', $e->getMessage()); - } + // A select leaves _tenant out of the projection, so $tenant is null at decode + $documents = $database->find(__FUNCTION__, [Query::select(['tenant'])]); - $database->deleteCollection('duplicateMessages'); + $this->assertCount(1, $documents); + $this->assertEquals('value', $documents[0]->getAttribute('tenant')); } - /** - * @depends testUniqueIndexDuplicate - */ - public function testUniqueIndexDuplicateUpdate(): void + public function testCreateDocumentReturnsTheCreatingTenantsDocument(): void { /** @var Database $database */ $database = $this->getDatabase(); - $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); - // create document then update to conflict with index - $document = $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user('1')), - Permission::read(Role::user('2')), - Permission::create(Role::any()), - Permission::create(Role::user('1x')), - Permission::create(Role::user('2x')), - Permission::update(Role::any()), - Permission::update(Role::user('1x')), - Permission::update(Role::user('2x')), - Permission::delete(Role::any()), - Permission::delete(Role::user('1x')), - Permission::delete(Role::user('2x')), - ], - 'name' => 'Frozen 5', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2013, - 'price' => 39.50, - 'active' => true, - 'genres' => ['animation', 'kids'], - 'with-dash' => 'Works4' - ])); + if (! $database->hasSharedTables() || $database->isTenantPerDocument()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getIdAttributeType() === ColumnType::Integer; + $first = $integerTenants ? 41 : 'tenant_41'; + $second = $integerTenants ? 42 : 'tenant_42'; + $collection = 'tenant_ids_'.\substr(\uniqid(), -6); try { - $database->updateDocument('movies', $document->getId(), $document->setAttribute('name', 'Frozen')); + foreach ([$first, $second] as $tenant) { + $database->setTenant($tenant); + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'email', size: 64, required: true), + Attribute::string(key: 'secret', size: 64, required: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + } - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - $this->assertInstanceOf(UniqueException::class, $e); + $database->setTenant($first); + $firstCreated = $database->createDocument($collection, new Document(['$id' => 'shared', 'email' => 'first@tenant', 'secret' => 'first'])); + $database->createDocuments($collection, [new Document(['$id' => 'batch', 'email' => 'first@tenant', 'secret' => 'first'])]); + + $database->setTenant($second); + $secondCreated = $database->createDocument($collection, new Document(['$id' => 'shared', 'email' => 'second@tenant', 'secret' => 'second'])); + + $this->assertSame((string) $second, (string) $secondCreated->getTenant()); + $this->assertSame('second@tenant', $secondCreated->getAttribute('email')); + $this->assertSame('second', $secondCreated->getAttribute('secret')); + + $batch = []; + $this->assertSame(1, $database->createDocuments( + $collection, + [new Document(['$id' => 'batch', 'email' => 'second@tenant', 'secret' => 'second'])], + onNext: function (Document $document) use (&$batch): void { + $batch[] = $document; + }, + )); + $this->assertCount(1, $batch); + $this->assertSame('second@tenant', $batch[0]->getAttribute('email')); + + $this->assertSame('second@tenant', $database->getDocument($collection, 'shared')->getAttribute('email')); + $this->assertSame($secondCreated->getSequence(), $database->getDocument($collection, 'shared')->getSequence()); + $this->assertSame('second@tenant', $database->getDocument($collection, 'batch')->getAttribute('email')); + $this->assertSame(2, $database->count($collection)); + + $database->setTenant($first); + $firstRead = $database->getDocument($collection, 'shared'); + $this->assertSame((string) $first, (string) $firstRead->getTenant()); + $this->assertSame('first@tenant', $firstRead->getAttribute('email')); + $this->assertSame('first', $firstRead->getAttribute('secret')); + $this->assertSame($firstCreated->getSequence(), $firstRead->getSequence()); + $this->assertSame('first@tenant', $database->getDocument($collection, 'batch')->getAttribute('email')); + $this->assertSame(2, $database->count($collection)); + } finally { + foreach ([$first, $second] as $tenant) { + $database->setTenant($tenant); + try { + $database->deleteCollection($collection); + } catch (Throwable) { + } + } + $database->setTenant($originalTenant); } } - public function propagateBulkDocuments(string $collection, int $amount = 10, bool $documentSecurity = false): void + public function testFindCheckPermissions(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - for ($i = 0; $i < $amount; $i++) { - $database->createDocument($collection, new Document( - array_merge([ - '$id' => 'doc' . $i, - 'text' => 'value' . $i, - 'integer' => $i - ], $documentSecurity ? [ - '$permissions' => [ - Permission::create(Role::any()), - Permission::read(Role::any()), - ], - ] : []) - )); - } + /** + * Check Permissions + */ + $this->getDatabase()->getAuthorization()->addRole('user:x'); + $documents = $database->find($this->getMoviesCollection()); + + $this->assertEquals(6, count($documents)); } - public function testDeleteBulkDocuments(): void + public function testFindStringQueryEqual(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection( - 'bulk_delete', - attributes: [ - new Document([ - '$id' => 'text', - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => true, - ]), - new Document([ - '$id' => 'integer', - 'type' => Database::VAR_INTEGER, - 'size' => 10, - 'required' => true, - ]) - ], - permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::delete(Role::any()) - ], - documentSecurity: false - ); - - $this->propagateBulkDocuments('bulk_delete'); - - $docs = $database->find('bulk_delete'); - $this->assertCount(10, $docs); - /** - * Test Short select query, test pagination as well, Add order to select + * String condition */ - $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt']; + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('director', ['TBD']), + ]); - $count = $database->deleteDocuments( - collection: 'bulk_delete', - queries: [ - Query::select([...$selects, '$createdAt']), - Query::cursorAfter($docs[6]), - Query::greaterThan('$createdAt', '2000-01-01'), - Query::orderAsc('$createdAt'), - Query::orderAsc(), - Query::limit(2), - ], - batchSize: 1 - ); + $this->assertEquals(2, count($documents)); - $this->assertEquals(2, $count); + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('director', ['']), + ]); - // TEST: Bulk Delete All Documents - $this->assertEquals(8, $database->deleteDocuments('bulk_delete')); + $this->assertEquals(0, count($documents)); + } - $docs = $database->find('bulk_delete'); - $this->assertCount(0, $docs); + public function testFindNotEqual(): void + { + $this->initMoviesFixture(); - // TEST: Bulk delete documents with queries. - $this->propagateBulkDocuments('bulk_delete'); + /** @var Database $database */ + $database = $this->getDatabase(); - $results = []; - $count = $database->deleteDocuments('bulk_delete', [ - Query::greaterThanEqual('integer', 5) - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - }); + /** + * Not Equal query + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEqual('director', 'TBD'), + ]); - $this->assertEquals(5, $count); + $this->assertGreaterThan(0, count($documents)); - foreach ($results as $document) { - $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); + foreach ($documents as $document) { + $this->assertTrue($document['director'] !== 'TBD'); } - $docs = $database->find('bulk_delete'); - $this->assertEquals(5, \count($docs)); + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEqual('director', ''), + ]); - // TEST (FAIL): Can't delete documents in the past - $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); + $total = $database->count($this->getMoviesCollection()); - try { - $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () { - return $this->getDatabase()->deleteDocuments('bulk_delete'); - }); - $this->fail('Failed to throw exception'); - } catch (ConflictException $e) { - $this->assertEquals('Document was updated after the request timestamp', $e->getMessage()); - } + $this->assertEquals($total, count($documents)); + } - // TEST (FAIL): Bulk delete all documents with invalid collection permission - $database->updateCollection('bulk_delete', [], false); - try { - $database->deleteDocuments('bulk_delete'); - $this->fail('Bulk deleted documents with invalid collection permission'); - } catch (\Utopia\Database\Exception\Authorization) { - } + public function testFindBetween(): void + { + $this->initMoviesFixture(); - $database->updateCollection('bulk_delete', [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::delete(Role::any()) - ], false); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals(5, $database->deleteDocuments('bulk_delete')); - $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); + $documents = $database->find($this->getMoviesCollection(), [ + Query::between('price', 25.94, 25.99), + ]); + $this->assertEquals(2, count($documents)); - // TEST: Make sure we can't delete documents we don't have permissions for - $database->updateCollection('bulk_delete', [ - Permission::create(Role::any()), - ], true); - $this->propagateBulkDocuments('bulk_delete', documentSecurity: true); + $documents = $database->find($this->getMoviesCollection(), [ + Query::between('price', 30, 35), + ]); + $this->assertEquals(0, count($documents)); - $this->assertEquals(0, $database->deleteDocuments('bulk_delete')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::between('$createdAt', '1975-12-06', '2050-12-06'), + ]); + $this->assertEquals(6, count($documents)); - $documents = $this->getDatabase()->getAuthorization()->skip(function () use ($database) { - return $database->find('bulk_delete'); - }); + $documents = $database->find($this->getMoviesCollection(), [ + Query::between('$updatedAt', '1975-12-06T07:08:49.733+02:00', '2050-02-05T10:15:21.825+00:00'), + ]); + $this->assertEquals(6, count($documents)); + } - $this->assertEquals(10, \count($documents)); + public function testFindMultipleConditions(): void + { + $this->initMoviesFixture(); - $database->updateCollection('bulk_delete', [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::delete(Role::any()) - ], false); + /** @var Database $database */ + $database = $this->getDatabase(); - $database->deleteDocuments('bulk_delete'); + /** + * Multiple conditions + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('director', ['TBD']), + Query::equal('year', [2026]), + ]); - $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); + $this->assertEquals(1, count($documents)); - // Teardown - $database->deleteCollection('bulk_delete'); + /** + * Multiple conditions and OR values + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('name', ['Frozen II', 'Captain Marvel']), + ]); + + $this->assertEquals(2, count($documents)); + $this->assertEquals('Frozen II', $documents[0]['name']); + $this->assertEquals('Captain Marvel', $documents[1]['name']); } - public function testDeleteBulkDocumentsQueries(): void + public function testFindOrderBy(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection( - 'bulk_delete_queries', - attributes: [ - new Document([ - '$id' => 'text', - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => true, - ]), - new Document([ - '$id' => 'integer', - 'type' => Database::VAR_INTEGER, - 'size' => 10, - 'required' => true, - ]) - ], - documentSecurity: false, - permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::delete(Role::any()) - ] - ); - - // Test limit - $this->propagateBulkDocuments('bulk_delete_queries'); - - $this->assertEquals(5, $database->deleteDocuments('bulk_delete_queries', [Query::limit(5)])); - $this->assertEquals(5, \count($database->find('bulk_delete_queries'))); - - $this->assertEquals(5, $database->deleteDocuments('bulk_delete_queries', [Query::limit(5)])); - $this->assertEquals(0, \count($database->find('bulk_delete_queries'))); + /** + * ORDER BY + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('name') + ]); - // Test Limit more than batchSize - $this->propagateBulkDocuments('bulk_delete_queries', Database::DELETE_BATCH_SIZE * 2); - $this->assertEquals(Database::DELETE_BATCH_SIZE * 2, \count($database->find('bulk_delete_queries', [Query::limit(Database::DELETE_BATCH_SIZE * 2)]))); - $this->assertEquals(Database::DELETE_BATCH_SIZE + 2, $database->deleteDocuments('bulk_delete_queries', [Query::limit(Database::DELETE_BATCH_SIZE + 2)])); - $this->assertEquals(Database::DELETE_BATCH_SIZE - 2, \count($database->find('bulk_delete_queries', [Query::limit(Database::DELETE_BATCH_SIZE * 2)]))); - $this->assertEquals(Database::DELETE_BATCH_SIZE - 2, $this->getDatabase()->deleteDocuments('bulk_delete_queries')); + $this->assertEquals(6, count($documents)); + $this->assertEquals('Frozen', $documents[0]['name']); + $this->assertEquals('Frozen II', $documents[1]['name']); + $this->assertEquals('Captain Marvel', $documents[2]['name']); + $this->assertEquals('Captain America: The First Avenger', $documents[3]['name']); + $this->assertEquals('Work in Progress', $documents[4]['name']); + $this->assertEquals('Work in Progress 2', $documents[5]['name']); + } - // Test Offset - $this->propagateBulkDocuments('bulk_delete_queries', 100); - $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries', [Query::offset(50)])); + public function testFindOrderByNatural(): void + { + $this->initMoviesFixture(); - $docs = $database->find('bulk_delete_queries', [Query::limit(100)]); - $this->assertEquals(50, \count($docs)); + /** @var Database $database */ + $database = $this->getDatabase(); - $lastDoc = \end($docs); - $this->assertNotEmpty($lastDoc); - $this->assertEquals('doc49', $lastDoc->getId()); - $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries')); + /** + * ORDER BY natural + */ + $base = array_reverse($database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + ])); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc(''), + ]); - $database->deleteCollection('bulk_delete_queries'); + $this->assertEquals(6, count($documents)); + $this->assertEquals($base[0]['name'], $documents[0]['name']); + $this->assertEquals($base[1]['name'], $documents[1]['name']); + $this->assertEquals($base[2]['name'], $documents[2]['name']); + $this->assertEquals($base[3]['name'], $documents[3]['name']); + $this->assertEquals($base[4]['name'], $documents[4]['name']); + $this->assertEquals($base[5]['name'], $documents[5]['name']); } - public function testDeleteBulkDocumentsWithCallbackSupport(): void + public function testFindOrderByMultipleAttributes(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } + /** + * ORDER BY - Multiple attributes + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('price'), + Query::orderDesc('name') + ]); - $database->createCollection( - 'bulk_delete_with_callback', - attributes: [ - new Document([ - '$id' => 'text', - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => true, - ]), - new Document([ - '$id' => 'integer', - 'type' => Database::VAR_INTEGER, - 'size' => 10, - 'required' => true, - ]) - ], - permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::delete(Role::any()) - ], - documentSecurity: false - ); + $this->assertEquals(6, count($documents)); + $this->assertEquals('Frozen II', $documents[0]['name']); + $this->assertEquals('Frozen', $documents[1]['name']); + $this->assertEquals('Captain Marvel', $documents[2]['name']); + $this->assertEquals('Captain America: The First Avenger', $documents[3]['name']); + $this->assertEquals('Work in Progress 2', $documents[4]['name']); + $this->assertEquals('Work in Progress', $documents[5]['name']); + } - $this->propagateBulkDocuments('bulk_delete_with_callback'); + public function testFindOrderByCursorAfter(): void + { + $this->initMoviesFixture(); - $docs = $database->find('bulk_delete_with_callback'); - $this->assertCount(10, $docs); + /** @var Database $database */ + $database = $this->getDatabase(); /** - * Test Short select query, test pagination as well, Add order to select + * ORDER BY - After */ - $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt']; + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + ]); - try { - // a non existent document to test the error thrown - $database->deleteDocuments( - collection: 'bulk_delete_with_callback', - queries: [ - Query::select([...$selects, '$createdAt']), - Query::lessThan('$createdAt', '1800-01-01'), - Query::orderAsc('$createdAt'), - Query::orderAsc(), - Query::limit(1), - ], - batchSize: 1, - onNext: function () { - throw new Exception("Error thrown to test that deletion doesn't stop and error is caught"); - } - ); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - $this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage()); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorAfter($movies[1]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[2]['name'], $documents[0]['name']); + $this->assertEquals($movies[3]['name'], $documents[1]['name']); - $docs = $database->find('bulk_delete_with_callback'); - $this->assertCount(10, $docs); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorAfter($movies[3]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[4]['name'], $documents[0]['name']); + $this->assertEquals($movies[5]['name'], $documents[1]['name']); - $count = $database->deleteDocuments( - collection: 'bulk_delete_with_callback', - queries: [ - Query::select([...$selects, '$createdAt']), - Query::cursorAfter($docs[6]), - Query::greaterThan('$createdAt', '2000-01-01'), - Query::orderAsc('$createdAt'), - Query::orderAsc(), - Query::limit(2), - ], - batchSize: 1, - onNext: function () { - // simulating error throwing but should not stop deletion - throw new Exception("Error thrown to test that deletion doesn't stop and error is caught"); - }, - onError:function ($e) { - $this->assertInstanceOf(Exception::class, $e); - $this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage()); - } - ); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorAfter($movies[4]) + ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[5]['name'], $documents[0]['name']); - $this->assertEquals(2, $count); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorAfter($movies[5]) + ]); + $this->assertEmpty(count($documents)); - // TEST: Bulk Delete All Documents without passing callbacks - $this->assertEquals(8, $database->deleteDocuments('bulk_delete_with_callback')); + /** + * Multiple order by, Test tie-break on year 2019 + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::orderAsc('year'), + Query::orderAsc('price'), + ]); - $docs = $database->find('bulk_delete_with_callback'); - $this->assertCount(0, $docs); + $this->assertEquals(6, count($movies)); - // TEST: Bulk delete documents with queries with callbacks - $this->propagateBulkDocuments('bulk_delete_with_callback'); + $this->assertEquals($movies[0]['name'], 'Captain America: The First Avenger'); + $this->assertEquals($movies[0]['year'], 2011); + $this->assertEquals($movies[0]['price'], 25.94); - $results = []; - $count = $database->deleteDocuments('bulk_delete_with_callback', [ - Query::greaterThanEqual('integer', 5) - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - throw new Exception("Error thrown to test that deletion doesn't stop and error is caught"); - }, onError:function ($e) { - $this->assertInstanceOf(Exception::class, $e); - $this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage()); - }); + $this->assertEquals($movies[1]['name'], 'Frozen'); + $this->assertEquals($movies[1]['year'], 2013); + $this->assertEquals($movies[1]['price'], 39.5); - $this->assertEquals(5, $count); + $this->assertEquals($movies[2]['name'], 'Captain Marvel'); + $this->assertEquals($movies[2]['year'], 2019); + $this->assertEquals($movies[2]['price'], 25.99); - foreach ($results as $document) { - $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); - } + $this->assertEquals($movies[3]['name'], 'Frozen II'); + $this->assertEquals($movies[3]['year'], 2019); + $this->assertEquals($movies[3]['price'], 39.5); - $docs = $database->find('bulk_delete_with_callback'); - $this->assertEquals(5, \count($docs)); + $this->assertEquals($movies[4]['name'], 'Work in Progress'); + $this->assertEquals($movies[4]['year'], 2025); + $this->assertEquals($movies[4]['price'], 0); - // Teardown - $database->deleteCollection('bulk_delete_with_callback'); - } + $this->assertEquals($movies[5]['name'], 'Work in Progress 2'); + $this->assertEquals($movies[5]['year'], 2026); + $this->assertEquals($movies[5]['price'], 0); - public function testUpdateDocumentsQueries(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $pos = 2; + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderAsc('year'), + Query::orderAsc('price'), + Query::cursorAfter($movies[$pos]) + ]); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } + $this->assertEquals(3, count($documents)); - $collection = 'testUpdateDocumentsQueries'; + foreach ($documents as $i => $document) { + $this->assertEquals($document['name'], $movies[$i + 1 + $pos]['name']); + $this->assertEquals($document['price'], $movies[$i + 1 + $pos]['price']); + $this->assertEquals($document['year'], $movies[$i + 1 + $pos]['year']); + } + } - $database->createCollection($collection, attributes: [ - new Document([ - '$id' => ID::custom('text'), - 'type' => Database::VAR_STRING, - 'size' => 64, - 'required' => true, - ]), - new Document([ - '$id' => ID::custom('integer'), - 'type' => Database::VAR_INTEGER, - 'size' => 64, - 'required' => true, - ]), - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ], documentSecurity: true); + public function testFindOrderByCursorBefore(): void + { + $this->initMoviesFixture(); - // Test limit - $this->propagateBulkDocuments($collection, 100); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals(10, $database->updateDocuments($collection, new Document([ - 'text' => 'text📝 updated', - ]), [Query::limit(10)])); + /** + * ORDER BY - Before + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + ]); - $this->assertEquals(10, \count($database->find($collection, [Query::equal('text', ['text📝 updated'])]))); - $this->assertEquals(100, $database->deleteDocuments($collection)); - $this->assertEquals(0, \count($database->find($collection))); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorBefore($movies[5]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[3]['name'], $documents[0]['name']); + $this->assertEquals($movies[4]['name'], $documents[1]['name']); - // Test Offset - $this->propagateBulkDocuments($collection, 100); - $this->assertEquals(50, $database->updateDocuments($collection, new Document([ - 'text' => 'text📝 updated', - ]), [ - Query::offset(50), - ])); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorBefore($movies[3]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[1]['name'], $documents[0]['name']); + $this->assertEquals($movies[2]['name'], $documents[1]['name']); - $docs = $database->find($collection, [Query::equal('text', ['text📝 updated']), Query::limit(100)]); - $this->assertCount(50, $docs); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorBefore($movies[2]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertEquals($movies[1]['name'], $documents[1]['name']); - $lastDoc = end($docs); - $this->assertNotEmpty($lastDoc); - $this->assertEquals('doc99', $lastDoc->getId()); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorBefore($movies[1]) + ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $this->assertEquals(100, $database->deleteDocuments($collection)); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorBefore($movies[0]) + ]); + $this->assertEmpty(count($documents)); } - /** - * @depends testCreateDocument - */ - public function testFulltextIndexWithInteger(): void + public function testFindOrderByAfterNaturalOrder(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { - $this->expectException(Exception::class); - if (!$this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { - $this->expectExceptionMessage('Fulltext index is not supported'); - } else { - $this->expectExceptionMessage('Attribute "integer_signed" cannot be part of a fulltext index, must be of type string'); - } + /** + * ORDER BY - After by natural order + */ + $movies = array_reverse($database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + ])); - $database->createIndex('documents', 'fulltext_integer', Database::INDEX_FULLTEXT, ['string','integer_signed']); - } else { - $this->expectNotToPerformAssertions(); - return; - } - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorAfter($movies[1]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[2]['name'], $documents[0]['name']); + $this->assertEquals($movies[3]['name'], $documents[1]['name']); - public function testEnableDisableValidation(): void - { - $database = $this->getDatabase(); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorAfter($movies[3]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[4]['name'], $documents[0]['name']); + $this->assertEquals($movies[5]['name'], $documents[1]['name']); - $database->createCollection('validation', permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorAfter($movies[4]) ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[5]['name'], $documents[0]['name']); - $database->createAttribute( - 'validation', - 'name', - Database::VAR_STRING, - 10, - false - ); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorAfter($movies[5]) + ]); + $this->assertEmpty(count($documents)); + } - $database->createDocument('validation', new Document([ - '$id' => 'docwithmorethan36charsasitsidentifier', - 'name' => 'value1', - ])); + public function testFindOrderByBeforeNaturalOrder(): void + { + $this->initMoviesFixture(); - try { - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - } + /** @var Database $database */ + $database = $this->getDatabase(); - $database->disableValidation(); + /** + * ORDER BY - Before by natural order + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc(''), + ]); - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorBefore($movies[5]) ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[3]['name'], $documents[0]['name']); + $this->assertEquals($movies[4]['name'], $documents[1]['name']); - $database->enableValidation(); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorBefore($movies[3]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[1]['name'], $documents[0]['name']); + $this->assertEquals($movies[2]['name'], $documents[1]['name']); - try { - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorBefore($movies[2]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertEquals($movies[1]['name'], $documents[1]['name']); - $database->skipValidation(function () use ($database) { - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), - ]); - }); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorBefore($movies[1]) + ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $database->enableValidation(); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorBefore($movies[0]) + ]); + $this->assertEmpty(count($documents)); } - /** - * @depends testGetDocument - */ - public function testExceptionDuplicate(Document $document): void + public function testFindOrderBySingleAttributeAfter(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - - $document->setAttribute('$id', 'duplicated'); - $document->removeAttribute('$sequence'); - - $database->createDocument($document->getCollection(), $document); - $document->removeAttribute('$sequence'); - - try { - $database->createDocument($document->getCollection(), $document); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - } - } + $this->initMoviesFixture(); - /** - * @depends testGetDocument - */ - public function testExceptionCaseInsensitiveDuplicate(Document $document): Document - { /** @var Database $database */ $database = $this->getDatabase(); - $document->setAttribute('$id', 'caseSensitive'); - $document->removeAttribute('$sequence'); + /** + * ORDER BY - Single Attribute After + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('year') + ]); - $database->createDocument($document->getCollection(), $document); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorAfter($movies[1]) + ]); - $document->setAttribute('$id', 'CaseSensitive'); - $document->removeAttribute('$sequence'); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[2]['name'], $documents[0]['name']); + $this->assertEquals($movies[3]['name'], $documents[1]['name']); - try { - $database->createDocument($document->getCollection(), $document); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorAfter($movies[3]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[4]['name'], $documents[0]['name']); + $this->assertEquals($movies[5]['name'], $documents[1]['name']); - return $document; + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorAfter($movies[4]) + ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[5]['name'], $documents[0]['name']); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorAfter($movies[5]) + ]); + $this->assertEmpty(count($documents)); } - public function testEmptyTenant(): void + public function testFindOrderBySingleAttributeBefore(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSharedTables()) { - $documents = $database->find( - 'documents', - [Query::select(['*'])] // Mongo bug with Integer UID - ); + /** + * ORDER BY - Single Attribute Before + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('year') + ]); - $document = $documents[0]; - $doc = $database->getDocument($document->getCollection(), $document->getId()); - $this->assertEquals($document->getTenant(), $doc->getTenant()); - return; - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[5]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[3]['name'], $documents[0]['name']); + $this->assertEquals($movies[4]['name'], $documents[1]['name']); - $documents = $database->find( - 'documents', - [Query::notEqual('$id', '56000')] // Mongo bug with Integer UID - ); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[3]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[1]['name'], $documents[0]['name']); + $this->assertEquals($movies[2]['name'], $documents[1]['name']); - $document = $documents[0]; - $this->assertArrayHasKey('$id', $document); - $this->assertArrayNotHasKey('$tenant', $document); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[2]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertEquals($movies[1]['name'], $documents[1]['name']); - $document = $database->getDocument('documents', $document->getId()); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayNotHasKey('$tenant', $document); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[1]) + ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $document = $database->updateDocument('documents', $document->getId(), $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayNotHasKey('$tenant', $document); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[0]) + ]); + $this->assertEmpty(count($documents)); } - public function testEmptyOperatorValues(): void + public function testFindOrderByMultipleAttributeAfter(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - try { - $database->findOne('documents', [ - Query::equal('string', []), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - $this->assertEquals('Invalid query: Equal queries require at least one value.', $e->getMessage()); - } - - try { - $database->findOne('documents', [ - Query::contains('string', []), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - $this->assertEquals('Invalid query: Contains queries require at least one value.', $e->getMessage()); - } - } - - public function testDateTimeDocument(): void - { /** - * @var Database $database + * ORDER BY - Multiple Attribute After */ - $database = $this->getDatabase(); - $collection = 'create_modify_dates'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false)); - $this->assertEquals(true, $database->createAttribute($collection, 'datetime', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime'])); - - $date = '2000-01-01T10:00:00.000+00:00'; - // test - default behaviour of external datetime attribute not changed - $doc = $database->createDocument($collection, new Document([ - '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any()),Permission::write(Role::any()),Permission::update(Role::any())], - 'datetime' => '' - ])); - $this->assertNotEmpty($doc->getAttribute('datetime')); - $this->assertNotEmpty($doc->getAttribute('$createdAt')); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year') + ]); - $doc = $database->getDocument($collection, 'doc1'); - $this->assertNotEmpty($doc->getAttribute('datetime')); - $this->assertNotEmpty($doc->getAttribute('$createdAt')); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - - $database->setPreserveDates(true); - // test - modifying $createdAt and $updatedAt - $doc = $database->createDocument($collection, new Document([ - '$id' => 'doc2', - '$permissions' => [Permission::read(Role::any()),Permission::write(Role::any()),Permission::update(Role::any())], - '$createdAt' => $date - ])); - - $this->assertEquals($doc->getAttribute('$createdAt'), $date); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorAfter($movies[1]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[2]['name'], $documents[0]['name']); + $this->assertEquals($movies[3]['name'], $documents[1]['name']); - $doc = $database->getDocument($collection, 'doc2'); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorAfter($movies[3]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[4]['name'], $documents[0]['name']); + $this->assertEquals($movies[5]['name'], $documents[1]['name']); - $this->assertEquals($doc->getAttribute('$createdAt'), $date); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorAfter($movies[4]) + ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[5]['name'], $documents[0]['name']); - $database->setPreserveDates(false); - $database->deleteCollection($collection); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorAfter($movies[5]) + ]); + $this->assertEmpty(count($documents)); } - public function testDateTimeArrayDocument(): void + public function testFindOrderByMultipleAttributeBefore(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'datetime_array_doc'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'dates', Database::VAR_DATETIME, 0, false, null, false, true, null, [], ['datetime'])); + /** + * ORDER BY - Multiple Attribute Before + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year') + ]); - $d1 = '2000-01-01T10:00:00.000+00:00'; - $d2 = '2001-02-03T05:06:07.000+00:00'; + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[5]) + ]); - $database->createDocument($collection, new Document([ - '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'dates' => [$d1, $d2], - ])); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[3]['name'], $documents[0]['name']); + $this->assertEquals($movies[4]['name'], $documents[1]['name']); - // Regression: reading a datetime (object) array used to throw in Mongo with - // "Cannot use object of type MongoDB\BSON\UTCDateTime as array" because the - // Document constructor tried to array-access non-array elements. - $doc = $database->getDocument($collection, 'doc1'); - $dates = $doc->getAttribute('dates'); - $this->assertIsArray($dates); - $this->assertCount(2, $dates); - $this->assertEquals($d1, $dates[0]); - $this->assertEquals($d2, $dates[1]); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[4]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[2]['name'], $documents[0]['name']); + $this->assertEquals($movies[3]['name'], $documents[1]['name']); - // Same values must round-trip through find() - $found = $database->find($collection, [Query::equal('$id', ['doc1'])]); - $this->assertCount(1, $found); - $this->assertEquals([$d1, $d2], $found[0]->getAttribute('dates')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[2]) + ]); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertEquals($movies[1]['name'], $documents[1]['name']); - $database->deleteCollection($collection); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[1]) + ]); + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[0]) + ]); + $this->assertEmpty(count($documents)); } - public function testInvalidCreatedAndUpdatedAtThrowStructureException(): void + public function testFindOrderByAndCursor(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + /** + * ORDER BY + CURSOR + */ + $documentsTest = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + ]); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(1), + Query::offset(0), + Query::orderDesc('price'), + Query::cursorAfter($documentsTest[0]) + ]); - $collection = 'invalid_date_attributes'; + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); + } - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false)); + public function testFindOrderByIdAndCursor(): void + { + $this->initMoviesFixture(); - $database->setPreserveDates(true); + /** @var Database $database */ + $database = $this->getDatabase(); - try { - // Outside allowed year range (Structure uses DatetimeValidator min/max, e.g. 0000–9999). - $invalidDate = '10000-01-01T00:00:00.000+00:00'; + /** + * ORDER BY ID + CURSOR + */ + $documentsTest = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('$id'), + ]); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(1), + Query::offset(0), + Query::orderDesc('$id'), + Query::cursorAfter($documentsTest[0]) + ]); - try { - $database->createDocument($collection, new Document([ - '$id' => 'doc1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - '$createdAt' => $invalidDate, - ])); - $this->fail('Expected StructureException for invalid $createdAt'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); + } - $database->createDocument($collection, new Document([ - '$id' => 'doc2', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'string' => 'x', - ])); + public function testFindOrderByCreateDateAndCursor(): void + { + $this->initMoviesFixture(); - try { - $database->updateDocument($collection, 'doc2', new Document([ - '$updatedAt' => $invalidDate, - ])); - $this->fail('Expected StructureException for invalid $updatedAt'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - } finally { - $database->setPreserveDates(false); - $database->deleteCollection($collection); - } + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * ORDER BY CREATE DATE + CURSOR + */ + $documentsTest = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('$createdAt'), + ]); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(1), + Query::offset(0), + Query::orderDesc('$createdAt'), + Query::cursorAfter($documentsTest[0]) + ]); + + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); } - public function testSingleDocumentDateOperations(): void + public function testFindOrderByUpdateDateAndCursor(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'normal_date_operations'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false)); - $database->setPreserveDates(true); + /** + * ORDER BY UPDATE DATE + CURSOR + */ + $documentsTest = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('$updatedAt'), + ]); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(1), + Query::offset(0), + Query::orderDesc('$updatedAt'), + Query::cursorAfter($documentsTest[0]) + ]); - $createDate = '2000-01-01T10:00:00.000+00:00'; - $updateDate = '2000-02-01T15:30:00.000+00:00'; - $date1 = '2000-01-01T10:00:00.000+00:00'; - $date2 = '2000-02-01T15:30:00.000+00:00'; - $date3 = '2000-03-01T20:45:00.000+00:00'; - // Test 1: Create with custom createdAt, then update with custom updatedAt - $doc = $database->createDocument($collection, new Document([ - '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'initial', - '$createdAt' => $createDate - ])); + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); + } - $this->assertEquals($createDate, $doc->getAttribute('$createdAt')); - $this->assertNotEquals($createDate, $doc->getAttribute('$updatedAt')); + public function testFindCreatedBefore(): void + { + $this->initMoviesFixture(); - // Update with custom updatedAt - $doc->setAttribute('string', 'updated'); - $doc->setAttribute('$updatedAt', $updateDate); - $updatedDoc = $database->updateDocument($collection, 'doc1', $doc); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals($createDate, $updatedDoc->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $updatedDoc->getAttribute('$updatedAt')); + /** + * Test Query::createdBefore wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; - // Test 2: Create with both custom dates - $doc2 = $database->createDocument($collection, new Document([ - '$id' => 'doc2', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'both_dates', - '$createdAt' => $createDate, - '$updatedAt' => $updateDate - ])); + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBefore($futureDate), + Query::limit(1) + ]); - $this->assertEquals($createDate, $doc2->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $doc2->getAttribute('$updatedAt')); + $this->assertGreaterThan(0, count($documents)); - // Test 3: Create without dates, then update with custom dates - $doc3 = $database->createDocument($collection, new Document([ - '$id' => 'doc3', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'no_dates' - ])); + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBefore($pastDate), + Query::limit(1) + ]); + $this->assertEquals(0, count($documents)); + } - $doc3->setAttribute('string', 'updated_no_dates'); - $doc3->setAttribute('$createdAt', $createDate); - $doc3->setAttribute('$updatedAt', $updateDate); - $updatedDoc3 = $database->updateDocument($collection, 'doc3', $doc3); + public function testFindCreatedAfter(): void + { + $this->initMoviesFixture(); - $this->assertEquals($createDate, $updatedDoc3->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $updatedDoc3->getAttribute('$updatedAt')); + /** @var Database $database */ + $database = $this->getDatabase(); - // Test 4: Update only createdAt - $doc4 = $database->createDocument($collection, new Document([ - '$id' => 'doc4', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'initial' - ])); + /** + * Test Query::createdAfter wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; - $originalCreatedAt4 = $doc4->getAttribute('$createdAt'); - $originalUpdatedAt4 = $doc4->getAttribute('$updatedAt'); + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdAfter($pastDate), + Query::limit(1) + ]); - sleep(1); // Ensure $updatedAt differs when adapter timestamp precision is seconds + $this->assertGreaterThan(0, count($documents)); - $doc4->setAttribute('$updatedAt', null); - $doc4->setAttribute('$createdAt', null); - $updatedDoc4 = $database->updateDocument($collection, 'doc4', document: $doc4); + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdAfter($futureDate), + Query::limit(1) + ]); - $this->assertEquals($originalCreatedAt4, $updatedDoc4->getAttribute('$createdAt')); - $this->assertNotEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt')); + $this->assertEquals(0, count($documents)); + } - // Test 5: Update only updatedAt - $updatedDoc4->setAttribute('$updatedAt', $updateDate); - $updatedDoc4->setAttribute('$createdAt', $createDate); - $finalDoc4 = $database->updateDocument($collection, 'doc4', $updatedDoc4); + public function testFindUpdatedBefore(): void + { + $this->initMoviesFixture(); - $this->assertEquals($createDate, $finalDoc4->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $finalDoc4->getAttribute('$updatedAt')); + /** @var Database $database */ + $database = $this->getDatabase(); - // Test 6: Create with updatedAt, update with createdAt - $doc5 = $database->createDocument($collection, new Document([ - '$id' => 'doc5', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'doc5', - '$updatedAt' => $date2 - ])); + /** + * Test Query::updatedBefore wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; - $this->assertNotEquals($date2, $doc5->getAttribute('$createdAt')); - $this->assertEquals($date2, $doc5->getAttribute('$updatedAt')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBefore($futureDate), + Query::limit(1) + ]); - $doc5->setAttribute('string', 'doc5_updated'); - $doc5->setAttribute('$createdAt', $date1); - $updatedDoc5 = $database->updateDocument($collection, 'doc5', $doc5); + $this->assertGreaterThan(0, count($documents)); - $this->assertEquals($date1, $updatedDoc5->getAttribute('$createdAt')); - $this->assertEquals($date2, $updatedDoc5->getAttribute('$updatedAt')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBefore($pastDate), + Query::limit(1) + ]); - // Test 7: Create with both dates, update with different dates - $doc6 = $database->createDocument($collection, new Document([ - '$id' => 'doc6', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'doc6', - '$createdAt' => $date1, - '$updatedAt' => $date2 - ])); + $this->assertEquals(0, count($documents)); + } - $this->assertEquals($date1, $doc6->getAttribute('$createdAt')); - $this->assertEquals($date2, $doc6->getAttribute('$updatedAt')); + public function testFindUpdatedAfter(): void + { + $this->initMoviesFixture(); - $doc6->setAttribute('string', 'doc6_updated'); - $doc6->setAttribute('$createdAt', $date3); - $doc6->setAttribute('$updatedAt', $date3); - $updatedDoc6 = $database->updateDocument($collection, 'doc6', $doc6); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals($date3, $updatedDoc6->getAttribute('$createdAt')); - $this->assertEquals($date3, $updatedDoc6->getAttribute('$updatedAt')); + /** + * Test Query::updatedAfter wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; - // Test 8: Preserve dates disabled - $database->setPreserveDates(false); + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedAfter($pastDate), + Query::limit(1) + ]); - $customDate = '2000-01-01T10:00:00.000+00:00'; + $this->assertGreaterThan(0, count($documents)); - $doc7 = $database->createDocument($collection, new Document([ - '$id' => 'doc7', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'doc7', - '$createdAt' => $customDate, - '$updatedAt' => $customDate - ])); + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedAfter($futureDate), + Query::limit(1) + ]); - $this->assertNotEquals($customDate, $doc7->getAttribute('$createdAt')); - $this->assertNotEquals($customDate, $doc7->getAttribute('$updatedAt')); + $this->assertEquals(0, count($documents)); + } - // Update with custom dates should also be ignored - $doc7->setAttribute('string', 'updated'); - $doc7->setAttribute('$createdAt', $customDate); - $doc7->setAttribute('$updatedAt', $customDate); - $updatedDoc7 = $database->updateDocument($collection, 'doc7', $doc7); + public function testFindCreatedBetween(): void + { + $this->initMoviesFixture(); - $this->assertNotEquals($customDate, $updatedDoc7->getAttribute('$createdAt')); - $this->assertNotEquals($customDate, $updatedDoc7->getAttribute('$updatedAt')); + /** @var Database $database */ + $database = $this->getDatabase(); - // Test checking updatedAt updates even old document exists - $database->setPreserveDates(true); - $doc11 = $database->createDocument($collection, new Document([ - '$id' => 'doc11', - '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], - 'string' => 'no_dates', - '$createdAt' => $customDate - ])); + /** + * Test Query::createdBetween wrapper + */ + $pastDate = '1900-01-01T00:00:00.000Z'; + $futureDate = '2050-01-01T00:00:00.000Z'; + $recentPastDate = '2020-01-01T00:00:00.000Z'; + $nearFutureDate = '2025-01-01T00:00:00.000Z'; - $newUpdatedAt = $doc11->getUpdatedAt(); + // All documents should be between past and future + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($pastDate, $futureDate), + Query::limit(25) + ]); - \usleep(2000); // Ensure $updatedAt differs when adapter timestamp precision is milliseconds + $this->assertGreaterThan(0, count($documents)); - $newDoc11 = new Document([ - 'string' => 'no_dates_update', + // No documents should exist in this range + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($pastDate, $pastDate), + Query::limit(25) ]); - $updatedDoc7 = $database->updateDocument($collection, 'doc11', $newDoc11); - $this->assertNotEquals($newUpdatedAt, $updatedDoc7->getAttribute('$updatedAt')); - $database->setPreserveDates(false); - $database->deleteCollection($collection); + $this->assertEquals(0, count($documents)); + + // Documents created between recent past and near future + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($recentPastDate, $nearFutureDate), + Query::limit(25) + ]); + + $count = count($documents); + + // Same count should be returned with expanded range + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($pastDate, $nearFutureDate), + Query::limit(25) + ]); + + $this->assertGreaterThanOrEqual($count, count($documents)); } - public function testBulkDocumentDateOperations(): void + public function testFindUpdatedBetween(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'bulk_date_operations'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false)); - $database->setPreserveDates(true); + /** + * Test Query::updatedBetween wrapper + */ + $pastDate = '1900-01-01T00:00:00.000Z'; + $futureDate = '2050-01-01T00:00:00.000Z'; + $recentPastDate = '2020-01-01T00:00:00.000Z'; + $nearFutureDate = '2025-01-01T00:00:00.000Z'; - $createDate = '2000-01-01T10:00:00.000+00:00'; - $updateDate = '2000-02-01T15:30:00.000+00:00'; - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + // All documents should be between past and future + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($pastDate, $futureDate), + Query::limit(25) + ]); - // Test 1: Bulk create with different date configurations - $documents = [ - new Document([ - '$id' => 'doc1', - '$permissions' => $permissions, - 'string' => 'doc1', - '$createdAt' => $createDate - ]), - new Document([ - '$id' => 'doc2', - '$permissions' => $permissions, - 'string' => 'doc2', - '$updatedAt' => $updateDate - ]), - new Document([ - '$id' => 'doc3', - '$permissions' => $permissions, - 'string' => 'doc3', - '$createdAt' => $createDate, - '$updatedAt' => $updateDate - ]), - new Document([ - '$id' => 'doc4', - '$permissions' => $permissions, - 'string' => 'doc4' - ]), - new Document([ - '$id' => 'doc5', - '$permissions' => $permissions, - 'string' => 'doc5', - '$createdAt' => null - ]), - new Document([ - '$id' => 'doc6', - '$permissions' => $permissions, - 'string' => 'doc6', - '$updatedAt' => null - ]) - ]; + $this->assertGreaterThan(0, count($documents)); - $database->createDocuments($collection, $documents); + // No documents should exist in this range + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($pastDate, $pastDate), + Query::limit(25) + ]); - // Verify initial state - foreach (['doc1', 'doc3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - } + $this->assertEquals(0, count($documents)); - foreach (['doc2', 'doc3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); - } + // Documents updated between recent past and near future + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($recentPastDate, $nearFutureDate), + Query::limit(25) + ]); - foreach (['doc4', 'doc5', 'doc6'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt missing for $id"); - $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt missing for $id"); - } + $count = count($documents); - // Test 2: Bulk update with custom dates - $updateDoc = new Document([ - 'string' => 'updated', - '$createdAt' => $createDate, - '$updatedAt' => $updateDate - ]); - $ids = []; - foreach ($documents as $doc) { - $ids[] = $doc->getId(); - } - $count = $database->updateDocuments($collection, $updateDoc, [ - Query::equal('$id', $ids) + // Same count should be returned with expanded range + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($pastDate, $nearFutureDate), + Query::limit(25) ]); - $this->assertEquals(6, $count); - foreach (['doc1', 'doc3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); - $this->assertEquals('updated', $doc->getAttribute('string'), "string mismatch for $id"); - } + $this->assertGreaterThanOrEqual($count, count($documents)); + } - foreach (['doc2', 'doc4','doc5','doc6'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); - $this->assertEquals('updated', $doc->getAttribute('string'), "string mismatch for $id"); - } + public function testFindLimit(): void + { + $this->initMoviesFixture(); - // Test 3: Bulk update with preserve dates disabled - $database->setPreserveDates(false); + /** @var Database $database */ + $database = $this->getDatabase(); - $customDate = 'should be ignored anyways so no error'; - $updateDocDisabled = new Document([ - 'string' => 'disabled_update', - '$createdAt' => $customDate, - '$updatedAt' => $customDate + /** + * Limit + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(4), + Query::offset(0), + Query::orderAsc('name') ]); - $countDisabled = $database->updateDocuments($collection, $updateDocDisabled); - $this->assertEquals(6, $countDisabled); + $this->assertEquals(4, count($documents)); + $this->assertEquals('Captain America: The First Avenger', $documents[0]['name']); + $this->assertEquals('Captain Marvel', $documents[1]['name']); + $this->assertEquals('Frozen', $documents[2]['name']); + $this->assertEquals('Frozen II', $documents[3]['name']); + } - // Test 4: Bulk update with preserve dates re-enabled - $database->setPreserveDates(true); + public function testFindLimitAndOffset(): void + { + $this->initMoviesFixture(); - $newDate = '2000-03-01T20:45:00.000+00:00'; - $updateDocEnabled = new Document([ - 'string' => 'enabled_update', - '$createdAt' => $newDate, - '$updatedAt' => $newDate - ]); + /** @var Database $database */ + $database = $this->getDatabase(); - $countEnabled = $database->updateDocuments($collection, $updateDocEnabled); - $this->assertEquals(6, $countEnabled); + /** + * Limit + Offset + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(4), + Query::offset(2), + Query::orderAsc('name') + ]); - $database->setPreserveDates(false); - $database->deleteCollection($collection); + $this->assertEquals(4, count($documents)); + $this->assertEquals('Frozen', $documents[0]['name']); + $this->assertEquals('Frozen II', $documents[1]['name']); + $this->assertEquals('Work in Progress', $documents[2]['name']); + $this->assertEquals('Work in Progress 2', $documents[3]['name']); } - public function testUpsertDateOperations(): void + public function testFindOrQueries(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { - $this->expectNotToPerformAssertions(); - return; - } + /** + * Test that OR queries are handled correctly + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('director', ['TBD', 'Joe Johnston']), + Query::equal('year', [2025]), + ]); + $this->assertEquals(1, count($documents)); + } + public function testFindEdgeCases(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $collection = 'upsert_date_operations'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false)); + $collection = 'edgeCases'; - $database->setPreserveDates(true); + $database->createCollection(Collection::create(id: $collection)); - $createDate = '2000-01-01T10:00:00.000+00:00'; - $updateDate = '2000-02-01T15:30:00.000+00:00'; - $date1 = '2000-01-01T10:00:00.000+00:00'; - $date2 = '2000-02-01T15:30:00.000+00:00'; - $date3 = '2000-03-01T20:45:00.000+00:00'; - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + $this->assertSame('value', $database->createAttribute($collection, Attribute::string(key: 'value', size: 256, required: true))->key); - // Test 1: Upsert new document with custom createdAt - $upsertResults = []; - $database->upsertDocuments($collection, [ - new Document([ - '$id' => 'upsert1', - '$permissions' => $permissions, - 'string' => 'upsert1_initial', - '$createdAt' => $createDate - ]) - ], onNext: function ($doc) use (&$upsertResults) { - $upsertResults[] = $doc; - }); - $upsertDoc1 = $upsertResults[0]; + $values = [ + 'NormalString', + '{"type":"json","somekey":"someval"}', + '{NormalStringInBraces}', + '"NormalStringInDoubleQuotes"', + '{"NormalStringInDoubleQuotesAndBraces"}', + "'NormalStringInSingleQuotes'", + "{'NormalStringInSingleQuotesAndBraces'}", + "SingleQuote'InMiddle", + 'DoubleQuote"InMiddle', + 'Slash/InMiddle', + 'Backslash\InMiddle', + 'Colon:InMiddle', + '"quoted":"colon"' + ]; - $this->assertEquals($createDate, $upsertDoc1->getAttribute('$createdAt')); - $this->assertNotEquals($createDate, $upsertDoc1->getAttribute('$updatedAt')); + foreach ($values as $value) { + $database->createDocument($collection, new Document([ + '$id' => Id::unique(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ], + 'value' => $value + ])); + } - // Test 2: Upsert existing document with custom updatedAt - $upsertDoc1->setAttribute('string', 'upsert1_updated'); - $upsertDoc1->setAttribute('$updatedAt', $updateDate); - $updatedUpsertResults = []; - $database->upsertDocuments($collection, [$upsertDoc1], onNext: function ($doc) use (&$updatedUpsertResults) { - $updatedUpsertResults[] = $doc; - }); - $updatedUpsertDoc1 = $updatedUpsertResults[0]; + /** + * Check Basic + */ + $documents = $database->find($collection); - $this->assertEquals($createDate, $updatedUpsertDoc1->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $updatedUpsertDoc1->getAttribute('$updatedAt')); + $this->assertEquals(count($values), count($documents)); + $this->assertNotEmpty($documents[0]->getId()); + $this->assertEquals($collection, $documents[0]->getCollection()); + $this->assertEquals(['any'], $documents[0]->getPermissionsByType(PermissionType::Read)); + $this->assertEquals(['any'], $documents[0]->getPermissionsByType(PermissionType::Update)); + $this->assertEquals(['any'], $documents[0]->getPermissionsByType(PermissionType::Delete)); + $this->assertEquals($values[0], $documents[0]->getAttribute('value')); - // Test 3: Upsert new document with both custom dates - $upsertResults2 = []; - $database->upsertDocuments($collection, [ - new Document([ - '$id' => 'upsert2', - '$permissions' => $permissions, - 'string' => 'upsert2_both_dates', - '$createdAt' => $createDate, - '$updatedAt' => $updateDate - ]) - ], onNext: function ($doc) use (&$upsertResults2) { - $upsertResults2[] = $doc; - }); - $upsertDoc2 = $upsertResults2[0]; + /** + * Check `equals` query + */ + foreach ($values as $value) { + $documents = $database->find($collection, [ + Query::limit(25), + Query::equal('value', [$value]) + ]); - $this->assertEquals($createDate, $upsertDoc2->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $upsertDoc2->getAttribute('$updatedAt')); + $this->assertEquals(1, count($documents)); + $this->assertEquals($value, $documents[0]->getAttribute('value')); + } + } - // Test 4: Upsert existing document with different dates - $upsertDoc2->setAttribute('string', 'upsert2_updated'); - $upsertDoc2->setAttribute('$createdAt', $date3); - $upsertDoc2->setAttribute('$updatedAt', $date3); - $updatedUpsertResults2 = []; - $database->upsertDocuments($collection, [$upsertDoc2], onNext: function ($doc) use (&$updatedUpsertResults2) { - $updatedUpsertResults2[] = $doc; - }); - $updatedUpsertDoc2 = $updatedUpsertResults2[0]; + public function testNestedIDQueries(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$createdAt')); - $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$updatedAt')); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - // Test 5: Upsert with preserve dates disabled - $database->setPreserveDates(false); + $database->createCollection(Collection::create(id: 'movies_nested_id', permissions: [ + Permission::create(Role::any()), + Permission::update(Role::users()) + ])); - $customDate = '2000-01-01T10:00:00.000+00:00'; - $upsertResults3 = []; - $database->upsertDocuments($collection, [ - new Document([ - '$id' => 'upsert3', - '$permissions' => $permissions, - 'string' => 'upsert3_disabled', - '$createdAt' => $customDate, - '$updatedAt' => $customDate - ]) - ], onNext: function ($doc) use (&$upsertResults3) { - $upsertResults3[] = $doc; - }); - $upsertDoc3 = $upsertResults3[0]; + $this->assertSame('name', $database->createAttribute('movies_nested_id', Attribute::string(key: 'name', size: 128, required: true))->key); - $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$createdAt')); - $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$updatedAt')); - - // Update with custom dates should also be ignored - $upsertDoc3->setAttribute('string', 'upsert3_updated'); - $upsertDoc3->setAttribute('$createdAt', $customDate); - $upsertDoc3->setAttribute('$updatedAt', $customDate); - $updatedUpsertResults3 = []; - $database->upsertDocuments($collection, [$upsertDoc3], onNext: function ($doc) use (&$updatedUpsertResults3) { - $updatedUpsertResults3[] = $doc; - }); - $updatedUpsertDoc3 = $updatedUpsertResults3[0]; + $database->createDocument('movies_nested_id', new Document([ + '$id' => Id::custom('1'), + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => '1', + ])); - $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$createdAt')); - $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$updatedAt')); + $database->createDocument('movies_nested_id', new Document([ + '$id' => Id::custom('2'), + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => '2', + ])); - // Test 6: Bulk upsert operations with custom dates - $database->setPreserveDates(true); + $database->createDocument('movies_nested_id', new Document([ + '$id' => Id::custom('3'), + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => '3', + ])); - // Test 7: Bulk upsert with different date configurations - $upsertDocuments = [ - new Document([ - '$id' => 'bulk_upsert1', - '$permissions' => $permissions, - 'string' => 'bulk_upsert1_initial', - '$createdAt' => $createDate - ]), - new Document([ - '$id' => 'bulk_upsert2', - '$permissions' => $permissions, - 'string' => 'bulk_upsert2_initial', - '$updatedAt' => $updateDate - ]), - new Document([ - '$id' => 'bulk_upsert3', - '$permissions' => $permissions, - 'string' => 'bulk_upsert3_initial', - '$createdAt' => $createDate, - '$updatedAt' => $updateDate - ]), - new Document([ - '$id' => 'bulk_upsert4', - '$permissions' => $permissions, - 'string' => 'bulk_upsert4_initial' + $queries = [ + Query::or([ + Query::equal('$id', ["1"]), + Query::equal('$id', ["2"]) ]) ]; - $bulkUpsertResults = []; - $database->upsertDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { - $bulkUpsertResults[] = $doc; - }); - - // Test 8: Verify initial bulk upsert state - foreach (['bulk_upsert1', 'bulk_upsert3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - } + $documents = $database->find('movies_nested_id', $queries); + $this->assertCount(2, $documents); - foreach (['bulk_upsert2', 'bulk_upsert3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); - } + // Make sure the query was not modified by reference + $nested = $queries[0]->getValues()[0]; + $this->assertInstanceOf(Query::class, $nested); + $this->assertEquals($nested->getAttribute(), '$id'); - foreach (['bulk_upsert4'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt missing for $id"); - $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt missing for $id"); - } + $count = $database->count('movies_nested_id', $queries); + $this->assertEquals(2, $count); + } - // Test 9: Bulk upsert update with custom dates using updateDocuments - $newDate = '2000-04-01T12:00:00.000+00:00'; - $updateUpsertDoc = new Document([ - 'string' => 'bulk_upsert_updated', - '$createdAt' => $newDate, - '$updatedAt' => $newDate - ]); + public function testFindNotBetween(): void + { + $this->initMoviesFixture(); - $upsertIds = []; - foreach ($upsertDocuments as $doc) { - $upsertIds[] = $doc->getId(); - } + /** @var Database $database */ + $database = $this->getDatabase(); - $database->updateDocuments($collection, $updateUpsertDoc, [ - Query::equal('$id', $upsertIds) + // Test notBetween with price range - should return documents outside the range + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('price', 25.94, 25.99), ]); + $this->assertEquals(4, count($documents)); // All movies except the 2 in the price range - foreach ($upsertIds as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); - $this->assertEquals('bulk_upsert_updated', $doc->getAttribute('string'), "string mismatch for $id"); - } - - // Test 10: checking by passing null to each - $updateUpsertDoc = new Document([ - 'string' => 'bulk_upsert_updated', - '$createdAt' => null, - '$updatedAt' => null + // Test notBetween with range that includes no documents - should return all documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('price', 30, 35), ]); + $this->assertEquals(6, count($documents)); - $upsertIds = []; - foreach ($upsertDocuments as $doc) { - $upsertIds[] = $doc->getId(); - } - - $database->updateDocuments($collection, $updateUpsertDoc, [ - Query::equal('$id', $upsertIds) + // Test notBetween with date range + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('$createdAt', '1975-12-06', '2050-12-06'), ]); + $this->assertEquals(0, count($documents)); // No movies outside this wide date range - foreach ($upsertIds as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); - } - - // Test 11: Bulk upsert operations with upsertDocuments - $upsertUpdateDocuments = []; - foreach ($upsertDocuments as $doc) { - $updatedDoc = clone $doc; - $updatedDoc->setAttribute('string', 'bulk_upsert_updated_via_upsert'); - $updatedDoc->setAttribute('$createdAt', $newDate); - $updatedDoc->setAttribute('$updatedAt', $newDate); - $upsertUpdateDocuments[] = $updatedDoc; - } + // Test notBetween with narrower date range + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), + ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - $upsertUpdateResults = []; - $countUpsertUpdate = $database->upsertDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { - $upsertUpdateResults[] = $doc; - }); - $this->assertEquals(4, $countUpsertUpdate); + // Test notBetween with updated date range + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('$updatedAt', '2000-01-01T00:00:00.000+00:00', '2001-01-01T00:00:00.000+00:00'), + ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - foreach ($upsertUpdateResults as $doc) { - $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for upsert update"); - $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for upsert update"); - $this->assertEquals('bulk_upsert_updated_via_upsert', $doc->getAttribute('string'), "string mismatch for upsert update"); - } + // Test notBetween with year range (integer values) + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('year', 2005, 2007), + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside 2005-2007 range - // Test 12: Bulk upsert with preserve dates disabled - $database->setPreserveDates(false); + // Test notBetween with reversed range (start > end) - should still work + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('price', 25.99, 25.94), // Note: reversed order + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Should handle reversed range gracefully - $customDate = 'should be ignored anyways so no error'; - $upsertDisabledDocuments = []; - foreach ($upsertDocuments as $doc) { - $disabledDoc = clone $doc; - $disabledDoc->setAttribute('string', 'bulk_upsert_disabled'); - $disabledDoc->setAttribute('$createdAt', $customDate); - $disabledDoc->setAttribute('$updatedAt', $customDate); - $upsertDisabledDocuments[] = $disabledDoc; - } + // Test notBetween with same start and end values + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('year', 2006, 2006), + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // All movies except those from exactly 2006 - $upsertDisabledResults = []; - $countUpsertDisabled = $database->upsertDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { - $upsertDisabledResults[] = $doc; - }); - $this->assertEquals(4, $countUpsertDisabled); + // Test notBetween combined with other filters + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('price', 25.94, 25.99), + Query::orderDesc('year'), + Query::limit(2) + ]); + $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range - foreach ($upsertDisabledResults as $doc) { - $this->assertNotEquals($customDate, $doc->getAttribute('$createdAt'), "createdAt should not be custom date when disabled"); - $this->assertNotEquals($customDate, $doc->getAttribute('$updatedAt'), "updatedAt should not be custom date when disabled"); - $this->assertEquals('bulk_upsert_disabled', $doc->getAttribute('string'), "string mismatch for disabled upsert"); - } + // Test notBetween with extreme ranges + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('year', -1000, 1000), // Very wide range + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside this range - $database->setPreserveDates(false); - $database->deleteCollection($collection); + // Test notBetween with float precision + $documents = $database->find($this->getMoviesCollection(), [ + Query::notBetween('price', 25.945, 25.955), // Very narrow range + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range } - public function testUpdateDocumentsCount(): void + public function testFindSelect(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collectionName = "update_count"; - $database->createCollection($collectionName); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year']) + ]); - $database->createAttribute($collectionName, 'key', Database::VAR_STRING, 60, false); - $database->createAttribute($collectionName, 'value', Database::VAR_STRING, 60, false); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + } - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$id']) + ]); - $docs = [ - new Document([ - '$id' => 'bulk_upsert1', - '$permissions' => $permissions, - 'key' => 'bulk_upsert1_initial', - ]), - new Document([ - '$id' => 'bulk_upsert2', - '$permissions' => $permissions, - 'key' => 'bulk_upsert2_initial', - ]), - new Document([ - '$id' => 'bulk_upsert3', - '$permissions' => $permissions, - 'key' => 'bulk_upsert3_initial', - ]), - new Document([ - '$id' => 'bulk_upsert4', - '$permissions' => $permissions, - 'key' => 'bulk_upsert4_initial' - ]) - ]; - $upsertUpdateResults = []; - $count = $database->upsertDocuments($collectionName, $docs, onNext: function ($doc) use (&$upsertUpdateResults) { - $upsertUpdateResults[] = $doc; - }); - $this->assertCount(4, $upsertUpdateResults); - $this->assertEquals(4, $count); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + } - $updates = new Document(['value' => 'test']); - $newDocs = []; - $count = $database->updateDocuments($collectionName, $updates, onNext:function ($doc) use (&$newDocs) { - $newDocs[] = $doc; - }); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$sequence']) + ]); - $this->assertCount(4, $newDocs); - $this->assertEquals(4, $count); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + } - $database->deleteCollection($collectionName); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$collection']) + ]); - public function testCreateUpdateDocumentsMismatch(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + } - // with different set of attributes - $colName = "docs_with_diff"; - $database->createCollection($colName); - $database->createAttribute($colName, 'key', Database::VAR_STRING, 50, true); - $database->createAttribute($colName, 'value', Database::VAR_STRING, 50, false, 'value'); - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; - $docs = [ - new Document([ - '$id' => 'doc1', - 'key' => 'doc1', - ]), - new Document([ - '$id' => 'doc2', - 'key' => 'doc2', - 'value' => 'test', - ]), - new Document([ - '$id' => 'doc3', - '$permissions' => $permissions, - 'key' => 'doc3' - ]), - ]; - $this->assertEquals(3, $database->createDocuments($colName, $docs)); - // we should get only one document as read permission provided to the last document only - $addedDocs = $database->find($colName); - $this->assertCount(1, $addedDocs); - $doc = $addedDocs[0]; - $this->assertEquals('doc3', $doc->getId()); - $this->assertNotEmpty($doc->getPermissions()); - $this->assertCount(3, $doc->getPermissions()); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$createdAt']) + ]); - $database->createDocument($colName, new Document([ - '$id' => 'doc4', - '$permissions' => $permissions, - 'key' => 'doc4' - ])); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + } - $this->assertEquals(2, $database->updateDocuments($colName, new Document(['key' => 'new doc']))); - $doc = $database->getDocument($colName, 'doc4'); - $this->assertEquals('doc4', $doc->getId()); - $this->assertEquals('value', $doc->getAttribute('value')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$updatedAt']) + ]); - $addedDocs = $database->find($colName); - $this->assertCount(2, $addedDocs); - foreach ($addedDocs as $doc) { - $this->assertNotEmpty($doc->getPermissions()); - $this->assertCount(3, $doc->getPermissions()); - $this->assertEquals('value', $doc->getAttribute('value')); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + } + + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$permissions']) + ]); + + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); } - $database->deleteCollection($colName); } - public function testBypassStructureWithSupportForAttributes(): void + public function testForeach(): void { + $this->initMoviesFixture(); + /** @var Database $database */ - $database = static::getDatabase(); - // for schemaless the validation will be automatically skipped - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; + $database = $this->getDatabase(); + + /** + * Test, foreach generator on empty collection + */ + $database->createCollection(Collection::create(id: 'moviesEmpty')); + $documents = []; + foreach ($database->cursor('moviesEmpty', batchSize: 2) as $document) { + $documents[] = $document; } + $this->assertEquals(0, \count($documents)); + $database->deleteCollection('moviesEmpty'); + $this->assertNull($database->findCollection('moviesEmpty')); - $collectionId = 'successive_update_single'; + /** + * Test, foreach generator + */ + $documents = []; + foreach ($database->cursor($this->getMoviesCollection(), batchSize: 2) as $document) { + $documents[] = $document; + } + $this->assertEquals(6, count($documents)); + + /** + * Test, foreach goes through all the documents + */ + $documents = []; + foreach ($database->cursor($this->getMoviesCollection(), batchSize: 2) as $document) { + $documents[] = $document; + } + $this->assertEquals(6, count($documents)); - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'attrA', Database::VAR_STRING, 50, true); - $database->createAttribute($collectionId, 'attrB', Database::VAR_STRING, 50, true); + /** + * Test, foreach with initial cursor + */ - // bypass required - $database->disableValidation(); + $first = $documents[0]; + $documents = []; + foreach ($database->cursor($this->getMoviesCollection(), [Query::cursorAfter($first)], batchSize: 2) as $document) { + $documents[] = $document; + } + $this->assertEquals(5, count($documents)); - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; - $docs = $database->createDocuments($collectionId, [ - new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) - ]); + /** + * Test, foreach with initial offset + */ - $docs = $database->find($collectionId); - foreach ($docs as $doc) { - $this->assertArrayHasKey('attrA', $doc->getAttributes()); - $this->assertNull($doc->getAttribute('attrA')); - $this->assertEquals('B', $doc->getAttribute('attrB')); + $documents = []; + foreach ($database->cursor($this->getMoviesCollection(), [Query::offset(2)], batchSize: 2) as $document) { + $documents[] = $document; } - // reset - $database->enableValidation(); + $this->assertEquals(4, count($documents)); + /** + * Test, cursor before throws error + */ try { - $database->createDocuments($collectionId, [ - new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); + $database->cursor($this->getMoviesCollection(), [Query::cursorBefore($documents[0]), Query::offset(2)]); + $this->fail('A cursor before was accepted'); + + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertEquals('Cursor ' . CursorDirection::Before->value . ' not supported in this method.', $e->getMessage()); } - $database->deleteCollection($collectionId); } - - public function testValidationGuardsWithNullRequired(): void + public function testCount(): void { + $this->initMoviesFixture(); + /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $count = $database->count($this->getMoviesCollection()); + $this->assertEquals(6, $count); + $count = $database->count($this->getMoviesCollection(), [Query::equal('year', [2019])]); - // Base collection and attributes - $collection = 'validation_guard_all'; - $database->createCollection($collection, permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], documentSecurity: true); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 32, true); - $database->createAttribute($collection, 'age', Database::VAR_INTEGER, 0, true); - $database->createAttribute($collection, 'value', Database::VAR_INTEGER, 0, false); + $this->assertEquals(2, $count); + $count = $database->count($this->getMoviesCollection(), [Query::equal('with-dash', ['Works'])]); + $this->assertEquals(2, $count); + $count = $database->count($this->getMoviesCollection(), [Query::equal('with-dash', ['Works2', 'Works3'])]); + $this->assertEquals(4, $count); - // 1) createDocument with null required should fail when validation enabled, pass when disabled - try { - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], - 'name' => null, - 'age' => null, - ])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - - $database->disableValidation(); - $doc = $database->createDocument($collection, new Document([ - '$id' => 'created-null', - '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], - 'name' => null, - 'age' => null, - ])); - $this->assertEquals('created-null', $doc->getId()); - $database->enableValidation(); - - // Seed a valid document for updates - $valid = $database->createDocument($collection, new Document([ - '$id' => 'valid', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'name' => 'ok', - 'age' => 10, - ])); - $this->assertEquals('valid', $valid->getId()); - - // 2) updateDocument set required to null should fail when validation enabled, pass when disabled - try { - $database->updateDocument($collection, 'valid', new Document([ - 'age' => null, - ])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - - $database->disableValidation(); - $updated = $database->updateDocument($collection, 'valid', new Document([ - 'age' => null, - ])); - $this->assertNull($updated->getAttribute('age')); - $database->enableValidation(); - - // Seed a few valid docs for bulk update - for ($i = 0; $i < 2; $i++) { - $database->createDocument($collection, new Document([ - '$id' => 'b' . $i, - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'name' => 'ok', - 'age' => 1, - ])); - } - - // 3) updateDocuments setting required to null should fail when validation enabled, pass when disabled - if ($database->getAdapter()->getSupportForBatchOperations()) { - try { - $database->updateDocuments($collection, new Document([ - 'name' => null, - ])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - - $database->disableValidation(); - $count = $database->updateDocuments($collection, new Document([ - 'name' => null, - ])); - $this->assertGreaterThanOrEqual(3, $count); // at least the seeded docs are updated - $database->enableValidation(); - } + $this->getDatabase()->getAuthorization()->removeRole('user:x'); + $count = $database->count($this->getMoviesCollection()); + $this->assertEquals(5, $count); + $this->getDatabase()->getAuthorization()->addRole('user:x'); - // 4) upsertDocumentsWithIncrease with null required should fail when validation enabled, pass when disabled - if ($database->getAdapter()->getSupportForUpserts()) { - try { - $database->upsertDocumentsWithIncrease( - collection: $collection, - attribute: 'value', - documents: [new Document([ - '$id' => 'u1', - 'name' => null, // required null - 'value' => 1, - ])] - ); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } + $this->getDatabase()->getAuthorization()->disable(); + $count = $database->count($this->getMoviesCollection()); + $this->assertEquals(6, $count); + $this->getDatabase()->getAuthorization()->reset(); - $database->disableValidation(); - $ucount = $database->upsertDocumentsWithIncrease( - collection: $collection, - attribute: 'value', - documents: [new Document([ - '$id' => 'u1', - 'name' => null, - 'value' => 1, - ])] - ); - $this->assertEquals(1, $ucount); - $database->enableValidation(); - } + $this->getDatabase()->getAuthorization()->disable(); + $count = $database->count($this->getMoviesCollection(), [], 3); + $this->assertEquals(3, $count); + $this->getDatabase()->getAuthorization()->reset(); - // Cleanup - $database->deleteCollection($collection); + /** + * Test that OR queries are handled correctly + */ + $this->getDatabase()->getAuthorization()->disable(); + $count = $database->count($this->getMoviesCollection(), [ + Query::equal('director', ['TBD', 'Joe Johnston']), + Query::equal('year', [2025]), + ]); + $this->assertEquals(1, $count); + $this->getDatabase()->getAuthorization()->reset(); } - /** - * SQL adapters store columns under filter(attributeId). After getDocument + decode, and after - * updateDocument (return value + refetch), the document must expose only schema ids (e.g. - * pb.e_DSS.FIRMWARE_VERSION), never the filtered alias. - */ - public function testDottedAttributeKeyGetDocumentExposesOnlySchemaKeys(): void + public function testEncodeDecode(): void { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - // Keep id short: MySQL/MariaDB table names are limited to 64 characters (namespace + collection). - $collectionId = 'dotkey_' . ID::unique(); - $attrId = 'pb.e_DSS.FIRMWARE_VERSION'; - $filteredStorageKey = $database->getAdapter()->filter($attrId); - - $database->createCollection($collectionId); - $this->assertTrue($database->createAttribute($collectionId, $attrId, Database::VAR_STRING, 128, false)); + $collection = new Document([ + '$collection' => Id::custom(Database::METADATA), + '$id' => Id::custom('users'), + 'name' => 'Users', + 'attributes' => [ + [ + '$id' => Id::custom('name'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => Id::custom('email'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 1024, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => Id::custom('status'), + 'type' => ColumnType::Integer, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => Id::custom('password'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => Id::custom('passwordUpdate'), + 'type' => ColumnType::Datetime, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => Id::custom('registration'), + 'type' => ColumnType::Datetime, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => Id::custom('emailVerification'), + 'type' => ColumnType::Boolean, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => Id::custom('reset'), + 'type' => ColumnType::Boolean, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => Id::custom('prefs'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => ['json'] + ], + [ + '$id' => Id::custom('sessions'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => Id::custom('tokens'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => Id::custom('memberships'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => Id::custom('roles'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'array' => true, + 'filters' => [], + ], + [ + '$id' => Id::custom('tags'), + 'type' => ColumnType::String, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'array' => true, + 'filters' => ['json'], + ], + ], + 'indexes' => [ + [ + '$id' => Id::custom('_key_email'), + 'type' => IndexType::Unique, + 'attributes' => ['email'], + 'lengths' => [1024], + 'orders' => [OrderDirection::Asc->value], + ] + ], + ]); - // Optional attribute omitted: DB column is NULL — decode must not leave the SQL column name as a key. - $database->createDocument($collectionId, new Document([ - '$id' => 'dev1', + $document = new Document([ + '$id' => Id::custom('608fdbe51361a'), '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()), + Permission::create(Role::user('608fdbe51361a')), + Permission::update(Role::user('608fdbe51361a')), + Permission::delete(Role::user('608fdbe51361a')), ], - ])); - - $doc = $database->getDocument($collectionId, 'dev1'); - $this->assertSame('dev1', $doc->getId()); - $this->assertNull($doc->getAttribute($attrId)); - $this->assertArrayNotHasKey($filteredStorageKey, $doc->getAttributes()); - $userKeys = array_keys($doc->getAttributes()); - sort($userKeys); - $this->assertSame([$attrId], $userKeys); - - $updated = $database->updateDocument($collectionId, 'dev1', new Document([ - $attrId => '1.0.0', - ])); - $this->assertSame('1.0.0', $updated->getAttribute($attrId)); - $this->assertArrayNotHasKey($filteredStorageKey, $updated->getAttributes()); - $userKeys = array_keys($updated->getAttributes()); - sort($userKeys); - $this->assertSame([$attrId], $userKeys); - - $doc = $database->getDocument($collectionId, 'dev1'); - $this->assertSame('1.0.0', $doc->getAttribute($attrId)); - $this->assertArrayNotHasKey($filteredStorageKey, $doc->getAttributes()); - $userKeys = array_keys($doc->getAttributes()); - sort($userKeys); - $this->assertSame([$attrId], $userKeys); + 'email' => 'test@example.com', + 'emailVerification' => false, + 'status' => 1, + 'password' => 'randomhash', + 'passwordUpdate' => '2000-06-12 14:12:55', + 'registration' => '1975-06-12 14:12:55+01:00', + 'reset' => false, + 'name' => 'My Name', + 'prefs' => new \stdClass(), + 'sessions' => [], + 'tokens' => [], + 'memberships' => [], + 'roles' => [ + 'admin', + 'developer', + 'tester', + ], + 'tags' => [ + ['$id' => '1', 'label' => 'x'], + ['$id' => '2', 'label' => 'y'], + ['$id' => '3', 'label' => 'z'], + ], + ]); - $updated = $database->updateDocument($collectionId, 'dev1', new Document([ - $attrId => '2.0.0', - ])); - $this->assertSame('2.0.0', $updated->getAttribute($attrId)); - $this->assertArrayNotHasKey($filteredStorageKey, $updated->getAttributes()); - $userKeys = array_keys($updated->getAttributes()); - sort($userKeys); - $this->assertSame([$attrId], $userKeys); + /** @var Database $database */ + $database = $this->getDatabase(); - $doc = $database->getDocument($collectionId, 'dev1'); - $this->assertSame('2.0.0', $doc->getAttribute($attrId)); - $this->assertArrayNotHasKey($filteredStorageKey, $doc->getAttributes()); - $userKeys = array_keys($doc->getAttributes()); - sort($userKeys); - $this->assertSame([$attrId], $userKeys); + $result = $database->encode($collection, $document); - $database->deleteCollection($collectionId); - } + $this->assertEquals('608fdbe51361a', $result->getAttribute('$id')); + $this->assertContains('read("any")', $result->getPermissions()); + $this->assertContains('read("any")', $result->getPermissions()); + $this->assertContains('any', $result->getPermissionsByType(PermissionType::Read)); + $this->assertContains(Permission::create(Role::user(Id::custom('608fdbe51361a'))), $result->getPermissions()); + $this->assertContains('user:608fdbe51361a', $result->getPermissionsByType(PermissionType::Create)); + $this->assertContains('user:608fdbe51361a', $this->writeRoles($result)); + $this->assertEquals('test@example.com', $result->getAttribute('email')); + $this->assertEquals(false, $result->getAttribute('emailVerification')); + $this->assertEquals(1, $result->getAttribute('status')); + $this->assertEquals('randomhash', $result->getAttribute('password')); + $this->assertEquals('2000-06-12 14:12:55.000', $result->getAttribute('passwordUpdate')); + $this->assertEquals('1975-06-12 13:12:55.000', $result->getAttribute('registration')); + $this->assertEquals(false, $result->getAttribute('reset')); + $this->assertEquals('My Name', $result->getAttribute('name')); + $this->assertEquals('{}', $result->getAttribute('prefs')); + $this->assertEquals('[]', $result->getAttribute('sessions')); + $this->assertEquals('[]', $result->getAttribute('tokens')); + $this->assertEquals('[]', $result->getAttribute('memberships')); + $this->assertEquals(['admin', 'developer', 'tester',], $result->getAttribute('roles')); + $this->assertEquals(['{"$id":"1","label":"x"}', '{"$id":"2","label":"y"}', '{"$id":"3","label":"z"}',], $result->getAttribute('tags')); - public function testUpsertWithJSONFilters(): void + $result = $database->decode($collection, $document); + + $this->assertEquals('608fdbe51361a', $result->getAttribute('$id')); + $this->assertContains('read("any")', $result->getPermissions()); + $this->assertContains('read("any")', $result->getPermissions()); + $this->assertContains('any', $result->getPermissionsByType(PermissionType::Read)); + $this->assertContains(Permission::create(Role::user('608fdbe51361a')), $result->getPermissions()); + $this->assertContains('user:608fdbe51361a', $result->getPermissionsByType(PermissionType::Create)); + $this->assertContains('user:608fdbe51361a', $this->writeRoles($result)); + $this->assertEquals('test@example.com', $result->getAttribute('email')); + $this->assertEquals(false, $result->getAttribute('emailVerification')); + $this->assertEquals(1, $result->getAttribute('status')); + $this->assertEquals('randomhash', $result->getAttribute('password')); + $this->assertEquals('2000-06-12T14:12:55.000+00:00', $result->getAttribute('passwordUpdate')); + $this->assertEquals('1975-06-12T13:12:55.000+00:00', $result->getAttribute('registration')); + $this->assertEquals(false, $result->getAttribute('reset')); + $this->assertEquals('My Name', $result->getAttribute('name')); + $this->assertEquals([], $result->getAttribute('prefs')); + $this->assertEquals([], $result->getAttribute('sessions')); + $this->assertEquals([], $result->getAttribute('tokens')); + $this->assertEquals([], $result->getAttribute('memberships')); + $this->assertEquals(['admin', 'developer', 'tester',], $result->getAttribute('roles')); + $this->assertEquals([ + new Document(['$id' => '1', 'label' => 'x']), + new Document(['$id' => '2', 'label' => 'y']), + new Document(['$id' => '3', 'label' => 'z']), + ], $result->getAttribute('tags')); + } + public function testUpdateDocumentConflict(): void { - $database = static::getDatabase(); + $document = $this->initDocumentsFixture(__FUNCTION__); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; + $document->setAttribute('integer_signed', 7); + $result = $this->getDatabase()->withRequestTimestamp(new \DateTime(), function () use ($document) { + return $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); + }); + $this->assertEquals(7, $result->getAttribute('integer_signed')); + + $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); + $document->setAttribute('integer_signed', 8); + try { + $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () use ($document) { + return $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); + }); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertTrue($e instanceof ConflictException); + $this->assertEquals('Document was updated after the request timestamp', $e->getMessage()); } + } + public function testDeleteDocumentConflict(): void + { + $document = $this->initDocumentsFixture(__FUNCTION__); - // Create collection with JSON filter attribute - $collection = ID::unique(); - $database->createCollection($collection, permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]); + $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); + $this->expectException(ConflictException::class); + $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () use ($document) { + return $this->getDatabase()->deleteDocument($document->getCollection(), $document->getId()); + }); + } + public function testUpdateDocumentDuplicatePermissions(): void + { + $document = $this->initDocumentsFixture(__FUNCTION__); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, true); - $database->createAttribute($collection, 'metadata', Database::VAR_STRING, 4000, true, filters: ['json']); + $new = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); - $permissions = [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]; + $new + ->setAttribute('$permissions', Permission::read(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::read(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::create(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::create(Role::guests()), SetType::Append); - // Test 1: Insertion (createDocument) with JSON filter - $docId1 = 'json-doc-1'; - $initialMetadata = [ - 'version' => '1.0.0', - 'tags' => ['php', 'database'], - 'config' => [ - 'debug' => false, - 'timeout' => 30 - ] - ]; + $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); - $document1 = $database->createDocument($collection, new Document([ - '$id' => $docId1, - 'name' => 'Initial Document', - 'metadata' => $initialMetadata, - '$permissions' => $permissions, - ])); + $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); - $this->assertEquals($docId1, $document1->getId()); - $this->assertEquals('Initial Document', $document1->getAttribute('name')); - $this->assertIsArray($document1->getAttribute('metadata')); - $this->assertEquals('1.0.0', $document1->getAttribute('metadata')['version']); - $this->assertEquals(['php', 'database'], $document1->getAttribute('metadata')['tags']); + $this->assertContains('guests', $new->getPermissionsByType(PermissionType::Read)); + $this->assertContains('guests', $new->getPermissionsByType(PermissionType::Create)); - // Test 2: Update (updateDocument) with modified JSON filter - $updatedMetadata = [ - 'version' => '2.0.0', - 'tags' => ['php', 'database', 'json'], - 'config' => [ - 'debug' => true, - 'timeout' => 60, - 'cache' => true - ], - 'updated' => true - ]; + $database = $this->getDatabase(); + $collection = 'dupCase'; + $database->createCollection(Collection::create(id: $collection)); - $document1->setAttribute('name', 'Updated Document'); - $document1->setAttribute('metadata', $updatedMetadata); + $stored = $database->createDocument($collection, new Document([ + '$id' => 'caseSensitive', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); - $updatedDoc = $database->updateDocument($collection, $docId1, $document1); + $stored->setAttribute('$id', 'CaseSensitive'); + $stored + ->setAttribute('$permissions', Permission::read(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::read(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::create(Role::guests()), SetType::Append) + ->setAttribute('$permissions', Permission::create(Role::guests()), SetType::Append); - $this->assertEquals($docId1, $updatedDoc->getId()); - $this->assertEquals('Updated Document', $updatedDoc->getAttribute('name')); - $this->assertIsArray($updatedDoc->getAttribute('metadata')); - $this->assertEquals('2.0.0', $updatedDoc->getAttribute('metadata')['version']); - $this->assertEquals(['php', 'database', 'json'], $updatedDoc->getAttribute('metadata')['tags']); - $this->assertTrue($updatedDoc->getAttribute('metadata')['config']['debug']); - $this->assertTrue($updatedDoc->getAttribute('metadata')['updated']); + $renamed = $database->updateDocument($collection, 'caseSensitive', $stored); + $this->assertSame('CaseSensitive', $renamed->getId()); - // Test 3: Upsert - Create new document (upsertDocument) - $docId2 = 'json-doc-2'; - $newMetadata = [ - 'version' => '1.5.0', - 'tags' => ['javascript', 'node'], - 'config' => [ - 'debug' => false, - 'timeout' => 45 - ] - ]; + $updated = $database->getDocument($collection, 'CaseSensitive'); + $this->assertSame('CaseSensitive', $updated->getId()); + $this->assertContains('guests', $updated->getPermissionsByType(PermissionType::Read)); + $this->assertContains('guests', $updated->getPermissionsByType(PermissionType::Create)); + $this->assertContains('any', $updated->getPermissionsByType(PermissionType::Create)); + } - $document2 = new Document([ - '$id' => $docId2, - 'name' => 'New Upsert Document', - 'metadata' => $newMetadata, - '$permissions' => $permissions, - ]); + /** + * Test that DuplicateException messages differentiate between + * document ID duplicates and unique index violations. + */ + public function testDuplicateExceptionMessages(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $upsertedDoc = $database->upsertDocument($collection, $document2); + if (!$database->getAdapter()->supports(Capability::IndexUnique)) { + $this->expectNotToPerformAssertions(); + return; + } - $this->assertEquals($docId2, $upsertedDoc->getId()); - $this->assertEquals('New Upsert Document', $upsertedDoc->getAttribute('name')); - $this->assertIsArray($upsertedDoc->getAttribute('metadata')); - $this->assertEquals('1.5.0', $upsertedDoc->getAttribute('metadata')['version']); + $database->createCollection(Collection::create(id: 'duplicateMessages')); + $database->createAttribute('duplicateMessages', Attribute::string(key: 'email', size: 128, required: true)); + $database->createIndex('duplicateMessages', Index::unique(key: 'emailUnique', attributes: ['email'], lengths: [128])); - // Test 4: Upsert - Update existing document (upsertDocument) - $document2->setAttribute('name', 'Updated Upsert Document'); - $document2->setAttribute('metadata', [ - 'version' => '2.5.0', - 'tags' => ['javascript', 'node', 'typescript'], - 'config' => [ - 'debug' => true, - 'timeout' => 90 + $database->createDocument('duplicateMessages', new Document([ + '$id' => 'dup_msg_1', + '$permissions' => [ + Permission::read(Role::any()), ], - 'migrated' => true - ]); + 'email' => 'test@example.com', + ])); - $upsertedDoc2 = $database->upsertDocument($collection, $document2); + try { + $database->createDocument('duplicateMessages', new Document([ + '$id' => 'dup_msg_1', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'email' => 'different@example.com', + ])); + $this->fail('Expected DuplicateException for duplicate document ID'); + } catch (DuplicateException $e) { + $this->assertNotInstanceOf(UniqueException::class, $e); + $this->assertStringContainsString('Document already exists', $e->getMessage()); + } - $this->assertEquals($docId2, $upsertedDoc2->getId()); - $this->assertEquals('Updated Upsert Document', $upsertedDoc2->getAttribute('name')); - $this->assertIsArray($upsertedDoc2->getAttribute('metadata')); - $this->assertEquals('2.5.0', $upsertedDoc2->getAttribute('metadata')['version']); - $this->assertEquals(['javascript', 'node', 'typescript'], $upsertedDoc2->getAttribute('metadata')['tags']); - $this->assertTrue($upsertedDoc2->getAttribute('metadata')['migrated']); + try { + $database->createDocument('duplicateMessages', new Document([ + '$id' => 'dup_msg_2', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'email' => 'test@example.com', + ])); + $this->fail('Expected UniqueException for unique index violation'); + } catch (DuplicateException $e) { + $this->assertInstanceOf(UniqueException::class, $e); + $this->assertStringContainsString('unique attributes', $e->getMessage()); + } - // Test 5: Upsert - Bulk upsertDocuments (create and update) - $docId3 = 'json-doc-3'; - $docId4 = 'json-doc-4'; + // '_uid' is the document ID column, so a conflicting value containing it must not be read as an ID conflict. + $database->createDocument('duplicateMessages', new Document([ + '$id' => 'dup_msg_3', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'email' => 'prefix_uid_suffix@example.com', + ])); - $bulkDocuments = [ - new Document([ - '$id' => $docId3, - 'name' => 'Bulk Upsert 1', - 'metadata' => [ - 'version' => '3.0.0', - 'tags' => ['python', 'flask'], - 'config' => ['debug' => false] - ], - '$permissions' => $permissions, - ]), - new Document([ - '$id' => $docId4, - 'name' => 'Bulk Upsert 2', - 'metadata' => [ - 'version' => '3.1.0', - 'tags' => ['go', 'golang'], - 'config' => ['debug' => true] - ], - '$permissions' => $permissions, - ]), - // Update existing document - new Document([ - '$id' => $docId1, - 'name' => 'Bulk Updated Document', - 'metadata' => [ - 'version' => '3.0.0', - 'tags' => ['php', 'database', 'bulk'], - 'config' => [ - 'debug' => false, - 'timeout' => 120 - ], - 'bulkUpdated' => true + try { + $database->createDocument('duplicateMessages', new Document([ + '$id' => 'dup_msg_4', + '$permissions' => [ + Permission::read(Role::any()), ], - '$permissions' => $permissions, - ]), - ]; - - $count = $database->upsertDocuments($collection, $bulkDocuments); - $this->assertEquals(3, $count); - - // Verify bulk upsert results - $bulkDoc1 = $database->getDocument($collection, $docId3); - $this->assertEquals('Bulk Upsert 1', $bulkDoc1->getAttribute('name')); - $this->assertEquals('3.0.0', $bulkDoc1->getAttribute('metadata')['version']); - - $bulkDoc2 = $database->getDocument($collection, $docId4); - $this->assertEquals('Bulk Upsert 2', $bulkDoc2->getAttribute('name')); - $this->assertEquals('3.1.0', $bulkDoc2->getAttribute('metadata')['version']); - - $bulkDoc3 = $database->getDocument($collection, $docId1); - $this->assertEquals('Bulk Updated Document', $bulkDoc3->getAttribute('name')); - $this->assertEquals('3.0.0', $bulkDoc3->getAttribute('metadata')['version']); - $this->assertTrue($bulkDoc3->getAttribute('metadata')['bulkUpdated']); + 'email' => 'prefix_uid_suffix@example.com', + ])); + $this->fail('Expected UniqueException for unique index violation on a value containing _uid'); + } catch (DuplicateException $e) { + $this->assertInstanceOf(UniqueException::class, $e); + $this->assertStringContainsString('unique attributes', $e->getMessage()); + } - // Cleanup - $database->deleteCollection($collection); + $database->deleteCollection('duplicateMessages'); } - public function testFindRegex(): void + public function testDeleteBulkDocuments(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - // Skip test if regex is not supported - if (!$database->getAdapter()->getSupportForRegex()) { + if (! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); return; } - // Determine regex support type - $supportsPCRE = $database->getAdapter()->getSupportForPCRERegex(); - $supportsPOSIX = $database->getAdapter()->getSupportForPOSIXRegex(); - - // Determine word boundary pattern based on support - $wordBoundaryPattern = null; - $wordBoundaryPatternPHP = null; - if ($supportsPCRE) { - $wordBoundaryPattern = '\\b'; // PCRE uses \b - $wordBoundaryPatternPHP = '\\b'; // PHP preg_match uses \b - } elseif ($supportsPOSIX) { - $wordBoundaryPattern = '\\y'; // POSIX uses \y - $wordBoundaryPatternPHP = '\\b'; // PHP preg_match still uses \b for verification - } - - $database->createCollection('moviesRegex', permissions: [ + $database->createCollection(Collection::create(id: 'bulk_delete', attributes: [ + Attribute::string(key: 'text', size: 100, required: true), + Attribute::integer(key: 'integer', width: IntegerWidth::Bits64, required: true) + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]); + Permission::delete(Role::any()) + ], documentSecurity: false)); - if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals(true, $database->createAttribute('moviesRegex', 'name', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('moviesRegex', 'director', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('moviesRegex', 'year', Database::VAR_INTEGER, 0, true)); - } + $this->propagateBulkDocuments('bulk_delete'); - if ($database->getAdapter()->getSupportForTrigramIndex()) { - $database->createIndex('moviesRegex', 'trigram_name', Database::INDEX_TRIGRAM, ['name']); - $database->createIndex('moviesRegex', 'trigram_director', Database::INDEX_TRIGRAM, ['director']); - } + $docs = $database->find('bulk_delete'); + $this->assertCount(10, $docs); - // Create test documents - $database->createDocuments('moviesRegex', [ - new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Frozen', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2013, - ]), - new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Frozen II', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2019, - ]), - new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Captain America: The First Avenger', - 'director' => 'Joe Johnston', - 'year' => 2011, - ]), - new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Captain Marvel', - 'director' => 'Anna Boden & Ryan Fleck', - 'year' => 2019, - ]), - new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Work in Progress', - 'director' => 'TBD', - 'year' => 2025, - ]), - new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Work in Progress 2', - 'director' => 'TBD', - 'year' => 2026, - ]), - ]); + /** + * Test Short select query, test pagination as well, Add order to select + */ + $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt']; - // Helper function to verify regex query completeness - $verifyRegexQuery = function (string $attribute, string $regexPattern, array $queryResults) use ($database) { - // Convert database regex pattern to PHP regex format. - // POSIX-style word boundary (\y) is not supported by PHP PCRE, so map it to \b. - $normalizedPattern = str_replace('\y', '\b', $regexPattern); - $phpPattern = '/' . str_replace('/', '\/', $normalizedPattern) . '/'; + $count = $database->deleteDocuments( + collection: 'bulk_delete', + queries: [ + Query::select([...$selects, '$createdAt']), + Query::cursorAfter($docs[6]), + Query::greaterThan('$createdAt', '2000-01-01'), + Query::orderAsc('$createdAt'), + Query::orderAsc(), + Query::limit(2), + ], + batchSize: 1 + ); - // Get all documents to manually verify - $allDocuments = $database->find('moviesRegex'); + $this->assertEquals(2, $count); - // Manually filter documents that match the pattern - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $value = $doc->getAttribute($attribute); - if (preg_match($phpPattern, $value)) { - $expectedMatches[] = $doc->getId(); - } - } + // TEST: Bulk Delete All Documents + $this->assertEquals(8, $database->deleteDocuments('bulk_delete')); - // Get IDs from query results - $actualMatches = array_map(fn ($doc) => $doc->getId(), $queryResults); + $docs = $database->find('bulk_delete'); + $this->assertCount(0, $docs); - // Verify no extra documents are returned - foreach ($queryResults as $doc) { - $value = $doc->getAttribute($attribute); - $this->assertTrue( - (bool) preg_match($phpPattern, $value), - "Document '{$doc->getId()}' with {$attribute}='{$value}' should match pattern '{$regexPattern}'" - ); - } + // TEST: Bulk delete documents with queries. + $this->propagateBulkDocuments('bulk_delete'); - // Verify all expected documents are returned (no missing) - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern '{$regexPattern}' on attribute '{$attribute}'" - ); - }; + $results = []; + $count = $database->deleteDocuments('bulk_delete', [ + Query::greaterThanEqual('integer', 5) + ], onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); - // Test basic regex pattern - match movies starting with 'Captain' - // Note: Pattern format may vary by adapter (MongoDB uses regex strings, SQL uses REGEXP) - $pattern = '/^Captain/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '^Captain'), - ]); + $this->assertEquals(5, $count); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '^Captain', $documents); + foreach ($results as $document) { + $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); + } - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Captain America: The First Avenger', $names)); - $this->assertTrue(in_array('Captain Marvel', $names)); + $docs = $database->find('bulk_delete'); + $this->assertEquals(5, \count($docs)); - // Test regex pattern - match movies containing 'Frozen' - $pattern = '/Frozen/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', 'Frozen'), - ]); + // TEST (FAIL): Can't delete documents in the past + $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', 'Frozen', $documents); + try { + $this->getDatabase()->withRequestTimestamp($oneHourAgo, function () { + return $this->getDatabase()->deleteDocuments('bulk_delete'); + }); + $this->fail('Failed to throw exception'); + } catch (ConflictException $e) { + $this->assertEquals('Document was updated after the request timestamp', $e->getMessage()); + } - // Test regex pattern - match exact title 'Frozen' - $exactFrozenDocuments = $database->find('moviesRegex', [ - Query::regex('name', '^Frozen$'), - ]); - $verifyRegexQuery('name', '^Frozen$', $exactFrozenDocuments); - $this->assertCount(1, $exactFrozenDocuments, 'Exact ^Frozen$ regex should return only one document'); - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Frozen', $names)); - $this->assertTrue(in_array('Frozen II', $names)); + // TEST (FAIL): Bulk delete all documents with invalid collection permission + $database->updateCollection('bulk_delete', new CollectionUpdate(permissions: [], documentSecurity: false)); + try { + $database->deleteDocuments('bulk_delete'); + $this->fail('Bulk deleted documents with invalid collection permission'); + } catch (\Utopia\Database\Exception\Authorization) { + } - // Test regex pattern - match movies ending with 'Marvel' - $pattern = '/Marvel$/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', 'Marvel$'), - ]); + $database->updateCollection('bulk_delete', new CollectionUpdate(permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], documentSecurity: false)); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', 'Marvel$', $documents); + $this->assertEquals(5, $database->deleteDocuments('bulk_delete')); + $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); - $this->assertEquals(1, count($documents)); // Only Captain Marvel - $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); + // TEST: Make sure we can't delete documents we don't have permissions for + $database->updateCollection('bulk_delete', new CollectionUpdate(permissions: [ + Permission::create(Role::any()), + ], documentSecurity: true)); + $this->propagateBulkDocuments('bulk_delete', documentSecurity: true); - // Test regex pattern - match movies with 'Work' in the name - $pattern = '/.*Work.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '.*Work.*'), - ]); + $this->assertEquals(0, $database->deleteDocuments('bulk_delete')); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '.*Work.*', $documents); + $documents = $this->getDatabase()->getAuthorization()->skip(function () use ($database) { + return $database->find('bulk_delete'); + }); - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Work in Progress', $names)); - $this->assertTrue(in_array('Work in Progress 2', $names)); + $this->assertEquals(10, \count($documents)); - // Test regex pattern - match movies with 'Buck' in director - $pattern = '/.*Buck.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('director', '.*Buck.*'), - ]); + $database->updateCollection('bulk_delete', new CollectionUpdate(permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], documentSecurity: false)); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('director', '.*Buck.*', $documents); + $database->deleteDocuments('bulk_delete'); - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Frozen', $names)); - $this->assertTrue(in_array('Frozen II', $names)); + $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); - // Test regex with case pattern - adapters may be case-sensitive or case-insensitive - // MySQL/MariaDB REGEXP is case-insensitive by default, MongoDB is case-sensitive - $patternCaseSensitive = '/captain/'; - $patternCaseInsensitive = '/captain/i'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', 'captain'), // lowercase - ]); + // Teardown + $database->deleteCollection('bulk_delete'); + } - // Verify all returned documents match the pattern (case-insensitive check for verification) - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - // Verify that returned documents contain 'captain' (case-insensitive check) - $this->assertTrue( - (bool) preg_match($patternCaseInsensitive, $name), - "Document '{$name}' should match pattern 'captain' (case-insensitive check)" - ); - } + public function testDeleteBulkDocumentsQueries(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - // Verify completeness: Check what the database actually returns - // Some adapters (MongoDB) are case-sensitive, others (MySQL/MariaDB) are case-insensitive - // We'll determine expected matches based on case-sensitive matching (pure regex behavior) - // If the adapter is case-insensitive, it will return more documents, which is fine - $allDocuments = $database->find('moviesRegex'); - $expectedMatchesCaseSensitive = []; - $expectedMatchesCaseInsensitive = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($patternCaseSensitive, $name)) { - $expectedMatchesCaseSensitive[] = $doc->getId(); - } - if (preg_match($patternCaseInsensitive, $name)) { - $expectedMatchesCaseInsensitive[] = $doc->getId(); - } + if (! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + return; } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($actualMatches); + $database->createCollection(Collection::create(id: 'bulk_delete_queries', attributes: [ + Attribute::string(key: 'text', size: 100, required: true), + Attribute::integer(key: 'integer', width: IntegerWidth::Bits64, required: true) + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], documentSecurity: false)); - // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) - // Check which one matches the actual results - sort($expectedMatchesCaseSensitive); - sort($expectedMatchesCaseInsensitive); + // Test limit + $this->propagateBulkDocuments('bulk_delete_queries'); - // Verify that actual results match either case-sensitive or case-insensitive expectations - $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); - $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); + $this->assertEquals(5, $database->deleteDocuments('bulk_delete_queries', [Query::limit(5)])); + $this->assertEquals(5, \count($database->find('bulk_delete_queries'))); - $this->assertTrue( - $matchesCaseSensitive || $matchesCaseInsensitive, - "Query results should match either case-sensitive (" . count($expectedMatchesCaseSensitive) . " docs) or case-insensitive (" . count($expectedMatchesCaseInsensitive) . " docs) expectations. Got " . count($actualMatches) . " documents." - ); + $this->assertEquals(5, $database->deleteDocuments('bulk_delete_queries', [Query::limit(5)])); + $this->assertEquals(0, \count($database->find('bulk_delete_queries'))); - // Test regex with case-insensitive pattern (if adapter supports it via flags) - // Test with uppercase to verify case sensitivity - $pattern = '/Captain/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', 'Captain'), // uppercase - ]); + // Test Limit more than batchSize + $this->propagateBulkDocuments('bulk_delete_queries', Database::BATCH_SIZE * 2); + $this->assertEquals(Database::BATCH_SIZE * 2, \count($database->find('bulk_delete_queries', [Query::limit(Database::BATCH_SIZE * 2)]))); + $this->assertEquals(Database::BATCH_SIZE + 2, $database->deleteDocuments('bulk_delete_queries', [Query::limit(Database::BATCH_SIZE + 2)])); + $this->assertEquals(Database::BATCH_SIZE - 2, \count($database->find('bulk_delete_queries', [Query::limit(Database::BATCH_SIZE * 2)]))); + $this->assertEquals(Database::BATCH_SIZE - 2, $this->getDatabase()->deleteDocuments('bulk_delete_queries')); - // Verify all returned documents match the pattern - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern 'Captain'" - ); - } + // Test Offset + $this->propagateBulkDocuments('bulk_delete_queries', 100); + $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries', [Query::offset(50)])); - // Verify completeness - $allDocuments = $database->find('moviesRegex'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern, $name)) { - $expectedMatches[] = $doc->getId(); - } - } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern 'Captain'" - ); + $docs = $database->find('bulk_delete_queries', [Query::limit(100)]); + $this->assertEquals(50, \count($docs)); - // Test regex combined with other queries - $pattern = '/^Captain/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '^Captain'), - Query::greaterThan('year', 2010), - ]); + $lastDoc = \end($docs); + $this->assertNotEmpty($lastDoc); + $this->assertEquals('doc49', $lastDoc->getId()); + $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries')); - // Verify all returned documents match both conditions - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $year = $doc->getAttribute('year'); - $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern '{$pattern}'" - ); - $this->assertGreaterThan(2010, $year, "Document '{$name}' should have year > 2010"); - } + $database->deleteCollection('bulk_delete_queries'); + } - // Verify completeness: manually check all documents that match both conditions - $allDocuments = $database->find('moviesRegex'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - $year = $doc->getAttribute('year'); - if (preg_match($pattern, $name) && $year > 2010) { - $expectedMatches[] = $doc->getId(); - } + public function testDeleteBulkDocumentsWithCallbackSupport(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + return; } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching both regex '^Captain' and year > 2010" - ); - // Test regex with limit - $pattern = '/.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '.*'), // Match all - Query::limit(3), - ]); + $database->createCollection(Collection::create(id: 'bulk_delete_with_callback', attributes: [ + Attribute::string(key: 'text', size: 100, required: true), + Attribute::integer(key: 'integer', width: IntegerWidth::Bits64, required: true) + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], documentSecurity: false)); - $this->assertEquals(3, count($documents)); + $this->propagateBulkDocuments('bulk_delete_with_callback'); - // Verify all returned documents match the pattern (should match all) - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern '{$pattern}'" + $docs = $database->find('bulk_delete_with_callback'); + $this->assertCount(10, $docs); + + /** + * Test Short select query, test pagination as well, Add order to select + */ + $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt']; + + try { + // a non existent document to test the error thrown + $database->deleteDocuments( + collection: 'bulk_delete_with_callback', + queries: [ + Query::select([...$selects, '$createdAt']), + Query::lessThan('$createdAt', '1800-01-01'), + Query::orderAsc('$createdAt'), + Query::orderAsc(), + Query::limit(1), + ], + batchSize: 1, + onNext: function () { + throw new Exception("Error thrown to test that deletion doesn't stop and error is caught"); + } ); + } catch (Exception $e) { + $this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage()); } - // Note: With limit, we can't verify completeness, but we can verify all returned match + $docs = $database->find('bulk_delete_with_callback'); + $this->assertCount(10, $docs); - // Test regex with non-matching pattern - $pattern = '/^NonExistentPattern$/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '^NonExistentPattern$'), - ]); + $deleted = 0; + $count = $database->deleteDocuments( + collection: 'bulk_delete_with_callback', + queries: [ + Query::select([...$selects, '$createdAt']), + Query::cursorAfter($docs[6]), + Query::greaterThan('$createdAt', '2000-01-01'), + Query::orderAsc('$createdAt'), + Query::orderAsc(), + Query::limit(2), + ], + batchSize: 1, + onNext: function () use (&$deleted) { + $deleted++; + } + ); - $this->assertEquals(0, count($documents)); + $this->assertEquals(2, $count); + $this->assertEquals(2, $deleted); - // Verify no documents match (double-check by getting all and filtering) - $allDocuments = $database->find('moviesRegex'); - $matchingCount = 0; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern, $name)) { - $matchingCount++; - } - } - $this->assertEquals(0, $matchingCount, "No documents should match pattern '{$pattern}'"); - - // Verify completeness: no documents should be returned - $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $documents)); - - // Test regex with special characters (should be escaped or handled properly) - $pattern = '/.*:.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '.*:.*'), // Match movies with colon - ]); - - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '.*:.*', $documents); + // TEST: Bulk Delete All Documents without passing callbacks + $this->assertEquals(8, $database->deleteDocuments('bulk_delete_with_callback')); - // Verify expected document is included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + $docs = $database->find('bulk_delete_with_callback'); + $this->assertCount(0, $docs); - // ReDOS safety: ensure pathological patterns respond quickly and do not hang - $catastrophicPattern = '(a+)+$'; - $start = microtime(true); - $redosDocs = $database->find('moviesRegex', [ - Query::regex('name', $catastrophicPattern), - ]); - $elapsed = microtime(true) - $start; - $this->assertLessThan(1.0, $elapsed, 'Regex evaluation should not be slow or vulnerable to ReDOS'); - $verifyRegexQuery('name', $catastrophicPattern, $redosDocs); - $this->assertCount(0, $redosDocs, 'Pathological regex should not match any movie titles'); + // TEST: Bulk delete documents with queries with callbacks + $this->propagateBulkDocuments('bulk_delete_with_callback'); - // Test regex search pattern - match movies with word boundaries - // Only test if word boundaries are supported (PCRE or POSIX) - if ($wordBoundaryPattern !== null) { - $dbPattern = $wordBoundaryPattern . 'Work' . $wordBoundaryPattern; - $phpPattern = '/' . $wordBoundaryPatternPHP . 'Work' . $wordBoundaryPatternPHP . '/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', $dbPattern), - ]); + $results = []; + $count = $database->deleteDocuments('bulk_delete_with_callback', [ + Query::greaterThanEqual('integer', 5) + ], onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); - // Verify all returned documents match the pattern - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $this->assertTrue( - (bool) preg_match($phpPattern, $name), - "Document '{$name}' should match pattern '{$dbPattern}'" - ); - } + $this->assertEquals(5, $count); - // Verify completeness: manually check all documents - $allDocuments = $database->find('moviesRegex'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($phpPattern, $name)) { - $expectedMatches[] = $doc->getId(); - } - } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern '{$dbPattern}'" - ); + foreach ($results as $document) { + $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); } - // Test regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' - $pattern1 = '/Captain/'; - $pattern2 = '/Frozen/'; - $documents = $database->find('moviesRegex', [ - Query::or([ - Query::regex('name', 'Captain'), - Query::regex('name', 'Frozen'), - ]), - ]); - - // Verify all returned documents match at least one pattern - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $matchesPattern1 = (bool) preg_match($pattern1, $name); - $matchesPattern2 = (bool) preg_match($pattern2, $name); - $this->assertTrue( - $matchesPattern1 || $matchesPattern2, - "Document '{$name}' should match either pattern 'Captain' or 'Frozen'" - ); - } + $docs = $database->find('bulk_delete_with_callback'); + $this->assertEquals(5, \count($docs)); - // Verify completeness: manually check all documents - $allDocuments = $database->find('moviesRegex'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern1, $name) || preg_match($pattern2, $name)) { - $expectedMatches[] = $doc->getId(); - } - } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern 'Captain' OR 'Frozen'" - ); - $database->deleteCollection('moviesRegex'); + // Teardown + $database->deleteCollection('bulk_delete_with_callback'); } - public function testRegexInjection(): void + + public function testUpdateDocumentsQueries(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - // Skip test if regex is not supported - if (!$database->getAdapter()->getSupportForRegex()) { + if (! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); return; } - $collectionName = 'injectionTest'; - $database->createCollection($collectionName, permissions: [ - Permission::create(Role::any()), + $collection = 'testUpdateDocumentsQueries'; + + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'text', size: 64, required: true), + Attribute::integer(key: 'integer', width: IntegerWidth::Bits64, required: true), + ], permissions: [ Permission::read(Role::any()), + Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()), - ]); - - if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals(true, $database->createAttribute($collectionName, 'text', Database::VAR_STRING, 1000, true)); - } - - // Create test documents - one that should match, one that shouldn't - $database->createDocument($collectionName, new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'text' => 'target', - ])); - - $database->createDocument($collectionName, new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'text' => 'other', + Permission::delete(Role::any()) ])); - // SQL injection attempts - these should NOT return the "other" document - $sqlInjectionPatterns = [ - "target') OR '1'='1", // SQL injection attempt - "target' OR 1=1--", // SQL injection with comment - "target' OR 'x'='x", // SQL injection attempt - "target' UNION SELECT *--", // SQL UNION injection - ]; + // Test limit + $this->propagateBulkDocuments($collection, 100); - // MongoDB injection attempts - these should NOT return the "other" document - $mongoInjectionPatterns = [ - 'target" || "1"=="1', // MongoDB injection attempt - 'target" || true', // MongoDB boolean injection - 'target"} || {"text": "other"}', // MongoDB operator injection - ]; + $this->assertEquals(10, $database->updateDocuments($collection, new Document([ + 'text' => 'text📝 updated', + ]), [Query::limit(10)])); - $allInjectionPatterns = array_merge($sqlInjectionPatterns, $mongoInjectionPatterns); + $this->assertEquals(10, \count($database->find($collection, [Query::equal('text', ['text📝 updated'])]))); + $this->assertEquals(100, $database->deleteDocuments($collection)); + $this->assertEquals(0, \count($database->find($collection))); - foreach ($allInjectionPatterns as $pattern) { - try { - $results = $database->find($collectionName, [ - Query::regex('text', $pattern), - ]); + // Test Offset + $this->propagateBulkDocuments($collection, 100); + $this->assertEquals(50, $database->updateDocuments($collection, new Document([ + 'text' => 'text📝 updated', + ]), [ + Query::offset(50), + ])); - // Critical check: if injection succeeded, we might get the "other" document - // which should NOT match a pattern starting with "target" - $foundOther = false; - foreach ($results as $doc) { - $text = $doc->getAttribute('text'); - if ($text === 'other') { - $foundOther = true; + $docs = $database->find($collection, [Query::equal('text', ['text📝 updated']), Query::limit(100)]); + $this->assertCount(50, $docs); - // Verify that "other" doesn't actually match the pattern as a regex - $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); - if ($matches === 0 || $matches === false) { - // "other" doesn't match the pattern but was returned - // This indicates potential injection vulnerability - $this->fail( - "Potential injection detected: Pattern '{$pattern}' returned document 'other' " . - "which doesn't match the pattern. This suggests SQL/MongoDB injection may have succeeded." - ); - } - } - } + $lastDoc = end($docs); + $this->assertNotEmpty($lastDoc); + $this->assertEquals('doc99', $lastDoc->getId()); - // Additional verification: check that all returned documents actually match the pattern - foreach ($results as $doc) { - $text = $doc->getAttribute('text'); - $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); + $this->assertEquals(100, $database->deleteDocuments($collection)); + } - // If pattern is invalid, skip validation - if ($matches === false) { - continue; - } + public function testEmptyOperatorValues(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $this->initDocumentsFixture(); - // If document doesn't match but was returned, it's suspicious - if ($matches === 0) { - $this->fail( - "Potential injection: Document '{$text}' was returned for pattern '{$pattern}' " . - "but doesn't match the regex pattern." - ); - } - } + try { + $database->findOne($this->getDocumentsCollection(), [ + Query::equal('string', []), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Invalid query: Equal queries require at least one value.', $e->getMessage()); + } - } catch (\Exception $e) { - // Exceptions are acceptable - they indicate the injection was blocked or caused an error - // This is actually good - it means the system rejected the malicious pattern - $this->assertInstanceOf(\Exception::class, $e); - } + try { + $database->findOne($this->getDocumentsCollection(), [ + Query::containsString('string', []), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Invalid query: Contains queries require at least one value.', $e->getMessage()); } + } - // Test that legitimate regex patterns still work correctly - $legitimatePatterns = [ - 'target', // Should match "target" - '^target', // Should match "target" (anchored) - 'other', // Should match "other" - ]; + public function testSingleDocumentDateOperations(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $collection = 'normal_date_operations'; + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('string', $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))->key); + + $database->setPreserveDates(true); + + $createDate = '2000-01-01T10:00:00.000+00:00'; + $updateDate = '2000-02-01T15:30:00.000+00:00'; + $date1 = '2000-01-01T10:00:00.000+00:00'; + $date2 = '2000-02-01T15:30:00.000+00:00'; + $date3 = '2000-03-01T20:45:00.000+00:00'; + // Test 1: Create with custom createdAt, then update with custom updatedAt + $doc = $database->createDocument($collection, new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'initial', + '$createdAt' => $createDate + ])); + + $this->assertEquals($createDate, $doc->getAttribute('$createdAt')); + $this->assertNotEquals($createDate, $doc->getAttribute('$updatedAt')); + + // Update with custom updatedAt + $doc->setAttribute('string', 'updated'); + $doc->setAttribute('$updatedAt', $updateDate); + $updatedDoc = $database->updateDocument($collection, 'doc1', $doc); + + $this->assertEquals($createDate, $updatedDoc->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $updatedDoc->getAttribute('$updatedAt')); + + // Test 2: Create with both custom dates + $doc2 = $database->createDocument($collection, new Document([ + '$id' => 'doc2', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'both_dates', + '$createdAt' => $createDate, + '$updatedAt' => $updateDate + ])); + + $this->assertEquals($createDate, $doc2->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $doc2->getAttribute('$updatedAt')); + + // Test 3: Create without dates, then update with custom dates + $doc3 = $database->createDocument($collection, new Document([ + '$id' => 'doc3', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'no_dates' + ])); + + $doc3->setAttribute('string', 'updated_no_dates'); + $doc3->setAttribute('$createdAt', $createDate); + $doc3->setAttribute('$updatedAt', $updateDate); + $updatedDoc3 = $database->updateDocument($collection, 'doc3', $doc3); + + $this->assertEquals($createDate, $updatedDoc3->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $updatedDoc3->getAttribute('$updatedAt')); + + // Test 4: Update only createdAt + $doc4 = $database->createDocument($collection, new Document([ + '$id' => 'doc4', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'initial' + ])); + + $originalCreatedAt4 = $doc4->getAttribute('$createdAt'); + $originalUpdatedAt4 = $doc4->getAttribute('$updatedAt'); + + sleep(1); // Ensure $updatedAt differs when adapter timestamp precision is seconds + + $doc4->setAttribute('$updatedAt', null); + $doc4->setAttribute('$createdAt', null); + $updatedDoc4 = $database->updateDocument($collection, 'doc4', document: $doc4); + + $this->assertEquals($originalCreatedAt4, $updatedDoc4->getAttribute('$createdAt')); + $this->assertNotEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt')); + + // Test 5: Update only updatedAt + $updatedDoc4->setAttribute('$updatedAt', $updateDate); + $updatedDoc4->setAttribute('$createdAt', $createDate); + $finalDoc4 = $database->updateDocument($collection, 'doc4', $updatedDoc4); + + $this->assertEquals($createDate, $finalDoc4->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $finalDoc4->getAttribute('$updatedAt')); + + // Test 6: Create with updatedAt, update with createdAt + $doc5 = $database->createDocument($collection, new Document([ + '$id' => 'doc5', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'doc5', + '$updatedAt' => $date2 + ])); + + $this->assertNotEquals($date2, $doc5->getAttribute('$createdAt')); + $this->assertEquals($date2, $doc5->getAttribute('$updatedAt')); + + $doc5->setAttribute('string', 'doc5_updated'); + $doc5->setAttribute('$createdAt', $date1); + $updatedDoc5 = $database->updateDocument($collection, 'doc5', $doc5); + + $this->assertEquals($date1, $updatedDoc5->getAttribute('$createdAt')); + $this->assertEquals($date2, $updatedDoc5->getAttribute('$updatedAt')); + + // Test 7: Create with both dates, update with different dates + $doc6 = $database->createDocument($collection, new Document([ + '$id' => 'doc6', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'doc6', + '$createdAt' => $date1, + '$updatedAt' => $date2 + ])); + + $this->assertEquals($date1, $doc6->getAttribute('$createdAt')); + $this->assertEquals($date2, $doc6->getAttribute('$updatedAt')); + + $doc6->setAttribute('string', 'doc6_updated'); + $doc6->setAttribute('$createdAt', $date3); + $doc6->setAttribute('$updatedAt', $date3); + $updatedDoc6 = $database->updateDocument($collection, 'doc6', $doc6); + + $this->assertEquals($date3, $updatedDoc6->getAttribute('$createdAt')); + $this->assertEquals($date3, $updatedDoc6->getAttribute('$updatedAt')); + + // Test 8: Preserve dates disabled + $database->setPreserveDates(false); + + $customDate = '2000-01-01T10:00:00.000+00:00'; + + $doc7 = $database->createDocument($collection, new Document([ + '$id' => 'doc7', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'doc7', + '$createdAt' => $customDate, + '$updatedAt' => $customDate + ])); + + $this->assertNotEquals($customDate, $doc7->getAttribute('$createdAt')); + $this->assertNotEquals($customDate, $doc7->getAttribute('$updatedAt')); + + // Update with custom dates should also be ignored + $doc7->setAttribute('string', 'updated'); + $doc7->setAttribute('$createdAt', $customDate); + $doc7->setAttribute('$updatedAt', $customDate); + $updatedDoc7 = $database->updateDocument($collection, 'doc7', $doc7); + + $this->assertNotEquals($customDate, $updatedDoc7->getAttribute('$createdAt')); + $this->assertNotEquals($customDate, $updatedDoc7->getAttribute('$updatedAt')); + + // Test checking updatedAt updates even old document exists + $database->setPreserveDates(true); + $doc11 = $database->createDocument($collection, new Document([ + '$id' => 'doc11', + '$permissions' => [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())], + 'string' => 'no_dates', + '$createdAt' => $customDate + ])); + + $newUpdatedAt = $doc11->getUpdatedAt(); + + $newDoc11 = new Document([ + 'string' => 'no_dates_update', + ]); + $updatedDoc7 = $database->updateDocument($collection, 'doc11', $newDoc11); + $this->assertNotEquals($newUpdatedAt, $updatedDoc7->getAttribute('$updatedAt')); + + $database->setPreserveDates(false); + $database->deleteCollection($collection); + } + + public function testBulkDocumentDateOperations(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $collection = 'bulk_date_operations'; + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('string', $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))->key); + + $database->setPreserveDates(true); + + $createDate = '2000-01-01T10:00:00.000+00:00'; + $updateDate = '2000-02-01T15:30:00.000+00:00'; + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + + // Test 1: Bulk create with different date configurations + $documents = [ + new Document([ + '$id' => 'doc1', + '$permissions' => $permissions, + 'string' => 'doc1', + '$createdAt' => $createDate + ]), + new Document([ + '$id' => 'doc2', + '$permissions' => $permissions, + 'string' => 'doc2', + '$updatedAt' => $updateDate + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'string' => 'doc3', + '$createdAt' => $createDate, + '$updatedAt' => $updateDate + ]), + new Document([ + '$id' => 'doc4', + '$permissions' => $permissions, + 'string' => 'doc4' + ]), + new Document([ + '$id' => 'doc5', + '$permissions' => $permissions, + 'string' => 'doc5', + '$createdAt' => null + ]), + new Document([ + '$id' => 'doc6', + '$permissions' => $permissions, + 'string' => 'doc6', + '$updatedAt' => null + ]) + ]; + + $database->createDocuments($collection, $documents); + + // Verify initial state + foreach (['doc1', 'doc3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + } + + foreach (['doc2', 'doc3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + } + + foreach (['doc4', 'doc5', 'doc6'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt missing for $id"); + $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt missing for $id"); + } + + // Test 2: Bulk update with custom dates + $updateDoc = new Document([ + 'string' => 'updated', + '$createdAt' => $createDate, + '$updatedAt' => $updateDate + ]); + $ids = []; + foreach ($documents as $doc) { + $ids[] = $doc->getId(); + } + $count = $database->updateDocuments($collection, $updateDoc, [ + Query::equal('$id', $ids) + ]); + $this->assertEquals(6, $count); + + foreach (['doc1', 'doc3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + $this->assertEquals('updated', $doc->getAttribute('string'), "string mismatch for $id"); + } + + foreach (['doc2', 'doc4','doc5','doc6'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + $this->assertEquals('updated', $doc->getAttribute('string'), "string mismatch for $id"); + } + + // Test 3: Bulk update with preserve dates disabled + $database->setPreserveDates(false); + + $customDate = 'should be ignored anyways so no error'; + $updateDocDisabled = new Document([ + 'string' => 'disabled_update', + '$createdAt' => $customDate, + '$updatedAt' => $customDate + ]); + + $countDisabled = $database->updateDocuments($collection, $updateDocDisabled); + $this->assertEquals(6, $countDisabled); + + // Test 4: Bulk update with preserve dates re-enabled + $database->setPreserveDates(true); + + $newDate = '2000-03-01T20:45:00.000+00:00'; + $updateDocEnabled = new Document([ + 'string' => 'enabled_update', + '$createdAt' => $newDate, + '$updatedAt' => $newDate + ]); + + $countEnabled = $database->updateDocuments($collection, $updateDocEnabled); + $this->assertEquals(6, $countEnabled); + + $database->setPreserveDates(false); + $database->deleteCollection($collection); + } + + public function testCreateUpdateDocumentsMismatch(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + // with different set of attributes + $colName = "docs_with_diff"; + $database->createCollection(Collection::create(id: $colName)); + $database->createAttribute($colName, Attribute::string(key: 'key', size: 50, required: true)); + $database->createAttribute($colName, Attribute::string(key: 'value', size: 50, default: 'value')); + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + $docs = [ + new Document([ + '$id' => 'doc1', + 'key' => 'doc1', + ]), + new Document([ + '$id' => 'doc2', + 'key' => 'doc2', + 'value' => 'test', + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'key' => 'doc3' + ]), + ]; + $this->assertEquals(3, $database->createDocuments($colName, $docs)); + // we should get only one document as read permission provided to the last document only + $addedDocs = $database->find($colName); + $this->assertCount(1, $addedDocs); + $doc = $addedDocs[0]; + $this->assertEquals('doc3', $doc->getId()); + $this->assertNotEmpty($doc->getPermissions()); + $this->assertCount(3, $doc->getPermissions()); + + $database->createDocument($colName, new Document([ + '$id' => 'doc4', + '$permissions' => $permissions, + 'key' => 'doc4' + ])); + + $this->assertEquals(2, $database->updateDocuments($colName, new Document(['key' => 'new doc']))); + $doc = $database->getDocument($colName, 'doc4'); + $this->assertEquals('doc4', $doc->getId()); + $this->assertEquals('value', $doc->getAttribute('value')); + + $addedDocs = $database->find($colName); + $this->assertCount(2, $addedDocs); + foreach ($addedDocs as $doc) { + $this->assertNotEmpty($doc->getPermissions()); + $this->assertCount(3, $doc->getPermissions()); + $this->assertEquals('value', $doc->getAttribute('value')); + } + $database->deleteCollection($colName); + } + + public function testBypassStructureWithSupportForAttributes(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + // for schemaless the validation will be automatically skipped + if (!$database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'successive_update_single'; + + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'attrA', size: 50, required: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'attrB', size: 50, required: true)); + + // bypass required + $database->setValidation(false); + + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $docs = $database->createDocuments($collectionId, [ + new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) + ]); + + $docs = $database->find($collectionId); + foreach ($docs as $doc) { + $this->assertArrayHasKey('attrA', $doc->getAttributes()); + $this->assertNull($doc->getAttribute('attrA')); + $this->assertEquals('B', $doc->getAttribute('attrB')); + } + // reset + $database->setValidation(true); + + try { + $database->createDocuments($collectionId, [ + new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + $database->deleteCollection($collectionId); + } + + public function testValidationGuardsWithNullRequired(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + return; + } + + // Base collection and attributes + $collection = 'validation_guard_all'; + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 32, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'age', required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'value')); + + // 1) createDocument with null required should fail when validation enabled, pass when disabled + try { + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], + 'name' => null, + 'age' => null, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + $database->setValidation(false); + $doc = $database->createDocument($collection, new Document([ + '$id' => 'created-null', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => null, + 'age' => null, + ])); + $this->assertEquals('created-null', $doc->getId()); + $database->setValidation(true); + + // Seed a valid document for updates + $valid = $database->createDocument($collection, new Document([ + '$id' => 'valid', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'ok', + 'age' => 10, + ])); + $this->assertEquals('valid', $valid->getId()); + + // 2) updateDocument set required to null should fail when validation enabled, pass when disabled + try { + $database->updateDocument($collection, 'valid', new Document([ + 'age' => null, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + $database->setValidation(false); + $updated = $database->updateDocument($collection, 'valid', new Document([ + 'age' => null, + ])); + $this->assertNull($updated->getAttribute('age')); + $database->setValidation(true); + + // Seed a few valid docs for bulk update + for ($i = 0; $i < 2; $i++) { + $database->createDocument($collection, new Document([ + '$id' => 'b' . $i, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'ok', + 'age' => 1, + ])); + } + + // 3) updateDocuments setting required to null should fail when validation enabled, pass when disabled + if ($this->supportsBulkWrites()) { + try { + $database->updateDocuments($collection, new Document([ + 'name' => null, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + $database->setValidation(false); + $count = $database->updateDocuments($collection, new Document([ + 'name' => null, + ])); + $this->assertGreaterThanOrEqual(3, $count); // at least the seeded docs are updated + $database->setValidation(true); + } + + // 4) upsertDocuments with an increase and a null required should fail when validation enabled, pass when disabled + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + try { + $database->upsertDocuments( + collection: $collection, + increase: 'value', + documents: [new Document([ + '$id' => 'u1', + 'name' => null, // required null + 'value' => 1, + ])] + ); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + $database->setValidation(false); + $ucount = $database->upsertDocuments( + collection: $collection, + increase: 'value', + documents: [new Document([ + '$id' => 'u1', + 'name' => null, + 'value' => 1, + ])] + ); + $this->assertEquals(1, $ucount); + $database->setValidation(true); + } + + // Cleanup + $database->deleteCollection($collection); + } + + /** + * The roles holding create, update and delete permission on the document. + * + * @return array + */ + private function writeRoles(Document $document): array + { + return \array_unique(\array_intersect( + $document->getPermissionsByType(PermissionType::Create), + $document->getPermissionsByType(PermissionType::Update), + $document->getPermissionsByType(PermissionType::Delete), + )); + } + + private function assertReadServedFromCache(Database $database, callable $read, string $message): void + { + $statements = $this->countStatements($database, $read); + if ($statements !== null) { + $this->assertSame(0, $statements, $message); + } + } + + private function assertReadReachesAdapter(Database $database, callable $read, string $message): void + { + $statements = $this->countStatements($database, $read); + if ($statements !== null) { + $this->assertGreaterThan(0, $statements, $message); + } + } + + /** + * Statements the adapter ran for $read, or null when the adapter reports none to the profiler. + */ + private function countStatements(Database $database, callable $read): ?int + { + if (! $database->getAdapter()->hasFeature(Feature\RawQuery::class)) { + $read(); + + return null; + } + + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + + try { + $profiler->reset(); + $read(); + + return $profiler->getQueryCount(); + } finally { + $database->setProfiling(false); + } + } + + public function testDropUnknownAttributes(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: __FUNCTION__)); + $this->assertSame('known', $database->createAttribute(__FUNCTION__, Attribute::string(key: 'known', size: 128))->key); + + try { + $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'strict', + '$permissions' => $permissions, + 'known' => 'kept', + 'unknown' => 'dropped', + ])); + $this->fail('Unknown attribute was accepted while dropping is disabled'); + } catch (StructureException $e) { + $this->assertEquals('Invalid document structure: Unknown attribute: "unknown"', $e->getMessage()); + } + + $this->assertSame(false, $database->isDroppingUnknownAttributes()); + $database->setDropUnknownAttributes(true); + $this->assertSame(true, $database->isDroppingUnknownAttributes()); + + try { + $collection = $database->getCollection(__FUNCTION__); + $encoded = $database->encode($collection, new Document([ + '$id' => 'encoded', + '$collection' => __FUNCTION__, + 'known' => 'kept', + 'unknown' => 'dropped', + ])); + $this->assertEquals('kept', $encoded->getAttribute('known')); + $this->assertNull($encoded->getAttribute('unknown'), 'Unknown attribute survived encode'); + + $created = $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'lenient', + '$permissions' => $permissions, + 'known' => 'kept', + 'unknown' => 'dropped', + ])); + + $this->assertEquals('kept', $created->getAttribute('known')); + $this->assertNull($created->getAttribute('unknown'), 'Unknown attribute survived the create'); + + $database->purgeCachedDocument(__FUNCTION__, 'lenient'); + $stored = $database->getDocument(__FUNCTION__, 'lenient'); + $this->assertEquals('kept', $stored->getAttribute('known')); + $this->assertNull($stored->getAttribute('unknown'), 'Unknown attribute reached storage on create'); + + $updated = $database->updateDocument(__FUNCTION__, 'lenient', new Document([ + '$id' => 'lenient', + '$permissions' => $permissions, + 'known' => 'changed', + 'unknown' => 'dropped', + ])); + + $this->assertEquals('changed', $updated->getAttribute('known')); + $this->assertNull($updated->getAttribute('unknown'), 'Unknown attribute survived the update'); + + $database->purgeCachedDocument(__FUNCTION__, 'lenient'); + $stored = $database->getDocument(__FUNCTION__, 'lenient'); + $this->assertEquals('changed', $stored->getAttribute('known')); + $this->assertNull($stored->getAttribute('unknown'), 'Unknown attribute reached storage on update'); + + \usleep(5000); + + $unchanged = $database->updateDocument(__FUNCTION__, 'lenient', new Document([ + '$id' => 'lenient', + '$permissions' => $permissions, + 'known' => 'changed', + 'unknown' => 'dropped', + ])); + + $this->assertEquals( + $stored->getUpdatedAt(), + $unchanged->getUpdatedAt(), + 'A write carrying only a dropped attribute counted as a change' + ); + } finally { + $database->setDropUnknownAttributes(false); + } + + $this->assertSame(false, $database->isDroppingUnknownAttributes()); + } + + public function testCreateDocumentWithBigIntType(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + $this->assertSame('bigint_signed', $database->createAttribute(__FUNCTION__, Attribute::bigInteger(key: 'bigint_signed', required: true))->key); + $this->assertSame('bigint_unsigned', $database->createAttribute(__FUNCTION__, Attribute::bigInteger(key: 'bigint_unsigned', required: true, signed: false))->key); + + $document = $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'bigint-type-doc', + '$permissions' => [Permission::read(Role::any())], + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, + ])); + + $this->assertIsInt($document->getAttribute('bigint_signed')); + $this->assertSame(-Database::MAX_BIG_INT, $document->getAttribute('bigint_signed')); + $this->assertIsInt($document->getAttribute('bigint_unsigned')); + $this->assertSame(Database::MAX_BIG_INT, $document->getAttribute('bigint_unsigned')); + + $results = $database->find(__FUNCTION__, [ + Query::equal('bigint_unsigned', [Database::MAX_BIG_INT]), + ]); + $this->assertCount(1, $results); + $this->assertSame('bigint-type-doc', $results[0]->getId()); + + $database->deleteCollection(__FUNCTION__); + } + + public function testBigIntScenariosWithFiltering(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::UnsignedBigInt)) { + $this->markTestSkipped('Adapter does not support unsigned bigint'); + } + + $collection = 'bigint_scenarios_filters'; + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('signed_bigint', $database->createAttribute($collection, Attribute::bigInteger(key: 'signed_bigint', required: true))->key); + $this->assertSame('unsigned_bigint', $database->createAttribute($collection, Attribute::bigInteger(key: 'unsigned_bigint', required: true, signed: false))->key); + + $collectionDoc = $database->getCollection($collection); + $this->assertSame($collection, $collectionDoc->getId()); + + $signedAttribute = null; + $unsignedAttribute = null; + foreach ($collectionDoc->attributes() as $attribute) { + if ($attribute->key === 'signed_bigint') { + $signedAttribute = $attribute; + } + if ($attribute->key === 'unsigned_bigint') { + $unsignedAttribute = $attribute; + } + } + + $this->assertNotNull($signedAttribute); + $this->assertNotNull($unsignedAttribute); + $this->assertSame(0, $signedAttribute->toDocument()->getAttribute('size')); + $this->assertSame(0, $unsignedAttribute->toDocument()->getAttribute('size')); + + $beyond32Bit = '2147483648'; + $signedMax = (string) \PHP_INT_MAX; + $signedMin = (string) \PHP_INT_MIN; + $unsignedValue = '18446744073709551615'; + + $document = $database->createDocument($collection, new Document([ + '$id' => 'bigint-scenarios-doc', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'signed_bigint' => $beyond32Bit, + 'unsigned_bigint' => $unsignedValue, + ])); + + $this->assertIsInt($document->getAttribute('signed_bigint'), 'A numeric string inside the signed range normalises to a PHP int'); + $this->assertSame((int) $beyond32Bit, $document->getAttribute('signed_bigint')); + + $this->assertIsString($document->getAttribute('unsigned_bigint'), 'A value past PHP_INT_MAX has to stay a string'); + $this->assertSame($unsignedValue, $document->getAttribute('unsigned_bigint')); + + $fetched = $database->getDocument($collection, $document->getId()); + $this->assertSame($unsignedValue, $fetched->getAttribute('unsigned_bigint')); + + $updated = $database->updateDocument($collection, $document->getId(), new Document([ + 'signed_bigint' => $signedMax, + ])); + $this->assertIsInt($updated->getAttribute('signed_bigint')); + $this->assertSame((int) $signedMax, $updated->getAttribute('signed_bigint')); + + $resultIntFilter = $database->find($collection, [ + Query::equal('signed_bigint', [(int) $signedMax]), + ]); + $this->assertCount(1, $resultIntFilter); + $this->assertSame('bigint-scenarios-doc', $resultIntFilter[0]->getId()); + + $resultStringFilter = $database->find($collection, [ + Query::equal('signed_bigint', [$signedMax]), + ]); + $this->assertCount(1, $resultStringFilter); + $this->assertSame('bigint-scenarios-doc', $resultStringFilter[0]->getId()); + + $resultUnsignedFilter = $database->find($collection, [ + Query::equal('unsigned_bigint', [$unsignedValue]), + ]); + $this->assertCount(1, $resultUnsignedFilter); + $this->assertSame('bigint-scenarios-doc', $resultUnsignedFilter[0]->getId()); + + $updatedMin = $database->updateDocument($collection, $document->getId(), new Document([ + 'signed_bigint' => $signedMin, + ])); + $this->assertIsInt($updatedMin->getAttribute('signed_bigint')); + $this->assertSame((int) $signedMin, $updatedMin->getAttribute('signed_bigint')); + + $database->deleteCollection($collection); + } + + public function testWithSignedBigInt(): void + { + $database = $this->getDatabase(); + + $collection = 'signed_bigint_only'; + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('signed_bigint', $database->createAttribute($collection, Attribute::bigInteger(key: 'signed_bigint', required: true))->key); + + $document = $database->createDocument($collection, new Document([ + '$id' => 'signed-bigint-doc', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'signed_bigint' => \PHP_INT_MAX, + ])); + + $this->assertIsInt($document->getAttribute('signed_bigint')); + $this->assertSame(\PHP_INT_MAX, $document->getAttribute('signed_bigint')); + + $updated = $database->updateDocument($collection, $document->getId(), new Document([ + 'signed_bigint' => \PHP_INT_MIN, + ])); + + $this->assertIsInt($updated->getAttribute('signed_bigint')); + $this->assertSame(\PHP_INT_MIN, $updated->getAttribute('signed_bigint')); + + $results = $database->find($collection, [ + Query::equal('signed_bigint', [\PHP_INT_MIN]), + ]); + $this->assertCount(1, $results); + $this->assertSame('signed-bigint-doc', $results[0]->getId()); + + $database->deleteCollection($collection); + } + + public function testCreateUpdateBigIntAndIncrementDecrement(): void + { + $database = $this->getDatabase(); + + $collection = 'bigint_update_increase_decrease'; + $database->createCollection(Collection::create(id: $collection)); + + $this->assertSame('inc', $database->createAttribute($collection, Attribute::bigInteger(key: 'inc', required: true))->key); + $this->assertSame('dec', $database->createAttribute($collection, Attribute::bigInteger(key: 'dec', required: true))->key); + + $document = $database->createDocument($collection, new Document([ + 'inc' => 10, + 'dec' => 10, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); + + $this->assertIsInt($document->getAttribute('inc')); + $this->assertSame(10, $document->getAttribute('inc')); + + $updated = $database->updateDocument($collection, $document->getId(), new Document([ + 'inc' => 20, + ])); + $this->assertSame(20, $updated->getAttribute('inc')); + + $afterIncrease = $database->increaseDocumentAttribute($collection, $document->getId(), 'inc', 5, 30); + $this->assertSame(25, $afterIncrease->getAttribute('inc')); + + $afterDecrease = $database->decreaseDocumentAttribute($collection, $document->getId(), 'dec', 3, 7); + $this->assertSame(7, $afterDecrease->getAttribute('dec')); + + $database->deleteCollection($collection); + } + + public function testCreateDocumentsIgnoreDuplicates(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'name', size: 128, required: true)); + + $database->createDocuments(__FUNCTION__, [ + new Document([ + '$id' => 'doc1', + 'name' => 'Original A', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + new Document([ + '$id' => 'doc2', + 'name' => 'Original B', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + ]); + + try { + $database->createDocuments(__FUNCTION__, [ + new Document([ + '$id' => 'doc1', + 'name' => 'Duplicate A', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + ]); + $this->fail('Expected DuplicateException'); + } catch (DuplicateException $e) { + $this->assertNotEmpty($e->getMessage()); + } + + $emittedIds = []; + $collection = __FUNCTION__; + $count = $database->ignoreDuplicates(function () use ($database, $collection, &$emittedIds) { + return $database->createDocuments($collection, [ + new Document([ + '$id' => 'doc1', + 'name' => 'Duplicate A', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + new Document([ + '$id' => 'doc3', + 'name' => 'New C', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + ], onNext: function (Document $doc) use (&$emittedIds) { + $emittedIds[] = $doc->getId(); + }); + }); + + $this->assertSame(1, $count, 'A skipped duplicate is not counted as created'); + $this->assertSame(['doc3'], $emittedIds, 'A skipped duplicate is not handed to onNext'); + + $doc1 = $database->getDocument(__FUNCTION__, 'doc1'); + $this->assertSame('Original A', $doc1->getAttribute('name')); + + $doc3 = $database->getDocument(__FUNCTION__, 'doc3'); + $this->assertSame('New C', $doc3->getAttribute('name')); + + $this->assertCount(3, $database->find(__FUNCTION__)); + + $database->deleteCollection(__FUNCTION__); + } + + public function testCreateDocumentsIgnoreAllDuplicates(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'name', size: 128, required: true)); + + $database->createDocuments(__FUNCTION__, [ + new Document([ + '$id' => 'existing', + 'name' => 'Original', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + ]); + + $emittedIds = []; + $collection = __FUNCTION__; + $count = $database->ignoreDuplicates(function () use ($database, $collection, &$emittedIds) { + return $database->createDocuments($collection, [ + new Document([ + '$id' => 'existing', + 'name' => 'Duplicate', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + ], onNext: function (Document $doc) use (&$emittedIds) { + $emittedIds[] = $doc->getId(); + }); + }); + + $this->assertSame(0, $count, 'A skipped duplicate is not counted as created'); + $this->assertSame([], $emittedIds, 'A skipped duplicate is not handed to onNext'); + + $doc = $database->getDocument(__FUNCTION__, 'existing'); + $this->assertSame('Original', $doc->getAttribute('name')); + + $this->assertCount(1, $database->find(__FUNCTION__)); + + $database->deleteCollection(__FUNCTION__); + } + + public function testCreateDocumentsSkipDuplicatesEmptyBatch(): void + { + $database = $this->getDatabase(); + + $collection = 'skipDupEmpty'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + + $count = $database->ignoreDuplicates(fn () => $database->createDocuments($collection, [])); + + $this->assertSame(0, $count); + $this->assertCount(0, $database->find($collection)); + + $database->deleteCollection($collection); + } + + public function testCreateDocumentsSkipDuplicatesNestedScope(): void + { + $database = $this->getDatabase(); + + $collection = 'skipDupNested'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + + $makeDoc = fn (string $id, string $name) => new Document([ + '$id' => $id, + 'name' => $name, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); + + $database->createDocuments($collection, [$makeDoc('seed', 'Seed')]); + + $countOuter = $database->ignoreDuplicates(function () use ($database, $collection, $makeDoc) { + $countInner = $database->ignoreDuplicates(function () use ($database, $collection, $makeDoc) { + return $database->createDocuments($collection, [ + $makeDoc('seed', 'Dup'), + $makeDoc('innerNew', 'InnerNew'), + ]); + }); + $this->assertSame(1, $countInner); + + return $database->createDocuments($collection, [ + $makeDoc('seed', 'Dup2'), + $makeDoc('outerNew', 'OuterNew'), + ]); + }); + $this->assertSame(1, $countOuter, 'Leaving the inner scope must keep the outer scope skipping duplicates'); + + $thrown = null; + try { + $database->createDocuments($collection, [$makeDoc('seed', 'ShouldThrow')]); + } catch (DuplicateException $e) { + $thrown = $e; + } + $this->assertNotNull($thrown, 'Plain createDocuments after nested scopes should throw on duplicate'); + + $all = $database->find($collection); + $ids = \array_map(fn (Document $d) => $d->getId(), $all); + \sort($ids); + $this->assertSame(['innerNew', 'outerNew', 'seed'], $ids); + + $database->deleteCollection($collection); + } + + public function testCreateDocumentsSkipDuplicatesLargeBatch(): void + { + $database = $this->getDatabase(); + + $collection = 'skipDupLarge'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::integer(key: 'idx', required: true)); + + $seed = []; + for ($i = 0; $i < 50; $i++) { + $seed[] = new Document([ + '$id' => 'doc_'.$i, + 'idx' => $i, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); + } + $database->createDocuments($collection, $seed); + + $batch = []; + for ($i = 0; $i < 300; $i++) { + $batch[] = new Document([ + '$id' => 'doc_'.$i, + 'idx' => $i + 1000, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); + } + + $emittedIds = []; + $count = $database->ignoreDuplicates(function () use ($database, $collection, $batch, &$emittedIds) { + return $database->createDocuments($collection, $batch, batchSize: 100, onNext: function (Document $doc) use (&$emittedIds) { + $emittedIds[] = $doc->getId(); + }); + }); + + $this->assertSame(250, $count, 'The 50 seeded ids are skipped and not counted'); + $this->assertCount(250, $emittedIds); + $this->assertNotContains('doc_25', $emittedIds); + + $seedDoc = $database->getDocument($collection, 'doc_25'); + $this->assertSame(25, $seedDoc->getAttribute('idx'), 'An existing row must not be overwritten by its duplicate'); + + $newDoc = $database->getDocument($collection, 'doc_100'); + $this->assertSame(1100, $newDoc->getAttribute('idx')); + + $this->assertSame(300, $database->count($collection)); + + $database->deleteCollection($collection); + } + + public function testCreateDocumentsSkipDuplicatesSecondCallSkipsAll(): void + { + $database = $this->getDatabase(); + + $collection = 'skipDupSecond'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); + + $makeBatch = fn (string $name) => \array_map( + fn (string $id) => new Document([ + '$id' => $id, + 'name' => $name, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + ['a', 'b', 'c'] + ); + + $firstCount = $database->ignoreDuplicates( + fn () => $database->createDocuments($collection, $makeBatch('First')) + ); + $this->assertSame(3, $firstCount); + + $emittedIds = []; + $secondCount = $database->ignoreDuplicates(function () use ($database, $collection, $makeBatch, &$emittedIds) { + return $database->createDocuments($collection, $makeBatch('Second'), onNext: function (Document $doc) use (&$emittedIds) { + $emittedIds[] = $doc->getId(); + }); + }); + $this->assertSame(0, $secondCount, 'A batch of stored ids creates nothing'); + $this->assertSame([], $emittedIds); + + foreach (['a', 'b', 'c'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertSame('First', $doc->getAttribute('name'), "Doc {$id} should not have been overwritten"); + } + + $database->deleteCollection($collection); + } + + public function testCreateDocumentsSkipDuplicatesRelationships(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parent = 'skipDupParent'; + $child = 'skipDupChild'; + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parent)); + $database->createCollection(Collection::create(id: $child)); + $database->createAttribute($parent, Attribute::string(key: 'name', size: 128, required: true)); + $database->createAttribute($child, Attribute::string(key: 'name', size: 128, required: true)); + $database->createRelationship($parent, Relationship::oneToMany( + relatedCollection: $child, + key: 'children', + )); + + $database->createDocument($parent, new Document([ + '$id' => 'existingParent', + 'name' => 'ExistingParent', + '$permissions' => $permissions, + 'children' => [ + new Document([ + '$id' => 'existingChild', + 'name' => 'ExistingChild', + '$permissions' => $permissions, + ]), + ], + ])); + + $batch = [ + new Document([ + '$id' => 'existingParent', + 'name' => 'ShouldNotOverwrite', + '$permissions' => $permissions, + 'children' => [ + new Document([ + '$id' => 'existingChild', + 'name' => 'ExistingChild', + '$permissions' => $permissions, + ]), + new Document([ + '$id' => 'retryChild', + 'name' => 'RetryChild', + '$permissions' => $permissions, + ]), + ], + ]), + new Document([ + '$id' => 'newParent', + 'name' => 'NewParent', + '$permissions' => $permissions, + 'children' => [ + new Document([ + '$id' => 'newChild', + 'name' => 'NewChild', + '$permissions' => $permissions, + ]), + ], + ]), + ]; + + $database->ignoreDuplicates(fn () => $database->createDocuments($parent, $batch)); + + $existing = $database->getDocument($parent, 'existingParent'); + $this->assertFalse($existing->isEmpty()); + $this->assertSame('ExistingParent', $existing->getAttribute('name')); + + /** @var array $existingChildren */ + $existingChildren = $existing->getAttribute('children', []); + $childIds = \array_map(fn (Document $d) => $d->getId(), $existingChildren); + \sort($childIds); + $this->assertSame(['existingChild', 'retryChild'], $childIds, 'A skipped parent must still receive its new children'); + + $new = $database->getDocument($parent, 'newParent'); + $this->assertFalse($new->isEmpty()); + $this->assertSame('NewParent', $new->getAttribute('name')); + /** @var array $newChildren */ + $newChildren = $new->getAttribute('children', []); + $this->assertCount(1, $newChildren); + $this->assertSame('newChild', $newChildren[0]->getId()); + + $allChildren = $database->find($child); + $allChildIds = \array_map(fn (Document $d) => $d->getId(), $allChildren); + \sort($allChildIds); + $this->assertSame(['existingChild', 'newChild', 'retryChild'], $allChildIds); + + $database->deleteCollection($parent); + $database->deleteCollection($child); + } + + public function testDateTimeArrayDocument(): void + { + $database = $this->getDatabase(); + + $collection = 'datetime_array_doc'; + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('dates', $database->createAttribute($collection, Attribute::datetime(key: 'dates', array: true))->key); + + $d1 = '2000-01-01T10:00:00.000+00:00'; + $d2 = '2001-02-03T05:06:07.000+00:00'; + + $database->createDocument($collection, new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'dates' => [$d1, $d2], + ])); + + $doc = $database->getDocument($collection, 'doc1'); + $dates = $doc->getAttribute('dates'); + $this->assertIsArray($dates); + $this->assertCount(2, $dates); + $this->assertSame($d1, $dates[0]); + $this->assertSame($d2, $dates[1]); + + $found = $database->find($collection, [Query::equal('$id', ['doc1'])]); + $this->assertCount(1, $found); + $this->assertSame([$d1, $d2], $found[0]->getAttribute('dates')); + + $database->deleteCollection($collection); + } + + /** + * SQL adapters store columns under filter(attributeId). After getDocument + decode, and after + * updateDocument (return value + refetch), the document must expose only schema ids, never the + * filtered storage alias. + */ + public function testDottedAttributeKeyGetDocumentExposesOnlySchemaKeys(): void + { + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionId = 'dotkey_'.Id::unique(); + $attributeId = 'pb.e_DSS.FIRMWARE_VERSION'; + $filteredStorageKey = $database->getAdapter()->filter($attributeId); + + $database->createCollection(Collection::create(id: $collectionId)); + $this->assertSame($attributeId, $database->createAttribute($collectionId, Attribute::string(key: $attributeId, size: 128))->key); + + $database->createDocument($collectionId, new Document([ + '$id' => 'dev1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ])); + + $assertOnlySchemaKeys = function (Document $document, ?string $expected) use ($attributeId, $filteredStorageKey): void { + $this->assertSame($expected, $document->getAttribute($attributeId)); + $this->assertArrayNotHasKey($filteredStorageKey, $document->getAttributes()); + $userKeys = \array_keys($document->getAttributes()); + \sort($userKeys); + $this->assertSame([$attributeId], $userKeys); + }; + + $doc = $database->getDocument($collectionId, 'dev1'); + $this->assertSame('dev1', $doc->getId()); + $assertOnlySchemaKeys($doc, null); + + $updated = $database->updateDocument($collectionId, 'dev1', new Document([ + $attributeId => '1.0.0', + ])); + $assertOnlySchemaKeys($updated, '1.0.0'); + $assertOnlySchemaKeys($database->getDocument($collectionId, 'dev1'), '1.0.0'); + + $updated = $database->updateDocument($collectionId, 'dev1', new Document([ + $attributeId => '2.0.0', + ])); + $assertOnlySchemaKeys($updated, '2.0.0'); + $assertOnlySchemaKeys($database->getDocument($collectionId, 'dev1'), '2.0.0'); + + $database->deleteCollection($collectionId); + } + + /** + * A batch insert stamps every row with the same `$createdAt`, so ordering by that timestamp + * alone leaves the order of those rows to the tie break Database::find() appends. The tie + * break follows the direction of the leading timestamp, so a descending order returns the + * batch newest-inserted first. + */ + public function testFindOrderByCreateDateTieBreak(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: 'tieBreak', attributes: [ + Attribute::string(key: 'name', size: 128, required: true), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + $documents = []; + foreach (['alpha', 'bravo', 'charlie'] as $name) { + $documents[] = new Document([ + 'name' => $name, + '$permissions' => [Permission::read(Role::any())], + ]); + } + + $this->assertSame(3, $database->createDocuments('tieBreak', $documents)); + + $created = $database->find('tieBreak', [Query::orderAsc('$sequence')]); + $this->assertCount(3, $created); + + $this->assertSame($created[0]->getCreatedAt(), $created[2]->getCreatedAt(), 'The batch must share one timestamp, otherwise the tie break is never consulted'); + + $descending = $database->find('tieBreak', [Query::orderDesc('$createdAt')]); + $this->assertSame( + [$created[2]->getSequence(), $created[1]->getSequence(), $created[0]->getSequence()], + \array_map(fn (Document $document) => $document->getSequence(), $descending) + ); + + $ascending = $database->find('tieBreak', [Query::orderAsc('$createdAt')]); + $this->assertSame( + [$created[0]->getSequence(), $created[1]->getSequence(), $created[2]->getSequence()], + \array_map(fn (Document $document) => $document->getSequence(), $ascending) + ); + + $byName = $database->find('tieBreak', [ + Query::orderDesc('$createdAt'), + Query::orderAsc('name'), + ]); + $this->assertSame( + ['alpha', 'bravo', 'charlie'], + \array_map(fn (Document $document) => $document->getAttribute('name'), $byName), + 'The tie break sits behind the caller\'s own order, so name still decides the rows the timestamp ties' + ); + + $database->deleteCollection('tieBreak'); + } + + public function testIntegersBeyondInt32(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__, attributes: [ + Attribute::integer(key: 'amount', width: IntegerWidth::Bits64, required: true), + Attribute::integer(key: 'amounts', width: IntegerWidth::Bits64, required: true, array: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], documentSecurity: false)); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'row1', + 'amount' => 2000000000, + 'amounts' => [-3408048000, -42, 3408048000, Database::MAX_BIG_INT], + ])); + $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'row2', + 'amount' => 2000000000, + 'amounts' => [-42], + ])); + + $paths = [ + 'getDocument' => $database->getDocument(__FUNCTION__, 'row1'), + 'find' => $database->find(__FUNCTION__, [Query::equal('$id', ['row1'])])[0], + ]; + + foreach ($paths as $path => $document) { + $this->assertIsInt($document->getAttribute('amount'), $path.' returned a non-int scalar'); + + $amounts = $document->getAttribute('amounts'); + $this->assertIsArray($amounts); + foreach ($amounts as $index => $amount) { + $this->assertIsInt($amount, $path.' returned a non-int at amounts['.$index.']'); + } + + $this->assertSame([-3408048000, -42, 3408048000, Database::MAX_BIG_INT], $amounts); + + $this->assertSame( + '{"amount":2000000000,"amounts":[-3408048000,-42,3408048000,'.Database::MAX_BIG_INT.']}', + \json_encode([ + 'amount' => $document->getAttribute('amount'), + 'amounts' => $amounts, + ]), + $path.' did not serialise as plain JSON numbers' + ); + } + + $sum = $database->sum(__FUNCTION__, 'amount'); + $this->assertIsInt($sum, 'A total past int32 must come back as a native integer'); + $this->assertSame(4000000000, $sum); + + $database->deleteCollection(__FUNCTION__); + } + + public function testInvalidCreatedAndUpdatedAtThrowStructureException(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'invalid_date_attributes'; + + $database->createCollection(Collection::create(id: $collection)); + $this->assertSame('string', $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))->key); + + $database->setPreserveDates(true); + + try { + $invalidDate = '10000-01-01T00:00:00.000+00:00'; + + try { + $database->createDocument($collection, new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + '$createdAt' => $invalidDate, + ])); + $this->fail('Expected StructureException for invalid $createdAt'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + $database->createDocument($collection, new Document([ + '$id' => 'doc2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'string' => 'x', + ])); + + try { + $database->updateDocument($collection, 'doc2', new Document([ + '$updatedAt' => $invalidDate, + ])); + $this->fail('Expected StructureException for invalid $updatedAt'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + } finally { + $database->setPreserveDates(false); + $database->deleteCollection($collection); + } + } + + /** + * When a document's UID changes on update, its permission rows must follow the new UID. + * Otherwise the old rows are orphaned and the renamed document is left with no permissions, + * even when the permission set itself was not changed. + */ + public function testUpdateDocumentChangeIdMigratesPermissions(): void + { + $database = $this->getDatabase(); + $auth = $database->getAuthorization(); + + $collection = 'update_change_id_perms'; + + try { + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 128), + ], permissions: [], documentSecurity: true)); + + $document = $auth->skip(fn () => $database->createDocument($collection, new Document([ + '$id' => 'old_id', + 'name' => 'test', + '$permissions' => [ + Permission::read(Role::user('alice')), + Permission::update(Role::user('alice')), + Permission::delete(Role::user('alice')), + ], + ]))); + $this->assertSame('old_id', $document->getId()); + + $auth->addRole(Role::user('alice')->toString()); + $visible = $database->find($collection); + $this->assertCount(1, $visible, 'As alice the document is visible through the permission rows'); + + $renamed = $auth->skip(fn () => $database->updateDocument($collection, 'old_id', new Document(\array_merge( + $document->getArrayCopy(), + ['$id' => 'new_id'], + )))); + $this->assertSame('new_id', $renamed->getId()); + + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'old_id'))->isEmpty()); + + $newDoc = $auth->skip(fn () => $database->getDocument($collection, 'new_id')); + $this->assertFalse($newDoc->isEmpty()); + $this->assertContains(Permission::read(Role::user('alice')), $newDoc->getPermissions()); + + $found = $database->find($collection); + $this->assertCount(1, $found, 'The permission rows must have migrated to the new UID, otherwise the row is unreadable'); + $this->assertSame('new_id', $found[0]->getId()); + + $rekeyed = $auth->skip(fn () => $database->updateDocument($collection, 'new_id', new Document(\array_merge( + $newDoc->getArrayCopy(), + [ + '$id' => 'final_id', + '$permissions' => [ + Permission::read(Role::user('bob')), + Permission::read(Role::user('bob')), + Permission::update(Role::user('bob')), + Permission::delete(Role::user('bob')), + ], + ], + )))); + $this->assertSame('final_id', $rekeyed->getId()); + + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'new_id'))->isEmpty()); + + $finalDoc = $auth->skip(fn () => $database->getDocument($collection, 'final_id')); + $this->assertFalse($finalDoc->isEmpty()); + $this->assertContains(Permission::read(Role::user('bob')), $finalDoc->getPermissions()); + $this->assertNotContains(Permission::read(Role::user('alice')), $finalDoc->getPermissions()); + + $foundAsAlice = $database->find($collection); + $this->assertCount(0, $foundAsAlice, 'The removed permission rows must be gone'); + + $auth->addRole(Role::user('bob')->toString()); + $foundAsBob = $database->find($collection); + $this->assertCount(1, $foundAsBob, 'The added permission rows must land under the new UID'); + $this->assertSame('final_id', $foundAsBob[0]->getId()); + } finally { + $auth->removeRole(Role::user('alice')->toString()); + $auth->removeRole(Role::user('bob')->toString()); + + $auth->skip(fn () => $database->deleteCollection($collection)); + } + } + + public function testArrayContainsQueriesOnScalarArrays(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'array_contains_scalars'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'labels', size: 32, array: true), + Attribute::integer(key: 'numbers', array: true), + Attribute::double(key: 'scores', array: true), + Attribute::boolean(key: 'flags', array: true), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + try { + $documents = [ + 't1' => ['labels' => ['a', 'b'], 'numbers' => [1, 2], 'scores' => [0.1, 1.5], 'flags' => [true]], + 't2' => ['labels' => ['c'], 'numbers' => [3], 'scores' => [2.5], 'flags' => [false]], + 't3' => ['labels' => [], 'numbers' => [], 'scores' => [], 'flags' => []], + 't4' => ['labels' => ['é', 'q"x', '1'], 'numbers' => [10], 'scores' => [], 'flags' => []], + 't5' => [], + ]; + foreach ($documents as $id => $attributes) { + $database->createDocument($collection, new Document(['$id' => $id, ...$attributes])); + } + + $cases = [ + 'containsAny strings' => [Query::containsAny('labels', ['a', 'c']), ['t1', 't2']], + 'containsAny a non-ASCII string' => [Query::containsAny('labels', ['é']), ['t4']], + 'containsAny a string with a double quote' => [Query::containsAny('labels', ['q"x']), ['t4']], + 'containsAny a numeric string' => [Query::containsAny('labels', ['1']), ['t4']], + 'containsAny integers' => [Query::containsAny('numbers', [2, 3]), ['t1', 't2']], + 'containsAny doubles' => [Query::containsAny('scores', [0.1, 2.5]), ['t1', 't2']], + 'containsAny true' => [Query::containsAny('flags', [true]), ['t1']], + 'containsAny false' => [Query::containsAny('flags', [false]), ['t2']], + 'containsAny no element' => [Query::containsAny('labels', ['z']), []], + 'containsAll every string present' => [Query::containsAll('labels', ['a', 'b']), ['t1']], + 'containsAll one string missing' => [Query::containsAll('labels', ['a', 'c']), []], + 'containsAll non-ASCII and quoted strings' => [Query::containsAll('labels', ['é', 'q"x']), ['t4']], + 'containsAll integers' => [Query::containsAll('numbers', [1, 2]), ['t1']], + 'containsAll doubles' => [Query::containsAll('scores', [0.1, 1.5]), ['t1']], + 'containsAll a boolean' => [Query::containsAll('flags', [false]), ['t2']], + 'notContains a string' => [Query::notContains('labels', ['a']), ['t2', 't3', 't4']], + 'notContains any of several strings' => [Query::notContains('labels', ['a', 'c']), ['t3', 't4']], + 'notContains a non-ASCII string' => [Query::notContains('labels', ['é']), ['t1', 't2', 't3']], + 'notContains an integer' => [Query::notContains('numbers', [1]), ['t2', 't3', 't4']], + 'notContains a double' => [Query::notContains('scores', [2.5]), ['t1', 't3', 't4']], + 'notContains a boolean' => [Query::notContains('flags', [true]), ['t2', 't3', 't4']], + 'contains a string' => [new Query(Method::Contains, 'labels', ['a']), ['t1']], + 'contains integers' => [new Query(Method::Contains, 'numbers', [3, 10]), ['t2', 't4']], + ]; + foreach ($cases as $case => [$query, $expected]) { + $countQuery = clone $query; + $ids = \array_map( + fn (Document $document): string => $document->getId(), + $database->find($collection, [$query]), + ); + \sort($ids); + + $this->assertSame($expected, $ids, $case); + $this->assertSame(\count($expected), $database->count($collection, [$countQuery]), $case); + } + } finally { + $database->deleteCollection($collection); + } + } + + /** + * A json value stored with a non-string permission (7.x accepted it) stays readable cold and + * cached, through find(), and does not block an update of another attribute. + */ + public function testStoredJsonValueWithANonStringPermissionStaysReadable(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $collection = 'stored_json_permissions'; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'prefs', size: 1024, filters: [Filter::Json]), + ], + permissions: [Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + + try { + $database->getAuthorization()->skip(fn (): Document => $database->getAdapter()->createDocument($database->getCollection($collection), new Document([ + '$id' => 'legacy', + ...($database->hasSharedTables() ? ['$tenant' => $database->getTenant()] : []), + '$permissions' => [], + '$createdAt' => DateTime::now(), + '$updatedAt' => DateTime::now(), + 'name' => 'Ada', + 'prefs' => '{"$id":"x","$permissions":["read(\\"any\\")",42],"theme":"dark"}', + ]))); + + foreach (['cold', 'cached'] as $read) { + $prefs = $database->getDocument($collection, 'legacy')->getAttribute('prefs'); + $this->assertInstanceOf(Document::class, $prefs, $read); + $this->assertSame('dark', $prefs->getAttribute('theme'), $read); + $this->assertSame([Permission::read(Role::any())], $prefs->getPermissions(), $read); + } + + $found = $database->find($collection); + $this->assertCount(1, $found); + $this->assertInstanceOf(Document::class, $found[0]->getAttribute('prefs')); + + $renamed = $database->updateDocument($collection, 'legacy', new Document(['name' => 'Grace'])); + $this->assertSame('Grace', $renamed->getAttribute('name')); + $this->assertSame('Grace', $database->getDocument($collection, 'legacy')->getAttribute('name')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testSkipDuplicatesNeverGrantsAnExistingDocument(): void + { + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $collection = 'skipDupGrants'; + + $database->createCollection(Collection::create( + id: $collection, + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $database->createAttribute($collection, Attribute::integer(key: 'rank', required: true)); + + $readableIds = fn (): array => \array_map( + fn (Document $document): string => $document->getId(), + $database->find($collection, [Query::orderAsc('$id')]), + ); + + try { + $database->createDocument($collection, new Document([ + '$id' => 'existing', + '$permissions' => [Permission::read(Role::user('alice'))], + 'rank' => 5, + ])); + + $emittedIds = []; + $created = $database->ignoreDuplicates(function () use ($database, $collection, &$emittedIds): int { + return $database->createDocuments($collection, [ + new Document([ + '$id' => 'existing', + '$permissions' => [Permission::read(Role::any())], + 'rank' => 7, + ]), + new Document([ + '$id' => 'fresh', + '$permissions' => [Permission::read(Role::any())], + 'rank' => 3, + ]), + ], onNext: function (Document $document) use (&$emittedIds): void { + $emittedIds[] = $document->getId(); + }); + }); + + $this->assertSame(1, $created, 'A skipped duplicate is not counted as created'); + $this->assertSame(['fresh'], $emittedIds, 'A skipped duplicate is not handed to onNext'); - foreach ($legitimatePatterns as $pattern) { - try { - $results = $database->find($collectionName, [ - Query::regex('text', $pattern), - ]); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); - $this->assertIsArray($results); + $this->assertSame(['fresh'], $readableIds(), 'A guest must not find a document only alice may read'); + $this->assertSame(1, $database->count($collection), 'A guest must not count a document only alice may read'); + $this->assertSame(3, (int) $database->sum($collection, 'rank'), 'A guest must not sum a document only alice may read'); + $this->assertTrue($database->getDocument($collection, 'existing')->isEmpty()); - // Verify each result actually matches - foreach ($results as $doc) { - $text = $doc->getAttribute('text'); - $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); - if ($matches !== false) { - $this->assertEquals( - 1, - $matches, - "Document '{$text}' should match pattern '{$pattern}'" - ); - } - } - } catch (\Exception $e) { - $this->fail("Legitimate pattern '{$pattern}' should not throw exception: " . $e->getMessage()); + $authorization->addRole(Role::user('alice')->toString()); + + $this->assertSame(['existing', 'fresh'], $readableIds()); + $existing = $database->getDocument($collection, 'existing'); + $this->assertSame(5, $existing->getAttribute('rank'), 'The stored document is not overwritten'); + $this->assertSame([Permission::read(Role::user('alice'))], $existing->getPermissions()); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); } + $database->deleteCollection($collection); } - - // Cleanup - $database->deleteCollection($collectionName); } - /** - * Test ReDoS (Regular Expression Denial of Service) with timeout protection - * This test verifies that ReDoS patterns either timeout properly or complete quickly, - * preventing denial of service attacks. - */ - // public function testRegexRedos(): void - // { - // /** @var Database $database */ - // $database = static::getDatabase(); - // - // // Skip test if regex is not supported - // if (!$database->getAdapter()->getSupportForRegex()) { - // $this->expectNotToPerformAssertions(); - // return; - // } - // - // $collectionName = 'redosTimeoutTest'; - // $database->createCollection($collectionName, permissions: [ - // Permission::create(Role::any()), - // Permission::read(Role::any()), - // Permission::update(Role::any()), - // Permission::delete(Role::any()), - // ]); - // - // if ($database->getAdapter()->getSupportForAttributes()) { - // $this->assertEquals(true, $database->createAttribute($collectionName, 'text', Database::VAR_STRING, 1000, true)); - // } - // - // // Create documents with strings designed to trigger ReDoS - // // These strings have many 'a's but end with 'c' instead of 'b' - // // This causes catastrophic backtracking with patterns like (a+)+b - // $redosStrings = []; - // for ($i = 15; $i <= 35; $i += 5) { - // $redosStrings[] = str_repeat('a', $i) . 'c'; - // } - // - // // Also add some normal strings - // $normalStrings = [ - // 'normal text', - // 'another string', - // 'test123', - // 'valid data', - // ]; - // - // $documents = []; - // foreach ($redosStrings as $text) { - // $documents[] = new Document([ - // '$permissions' => [ - // Permission::read(Role::any()), - // Permission::create(Role::any()), - // Permission::update(Role::any()), - // Permission::delete(Role::any()), - // ], - // 'text' => $text, - // ]); - // } - // - // foreach ($normalStrings as $text) { - // $documents[] = new Document([ - // '$permissions' => [ - // Permission::read(Role::any()), - // Permission::create(Role::any()), - // Permission::update(Role::any()), - // Permission::delete(Role::any()), - // ], - // 'text' => $text, - // ]); - // } - // - // $database->createDocuments($collectionName, $documents); - // - // // ReDoS patterns that cause exponential backtracking - // $redosPatterns = [ - // '(a+)+b', // Classic ReDoS: nested quantifiers - // '(a|a)*b', // Alternation with quantifier - // '(a+)+$', // Anchored pattern - // '(a*)*b', // Nested star quantifiers - // '(a+)+b+', // Multiple nested quantifiers - // '(.+)+b', // Generic nested quantifiers - // '(.*)+b', // Generic nested quantifiers - // ]; - // - // $supportsTimeout = $database->getAdapter()->getSupportForTimeouts(); - // - // if ($supportsTimeout) { - // $database->setTimeout(2000); - // } - // - // foreach ($redosPatterns as $pattern) { - // $startTime = microtime(true); - // - // try { - // $results = $database->find($collectionName, [ - // Query::regex('text', $pattern), - // ]); - // $elapsed = microtime(true) - $startTime; - // // If timeout is supported, the query should either: - // // 1. Complete quickly (< 3 seconds) if ReDoS is mitigated - // // 2. Throw TimeoutException if it takes too long - // if ($supportsTimeout) { - // // If we got here without timeout, it should have completed quickly - // $this->assertLessThan( - // 3.0, - // $elapsed, - // "Regex pattern '{$pattern}' should complete quickly or timeout. Took {$elapsed}s" - // ); - // } else { - // // Without timeout support, we just check it doesn't hang forever - // // Set a reasonable upper bound (15 seconds) for systems without timeout - // $this->assertLessThan( - // 15.0, - // $elapsed, - // "Regex pattern '{$pattern}' should not cause excessive delay. Took {$elapsed}s" - // ); - // } - // - // // Verify results: none of our ReDoS strings should match these patterns - // // (they all end with 'c', not 'b') - // foreach ($results as $doc) { - // $text = $doc->getAttribute('text'); - // // If it matched, verify it's actually a valid match - // $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); - // if ($matches !== false) { - // $this->assertEquals( - // 1, - // $matches, - // "Document with text '{$text}' should actually match pattern '{$pattern}'" - // ); - // } - // } - // - // } catch (TimeoutException $e) { - // // Timeout is expected for ReDoS patterns if not properly mitigated - // $elapsed = microtime(true) - $startTime; - // $this->assertInstanceOf( - // TimeoutException::class, - // $e, - // "Regex pattern '{$pattern}' should timeout if it causes ReDoS. Elapsed: {$elapsed}s" - // ); - // - // // Timeout should happen within reasonable time (not immediately, but not too late) - // // Fast timeouts are actually good - they mean the system is protecting itself quickly - // $this->assertGreaterThan( - // 0.05, - // $elapsed, - // "Timeout should occur after some minimal processing time" - // ); - // - // // Timeout should happen before the timeout limit (with some buffer) - // if ($supportsTimeout) { - // $this->assertLessThan( - // 5.0, - // $elapsed, - // "Timeout should occur within reasonable time (before 5 seconds)" - // ); - // } - // - // } catch (\Exception $e) { - // // Check if this is a query interruption/timeout from MySQL (error 1317) - // // MySQL sometimes throws "Query execution was interrupted" instead of TimeoutException - // $message = $e->getMessage(); - // $isQueryInterrupted = false; - // - // // Check message for interruption keywords - // if (strpos($message, 'Query execution was interrupted') !== false || - // strpos($message, 'interrupted') !== false) { - // $isQueryInterrupted = true; - // } - // - // // Check if it's a PDOException with error code 1317 - // if ($e instanceof PDOException) { - // $errorInfo = $e->errorInfo ?? []; - // // Error 1317 is "Query execution was interrupted" - // if (isset($errorInfo[1]) && $errorInfo[1] === 1317) { - // $isQueryInterrupted = true; - // } - // // Also check SQLSTATE 70100 - // if ($e->getCode() === '70100') { - // $isQueryInterrupted = true; - // } - // } - // - // if ($isQueryInterrupted) { - // // This is effectively a timeout - MySQL interrupted the query - // $elapsed = microtime(true) - $startTime; - // $this->assertGreaterThan( - // 0.05, - // $elapsed, - // "Query interruption should occur after some minimal processing time" - // ); - // // This is acceptable - the query was interrupted due to timeout - // continue; - // } - // - // // Other exceptions are unexpected - // $this->fail("Unexpected exception for pattern '{$pattern}': " . get_class($e) . " - " . $e->getMessage()); - // } - // } - // - // // Test with a pattern that should match quickly (not ReDoS) - // $safePattern = 'normal'; - // $startTime = microtime(true); - // $results = $database->find($collectionName, [ - // Query::regex('text', $safePattern), - // ]); - // $elapsed = microtime(true) - $startTime; - // - // // Safe patterns should complete very quickly - // $this->assertLessThan(1.0, $elapsed, 'Safe regex pattern should complete quickly'); - // $this->assertGreaterThan(0, count($results), 'Safe pattern should match some documents'); - // - // // Verify safe pattern results are correct - // foreach ($results as $doc) { - // $text = $doc->getAttribute('text'); - // $this->assertStringContainsString('normal', $text, "Document '{$text}' should contain 'normal'"); - // } - // - // // Cleanup - // if ($supportsTimeout) { - // $database->clearTimeout(); - // } - // $database->deleteCollection($collectionName); - // } + public function testSkipDuplicatesStillThrowsUniqueForAnotherUniqueIndex(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::IndexUnique)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'skipDupUnique'; + $permissions = [Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'slug', size: 64, required: true)); + $database->createIndex($collection, Index::unique(key: 'slugUnique', attributes: ['slug'], lengths: [64])); + + try { + $database->createDocument($collection, new Document(['$id' => 'taken', '$permissions' => $permissions, 'slug' => 'shared'])); + + $created = null; + $thrown = null; + try { + $created = $database->ignoreDuplicates(fn (): int => $database->createDocuments($collection, [ + new Document(['$id' => 'colliding', '$permissions' => $permissions, 'slug' => 'shared']), + ])); + } catch (DuplicateException $exception) { + $thrown = $exception; + } - public function testCreateDocumentsIgnoreDuplicates(): void + if ($this->skipsOnlyStoredIds($database)) { + $this->assertInstanceOf(UniqueException::class, $thrown, 'PostgreSQL and MongoDB skip only a stored id, so a new id colliding on another unique index throws'); + $this->assertNull($created); + } else { + $this->assertNull($thrown, 'MariaDB, MySQL, SQLite, Memory and Redis cannot name the index to ignore and skip the row'); + $this->assertSame(0, $created); + } + + $this->assertTrue($database->getDocument($collection, 'colliding')->isEmpty()); + $this->assertSame('shared', $database->getDocument($collection, 'taken')->getAttribute('slug')); + $this->assertSame(1, $database->count($collection)); + } finally { + $database->deleteCollection($collection); + } + } + + /** + * Whether the engine behind the database, pooled or not, can skip a stored id without + * skipping other unique collisions. + */ + private function skipsOnlyStoredIds(Database $database): bool + { + $adapter = $database->getAdapter(); + if ($adapter instanceof Mongo) { + return true; + } + + $driver = $adapter->getDriver(); + + return ($driver instanceof PDO || $driver instanceof DatabasePDO) + && $driver->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql'; + } + + public function testStringThatReadsLikeWktRoundTripsUnchanged(): void { /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 128, true); + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); - // Insert initial documents - $database->createDocuments(__FUNCTION__, [ - new Document([ - '$id' => 'doc1', - 'name' => 'Original A', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - new Document([ - '$id' => 'doc2', - 'name' => 'Original B', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - ]); + return; + } - // Without ignore, duplicates should throw - try { - $database->createDocuments(__FUNCTION__, [ - new Document([ - '$id' => 'doc1', - 'name' => 'Duplicate A', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - ]); - $this->fail('Expected DuplicateException'); - } catch (DuplicateException $e) { - $this->assertNotEmpty($e->getMessage()); + $spatial = $database->getAdapter()->hasFeature(Feature\Spatial::class); + $upserts = $database->getAdapter()->hasFeature(Feature\Upserts::class); + $position = [3.0, 4.0]; + $answers = [ + 'point' => 'POINT(1 2)', + 'point with trailing text' => 'POINT(1 2) is my answer', + 'linestring' => 'LINESTRING(0 0,1 1)', + 'polygon' => 'POLYGON((0 0,1 1,1 0,0 0))', + 'lowercase point' => 'point (1 2)', + ]; + + $attributes = [Attribute::string(key: 'answer', size: 255)]; + if ($spatial) { + $attributes[] = Attribute::point(key: 'position', required: true); } - // With skipDuplicates, duplicates should be silently skipped - $emittedIds = []; - $collection = __FUNCTION__; - $count = $database->skipDuplicates(function () use ($database, $collection, &$emittedIds) { - return $database->createDocuments($collection, [ - new Document([ - '$id' => 'doc1', - 'name' => 'Duplicate A', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - new Document([ - '$id' => 'doc3', - 'name' => 'New C', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - ], onNext: function (Document $doc) use (&$emittedIds) { - $emittedIds[] = $doc->getId(); - }); - }); + $collection = 'wkt_text_round_trip'; + $database->createCollection(Collection::create( + id: $collection, + attributes: $attributes, + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); - $this->assertSame(2, $count); - $this->assertCount(2, $emittedIds); - \sort($emittedIds); - $this->assertSame(['doc1', 'doc3'], $emittedIds); + $document = function (string $id, ?string $answer) use ($spatial, $position): Document { + $attributes = ['$id' => $id, 'answer' => $answer]; + if ($spatial) { + $attributes['position'] = $position; + } - $doc1 = $database->getDocument(__FUNCTION__, 'doc1'); - $this->assertSame('Original A', $doc1->getAttribute('name')); + return new Document($attributes); + }; - $doc3 = $database->getDocument(__FUNCTION__, 'doc3'); - $this->assertSame('New C', $doc3->getAttribute('name')); + $assertStored = function (string $id, ?string $answer, string $case) use ($database, $collection, $spatial, $position): void { + $stored = $database->getDocument($collection, $id); + $this->assertSame($answer, $stored->getAttribute('answer'), $case); + if ($spatial) { + $this->assertEquals($position, $stored->getAttribute('position'), $case); + } + if ($answer !== null) { + $found = $database->find($collection, [Query::equal('answer', [$answer]), Query::equal('$id', [$id])]); + $this->assertCount(1, $found, $case.' is stored as the text itself'); + } + }; + + try { + foreach ($answers as $case => $answer) { + $single = 'single_'.\str_replace(' ', '_', $case); + $created = $database->createDocument($collection, $document($single, $answer)); + $this->assertSame($answer, $created->getAttribute('answer'), 'createDocument '.$case); + $assertStored($single, $answer, 'createDocument '.$case); + + $database->updateDocument($collection, $single, new Document(['answer' => 'plain '.$answer])); + $assertStored($single, 'plain '.$answer, 'updateDocument away from '.$case); + $database->updateDocument($collection, $single, new Document(['answer' => $answer])); + $assertStored($single, $answer, 'updateDocument '.$case); + + $batch = 'batch_'.\str_replace(' ', '_', $case); + $count = $database->createDocuments($collection, [ + $document($batch, $answer), + $document($batch.'_plain', 'plain text'), + $document($batch.'_null', null), + ]); + $this->assertSame(3, $count, 'createDocuments '.$case); + $assertStored($batch, $answer, 'createDocuments '.$case); + $assertStored($batch.'_plain', 'plain text', 'createDocuments sibling of '.$case); + $assertStored($batch.'_null', null, 'createDocuments null sibling of '.$case); + + $database->updateDocuments($collection, new Document(['answer' => $answer]), [ + Query::equal('$id', [$batch.'_plain', $batch.'_null']), + ]); + $assertStored($batch.'_plain', $answer, 'updateDocuments '.$case); + $assertStored($batch.'_null', $answer, 'updateDocuments '.$case); + + if ($upserts) { + $upserted = 'upsert_'.\str_replace(' ', '_', $case); + $database->upsertDocuments($collection, [ + $document($upserted, $answer), + $document($batch, 'plain text'), + ]); + $assertStored($upserted, $answer, 'upsertDocuments insert '.$case); + $assertStored($batch, 'plain text', 'upsertDocuments update beside '.$case); + + $database->upsertDocuments($collection, [$document($batch, $answer)]); + $assertStored($batch, $answer, 'upsertDocuments update '.$case); + } + } - // Total should be 3 (doc1, doc2, doc3) - $all = $database->find(__FUNCTION__); - $this->assertCount(3, $all); + if ($spatial) { + try { + $database->createDocument($collection, new Document(['$id' => 'not_a_point', 'answer' => 'x', 'position' => 'not a point'])); + $this->fail('A point attribute given text that is not WKT is refused'); + } catch (StructureException $error) { + $this->assertStringContainsString('position', $error->getMessage()); + } + $this->assertTrue($database->getDocument($collection, 'not_a_point')->isEmpty()); + } + } finally { + $database->deleteCollection($collection); + } } - public function testCreateDocumentsIgnoreAllDuplicates(): void + public function testIncreasingAnUnsetOptionalNumberStoresIt(): void { /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 128, true); + $collection = 'unset_optional_numbers'; + $database->createCollection(Collection::create( + id: $collection, + attributes: $database->getAdapter()->supports(Capability::DefinedAttributes) + ? [ + Attribute::integer(key: 'visits', required: false), + Attribute::integer(key: 'capped', required: false), + Attribute::integer(key: 'excluded', required: false), + Attribute::double(key: 'balance', required: false), + Attribute::double(key: 'floored', required: false), + ] + : [], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); - // Insert initial document - $database->createDocuments(__FUNCTION__, [ - new Document([ - '$id' => 'existing', - 'name' => 'Original', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - ]); + try { + $database->createDocument($collection, new Document([Document::ID => 'unset'])); + + $database->increaseDocumentAttribute($collection, 'unset', 'visits', 3); + $database->increaseDocumentAttribute($collection, 'unset', 'capped', 4, max: 10); + $database->decreaseDocumentAttribute($collection, 'unset', 'balance', 1.5); + $database->decreaseDocumentAttribute($collection, 'unset', 'floored', 2.5, min: -10); + $database->getAdapter()->increaseDocumentAttribute($database->getCollection($collection), 'unset', 'excluded', 5, DateTime::now(), max: -1); + + $stored = $database->getDocument($collection, 'unset'); + $this->assertSame(3, $stored->getAttribute('visits')); + $this->assertSame(4, $stored->getAttribute('capped')); + $this->assertSame(-1.5, $stored->getAttribute('balance')); + $this->assertSame(-2.5, $stored->getAttribute('floored')); + $this->assertNull($stored->getAttribute('excluded'), 'A bound that excludes zero must leave an unset number unset'); + + foreach (['visits' => 3, 'capped' => 4, 'balance' => -1.5, 'floored' => -2.5] as $attribute => $value) { + $this->assertSame(1, $database->count($collection, [Query::equal($attribute, [$value])]), "'{$attribute}' must be stored as {$value}"); + } - // With skipDuplicates, inserting only duplicates should succeed with no new rows - $emittedIds = []; - $collection = __FUNCTION__; - $count = $database->skipDuplicates(function () use ($database, $collection, &$emittedIds) { - return $database->createDocuments($collection, [ - new Document([ - '$id' => 'existing', - 'name' => 'Duplicate', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - ], onNext: function (Document $doc) use (&$emittedIds) { - $emittedIds[] = $doc->getId(); - }); - }); + $database->increaseDocumentAttribute($collection, 'unset', 'capped', 6, max: 10); + $this->assertSame(10, $database->getDocument($collection, 'unset')->getAttribute('capped')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testFilteredCountAndSumIssueFlatStatements(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $this->assertSame(1, $count); - $this->assertSame(['existing'], $emittedIds); + if (! $database->getAdapter()->hasFeature(Feature\RawQuery::class)) { + $this->expectNotToPerformAssertions(); - $doc = $database->getDocument(__FUNCTION__, 'existing'); - $this->assertSame('Original', $doc->getAttribute('name')); + return; + } + + $collection = 'flat_aggregates'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'category', size: 16), + Attribute::integer(key: 'price'), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + try { + foreach ([ + ['f1', 'a', 10, Role::any()], + ['f2', 'a', 20, Role::any()], + ['f3', 'a', 30, Role::user('flat_aggregates_other')], + ['f4', 'b', 40, Role::any()], + ['f5', 'a', 50, Role::any()], + ] as [$id, $category, $price, $reader]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read($reader)], + 'category' => $category, + 'price' => $price, + ])); + } - // Still only 1 document - $all = $database->find(__FUNCTION__); - $this->assertCount(1, $all); + $filter = [Query::equal('category', ['a'])]; + $table = $database->getNamespace().'_'.$collection; + + [$count, $statements] = $this->statementsOn($database, $table, fn (): int => $database->count($collection, $filter)); + $this->assertSame(3, $count); + $this->assertCount(1, $statements, \implode("\n", $statements)); + $this->assertStringNotContainsString('table_count', $statements[0]); + $this->assertStringNotContainsString('FROM (SELECT', $statements[0]); + + [$sum, $statements] = $this->statementsOn($database, $table, fn (): int|float => $database->sum($collection, 'price', $filter)); + $this->assertEquals(80, $sum); + $this->assertCount(1, $statements, \implode("\n", $statements)); + $this->assertStringNotContainsString('table_count', $statements[0]); + $this->assertStringNotContainsString('FROM (SELECT', $statements[0]); + + [$bounded, $statements] = $this->statementsOn($database, $table, fn (): int => $database->count($collection, $filter, 2)); + $this->assertSame(2, $bounded); + $this->assertCount(1, $statements); + $this->assertStringContainsString('table_count', $statements[0], 'a bound on the rows keeps the derived table'); + + $this->assertSame(0, $database->count($collection, [Query::equal('category', ['c'])])); + $this->assertEquals(0, $database->sum($collection, 'price', [Query::equal('category', ['c'])])); + $this->assertSame(4, $database->getAuthorization()->skip(fn (): int => $database->count($collection, $filter))); + } finally { + $database->getAuthorization()->skip(fn () => $database->deleteCollection($collection)); + } } - public function testCreateDocumentsSkipDuplicatesEmptyBatch(): void + public function testUnaliasedBitwiseAggregatesOfAnEmptySetAreNull(): void { + /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'skipDupEmpty'; - $database->createCollection($collection); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, true); + if (! $this->supportsBitwiseAggregates()) { + $this->expectNotToPerformAssertions(); - $count = $database->skipDuplicates(fn () => $database->createDocuments($collection, [])); + return; + } - $this->assertSame(0, $count); - $this->assertCount(0, $database->find($collection)); + $collection = 'bitwise_unaliased'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'category', size: 16), + Attribute::integer(key: 'flags'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + try { + foreach ([['b1', 'a', 6], ['b2', 'a', 3]] as [$id, $category, $flags]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'category' => $category, + 'flags' => $flags, + ])); + } + + $aggregates = [ + 'bitAnd' => static fn (string $alias = ''): Query => Query::bitAnd('flags', $alias), + 'bitOr' => static fn (string $alias = ''): Query => Query::bitOr('flags', $alias), + 'bitXor' => static fn (string $alias = ''): Query => Query::bitXor('flags', $alias), + ]; + foreach ($aggregates as $method => $aggregate) { + $empty = $database->aggregate($collection, [Query::equal('category', ['none']), $aggregate()]); + $this->assertCount(1, $empty, $method); + $values = $empty[0]; + $this->assertNotSame([], $values, $method); + foreach ($values as $name => $value) { + $this->assertStringStartsNotWith('$inputs:', (string) $name, $method); + $this->assertNull($value, $method.': '.$name); + } + + $filled = $database->aggregate($collection, [Query::equal('category', ['a']), $aggregate(), $aggregate('named')]); + $this->assertCount(1, $filled, $method); + $values = $filled[0]; + $named = $values['named'] ?? null; + unset($values['named']); + $this->assertNotNull($named, $method); + $this->assertCount(1, $values, $method); + $this->assertEquals($named, \array_values($values)[0], $method.': the unaliased value is the aliased one'); + } + } finally { + $database->deleteCollection($collection); + } } - public function testCreateDocumentsSkipDuplicatesNestedScope(): void + public function testProfiledReadsLogTheirValuesCollectionAndOperation(): void { + /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'skipDupNested'; - $database->createCollection($collection); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, true); + if (! $database->getAdapter()->hasFeature(Feature\RawQuery::class)) { + $this->expectNotToPerformAssertions(); - $makeDoc = fn (string $id, string $name) => new Document([ - '$id' => $id, - 'name' => $name, - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]); + return; + } - // Seed an existing doc - $database->createDocuments($collection, [$makeDoc('seed', 'Seed')]); + $collection = 'profiled_reads'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'category', size: 16)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); - // Nested scope — inner scope runs inside outer scope. - // After inner exits, outer state should still be "skip enabled". - // After outer exits, state should restore to "skip disabled". - $countOuter = $database->skipDuplicates(function () use ($database, $collection, $makeDoc) { - // Inner scope: add dup + new - $countInner = $database->skipDuplicates(function () use ($database, $collection, $makeDoc) { - return $database->createDocuments($collection, [ - $makeDoc('seed', 'Dup'), - $makeDoc('innerNew', 'InnerNew'), - ]); - }); - $this->assertSame(2, $countInner); + try { + $database->createDocument($collection, new Document([ + '$id' => 'p1', + '$permissions' => [Permission::read(Role::any())], + 'category' => 'profiled_value', + ])); - // Still inside outer scope — skip flag should still be on - return $database->createDocuments($collection, [ - $makeDoc('seed', 'Dup2'), - $makeDoc('outerNew', 'OuterNew'), - ]); - }); - $this->assertSame(2, $countOuter); + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); - // After both scopes exit, skip flag is off again — a plain createDocuments - // call with a duplicate should throw. - $thrown = null; - try { - $database->createDocuments($collection, [$makeDoc('seed', 'ShouldThrow')]); - } catch (DuplicateException $e) { - $thrown = $e; - } - $this->assertNotNull($thrown, 'Plain createDocuments after nested scopes should throw on duplicate'); + try { + $profiler->reset(); + $database->find($collection, [Query::equal('category', ['profiled_value'])]); + $database->count($collection, [Query::equal('category', ['profiled_value'])]); + } finally { + $database->setProfiling(false); + } - // Final state: seed + innerNew + outerNew - $all = $database->find($collection); - $ids = \array_map(fn (Document $d) => $d->getId(), $all); - \sort($ids); - $this->assertSame(['innerNew', 'outerNew', 'seed'], $ids); + $operations = []; + foreach ($profiler->getLogs() as $log) { + if ($log->collection !== $collection) { + continue; + } + $this->assertContains('profiled_value', $log->bindings, $log->query); + $operations[] = $log->operation; + } + + $this->assertSame(['document_find', 'document_count'], $operations); + } finally { + $database->deleteCollection($collection); + } } - public function testCreateDocumentsSkipDuplicatesLargeBatch(): void + public function testSumResolvesABareNameOnlyAJoinDeclares(): void { + /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'skipDupLarge'; - $database->createCollection($collection); - $database->createAttribute($collection, 'idx', Database::VAR_INTEGER, 0, true); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); - // Seed 50 docs - $seed = []; - for ($i = 0; $i < 50; $i++) { - $seed[] = new Document([ - '$id' => 'doc_' . $i, - 'idx' => $i, - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]); + return; } - $database->createDocuments($collection, $seed); - // Now call skipDuplicates with 300 docs: 50 existing (0-49) + 250 new (50-299). - // 300 > default INSERT_BATCH_SIZE, so this exercises the chunk loop. - $batch = []; - for ($i = 0; $i < 300; $i++) { - $batch[] = new Document([ - '$id' => 'doc_' . $i, - 'idx' => $i + 1000, // different value so we can detect if existing got overwritten - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]); - } + $orders = 'sum_join_orders'; + $items = 'sum_join_items'; + $extras = 'sum_join_extras'; + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: $orders, + attributes: [Attribute::string(key: 'item', size: 16), Attribute::integer(key: 'quantity')], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: $items, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::integer(key: 'price'), Attribute::integer(key: 'quantity')], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: $extras, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::integer(key: 'price')], + permissions: $permissions, + )); - $emittedIds = []; - $count = $database->skipDuplicates(function () use ($database, $collection, $batch, &$emittedIds) { - return $database->createDocuments($collection, $batch, onNext: function (Document $doc) use (&$emittedIds) { - $emittedIds[] = $doc->getId(); - }); - }); + try { + foreach ([['o1', 'a', 1], ['o2', 'b', 2], ['o3', 'a', 3]] as [$id, $item, $quantity]) { + $database->createDocument($orders, new Document(['$id' => $id, '$permissions' => [], 'item' => $item, 'quantity' => $quantity])); + } + foreach ([['a', 10], ['b', 20]] as [$code, $price]) { + $database->createDocument($items, new Document(['$id' => $code, '$permissions' => [], 'code' => $code, 'price' => $price, 'quantity' => 100])); + } + $database->createDocument($extras, new Document(['$id' => 'a', '$permissions' => [], 'code' => 'a', 'price' => 100])); - $this->assertSame(300, $count); - $this->assertCount(300, $emittedIds); + $item = Query::join($items, 'it', [Query::on('item', 'code')]); + $extra = Query::join($extras, 'ex', [Query::on('item', 'code')]); - $seedDoc = $database->getDocument($collection, 'doc_25'); - $this->assertSame(25, $seedDoc->getAttribute('idx')); + $this->assertEquals(40, $database->sum($orders, 'price', [$item])); + $this->assertEquals(40, $database->sum($orders, 'it.price', [$item])); + $this->assertEquals(20, $database->sum($orders, 'price', [$item, Query::equal('it.code', ['a'])])); + $this->assertEquals(6, $database->sum($orders, 'quantity', [$item]), 'the main collection declares quantity'); + $this->assertEquals(200, $database->sum($orders, 'ex.price', [$item, $extra])); - $newDoc = $database->getDocument($collection, 'doc_100'); - $this->assertSame(1100, $newDoc->getAttribute('idx')); + try { + $database->sum($orders, 'price', [$item, $extra]); + $this->fail('a name two joins declare was summed'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute "price" is ambiguous across joins; qualify it with a join alias', $error->getMessage()); + } - $total = $database->count($collection); - $this->assertSame(300, $total); + try { + $database->sum($orders, 'weight', [$item]); + $this->fail('a name no collection declares was summed'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute not found in schema: weight', $error->getMessage()); + } + } finally { + foreach ([$orders, $items, $extras] as $collection) { + $database->deleteCollection($collection); + } + } } - public function testCreateDocumentsSkipDuplicatesSecondCallSkipsAll(): void + public function testJoinedGroupSharingAMainGroupNameKeepsItsQualifiedName(): void { + /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'skipDupSecond'; - $database->createCollection($collection); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, true); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); - $makeBatch = fn (string $name) => \array_map( - fn (string $id) => new Document([ - '$id' => $id, - 'name' => $name, - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ], - ]), - ['a', 'b', 'c'] - ); + return; + } - // First call — all new - $firstCount = $database->skipDuplicates( - fn () => $database->createDocuments($collection, $makeBatch('First')) - ); - $this->assertSame(3, $firstCount); + $orders = 'group_name_orders'; + $items = 'group_name_items'; + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: $orders, + attributes: [Attribute::string(key: 'item', size: 16), Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: $items, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); - $emittedIds = []; - $secondCount = $database->skipDuplicates(function () use ($database, $collection, $makeBatch, &$emittedIds) { - return $database->createDocuments($collection, $makeBatch('Second'), onNext: function (Document $doc) use (&$emittedIds) { - $emittedIds[] = $doc->getId(); - }); - }); - $this->assertSame(3, $secondCount); - \sort($emittedIds); - $this->assertSame(['a', 'b', 'c'], $emittedIds); + try { + foreach ([['o1', 'a', 'x'], ['o2', 'b', 'y'], ['o3', 'a', 'x']] as [$id, $item, $name]) { + $database->createDocument($orders, new Document(['$id' => $id, '$permissions' => [], 'item' => $item, 'name' => $name])); + } + foreach ([['a', 'apple'], ['b', 'banana']] as [$code, $name]) { + $database->createDocument($items, new Document(['$id' => $code, '$permissions' => [], 'code' => $code, 'name' => $name])); + } - // All three should retain the First values - foreach (['a', 'b', 'c'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertSame('First', $doc->getAttribute('name'), "Doc {$id} should not have been overwritten"); + foreach (['join' => Query::join($items, 'it', [Query::on('item', 'code')]), 'full outer join' => Query::fullOuterJoin($items, 'it', [Query::on('item', 'code')])] as $case => $join) { + $this->assertEquals( + [['orders' => 2, 'name' => 'x', 'it.name' => 'apple'], ['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $database->aggregate($orders, [$join, Query::count('*', 'orders'), Query::groupBy(['name', 'it.name']), Query::orderAsc('name')]), + $case, + ); + $this->assertEquals( + [['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $database->aggregate($orders, [$join, Query::count('*', 'orders'), Query::groupBy(['it.name', 'name']), Query::having([Query::equal('it.name', ['banana'])])]), + $case.': a having on the qualified group', + ); + $this->assertEquals( + [['orders' => 2, 'name' => 'apple'], ['orders' => 1, 'name' => 'banana']], + $database->aggregate($orders, [$join, Query::count('*', 'orders'), Query::groupBy(['it.name']), Query::orderAsc('it.name')]), + $case.': a joined group alone keeps its bare name', + ); + } + } finally { + foreach ([$orders, $items] as $collection) { + $database->deleteCollection($collection); + } } } - public function testCreateDocumentsSkipDuplicatesRelationships(): void + public function testJoinWildcardSelectAndBareJoinedOrder(): void { + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! $database->getAdapter()->supports(Capability::Joins)) { $this->expectNotToPerformAssertions(); + return; } - $parent = 'skipDupParent'; - $child = 'skipDupChild'; - $permissions = [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]; - - $database->createCollection($parent); - $database->createCollection($child); - $database->createAttribute($parent, 'name', Database::VAR_STRING, 128, true); - $database->createAttribute($child, 'name', Database::VAR_STRING, 128, true); - $database->createRelationship( - collection: $parent, - relatedCollection: $child, - type: Database::RELATION_ONE_TO_MANY, - id: 'children', - ); - - $database->createDocument($parent, new Document([ - '$id' => 'existingParent', - 'name' => 'ExistingParent', - '$permissions' => $permissions, - 'children' => [ - new Document([ - '$id' => 'existingChild', - 'name' => 'ExistingChild', - '$permissions' => $permissions, - ]), - ], - ])); + $orders = 'joined_shape_orders'; + $items = 'joined_shape_items'; + $extras = 'joined_shape_extras'; + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: $orders, + attributes: [Attribute::string(key: 'item', size: 16), Attribute::integer(key: 'quantity'), Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: $items, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::integer(key: 'price'), Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: $extras, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::integer(key: 'price')], + permissions: $permissions, + )); - $batch = [ - new Document([ - '$id' => 'existingParent', - 'name' => 'ShouldNotOverwrite', - '$permissions' => $permissions, - 'children' => [ - new Document([ - '$id' => 'existingChild', - 'name' => 'ExistingChild', - '$permissions' => $permissions, - ]), - new Document([ - '$id' => 'retryChild', - 'name' => 'RetryChild', - '$permissions' => $permissions, - ]), - ], - ]), - new Document([ - '$id' => 'newParent', - 'name' => 'NewParent', - '$permissions' => $permissions, - 'children' => [ - new Document([ - '$id' => 'newChild', - 'name' => 'NewChild', - '$permissions' => $permissions, - ]), - ], - ]), - ]; + try { + foreach ([['o1', 'a', 1, 'x'], ['o2', 'b', 2, 'y'], ['o3', 'a', 3, 'x']] as [$id, $item, $quantity, $name]) { + $database->createDocument($orders, new Document(['$id' => $id, '$permissions' => [], 'item' => $item, 'quantity' => $quantity, 'name' => $name])); + } + foreach ([['a', 10, 'apple'], ['b', 20, 'banana']] as [$code, $price, $name]) { + $database->createDocument($items, new Document(['$id' => $code, '$permissions' => [], 'code' => $code, 'price' => $price, 'name' => $name])); + } + $database->createDocument($extras, new Document(['$id' => 'a', '$permissions' => [], 'code' => 'a', 'price' => 100])); + + $item = Query::join($items, 'it', [Query::on('item', 'code')]); + $extra = Query::join($extras, 'ex', [Query::on('item', 'code')]); + $ids = static function (array $documents): array { + /** @var array $documents */ + return \array_map(static fn (Document $document): string => $document->getId(), $documents); + }; + + $rows = $database->find($orders, [$item, Query::select(['name', 'it.*']), Query::orderAsc('$id')]); + $this->assertSame(['o1', 'o2', 'o3'], $ids($rows)); + $this->assertSame('y', $rows[1]->getAttribute('name')); + $this->assertSame('banana', $rows[1]->getAttribute('it.name')); + $this->assertEquals(20, $rows[1]->getAttribute('it.price')); + $this->assertSame('b', $rows[1]->getAttribute('it.$id')); + $this->assertNull($rows[1]->getAttribute('quantity')); + + $this->assertSame(['o2', 'o1', 'o3'], $ids($database->find($orders, [$item, Query::orderDesc('price'), Query::orderAsc('$id')]))); + $this->assertSame(['o2', 'o1', 'o3'], $ids($database->find($orders, [Query::fullOuterJoin($items, 'it', [Query::on('item', 'code')]), Query::orderDesc('price'), Query::orderAsc('$id')]))); + $this->assertSame(['o2', 'o1', 'o3'], $ids($database->find($orders, [$item, Query::orderDesc('name'), Query::orderAsc('$id')])), 'a name the main collection declares reads the main table'); + $this->assertEquals( + [['orders' => 1, 'code' => 'b'], ['orders' => 2, 'code' => 'a']], + $database->aggregate($orders, [$item, Query::count('*', 'orders'), Query::groupBy(['code']), Query::orderDesc('code')]), + ); - $database->skipDuplicates(fn () => $database->createDocuments($parent, $batch)); + foreach ([ + 'Invalid query: Attribute "price" is ambiguous across joins; qualify it with a join alias' => fn (): array => $database->find($orders, [$item, $extra, Query::orderAsc('price')]), + 'Invalid query: Attribute not found in schema: weight' => fn (): array => $database->find($orders, [$item, Query::orderAsc('weight')]), + 'Invalid query: Cannot select "it.*": an aggregation query can only select the attributes it groups by' => fn (): array => $database->aggregate($orders, [$item, Query::count('*', 'orders'), Query::groupBy(['it.name']), Query::select(['it.*'])]), + 'Invalid query: Attribute not found in schema: zz' => fn (): array => $database->find($orders, [$item, Query::select(['name', 'zz.*'])]), + ] as $message => $read) { + try { + $read(); + $this->fail('accepted: '.$message); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage()); + } + } + } finally { + foreach ([$orders, $items, $extras] as $collection) { + $database->deleteCollection($collection); + } + } + } - $existing = $database->getDocument($parent, 'existingParent'); - $this->assertFalse($existing->isEmpty()); - $this->assertSame('ExistingParent', $existing->getAttribute('name')); + /** + * Run $read with the profiler on; return its result and the statements it ran on $table. + * + * @template T + * + * @param callable(): T $read + * @return array{T, list} + */ + private function statementsOn(Database $database, string $table, callable $read): array + { + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); - $existingChildren = $existing->getAttribute('children', []); - $childIds = \array_map(fn (Document $d) => $d->getId(), $existingChildren); - \sort($childIds); - $this->assertSame(['existingChild', 'retryChild'], $childIds); + try { + $profiler->reset(); + $result = $read(); + } finally { + $database->setProfiling(false); + } - $new = $database->getDocument($parent, 'newParent'); - $this->assertFalse($new->isEmpty()); - $this->assertSame('NewParent', $new->getAttribute('name')); - $newChildren = $new->getAttribute('children', []); - $this->assertCount(1, $newChildren); - $this->assertSame('newChild', $newChildren[0]->getId()); + $statements = []; + foreach ($profiler->getLogs() as $log) { + if (\str_contains($log->query, $table) && ! \str_contains($log->query, '_metadata')) { + $statements[] = $log->query; + } + } - $allChildren = $database->find($child); - $allChildIds = \array_map(fn (Document $d) => $d->getId(), $allChildren); - \sort($allChildIds); - $this->assertSame(['existingChild', 'newChild', 'retryChild'], $allChildIds); + return [$result, $statements]; } - - public function testDropUnknownAttributes(): void + public function testIncreaseAndDecreaseRefuseAFractionalBoundOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); + $collection = 'fractional_bound_'.uniqid(); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $permissions = [ - Permission::read(Role::any()), + $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::create(Role::any()), + Permission::read(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()), - ]; - - $database->createCollection(__FUNCTION__); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'known', Database::VAR_STRING, 128, false)); + ], documentSecurity: false)); + $database->createAttribute($collection, Attribute::integer(key: 'count', required: true)); + $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { - $database->createDocument(__FUNCTION__, new Document([ - '$id' => 'strict', - '$permissions' => $permissions, - 'known' => 'kept', - 'unknown' => 'dropped', - ])); - $this->fail('Unknown attribute was accepted while dropping is disabled'); - } catch (StructureException $e) { - $this->assertEquals('Invalid document structure: Unknown attribute: "unknown"', $e->getMessage()); + try { + $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 102.4); + $this->fail('A fractional maximum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Max must be an integer.', $error->getMessage()); + } + + try { + $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 0.5); + $this->fail('A fractional minimum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Min must be an integer.', $error->getMessage()); + } + + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(101, $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 102.0)->getAttribute('count')); + $this->assertSame(100, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 99.0)->getAttribute('count')); + } finally { + $database->deleteCollection($collection); } + } - $database->setDropUnknownAttributes(true); + public function testIncreaseAndDecreaseRefuseAFractionalChangeValueOnAnInteger(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $collection = 'fractional_change_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], documentSecurity: false)); + $database->createAttribute($collection, Attribute::integer(key: 'count', required: true)); + $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { - $collection = $database->getCollection(__FUNCTION__); - $encoded = $database->encode($collection, new Document([ - '$id' => 'encoded', - '$collection' => __FUNCTION__, - 'known' => 'kept', - 'unknown' => 'dropped', - ])); - $this->assertEquals('kept', $encoded->getAttribute('known')); - $this->assertNull($encoded->getAttribute('unknown'), 'Unknown attribute survived encode'); + try { + $database->increaseDocumentAttribute($collection, 'counter', 'count', 1.5); + $this->fail('A fractional increase of an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage()); + } - $created = $database->createDocument(__FUNCTION__, new Document([ - '$id' => 'lenient', - '$permissions' => $permissions, - 'known' => 'kept', - 'unknown' => 'dropped', - ])); + try { + $database->decreaseDocumentAttribute($collection, 'counter', 'count', 0.5); + $this->fail('A fractional decrease of an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage()); + } - $this->assertEquals('kept', $created->getAttribute('known')); - $this->assertNull($created->getAttribute('unknown'), 'Unknown attribute survived the create'); + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(102, $database->increaseDocumentAttribute($collection, 'counter', 'count', 2)->getAttribute('count')); + } finally { + $database->deleteCollection($collection); + } + } - $database->purgeCachedDocument(__FUNCTION__, 'lenient'); - $stored = $database->getDocument(__FUNCTION__, 'lenient'); - $this->assertEquals('kept', $stored->getAttribute('known')); - $this->assertNull($stored->getAttribute('unknown'), 'Unknown attribute reached storage on create'); + public function testDistinctIsRefusedWhereTheAdapterCannotDeduplicate(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $updated = $database->updateDocument(__FUNCTION__, 'lenient', new Document([ - '$id' => 'lenient', - '$permissions' => $permissions, - 'known' => 'changed', - 'unknown' => 'dropped', - ])); + $collection = 'distinct_capability'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'colour', size: 32, required: false)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); - $this->assertEquals('changed', $updated->getAttribute('known')); - $this->assertNull($updated->getAttribute('unknown'), 'Unknown attribute survived the update'); + try { + foreach (['first', 'second'] as $id) { + $database->createDocument($collection, new Document(['$id' => $id, 'colour' => 'red'])); + } - $database->purgeCachedDocument(__FUNCTION__, 'lenient'); - $stored = $database->getDocument(__FUNCTION__, 'lenient'); - $this->assertEquals('changed', $stored->getAttribute('known')); - $this->assertNull($stored->getAttribute('unknown'), 'Unknown attribute reached storage on update'); + $read = fn (): array => $database->skipValidation(fn (): array => $database->find($collection, [ + Query::select(['colour']), + Query::distinct(), + ])); - \usleep(5000); + if ($database->getAdapter()->supports(Capability::Aggregations)) { + $this->assertSame(['red'], \array_map(fn (Document $row): mixed => $row->getAttribute('colour'), $read())); - $unchanged = $database->updateDocument(__FUNCTION__, 'lenient', new Document([ - '$id' => 'lenient', - '$permissions' => $permissions, - 'known' => 'changed', - 'unknown' => 'dropped', - ])); + return; + } - $this->assertEquals( - $stored->getUpdatedAt(), - $unchanged->getUpdatedAt(), - 'A write carrying only a dropped attribute counted as a change' - ); + try { + $read(); + $this->fail('A distinct() read must be refused where the adapter cannot deduplicate rows'); + } catch (QueryException $exception) { + $this->assertSame('Distinct queries are not supported by this adapter', $exception->getMessage()); + } } finally { - $database->setDropUnknownAttributes(false); + $database->deleteCollection($collection); } } } diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 2c86ead2e2..352f502b57 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -3,25 +3,41 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use PDOException; +use Redis; +use RedisException; +use Tests\Unit\Cache\CountingCache; use Throwable; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; -use Utopia\Console; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\Cache\Query as ResultCache; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Conflict as ConflictException; +use Utopia\Database\Exception\Contention as ContentionException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Exception\Transaction as TransactionException; +use Utopia\Database\Id; +use Utopia\Database\Index; use Utopia\Database\Mirror; +use Utopia\Database\PDO; +use Utopia\Database\Permission; +use Utopia\Database\Profiler; use Utopia\Database\Query; +use Utopia\Database\Role; trait GeneralTests { @@ -40,827 +56,617 @@ public function testPing(): void */ public function testQueryTimeout(): void { - if (!$this->getDatabase()->getAdapter()->getSupportForTimeouts()) { + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Timeouts::class))) { $this->expectNotToPerformAssertions(); + return; } /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('global-timeouts'); - - $this->assertEquals( - true, - $database->createAttribute( - collection: 'global-timeouts', - id: 'longtext', - type: Database::VAR_STRING, - size: 100000000, - required: true - ) - ); - - for ($i = 0; $i < 20; $i++) { - $database->createDocument('global-timeouts', new Document([ - 'longtext' => file_get_contents(__DIR__ . '/../../../resources/longtext.txt'), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ] - ])); - } - - $database->setTimeout(1); + $database->createCollection(Collection::create(id: 'global-timeouts')); try { - $database->find('global-timeouts', [ - Query::notEqual('longtext', 'appwrite'), - ]); - $this->fail('Failed to throw exception'); - } catch (\Exception $e) { - $database->clearTimeout(); - $database->deleteCollection('global-timeouts'); - $this->assertInstanceOf(TimeoutException::class, $e); - } - } - - public function testCountTimeout(): void - { - if (!$this->getDatabase()->getAdapter()->getSupportForTimeouts()) { - $this->expectNotToPerformAssertions(); - return; - } - - /** @var Database $database */ - $database = $this->getDatabase(); + $database->createAttribute('global-timeouts', Attribute::string(key: 'longtext', size: 100000000, required: true)); - $database->createCollection('count-timeouts'); - - $this->assertEquals( - true, - $database->createAttribute( - collection: 'count-timeouts', - id: 'longtext', - type: Database::VAR_STRING, - size: 100000000, - required: true - ) - ); + $longtext = file_get_contents(__DIR__.'/../../../resources/longtext.txt'); + $this->assertIsString($longtext); - $longtext = file_get_contents(__DIR__ . '/../../../resources/longtext.txt'); - for ($i = 0; $i < 20; $i++) { - $database->createDocument('count-timeouts', new Document([ - 'longtext' => $longtext, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ] - ])); - } + for ($i = 0; $i < 20; $i++) { + $database->createDocument('global-timeouts', new Document([ + 'longtext' => $longtext, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); + } - try { $database->setTimeout(1); $thrown = null; try { - // A substring scan forces the engine to walk every huge value; a - // cheap filter (e.g. notEqual) lets COUNT finish inside the timeout. - $database->count('count-timeouts', [ - Query::contains('longtext', ['needle-that-does-not-exist']), + $database->find('global-timeouts', [ + Query::notEqual('longtext', 'appwrite'), ]); } catch (\Exception $e) { $thrown = $e; } - $this->assertInstanceOf(TimeoutException::class, $thrown, 'count() must throw a timeout exception'); + $this->assertInstanceOf(TimeoutException::class, $thrown, 'find() must throw a timeout exception'); } finally { $database->clearTimeout(); - $database->deleteCollection('count-timeouts'); + $database->deleteCollection('global-timeouts'); } } - public function testPreserveDatesUpdate(): void + public function testSharedTablesUpdateTenant(): void { - $this->getDatabase()->getAuthorization()->disable(); - - /** @var Database $database */ $database = $this->getDatabase(); + $sharedTables = $database->hasSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::Schemas)) { $this->expectNotToPerformAssertions(); + return; } - $database->setPreserveDates(true); + $sharedTablesDb = 'sharedTables_'.static::getTestToken(); - $database->createCollection('preserve_update_dates'); + if ($database->exists($sharedTablesDb)) { + $database->setDatabase($sharedTablesDb)->delete(); + } - $database->createAttribute('preserve_update_dates', 'attr1', Database::VAR_STRING, 10, false); + $database + ->setDatabase($sharedTablesDb) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); - $doc1 = $database->createDocument('preserve_update_dates', new Document([ - '$id' => 'doc1', - '$permissions' => [], - 'attr1' => 'value1', - ])); + try { + $database->createCollection(Collection::create(id: __FUNCTION__, documentSecurity: false)); - $doc2 = $database->createDocument('preserve_update_dates', new Document([ - '$id' => 'doc2', - '$permissions' => [], - 'attr1' => 'value2', - ])); + $database + ->setTenant(1) + ->updateDocument(Database::METADATA, __FUNCTION__, new Document([ + '$id' => __FUNCTION__, + 'name' => 'Scooby Doo', + ])); - $doc3 = $database->createDocument('preserve_update_dates', new Document([ - '$id' => 'doc3', - '$permissions' => [], - 'attr1' => 'value3', - ])); - // updating with empty dates - try { - $doc1->setAttribute('$updatedAt', ''); - $doc1 = $database->updateDocument('preserve_update_dates', 'doc1', $doc1); - $this->fail('Failed to throw structure exception'); + $database->setTenant(null); + $database->purgeCachedDocument(Database::METADATA, __FUNCTION__); + $doc = $database->getDocument(Database::METADATA, __FUNCTION__); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); - $this->assertEquals('Invalid document structure: Missing required attribute "$updatedAt"', $e->getMessage()); + $this->assertFalse($doc->isEmpty()); + $this->assertEquals(__FUNCTION__, $doc->getId()); + } finally { + $database->setTenant(null)->setSharedTables(false); + if ($database->exists($sharedTablesDb)) { + $database->delete($sharedTablesDb); + } + $database + ->setSharedTables($sharedTables) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); } + } - try { - $this->getDatabase()->updateDocuments( - 'preserve_update_dates', - new Document([ - '$updatedAt' => '' - ]), - [ - Query::equal('$id', [ - $doc2->getId(), - $doc3->getId() - ]) - ] - ); - $this->fail('Failed to throw structure exception'); + public function testSharedTablesTenantPerDocument(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); - $this->assertEquals('Invalid document structure: Missing required attribute "$updatedAt"', $e->getMessage()); + $sharedTables = $database->hasSharedTables(); + $tenantPerDocument = $database->isTenantPerDocument(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); + + if (! $database->getAdapter()->supports(Capability::Schemas)) { + $this->markTestSkipped('Tenant per document needs a schema to hold the shared table'); } - // non empty dates - $newDate = '2000-01-01T10:00:00.000+00:00'; + $tenantPerDocDb = 'sharedTablesTenantPerDocument_'.static::getTestToken(); - $doc1->setAttribute('$updatedAt', $newDate); - $doc1 = $database->updateDocument('preserve_update_dates', 'doc1', $doc1); - $this->assertEquals($newDate, $doc1->getAttribute('$updatedAt')); - $doc1 = $database->getDocument('preserve_update_dates', 'doc1'); - $this->assertEquals($newDate, $doc1->getAttribute('$updatedAt')); + if ($database->exists($tenantPerDocDb)) { + $database->delete($tenantPerDocDb); + } - $this->getDatabase()->updateDocuments( - 'preserve_update_dates', - new Document([ - '$updatedAt' => $newDate - ]), - [ - Query::equal('$id', [ - $doc2->getId(), - $doc3->getId() - ]) - ] - ); + $database + ->setDatabase($tenantPerDocDb) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); - $doc2 = $database->getDocument('preserve_update_dates', 'doc2'); - $doc3 = $database->getDocument('preserve_update_dates', 'doc3'); - $this->assertEquals($newDate, $doc2->getAttribute('$updatedAt')); - $this->assertEquals($newDate, $doc3->getAttribute('$updatedAt')); + try { + // Create collection + $database->createCollection(Collection::create(id: __FUNCTION__, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], documentSecurity: false)); + + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'name', size: 100)); + $database->createIndex(__FUNCTION__, Index::key(key: 'nameIndex', attributes: ['name'])); + + $doc1Id = Id::unique(); + + // Create doc for tenant 1 + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->createDocument(__FUNCTION__, new Document([ + '$id' => $doc1Id, + '$tenant' => 1, + 'name' => 'Spiderman', + ])); + + // Set to tenant 1 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(1) + ->getDocument(__FUNCTION__, $doc1Id); + + $this->assertEquals('Spiderman', $doc['name']); + $doc1CreatedAt = $doc->getCreatedAt(); - $database->deleteCollection('preserve_update_dates'); + $doc2Id = Id::unique(); - $database->setPreserveDates(false); + // Create doc for tenant 2 + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->createDocument(__FUNCTION__, new Document([ + '$id' => $doc2Id, + '$tenant' => 2, + 'name' => 'Batman', + ])); + + // Set to tenant 2 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(2) + ->getDocument(__FUNCTION__, $doc2Id); + + $this->assertEquals('Batman', $doc['name']); + $this->assertEquals(2, $doc->getTenant()); - $this->getDatabase()->getAuthorization()->reset(); + // Ensure no read cross-tenant + $docs = $database + ->setTenantPerDocument(false) + ->setTenant(1) + ->find(__FUNCTION__); + + $this->assertEquals(1, \count($docs)); + $this->assertEquals($doc1Id, $docs[0]->getId()); + + // Selecting no tenant has to scope a read to no tenant rather than to every + // tenant: this collection's own metadata row is tenantless, so nothing above + // the document read is left to keep one tenant out of another's rows. + $database->setTenant(null)->setTenantPerDocument(true); + + $this->assertCount(0, $database->find(__FUNCTION__)); + $this->assertSame(0, $database->count(__FUNCTION__)); + $this->assertTrue($database->getDocument(__FUNCTION__, $doc1Id)->isEmpty()); + + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + // An upsert has to recognise a row that createDocument() wrote, not shadow it + // with a second one: a duplicate moves $createdAt and is checked against + // create permission rather than update permission. + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->upsertDocuments(__FUNCTION__, [new Document([ + '$id' => $doc1Id, + '$tenant' => 1, + 'name' => 'Spiderman revised', + ])]); + + $documents = $database + ->setTenantPerDocument(false) + ->setTenant(1) + ->find(__FUNCTION__); + + $this->assertCount(1, $documents); + $this->assertSame('Spiderman revised', $documents[0]->getAttribute('name')); + $this->assertSame($doc1CreatedAt, $documents[0]->getCreatedAt()); + + // Test upsert with tenant per doc + $doc3Id = Id::unique(); + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->upsertDocuments(__FUNCTION__, [new Document([ + '$id' => $doc3Id, + '$tenant' => 3, + 'name' => 'Superman3', + ])]); + + // Set to tenant 3 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(3) + ->getDocument(__FUNCTION__, $doc3Id); + + $this->assertEquals('Superman3', $doc['name']); + $this->assertEquals(3, $doc->getTenant()); + $this->assertEquals($doc3Id, $doc->getId()); + + // Test no read from other tenants + $docs = $database + ->setTenantPerDocument(false) + ->setTenant(1) + ->find(__FUNCTION__); + + $this->assertEquals(1, \count($docs)); + + // Ensure no cross-tenant read from upsert + $doc = $database + ->setTenant(1) + ->setTenantPerDocument(false) + ->getDocument(__FUNCTION__, $doc3Id); + + $this->assertEquals(true, $doc->isEmpty()); + + // Upsert new documents with different tenants. The sequence lookup binds one + // placeholder per distinct tenant, so a cross-tenant batch has to keep each + // tenant's value at the position its placeholder was named for -- collected here + // because $onNext is the only way these documents reach the caller. + $doc4Id = Id::unique(); + $doc5Id = Id::unique(); + $sequences = []; + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->upsertDocuments( + __FUNCTION__, + [new Document([ + '$id' => $doc4Id, + '$tenant' => 4, + 'name' => 'Superman4', + ]), new Document([ + '$id' => $doc5Id, + '$tenant' => 5, + 'name' => 'Superman5', + ])], + onNext: function (Document $document) use (&$sequences) { + $sequences[$document->getId()] = $document->getSequence(); + } + ); + + $this->assertCount(2, $sequences); + $this->assertNotEmpty($sequences[$doc4Id]); + $this->assertNotEmpty($sequences[$doc5Id]); + + // Set to tenant 4 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(4) + ->getDocument(__FUNCTION__, $doc4Id); + + $this->assertEquals('Superman4', $doc['name']); + $this->assertEquals(4, $doc->getTenant()); + $this->assertEquals($doc->getSequence(), $sequences[$doc4Id]); + + // Set to tenant 5 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(5) + ->getDocument(__FUNCTION__, $doc5Id); + + $this->assertEquals('Superman5', $doc['name']); + $this->assertEquals(5, $doc->getTenant()); + $this->assertEquals($doc->getSequence(), $sequences[$doc5Id]); + + // Update names via upsert + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->upsertDocuments(__FUNCTION__, [new Document([ + '$id' => $doc4Id, + '$tenant' => 4, + 'name' => 'Superman4 updated', + ]), new Document([ + '$id' => $doc5Id, + '$tenant' => 5, + 'name' => 'Superman5 updated', + ])]); + + // Set to tenant 4 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(4) + ->getDocument(__FUNCTION__, $doc4Id); + + $this->assertEquals('Superman4 updated', $doc['name']); + $this->assertEquals(4, $doc->getTenant()); + + // Set to tenant 5 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(5) + ->getDocument(__FUNCTION__, $doc5Id); + + $this->assertEquals('Superman5 updated', $doc['name']); + $this->assertEquals(5, $doc->getTenant()); + } + } finally { + $database + ->setSharedTables($sharedTables) + ->setTenantPerDocument($tenantPerDocument) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); + } } - public function testPreserveDatesCreate(): void + public function testSharedTablesTenantPerDocumentUpsertRefreshesTheQueryCache(): void { - $this->getDatabase()->getAuthorization()->disable(); - /** @var Database $database */ $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForAttributes()) { + $adapter = $database->getAdapter(); + + if ( + ! $database->hasSharedTables() + || ! $adapter->hasFeature(Feature\Upserts::class) + || ! $adapter->supports(Capability::Schemas) + || ! $adapter->supports(Capability::Caching) + ) { $this->expectNotToPerformAssertions(); + return; } - $database->setPreserveDates(true); - - $database->createCollection('preserve_create_dates'); - - $database->createAttribute('preserve_create_dates', 'attr1', Database::VAR_STRING, 10, false); + $tenantPerDocument = $database->isTenantPerDocument(); + $tenant = $database->getTenant(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $queryCache = $database->getQueryCache(); + $cacheSchema = 'queryCacheTenants_'.static::getTestToken(); - // empty string for $createdAt should throw Structure exception - try { - $date = ''; - $database->createDocument('preserve_create_dates', new Document([ - '$id' => 'doc1', - '$permissions' => [], - 'attr1' => 'value1', - '$createdAt' => $date - ])); - $this->fail('Failed to throw structure exception'); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); - $this->assertEquals('Invalid document structure: Missing required attribute "$createdAt"', $e->getMessage()); + if ($database->exists($cacheSchema)) { + $database->delete($cacheSchema); } try { - $database->createDocuments('preserve_create_dates', [ - new Document([ - '$id' => 'doc2', - '$permissions' => [], - 'attr1' => 'value2', - '$createdAt' => $date - ]), - new Document([ - '$id' => 'doc3', - '$permissions' => [], - 'attr1' => 'value3', - '$createdAt' => $date - ]), - ], batchSize: 2); - $this->fail('Failed to throw structure exception'); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); - $this->assertEquals('Invalid document structure: Missing required attribute "$createdAt"', $e->getMessage()); - } + $database + ->setDatabase($cacheSchema) + ->setNamespace('') + ->setTenant(null) + ->create(); + $database->createCollection(Collection::create( + id: 'notes', + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + $database + ->setTenantPerDocument(true) + ->setQueryCache(new ResultCache($database->getCache())); - // non empty date - $date = '2000-01-01T10:00:00.000+00:00'; + foreach ([5, 6] as $documentTenant) { + $database->createDocument('notes', $this->queryCacheTenantNote($documentTenant, ['alice', 'bob'], 'draft')); + } + foreach (['alice', 'bob'] as $reader) { + $this->assertSame(['note' => 'draft'], $this->queryCacheTenantTitles($database, $reader, 5)); + } - $database->createDocument('preserve_create_dates', new Document([ - '$id' => 'doc1', - '$permissions' => [], - 'attr1' => 'value1', - '$createdAt' => $date - ])); + $database->upsertDocuments('notes', [$this->queryCacheTenantNote(5, ['alice'], 'final')]); - $database->createDocuments('preserve_create_dates', [ - new Document([ - '$id' => 'doc2', - '$permissions' => [], - 'attr1' => 'value2', - '$createdAt' => $date - ]), - new Document([ - '$id' => 'doc3', - '$permissions' => [], - 'attr1' => 'value3', - '$createdAt' => $date, - ]), - new Document([ - '$id' => 'doc4', - '$permissions' => [], - 'attr1' => 'value3', - '$createdAt' => null, - ]), - new Document([ - '$id' => 'doc5', - '$permissions' => [], - 'attr1' => 'value3', - ]), - ], batchSize: 2); - - $doc1 = $database->getDocument('preserve_create_dates', 'doc1'); - $doc2 = $database->getDocument('preserve_create_dates', 'doc2'); - $doc3 = $database->getDocument('preserve_create_dates', 'doc3'); - $doc4 = $database->getDocument('preserve_create_dates', 'doc4'); - $doc5 = $database->getDocument('preserve_create_dates', 'doc5'); - $this->assertEquals($date, $doc1->getAttribute('$createdAt')); - $this->assertEquals($date, $doc2->getAttribute('$createdAt')); - $this->assertEquals($date, $doc3->getAttribute('$createdAt')); - $this->assertNotEmpty($date, $doc4->getAttribute('$createdAt')); - $this->assertNotEquals($date, $doc4->getAttribute('$createdAt')); - $this->assertNotEmpty($date, $doc5->getAttribute('$createdAt')); - $this->assertNotEquals($date, $doc5->getAttribute('$createdAt')); - - $database->deleteCollection('preserve_create_dates'); - - $database->setPreserveDates(false); - - $this->getDatabase()->getAuthorization()->reset(); + $this->assertSame( + [], + $this->queryCacheTenantTitles($database, 'bob', 5), + 'An upsert with no tenant selected revoked bob on tenant 5\'s note, so the query cache must not keep serving it to him', + ); + $this->assertSame(['note' => 'final'], $this->queryCacheTenantTitles($database, 'alice', 5)); + $this->assertSame(['note' => 'draft'], $this->queryCacheTenantTitles($database, 'bob', 6)); + } finally { + $database + ->setQueryCache($queryCache) + ->setTenantPerDocument($tenantPerDocument) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); + } } - public function testGetAttributeLimit(): void - { - $this->assertIsInt($this->getDatabase()->getLimitForAttributes()); - } - public function testGetIndexLimit(): void + /** + * @param list $readers + */ + private function queryCacheTenantNote(int $tenant, array $readers, string $title): Document { - $this->assertEquals(58, $this->getDatabase()->getLimitForIndexes()); + return new Document([ + '$id' => 'note', + '$tenant' => $tenant, + 'title' => $title, + '$permissions' => \array_map( + static fn (string $reader): string => Permission::read(Role::user($reader)), + $readers, + ), + ]); } - public function testGetId(): void + /** + * @return array Titles by document id + */ + private function queryCacheTenantTitles(Database $database, string $reader, int $tenant): array { - $this->assertEquals(20, strlen(ID::unique())); - $this->assertEquals(13, strlen(ID::unique(0))); - $this->assertEquals(13, strlen(ID::unique(-1))); - $this->assertEquals(23, strlen(ID::unique(10))); + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::user($reader)->toString()); + + try { + $titles = []; + foreach ($database->withTenant($tenant, fn (): array => $database->find('notes', [Query::orderAsc('$id')])) as $document) { + $titles[$document->getId()] = $document->getAttribute('title'); + } - // ensure two sequential calls to getId do not give the same result - $this->assertNotEquals(ID::unique(10), ID::unique(10)); + return $titles; + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + } } - public function testSharedTablesUpdateTenant(): void + public function testSharedTablesReadsScopeToTheSelectedTenant(): void { + /** @var Database $database */ $database = $this->getDatabase(); - $sharedTables = $database->getSharedTables(); - $namespace = $database->getNamespace(); - $schema = $database->getDatabase(); - - if (!$database->getAdapter()->getSupportForSchemas()) { - $this->expectNotToPerformAssertions(); - return; - } - if ($database->exists('sharedTables')) { - $database->setDatabase('sharedTables')->delete(); + if (! $database->hasSharedTables()) { + $this->markTestSkipped('Reads are only tenant scoped when tables are shared'); } - $database - ->setDatabase('sharedTables') - ->setNamespace('') - ->setSharedTables(true) - ->setTenant(null) - ->create(); + $tenant = $database->getTenant(); + $tenantPerDocument = $database->isTenantPerDocument(); + $collection = 'sharedTablesTenantScopedReads'; - // Create collection - $database->createCollection(__FUNCTION__, documentSecurity: false); + try { + // A collection whose own metadata row is tenantless, the way a shared pool + // holds one definition for every tenant on it. The collection lookup then has + // no tenant to refuse on, so the document read is the only thing keeping one + // tenant out of another's rows. + $database->setTenant(null)->setTenantPerDocument(true); + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 128, required: true)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); - $database - ->setTenant(1) - ->updateDocument(Database::METADATA, __FUNCTION__, new Document([ - '$id' => __FUNCTION__, - 'name' => 'Scooby Doo', + $database->createDocument($collection, new Document([ + Document::ID => 'one', + Document::TENANT => 1, + 'name' => 'tenant one', + ])); + $database->createDocument($collection, new Document([ + Document::ID => 'two', + Document::TENANT => 2, + 'name' => 'tenant two', ])); - // Ensure tenant was not swapped - $doc = $database - ->setTenant(null) - ->getDocument(Database::METADATA, __FUNCTION__); + $database->setTenantPerDocument(false)->setTenant(1); - $this->assertEquals('Scooby Doo', $doc['name']); + $this->assertSame( + ['one'], + \array_map(fn (Document $document) => $document->getId(), $database->find($collection)) + ); + $this->assertSame(1, $database->count($collection)); + $this->assertTrue($database->getDocument($collection, 'two')->isEmpty()); - // Reset state - $database - ->setSharedTables($sharedTables) - ->setNamespace($namespace) - ->setDatabase($schema); - } + $database->setTenant(null)->setTenantPerDocument(true); + $this->assertCount(0, $database->find($collection)); + $this->assertSame(0, $database->count($collection)); + $this->assertTrue($database->getDocument($collection, 'one')->isEmpty()); + } finally { + $database->setTenant($tenant)->setTenantPerDocument($tenantPerDocument); + } + } - public function testFindOrderByAfterException(): void + public function testCacheFallbackOnFailure(): void { - /** - * ORDER BY - After Exception - * Must be last assertion in test - */ - $document = new Document([ - '$collection' => 'other collection' - ]); - - $this->expectException(Exception::class); - /** @var Database $database */ $database = $this->getDatabase(); - $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorAfter($document) - ]); - } + if (! $this->skipsCacheOnFailure()) { + $this->expectNotToPerformAssertions(); + return; + } - public function testNestedQueryValidation(): void - { - $this->getDatabase()->createCollection(__FUNCTION__, [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => true, - ]) + $collection = 'cacheFallback_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'string', size: 767, required: true), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $this->getDatabase()->createDocuments(__FUNCTION__, [ - new Document([ - '$id' => ID::unique(), - 'name' => 'test1', - ]), - new Document([ - '$id' => ID::unique(), - 'name' => 'doc2', - ]), - ]); - - try { - $this->getDatabase()->find(__FUNCTION__, [ - Query::or([ - Query::equal('name', ['test1']), - Query::search('name', 'doc'), - ]) - ]); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(QueryException::class, $e); - $this->assertEquals('Searching by attribute "name" requires a fulltext index.', $e->getMessage()); - } - } - - - public function testSharedTablesTenantPerDocument(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $sharedTables = $database->getSharedTables(); - $tenantPerDocument = $database->getTenantPerDocument(); - $namespace = $database->getNamespace(); - $schema = $database->getDatabase(); - - if (!$database->getAdapter()->getSupportForSchemas()) { - $this->expectNotToPerformAssertions(); - return; - } - - if ($database->exists('sharedTablesTenantPerDocument')) { - $database->delete('sharedTablesTenantPerDocument'); - } - - $database - ->setDatabase('sharedTablesTenantPerDocument') - ->setNamespace('') - ->setSharedTables(true) - ->setTenant(null) - ->create(); - - // Create collection - $database->createCollection(__FUNCTION__, permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - ], documentSecurity: false); - - $database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 100, false); - $database->createIndex(__FUNCTION__, 'nameIndex', Database::INDEX_KEY, ['name']); - - $doc1Id = ID::unique(); - - // Create doc for tenant 1 - $database - ->setTenant(null) - ->setTenantPerDocument(true) - ->createDocument(__FUNCTION__, new Document([ - '$id' => $doc1Id, - '$tenant' => 1, - 'name' => 'Spiderman', - ])); - - // Set to tenant 1 and read - $doc = $database - ->setTenantPerDocument(false) - ->setTenant(1) - ->getDocument(__FUNCTION__, $doc1Id); - - $this->assertEquals('Spiderman', $doc['name']); - - $doc2Id = ID::unique(); - - // Create doc for tenant 2 - $database - ->setTenant(null) - ->setTenantPerDocument(true) - ->createDocument(__FUNCTION__, new Document([ - '$id' => $doc2Id, - '$tenant' => 2, - 'name' => 'Batman', - ])); - - // Set to tenant 2 and read - $doc = $database - ->setTenantPerDocument(false) - ->setTenant(2) - ->getDocument(__FUNCTION__, $doc2Id); - - $this->assertEquals('Batman', $doc['name']); - $this->assertEquals(2, $doc->getTenant()); - - // Ensure no read cross-tenant - $docs = $database - ->setTenantPerDocument(false) - ->setTenant(1) - ->find(__FUNCTION__); - - $this->assertEquals(1, \count($docs)); - $this->assertEquals($doc1Id, $docs[0]->getId()); - - if ($database->getAdapter()->getSupportForUpserts()) { - // Test upsert with tenant per doc - $doc3Id = ID::unique(); - $database - ->setTenant(null) - ->setTenantPerDocument(true) - ->upsertDocuments(__FUNCTION__, [new Document([ - '$id' => $doc3Id, - '$tenant' => 3, - 'name' => 'Superman3', - ])]); - - // Set to tenant 3 and read - $doc = $database - ->setTenantPerDocument(false) - ->setTenant(3) - ->getDocument(__FUNCTION__, $doc3Id); - - $this->assertEquals('Superman3', $doc['name']); - $this->assertEquals(3, $doc->getTenant()); - $this->assertEquals($doc3Id, $doc->getId()); - - // Test no read from other tenants - $docs = $database - ->setTenantPerDocument(false) - ->setTenant(1) - ->find(__FUNCTION__); - - $this->assertEquals(1, \count($docs)); - - // Ensure no cross-tenant read from upsert - $doc = $database - ->setTenant(1) - ->setTenantPerDocument(false) - ->getDocument(__FUNCTION__, $doc3Id); - - $this->assertEquals(true, $doc->isEmpty()); - - // Upsert new documents with different tenants. The sequence lookup binds one - // placeholder per distinct tenant, so a cross-tenant batch has to keep each - // tenant's value at the position its placeholder was named for -- collected here - // because $onNext is the only way these documents reach the caller. - $doc4Id = ID::unique(); - $doc5Id = ID::unique(); - $sequences = []; - $database - ->setTenant(null) - ->setTenantPerDocument(true) - ->upsertDocuments( - __FUNCTION__, - [new Document([ - '$id' => $doc4Id, - '$tenant' => 4, - 'name' => 'Superman4', - ]), new Document([ - '$id' => $doc5Id, - '$tenant' => 5, - 'name' => 'Superman5', - ])], - onNext: function (Document $document) use (&$sequences) { - $sequences[$document->getId()] = $document->getSequence(); - } - ); - - $this->assertCount(2, $sequences); - $this->assertNotEmpty($sequences[$doc4Id]); - $this->assertNotEmpty($sequences[$doc5Id]); - - // Set to tenant 4 and read - $doc = $database - ->setTenantPerDocument(false) - ->setTenant(4) - ->getDocument(__FUNCTION__, $doc4Id); - - $this->assertEquals('Superman4', $doc['name']); - $this->assertEquals(4, $doc->getTenant()); - $this->assertEquals($doc->getSequence(), $sequences[$doc4Id]); - - // Set to tenant 5 and read - $doc = $database - ->setTenantPerDocument(false) - ->setTenant(5) - ->getDocument(__FUNCTION__, $doc5Id); - - $this->assertEquals('Superman5', $doc['name']); - $this->assertEquals(5, $doc->getTenant()); - $this->assertEquals($doc->getSequence(), $sequences[$doc5Id]); - - // Update names via upsert - $database - ->setTenant(null) - ->setTenantPerDocument(true) - ->upsertDocuments(__FUNCTION__, [new Document([ - '$id' => $doc4Id, - '$tenant' => 4, - 'name' => 'Superman4 updated', - ]), new Document([ - '$id' => $doc5Id, - '$tenant' => 5, - 'name' => 'Superman5 updated', - ])]); - - // Set to tenant 4 and read - $doc = $database - ->setTenantPerDocument(false) - ->setTenant(4) - ->getDocument(__FUNCTION__, $doc4Id); - - $this->assertEquals('Superman4 updated', $doc['name']); - $this->assertEquals(4, $doc->getTenant()); - - // Set to tenant 5 and read - $doc = $database - ->setTenantPerDocument(false) - ->setTenant(5) - ->getDocument(__FUNCTION__, $doc5Id); - - $this->assertEquals('Superman5 updated', $doc['name']); - $this->assertEquals(5, $doc->getTenant()); - } - - // Reset instance - $database - ->setSharedTables($sharedTables) - ->setTenantPerDocument($tenantPerDocument) - ->setNamespace($namespace) - ->setDatabase($schema); - } - - - public function testCacheFallback(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForCacheSkipOnFailure()) { - $this->expectNotToPerformAssertions(); - return; - } - - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - - // Write mock data - $database->createCollection('testRedisFallback', attributes: [ - new Document([ - '$id' => ID::custom('string'), - 'type' => Database::VAR_STRING, - 'size' => 767, - 'required' => true, - ]) - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - - $database->createDocument('testRedisFallback', new Document([ + $database->createDocument($collection, new Document([ '$id' => 'doc1', 'string' => 'text📝', ])); - $database->createIndex('testRedisFallback', 'index1', Database::INDEX_KEY, ['string']); - $this->assertCount(1, $database->find('testRedisFallback', [Query::equal('string', ['text📝'])])); - - // Bring down Redis - $stdout = ''; - $stderr = ''; - Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker stop', "", $stdout, $stderr); - - // Check we can read data still - $this->assertCount(1, $database->find('testRedisFallback', [Query::equal('string', ['text📝'])])); - $this->assertFalse(($database->getDocument('testRedisFallback', 'doc1'))->isEmpty()); - - // Check we cannot modify data - try { - $database->updateDocument('testRedisFallback', 'doc1', new Document([ - 'string' => 'text📝 updated', - ])); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertEquals('Redis server redis:6379 went away', $e->getMessage()); - } - - try { - $database->deleteDocument('testRedisFallback', 'doc1'); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertEquals('Redis server redis:6379 went away', $e->getMessage()); - } - - // Bring backup Redis - Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker start', "", $stdout, $stderr); - sleep(5); - - $this->assertCount(1, $database->find('testRedisFallback', [Query::equal('string', ['text📝'])])); - } - - public function testCacheReconnect(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForCacheSkipOnFailure()) { - $this->expectNotToPerformAssertions(); - return; - } - - // Wait for Redis to be fully healthy after previous test - $this->waitForRedis(); - - // Create new cache with reconnection enabled - $redis = new \Redis(); - $redis->connect('redis', 6379); - $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); - - // For Mirror, we need to set cache on both source and destination - if ($database instanceof Mirror) { - $database->getSource()->setCache($cache); + $database->createIndex($collection, Index::key(key: 'index1', attributes: ['string'])); + $this->assertCount(1, $database->find($collection, [Query::equal('string', ['text📝'])])); - $mirrorRedis = new \Redis(); - $mirrorRedis->connect('redis-mirror', 6379); - $mirrorCache = new Cache((new RedisAdapter($mirrorRedis))->setMaxRetries(3)); - $database->getDestination()->setCache($mirrorCache); + // Stopping the shared Redis container would also fail every test paratest runs alongside this one, + // so the outage is a client whose every command fails the way a lost server does. + $unreachable = $this->createStub(Redis::class); + foreach (['hGet', 'hSet', 'hKeys', 'eval', 'evalSha', 'flushDB', 'dbSize', 'ping'] as $command) { + $unreachable->method($command)->willThrowException(new RedisException('Redis server redis:6379 went away')); } - $database->setCache($cache); + $original = $database->getCache(); + $destination = $database instanceof Mirror ? $database->getDestination() : null; + $destinationCache = $destination?->getCache(); - $database->getAuthorization()->cleanRoles(); - $database->getAuthorization()->addRole(Role::any()->toString()); + $database->setCache(new Cache((new RedisAdapter($unreachable))->setMaxRetries(0))); try { - $database->createCollection('testCacheReconnect', attributes: [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => true, - ]) - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + $this->assertCount(1, $database->find($collection, [Query::equal('string', ['text📝'])])); + $this->assertSame('text📝', $database->getDocument($collection, 'doc1')->getAttribute('string')); - $database->createDocument('testCacheReconnect', new Document([ - '$id' => 'reconnect_doc', - 'title' => 'Test Document', - ])); + try { + $database->updateDocument($collection, 'doc1', new Document([ + 'string' => 'text📝 updated', + ])); + $this->fail('Updating a document must fail while its cache entry cannot be invalidated'); + } catch (Throwable $e) { + $this->assertInstanceOf(RedisException::class, $e); + $this->assertSame('Redis server redis:6379 went away', $e->getMessage()); + } - // Cache the document - $doc = $database->getDocument('testCacheReconnect', 'reconnect_doc'); - $this->assertEquals('Test Document', $doc->getAttribute('title')); + try { + $database->deleteDocument($collection, 'doc1'); + $this->fail('Deleting a document must fail while its cache entry cannot be invalidated'); + } catch (Throwable $e) { + $this->assertInstanceOf(RedisException::class, $e); + $this->assertSame('Redis server redis:6379 went away', $e->getMessage()); + } - // Bring down Redis - $stdout = ''; - $stderr = ''; - Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker stop', "", $stdout, $stderr); - sleep(1); + $this->assertSame('text📝', $database->getDocument($collection, 'doc1')->getAttribute('string')); + } finally { + $database->setCache($original); + if ($destination !== null && $destinationCache !== null) { + $destination->setCache($destinationCache); + } + } - // Bring back Redis - Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker start', "", $stdout, $stderr); - $this->waitForRedis(); + $this->assertCount(1, $database->find($collection, [Query::equal('string', ['text📝'])])); - // Cache should reconnect - read should work - $doc = $database->getDocument('testCacheReconnect', 'reconnect_doc'); - $this->assertEquals('Test Document', $doc->getAttribute('title')); + $updated = $database->updateDocument($collection, 'doc1', new Document([ + 'string' => 'text📝 updated', + ])); + $this->assertSame('text📝 updated', $updated->getAttribute('string')); + $this->assertSame('text📝 updated', $database->getDocument($collection, 'doc1')->getAttribute('string')); - // Update should work after reconnect - $database->updateDocument('testCacheReconnect', 'reconnect_doc', new Document([ - '$id' => 'reconnect_doc', - 'title' => 'Updated Title', - ])); + $this->assertTrue($database->deleteDocument($collection, 'doc1')); + $this->assertTrue($database->getDocument($collection, 'doc1')->isEmpty()); - $doc = $database->getDocument('testCacheReconnect', 'reconnect_doc'); - $this->assertEquals('Updated Title', $doc->getAttribute('title')); - } finally { - // Ensure Redis is running - $stdout = ''; - $stderr = ''; - Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker start', "", $stdout, $stderr); - $this->waitForRedis(); - - // Cleanup collection if it exists - if ($database->exists() && !$database->getCollection('testCacheReconnect')->isEmpty()) { - $database->deleteCollection('testCacheReconnect'); - } - } + $database->deleteCollection($collection); } /** @@ -873,8 +679,8 @@ public function testTransactionAtomicity(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('transactionAtomicity'); - $database->createAttribute('transactionAtomicity', 'title', Database::VAR_STRING, 128, true); + $database->createCollection(Collection::create(id: 'transactionAtomicity')); + $database->createAttribute('transactionAtomicity', Attribute::string(key: 'title', size: 128, required: true)); // Verify a successful transaction commits $doc = $database->withTransaction(function () use ($database) { @@ -915,91 +721,321 @@ public function testTransactionAtomicity(): void $database->deleteCollection('transactionAtomicity'); } - /** - * Test that withTransaction correctly resets inTransaction state - * when a known exception (DuplicateException) is thrown after successful rollback. - */ - public function testTransactionStateAfterKnownException(): void + public function testReadersSeeTheNewDocumentAfterATransactionCommits(): void { - /** @var Database $database */ $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Caching)) { + $this->markTestSkipped('Adapter does not use the document cache.'); + } - $database->createCollection('txKnownException'); - $database->createAttribute('txKnownException', 'title', Database::VAR_STRING, 128, true); - - $database->createDocument('txKnownException', new Document([ - '$id' => 'existing_doc', - '$permissions' => [ - Permission::read(Role::any()), - ], - 'title' => 'Original', - ])); + $collection = 'txDocumentCacheCommit'; + $this->createCachedUsers($database, $collection); - // Trigger a DuplicateException inside withTransaction by inserting a duplicate ID try { - $database->withTransaction(function () use ($database) { - $database->createDocument('txKnownException', new Document([ - '$id' => 'existing_doc', - '$permissions' => [ - Permission::read(Role::any()), - ], - 'title' => 'Duplicate', - ])); + $database->withTransaction(function () use ($database, $collection): void { + $database->updateDocument($collection, 'user', new Document(['name' => 'updated'])); + $this->assertSame('updated', $database->getDocument($collection, 'user')->getAttribute('name'), 'A transaction reads its own write'); }); - $this->fail('Expected DuplicateException was not thrown'); - } catch (DuplicateException $e) { - // Expected + + $this->assertSame('updated', $database->getDocument($collection, 'user')->getAttribute('name'), 'The copy cached before the transaction must not outlive its commit'); + } finally { + $database->deleteCollection($collection); } + } - // inTransaction must be false after the exception - $this->assertFalse( - $database->getAdapter()->inTransaction(), - 'Adapter should not be in transaction after DuplicateException' - ); + public function testReadersSeeTheOriginalDocumentAfterATransactionRollsBack(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Caching)) { + $this->markTestSkipped('Adapter does not use the document cache.'); + } - // Database should still be functional - $doc = $database->getDocument('txKnownException', 'existing_doc'); - $this->assertEquals('Original', $doc->getAttribute('title')); + $collection = 'txDocumentCacheRollback'; + $this->createCachedUsers($database, $collection); - $database->deleteCollection('txKnownException'); + try { + try { + $database->withTransaction(function () use ($database, $collection): void { + $database->updateDocument($collection, 'user', new Document(['name' => 'rolled-back'])); + $this->assertSame('rolled-back', $database->getDocument($collection, 'user')->getAttribute('name'), 'A transaction reads its own write'); + + throw new ConflictException('rollback'); + }); + } catch (ConflictException) { + } + + $this->assertSame('original', $database->getDocument($collection, 'user')->getAttribute('name'), 'A rolled back write must never be served'); + $this->assertSame('original', $database->getDocument($collection, 'user')->getAttribute('name'), 'A rolled back write must never be cached'); + } finally { + $database->deleteCollection($collection); + } } - /** - * Test that withTransaction correctly resets inTransaction state - * when retries are exhausted for a generic exception. - * - * MongoDB's withTransaction has no retry logic, so this test - * only applies to SQL-based adapters. - */ - public function testTransactionStateAfterRetriesExhausted(): void + public function testReadersSeeTheNewDocumentAfterANestedTransactionCommits(): void { - /** @var Database $database */ $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForTransactionRetries()) { + if (! $database->getAdapter()->supports(Capability::Caching)) { + $this->markTestSkipped('Adapter does not use the document cache.'); + } + if (! $database->getAdapter()->supports(Capability::TransactionNested)) { $this->expectNotToPerformAssertions(); + return; } - $attempts = 0; + $collection = 'txNestedDocumentCache'; + $this->createCachedUsers($database, $collection); try { - $database->withTransaction(function () use (&$attempts) { - $attempts++; - throw new \RuntimeException('Persistent failure'); - }); - } catch (\RuntimeException $e) { - $this->assertEquals('Persistent failure', $e->getMessage()); - } + $database->withTransaction(function () use ($database, $collection): void { + $database->withTransaction(function () use ($database, $collection): void { + $database->updateDocument($collection, 'user', new Document(['name' => 'updated'])); + }); - // Should have attempted 3 times (initial + 2 retries) - $this->assertEquals(3, $attempts, 'Should have exhausted all retry attempts'); + $this->assertSame('updated', $database->getDocument($collection, 'user')->getAttribute('name'), 'The outer transaction reads the nested write'); + }); - // inTransaction must be false after retries exhausted - $this->assertFalse( - $database->getAdapter()->inTransaction(), - 'Adapter should not be in transaction after retries exhausted' - ); + $this->assertSame('updated', $database->getDocument($collection, 'user')->getAttribute('name'), 'The copy cached before the transaction must not outlive the outer commit'); + } finally { + $database->deleteCollection($collection); + } + } + + public function testASiblingReadAfterAWriteRunsNoStatement(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Caching)) { + $this->markTestSkipped('Adapter does not use the document cache.'); + } + if (! $database->getAdapter()->hasFeature(Feature\RawQuery::class)) { + $this->markTestSkipped($database->getAdapter()::class.' reports no statements to the profiler.'); + } + + $collection = 'siblingDocumentCache'; + $this->createCachedUsers($database, $collection); + $database->createDocument($collection, new Document([ + '$id' => 'sibling', + 'name' => 'sibling', + ])); + $this->assertSame('sibling', $database->getDocument($collection, 'sibling')->getAttribute('name')); + + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + + try { + $database->updateDocument($collection, 'user', new Document(['name' => 'updated'])); + + $profiler->reset(); + $this->assertSame('sibling', $database->getDocument($collection, 'sibling')->getAttribute('name')); + $this->assertSame(0, $profiler->getQueryCount(), 'A write to one document must leave its siblings cached (7.3.12: 0 statements)'); + + $this->assertSame('updated', $database->getDocument($collection, 'user')->getAttribute('name')); + $this->assertGreaterThan(0, $profiler->getQueryCount(), 'The written document itself must be read again'); + } finally { + $database->setProfiling(false); + $database->deleteCollection($collection); + } + } + + private function createCachedUsers(Database $database, string $collection): void + { + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $database->createDocument($collection, new Document([ + '$id' => 'user', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument($collection, 'user')->getAttribute('name')); + } + + public function testCacheInvalidationDoesNotAddRedisKeysPerWrite(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Caching)) { + $this->markTestSkipped('Adapter does not use the document cache.'); + } + + $collection = 'ownerKeys'.Id::unique(); + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $redis = new Redis(); + $redis->connect('redis', 6379); + $cache = new Cache(new RedisAdapter($redis)); + $original = $database->getCache(); + $queryCache = $database->getQueryCache(); + $destination = $database instanceof Mirror ? $database->getDestination() : null; + $destinationCache = $destination?->getCache(); + $database->setCache($cache)->setQueryCache(new ResultCache($cache)); + + $documents = 10; + try { + for ($index = 0; $index < $documents; $index++) { + $database->createDocument($collection, new Document(['$id' => 'doc'.$index, 'name' => 'doc '.$index])); + } + + $keysAfterFirstRound = 0; + for ($round = 1; $round <= 10; $round++) { + $database->withTransaction(function () use ($database, $collection, $documents, $round): void { + for ($index = 0; $index < $documents; $index++) { + $database->updateDocument($collection, 'doc'.$index, new Document(['name' => 'round '.$round])); + } + }); + for ($index = 0; $index < $documents; $index++) { + $this->assertSame('round '.$round, $database->getDocument($collection, 'doc'.$index)->getAttribute('name')); + } + + if ($round === 1) { + $keysAfterFirstRound = \count($this->scanKeys($redis, $collection)); + } + } + + $keys = \count($this->scanKeys($redis, $collection)); + $this->assertSame($keysAfterFirstRound, $keys, 'Redis keeps a purged key with no expiry, so writes and reads of the same documents must not add keys'); + $databases = $destination === null ? 1 : 2; + $this->assertLessThanOrEqual(3 * $documents * $databases, $keys, 'Each database writing to the cache holds at most one key per document plus a few per collection'); + $this->assertCount($documents, $database->find($collection, [Query::limit(100)])); + } finally { + $database->setQueryCache($queryCache)->setCache($original); + if ($destination !== null && $destinationCache !== null) { + $destination->setCache($destinationCache); + } + $database->deleteCollection($collection); + } + } + + /** + * Test that withTransaction correctly resets inTransaction state + * when a known exception (DuplicateException) is thrown after successful rollback. + */ + public function testTransactionStateAfterKnownException(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: 'txKnownException')); + $database->createAttribute('txKnownException', Attribute::string(key: 'title', size: 128, required: true)); + + $database->createDocument('txKnownException', new Document([ + '$id' => 'existing_doc', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => 'Original', + ])); + + // Trigger a DuplicateException inside withTransaction by inserting a duplicate ID + try { + $database->withTransaction(function () use ($database) { + $database->createDocument('txKnownException', new Document([ + '$id' => 'existing_doc', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => 'Duplicate', + ])); + }); + $this->fail('Expected DuplicateException was not thrown'); + } catch (DuplicateException $e) { + // Expected + } + + // inTransaction must be false after the exception + $this->assertFalse( + $database->getAdapter()->inTransaction(), + 'Adapter should not be in transaction after DuplicateException' + ); + + // Database should still be functional + $doc = $database->getDocument('txKnownException', 'existing_doc'); + $this->assertEquals('Original', $doc->getAttribute('title')); + + $database->deleteCollection('txKnownException'); + } + + /** + * Test that withTransaction correctly resets inTransaction state + * when retries are exhausted for a lock conflict. + */ + public function testTransactionStateAfterRetriesExhausted(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::TransactionRetries)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $attempts = 0; + + try { + $database->withTransaction(function () use (&$attempts) { + $attempts++; + throw new ContentionException('Deadlock detected'); + }); + } catch (ContentionException $e) { + $this->assertSame('Deadlock detected', $e->getMessage()); + } + + $this->assertSame(3, $attempts, 'Should have exhausted all retry attempts'); + + $this->assertFalse( + $database->getAdapter()->inTransaction(), + 'Adapter should not be in transaction after retries exhausted' + ); + } + + /** + * An invalid document fails the same way on every attempt, so the transaction + * rethrows it at once instead of running the callback again. + */ + public function testInvalidDocumentInATransactionIsNotRetried(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'txInvalidDocument'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'title', size: 128, required: true), + ], permissions: [ + Permission::create(Role::any()), + ])); + + $attempts = 0; + $error = null; + + try { + $database->withTransaction(function () use ($database, $collection, &$attempts): void { + $attempts++; + $database->createDocument($collection, new Document(['$id' => 'invalid'])); + }); + } catch (StructureException $caught) { + $error = $caught; + } finally { + $database->deleteCollection($collection); + } + + $this->assertInstanceOf(StructureException::class, $error); + $this->assertSame(1, $attempts, 'A deterministic failure must not run again'); + $this->assertFalse($database->getAdapter()->inTransaction()); } /** @@ -1014,13 +1050,14 @@ public function testNestedTransactionState(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForNestedTransactions()) { + if (! $database->getAdapter()->supports(Capability::TransactionNested)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('txNested'); - $database->createAttribute('txNested', 'title', Database::VAR_STRING, 128, true); + $database->createCollection(Collection::create(id: 'txNested')); + $database->createAttribute('txNested', Attribute::string(key: 'title', size: 128, required: true)); $database->createDocument('txNested', new Document([ '$id' => 'nested_existing', @@ -1031,7 +1068,7 @@ public function testNestedTransactionState(): void ])); // Outer transaction should succeed even if inner transaction throws - $result = $database->withTransaction(function () use ($database) { + $database->withTransaction(function () use ($database) { $database->createDocument('txNested', new Document([ '$id' => 'outer_doc', '$permissions' => [ @@ -1058,8 +1095,6 @@ public function testNestedTransactionState(): void return true; }); - $this->assertTrue($result); - // inTransaction must be false after everything completes $this->assertFalse( $database->getAdapter()->inTransaction(), @@ -1081,17 +1116,743 @@ public function testNestedTransactionState(): void /** * Wait for Redis to be ready with a readiness probe */ - private function waitForRedis(int $maxRetries = 10, int $delayMs = 500): void + + public function testCacheReconnect(): void + { + $database = $this->getDatabase(); + + if (! $this->skipsCacheOnFailure()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $redis = new Redis(); + $redis->connect('redis', 6379); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); + + $original = $database->getCache(); + $database->setCache($cache); + + $collection = 'cacheReconnect_'.uniqid(); + + try { + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'title', size: 255, required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $database->createDocument($collection, new Document([ + '$id' => 'reconnect_doc', + 'title' => 'Test Document', + ])); + + $this->assertSame('Test Document', $database->getDocument($collection, 'reconnect_doc')->getAttribute('title')); + + $this->dropRedisConnection($redis); + + $this->assertTrue((bool) $cache->save('reconnect_probe', 'alive'), 'The cache must reconnect after the server dropped the connection'); + $this->assertSame('alive', $cache->load('reconnect_probe', 60)); + + $this->assertSame('Test Document', $database->getDocument($collection, 'reconnect_doc')->getAttribute('title')); + + $database->updateDocument($collection, 'reconnect_doc', new Document([ + '$id' => 'reconnect_doc', + 'title' => 'Updated Title', + ])); + + $this->assertSame('Updated Title', $database->getDocument($collection, 'reconnect_doc')->getAttribute('title')); + } finally { + $database->setCache($original); + $database->deleteCollection($collection); + } + } + + /** + * @return array + */ + private function scanKeys(Redis $redis, string $collection): array + { + $keys = []; + $iterator = null; + do { + $batch = $redis->scan($iterator, '*'.\strtolower($collection).'*', 1000); + if (\is_array($batch)) { + \array_push($keys, ...$batch); + } + } while ($iterator > 0); + + $keys = \array_values(\array_unique($keys)); + \sort($keys); + + return $keys; + } + + private function dropRedisConnection(Redis $redis): void + { + $id = $redis->rawCommand('CLIENT', 'ID'); + $this->assertIsInt($id); + + $killer = new Redis(); + $killer->connect('redis', 6379); + $killer->rawCommand('CLIENT', 'KILL', 'ID', (string) $id); + $killer->close(); + } + + public function testCountTimeout(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Timeouts::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(Collection::create(id: 'count-timeouts')); + + try { + $database->createAttribute('count-timeouts', Attribute::string(key: 'longtext', size: 100000000, required: true)); + + $longtext = file_get_contents(__DIR__.'/../../../resources/longtext.txt'); + $this->assertIsString($longtext); + + for ($i = 0; $i < 20; $i++) { + $database->createDocument('count-timeouts', new Document([ + 'longtext' => $longtext, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); + } + + $database->setTimeout(1); + + $thrown = null; + try { + $database->count('count-timeouts', [ + Query::containsString('longtext', ['needle-that-does-not-exist']), + ]); + } catch (Exception $e) { + $thrown = $e; + } + + $this->assertInstanceOf(TimeoutException::class, $thrown, 'count() must throw a timeout exception'); + } finally { + $database->clearTimeout(); + $database->deleteCollection('count-timeouts'); + } + } + + public function testTimeoutSurvivesReconnect(): void { - for ($i = 0; $i < $maxRetries; $i++) { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter instanceof MariaDB || ! $adapter->getDriver() instanceof PDO) { + $this->expectNotToPerformAssertions(); + + return; + } + + $driver = $adapter->getDriver(); + + $interruptedByTimeout = function () use ($driver): bool { + $statement = $driver->prepare('SELECT SLEEP(3)'); try { - $redis = new \Redis(); - $redis->connect('redis', 6379); - $redis->ping(); - return; - } catch (\RedisException $e) { - usleep($delayMs * 1000); + $statement->execute(); + } catch (PDOException $error) { + if (\in_array($error->errorInfo[1] ?? null, [1969, 3024], true)) { + return true; + } + + throw $error; } + + return \in_array($statement->fetchColumn(), [1, '1'], true); + }; + + $database->setTimeout(1000); + + try { + $connection = $adapter->id(); + $this->assertMatchesRegularExpression('/^\d+$/', $connection); + $driver->exec('SET SESSION wait_timeout = 1'); + \sleep(3); + + $interrupted = ['statement that reconnects' => $interruptedByTimeout()]; + $this->assertNotSame($connection, $adapter->id(), 'The server closing the idle session must have forced a reconnect'); + + $database->setTimeout(1000); + $interrupted['same timeout set again'] = $interruptedByTimeout(); + + $database->reconnect(); + $interrupted['explicit reconnect'] = $interruptedByTimeout(); + + $this->assertSame([ + 'statement that reconnects' => true, + 'same timeout set again' => true, + 'explicit reconnect' => true, + ], $interrupted, 'The 1s timeout must cut SELECT SLEEP(3) short after every reconnect'); + } finally { + $driver->exec('SET SESSION wait_timeout = DEFAULT'); + $database->clearTimeout(); + } + } + + public function testFindOrderByAfterException(): void + { + $database = $this->getDatabase(); + $collection = 'cursorCollection_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection)); + + try { + $database->find($collection, [ + Query::limit(2), + Query::offset(0), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + '$sequence' => '1', + '$collection' => 'other collection', + ])), + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertSame('cursor Document must be from the same Collection.', $e->getMessage()); + } finally { + $database->deleteCollection($collection); + } + } + + public function testGetAttributeLimit(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if ($adapter->limits()->attributes === 0) { + $this->assertSame(0, $database->getLimitForAttributes(), 'An adapter without a column limit reports no limit'); + + return; + } + + $this->assertSame($adapter->limits()->attributes - $adapter->limits()->defaultAttributes, $database->getLimitForAttributes(), 'The limit must leave room for the internal columns'); + } + + public function testGetIndexLimit(): void + { + $this->assertSame(58, $this->getDatabase()->getLimitForIndexes()); + } + + public function testGetId(): void + { + $this->assertSame(20, strlen(Id::unique())); + $this->assertSame(13, strlen(Id::unique(0))); + $this->assertSame(13, strlen(Id::unique(-1))); + $this->assertSame(23, strlen(Id::unique(10))); + + $this->assertNotSame(Id::unique(10), Id::unique(10)); + } + + public function testNestedQueryValidation(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: __FUNCTION__, attributes: [ + Attribute::string(key: 'name', size: 255, required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + + $database->createDocuments(__FUNCTION__, [ + new Document([ + '$id' => Id::unique(), + 'name' => 'test1', + ]), + new Document([ + '$id' => Id::unique(), + 'name' => 'doc2', + ]), + ]); + + try { + $database->find(__FUNCTION__, [ + Query::or([ + Query::equal('name', ['test1']), + Query::search('name', 'doc'), + ]), + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(QueryException::class, $e); + $this->assertSame('Searching by attribute "name" requires a fulltext index.', $e->getMessage()); + } finally { + $database->deleteCollection(__FUNCTION__); + } + } + + public function testPreserveDatesCreate(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->getAuthorization()->disable(); + $database->setPreserveDates(true); + + try { + $database->createCollection(Collection::create(id: 'preserve_create_dates', attributes: [ + Attribute::string(key: 'attr1', size: 10), + ])); + + $date = ''; + + try { + $database->createDocument('preserve_create_dates', new Document([ + '$id' => 'doc1', + '$permissions' => [], + 'attr1' => 'value1', + '$createdAt' => $date, + ])); + $this->fail('Failed to throw structure exception'); + } catch (Exception $e) { + $this->assertInstanceOf(StructureException::class, $e); + $this->assertSame('Invalid document structure: Missing required attribute "$createdAt"', $e->getMessage()); + } + + try { + $database->createDocuments('preserve_create_dates', [ + new Document([ + '$id' => 'doc2', + '$permissions' => [], + 'attr1' => 'value2', + '$createdAt' => $date, + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => [], + 'attr1' => 'value3', + '$createdAt' => $date, + ]), + ], batchSize: 2); + $this->fail('Failed to throw structure exception'); + } catch (Exception $e) { + $this->assertInstanceOf(StructureException::class, $e); + $this->assertSame('Invalid document structure: Missing required attribute "$createdAt"', $e->getMessage()); + } + + $date = '2000-01-01T10:00:00.000+00:00'; + + $database->createDocument('preserve_create_dates', new Document([ + '$id' => 'doc1', + '$permissions' => [], + 'attr1' => 'value1', + '$createdAt' => $date, + ])); + + $database->createDocuments('preserve_create_dates', [ + new Document([ + '$id' => 'doc2', + '$permissions' => [], + 'attr1' => 'value2', + '$createdAt' => $date, + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => [], + 'attr1' => 'value3', + '$createdAt' => $date, + ]), + new Document([ + '$id' => 'doc4', + '$permissions' => [], + 'attr1' => 'value3', + '$createdAt' => null, + ]), + new Document([ + '$id' => 'doc5', + '$permissions' => [], + 'attr1' => 'value3', + ]), + ], batchSize: 2); + + $doc1 = $database->getDocument('preserve_create_dates', 'doc1'); + $doc2 = $database->getDocument('preserve_create_dates', 'doc2'); + $doc3 = $database->getDocument('preserve_create_dates', 'doc3'); + $doc4 = $database->getDocument('preserve_create_dates', 'doc4'); + $doc5 = $database->getDocument('preserve_create_dates', 'doc5'); + $this->assertSame($date, $doc1->getCreatedAt()); + $this->assertSame($date, $doc2->getCreatedAt()); + $this->assertSame($date, $doc3->getCreatedAt()); + $this->assertNotEmpty($doc4->getCreatedAt()); + $this->assertNotSame($date, $doc4->getCreatedAt(), 'A null date is replaced by the current time'); + $this->assertNotEmpty($doc5->getCreatedAt()); + $this->assertNotSame($date, $doc5->getCreatedAt(), 'A missing date is replaced by the current time'); + } finally { + $database->deleteCollection('preserve_create_dates'); + $database->setPreserveDates(false); + $database->getAuthorization()->reset(); + } + } + + public function testPreserveDatesUpdate(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->getAuthorization()->disable(); + $database->setPreserveDates(true); + + try { + $database->createCollection(Collection::create(id: 'preserve_update_dates', attributes: [ + Attribute::string(key: 'attr1', size: 10), + ])); + + $doc1 = $database->createDocument('preserve_update_dates', new Document([ + '$id' => 'doc1', + '$permissions' => [], + 'attr1' => 'value1', + ])); + + $doc2 = $database->createDocument('preserve_update_dates', new Document([ + '$id' => 'doc2', + '$permissions' => [], + 'attr1' => 'value2', + ])); + + $doc3 = $database->createDocument('preserve_update_dates', new Document([ + '$id' => 'doc3', + '$permissions' => [], + 'attr1' => 'value3', + ])); + + try { + $doc1->setAttribute('$updatedAt', ''); + $database->updateDocument('preserve_update_dates', 'doc1', $doc1); + $this->fail('Failed to throw structure exception'); + } catch (Exception $e) { + $this->assertInstanceOf(StructureException::class, $e); + $this->assertSame('Invalid document structure: Missing required attribute "$updatedAt"', $e->getMessage()); + } + + try { + $database->updateDocuments( + 'preserve_update_dates', + new Document([ + '$updatedAt' => '', + ]), + [ + Query::equal('$id', [ + $doc2->getId(), + $doc3->getId(), + ]), + ] + ); + $this->fail('Failed to throw structure exception'); + } catch (Exception $e) { + $this->assertInstanceOf(StructureException::class, $e); + $this->assertSame('Invalid document structure: Missing required attribute "$updatedAt"', $e->getMessage()); + } + + $newDate = '2000-01-01T10:00:00.000+00:00'; + + $doc1->setAttribute('$updatedAt', $newDate); + $doc1 = $database->updateDocument('preserve_update_dates', 'doc1', $doc1); + $this->assertSame($newDate, $doc1->getUpdatedAt()); + $doc1 = $database->getDocument('preserve_update_dates', 'doc1'); + $this->assertSame($newDate, $doc1->getUpdatedAt()); + + $database->updateDocuments( + 'preserve_update_dates', + new Document([ + '$updatedAt' => $newDate, + ]), + [ + Query::equal('$id', [ + $doc2->getId(), + $doc3->getId(), + ]), + ] + ); + + $doc2 = $database->getDocument('preserve_update_dates', 'doc2'); + $doc3 = $database->getDocument('preserve_update_dates', 'doc3'); + $this->assertSame($newDate, $doc2->getUpdatedAt()); + $this->assertSame($newDate, $doc3->getUpdatedAt()); + } finally { + $database->deleteCollection('preserve_update_dates'); + $database->setPreserveDates(false); + $database->getAuthorization()->reset(); + } + } + + /** + * The server ends the session after the outer transaction wrote A. The nested + * transaction must not begin a fresh transaction on the new connection and commit B + * alone: both calls fail and neither document is stored. + */ + public function testLostConnectionInsideANestedTransactionFailsTheOuterTransaction(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->hasFeature(Feature\Connection::class) || ! $adapter->hasFeature(Feature\RawQuery::class) || $this->engineIs(SQLite::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'lostConnectionNestedTransaction'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'title', size: 16, required: true)); + + $create = fn (string $id): Document => $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [ + Permission::read(Role::any()), + ], + 'title' => $id, + ])); + + try { + $thrown = null; + try { + $database->withTransaction(function () use ($adapter, $create, $database): void { + $create('outer'); + + $end = $adapter instanceof Postgres + ? 'SELECT pg_terminate_backend(pg_backend_pid())' + : 'KILL '.$database->getConnectionId(); + try { + $database->getAuthorization()->skip(fn (): array => $database->rawQuery($end)); + } catch (Throwable) { + // The server ends the session that runs the statement, so the statement itself may fail. + } + + $database->withTransaction(fn (): Document => $create('nested')); + }); + } catch (Throwable $error) { + $thrown = $error; + } + + $this->assertInstanceOf(TransactionException::class, $thrown, 'The outer transaction must fail once the server ended its session'); + $this->assertFalse($adapter->inTransaction(), 'The adapter must not count a transaction after the failure'); + $this->assertTrue($database->getDocument($collection, 'outer')->isEmpty(), 'The outer write must not be stored'); + $this->assertTrue($database->getDocument($collection, 'nested')->isEmpty(), 'The nested write must not be committed on its own'); + } finally { + $database->deleteCollection($collection); + } + } + + public function testWritesReadTheirCollectionDefinitionFromTheCache(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Caching)) { + $this->markTestSkipped('Adapter does not use the document cache.'); + } + if (! $database->getAdapter()->hasFeature(Feature\RawQuery::class)) { + $this->markTestSkipped($database->getAdapter()::class.' reports no statements to the profiler.'); + } + + $collection = 'transactionDocumentCache'; + $this->createCachedUsers($database, $collection); + $database->createDocument($collection, new Document([ + '$id' => 'sibling', + 'name' => 'sibling', + ])); + $this->assertSame('sibling', $database->getDocument($collection, 'sibling')->getAttribute('name')); + + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + $metadata = $database->getNamespace().'_'.Database::METADATA; + + try { + $profiler->reset(); + $database->updateDocument($collection, 'user', new Document(['name' => 'updated'])); + $this->assertSame(0, $this->countStatementsAgainst($profiler, $metadata), 'updateDocument() must read its collection definition from the cache (7.3.12: 0 statements)'); + + $profiler->reset(); + $sibling = $database->withTransaction(function () use ($database, $collection): Document { + $database->updateDocument($collection, 'user', new Document(['name' => 'renamed'])); + + return $database->getDocument($collection, 'sibling'); + }); + $this->assertSame('sibling', $sibling->getAttribute('name')); + $this->assertSame(0, $this->countStatementsAgainst($profiler, $metadata), 'withTransaction(update + get) must read no collection definition on a warm cache (7.3.12: 0 statements)'); + $this->assertSame('renamed', $database->getDocument($collection, 'user')->getAttribute('name')); + } finally { + $database->setProfiling(false); + $database->deleteCollection($collection); + } + } + + private function countStatementsAgainst(Profiler $profiler, string $table): int + { + $statements = 0; + foreach ($profiler->getLogs() as $log) { + if (\str_contains($log->query, $table)) { + $statements++; + } + } + + return $statements; + } + + public function testDocumentCacheRoundTripsStayWithinSevenThreeCounts(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->supports(Capability::Caching)) { + $this->markTestSkipped('Adapter does not use the document cache.'); + } + if ($database instanceof Mirror) { + $this->markTestSkipped('Mirror writes its destination through the same cache, which the counts would include.'); + } + + $redis = new Redis(); + $redis->connect('redis', 6379); + $counting = new CountingCache(new RedisAdapter($redis)); + $original = $database->getCache(); + $queryCache = $database->getQueryCache(); + $database->setCache(new Cache($counting))->setQueryCache(null); + $counted = $database->getAdapter() instanceof MariaDB; + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + + $collection = 'roundTrips'.Id::unique(); + $measure = function (string $operation, int $roundTrips, int $statements, callable $callback) use ($counting, $profiler, $counted): mixed { + $counting->resetOperations(); + $profiler->reset(); + $result = $callback(); + $this->assertLessThanOrEqual($roundTrips, $counting->getOperations(), "{$operation}: cache round trips on a warm cache (7.3.12: {$roundTrips})"); + if ($counted) { + $this->assertLessThanOrEqual($statements, $profiler->getQueryCount(), "{$operation}: statements on a warm cache (7.3.12: {$statements})"); + } + + return $result; + }; + + try { + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + Attribute::integer(key: 'count', default: 0), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + foreach (['written', 'sibling', 'deleted'] as $id) { + $database->createDocument($collection, new Document(['$id' => $id, 'name' => $id])); + $database->getDocument($collection, $id); + } + + $measure('getCollection()', 1, 0, fn () => $database->getCollection($collection)); + $measure('getDocument() hit', 2, 0, fn () => $database->getDocument($collection, 'sibling')); + $measure('find()', 1, 1, fn () => $database->find($collection, [Query::equal('name', ['sibling'])])); + $measure('count()', 1, 1, fn () => $database->count($collection, [Query::equal('name', ['sibling'])])); + $measure('sum()', 1, 1, fn () => $database->sum($collection, 'count')); + $measure('createDocument()', 3, 5, fn () => $database->createDocument($collection, new Document(['$id' => 'created', 'name' => 'created']))); + $measure('updateDocument()', 6, 5, fn () => $database->updateDocument($collection, 'written', new Document(['name' => 'renamed']))); + $measure('getDocument() of a sibling after a write', 2, 0, fn () => $database->getDocument($collection, 'sibling')); + $measure('increaseDocumentAttribute()', 4, 5, fn () => $database->increaseDocumentAttribute($collection, 'sibling', 'count')); + $measure('deleteDocument()', 6, 6, fn () => $database->deleteDocument($collection, 'deleted')); + $database->getDocument($collection, 'written'); + $read = $measure('withTransaction(update + get of a sibling)', 11, 7, fn () => $database->withTransaction(function () use ($database, $collection): Document { + $database->updateDocument($collection, 'written', new Document(['name' => 'again'])); + + return $database->getDocument($collection, 'created'); + })); + $this->assertInstanceOf(Document::class, $read); + $this->assertSame('created', $read->getAttribute('name')); + $this->assertSame('again', $database->getDocument($collection, 'written')->getAttribute('name')); + } finally { + $database->setProfiling(false); + $database->setQueryCache($queryCache)->setCache($original); + $database->deleteCollection($collection); + } + } + + public function testTenantPerDocumentRejectsATenantThatIsNotAnIntegerOrString(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Schemas)) { + $this->markTestSkipped('Tenant per document needs a schema to hold the shared table'); + } + + $sharedTables = $database->hasSharedTables(); + $tenantPerDocument = $database->isTenantPerDocument(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); + + $tenantPerDocumentDatabase = 'tenantPerDocumentFloat_'.static::getTestToken(); + $collection = 'floatTenants'; + + if ($database->exists($tenantPerDocumentDatabase)) { + $database->delete($tenantPerDocumentDatabase); + } + + $database + ->setDatabase($tenantPerDocumentDatabase) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); + + try { + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 100)); + + $database->setTenant(null)->setTenantPerDocument(true); + + try { + $database->createDocument($collection, new Document([ + '$id' => 'floatTenant', + '$tenant' => 1.0, + 'name' => 'Spiderman', + ])); + $this->fail('A tenant that is neither an integer nor a string was accepted'); + } catch (DatabaseException $error) { + $this->assertSame('Missing tenant. Tenant must be set when tenant per document is enabled.', $error->getMessage()); + } + + $stored = $database + ->setTenantPerDocument(false) + ->setTenant(1) + ->getDocument($collection, 'floatTenant'); + + $this->assertTrue($stored->isEmpty()); + } finally { + $database + ->setSharedTables($sharedTables) + ->setTenantPerDocument($tenantPerDocument) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); } } } diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 9d6666e52c..63ec28bfb4 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -2,20 +2,46 @@ namespace Tests\E2E\Adapter\Scopes; +use DateTime as NativeDateTime; use Exception; +use MongoDB\BSON\Binary; +use MongoDB\BSON\Javascript; +use MongoDB\BSON\MaxKey; +use MongoDB\BSON\MinKey; +use MongoDB\BSON\Regex; +use MongoDB\BSON\Timestamp; +use MongoDB\BSON\UTCDateTime; +use stdClass; use Throwable; +use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\AttributeUpdate; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Unique as UniqueException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Index; +use Utopia\Database\IntegerWidth; +use Utopia\Database\Permission; use Utopia\Database\Query; -use Utopia\Database\Validator\Index; +use Utopia\Database\Role; +use Utopia\Database\Storage; +use Utopia\Database\Validator\IndexDefinition; +use Utopia\Mongo\Exception as MongoException; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; trait IndexTests { @@ -23,29 +49,29 @@ public function testCreateIndex(): void { $database = $this->getDatabase(); - $database->createCollection('indexes'); + $database->createCollection(Collection::create(id: 'indexes')); /** * Check ticks sounding cast index for reserved words */ - $database->createAttribute('indexes', 'int', Database::VAR_INTEGER, 8, false, array:true); - if ($database->getAdapter()->getSupportForIndexArray()) { - $database->createIndex('indexes', 'indx8711', Database::INDEX_KEY, ['int'], [255]); + $database->createAttribute('indexes', Attribute::integer(key: 'int', width: IntegerWidth::Bits64, array: true)); + if ($database->getAdapter()->supports(Capability::IndexArray)) { + $database->createIndex('indexes', Index::key(key: 'indx8711', attributes: ['int'], lengths: [255])); } - $database->createAttribute('indexes', 'name', Database::VAR_STRING, 10, false); + $database->createAttribute('indexes', Attribute::string(key: 'name', size: 10)); - $database->createIndex('indexes', 'index_1', Database::INDEX_KEY, ['name']); + $database->createIndex('indexes', Index::key(key: 'index_1', attributes: ['name'])); try { - $database->createIndex('indexes', 'index3', Database::INDEX_KEY, ['$id', '$id']); + $database->createIndex('indexes', Index::key(key: 'index3', attributes: ['$id', '$id'])); } catch (Throwable $e) { self::assertTrue($e instanceof DatabaseException); self::assertEquals($e->getMessage(), 'Duplicate attributes provided'); } try { - $database->createIndex('indexes', 'index4', Database::INDEX_KEY, ['name', 'Name']); + $database->createIndex('indexes', Index::key(key: 'index4', attributes: ['name', 'Name'])); } catch (Throwable $e) { self::assertTrue($e instanceof DatabaseException); self::assertEquals($e->getMessage(), 'Duplicate attributes provided'); @@ -59,614 +85,295 @@ public function testCreateDeleteIndex(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('indexes'); + $database->createCollection(Collection::create(id: 'indexes')); - $this->assertEquals(true, $database->createAttribute('indexes', 'string', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('indexes', 'order', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('indexes', 'integer', Database::VAR_INTEGER, 0, true)); - $this->assertEquals(true, $database->createAttribute('indexes', 'float', Database::VAR_FLOAT, 0, true)); - $this->assertEquals(true, $database->createAttribute('indexes', 'boolean', Database::VAR_BOOLEAN, 0, true)); + $database->createAttribute('indexes', Attribute::string(key: 'string', size: 128, required: true)); + $database->createAttribute('indexes', Attribute::string(key: 'order', size: 128, required: true)); + $database->createAttribute('indexes', Attribute::integer(key: 'integer', required: true)); + $database->createAttribute('indexes', Attribute::double(key: 'float', required: true)); + $database->createAttribute('indexes', Attribute::boolean(key: 'boolean', required: true)); // Indexes - $this->assertEquals(true, $database->createIndex('indexes', 'index1', Database::INDEX_KEY, ['string', 'integer'], [128], [Database::ORDER_ASC])); - $this->assertEquals(true, $database->createIndex('indexes', 'index2', Database::INDEX_KEY, ['float', 'integer'], [], [Database::ORDER_ASC, Database::ORDER_DESC])); - $this->assertEquals(true, $database->createIndex('indexes', 'index3', Database::INDEX_KEY, ['integer', 'boolean'], [], [Database::ORDER_ASC, Database::ORDER_DESC, Database::ORDER_DESC])); - $this->assertEquals(true, $database->createIndex('indexes', 'index4', Database::INDEX_UNIQUE, ['string'], [128], [Database::ORDER_ASC])); - $this->assertEquals(true, $database->createIndex('indexes', 'index5', Database::INDEX_UNIQUE, ['$id', 'string'], [128], [Database::ORDER_ASC])); - $this->assertEquals(true, $database->createIndex('indexes', 'order', Database::INDEX_UNIQUE, ['order'], [128], [Database::ORDER_ASC])); + $database->createIndex('indexes', Index::key(key: 'index1', attributes: ['string', 'integer'], lengths: [128], orders: [OrderDirection::Asc])); + $database->createIndex('indexes', Index::key(key: 'index2', attributes: ['float', 'integer'], orders: [OrderDirection::Asc, OrderDirection::Desc])); + $database->createIndex('indexes', Index::key(key: 'index3', attributes: ['integer', 'boolean'], orders: [OrderDirection::Asc, OrderDirection::Desc, OrderDirection::Desc])); + $database->createIndex('indexes', Index::unique(key: 'index4', attributes: ['string'], lengths: [128], orders: [OrderDirection::Asc])); + $database->createIndex('indexes', Index::unique(key: 'index5', attributes: ['$id', 'string'], lengths: [128], orders: [OrderDirection::Asc])); + $database->createIndex('indexes', Index::unique(key: 'order', attributes: ['order'], lengths: [128], orders: [OrderDirection::Asc])); $collection = $database->getCollection('indexes'); - $this->assertCount(6, $collection->getAttribute('indexes')); + $this->assertCount(6, $collection->indexes()); // Delete Indexes - $this->assertEquals(true, $database->deleteIndex('indexes', 'index1')); - $this->assertEquals(true, $database->deleteIndex('indexes', 'index2')); - $this->assertEquals(true, $database->deleteIndex('indexes', 'index3')); - $this->assertEquals(true, $database->deleteIndex('indexes', 'index4')); - $this->assertEquals(true, $database->deleteIndex('indexes', 'index5')); - $this->assertEquals(true, $database->deleteIndex('indexes', 'order')); + $database->deleteIndex('indexes', 'index1'); + $database->deleteIndex('indexes', 'index2'); + $database->deleteIndex('indexes', 'index3'); + $database->deleteIndex('indexes', 'index4'); + $database->deleteIndex('indexes', 'index5'); + $database->deleteIndex('indexes', 'order'); $collection = $database->getCollection('indexes'); - $this->assertCount(0, $collection->getAttribute('indexes')); + $this->assertCount(0, $collection->indexes()); // Test non-shared tables duplicates throw duplicate - $database->createIndex('indexes', 'duplicate', Database::INDEX_KEY, ['string', 'boolean'], [128], [Database::ORDER_ASC]); + $database->createIndex('indexes', Index::key(key: 'duplicate', attributes: ['string', 'boolean'], lengths: [128], orders: [OrderDirection::Asc])); try { - $database->createIndex('indexes', 'duplicate', Database::INDEX_KEY, ['string', 'boolean'], [128], [Database::ORDER_ASC]); + $database->createIndex('indexes', Index::key(key: 'duplicate', attributes: ['string', 'boolean'], lengths: [128], orders: [OrderDirection::Asc])); $this->fail('Failed to throw exception'); } catch (Exception $e) { $this->assertInstanceOf(DuplicateException::class, $e); } // Test delete index when index does not exist - $this->assertEquals(true, $database->createIndex('indexes', 'index1', Database::INDEX_KEY, ['string', 'integer'], [128], [Database::ORDER_ASC])); + $database->createIndex('indexes', Index::key(key: 'index1', attributes: ['string', 'integer'], lengths: [128], orders: [OrderDirection::Asc])); $this->assertEquals(true, $this->deleteIndex('indexes', 'index1')); - $this->assertEquals(true, $database->deleteIndex('indexes', 'index1')); + $database->deleteIndex('indexes', 'index1'); // Test delete index when attribute does not exist - $this->assertEquals(true, $database->createIndex('indexes', 'index1', Database::INDEX_KEY, ['string', 'integer'], [128], [Database::ORDER_ASC])); - $this->assertEquals(true, $database->deleteAttribute('indexes', 'string')); - $this->assertEquals(true, $database->deleteIndex('indexes', 'index1')); + $database->createIndex('indexes', Index::key(key: 'index1', attributes: ['string', 'integer'], lengths: [128], orders: [OrderDirection::Asc])); + $database->deleteAttribute('indexes', 'string'); + $database->deleteIndex('indexes', 'index1'); $database->deleteCollection('indexes'); } - - - /** - * @throws Exception|Throwable - */ - public function testIndexValidation(): void + public function testCreateUniqueIndexOverDuplicates(): void { - $attributes = [ - new Document([ - '$id' => ID::custom('title1'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('title2'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 500, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ]; - - $indexes = [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['title1', 'title2'], - 'lengths' => [701,50], - 'orders' => [], - ]), - ]; - - $collection = new Document([ - '$id' => ID::custom('index_length'), - 'name' => 'test', - 'attributes' => $attributes, - 'indexes' => $indexes - ]); - - /** @var Database $database */ $database = $this->getDatabase(); - $validator = new Index( - $attributes, - $indexes, - $database->getAdapter()->getMaxIndexLength(), - $database->getAdapter()->getInternalIndexesKeys(), - $database->getAdapter()->getSupportForIndexArray(), - $database->getAdapter()->getSupportForSpatialIndexNull(), - $database->getAdapter()->getSupportForSpatialIndexOrder(), - $database->getAdapter()->getSupportForVectors(), - $database->getAdapter()->getSupportForAttributes(), - $database->getAdapter()->getSupportForMultipleFulltextIndexes(), - $database->getAdapter()->getSupportForIdenticalIndexes(), - $database->getAdapter()->getSupportForObject(), - $database->getAdapter()->getSupportForTrigramIndex(), - $database->getAdapter()->getSupportForSpatialAttributes(), - $database->getAdapter()->getSupportForIndex(), - $database->getAdapter()->getSupportForUniqueIndex(), - $database->getAdapter()->getSupportForFulltextIndex() - ); - if ($database->getAdapter()->getSupportForIdenticalIndexes()) { - $errorMessage = 'Index length 701 is larger than the size for title1: 700"'; - $this->assertFalse($validator->isValid($indexes[0])); - $this->assertEquals($errorMessage, $validator->getDescription()); - try { - $database->createCollection($collection->getId(), $attributes, $indexes, [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); - } - } - - $indexes = [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['title1', 'title2'], - 'lengths' => [700], // 700, 500 (length(title2)) - 'orders' => [], - ]), - ]; - - $collection->setAttribute('indexes', $indexes); - - if ($database->getAdapter()->getSupportForAttributes() && $database->getAdapter()->getMaxIndexLength() > 0) { - $errorMessage = 'Index length is longer than the maximum: ' . $database->getAdapter()->getMaxIndexLength(); - $this->assertFalse($validator->isValid($indexes[0])); - $this->assertEquals($errorMessage, $validator->getDescription()); + if (! $database->getAdapter()->supports(Capability::IndexUnique)) { + $this->expectNotToPerformAssertions(); - try { - $database->createCollection($collection->getId(), $attributes, $indexes); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); - } + return; } - $attributes[] = new Document([ - '$id' => ID::custom('integer'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 10000, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]); - - $indexes = [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['title1', 'integer'], - 'lengths' => [], - 'orders' => [], - ]), - ]; - - $collection = new Document([ - '$id' => ID::custom('index_length'), - 'name' => 'test', - 'attributes' => $attributes, - 'indexes' => $indexes - ]); - - // not using $indexes[0] as the index validator skips indexes with same id - $newIndex = new Document([ - '$id' => ID::custom('newIndex1'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['title1', 'integer'], - 'lengths' => [], - 'orders' => [], - ]); - - $validator = new Index( - $attributes, - $indexes, - $database->getAdapter()->getMaxIndexLength(), - $database->getAdapter()->getInternalIndexesKeys(), - $database->getAdapter()->getSupportForIndexArray(), - $database->getAdapter()->getSupportForSpatialIndexNull(), - $database->getAdapter()->getSupportForSpatialIndexOrder(), - $database->getAdapter()->getSupportForVectors(), - $database->getAdapter()->getSupportForAttributes(), - $database->getAdapter()->getSupportForMultipleFulltextIndexes(), - $database->getAdapter()->getSupportForIdenticalIndexes(), - $database->getAdapter()->getSupportForObject(), - $database->getAdapter()->getSupportForTrigramIndex(), - $database->getAdapter()->getSupportForSpatialAttributes(), - $database->getAdapter()->getSupportForIndex(), - $database->getAdapter()->getSupportForUniqueIndex(), - $database->getAdapter()->getSupportForFulltextIndex() - ); - - $this->assertFalse($validator->isValid($newIndex)); + $collection = 'uniqueOverDuplicates'; + $objectPaths = $database->getAdapter() instanceof Postgres && $database->getAdapter()->supports(Capability::Objects); - if (!$database->getAdapter()->getSupportForFulltextIndex()) { - $this->assertEquals('Fulltext index is not supported', $validator->getDescription()); - } elseif (!$database->getAdapter()->getSupportForMultipleFulltextIndexes()) { - $this->assertEquals('There is already a fulltext index in the collection', $validator->getDescription()); - } elseif ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals('Attribute "integer" cannot be part of a fulltext index, must be of type string', $validator->getDescription()); - } + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 128), + Attribute::integer(key: 'age'), + ...($objectPaths ? [Attribute::object(key: 'data')] : []), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); try { - $database->createCollection($collection->getId(), $attributes, $indexes); - if ($database->getAdapter()->getSupportForAttributes()) { - $this->fail('Failed to throw exception'); - } - } catch (Exception $e) { - if (!$database->getAdapter()->getSupportForFulltextIndex()) { - $this->assertEquals('Fulltext index is not supported', $e->getMessage()); - } else { - $this->assertEquals('Attribute "integer" cannot be part of a fulltext index, must be of type string', $e->getMessage()); + foreach (['first', 'second'] as $id) { + $database->createDocument($collection, new Document([ + '$id' => $id, + 'name' => 'chester', + 'age' => 7, + ...($objectPaths ? ['data' => ['country' => 'au']] : []), + ])); } - } + foreach ($objectPaths ? ['name', 'age', 'data.country'] : ['name', 'age'] as $attribute) { + $key = 'unique_'.\str_replace('.', '_', $attribute); - $indexes = [ - new Document([ - '$id' => ID::custom('index_negative_length'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['title1'], - 'lengths' => [-1], - 'orders' => [], - ]), - ]; - if ($database->getAdapter()->getSupportForAttributes()) { - $errorMessage = 'Negative index length provided for title1'; - $this->assertFalse($validator->isValid($indexes[0])); - $this->assertEquals($errorMessage, $validator->getDescription()); - - try { - $database->createCollection(ID::unique(), $attributes, $indexes); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); - } + $error = null; + try { + $database->createIndex($collection, Index::unique(key: $key, attributes: [$attribute])); + } catch (Throwable $caught) { + $error = $caught; + } - $indexes = [ - new Document([ - '$id' => ID::custom('index_extra_lengths'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['title1', 'title2'], - 'lengths' => [100, 100, 100], - 'orders' => [], - ]), - ]; - $errorMessage = 'Invalid index lengths. Count of lengths must be equal or less than the number of attributes.'; - $this->assertFalse($validator->isValid($indexes[0])); - $this->assertEquals($errorMessage, $validator->getDescription()); + $this->assertInstanceOf(UniqueException::class, $error, 'A unique index on '.$attribute.' over duplicate values must be refused as Unique'); - try { - $database->createCollection(ID::unique(), $attributes, $indexes); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); + $keys = \array_map(static fn (Index $index): string => $index->key, $database->getCollection($collection)->indexes()); + $this->assertNotContains($key, $keys, 'A refused unique index on '.$attribute.' must leave no metadata behind'); } + } finally { + $database->deleteCollection($collection); } } - public function testCreateCollectionWithIndexOnSequence(): void + public function testIndexKeyOverTheEngineLimitIsRefused(): void { - /** @var Database $database */ $database = $this->getDatabase(); + $adapter = $database->getAdapter(); - if (!$database->getAdapter()->getSupportForIndex()) { + if (! $adapter instanceof MariaDB && ! $adapter instanceof Postgres) { $this->expectNotToPerformAssertions(); - return; - } - - $collection = $database->createCollection('sequenceIndexes', [ - new Document([ - '$id' => ID::custom('username'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('email'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [ - new Document([ - '$id' => ID::custom('_index 123'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['username', '$sequence'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC, Database::ORDER_DESC], - ]), - new Document([ - '$id' => ID::custom('_index 456'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['email', '$sequence'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC, Database::ORDER_DESC], - ]), - ]); - - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(2, $indexes); - $this->assertEquals('_index 123', $indexes[0]->getId()); - $this->assertEquals(['username', '$sequence'], $indexes[0]->getAttribute('attributes')); - $this->assertEquals('_index 456', $indexes[1]->getId()); - $this->assertEquals(['email', '$sequence'], $indexes[1]->getAttribute('attributes')); - - $database->createDocument('sequenceIndexes', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - ], - 'username' => 'chester', - 'email' => 'chester@example.com', - ])); - - $documents = $database->find('sequenceIndexes', [ - Query::equal('username', ['chester']), - Query::orderDesc('$sequence'), - ]); - - $this->assertCount(1, $documents); - $this->assertEquals('chester', $documents[0]->getAttribute('username')); - - /** - * $sequence is unique on its own, so a unique index containing it never - * conflicts. A duplicate here would mean the adapter built the index - * without the $sequence column. - */ - $database->createDocument('sequenceIndexes', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - ], - 'username' => 'chester', - 'email' => 'chester@example.com', - ])); - - $this->assertCount(2, $database->find('sequenceIndexes', [ - Query::equal('email', ['chester@example.com']), - ])); - - $database->deleteCollection('sequenceIndexes'); - } - - public function testCreateIndexOnSequence(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForIndex()) { - $this->expectNotToPerformAssertions(); return; } - $database->createCollection(__FUNCTION__); - - $this->assertTrue($database->createAttribute(__FUNCTION__, 'username', Database::VAR_STRING, 128, false)); - $this->assertTrue($database->createAttribute(__FUNCTION__, 'email', Database::VAR_STRING, 128, false)); - - $this->assertTrue($database->createIndex( - __FUNCTION__, - '_index 123', - Database::INDEX_KEY, - ['username', '$sequence'], - [], - [Database::ORDER_ASC, Database::ORDER_DESC] - )); - - $this->assertTrue($database->createIndex( - __FUNCTION__, - '_index 456', - Database::INDEX_UNIQUE, - ['email', '$sequence'], - [], - [Database::ORDER_ASC, Database::ORDER_DESC] - )); - - $indexes = $database->getCollection(__FUNCTION__)->getAttribute('indexes'); - $this->assertCount(2, $indexes); - $this->assertEquals('_index 123', $indexes[0]->getId()); - $this->assertEquals(['username', '$sequence'], $indexes[0]->getAttribute('attributes')); - $this->assertEquals('_index 456', $indexes[1]->getId()); - $this->assertEquals(['email', '$sequence'], $indexes[1]->getAttribute('attributes')); - - $database->createDocument(__FUNCTION__, new Document([ - '$permissions' => [ - Permission::read(Role::any()), - ], - 'username' => 'chester', - 'email' => 'chester@example.com', - ])); + $collection = 'indexKeyOverLimit'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'first', size: 768), + Attribute::string(key: 'second', size: 768), + Attribute::string(key: 'note', size: 20000), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); - $documents = $database->find(__FUNCTION__, [ - Query::equal('username', ['chester']), - Query::orderDesc('$sequence'), - ]); + try { + $error = null; + if ($adapter instanceof MariaDB) { + try { + $adapter->createIndex($collection, Index::key(key: 'wide', attributes: ['first', 'second'], lengths: [768, 768])); + } catch (Throwable $caught) { + $error = $caught; + } - $this->assertCount(1, $documents); - $this->assertEquals('chester', $documents[0]->getAttribute('username')); + $this->assertInstanceOf(IndexException::class, $error, 'A key longer than the engine allows must be refused as an Index error'); + $this->assertSame('Index key length exceeds the maximum', $error->getMessage()); - /** - * $sequence is unique on its own, so a unique index containing it never - * conflicts. A duplicate here would mean the adapter built the index - * without the $sequence column. - */ - $database->createDocument(__FUNCTION__, new Document([ - '$permissions' => [ - Permission::read(Role::any()), - ], - 'username' => 'chester', - 'email' => 'chester@example.com', - ])); + return; + } - $this->assertCount(2, $database->find(__FUNCTION__, [ - Query::equal('email', ['chester@example.com']), - ])); + $database->createDocument($collection, new Document(['note' => \bin2hex(\random_bytes(8000))])); + try { + $adapter->createIndex($collection, Index::key(key: 'by_note', attributes: ['note'])); + } catch (Throwable $caught) { + $error = $caught; + } - $database->deleteCollection(__FUNCTION__); + $this->assertInstanceOf(LimitException::class, $error, 'An index row larger than the engine allows must be refused as a Limit'); + $this->assertSame('Index row size exceeds the maximum', $error->getMessage()); + } finally { + $database->deleteCollection($collection); + } } - public function testCreateUniqueIndexOverDuplicates(): void + public function testIndexLengthZero(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUniqueIndex()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 128, false); - $database->createAttribute(__FUNCTION__, 'age', Database::VAR_INTEGER, 0, false); + $database->createCollection(Collection::create(id: __FUNCTION__)); - foreach (['first', 'second'] as $id) { - $database->createDocument(__FUNCTION__, new Document([ - '$id' => $id, - '$permissions' => [ - Permission::read(Role::any()), - ], - 'name' => 'chester', - 'age' => 7, - ])); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'title1', size: $database->getAdapter()->limits()->indexLength + 300, required: true)); + + try { + $database->createIndex(__FUNCTION__, Index::key(key: 'index_title1', attributes: ['title1'], lengths: [0])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); } - foreach (['name', 'age'] as $attribute) { - try { - $database->createIndex(__FUNCTION__, "unique_{$attribute}", Database::INDEX_UNIQUE, [$attribute]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(UniqueException::class, $e); - } + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'title2', size: 100, required: true)); + $database->createIndex(__FUNCTION__, Index::key(key: 'index_title2', attributes: ['title2'], lengths: [0])); - $indexes = $database->getCollection(__FUNCTION__)->getAttribute('indexes'); - $this->assertNotContains("unique_{$attribute}", \array_map(fn ($index) => $index->getId(), $indexes)); + try { + $database->updateAttribute(__FUNCTION__, 'title2', new AttributeUpdate(type: ColumnType::String, size: $database->getAdapter()->limits()->indexLength + 300, required: true)); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); } - - $database->deleteCollection(__FUNCTION__); } - public function testIndexLengthZero(): void + /** + * An index length may not exceed the size of the attribute it covers. This is + * a different bound from the adapter's maximum index length that + * {@see self::testIndexLengthZero} covers: 701 is well under the maximum, and + * only oversized relative to title1's own 700. + * + * Ported from main's testIndexValidation, which drove the index validator + * directly. Going through createIndex() proves the validator is actually + * consulted on the path a caller takes, which a direct construction cannot. + */ + public function testIndexLengthExceedsAttributeSize(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes) + || ! $database->getAdapter()->supports(Capability::IndexIdentical)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__); - - $database->createAttribute(__FUNCTION__, 'title1', Database::VAR_STRING, $database->getAdapter()->getMaxIndexLength() + 300, true); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'title1', size: 700, required: false)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'title2', size: 500, required: false)); try { - $database->createIndex(__FUNCTION__, 'index_title1', Database::INDEX_KEY, ['title1'], [0]); + $database->createIndex(__FUNCTION__, Index::key(key: 'index1', attributes: ['title1', 'title2'], lengths: [701, 50])); $this->fail('Failed to throw exception'); } catch (Throwable $e) { - $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->getMaxIndexLength(), $e->getMessage()); + $this->assertEquals('Index length 701 is larger than the size for title1: 700"', $e->getMessage()); } - - $database->createAttribute(__FUNCTION__, 'title2', Database::VAR_STRING, 100, true); - $database->createIndex(__FUNCTION__, 'index_title2', Database::INDEX_KEY, ['title2'], [0]); - - try { - $database->updateAttribute(__FUNCTION__, 'title2', Database::VAR_STRING, $database->getAdapter()->getMaxIndexLength() + 300, true); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->getMaxIndexLength(), $e->getMessage()); - } + $database->deleteCollection(__FUNCTION__); } public function testRenameIndex(): void { $database = $this->getDatabase(); + $collection = $this->getNumbersCollection(); + $this->initRenameIndexFixture(); - $numbers = $database->createCollection('numbers'); - $database->createAttribute('numbers', 'verbose', Database::VAR_STRING, 128, true); - $database->createAttribute('numbers', 'symbol', Database::VAR_INTEGER, 0, true); - - $database->createIndex('numbers', 'index1', Database::INDEX_KEY, ['verbose'], [128], [Database::ORDER_ASC]); - $database->createIndex('numbers', 'index2', Database::INDEX_KEY, ['symbol'], [0], [Database::ORDER_ASC]); + $numbers = $database->getCollection($collection); - $index = $database->renameIndex('numbers', 'index1', 'index3'); + $this->assertCount(2, $numbers->indexes()); + $this->assertSame('index3', $numbers->indexes()[0]->key); + $this->assertSame('index2', $numbers->indexes()[1]->key); - $this->assertTrue($index); + $database->renameIndex($collection, 'index2', 'index4'); + $this->assertSame('index4', $database->getCollection($collection)->indexes()[1]->key); - $numbers = $database->getCollection('numbers'); - - $this->assertEquals('index2', $numbers->getAttribute('indexes')[1]['$id']); - $this->assertEquals('index3', $numbers->getAttribute('indexes')[0]['$id']); - $this->assertCount(2, $numbers->getAttribute('indexes')); + $database->renameIndex($collection, 'index4', 'index2'); + $this->assertSame('index2', $database->getCollection($collection)->indexes()[1]->key); } + private static string $numbersCollection = ''; - /** - * @depends testRenameIndex - * @expectedException Exception - */ - public function testRenameIndexMissing(): void - { - $database = $this->getDatabase(); - $this->expectExceptionMessage('Index not found'); - $index = $database->renameIndex('numbers', 'index1', 'index4'); - } - - /** - * @depends testRenameIndex - * @expectedException Exception - */ - public function testRenameIndexExisting(): void + protected function getNumbersCollection(): string { - $database = $this->getDatabase(); - $this->expectExceptionMessage('Index name already used'); - $index = $database->renameIndex('numbers', 'index3', 'index2'); + if (self::$numbersCollection === '') { + self::$numbersCollection = 'numbers_' . uniqid(); + } + return self::$numbersCollection; } + private static bool $renameIndexFixtureInit = false; - public function testExceptionIndexLimit(): void + protected function initRenameIndexFixture(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - - $database->createCollection('indexLimit'); - - // add unique attributes for indexing - for ($i = 0; $i < 64; $i++) { - $this->assertEquals(true, $database->createAttribute('indexLimit', "test{$i}", Database::VAR_STRING, 16, true)); + if (self::$renameIndexFixtureInit) { + return; } - // Testing for indexLimit - // Add up to the limit, then check if the next index throws IndexLimitException - for ($i = 0; $i < ($this->getDatabase()->getLimitForIndexes()); $i++) { - $this->assertEquals(true, $database->createIndex('indexLimit', "index{$i}", Database::INDEX_KEY, ["test{$i}"], [16])); - } - $this->expectException(LimitException::class); - $this->assertEquals(false, $database->createIndex('indexLimit', "index64", Database::INDEX_KEY, ["test64"], [16])); + $database = $this->getDatabase(); + $collection = $this->getNumbersCollection(); - $database->deleteCollection('indexLimit'); + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'verbose', size: 128, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'symbol', required: true)); + $database->createIndex($collection, Index::key(key: 'index1', attributes: ['verbose'], lengths: [128], orders: [OrderDirection::Asc])); + $database->createIndex($collection, Index::key(key: 'index2', attributes: ['symbol'], lengths: [0], orders: [OrderDirection::Asc])); + $database->renameIndex($collection, 'index1', 'index3'); + + self::$renameIndexFixtureInit = true; } public function testListDocumentSearch(): void { - $fulltextSupport = $this->getDatabase()->getAdapter()->getSupportForFulltextIndex(); - if (!$fulltextSupport) { + $fulltextSupport = $this->getDatabase()->getAdapter()->supports(Capability::IndexFulltext); + if (! $fulltextSupport) { $this->expectNotToPerformAssertions(); + return; } + $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $database->createIndex('documents', 'string', Database::INDEX_FULLTEXT, ['string']); - $database->createDocument('documents', new Document([ + $database->createIndex($this->getDocumentsCollection(), Index::fulltext(key: 'string', attributes: ['string'])); + $database->createDocument($this->getDocumentsCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -688,229 +395,92 @@ public function testListDocumentSearch(): void /** * Allow reserved keywords for search */ - $documents = $database->find('documents', [ + $documents = $database->find($this->getDocumentsCollection(), [ Query::search('string', '*test+alias@email-provider.com'), ]); $this->assertEquals(1, count($documents)); } - public function testMaxQueriesValues(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $max = $database->getMaxQueryValues(); - - $database->setMaxQueryValues(5); - - try { - $database->find( - 'documents', - [Query::equal('$id', [1, 2, 3, 4, 5, 6])] - ); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertTrue($e instanceof QueryException); - $this->assertEquals('Invalid query: Query on attribute has greater than 5 values: $id', $e->getMessage()); - } - - $database->setMaxQueryValues($max); - } - public function testEmptySearch(): void { - $fulltextSupport = $this->getDatabase()->getAdapter()->getSupportForFulltextIndex(); - if (!$fulltextSupport) { + $fulltextSupport = $this->getDatabase()->getAdapter()->supports(Capability::IndexFulltext); + if (! $fulltextSupport) { $this->expectNotToPerformAssertions(); + return; } + $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $documents = $database->find('documents', [ + // Create fulltext index if it doesn't exist (was created by testListDocumentSearch in sequential mode) + try { + $database->createIndex($this->getDocumentsCollection(), Index::fulltext(key: 'string', attributes: ['string'])); + } catch (\Exception $e) { + // Already exists + } + + $documents = $database->find($this->getDocumentsCollection(), [ Query::search('string', ''), ]); $this->assertEquals(0, count($documents)); - $documents = $database->find('documents', [ + $documents = $database->find($this->getDocumentsCollection(), [ Query::search('string', '*'), ]); $this->assertEquals(0, count($documents)); - $documents = $database->find('documents', [ + $documents = $database->find($this->getDocumentsCollection(), [ Query::search('string', '<>'), ]); $this->assertEquals(0, count($documents)); } - public function testMultipleFulltextIndexValidation(): void + public function testTrigramIndex(): void { - - $fulltextSupport = $this->getDatabase()->getAdapter()->getSupportForFulltextIndex(); - if (!$fulltextSupport) { + $trigramSupport = $this->getDatabase()->getAdapter()->supports(Capability::IndexTrigram); + if (! $trigramSupport) { $this->expectNotToPerformAssertions(); + return; } /** @var Database $database */ - $database = $this->getDatabase(); + $database = static::getDatabase(); - $collectionId = 'multiple_fulltext_test'; + $collectionId = 'trigram_test'; try { - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 256, false); - $database->createAttribute($collectionId, 'content', Database::VAR_STRING, 256, false); - $database->createIndex($collectionId, 'fulltext_title', Database::INDEX_FULLTEXT, ['title']); + $database->createAttribute($collectionId, Attribute::string(key: 'name', size: 256)); + $database->createAttribute($collectionId, Attribute::string(key: 'description', size: 512)); - $supportsMultipleFulltext = $database->getAdapter()->getSupportForMultipleFulltextIndexes(); - - // Try to add second fulltext index - try { - $database->createIndex($collectionId, 'fulltext_content', Database::INDEX_FULLTEXT, ['content']); - - if ($supportsMultipleFulltext) { - $this->assertTrue(true, 'Multiple fulltext indexes are supported and second index was created successfully'); - } else { - $this->fail('Expected exception when creating second fulltext index, but none was thrown'); - } - } catch (Throwable $e) { - if (!$supportsMultipleFulltext) { - $this->assertTrue(true, 'Multiple fulltext indexes are not supported and exception was thrown as expected'); - } else { - $this->fail('Unexpected exception when creating second fulltext index: ' . $e->getMessage()); - } - } - - } finally { - // Clean up - $database->deleteCollection($collectionId); - } - } - - public function testIdenticalIndexValidation(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $collectionId = 'identical_index_test'; - - try { - $database->createCollection($collectionId); - - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); - $database->createAttribute($collectionId, 'age', Database::VAR_INTEGER, 8, false); - - $database->createIndex($collectionId, 'index1', Database::INDEX_KEY, ['name', 'age'], [], [Database::ORDER_ASC, Database::ORDER_DESC]); - - $supportsIdenticalIndexes = $database->getAdapter()->getSupportForIdenticalIndexes(); - - // Try to add identical index (failure) - try { - $database->createIndex($collectionId, 'index2', Database::INDEX_KEY, ['name', 'age'], [], [Database::ORDER_ASC, Database::ORDER_DESC]); - if ($supportsIdenticalIndexes) { - $this->assertTrue(true, 'Identical indexes are supported and second index was created successfully'); - } else { - $this->fail('Expected exception but got none'); - } - - } catch (Throwable $e) { - if (!$supportsIdenticalIndexes) { - $this->assertTrue(true, 'Identical indexes are not supported and exception was thrown as expected'); - } else { - $this->fail('Unexpected exception when creating identical index: ' . $e->getMessage()); - } - - } - - // Test with different attributes order - faliure - try { - $database->createIndex($collectionId, 'index3', Database::INDEX_KEY, ['age', 'name'], [], [ Database::ORDER_ASC, Database::ORDER_DESC]); - $this->assertTrue(true, 'Index with different attributes was created successfully'); - } catch (Throwable $e) { - if (!$supportsIdenticalIndexes) { - $this->assertTrue(true, 'Identical indexes are not supported and exception was thrown as expected'); - } else { - $this->fail('Unexpected exception when creating identical index: ' . $e->getMessage()); - } - } - - // Test with different orders order - faliure - try { - $database->createIndex($collectionId, 'index4', Database::INDEX_KEY, ['age', 'name'], [], [ Database::ORDER_DESC, Database::ORDER_ASC]); - $this->assertTrue(true, 'Index with different attributes was created successfully'); - } catch (Throwable $e) { - if (!$supportsIdenticalIndexes) { - $this->assertTrue(true, 'Identical indexes are not supported and exception was thrown as expected'); - } else { - $this->fail('Unexpected exception when creating identical index: ' . $e->getMessage()); - } - } - - // Test with different attributes - success - try { - $database->createIndex($collectionId, 'index5', Database::INDEX_KEY, ['name'], [], [Database::ORDER_ASC]); - $this->assertTrue(true, 'Index with different attributes was created successfully'); - } catch (Throwable $e) { - $this->fail('Unexpected exception when creating index with different attributes: ' . $e->getMessage()); - } - - // Test with different orders - success - try { - $database->createIndex($collectionId, 'index6', Database::INDEX_KEY, ['name', 'age'], [], [Database::ORDER_ASC]); - $this->assertTrue(true, 'Index with different orders was created successfully'); - } catch (Throwable $e) { - $this->fail('Unexpected exception when creating index with different orders: ' . $e->getMessage()); - } - } finally { - // Clean up - $database->deleteCollection($collectionId); - } - } - - public function testTrigramIndex(): void - { - $trigramSupport = $this->getDatabase()->getAdapter()->getSupportForTrigramIndex(); - if (!$trigramSupport) { - $this->expectNotToPerformAssertions(); - return; - } - - /** @var Database $database */ - $database = static::getDatabase(); - - $collectionId = 'trigram_test'; - try { - $database->createCollection($collectionId); - - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); - $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 512, false); - - // Create trigram index on name attribute - $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_name', Database::INDEX_TRIGRAM, ['name'])); + // Create trigram index on name attribute + $database->createIndex($collectionId, Index::trigram(key: 'trigram_name', attributes: ['name'])); $collection = $database->getCollection($collectionId); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(1, $indexes); - $this->assertEquals('trigram_name', $indexes[0]['$id']); - $this->assertEquals(Database::INDEX_TRIGRAM, $indexes[0]['type']); - $this->assertEquals(['name'], $indexes[0]['attributes']); + $this->assertEquals('trigram_name', $indexes[0]->key); + $this->assertEquals(IndexType::Trigram, $indexes[0]->type); + $this->assertEquals(['name'], $indexes[0]->attributes); // Create another trigram index on description - $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_description', Database::INDEX_TRIGRAM, ['description'])); + $database->createIndex($collectionId, Index::trigram(key: 'trigram_description', attributes: ['description'])); $collection = $database->getCollection($collectionId); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(2, $indexes); // Test that trigram index can be deleted - $this->assertEquals(true, $database->deleteIndex($collectionId, 'trigram_name')); - $this->assertEquals(true, $database->deleteIndex($collectionId, 'trigram_description')); + $database->deleteIndex($collectionId, 'trigram_name'); + $database->deleteIndex($collectionId, 'trigram_description'); $collection = $database->getCollection($collectionId); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(0, $indexes); } finally { @@ -921,73 +491,67 @@ public function testTrigramIndex(): void public function testTrigramIndexValidation(): void { - $trigramSupport = $this->getDatabase()->getAdapter()->getSupportForTrigramIndex(); - if (!$trigramSupport) { + /** @var Database $database */ + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::IndexTrigram)) { $this->expectNotToPerformAssertions(); + return; } - /** @var Database $database */ - $database = static::getDatabase(); - $collectionId = 'trigram_validation_test'; + try { - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); - $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 412, false); - $database->createAttribute($collectionId, 'age', Database::VAR_INTEGER, 8, false); + $database->createAttribute($collectionId, Attribute::string(key: 'name', size: 256)); + $database->createAttribute($collectionId, Attribute::string(key: 'description', size: 412)); + $database->createAttribute($collectionId, Attribute::integer(key: 'age', width: IntegerWidth::Bits64)); - // Test: Trigram index on non-string attribute should fail try { - $database->createIndex($collectionId, 'trigram_invalid', Database::INDEX_TRIGRAM, ['age']); + $database->createIndex($collectionId, Index::trigram(key: 'trigram_invalid', attributes: ['age'])); $this->fail('Expected exception when creating trigram index on non-string attribute'); - } catch (Exception $e) { + } catch (DatabaseException $e) { $this->assertStringContainsString('Trigram index can only be created on string type attributes', $e->getMessage()); } - // Test: Trigram index with multiple string attributes should succeed - $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_multi', Database::INDEX_TRIGRAM, ['name', 'description'])); + $database->createIndex($collectionId, Index::trigram(key: 'trigram_multi', attributes: ['name', 'description'])); - $collection = $database->getCollection($collectionId); - $indexes = $collection->getAttribute('indexes'); - $trigramMultiIndex = null; - foreach ($indexes as $idx) { - if ($idx['$id'] === 'trigram_multi') { - $trigramMultiIndex = $idx; - break; - } - } - $this->assertNotNull($trigramMultiIndex); - $this->assertEquals(Database::INDEX_TRIGRAM, $trigramMultiIndex['type']); - $this->assertEquals(['name', 'description'], $trigramMultiIndex['attributes']); + $indexes = \array_values(\array_filter( + $database->getCollection($collectionId)->indexes(), + fn (Index $index) => $index->key === 'trigram_multi' + )); + $this->assertCount(1, $indexes); + $this->assertSame(IndexType::Trigram, $indexes[0]->type); + $this->assertSame(['name', 'description'], $indexes[0]->attributes); - // Test: Trigram index with mixed string and non-string attributes should fail try { - $database->createIndex($collectionId, 'trigram_mixed', Database::INDEX_TRIGRAM, ['name', 'age']); + $database->createIndex($collectionId, Index::trigram(key: 'trigram_mixed', attributes: ['name', 'age'])); $this->fail('Expected exception when creating trigram index with mixed attribute types'); - } catch (Exception $e) { + } catch (DatabaseException $e) { $this->assertStringContainsString('Trigram index can only be created on string type attributes', $e->getMessage()); } - // Test: Trigram index with orders should fail try { - $database->createIndex($collectionId, 'trigram_order', Database::INDEX_TRIGRAM, ['name'], [], [Database::ORDER_ASC]); + $database->createIndex($collectionId, Index::fromArray(['key' => 'trigram_order', 'type' => IndexType::Trigram, 'attributes' => ['name'], 'orders' => [OrderDirection::Asc]])); $this->fail('Expected exception when creating trigram index with orders'); - } catch (Exception $e) { + } catch (DatabaseException $e) { $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $e->getMessage()); } - // Test: Trigram index with lengths should fail try { - $database->createIndex($collectionId, 'trigram_length', Database::INDEX_TRIGRAM, ['name'], [128]); + $database->createIndex($collectionId, Index::fromArray(['key' => 'trigram_length', 'type' => IndexType::Trigram, 'attributes' => ['name'], 'lengths' => [128]])); $this->fail('Expected exception when creating trigram index with lengths'); - } catch (Exception $e) { + } catch (DatabaseException $e) { $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $e->getMessage()); } + $this->assertSame( + ['trigram_multi'], + \array_map(fn (Index $index) => $index->key, $database->getCollection($collectionId)->indexes()) + ); } finally { - // Clean up $database->deleteCollection($collectionId); } } @@ -997,42 +561,33 @@ public function testTTLIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForTTLIndexes()) { + if (! $database->getAdapter()->supports(Capability::IndexTtl)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_ttl'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createAttribute($col, Attribute::datetime(key: 'expiresAt')); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_valid', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 3600 // 1 hour TTL - ) - ); + $database->createIndex($col, Index::ttl(key: 'idx_ttl_valid', attribute: 'expiresAt', ttl: 3600)); $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(1, $indexes); $ttlIndex = $indexes[0]; - $this->assertEquals('idx_ttl_valid', $ttlIndex->getId()); - $this->assertEquals(Database::INDEX_TTL, $ttlIndex->getAttribute('type')); - $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); + $this->assertEquals('idx_ttl_valid', $ttlIndex->key); + $this->assertEquals(IndexType::Ttl, $ttlIndex->type); + $this->assertEquals(3600, $ttlIndex->ttl); $now = new \DateTime(); $future1 = (clone $now)->modify('+2 hours'); @@ -1054,204 +609,1127 @@ public function testTTLIndexes(): void '$id' => 'doc3', '$permissions' => $permissions, 'expiresAt' => $past->format(\DateTime::ATOM), - ]) + ]), ]); - $this->assertTrue($database->deleteIndex($col, 'idx_ttl_valid')); - - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_min', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 1 // Minimum TTL - ) - ); + $database->deleteIndex($col, 'idx_ttl_valid'); + + $database->createIndex($col, Index::ttl(key: 'idx_ttl_min', attribute: 'expiresAt', ttl: 1)); $col2 = uniqid('sl_ttl_collection'); - $expiresAtAttr = new Document([ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]); + $expiresAtAttr = Attribute::datetime(key: 'expiresAt'); - $ttlIndexDoc = new Document([ - '$id' => ID::custom('idx_ttl_collection'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200 // 2 hours - ]); + $ttlIndexDoc = Index::ttl(key: 'idx_ttl_collection', attribute: 'expiresAt', ttl: 7200); - $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); + $database->createCollection(Collection::create(id: $col2, attributes: [$expiresAtAttr], indexes: [$ttlIndexDoc])); $collection2 = $database->getCollection($col2); - $indexes2 = $collection2->getAttribute('indexes'); + $indexes2 = $collection2->indexes(); $this->assertCount(1, $indexes2); $ttlIndex2 = $indexes2[0]; - $this->assertEquals('idx_ttl_collection', $ttlIndex2->getId()); - $this->assertEquals(7200, $ttlIndex2->getAttribute('ttl')); + $this->assertEquals('idx_ttl_collection', $ttlIndex2->key); + $this->assertEquals(7200, $ttlIndex2->ttl); $database->deleteCollection($col); $database->deleteCollection($col2); } - public function testTTLIndexDuplicatePrevention(): void + public function testRenameIndexMissing(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); + $this->initRenameIndexFixture(); - if (!$database->getAdapter()->getSupportForTTLIndexes()) { - $this->expectNotToPerformAssertions(); - return; + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Index not found'); + $database->renameIndex($this->getNumbersCollection(), 'index1', 'index4'); + } + + public function testRenameIndexExisting(): void + { + $database = $this->getDatabase(); + $this->initRenameIndexFixture(); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Index name already used'); + $database->renameIndex($this->getNumbersCollection(), 'index3', 'index2'); + } + + /** + * @param array $attributes + * @param array $indexes + */ + private function indexValidator(array $attributes, array $indexes): IndexDefinition + { + return new IndexDefinition($attributes, $indexes, $this->getDatabase()->profile()); + } + + public function testIndexValidation(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + $attributes = [ + Attribute::string(key: 'title1', size: 700), + Attribute::string(key: 'title2', size: 500), + ]; + + $indexes = [ + Index::key(key: 'index1', attributes: ['title1', 'title2'], lengths: [701, 50]), + ]; + + $validator = $this->indexValidator($attributes, $indexes); + + if ($adapter->supports(Capability::IndexIdentical)) { + $errorMessage = 'Index length 701 is larger than the size for title1: 700"'; + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertSame($errorMessage, $validator->getDescription()); + + try { + $database->createCollection(Collection::create(id: 'index_length', attributes: $attributes, indexes: $indexes, permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ])); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertSame($errorMessage, $e->getMessage()); + } } - $col = uniqid('sl_ttl_dup'); - $database->createCollection($col); + $indexes = [ + Index::key(key: 'index1', attributes: ['title1', 'title2'], lengths: [700]), + ]; - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createAttribute($col, 'deletedAt', Database::VAR_DATETIME, 0, false); + if ($adapter->supports(Capability::DefinedAttributes) && $adapter->limits()->indexLength > 0) { + $errorMessage = 'Index length is longer than the maximum: '.$adapter->limits()->indexLength; + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertSame($errorMessage, $validator->getDescription()); - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expires', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 3600 // 1 hour - ) - ); + try { + $database->createCollection(Collection::create(id: 'index_length', attributes: $attributes, indexes: $indexes)); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertSame($errorMessage, $e->getMessage()); + } + } + + $attributes[] = Attribute::integer(key: 'integer', width: IntegerWidth::Bits64); + + $indexes = [ + Index::fulltext(key: 'index1', attributes: ['title1', 'integer']), + ]; + + $newIndex = Index::fulltext(key: 'newIndex1', attributes: ['title1', 'integer']); + + $validator = $this->indexValidator($attributes, $indexes); + + $this->assertFalse($validator->isValid($newIndex)); + + if (! $adapter->supports(Capability::IndexFulltext)) { + $this->assertSame('Fulltext index is not supported', $validator->getDescription()); + } elseif (! $adapter->supports(Capability::IndexFulltextMultiple)) { + $this->assertSame('There is already a fulltext index in the collection', $validator->getDescription()); + } elseif ($adapter->supports(Capability::DefinedAttributes)) { + $this->assertSame('Attribute "integer" cannot be part of a fulltext index, must be of type string', $validator->getDescription()); + } try { - $database->createIndex( - $col, - 'idx_ttl_expires_duplicate', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 7200 // 2 hours - ); - $this->fail('Expected exception for creating a second TTL index in a collection'); + $database->createCollection(Collection::create(id: 'index_length', attributes: $attributes, indexes: $indexes)); + if ($adapter->supports(Capability::DefinedAttributes)) { + $this->fail('Failed to throw exception'); + } + $database->deleteCollection('index_length'); } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + if (! $adapter->supports(Capability::IndexFulltext)) { + $this->assertSame('Fulltext index is not supported', $e->getMessage()); + } else { + $this->assertSame('Attribute "integer" cannot be part of a fulltext index, must be of type string', $e->getMessage()); + } + } + + if (! $adapter->supports(Capability::DefinedAttributes)) { + return; } + $indexes = [ + Index::key(key: 'index_negative_length', attributes: ['title1'], lengths: [-1]), + ]; + + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertSame('Negative index length provided for title1', $validator->getDescription()); + try { - $database->createIndex( - $col, - 'idx_ttl_deleted', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 86400 // 24 hours - ); - $this->fail('Expected exception for creating a second TTL index in a collection'); + $database->createCollection(Collection::create(id: Id::unique(), attributes: $attributes, indexes: $indexes)); + $this->fail('Failed to throw exception'); } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + $this->assertSame('Negative index length provided for title1', $e->getMessage()); } - $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(1, $indexes); + $indexes = [ + Index::key(key: 'index_extra_lengths', attributes: ['title1', 'title2'], lengths: [100, 100, 100]), + ]; - $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); - $this->assertContains('idx_ttl_expires', $indexIds); - $this->assertNotContains('idx_ttl_deleted', $indexIds); + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertSame('Invalid index lengths. Count of lengths must be equal or less than the number of attributes.', $validator->getDescription()); try { - $database->createIndex( - $col, - 'idx_ttl_deleted_duplicate', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 172800 // 48 hours - ); - $this->fail('Expected exception for creating a second TTL index in a collection'); + $database->createCollection(Collection::create(id: Id::unique(), attributes: $attributes, indexes: $indexes)); + $this->fail('Failed to throw exception'); } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + $this->assertSame('Invalid index lengths. Count of lengths must be equal or less than the number of attributes.', $e->getMessage()); + } + } + + public function testCreateCollectionWithIndexOnSequence(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::IndexKey)) { + $this->expectNotToPerformAssertions(); + + return; } - $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); + $collection = $database->createCollection(Collection::create(id: 'sequenceIndexes', attributes: [ + Attribute::string(key: 'username', size: 128), + Attribute::string(key: 'email', size: 128), + ], indexes: [ + Index::key(key: '_index 123', attributes: ['username', '$sequence'], orders: [OrderDirection::Asc, OrderDirection::Desc]), + Index::unique(key: '_index 456', attributes: ['email', '$sequence'], orders: [OrderDirection::Asc, OrderDirection::Desc]), + ])); - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_deleted', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 1800 // 30 minutes - ) - ); + $indexes = $collection->indexes(); + $this->assertCount(2, $indexes); + $this->assertSame('_index 123', $indexes[0]->key); + $this->assertSame(['username', '$sequence'], $indexes[0]->attributes); + $this->assertSame('_index 456', $indexes[1]->key); + $this->assertSame(['email', '$sequence'], $indexes[1]->attributes); - $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(1, $indexes); + $this->assertSequenceIndexesAnswerQueries('sequenceIndexes'); - $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); - $this->assertNotContains('idx_ttl_expires', $indexIds); - $this->assertContains('idx_ttl_deleted', $indexIds); + $database->deleteCollection('sequenceIndexes'); + } - $col3 = uniqid('sl_ttl_dup_collection'); - - $expiresAtAttr = new Document([ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]); + public function testCreateIndexOnSequence(): void + { + $database = $this->getDatabase(); - $ttlIndex1 = new Document([ - '$id' => ID::custom('idx_ttl_1'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 3600 - ]); + if (! $database->getAdapter()->supports(Capability::IndexKey)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(Collection::create(id: __FUNCTION__)); + + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'username', size: 128)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'email', size: 128)); + + $database->createIndex(__FUNCTION__, Index::key(key: '_index 123', attributes: ['username', '$sequence'], orders: [OrderDirection::Asc, OrderDirection::Desc])); + $database->createIndex(__FUNCTION__, Index::unique(key: '_index 456', attributes: ['email', '$sequence'], orders: [OrderDirection::Asc, OrderDirection::Desc])); + + $indexes = $database->getCollection(__FUNCTION__)->indexes(); + $this->assertCount(2, $indexes); + $this->assertSame('_index 123', $indexes[0]->key); + $this->assertSame(['username', '$sequence'], $indexes[0]->attributes); + $this->assertSame('_index 456', $indexes[1]->key); + $this->assertSame(['email', '$sequence'], $indexes[1]->attributes); + + $this->assertSequenceIndexesAnswerQueries(__FUNCTION__); + + $database->deleteCollection(__FUNCTION__); + } + + private function assertSequenceIndexesAnswerQueries(string $collection): void + { + $database = $this->getDatabase(); + + $database->createDocument($collection, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'username' => 'chester', + 'email' => 'chester@example.com', + ])); - $ttlIndex2 = new Document([ - '$id' => ID::custom('idx_ttl_2'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200 + $documents = $database->find($collection, [ + Query::equal('username', ['chester']), + Query::orderDesc('$sequence'), ]); - try { - $database->createCollection($col3, [$expiresAtAttr], [$ttlIndex1, $ttlIndex2]); - $this->fail('Expected exception for duplicate TTL indexes in createCollection'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); - } + $this->assertCount(1, $documents); + $this->assertSame('chester', $documents[0]->getAttribute('username')); - // Cleanup - $database->deleteCollection($col); + $database->createDocument($collection, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'username' => 'chester', + 'email' => 'chester@example.com', + ])); + + $this->assertCount(2, $database->find($collection, [ + Query::equal('email', ['chester@example.com']), + ]), '$sequence is unique on its own, so a unique index containing it never conflicts. A duplicate here means the index was built without the $sequence column'); + } + + public function testCompositeIndexKeepsArrayAttributePosition(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! ($adapter instanceof MariaDB || $adapter instanceof Postgres) || ! $adapter->supports(Capability::IndexArray)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $attributes = [ + Attribute::string(key: 'tags', size: 64, array: true), + Attribute::string(key: 'status', size: 32), + Attribute::string(key: 'name', size: 128), + ]; + $index = Index::key(key: 'tagsfirst', attributes: ['tags', 'status', 'name'], lengths: [255, null, 16], orders: [null, null, OrderDirection::Desc]); + + $tenant = $database->hasSharedTables() ? ['_tenant'] : []; + $expected = match (true) { + $adapter instanceof Postgres => [...$tenant, 'tags', 'status', 'name DESC'], + $adapter->supports(Capability::IndexArrayCast) => [...$tenant, '', 'status', 'name(16)'], + default => [...$tenant, 'tags(255)', 'status', 'name(16)'], + }; + + $database->createCollection(Collection::create(id: 'index_array_position_created', attributes: $attributes, indexes: [$index])); + try { + $this->assertSame($expected, $this->getIndexKeyParts($database, 'index_array_position_created', 'tagsfirst')); + } finally { + $database->deleteCollection('index_array_position_created'); + } + + $database->createCollection(Collection::create(id: 'index_array_position_added')); + try { + $database->createAttributes('index_array_position_added', $attributes); + $database->createIndex('index_array_position_added', $index); + $this->assertSame($expected, $this->getIndexKeyParts($database, 'index_array_position_added', 'tagsfirst')); + } finally { + $database->deleteCollection('index_array_position_added'); + } + } + + public function testCompositeIndexKeepsObjectPathPosition(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter instanceof Postgres || ! $adapter->supports(Capability::Objects)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'index_object_path_position'; + $database->createCollection(Collection::create(id: $collection)); + + try { + $database->createAttribute($collection, Attribute::object(key: 'data')); + $database->createAttribute($collection, Attribute::string(key: 'status', size: 32)); + $database->createIndex($collection, Index::key(key: 'countryfirst', attributes: ['data.country', 'status'], orders: [OrderDirection::Desc, null])); + + $parts = $this->getIndexKeyParts($database, $collection, 'countryfirst'); + $tenant = $database->hasSharedTables() ? ['_tenant'] : []; + + $this->assertSame([...$tenant, "(data ->> 'country'::text) DESC", 'status'], $parts); + } finally { + $database->deleteCollection($collection); + } + } + + /** + * Key parts of an index in the order the engine stores them: MariaDB and MySQL prefix lengths as "column(length)", + * PostgreSQL descending parts as "part DESC". + * + * @return list + */ + private function getIndexKeyParts(Database $database, string $collection, string $index): array + { + $adapter = $database->getAdapter(); + + if ($adapter instanceof Postgres) { + $rows = $adapter->rawQuery( + 'SELECT c.relname AS "index", pg_get_indexdef(i.indexrelid, k.position, true) || CASE WHEN i.indoption[k.position - 1] & 1 = 1 THEN \' DESC\' ELSE \'\' END AS "part" + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + CROSS JOIN LATERAL generate_series(1, i.indnkeyatts) AS k(position) + WHERE i.indrelid = to_regclass(?) + ORDER BY c.relname, k.position', + ['"'.$database->getDatabase().'"."'.$database->getNamespace().'_'.$collection.'"'], + ); + + $parts = []; + foreach ($rows as $row) { + $name = $row->getAttribute('index'); + $part = $row->getAttribute('part'); + $this->assertIsString($name); + $this->assertIsString($part); + if (\str_ends_with($name, '_'.$index)) { + $parts[] = $part; + } + } + $this->assertNotEmpty($parts, 'Index '.$index.' was not found on '.$collection); + + return $parts; + } + + foreach ($database->getSchemaIndexes($collection) as $schemaIndex) { + if ($schemaIndex->name !== $index) { + continue; + } + + $parts = []; + foreach ($schemaIndex->columns as $position => $column) { + $length = $schemaIndex->lengths[$position] ?? null; + $parts[] = $length !== null ? $column.'('.$length.')' : $column; + } + + return $parts; + } + + $this->fail('Index '.$index.' was not found on '.$collection); + } + + public function testExceptionIndexLimit(): void + { + $database = $this->getDatabase(); + + $database->createCollection(Collection::create(id: 'indexLimit')); + + for ($i = 0; $i < 64; $i++) { + $database->createAttribute('indexLimit', Attribute::string(key: "test{$i}", size: 16, required: true)); + } + + for ($i = 0; $i < $database->getLimitForIndexes(); $i++) { + $database->createIndex('indexLimit', Index::key(key: "index{$i}", attributes: ["test{$i}"], lengths: [16])); + } + + try { + $database->createIndex('indexLimit', Index::key(key: 'index64', attributes: ['test64'], lengths: [16])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(LimitException::class, $e); + } finally { + $database->deleteCollection('indexLimit'); + } + } + + public function testIdenticalIndexValidation(): void + { + $database = $this->getDatabase(); + + $collectionId = 'identical_index_test'; + + try { + $database->createCollection(Collection::create(id: $collectionId)); + + $database->createAttribute($collectionId, Attribute::string(key: 'name', size: 256)); + $database->createAttribute($collectionId, Attribute::integer(key: 'age', width: IntegerWidth::Bits64)); + + $database->createIndex($collectionId, Index::key(key: 'index1', attributes: ['name', 'age'], orders: [OrderDirection::Asc, OrderDirection::Desc])); + + $supportsIdenticalIndexes = $database->getAdapter()->supports(Capability::IndexIdentical); + + try { + $database->createIndex($collectionId, Index::key(key: 'index2', attributes: ['name', 'age'], orders: [OrderDirection::Asc, OrderDirection::Desc])); + $this->assertTrue($supportsIdenticalIndexes, 'An identical index must be rejected when the adapter does not support identical indexes'); + } catch (Throwable $e) { + $this->assertFalse($supportsIdenticalIndexes, 'Unexpected exception when creating identical index: '.$e->getMessage()); + $this->assertSame('There is already an index with the same attributes and orders', $e->getMessage()); + } + + try { + $database->createIndex($collectionId, Index::key(key: 'index3', attributes: ['age', 'name'], orders: [OrderDirection::Asc, OrderDirection::Desc])); + } catch (Throwable $e) { + $this->assertFalse($supportsIdenticalIndexes, 'Unexpected exception when creating index with a different attribute order: '.$e->getMessage()); + } + + try { + $database->createIndex($collectionId, Index::key(key: 'index4', attributes: ['age', 'name'], orders: [OrderDirection::Desc, OrderDirection::Asc])); + } catch (Throwable $e) { + $this->assertFalse($supportsIdenticalIndexes, 'Unexpected exception when creating index with different orders: '.$e->getMessage()); + } + + $database->createIndex($collectionId, Index::key(key: 'index5', attributes: ['name'], orders: [OrderDirection::Asc])); + $database->createIndex($collectionId, Index::key(key: 'index6', attributes: ['name', 'age'], orders: [OrderDirection::Asc])); + } finally { + $database->deleteCollection($collectionId); + } + } + + public function testMaxQueriesValues(): void + { + $database = $this->getDatabase(); + $collection = 'maxQueryValues_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection)); + + $max = $database->getMaxQueryValues(); + $database->setMaxQueryValues(5); + + try { + $database->find($collection, [Query::equal('$id', ['1', '2', '3', '4', '5', '6'])]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(QueryException::class, $e); + $this->assertSame('Invalid query: Query on attribute has greater than 5 values: $id', $e->getMessage()); + } finally { + $database->setMaxQueryValues($max); + $database->deleteCollection($collection); + } + } + + public function testMultipleFulltextIndexValidation(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionId = 'multiple_fulltext_test'; + + try { + $database->createCollection(Collection::create(id: $collectionId)); + + $database->createAttribute($collectionId, Attribute::string(key: 'title', size: 256)); + $database->createAttribute($collectionId, Attribute::string(key: 'content', size: 256)); + $database->createIndex($collectionId, Index::fulltext(key: 'fulltext_title', attributes: ['title'])); + + $supportsMultipleFulltext = $database->getAdapter()->supports(Capability::IndexFulltextMultiple); + + try { + $database->createIndex($collectionId, Index::fulltext(key: 'fulltext_content', attributes: ['content'])); + $this->assertTrue($supportsMultipleFulltext, 'Expected exception when creating second fulltext index, but none was thrown'); + } catch (Throwable $e) { + $this->assertFalse($supportsMultipleFulltext, 'Unexpected exception when creating second fulltext index: '.$e->getMessage()); + $this->assertSame('There is already a fulltext index in the collection', $e->getMessage()); + } + } finally { + $database->deleteCollection($collectionId); + } + } + + public function testTTLIndexDuplicatePrevention(): void + { + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::IndexTtl)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = uniqid('sl_ttl_dup'); + $database->createCollection(Collection::create(id: $collection)); + + $database->createAttribute($collection, Attribute::datetime(key: 'expiresAt')); + $database->createAttribute($collection, Attribute::datetime(key: 'deletedAt')); + + $database->createIndex($collection, Index::ttl(key: 'idx_ttl_expires', attribute: 'expiresAt', ttl: 3600)); + + foreach ([ + Index::ttl(key: 'idx_ttl_expires_duplicate', attribute: 'expiresAt', ttl: 7200), + Index::ttl(key: 'idx_ttl_deleted', attribute: 'deletedAt', ttl: 86400), + ] as $duplicate) { + try { + $database->createIndex($collection, $duplicate); + $this->fail('Expected exception for creating a second TTL index in a collection'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + } + } + + $indexes = $database->getCollection($collection)->indexes(); + $this->assertCount(1, $indexes); + + $indexIds = array_map(fn (Index $index) => $index->key, $indexes); + $this->assertContains('idx_ttl_expires', $indexIds); + $this->assertNotContains('idx_ttl_deleted', $indexIds); + + try { + $database->createIndex($collection, Index::ttl(key: 'idx_ttl_deleted_duplicate', attribute: 'deletedAt', ttl: 172800)); + $this->fail('Expected exception for creating a second TTL index in a collection'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + } + + $database->deleteIndex($collection, 'idx_ttl_expires'); + + $database->createIndex($collection, Index::ttl(key: 'idx_ttl_deleted', attribute: 'deletedAt', ttl: 1800)); + + $indexes = $database->getCollection($collection)->indexes(); + $this->assertCount(1, $indexes); + + $indexIds = array_map(fn (Index $index) => $index->key, $indexes); + $this->assertNotContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + try { + $database->createCollection(Collection::create(id: uniqid('sl_ttl_dup_collection'), attributes: [ + Attribute::datetime(key: 'expiresAt'), + ], indexes: [ + Index::ttl(key: 'idx_ttl_1', attribute: 'expiresAt', ttl: 3600), + Index::ttl(key: 'idx_ttl_2', attribute: 'expiresAt', ttl: 7200), + ])); + $this->fail('Expected exception for duplicate TTL indexes in createCollection'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + } + + $database->deleteCollection($collection); + } + + public function testSchemaIndexesListFulltextIndexes(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->supports(Capability::SchemaIntrospection) || ! $adapter->supports(Capability::IndexFulltext) || $adapter instanceof Postgres) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'schema_fulltext'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'title', size: 128), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + try { + $database->createIndex($collection, Index::fulltext(key: 'title_search', attributes: ['title'])); + $this->assertSame(['title_search' => ['title']], $this->getFulltextSchemaIndexes($database, $collection)); + + $database->renameIndex($collection, 'title_search', 'title_lookup'); + $this->assertSame(['title_lookup' => ['title']], $this->getFulltextSchemaIndexes($database, $collection)); + } finally { + $database->deleteCollection($collection); + } + } + + public function testDeleteFulltextIndexDropsItsTables(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->supports(Capability::SchemaIntrospection) || ! $adapter->supports(Capability::IndexFulltext) || $adapter instanceof Postgres) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'delete_fulltext'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'title', size: 128), + Attribute::string(key: 'body', size: 128), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + $search = static fn (string $attribute, string $term): array => \array_map( + static fn (Document $document): string => $document->getId(), + $database->find($collection, [Query::search($attribute, $term)]), + ); + + try { + $database->createDocument($collection, new Document([ + '$id' => 'fox', + 'title' => 'quick brown fox', + 'body' => 'lazy dog', + ])); + + $multiple = $adapter->supports(Capability::IndexFulltextMultiple); + $database->createIndex($collection, Index::fulltext(key: 'title_search', attributes: ['title'])); + if ($multiple) { + $database->createIndex($collection, Index::fulltext(key: 'body_search', attributes: ['body'])); + } + $remaining = $multiple ? ['body_search' => ['body']] : []; + + $database->deleteIndex($collection, 'title_search'); + $this->assertSame($remaining, $this->getFulltextSchemaIndexes($database, $collection)); + + try { + $search('title', 'quick'); + $this->fail('A search on an attribute whose fulltext index was deleted must be refused'); + } catch (QueryException $error) { + $this->assertSame('Searching by attribute "title" requires a fulltext index.', $error->getMessage()); + } + + if ($multiple) { + $this->assertSame(['fox'], $search('body', 'lazy')); + } + + $database->createIndex($collection, Index::fulltext(key: 'title_search', attributes: ['title'])); + $this->assertSame($remaining + ['title_search' => ['title']], $this->getFulltextSchemaIndexes($database, $collection)); + $this->assertSame(['fox'], $search('title', 'quick')); + } finally { + $database->deleteCollection($collection); + } + } + + /** + * @return array> The columns of each fulltext index by id, without the tenant column, sorted by id + */ + private function getFulltextSchemaIndexes(Database $database, string $collection): array + { + $indexes = []; + foreach ($database->getSchemaIndexes($collection) as $schemaIndex) { + if ($schemaIndex->type !== IndexType::Fulltext) { + continue; + } + + $indexes[$schemaIndex->name] = \array_values(\array_filter( + $schemaIndex->columns, + static fn (string $column): bool => $column !== Storage::TENANT, + )); + } + \ksort($indexes); + + return $indexes; + } + + public function testCreateIndexReplacesMismatchedOrphanIndex(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::SchemaIntrospection)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'orphanIndex'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'email', size: 64), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + try { + $database->getAdapter()->createIndex($collection, Index::key(key: 'lookup', attributes: ['name'])); + + if ($this->getSchemaIndexColumns($database, $collection, 'lookup') === null) { + $this->markTestSkipped('getSchemaIndexes() does not report indexes under their id on this adapter'); + } + + if ($database->hasSharedTables()) { + try { + $database->createIndex($collection, Index::unique(key: 'lookup', attributes: ['email'])); + $this->fail('An index another tenant may use must not be replaced under shared tables'); + } catch (DuplicateException $error) { + $this->assertSame('Index exists in the shared table with another definition', $error->getMessage()); + } + + $this->assertSame(['name'], $this->getSchemaIndexColumns($database, $collection, 'lookup')); + $this->assertSame([], $database->getCollection($collection)->indexes()); + + return; + } + + $database->createIndex($collection, Index::unique(key: 'lookup', attributes: ['email'])); + $this->assertSame(['email'], $this->getSchemaIndexColumns($database, $collection, 'lookup')); + + $database->createDocument($collection, new Document(['email' => 'user@example.com'])); + try { + $database->createDocument($collection, new Document(['email' => 'user@example.com'])); + $this->fail('The replaced index must enforce uniqueness on email'); + } catch (DuplicateException) { + $this->assertSame(1, $database->count($collection)); + } + } finally { + $database->deleteCollection($collection); + } + } + + public function testUpdateAttributeCoveredByAKeyIndexSucceeds(): void + { + $database = $this->getDatabase(); + $collection = 'indexedResize'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + indexes: [Index::key(key: 'by_name', attributes: ['name'])], + )); + + try { + $updated = $database->updateAttribute($collection, 'name', new AttributeUpdate(size: 128)); + + $this->assertSame(128, $updated->size); + $this->assertSame(128, $database->getCollection($collection)->attributes()[0]->size); + $this->assertSame(['by_name'], \array_map( + static fn (Index $index): string => $index->key, + $database->getCollection($collection)->indexes(), + )); + } finally { + $database->deleteCollection($collection); + } + } + + /** + * @return list|null The index's columns without the tenant column, or null when the schema does not list it + */ + private function getSchemaIndexColumns(Database $database, string $collection, string $index): ?array + { + foreach ($database->getSchemaIndexes($collection) as $schemaIndex) { + if ($schemaIndex->name !== $index) { + continue; + } + + return \array_values(\array_filter( + $schemaIndex->columns, + static fn (string $column): bool => $column !== Storage::TENANT, + )); + } + + return null; + } + + public function testMongoUniqueIndexOnAnIntegerIsEnforced(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter() instanceof Mongo) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createMongoUniqueIndexCollection($database, [Attribute::integer(key: 'count', width: IntegerWidth::Bits64)]); + + $this->assertMongoUniqueIndexRejectsDuplicates($database, $collection, 'count', 7); + $this->assertMongoUniqueIndexRejectsDuplicates($database, $collection, 'count', 5_000_000_000); + + $database->deleteCollection($collection); + } + + public function testMongoUniqueIndexesOnFloatBooleanAndDatetimeAreEnforced(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter() instanceof Mongo) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createMongoUniqueIndexCollection($database, [ + Attribute::double(key: 'price'), + Attribute::boolean(key: 'active'), + Attribute::datetime(key: 'seenAt'), + ]); + + $this->assertMongoUniqueIndexRejectsDuplicates($database, $collection, 'price', 9.5); + $this->assertMongoUniqueIndexRejectsDuplicates($database, $collection, 'active', true); + $this->assertMongoUniqueIndexRejectsDuplicates($database, $collection, 'seenAt', '2026-01-01T00:00:00.000+00:00'); + + $database->deleteCollection($collection); + } + + public function testMongoUniqueIndexOnAnUntypedAttributeIsEnforced(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter() instanceof Mongo) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createMongoUniqueIndexCollection($database, []); + $database->createIndex($collection, Index::unique(key: 'created_unique', attributes: ['$createdAt'])); + + $database->withPreserveDates(true, fn () => $this->assertMongoUniqueIndexRejectsDuplicates($database, $collection, '$createdAt', '2026-01-01T00:00:00.000+00:00')); + + $database->deleteCollection($collection); + } + + public function testMongoUniqueIndexOnAnUntypedAttributeCoversEveryStoredType(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter instanceof Mongo) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createMongoUniqueIndexCollection($database, []); + $database->createIndex($collection, Index::unique(key: 'created_unique', attributes: ['$createdAt'])); + $table = $adapter->getNamespace().'_'.$adapter->filter($collection); + + $values = [ + 'an array' => [[1], [2]], + 'an empty array' => [], + 'binary data' => new Binary("\x01", Binary::TYPE_GENERIC), + 'a timestamp' => new Timestamp(1, 1), + 'a regular expression' => new Regex('x'), + 'code' => new Javascript('1'), + 'the lowest key' => new MinKey(), + 'the highest key' => new MaxKey(), + ]; + + try { + foreach ($values as $name => $value) { + $adapter->getDriver()->insert($table, $this->rawMongoDocument($adapter, $value)); + + $error = null; + try { + $adapter->getDriver()->insert($table, $this->rawMongoDocument($adapter, $value)); + } catch (MongoException $caught) { + $error = $caught; + } + + $this->assertInstanceOf(MongoException::class, $error, 'The unique index on $createdAt must reject a second document holding '.$name); + $this->assertTrue($error->isDuplicateKeyError(), $name.': '.$error->getMessage()); + $this->assertStringContainsString('created_unique', $error->getMessage(), $name.': the duplicate must be caught by created_unique'); + } + } finally { + $database->deleteCollection($collection); + } + } + + public function testMongoKeyIndexesServeEqualityFilters(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter instanceof Mongo) { + $this->expectNotToPerformAssertions(); + + return; + } + + $values = [ + 'count' => 7, + 'price' => 9.5, + 'active' => true, + 'seenAt' => new UTCDateTime(new NativeDateTime('2026-01-01T00:00:00+00:00')), + 'name' => 'first', + ]; + $attributes = [ + Attribute::integer(key: 'count'), + Attribute::double(key: 'price'), + Attribute::boolean(key: 'active'), + Attribute::datetime(key: 'seenAt'), + Attribute::string(key: 'name', size: 16), + Attribute::string(key: 'group', size: 16), + ]; + $indexes = \array_map( + fn (string $attribute): Index => Index::key(key: $attribute.'_key', attributes: [$attribute]), + \array_keys($values), + ); + $indexes[] = Index::key(key: 'group_count', attributes: ['group', 'count']); + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + + $fromCollection = 'key_scan_collection_'.\uniqid(); + $database->createCollection(Collection::create(id: $fromCollection, attributes: $attributes, indexes: $indexes, permissions: $permissions, documentSecurity: false)); + + $fromIndex = 'key_scan_index_'.\uniqid(); + $database->createCollection(Collection::create(id: $fromIndex, attributes: $attributes, permissions: $permissions, documentSecurity: false)); + foreach ($indexes as $index) { + $database->createIndex($fromIndex, $index); + } + + foreach ([$fromCollection, $fromIndex] as $collection) { + $database->createDocument($collection, new Document([ + 'count' => 7, + 'price' => 9.5, + 'active' => true, + 'seenAt' => '2026-01-01T00:00:00.000+00:00', + 'name' => 'first', + 'group' => 'a', + ])); + + foreach ($values as $attribute => $value) { + $plan = $this->explainMongoFind($adapter, $collection, [$attribute => $value]); + + $this->assertStringContainsString('"stage":"IXSCAN"', $plan, $collection.': an equality on '.$attribute.' must scan its key index'); + $this->assertStringContainsString('"indexName":"'.$attribute.'_key"', $plan, $collection.': an equality on '.$attribute.' must use '.$attribute.'_key'); + } + + $plan = $this->explainMongoFind($adapter, $collection, ['group' => 'a']); + $this->assertStringContainsString('"stage":"IXSCAN"', $plan, $collection.': an equality on the leading field of a compound key index must scan it'); + $this->assertStringContainsString('"indexName":"group_count"', $plan, $collection.': an equality on group alone must use group_count'); + + $database->deleteCollection($collection); + } + } + + public function testRenamingAnIndexTheSchemaNoLongerHasFails(): void + { + $database = $this->getDatabase(); + $collection = 'renameDroppedIndex'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::integer(key: 'age'), + ], indexes: [ + Index::key(key: 'byAge', attributes: ['age']), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + try { + $database->getAdapter()->deleteIndex($collection, 'byAge'); + + if ($database->getAdapter() instanceof SQLite) { + $database->renameIndex($collection, 'byAge', 'ageIndex'); + $this->assertSame(['ageIndex'], $this->getIndexKeys($database, $collection)); + + return; + } + + try { + $database->renameIndex($collection, 'byAge', 'ageIndex'); + $this->fail('A rename of an index the schema does not have must fail'); + } catch (DatabaseException $error) { + $this->assertStringStartsWith("Failed to rename index 'byAge' to 'ageIndex'", $error->getMessage()); + } + + $this->assertSame(['byAge'], $this->getIndexKeys($database, $collection)); + } finally { + $database->deleteCollection($collection); + } + } + + public function testRenamingAnIndexTheSchemaAlreadyRenamedCompletes(): void + { + $database = $this->getDatabase(); + $collection = 'renameRenamedIndex'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::integer(key: 'age'), + ], indexes: [ + Index::key(key: 'byAge', attributes: ['age']), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + try { + $this->assertTrue($database->getAdapter()->renameIndex($collection, 'byAge', 'ageIndex')); + + if ($database->getAdapter() instanceof Mongo) { + try { + $database->renameIndex($collection, 'byAge', 'ageIndex'); + $this->fail('MongoDB drops the old index before it renames, so a rename the schema already made fails'); + } catch (DatabaseException $error) { + $this->assertStringStartsWith("Failed to rename index 'byAge' to 'ageIndex': ", $error->getMessage()); + } + + $this->assertSame(['byAge'], $this->getIndexKeys($database, $collection)); + + return; + } + + $database->renameIndex($collection, 'byAge', 'ageIndex'); + $this->assertSame(['ageIndex'], $this->getIndexKeys($database, $collection)); + } finally { + $database->deleteCollection($collection); + } + } + + /** + * @return list + */ + private function getIndexKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Index $index): string => $index->key, + $database->getCollection($collection)->indexes(), + ); + } + + /** + * @param array $filter + */ + private function explainMongoFind(Mongo $adapter, string $collection, array $filter): string + { + if ($adapter->hasSharedTables()) { + $filter = [Storage::TENANT => $adapter->getTenant(), ...$filter]; + } + + $client = $adapter->getDriver(); + + $explain = $client->query([ + 'explain' => [ + 'find' => $adapter->getNamespace().'_'.$adapter->filter($collection), + 'filter' => $filter, + ], + 'verbosity' => 'queryPlanner', + ]); + $this->assertInstanceOf(stdClass::class, $explain); + + $planner = $explain->queryPlanner ?? null; + $this->assertInstanceOf(stdClass::class, $planner); + + $plan = \json_encode($planner->winningPlan ?? null); + $this->assertIsString($plan); + + return $plan; + } + + /** + * @return array + */ + private function rawMongoDocument(Mongo $adapter, mixed $createdAt): array + { + $document = [Storage::UID => \uniqid(), Storage::CREATED_AT => $createdAt]; + if ($adapter->hasSharedTables()) { + $document[Storage::TENANT] = $adapter->getTenant(); + } + + return $document; + } + + /** + * @param list $attributes + */ + private function createMongoUniqueIndexCollection(Database $database, array $attributes): string + { + $collection = 'unique_types_'.\uniqid(); + + $database->createCollection(Collection::create( + id: $collection, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + foreach ($attributes as $attribute) { + $database->createIndex($collection, Index::unique(key: $attribute->key.'_unique', attributes: [$attribute->key])); + } + + return $collection; + } + + private function assertMongoUniqueIndexRejectsDuplicates(Database $database, string $collection, string $attribute, mixed $value): void + { + $database->createDocument($collection, new Document([$attribute => $value])); + + $error = null; + try { + $database->createDocument($collection, new Document([$attribute => $value])); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(UniqueException::class, $error, 'The unique index on '.$attribute.' must reject a second document with the same value'); } } diff --git a/tests/e2e/Adapter/Scopes/JoinComboTests.php b/tests/e2e/Adapter/Scopes/JoinComboTests.php new file mode 100644 index 0000000000..f80803224e --- /dev/null +++ b/tests/e2e/Adapter/Scopes/JoinComboTests.php @@ -0,0 +1,2240 @@ +getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $pubCol, $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $pubCol, $secCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::join($secCol, 'sec', [Query::on('$id', 'mainId')]), + ]); + + $this->assertSame(5, \count($results)); + $this->assertComboSecretsHidden($results); + + $scores = $this->comboNumericScores($results); + $this->assertContains(313, $scores); + $this->assertContains(10, $scores); + $this->assertSame(false, \in_array(777, $scores, true)); + $this->assertSame(false, \in_array(4242, $scores, true)); + + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + $this->assertContains('m1', $ids); + $this->assertContains('m2', $ids); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboSelfJoinPlusThirdTableAclPerAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [, , , $selfCol, $cCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $selfCol, $cCol): void { + $results = $database->find($selfCol, [ + Query::join($selfCol, 'visible', [Query::on('tag', 'tag')]), + Query::join($selfCol, 'hidden', [Query::on('tag', 'tag')]), + Query::join($cCol, 'c', [Query::on('visible.$id', 'selfId')]), + Query::select(['visible.payload', 'hidden.payload', 'c.secret']), + ]); + + $this->assertSame(1, \count($results)); + $this->assertComboSecretsHidden($results); + $this->assertSame('open-payload', $results[0]->getAttribute('visible.payload')); + $this->assertNotSame('combo-secret-alpha', $results[0]->getAttribute('visible.payload')); + $this->assertSame('c-open-token', $results[0]->getAttribute('c.secret')); + $this->assertNotSame('c-combo-secret', $results[0]->getId()); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboOrderBySecretColumnLimitOffsetOracle(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $pubCol, $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $pubCol, $secCol): void { + $ordered = $database->find($mCol, [ + Query::leftJoin($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::orderDesc('sec.score'), + ]); + $this->assertComboSecretsHidden($ordered); + $scores = $this->comboNumericScores($ordered); + $this->assertSame(false, \in_array(777, $scores, true)); + $this->assertContains(313, $scores); + $this->assertSame($scores, $this->sortedDesc($scores)); + + $limited = $database->find($mCol, [ + Query::leftJoin($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::orderDesc('sec.score'), + Query::limit(2), + ]); + $this->assertSame(2, \count($limited)); + $this->assertComboSecretsHidden($limited); + $this->assertSame( + \array_slice($this->comboNumericScores($ordered), 0, 2), + $this->comboNumericScores($limited) + ); + + $offset = $database->find($mCol, [ + Query::leftJoin($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::orderDesc('sec.score'), + Query::limit(2), + Query::offset(1), + ]); + $this->assertSame(2, \count($offset)); + $this->assertComboSecretsHidden($offset); + $this->assertSame( + \array_slice($this->comboNumericScores($ordered), 1, 2), + $this->comboNumericScores($offset) + ); + + $foj = $database->find($mCol, [ + Query::fullOuterJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::orderDesc('pub.score'), + ]); + $this->assertComboSecretsHidden($foj); + $fojScores = $this->comboNumericScores($foj); + $this->assertSame(4242, $fojScores[0]); + $this->assertContains(313, $fojScores); + $this->assertSame(false, \in_array(777, $fojScores, true)); + $this->assertSame($fojScores, $this->sortedDesc($fojScores)); + + $fojLimited = $database->find($mCol, [ + Query::fullOuterJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::orderDesc('pub.score'), + Query::limit(2), + Query::offset(1), + ]); + $this->assertSame(2, \count($fojLimited)); + $this->assertComboSecretsHidden($fojLimited); + + $identity = static function (Document $document): string { + $score = $document->getAttribute('pub.score') ?? $document->getAttribute('score'); + + return $document->getId().':'.(\is_numeric($score) ? (string) (int) $score : ''); + }; + $this->assertSame( + \array_slice(\array_map($identity, $foj), 1, 2), + \array_map($identity, $fojLimited) + ); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboFilterSecretWithoutProjectingSecret(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, , $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $secCol): void { + $results = $database->find($mCol, [ + Query::join($secCol, 'rev', [Query::on('$id', 'mainId')]), + Query::equal('rev.score', [777]), + Query::select(['name']), + ]); + + $this->assertSame(0, \count($results)); + $this->assertComboSecretsHidden($results); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboSumCountHavingExactSiblingOracle(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, , $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $secCol): void { + $aggregated = $database->aggregate($mCol, [ + Query::join($secCol, 'rev', [Query::on('$id', 'mainId')]), + Query::equal('name', ['Main']), + Query::sum('rev.score', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + ]); + + $this->assertSame(1, \count($aggregated)); + $this->assertComboSecretsHidden(self::rowDocuments($aggregated)); + $total = $aggregated[0]['total'] ?? null; + $this->assertTrue(\is_numeric($total)); + $this->assertSame(323, (int) $total); + $this->assertNotSame(1100, (int) $total); + $cnt = $aggregated[0]['cnt'] ?? null; + $this->assertTrue(\is_numeric($cnt)); + $this->assertSame(2, (int) $cnt); + $this->assertNotSame(3, (int) $cnt); + + $havingSum = $database->aggregate($mCol, [ + Query::join($secCol, 'rev', [Query::on('$id', 'mainId')]), + Query::sum('rev.score', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + Query::having([Query::equal('total', [1100])]), + ]); + $this->assertSame(0, \count($havingSum)); + $this->assertComboSecretsHidden(self::rowDocuments($havingSum)); + + $havingCount = $database->aggregate($mCol, [ + Query::join($secCol, 'rev', [Query::on('$id', 'mainId')]), + Query::sum('rev.score', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + Query::having([Query::equal('cnt', [3])]), + ]); + $this->assertSame(0, \count($havingCount)); + $this->assertComboSecretsHidden(self::rowDocuments($havingCount)); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboCursorAfterJoinSideOrderAttribute(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $pubCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $pubCol): void { + $full = $database->find($mCol, [ + Query::join($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::orderAsc('pub.score'), + ]); + $this->assertSame(2, \count($full)); + $this->assertComboSecretsHidden($full); + $this->assertSame([10, 313], $this->comboNumericScores($full)); + + $first = $database->find($mCol, [ + Query::join($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::orderAsc('pub.score'), + Query::limit(1), + ]); + $this->assertSame(1, \count($first)); + $this->assertSame(10, $this->comboNumericScores($first)[0]); + $this->assertComboSecretsHidden($first); + + $next = $database->find($mCol, [ + Query::join($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::orderAsc('pub.score'), + Query::cursorAfter($first[0]), + Query::limit(1), + ]); + $this->assertSame(1, \count($next)); + $this->assertComboSecretsHidden($next); + $this->assertSame(313, $this->comboNumericScores($next)[0]); + $this->assertSame($full[1]->getId(), $next[0]->getId()); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboFullOuterPlusLeftThenIsNull(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $pubCol, $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $pubCol, $secCol): void { + $results = $database->find($mCol, [ + Query::fullOuterJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::leftJoin($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::isNull('sec.score'), + Query::select(['name', 'pub.score']), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertComboSecretsHidden($results); + + $scores = $this->comboNumericScores($results); + $this->assertContains(4242, $scores); + $this->assertSame(false, \in_array(777, $scores, true)); + $this->assertSame(false, \in_array(313, $scores, true)); + $this->assertSame(false, \in_array(10, $scores, true)); + + foreach ($results as $document) { + $this->assertNotSame('j-combo-secret', $document->getId()); + $pubScore = $document->getAttribute('pub.score') ?? $document->getAttribute('score'); + if (\is_numeric($pubScore) && (int) $pubScore === 4242) { + continue; + } + $secScore = $document->getAttribute('sec.score'); + $this->assertTrue($secScore === null || $secScore === ''); + } + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboCrossJoinEqualDoesNotCartesianExplodeSecrets(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, , $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $secCol): void { + $crossed = $database->find($mCol, [ + Query::crossJoin($secCol, 'sec'), + ]); + $this->assertSame(6, \count($crossed)); + $this->assertComboSecretsHidden($crossed); + $this->assertSame(false, \in_array(777, $this->comboNumericScores($crossed), true)); + + $equal = $database->find($mCol, [ + Query::crossJoin($secCol, 'sec'), + Query::equal('sec.score', [10]), + ]); + $this->assertSame(2, \count($equal)); + $this->assertComboSecretsHidden($equal); + foreach ($this->comboNumericScores($equal) as $score) { + $this->assertNotSame(777, $score); + $this->assertSame(10, $score); + } + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboGetDocumentLeftUnmatchedAndInnerMatched(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $pubCol, $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $pubCol, $secCol): void { + $unmatched = $database->getDocument($mCol, 'm2', [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::join($secCol, 'sec', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $unmatched->isEmpty()); + $this->assertSame('m2', $unmatched->getId()); + $this->assertComboSecretHidden($unmatched); + $unmatchedScore = $unmatched->getAttribute('sec.score'); + $this->assertTrue(\is_numeric($unmatchedScore)); + $this->assertSame(313, (int) $unmatchedScore); + + $matched = $database->getDocument($mCol, 'm1', [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::join($secCol, 'sec', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $matched->isEmpty()); + $this->assertSame('m1', $matched->getId()); + $this->assertComboSecretHidden($matched); + $this->assertNotSame(777, $matched->getAttribute('sec.score')); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboSelectJoinPermissionsAndIdDoesNotLeak(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, , $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $secCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::select(['name', 'sec.$id', 'sec.$permissions']), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertComboSecretsHidden($results); + + foreach ($results as $document) { + $this->assertNotSame('j-combo-secret', $document->getId()); + $this->assertContains($document->getId(), ['m1', 'm2']); + foreach ($document->getPermissions() as $permission) { + $this->assertSame(false, \str_contains($permission, 'user:combo-hidden')); + $this->assertSame(false, \str_contains($permission, 'combo-secret-perm')); + $this->assertSame(false, \str_contains($permission, 'j-combo-secret')); + } + } + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboChainDocSecOffThenOnHidesSecretC(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, , , $selfCol, $cCol] = $this->seedJoinComboFixture($database); + + $any = [Permission::create(Role::any()), Permission::read(Role::any())]; + $documentLevel = [Permission::create(Role::any())]; + $database->updateCollection($selfCol, new CollectionUpdate(permissions: $any, documentSecurity: false)); + $database->updateCollection($cCol, new CollectionUpdate(permissions: $any, documentSecurity: false)); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $selfCol, $cCol, $documentLevel): void { + $visible = $database->find($mCol, [ + Query::join($selfCol, 'mid', [Query::on('$id', 'mainId')]), + Query::join($cCol, 'c', [Query::on('mid.$id', 'selfId')]), + ]); + $this->assertGreaterThanOrEqual(1, \count($visible)); + $visibleEncoded = \json_encode(\array_map(static function (Document $document): array { + /** @var array $copy */ + $copy = $document->getArrayCopy(); + + return $copy; + }, $visible)); + $this->assertNotFalse($visibleEncoded); + $this->assertSame(true, \str_contains($visibleEncoded, 'c-combo-secret')); + + $database->updateCollection($cCol, new CollectionUpdate(permissions: $documentLevel, documentSecurity: true)); + + $hidden = $database->find($mCol, [ + Query::join($selfCol, 'mid', [Query::on('$id', 'mainId')]), + Query::join($cCol, 'c', [Query::on('mid.$id', 'selfId')]), + ]); + $this->assertGreaterThanOrEqual(1, \count($hidden)); + $this->assertComboSecretsHidden($hidden); + foreach ($hidden as $document) { + $this->assertNotSame('c-combo-secret', $document->getId()); + $this->assertNotSame('c-combo-secret', $document->getAttribute('secret')); + $this->assertNotSame('c-combo-secret', $document->getAttribute('selfId')); + } + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboCountMatchesFindWhenFilteringJoinAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $pubCol, $secCol] = $this->seedJoinComboFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $pubCol, $secCol): void { + $visible = [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::join($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::equal('sec.score', [313]), + ]; + $found = $database->find($mCol, $visible); + $this->assertSame(\count($found), $database->count($mCol, $visible)); + $this->assertGreaterThan(0, \count($found)); + $this->assertComboSecretsHidden($found); + $this->assertContains(313, $this->comboNumericScores($found)); + + $hiddenScore = [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::join($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::equal('sec.score', [777]), + ]; + $hiddenScoreFound = $database->find($mCol, $hiddenScore); + $this->assertSame(0, \count($hiddenScoreFound)); + $this->assertSame(0, $database->count($mCol, $hiddenScore)); + $this->assertComboSecretsHidden($hiddenScoreFound); + + $hiddenSecret = [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::join($secCol, 'sec', [Query::on('$id', 'mainId')]), + Query::equal('sec.secret', ['combo-secret-alpha']), + ]; + $hiddenSecretFound = $database->find($mCol, $hiddenSecret); + $this->assertSame(0, \count($hiddenSecretFound)); + $this->assertSame(0, $database->count($mCol, $hiddenSecret)); + $this->assertComboSecretsHidden($hiddenSecretFound); + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinComboDottedAttributeNameDoesNotSplitAsAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $pubCol] = $this->seedJoinComboFixture($database); + $database->createAttribute($mCol, Attribute::integer(key: 'rev.score')); + $database->getAuthorization()->skip(function () use ($database, $mCol): void { + $database->deleteDocument($mCol, 'm1'); + $database->deleteDocument($mCol, 'm2'); + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + 'rev.score' => 21, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'Unmatched', + 'rev.score' => 22, + '$permissions' => [Permission::read(Role::any())], + ])); + }); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $pubCol): void { + $selected = $database->find($mCol, [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::select(['name', 'rev.score']), + Query::orderDesc('rev.score'), + ]); + $this->assertGreaterThanOrEqual(1, \count($selected)); + $this->assertComboSecretsHidden($selected); + + $dotted = []; + foreach ($selected as $document) { + $value = $document->getAttribute('rev.score') ?? $document->getAttribute('revscore'); + if (\is_numeric($value)) { + $dotted[] = (int) $value; + } + } + $this->assertContains(21, $dotted); + $this->assertContains(22, $dotted); + $this->assertSame(false, \in_array(313, $dotted, true)); + $this->assertSame(false, \in_array(777, $dotted, true)); + + $filtered = $database->find($mCol, [ + Query::leftJoin($pubCol, 'pub', [Query::on('$id', 'mainId')]), + Query::equal('rev.score', [21]), + ]); + $this->assertGreaterThanOrEqual(1, \count($filtered)); + $this->assertComboSecretsHidden($filtered); + foreach ($filtered as $document) { + $value = $document->getAttribute('rev.score') ?? $document->getAttribute('revscore'); + $this->assertTrue(\is_numeric($value)); + $this->assertSame(21, (int) $value); + } + }); + + $this->cleanupAggCollections($database, $this->joinComboCollections()); + } + + public function testJoinHardcoreNestedJsonAndJoinAliasSameQuery(): void + { + $database = static::getDatabase(); + if ( + ! $database->getAdapter()->supports(Capability::Joins) + || ! $database->getAdapter()->supports(Capability::Objects) + ) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $database->createAttribute($mCol, Attribute::object(key: 'profile')); + if ($database->getAdapter()->supports(Capability::IndexObject)) { + $database->createIndex($mCol, Index::key(key: 'idx_jh_profile_email', attributes: ['profile.user.email'])); + } + + $database->getAuthorization()->skip(function () use ($database, $mCol): void { + $main = $database->getDocument($mCol, 'hm1'); + $main->setAttribute('profile', [ + 'user' => [ + 'email' => 'alice@hard.example', + ], + ]); + $database->updateDocument($mCol, 'hm1', $main); + + $second = $database->getDocument($mCol, 'hm2'); + $second->setAttribute('profile', [ + 'user' => [ + 'email' => 'bob@hard.example', + ], + ]); + $database->updateDocument($mCol, 'hm2', $second); + }); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::select(['name', 'profile', 'meta.score']), + Query::equal('profile.user.email', ['alice@hard.example']), + Query::equal('meta.score', [10]), + Query::orderDesc('meta.score'), + ]); + + $this->assertSame(1, \count($results)); + $this->assertComboSecretsHidden($results); + $this->assertSame('hm1', $results[0]->getId()); + $this->assertSame('Main', $results[0]->getAttribute('name')); + + $profile = $results[0]->getAttribute('profile'); + $this->assertTrue(\is_array($profile)); + $user = $profile['user'] ?? null; + $this->assertTrue(\is_array($user)); + $this->assertSame('alice@hard.example', $user['email'] ?? null); + + $score = $results[0]->getAttribute('meta.score') ?? $results[0]->getAttribute('score'); + $this->assertTrue(\is_numeric($score)); + $this->assertSame(10, (int) $score); + $this->assertSame(false, \in_array(8686, $this->comboNumericScores($results), true)); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreSameTableTwoAliasesIndependentPredicates(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, , $peerCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $peerCol): void { + $results = $database->find($mCol, [ + Query::join($peerCol, 'alpha', [Query::on('$id', 'mainId')]), + Query::join($peerCol, 'beta', [Query::on('peerKey', '$id')]), + Query::equal('alpha.label', ['alpha-one']), + Query::equal('beta.label', ['beta-key']), + Query::select(['name', 'alpha.$id', 'beta.$id', 'alpha.label', 'beta.label', 'alpha.score']), + Query::orderDesc('alpha.score'), + ]); + + $this->assertSame(1, \count($results)); + $this->assertComboSecretsHidden($results); + $this->assertSame('hm1', $results[0]->getId()); + $this->assertSame('Main', $results[0]->getAttribute('name')); + $this->assertSame('peer-a', $results[0]->getAttribute('alpha.$id')); + $this->assertSame('peer-b', $results[0]->getAttribute('beta.$id')); + $this->assertSame('alpha-one', $results[0]->getAttribute('alpha.label')); + $this->assertSame('beta-key', $results[0]->getAttribute('beta.label')); + $this->assertNotSame('peer-a', $results[0]->getId()); + $this->assertNotSame('peer-b', $results[0]->getId()); + $this->assertNotSame('peer-hidden', $results[0]->getId()); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreSelfJoinOnIdDoesNotSmashIdentity(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol): void { + $results = $database->find($mCol, [ + Query::join($mCol, 'twin', [Query::on('$id', '$id')]), + Query::select(['name', 'rank', 'twin.$id', 'twin.name', 'twin.$permissions']), + Query::orderAsc('rank'), + ]); + + $this->assertSame(3, \count($results)); + $this->assertComboSecretsHidden($results); + + $names = [ + 'hm1' => 'Main', + 'hm2' => 'Second', + 'hm3' => 'Third', + ]; + $ids = []; + foreach ($results as $document) { + $id = $document->getId(); + $ids[] = $id; + $this->assertArrayHasKey($id, $names); + $this->assertSame($names[$id], $document->getAttribute('name')); + $this->assertSame($id, $document->getAttribute('twin.$id')); + $this->assertSame($names[$id], $document->getAttribute('twin.name')); + } + \sort($ids); + $this->assertSame(['hm1', 'hm2', 'hm3'], $ids); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreLeftInnerRightMixedDocSec(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol, , , $bCol, $cCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol, $bCol, $cCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::join($bCol, 'mid', [Query::on('$id', 'mainId')]), + Query::rightJoin($cCol, 'tail', [Query::on('$id', 'mainId')]), + Query::select(['name', 'meta.score', 'mid.label', 'tail.secret', 'tail.score']), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertComboSecretsHidden($results); + + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + $this->assertContains('hm1', $ids); + $this->assertContains('', $ids); + $this->assertSame(false, \in_array('hm2', $ids, true)); + $this->assertSame(false, \in_array('hm3', $ids, true)); + $this->assertSame(false, \in_array('hc-hidden', $ids, true)); + $this->assertSame(false, \in_array('hc-right', $ids, true)); + + $labels = []; + foreach ($results as $document) { + $label = $document->getAttribute('mid.label') ?? $document->getAttribute('label'); + if (\is_string($label) && $label !== '') { + $labels[] = $label; + } + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('tail.secret')); + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('secret')); + } + $this->assertContains('b-public', $labels); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreChainAOnBOffCOnHidesC(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [, , , $aCol, $bCol, $cCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $aCol, $bCol, $cCol): void { + $results = $database->find($aCol, [ + Query::distinct(), + Query::join($bCol, 'b', [Query::on('$id', 'aId')]), + Query::join($cCol, 'c', [Query::on('b.$id', 'bId')]), + Query::select(['$id', 'name', 'b.label', 'c.secret', 'c.score']), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertComboSecretsHidden($results); + + $labels = []; + foreach ($results as $document) { + $this->assertSame('ha1', $document->getId()); + $label = $document->getAttribute('b.label') ?? $document->getAttribute('label'); + if (\is_string($label) && $label !== '') { + $labels[] = $label; + } + $this->assertNotSame('hc-hidden', $document->getId()); + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('c.secret')); + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('secret')); + $score = $document->getAttribute('c.score') ?? $document->getAttribute('score'); + if (\is_numeric($score)) { + $this->assertNotSame(8686, (int) $score); + } + } + $this->assertContains('b-public', $labels); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreFullOuterJoinSideCursorPageWalk(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $ordered = [ + Query::fullOuterJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::orderAsc('meta.score'), + ]; + + $full = $database->find($mCol, $ordered); + $this->assertGreaterThanOrEqual(2, \count($full)); + $this->assertComboSecretsHidden($full); + + $fullScores = $this->comboNumericScores($full); + $this->assertContains(10, $fullScores); + $this->assertContains(15, $fullScores); + $this->assertContains(20, $fullScores); + $this->assertContains(42, $fullScores); + $this->assertContains(313, $fullScores); + $this->assertSame(false, \in_array(8686, $fullScores, true)); + $this->assertSame($fullScores, $this->sortedAsc($fullScores)); + + $cursor = null; + foreach ($full as $document) { + $score = $this->comboJoinScore($document); + if ($document->getId() !== '' && $score !== null) { + $cursor = $document; + break; + } + } + $this->assertNotNull($cursor); + $this->assertNotSame('', $cursor->getId()); + $cursorScore = $this->comboJoinScore($cursor); + $this->assertNotNull($cursorScore); + + $after = $database->find($mCol, [ + ...$ordered, + Query::cursorAfter($cursor), + Query::limit(1), + ]); + $this->assertSame(1, \count($after)); + $this->assertComboSecretsHidden($after); + $afterScore = $this->comboJoinScore($after[0]); + $this->assertNotNull($afterScore); + + $cursorIndex = \array_search($cursorScore, $fullScores, true); + $this->assertNotSame(false, $cursorIndex); + $nextIndex = (int) $cursorIndex + 1; + $this->assertArrayHasKey($nextIndex, $fullScores); + $this->assertSame($fullScores[$nextIndex], $afterScore); + + $beforeCursor = null; + for ($index = \count($full) - 1; $index >= 0; $index--) { + $score = $this->comboJoinScore($full[$index]); + if ($full[$index]->getId() !== '' && $score !== null) { + $beforeCursor = $full[$index]; + break; + } + } + $this->assertNotNull($beforeCursor); + $this->assertNotSame('', $beforeCursor->getId()); + $beforeCursorScore = $this->comboJoinScore($beforeCursor); + $this->assertNotNull($beforeCursorScore); + + $before = $database->find($mCol, [ + ...$ordered, + Query::cursorBefore($beforeCursor), + Query::limit(1), + ]); + $this->assertSame(1, \count($before)); + $this->assertComboSecretsHidden($before); + $beforeScore = $this->comboJoinScore($before[0]); + $this->assertNotNull($beforeScore); + + $beforeIndex = \array_search($beforeCursorScore, $fullScores, true); + $this->assertNotSame(false, $beforeIndex); + $previousIndex = (int) $beforeIndex - 1; + $this->assertArrayHasKey($previousIndex, $fullScores); + $this->assertSame($fullScores[$previousIndex], $beforeScore); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreAndOrMixMainAndJoinFilters(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $mixed = $database->find($mCol, [ + Query::join($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::and([ + Query::equal('name', ['Main']), + Query::or([ + Query::equal('meta.score', [10]), + Query::equal('rank', [2]), + ]), + ]), + Query::select(['name', 'meta.score']), + ]); + + $this->assertSame(1, \count($mixed)); + $this->assertComboSecretsHidden($mixed); + $this->assertSame('hm1', $mixed[0]->getId()); + $score = $mixed[0]->getAttribute('meta.score') ?? $mixed[0]->getAttribute('score'); + $this->assertTrue(\is_numeric($score)); + $this->assertSame(10, (int) $score); + + $hiddenOnly = $database->find($mCol, [ + Query::join($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::and([ + Query::equal('name', ['Main']), + Query::or([ + Query::equal('meta.score', [8686]), + Query::equal('meta.secret', ['combo-hard-alpha']), + ]), + ]), + ]); + $this->assertSame(0, \count($hiddenOnly)); + $this->assertComboSecretsHidden($hiddenOnly); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreMixedMainJoinOrderCursor(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $ordered = [ + Query::join($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::orderAsc('rank'), + Query::orderDesc('meta.score'), + ]; + + $full = $database->find($mCol, $ordered); + $this->assertSame(4, \count($full)); + $this->assertComboSecretsHidden($full); + $this->assertSame([313, 15, 10, 20], $this->comboNumericScores($full)); + + $first = $database->find($mCol, [ + ...$ordered, + Query::limit(1), + ]); + $this->assertSame(1, \count($first)); + $this->assertComboSecretsHidden($first); + $this->assertSame('hm1', $first[0]->getId()); + $this->assertSame(313, $this->comboNumericScores($first)[0]); + + $next = $database->find($mCol, [ + ...$ordered, + Query::cursorAfter($first[0]), + Query::limit(1), + ]); + $this->assertSame(1, \count($next)); + $this->assertComboSecretsHidden($next); + $this->assertSame($full[1]->getId(), $next[0]->getId()); + $this->assertSame(15, $this->comboNumericScores($next)[0]); + + $before = $database->find($mCol, [ + ...$ordered, + Query::cursorBefore($next[0]), + Query::limit(1), + ]); + $this->assertSame(1, \count($before)); + $this->assertComboSecretsHidden($before); + $this->assertSame($first[0]->getId(), $before[0]->getId()); + $this->assertSame(313, $this->comboNumericScores($before)[0]); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreJoinSideOperatorsAndInternalAttrs(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::containsString('meta.body', ['hard-needle']), + Query::between('meta.score', 1, 50), + Query::startsWith('meta.label', 'visible'), + Query::equal('meta.$id', ['hm-meta-10']), + Query::greaterThan('meta.$createdAt', '2000-01-01 00:00:00.000'), + Query::select(['name', 'meta.$id', 'meta.score', 'meta.label', 'meta.body']), + ]); + + $this->assertSame(1, \count($results)); + $this->assertComboSecretsHidden($results); + $this->assertSame('hm1', $results[0]->getId()); + $this->assertSame('hm-meta-10', $results[0]->getAttribute('meta.$id')); + $score = $results[0]->getAttribute('meta.score') ?? $results[0]->getAttribute('score'); + $this->assertTrue(\is_numeric($score)); + $this->assertSame(10, (int) $score); + + if ( + $database->getAdapter()->supports(Capability::IndexFulltext) + && $this->joinHardcoreHasFulltextIndex($database, $metaCol) + ) { + $searched = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::search('meta.body', 'needle'), + ]); + $this->assertGreaterThanOrEqual(1, \count($searched)); + $this->assertComboSecretsHidden($searched); + $this->assertSame('hm1', $searched[0]->getId()); + } + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreRightUnmatchedMainIdentityAndSelectSubset(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $results = $database->find($mCol, [ + Query::rightJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::select(['name', 'meta.score', 'meta.secret']), + ]); + + $this->assertGreaterThanOrEqual(2, \count($results)); + $this->assertComboSecretsHidden($results); + + $unmatched = null; + $ids = []; + foreach ($results as $document) { + $ids[] = $document->getId(); + $this->assertNotSame('hm-meta-orphan', $document->getId()); + $this->assertNotSame('hm-meta-secret', $document->getId()); + $this->assertNotSame('hm-meta-10', $document->getId()); + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('meta.secret')); + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('secret')); + if ($document->getId() === '') { + $unmatched = $document; + } + } + + $this->assertNotNull($unmatched); + $this->assertSame('', $unmatched->getId()); + $this->assertTrue($unmatched->getAttribute('name') === null || $unmatched->getAttribute('name') === ''); + $orphanScore = $unmatched->getAttribute('meta.score') ?? $unmatched->getAttribute('score'); + $this->assertTrue(\is_numeric($orphanScore)); + $this->assertSame(42, (int) $orphanScore); + $this->assertSame(false, \in_array('hm-meta-orphan', $ids, true)); + $this->assertContains('hm1', $ids); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreCoerceSecret8686AbsentWhenUnauthorized(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $results = $database->find($mCol, [ + Query::join($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::equal('meta.score', [8686]), + Query::select(['name', 'meta.score', 'meta.secret']), + ]); + + $this->assertSame(0, \count($results)); + $this->assertComboSecretsHidden($results); + + $encoded = \json_encode(\array_map(static function (Document $document): array { + /** @var array $copy */ + $copy = $document->getArrayCopy(); + + return $copy; + }, $results)); + $this->assertNotFalse($encoded); + $this->assertSame(false, $this->comboEncodedJsonContainsScalar($encoded, 8686)); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreSharedTablesSecret5151NotTenant(): void + { + $database = static::getDatabase(); + if ( + ! $database->getAdapter()->supports(Capability::Joins) + || ! $database->getAdapter()->supports(Capability::Schemas) + ) { + $this->expectNotToPerformAssertions(); + + return; + } + + $sharedTables = $database->hasSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); + + $sharedTablesDb = 'sharedTablesJh_'.static::getTestToken(); + $mCol = 'jh_m'; + $metaCol = 'jh_meta'; + + try { + if ($database->exists($sharedTablesDb)) { + $database->setDatabase($sharedTablesDb)->delete(); + } + + $database + ->setDatabase($sharedTablesDb) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); + + $any = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $mCol, permissions: $any, documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $metaCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($metaCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($metaCol, Attribute::integer(key: 'score', required: true)); + $database->createAttribute($metaCol, Attribute::string(key: 'secret', size: 100)); + + $database->setTenant(5151); + $database->createDocument($mCol, new Document([ + '$id' => 'hm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-10', + 'mainId' => 'hm1', + 'score' => 10, + 'secret' => 'visible', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-secret', + 'mainId' => 'hm1', + 'score' => 5151, + 'secret' => 'combo-hard-alpha', + '$permissions' => [ + Permission::read(Role::user('combo-hard-hidden')), + ], + ])); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($metaCol, 'sec', [Query::on('$id', 'mainId')]), + Query::select(['name', 'sec.score', 'sec.secret', 'sec.$tenant']), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertComboSecretsHidden($results); + + $payloads = []; + foreach ($results as $document) { + $this->assertSame('hm1', $document->getId()); + $this->assertNotSame('hm-meta-secret', $document->getId()); + $score = $document->getAttribute('sec.score') ?? $document->getAttribute('score'); + if (\is_numeric($score)) { + $payloads[] = (int) $score; + } + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('sec.secret')); + $this->assertNotSame('combo-hard-alpha', $document->getAttribute('secret')); + } + $this->assertContains(10, $payloads); + $this->assertSame(false, \in_array(5151, $payloads, true)); + + $encoded = \json_encode(\array_map(static function (Document $document): array { + /** @var array $copy */ + $copy = $document->getArrayCopy(); + + return $copy; + }, $results)); + $this->assertNotFalse($encoded); + $this->assertSame(false, $this->comboEncodedJsonContainsScalar($encoded, 5151)); + $this->assertSame(false, \str_contains($encoded, 'combo-hard-alpha')); + }); + } finally { + $database->setTenant(null)->setSharedTables(false); + if ($database->exists($sharedTablesDb)) { + $database->delete($sharedTablesDb); + } + $database + ->setSharedTables($sharedTables) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); + } + } + + public function testJoinHardcoreSkipAuthMixedDocSecStillHidesSecrets(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $withoutJoins = $database->find($mCol); + $this->assertSame(3, \count($withoutJoins)); + $this->assertComboSecretsHidden($withoutJoins); + $withoutIds = \array_map(static fn (Document $document): string => $document->getId(), $withoutJoins); + \sort($withoutIds); + $this->assertSame(['hm1', 'hm2', 'hm3'], $withoutIds); + + $joinedQueries = [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::select(['name', 'meta.score', 'meta.secret']), + ]; + $joined = $database->find($mCol, $joinedQueries); + $this->assertSame(4, \count($joined)); + $this->assertComboSecretsHidden($joined); + + $scores = $this->comboNumericScores($joined); + $this->assertContains(10, $scores); + $this->assertContains(313, $scores); + $this->assertContains(20, $scores); + $this->assertContains(15, $scores); + $this->assertSame(false, \in_array(8686, $scores, true)); + $this->assertSame(false, \in_array(42, $scores, true)); + + $this->assertSame(\count($joined), $database->count($mCol, $joinedQueries)); + + $sum = $database->sum($mCol, 'meta.score', [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(358, (int) $sum); + + $document = $database->getDocument($mCol, 'hm1', $joinedQueries); + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('hm1', $document->getId()); + $this->assertComboSecretsHidden([$document]); + $documentScore = $document->getAttribute('meta.score') ?? $document->getAttribute('score'); + if (\is_numeric($documentScore)) { + $this->assertContains((int) $documentScore, [10, 313]); + $this->assertNotSame(8686, (int) $documentScore); + } + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreNestedAndOrTwoAliasesIndependent(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, , $peerCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $peerCol): void { + $results = $database->find($mCol, [ + Query::join($peerCol, 'alpha', [Query::on('$id', 'mainId')]), + Query::join($peerCol, 'beta', [Query::on('peerKey', '$id')]), + Query::and([ + Query::equal('alpha.label', ['alpha-one']), + Query::or([ + Query::equal('beta.label', ['beta-key']), + Query::equal('alpha.score', [8686]), + ]), + ]), + Query::select(['name', 'alpha.$id', 'beta.$id', 'alpha.label', 'beta.label']), + ]); + + $this->assertSame(1, \count($results)); + $this->assertComboSecretsHidden($results); + $this->assertSame('hm1', $results[0]->getId()); + $this->assertSame('peer-a', $results[0]->getAttribute('alpha.$id')); + $this->assertSame('peer-b', $results[0]->getAttribute('beta.$id')); + $this->assertSame('alpha-one', $results[0]->getAttribute('alpha.label')); + $this->assertSame('beta-key', $results[0]->getAttribute('beta.label')); + $this->assertNotSame('peer-a', $results[0]->getId()); + $this->assertNotSame('peer-b', $results[0]->getId()); + $this->assertNotSame('peer-hidden', $results[0]->getId()); + + $hiddenOnly = $database->find($mCol, [ + Query::join($peerCol, 'alpha', [Query::on('$id', 'mainId')]), + Query::join($peerCol, 'beta', [Query::on('peerKey', '$id')]), + Query::or([ + Query::equal('alpha.score', [8686]), + Query::equal('beta.secret', ['combo-hard-alpha']), + ]), + ]); + $this->assertSame(0, \count($hiddenOnly)); + $this->assertComboSecretsHidden($hiddenOnly); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreLeftOnVsInnerWhereVsFojNull(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $left = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + ]); + $this->assertGreaterThanOrEqual(3, \count($left)); + $this->assertComboSecretsHidden($left); + $leftIds = \array_map(static fn (Document $document): string => $document->getId(), $left); + $this->assertContains('hm1', $leftIds); + $this->assertContains('hm2', $leftIds); + $this->assertContains('hm3', $leftIds); + $leftScores = $this->comboNumericScores($left); + $this->assertContains(10, $leftScores); + $this->assertContains(313, $leftScores); + $this->assertSame(false, \in_array(8686, $leftScores, true)); + $this->assertSame(false, \in_array(42, $leftScores, true)); + + $innerHidden = $database->find($mCol, [ + Query::join($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::equal('meta.score', [8686]), + ]); + $this->assertSame(0, \count($innerHidden)); + $this->assertComboSecretsHidden($innerHidden); + + $foj = $database->find($mCol, [ + Query::fullOuterJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + ]); + $this->assertGreaterThanOrEqual(2, \count($foj)); + $this->assertComboSecretsHidden($foj); + $fojScores = $this->comboNumericScores($foj); + $this->assertContains(42, $fojScores); + $this->assertSame(false, \in_array(8686, $fojScores, true)); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreFojPlusSecondAliasCursorRemap(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol, $peerCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol, $peerCol): void { + $ordered = [ + Query::fullOuterJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::join($peerCol, 'peer', [Query::on('$id', 'mainId')]), + Query::orderAsc('meta.score'), + Query::select(['name', 'meta.$id', 'meta.score', 'peer.$id', 'peer.label']), + ]; + + $full = $database->find($mCol, $ordered); + $this->assertGreaterThanOrEqual(2, \count($full)); + $this->assertComboSecretsHidden($full); + + $fullScores = $this->comboNumericScores($full); + $this->assertSame(false, \in_array(8686, $fullScores, true)); + $this->assertSame($fullScores, $this->sortedAsc($fullScores)); + + foreach ($full as $document) { + $id = $document->getId(); + $this->assertTrue($id === '' || \in_array($id, ['hm1', 'hm2', 'hm3'], true)); + } + + $cursor = null; + $cursorIndex = null; + foreach ($full as $index => $document) { + $score = $this->comboJoinScore($document); + if ($document->getId() !== '' && $score !== null) { + $cursor = $document; + $cursorIndex = $index; + break; + } + } + $this->assertNotNull($cursor); + $this->assertNotSame('', $cursor->getId()); + $this->assertNotNull($cursorIndex); + $cursorScore = $this->comboJoinScore($cursor); + $this->assertNotNull($cursorScore); + + $after = $database->find($mCol, [ + ...$ordered, + Query::cursorAfter($cursor), + Query::limit(1), + ]); + $this->assertSame(1, \count($after)); + $this->assertComboSecretsHidden($after); + $afterScore = $this->comboJoinScore($after[0]); + $this->assertNotNull($afterScore); + $this->assertTrue($after[0]->getId() === '' || \in_array($after[0]->getId(), ['hm1', 'hm2', 'hm3'], true)); + + $next = $full[(int) $cursorIndex + 1] ?? null; + $this->assertNotNull($next); + $this->assertSame($this->comboJoinScore($next), $afterScore); + $this->assertSame($next->getId(), $after[0]->getId()); + $this->assertSame($next->getAttribute('meta.$id'), $after[0]->getAttribute('meta.$id')); + $this->assertSame($next->getAttribute('peer.$id'), $after[0]->getAttribute('peer.$id')); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreGetDocumentSelectDottedJoinInternals(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $queries = [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::select(['name', 'meta.score', 'meta.$id', 'meta.$permissions']), + ]; + + $hm1 = $database->getDocument($mCol, 'hm1', $queries); + $this->assertSame(false, $hm1->isEmpty()); + $this->assertSame('hm1', $hm1->getId()); + $this->assertContains($hm1->getAttribute('meta.$id'), ['hm-meta-10', 'hm-meta-313']); + $this->assertNotSame($hm1->getId(), $hm1->getAttribute('meta.$id')); + + $hm2 = $database->getDocument($mCol, 'hm2', $queries); + $this->assertSame(false, $hm2->isEmpty()); + $this->assertSame('hm2', $hm2->getId()); + $this->assertSame('hm-meta-20', $hm2->getAttribute('meta.$id')); + $this->assertNotSame($hm2->getId(), $hm2->getAttribute('meta.$id')); + + foreach ([$hm1, $hm2] as $document) { + $score = $document->getAttribute('meta.score') ?? $document->getAttribute('score'); + if (\is_numeric($score)) { + $this->assertNotSame(8686, (int) $score); + } + $permissions = $document->getAttribute('meta.$permissions'); + if (\is_string($permissions)) { + $permissions = \json_decode($permissions, true); + } + if (\is_array($permissions)) { + foreach ($permissions as $permission) { + if (\is_string($permission)) { + $this->assertSame(false, \str_contains($permission, 'user:combo-hard-hidden')); + } + } + } + } + + $this->assertComboSecretsHidden([$hm1, $hm2]); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreCountSumFojExcludesSecret(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $foj = [ + Query::fullOuterJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + ]; + $found = $database->find($mCol, $foj); + $this->assertGreaterThanOrEqual(2, \count($found)); + $this->assertComboSecretsHidden($found); + $this->assertSame(\count($found), $database->count($mCol, $foj)); + $this->assertSame(false, \in_array(8686, $this->comboNumericScores($found), true)); + $this->assertContains(42, $this->comboNumericScores($found)); + + $this->assertSame(0, $database->count($mCol, [ + Query::fullOuterJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::equal('meta.score', [8686]), + ])); + + $sum = $database->sum($mCol, 'meta.score', $foj); + $this->assertSame(400, (int) $sum); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreIsNotNullNotEqualSecretDoesNotLeak(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol): void { + $notNull = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::isNotNull('meta.secret'), + Query::select(['name', 'meta.score', 'meta.secret']), + ]); + $this->assertGreaterThanOrEqual(1, \count($notNull)); + $this->assertComboSecretsHidden($notNull); + + $secrets = []; + foreach ($notNull as $document) { + $secret = $document->getAttribute('meta.secret') ?? $document->getAttribute('secret'); + if (\is_string($secret) && $secret !== '') { + $secrets[] = $secret; + } + $score = $document->getAttribute('meta.score') ?? $document->getAttribute('score'); + if (\is_numeric($score)) { + $this->assertNotSame(8686, (int) $score); + } + } + $this->assertContains('visible', $secrets); + $this->assertContains('visible-313', $secrets); + $this->assertContains('visible-20', $secrets); + $this->assertContains('visible-15', $secrets); + $this->assertSame(false, \in_array('combo-hard-alpha', $secrets, true)); + + $notEqual = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::notEqual('meta.score', 8686), + Query::select(['name', 'meta.score', 'meta.secret']), + ]); + $this->assertGreaterThanOrEqual(1, \count($notEqual)); + $this->assertComboSecretsHidden($notEqual); + $this->assertSame(false, \in_array(8686, $this->comboNumericScores($notEqual), true)); + + $notContains = $database->find($mCol, [ + Query::leftJoin($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::notContains('meta.secret', ['combo-hard-alpha']), + Query::select(['name', 'meta.score', 'meta.secret']), + ]); + $this->assertGreaterThanOrEqual(1, \count($notContains)); + $this->assertComboSecretsHidden($notContains); + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + public function testJoinHardcoreStaleJoinAliasRejectedOnSecondFind(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$mCol, $metaCol, $peerCol] = $this->seedJoinHardcoreFixture($database); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $metaCol, $peerCol): void { + $first = $database->find($mCol, [ + Query::join($metaCol, 'meta', [Query::on('$id', 'mainId')]), + Query::equal('meta.score', [10]), + ]); + $this->assertSame(1, \count($first)); + $this->assertComboSecretsHidden($first); + $this->assertSame('hm1', $first[0]->getId()); + $this->assertSame(false, \in_array(8686, $this->comboNumericScores($first), true)); + + try { + $database->find($mCol, [ + Query::join($peerCol, 'peer', [Query::on('$id', 'mainId')]), + Query::equal('meta.score', [8686]), + ]); + $this->fail('Expected QueryException for stale join alias'); + } catch (QueryException $exception) { + $this->assertStringContainsString('Attribute not found', $exception->getMessage()); + } + }); + + $this->cleanupAggCollections($database, $this->joinHardcoreCollections()); + } + + /** + * @return list + */ + private function joinComboCollections(): array + { + return ['jc_m', 'jc_pub', 'jc_sec', 'jc_self', 'jc_c']; + } + + /** + * @return array{0: string, 1: string, 2: string, 3: string, 4: string} + */ + private function seedJoinComboFixture(Database $database): array + { + $mCol = 'jc_m'; + $pubCol = 'jc_pub'; + $secCol = 'jc_sec'; + $selfCol = 'jc_self'; + $cCol = 'jc_c'; + $this->cleanupAggCollections($database, [$mCol, $pubCol, $secCol, $selfCol, $cCol]); + + $any = [Permission::create(Role::any()), Permission::read(Role::any())]; + $documentLevel = [Permission::create(Role::any())]; + + $database->createCollection(Collection::create(id: $mCol, permissions: $any, documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $pubCol, permissions: $any)); + $database->createAttribute($pubCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($pubCol, Attribute::integer(key: 'score', required: true)); + + $database->createCollection(Collection::create(id: $secCol, permissions: $documentLevel)); + $database->createAttribute($secCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($secCol, Attribute::integer(key: 'score', required: true)); + $database->createAttribute($secCol, Attribute::string(key: 'secret', size: 100)); + + $database->createCollection(Collection::create(id: $selfCol, permissions: $documentLevel)); + $database->createAttribute($selfCol, Attribute::string(key: 'payload', size: 100, required: true)); + $database->createAttribute($selfCol, Attribute::string(key: 'tag', size: 50, required: true)); + $database->createAttribute($selfCol, Attribute::string(key: 'mainId')); + + $database->createCollection(Collection::create(id: $cCol, permissions: $documentLevel)); + $database->createAttribute($cCol, Attribute::string(key: 'selfId', required: true)); + $database->createAttribute($cCol, Attribute::string(key: 'secret', size: 100, required: true)); + + $readAny = [Permission::read(Role::any())]; + $hidden = [ + Permission::read(Role::user('combo-hidden')), + Permission::update(Role::user('combo-secret-perm')), + ]; + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => $readAny, + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'Unmatched', + '$permissions' => $readAny, + ])); + + $database->createDocument($pubCol, new Document([ + '$id' => 'j-pub-auth', + 'mainId' => 'm1', + 'score' => 313, + '$permissions' => $readAny, + ])); + $database->createDocument($pubCol, new Document([ + '$id' => 'j-pub-sib', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => $readAny, + ])); + $database->createDocument($pubCol, new Document([ + '$id' => 'j-pub-orphan', + 'mainId' => 'missing', + 'score' => 4242, + '$permissions' => $readAny, + ])); + + $database->createDocument($secCol, new Document([ + '$id' => 'j-combo-auth', + 'mainId' => 'm1', + 'score' => 313, + 'secret' => 'visible', + '$permissions' => $readAny, + ])); + $database->createDocument($secCol, new Document([ + '$id' => 'j-combo-ten', + 'mainId' => 'm1', + 'score' => 10, + 'secret' => 'visible-ten', + '$permissions' => $readAny, + ])); + $database->createDocument($secCol, new Document([ + '$id' => 'j-combo-secret', + 'mainId' => 'm1', + 'score' => 777, + 'secret' => 'combo-secret-alpha', + '$permissions' => $hidden, + ])); + $database->createDocument($secCol, new Document([ + '$id' => 'j-combo-m2', + 'mainId' => 'm2', + 'score' => 313, + 'secret' => 'visible-m2', + '$permissions' => $readAny, + ])); + + $database->createDocument($selfCol, new Document([ + '$id' => 'open', + 'payload' => 'open-payload', + 'tag' => 'shared', + 'mainId' => 'm1', + '$permissions' => $readAny, + ])); + $database->createDocument($selfCol, new Document([ + '$id' => 'hidden-self', + 'payload' => 'combo-secret-alpha', + 'tag' => 'shared', + 'mainId' => 'm1', + '$permissions' => $hidden, + ])); + + $database->createDocument($cCol, new Document([ + '$id' => 'c-open', + 'selfId' => 'open', + 'secret' => 'c-open-token', + '$permissions' => $readAny, + ])); + $database->createDocument($cCol, new Document([ + '$id' => 'c-combo-secret', + 'selfId' => 'open', + 'secret' => 'c-combo-secret', + '$permissions' => $hidden, + ])); + + return [$mCol, $pubCol, $secCol, $selfCol, $cCol]; + } + + /** + * @param list $roles + * @param callable(): void $callback + */ + private function withComboRoles(Database $database, array $roles, callable $callback): void + { + $authorization = $database->getAuthorization(); + $previousRoles = $authorization->getRoles(); + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + try { + $callback(); + } finally { + $authorization->cleanRoles(); + foreach ($previousRoles as $role) { + $authorization->addRole($role); + } + } + } + + /** + * @param array $documents + */ + private function assertComboSecretsHidden(array $documents): void + { + $payload = []; + foreach ($documents as $document) { + $this->assertComboSecretHidden($document); + $payload[] = $document->getArrayCopy(); + } + + $this->assertEncodedComboSecretHidden(\json_encode($payload)); + } + + private function assertComboSecretHidden(Document $document): void + { + $this->assertNotSame('j-combo-secret', $document->getId()); + $this->assertNotSame('c-combo-secret', $document->getId()); + $this->assertNotSame('hm-meta-secret', $document->getId()); + $this->assertNotSame('peer-hidden', $document->getId()); + $this->assertNotSame('hc-hidden', $document->getId()); + + foreach (['score', 'meta.score', 'sec.score', 'pub.score', 'rev.score', 'c.score', 'tail.score', 'alpha.score', 'beta.score', 'peer.score'] as $scoreKey) { + $score = $document->getAttribute($scoreKey); + if (\is_numeric($score)) { + $this->assertNotSame(777, (int) $score); + $this->assertNotSame(8686, (int) $score); + $this->assertNotSame(5151, (int) $score); + } + } + + foreach (['secret', 'payload', 'meta.secret', 'sec.secret', 'c.secret', 'tail.secret', 'alpha.secret', 'beta.secret', 'peer.secret'] as $secretKey) { + $this->assertNotSame('combo-secret-alpha', $document->getAttribute($secretKey)); + $this->assertNotSame('combo-hard-alpha', $document->getAttribute($secretKey)); + } + + foreach ($document->getPermissions() as $permission) { + $this->assertSame(false, \str_contains($permission, 'j-combo-secret')); + $this->assertSame(false, \str_contains($permission, 'c-combo-secret')); + $this->assertSame(false, \str_contains($permission, 'user:combo-hidden')); + $this->assertSame(false, \str_contains($permission, 'combo-secret-perm')); + $this->assertSame(false, \str_contains($permission, 'user:combo-hard-hidden')); + $this->assertSame(false, \str_contains($permission, 'combo-hard-alpha')); + } + + $this->assertEncodedComboSecretHidden(\json_encode($document)); + } + + private function assertEncodedComboSecretHidden(string|false $encoded): void + { + $this->assertNotFalse($encoded); + $this->assertSame(false, \str_contains($encoded, 'j-combo-secret')); + $this->assertSame(false, \str_contains($encoded, 'c-combo-secret')); + $this->assertSame(false, \str_contains($encoded, 'user:combo-hidden')); + $this->assertSame(false, \str_contains($encoded, 'combo-secret-perm')); + $this->assertSame(false, \str_contains($encoded, 'combo-secret-alpha')); + $this->assertSame(false, \str_contains($encoded, 'combo-hard-alpha')); + $this->assertSame(false, \str_contains($encoded, 'user:combo-hard-hidden')); + $this->assertSame(false, $this->comboEncodedJsonContainsScalar($encoded, 777)); + $this->assertSame(false, $this->comboEncodedJsonContainsScalar($encoded, 8686)); + $this->assertSame(false, $this->comboEncodedJsonContainsScalar($encoded, 5151)); + } + + private function comboEncodedJsonContainsScalar(string $encoded, int $needle): bool + { + $decoded = \json_decode($encoded, true); + if (! \is_array($decoded)) { + return false; + } + + return $this->comboJsonContainsScalar($decoded, $needle); + } + + private function comboJsonContainsScalar(mixed $value, int $needle, string|int|null $key = null): bool + { + if (\is_int($value) || \is_float($value) || (\is_string($value) && \is_numeric($value))) { + if ($this->isIgnoredJoinComboSecretKey($key)) { + return false; + } + + return (int) $value === $needle; + } + + if (! \is_array($value)) { + return false; + } + + foreach ($value as $childKey => $child) { + if ($this->comboJsonContainsScalar($child, $needle, $childKey)) { + return true; + } + } + + return false; + } + + private function isIgnoredJoinComboSecretKey(string|int|null $key): bool + { + $name = \is_string($key) && \str_contains($key, '.') + ? \substr($key, (int) \strrpos($key, '.') + 1) + : $key; + + return \in_array($name, [ + Document::SEQUENCE, + Document::CREATED_AT, + Document::UPDATED_AT, + Document::TENANT, + Document::COLLECTION, + Document::DISTANCE, + Document::DELETED_AT, + ], true); + } + + /** + * @param array $documents + * @return list + */ + private function comboNumericScores(array $documents): array + { + $scores = []; + foreach ($documents as $document) { + $score = $this->comboJoinScore($document); + if ($score !== null) { + $scores[] = $score; + } + } + + return $scores; + } + + /** + * @param list $scores + * @return list + */ + private function sortedDesc(array $scores): array + { + $sorted = $scores; + \rsort($sorted, SORT_NUMERIC); + + return $sorted; + } + + /** + * @param list $scores + * @return list + */ + private function sortedAsc(array $scores): array + { + $sorted = $scores; + \sort($sorted, SORT_NUMERIC); + + return $sorted; + } + + private function comboJoinScore(Document $document): ?int + { + $score = $document->getAttribute('pub.score') + ?? $document->getAttribute('sec.score') + ?? $document->getAttribute('rev.score') + ?? $document->getAttribute('meta.score') + ?? $document->getAttribute('peer.score') + ?? $document->getAttribute('tail.score') + ?? $document->getAttribute('alpha.score') + ?? $document->getAttribute('beta.score') + ?? $document->getAttribute('score'); + if (! \is_numeric($score)) { + return null; + } + + return (int) $score; + } + + /** + * @return list + */ + private function joinHardcoreCollections(): array + { + return ['jh_m', 'jh_meta', 'jh_peer', 'jh_a', 'jh_b', 'jh_c']; + } + + /** + * @return array{0: string, 1: string, 2: string, 3: string, 4: string, 5: string} + */ + private function seedJoinHardcoreFixture(Database $database): array + { + $mCol = 'jh_m'; + $metaCol = 'jh_meta'; + $peerCol = 'jh_peer'; + $aCol = 'jh_a'; + $bCol = 'jh_b'; + $cCol = 'jh_c'; + $this->cleanupAggCollections($database, [$mCol, $metaCol, $peerCol, $aCol, $bCol, $cCol]); + + $any = [Permission::create(Role::any()), Permission::read(Role::any())]; + $documentLevel = [Permission::create(Role::any())]; + $readAny = [Permission::read(Role::any())]; + $hidden = [Permission::read(Role::user('combo-hard-hidden'))]; + + $database->createCollection(Collection::create(id: $mCol, permissions: $any, documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createAttribute($mCol, Attribute::integer(key: 'rank', required: true)); + $database->createAttribute($mCol, Attribute::string(key: 'peerKey')); + + $database->createCollection(Collection::create(id: $metaCol, permissions: $documentLevel)); + $database->createAttribute($metaCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($metaCol, Attribute::integer(key: 'score', required: true)); + $database->createAttribute($metaCol, Attribute::string(key: 'secret', size: 100)); + $database->createAttribute($metaCol, Attribute::string(key: 'label', size: 100)); + $database->createAttribute($metaCol, Attribute::string(key: 'body')); + + $database->createCollection(Collection::create(id: $peerCol, permissions: $documentLevel)); + $database->createAttribute($peerCol, Attribute::string(key: 'mainId')); + $database->createAttribute($peerCol, Attribute::string(key: 'label', size: 100, required: true)); + $database->createAttribute($peerCol, Attribute::integer(key: 'score', required: true)); + $database->createAttribute($peerCol, Attribute::string(key: 'secret', size: 100)); + + $database->createCollection(Collection::create(id: $aCol, permissions: $any)); + $database->createAttribute($aCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $bCol, permissions: $any, documentSecurity: false)); + $database->createAttribute($bCol, Attribute::string(key: 'aId', required: true)); + $database->createAttribute($bCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($bCol, Attribute::string(key: 'label', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $cCol, permissions: $documentLevel)); + $database->createAttribute($cCol, Attribute::string(key: 'bId', required: true)); + $database->createAttribute($cCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($cCol, Attribute::string(key: 'secret', size: 100, required: true)); + $database->createAttribute($cCol, Attribute::integer(key: 'score', required: true)); + + if ($database->getAdapter()->supports(Capability::IndexFulltext)) { + $database->createIndex($metaCol, Index::fulltext(key: 'idx_jh_meta_body', attributes: ['body'])); + } + + $database->createDocument($mCol, new Document([ + '$id' => 'hm1', + 'name' => 'Main', + 'rank' => 1, + 'peerKey' => 'peer-b', + '$permissions' => $readAny, + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'hm2', + 'name' => 'Second', + 'rank' => 2, + 'peerKey' => 'peer-missing', + '$permissions' => $readAny, + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'hm3', + 'name' => 'Third', + 'rank' => 1, + 'peerKey' => 'peer-a', + '$permissions' => $readAny, + ])); + + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-10', + 'mainId' => 'hm1', + 'score' => 10, + 'secret' => 'visible', + 'label' => 'visible-ten', + 'body' => 'hard-needle visible', + '$permissions' => $readAny, + ])); + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-313', + 'mainId' => 'hm1', + 'score' => 313, + 'secret' => 'visible-313', + 'label' => 'visible-high', + 'body' => 'other text', + '$permissions' => $readAny, + ])); + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-20', + 'mainId' => 'hm2', + 'score' => 20, + 'secret' => 'visible-20', + 'label' => 'visible-m2', + 'body' => 'm2 text', + '$permissions' => $readAny, + ])); + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-15', + 'mainId' => 'hm3', + 'score' => 15, + 'secret' => 'visible-15', + 'label' => 'visible-third', + 'body' => 'third text', + '$permissions' => $readAny, + ])); + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-secret', + 'mainId' => 'hm1', + 'score' => 8686, + 'secret' => 'combo-hard-alpha', + 'label' => 'hidden-label', + 'body' => 'hidden-search', + '$permissions' => $hidden, + ])); + $database->createDocument($metaCol, new Document([ + '$id' => 'hm-meta-orphan', + 'mainId' => 'missing', + 'score' => 42, + 'secret' => 'orphan-visible', + 'label' => 'orphan', + 'body' => 'orphan text', + '$permissions' => $readAny, + ])); + + $database->createDocument($peerCol, new Document([ + '$id' => 'peer-a', + 'mainId' => 'hm1', + 'label' => 'alpha-one', + 'score' => 11, + '$permissions' => $readAny, + ])); + $database->createDocument($peerCol, new Document([ + '$id' => 'peer-b', + 'mainId' => 'hm2', + 'label' => 'beta-key', + 'score' => 22, + '$permissions' => $readAny, + ])); + $database->createDocument($peerCol, new Document([ + '$id' => 'peer-c', + 'mainId' => 'hm1', + 'label' => 'alpha-two', + 'score' => 33, + '$permissions' => $readAny, + ])); + $database->createDocument($peerCol, new Document([ + '$id' => 'peer-hidden', + 'mainId' => 'hm1', + 'label' => 'combo-hard-alpha', + 'score' => 8686, + 'secret' => 'combo-hard-alpha', + '$permissions' => $hidden, + ])); + + $database->createDocument($aCol, new Document([ + '$id' => 'ha1', + 'name' => 'Alpha', + '$permissions' => $readAny, + ])); + + $database->createDocument($bCol, new Document([ + '$id' => 'hb1', + 'aId' => 'ha1', + 'mainId' => 'hm1', + 'label' => 'b-public', + '$permissions' => $readAny, + ])); + $database->createDocument($bCol, new Document([ + '$id' => 'hb2', + 'aId' => 'ha1', + 'mainId' => 'hm2', + 'label' => 'b-second', + '$permissions' => $readAny, + ])); + + $database->createDocument($cCol, new Document([ + '$id' => 'hc-open', + 'bId' => 'hb1', + 'mainId' => 'hm1', + 'secret' => 'c-open-token', + 'score' => 1, + '$permissions' => $readAny, + ])); + $database->createDocument($cCol, new Document([ + '$id' => 'hc-hidden', + 'bId' => 'hb1', + 'mainId' => 'hm1', + 'secret' => 'combo-hard-alpha', + 'score' => 8686, + '$permissions' => $hidden, + ])); + $database->createDocument($cCol, new Document([ + '$id' => 'hc-right', + 'bId' => 'missing', + 'mainId' => 'missing', + 'secret' => 'c-right-open', + 'score' => 7, + '$permissions' => $readAny, + ])); + + return [$mCol, $metaCol, $peerCol, $aCol, $bCol, $cCol]; + } + + private function joinHardcoreHasFulltextIndex(Database $database, string $collection): bool + { + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { + return false; + } + + foreach ($database->getCollection($collection)->indexes() as $index) { + if ($index->type === IndexType::Fulltext) { + return true; + } + } + + return false; + } +} diff --git a/tests/e2e/Adapter/Scopes/JoinTests.php b/tests/e2e/Adapter/Scopes/JoinTests.php new file mode 100644 index 0000000000..4e9233a58a --- /dev/null +++ b/tests/e2e/Adapter/Scopes/JoinTests.php @@ -0,0 +1,9076 @@ +getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljnm_p'; + $rCol = 'ljnm_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + foreach (['Alpha', 'Beta', 'Gamma'] as $name) { + $database->createDocument($pCol, new Document([ + '$id' => strtolower($name), + 'name' => $name, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($pCol, [ + Query::leftJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + ]); + + $this->assertCount(3, $results); + foreach ($results as $doc) { + $this->assertEquals(1, $doc['cnt']); + } + + $this->cleanupAggCollections($database, $cols); + } + + public function testLeftJoinPartialMatches(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljpm_p'; + $rCol = 'ljpm_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + foreach (['p1', 'p2', 'p3'] as $id) { + $database->createDocument($pCol, new Document([ + '$id' => $id, + 'name' => 'Product ' . $id, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $reviews = [ + ['prod_uid' => 'p1', 'score' => 5], + ['prod_uid' => 'p1', 'score' => 3], + ['prod_uid' => 'p1', 'score' => 4], + ['prod_uid' => 'p2', 'score' => 2], + ['prod_uid' => 'p2', 'score' => 4], + ]; + foreach ($reviews as $r) { + $database->createDocument($rCol, new Document(array_merge($r, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($pCol, [ + Query::leftJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::count('*', 'cnt'), + Query::avg('score', 'avg_score'), + Query::groupBy(['name']), + ]); + + $this->assertCount(3, $results); + $mapped = []; + foreach ($results as $doc) { + $name = $doc['name']; + $this->assertIsString($name); + $mapped[$name] = $doc; + } + $this->assertEquals(3, $mapped['Product p1']['cnt']); + $this->assertEqualsWithDelta(4.0, $this->numericAttribute($mapped['Product p1'], 'avg_score'), 0.1); + $this->assertEquals(2, $mapped['Product p2']['cnt']); + $this->assertEqualsWithDelta(3.0, $this->numericAttribute($mapped['Product p2'], 'avg_score'), 0.1); + $this->assertEquals(1, $mapped['Product p3']['cnt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinMultipleAggregationAliases(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jma_o'; + $cCol = 'jma_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + foreach ([100, 200, 300, 400, 500] as $amt) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', 'amount' => $amt, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'order_count'), + Query::sum('amount', 'total_amount'), + Query::avg('amount', 'avg_amount'), + Query::min('amount', 'min_amount'), + Query::max('amount', 'max_amount'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(5, $results[0]['order_count']); + $this->assertEquals(1500, $results[0]['total_amount']); + $this->assertEqualsWithDelta(300.0, $this->numericAttribute($results[0], 'avg_amount'), 0.1); + $this->assertEquals(100, $results[0]['min_amount']); + $this->assertEquals(500, $results[0]['max_amount']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinMultipleGroupByColumns(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jmg_o'; + $cCol = 'jmg_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 100], + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 200], + ['cust_uid' => 'c1', 'status' => 'pending', 'amount' => 50], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 300], + ['cust_uid' => 'c2', 'status' => 'pending', 'amount' => 75], + ['cust_uid' => 'c2', 'status' => 'pending', 'amount' => 25], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid', 'status']), + ]); + + $this->assertCount(4, $results); + $mapped = []; + foreach ($results as $doc) { + $custUid = $doc['cust_uid']; + $status = $doc['status']; + $this->assertIsString($custUid); + $this->assertIsString($status); + $key = $custUid . '_' . $status; + $mapped[$key] = $doc; + } + $this->assertEquals(2, $mapped['c1_done']['cnt']); + $this->assertEquals(300, $mapped['c1_done']['total']); + $this->assertEquals(1, $mapped['c1_pending']['cnt']); + $this->assertEquals(50, $mapped['c1_pending']['total']); + $this->assertEquals(1, $mapped['c2_done']['cnt']); + $this->assertEquals(300, $mapped['c2_done']['total']); + $this->assertEquals(2, $mapped['c2_pending']['cnt']); + $this->assertEquals(100, $mapped['c2_pending']['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithHavingOnCount(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhc_o'; + $cCol = 'jhc_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 20], + ['cust_uid' => 'c2', 'amount' => 30], + ['cust_uid' => 'c3', 'amount' => 40], + ['cust_uid' => 'c3', 'amount' => 50], + ['cust_uid' => 'c3', 'amount' => 60], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::groupBy(['cust_uid']), + Query::having([Query::greaterThan('cnt', 1)]), + ]); + + $this->assertCount(2, $results); + $ids = array_map(fn ($d) => $d['cust_uid'], $results); + $this->assertContains('c2', $ids); + $this->assertContains('c3', $ids); + $this->assertNotContains('c1', $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithHavingOnAvg(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jha_o'; + $cCol = 'jha_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c1', 'amount' => 20], + ['cust_uid' => 'c2', 'amount' => 500], + ['cust_uid' => 'c2', 'amount' => 600], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::avg('amount', 'avg_amt'), + Query::groupBy(['cust_uid']), + Query::having([Query::greaterThan('avg_amt', 100)]), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('c2', $results[0]['cust_uid']); + $avgAmt = $results[0]['avg_amt']; + $this->assertIsNumeric($avgAmt); + $this->assertEqualsWithDelta(550.0, (float) $avgAmt, 0.1); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithHavingOnSum(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhs_o'; + $cCol = 'jhs_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 50], + ['cust_uid' => 'c2', 'amount' => 300], + ['cust_uid' => 'c2', 'amount' => 400], + ['cust_uid' => 'c3', 'amount' => 100], + ['cust_uid' => 'c3', 'amount' => 100], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::having([Query::greaterThan('total', 250)]), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('c2', $results[0]['cust_uid']); + $this->assertEquals(700, $results[0]['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithHavingBetween(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhb_o'; + $cCol = 'jhb_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 100], + ['cust_uid' => 'c2', 'amount' => 200], + ['cust_uid' => 'c3', 'amount' => 500], + ['cust_uid' => 'c3', 'amount' => 600], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::having([Query::between('total', 100, 500)]), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('c2', $results[0]['cust_uid']); + $this->assertEquals(300, $results[0]['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinCountDistinct(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jcd_o'; + $cCol = 'jcd_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'product', size: 50, required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'product' => 'A'], + ['cust_uid' => 'c1', 'product' => 'A'], + ['cust_uid' => 'c1', 'product' => 'B'], + ['cust_uid' => 'c2', 'product' => 'C'], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::countDistinct('product', 'uniq_prod'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]['uniq_prod']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinMinMax(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jmm_o'; + $cCol = 'jmm_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c1', 'amount' => 50], + ['cust_uid' => 'c1', 'amount' => 30], + ['cust_uid' => 'c2', 'amount' => 200], + ['cust_uid' => 'c2', 'amount' => 100], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::min('amount', 'min_amt'), + Query::max('amount', 'max_amt'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc['cust_uid']; + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(10, $mapped['c1']['min_amt']); + $this->assertEquals(50, $mapped['c1']['max_amt']); + $this->assertEquals(100, $mapped['c2']['min_amt']); + $this->assertEquals(200, $mapped['c2']['max_amt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinFilterOnMainTable(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jfm_o'; + $cCol = 'jfm_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 100], + ['cust_uid' => 'c1', 'status' => 'open', 'amount' => 200], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 300], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 400], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('status', ['done']), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc['cust_uid']; + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(1, $mapped['c1']['cnt']); + $this->assertEquals(100, $mapped['c1']['total']); + $this->assertEquals(2, $mapped['c2']['cnt']); + $this->assertEquals(700, $mapped['c2']['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinBetweenFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jbf_o'; + $cCol = 'jbf_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + foreach ([50, 150, 250, 350, 450] as $amt) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', 'amount' => $amt, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::between('amount', 100, 300), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]['cnt']); + $this->assertEquals(400, $results[0]['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinGreaterLessThanFilters(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jgl_o'; + $cCol = 'jgl_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + foreach ([10, 20, 30, 40, 50] as $amt) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', 'amount' => $amt, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::greaterThan('amount', 15), + Query::lessThanEqual('amount', 40), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]['cnt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinEmptyResultSet(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jer_o'; + $cCol = 'jer_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'nonexistent', 'amount' => 100, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]['cnt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinFilterYieldsNoResults(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jfnr_o'; + $cCol = 'jfnr_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', 'status' => 'done', 'amount' => 100, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('status', ['ghost']), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]['cnt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testLeftJoinSumNullRightSide(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljsn_p'; + $oCol = 'ljsn_o'; + $cols = [$pCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', 'name' => 'WithOrders', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', 'name' => 'NoOrders', + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->createDocument($oCol, new Document([ + 'prod_uid' => 'p1', 'amount' => 100, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'prod_uid' => 'p1', 'amount' => 200, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->aggregate($pCol, [ + Query::leftJoin($oCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::sum('amount', 'total'), + Query::groupBy(['name']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $name = $doc['name']; + $this->assertIsString($name); + $mapped[$name] = $doc; + } + $this->assertEquals(300, $mapped['WithOrders']['total']); + $noOrderTotal = $mapped['NoOrders']['total']; + $this->assertTrue($noOrderTotal === null || $noOrderTotal === 0 || $noOrderTotal === 0.0); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinMultipleFilterTypes(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jmft_o'; + $cCol = 'jmft_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 500], + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 600], + ['cust_uid' => 'c1', 'status' => 'open', 'amount' => 100], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 50], + ['cust_uid' => 'c3', 'status' => 'done', 'amount' => 800], + ['cust_uid' => 'c3', 'status' => 'done', 'amount' => 900], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('status', ['done']), + Query::greaterThan('amount', 100), + Query::sum('amount', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['cust_uid']), + Query::having([Query::greaterThan('total', 500)]), + ]); + + $this->assertCount(2, $results); + $ids = array_map(fn ($d) => $d['cust_uid'], $results); + $this->assertContains('c1', $ids); + $this->assertContains('c3', $ids); + $this->assertNotContains('c2', $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinLargeDataset(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jld_o'; + $cCol = 'jld_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + for ($i = 1; $i <= 10; $i++) { + $cid = 'c' . $i; + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $i, + '$permissions' => [Permission::read(Role::any())], + ])); + + for ($j = 1; $j <= 10; $j++) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => $cid, 'amount' => $j * 10, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(10, $results); + foreach ($results as $doc) { + $this->assertEquals(10, $doc['cnt']); + $this->assertEquals(550, $doc['total']); + } + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinNotEqualFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jne_o'; + $cCol = 'jne_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 100], + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 200], + ['cust_uid' => 'c1', 'status' => 'cancel', 'amount' => 50], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::notEqual('status', 'cancel'), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]['cnt']); + $this->assertEquals(300, $results[0]['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinStartsWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jsw_o'; + $cCol = 'jsw_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'tag', size: 50, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + $orders = [ + ['cust_uid' => 'c1', 'tag' => 'promo_spring', 'amount' => 100], + ['cust_uid' => 'c1', 'tag' => 'promo_fall', 'amount' => 200], + ['cust_uid' => 'c1', 'tag' => 'regular', 'amount' => 50], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::startsWith('tag', 'promo'), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]['cnt']); + $this->assertEquals(300, $results[0]['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinEqualMultipleValues(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jemv_o'; + $cCol = 'jemv_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 100], + ['cust_uid' => 'c1', 'status' => 'open', 'amount' => 200], + ['cust_uid' => 'c1', 'status' => 'cancel', 'amount' => 50], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 300], + ['cust_uid' => 'c2', 'status' => 'cancel', 'amount' => 25], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('status', ['done', 'open']), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc['cust_uid']; + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(2, $mapped['c1']['cnt']); + $this->assertEquals(300, $mapped['c1']['total']); + $this->assertEquals(1, $mapped['c2']['cnt']); + $this->assertEquals(300, $mapped['c2']['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinGroupByHavingLessThan(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jghl_o'; + $cCol = 'jghl_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 500], + ['cust_uid' => 'c2', 'amount' => 600], + ['cust_uid' => 'c3', 'amount' => 20], + ['cust_uid' => 'c3', 'amount' => 30], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::having([Query::lessThan('total', 100)]), + ]); + + $this->assertCount(2, $results); + $ids = array_map(fn ($d) => $d['cust_uid'], $results); + $this->assertContains('c1', $ids); + $this->assertContains('c3', $ids); + $this->assertNotContains('c2', $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testLeftJoinHavingCountZero(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljhz_p'; + $oCol = 'ljhz_o'; + $cols = [$pCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['p1', 'p2', 'p3'] as $pid) { + $database->createDocument($pCol, new Document([ + '$id' => $pid, 'name' => 'Product ' . $pid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $database->createDocument($oCol, new Document([ + 'prod_uid' => 'p1', 'amount' => 100, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'prod_uid' => 'p1', 'amount' => 200, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->aggregate($pCol, [ + Query::leftJoin($oCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + Query::having([Query::greaterThan('cnt', 1)]), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('Product p1', $results[0]['name']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinGroupByAllAggregations(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jgba_o'; + $cCol = 'jgba_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 100], + ['cust_uid' => 'c1', 'amount' => 200], + ['cust_uid' => 'c1', 'amount' => 300], + ['cust_uid' => 'c2', 'amount' => 50], + ['cust_uid' => 'c2', 'amount' => 150], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::avg('amount', 'avg_amt'), + Query::min('amount', 'min_amt'), + Query::max('amount', 'max_amt'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc['cust_uid']; + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + + $this->assertEquals(3, $mapped['c1']['cnt']); + $this->assertEquals(600, $mapped['c1']['total']); + $c1Avg = $mapped['c1']['avg_amt']; + $this->assertIsNumeric($c1Avg); + $this->assertEqualsWithDelta(200.0, (float) $c1Avg, 0.1); + $this->assertEquals(100, $mapped['c1']['min_amt']); + $this->assertEquals(300, $mapped['c1']['max_amt']); + + $this->assertEquals(2, $mapped['c2']['cnt']); + $this->assertEquals(200, $mapped['c2']['total']); + $c2Avg = $mapped['c2']['avg_amt']; + $this->assertIsNumeric($c2Avg); + $this->assertEqualsWithDelta(100.0, (float) $c2Avg, 0.1); + $this->assertEquals(50, $mapped['c2']['min_amt']); + $this->assertEquals(150, $mapped['c2']['max_amt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinSingleRowPerGroup(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jsr_o'; + $cCol = 'jsr_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + foreach (['c1', 'c2', 'c3'] as $i => $cid) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => $cid, 'amount' => ($i + 1) * 100, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(3, $results); + foreach ($results as $doc) { + $this->assertEquals(1, $doc['cnt']); + } + + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc['cust_uid']; + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(100, $mapped['c1']['total']); + $this->assertEquals(200, $mapped['c2']['total']); + $this->assertEquals(300, $mapped['c3']['total']); + + $this->cleanupAggCollections($database, $cols); + } + + /** + * @return array + */ + public static function joinTypeProvider(): array + { + return [ + 'inner join' => ['join', 2], + 'left join' => ['leftJoin', 3], + ]; + } + + #[DataProvider('joinTypeProvider')] + public function testJoinTypeCountsCorrectly(string $joinMethod, int $expectedGroups): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'jtc_p_'.$joinMethod; + $oCol = 'jtc_o_'.$joinMethod; + $cols = [$pCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'qty', required: true)); + + foreach (['p1', 'p2', 'p3'] as $pid) { + $database->createDocument($pCol, new Document([ + '$id' => $pid, 'name' => 'Product ' . $pid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $database->createDocument($oCol, new Document([ + 'prod_uid' => 'p1', 'qty' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'prod_uid' => 'p2', 'qty' => 3, + '$permissions' => [Permission::read(Role::any())], + ])); + + $joinQuery = match ($joinMethod) { + 'join' => Query::join($oCol, 'j0', [Query::on('$id', 'prod_uid')]), + 'leftJoin' => Query::leftJoin($oCol, 'j0', [Query::on('$id', 'prod_uid')]), + default => throw new \InvalidArgumentException('Unknown join method: '.$joinMethod), + }; + + $results = $database->aggregate($pCol, [ + $joinQuery, + Query::count('*', 'cnt'), + Query::groupBy(['name']), + ]); + + $this->assertCount($expectedGroups, $results); + + $this->cleanupAggCollections($database, $cols); + } + + /** + * @return array + */ + public static function joinAggregationTypeProvider(): array + { + return [ + 'count' => ['count', '*', 10], + 'sum' => ['sum', 'amount', 5500], + 'avg' => ['avg', 'amount', 550.0], + 'min' => ['min', 'amount', 100], + 'max' => ['max', 'amount', 1000], + ]; + } + + #[DataProvider('joinAggregationTypeProvider')] + public function testJoinWithDifferentAggTypes(string $aggMethod, string $attribute, int|float $expected): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jat_o_'.$aggMethod; + $cCol = 'jat_c_'.$aggMethod; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + for ($i = 1; $i <= 10; $i++) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', 'amount' => $i * 100, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $aggQuery = match ($aggMethod) { + 'count' => Query::count($attribute, 'result'), + 'sum' => Query::sum($attribute, 'result'), + 'avg' => Query::avg($attribute, 'result'), + 'min' => Query::min($attribute, 'result'), + 'max' => Query::max($attribute, 'result'), + default => throw new \InvalidArgumentException('Unknown aggregation method: '.$aggMethod), + }; + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + $aggQuery, + ]); + + $this->assertCount(1, $results); + if ($aggMethod === 'avg') { + $result = $results[0]['result']; + $this->assertIsNumeric($result); + $this->assertEqualsWithDelta($expected, (float) $result, 0.1); + } else { + $this->assertEquals($expected, $results[0]['result']); + } + + $this->cleanupAggCollections($database, $cols); + } + + /** + * @return array + */ + public static function joinHavingOperatorProvider(): array + { + return [ + 'gt 2' => ['greaterThan', 'cnt', 2, 2], + 'gte 3' => ['greaterThanEqual', 'cnt', 3, 2], + 'lt 4' => ['lessThan', 'cnt', 4, 2], + 'lte 3' => ['lessThanEqual', 'cnt', 3, 2], + ]; + } + + #[DataProvider('joinHavingOperatorProvider')] + public function testJoinHavingOperators(string $operator, string $alias, int|float $threshold, int $expectedGroups): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jho_o_'.$operator; + $cCol = 'jho_c_'.$operator; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', 'amount' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + + for ($i = 0; $i < 3; $i++) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c2', 'amount' => 20, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + for ($i = 0; $i < 5; $i++) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c3', 'amount' => 30, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $havingQuery = match ($operator) { + 'greaterThan' => Query::greaterThan($alias, $threshold), + 'greaterThanEqual' => Query::greaterThanEqual($alias, $threshold), + 'lessThan' => Query::lessThan($alias, $threshold), + 'lessThanEqual' => Query::lessThanEqual($alias, $threshold), + default => throw new \InvalidArgumentException('Unknown operator: '.$operator), + }; + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', $alias), + Query::groupBy(['cust_uid']), + Query::having([$havingQuery]), + ]); + + $this->assertCount($expectedGroups, $results); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinOrderByAggregation(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'joa_o'; + $cCol = 'joa_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 20], + ['cust_uid' => 'c2', 'amount' => 30], + ['cust_uid' => 'c2', 'amount' => 40], + ['cust_uid' => 'c3', 'amount' => 50], + ['cust_uid' => 'c3', 'amount' => 60], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::orderDesc('total'), + ]); + + $this->assertCount(3, $results); + $totals = array_map(fn (array $d) => $this->intAttribute($d, 'total'), $results); + $this->assertEquals([110, 90, 10], $totals); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithLimit(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jwl_o'; + $cCol = 'jwl_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + for ($i = 1; $i <= 5; $i++) { + $cid = 'c' . $i; + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $i, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'cust_uid' => $cid, 'amount' => $i * 100, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::orderDesc('total'), + Query::limit(2), + ]); + + $this->assertCount(2, $results); + $this->assertEquals(500, $this->intAttribute($results[0], 'total')); + $this->assertEquals(400, $this->intAttribute($results[1], 'total')); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithLimitAndOffset(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jlo_o'; + $cCol = 'jlo_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + for ($i = 1; $i <= 5; $i++) { + $cid = 'c' . $i; + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $i, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'cust_uid' => $cid, 'amount' => $i * 100, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::orderDesc('total'), + Query::limit(2), + Query::offset(1), + ]); + + $this->assertCount(2, $results); + $this->assertEquals(400, $this->intAttribute($results[0], 'total')); + $this->assertEquals(300, $this->intAttribute($results[1], 'total')); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinMultipleHavingConditions(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jmhc_o'; + $cCol = 'jmhc_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3', 'c4'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 100], + ['cust_uid' => 'c2', 'amount' => 200], + ['cust_uid' => 'c3', 'amount' => 50], + ['cust_uid' => 'c3', 'amount' => 50], + ['cust_uid' => 'c3', 'amount' => 50], + ['cust_uid' => 'c4', 'amount' => 500], + ['cust_uid' => 'c4', 'amount' => 600], + ['cust_uid' => 'c4', 'amount' => 700], + ['cust_uid' => 'c4', 'amount' => 800], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // HAVING count >= 2 AND sum > 200 → c2 (cnt=2, sum=300) and c4 (cnt=4, sum=2600) + // c1 excluded (cnt=1), c3 excluded (cnt=3, sum=150 < 200) + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::having([ + Query::greaterThanEqual('cnt', 2), + Query::greaterThan('total', 200), + ]), + ]); + + $this->assertCount(2, $results); + $ids = array_map(fn ($d) => $d['cust_uid'], $results); + $this->assertContains('c2', $ids); + $this->assertContains('c4', $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinHavingWithEqual(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhe_o'; + $cCol = 'jhe_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 20], + ['cust_uid' => 'c2', 'amount' => 30], + ['cust_uid' => 'c3', 'amount' => 40], + ['cust_uid' => 'c3', 'amount' => 50], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::groupBy(['cust_uid']), + Query::having([Query::equal('cnt', [2])]), + ]); + + $this->assertCount(2, $results); + $ids = array_map(fn ($d) => $d['cust_uid'], $results); + $this->assertContains('c2', $ids); + $this->assertContains('c3', $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinEmptyMainTable(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jem_o'; + $cCol = 'jem_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Main table (orders) is empty + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]['cnt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinOrderByGroupedColumn(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jogc_o'; + $cCol = 'jogc_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['alpha', 'beta', 'gamma'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => ucfirst($cid), + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'cust_uid' => $cid, 'amount' => 100, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::groupBy(['cust_uid']), + Query::orderDesc('cust_uid'), + ]); + + $this->assertCount(3, $results); + $custIds = array_map(fn ($d) => $d['cust_uid'], $results); + $this->assertEquals(['gamma', 'beta', 'alpha'], $custIds); + + $this->cleanupAggCollections($database, $cols); + } + + public function testTwoTableJoinFromMainTable(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + // Main table: orders, referencing both customers and products + $cCol = 'ttj_c'; + $pCol = 'ttj_p'; + $oCol = 'ttj_o'; + $cols = [$cCol, $pCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'title', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Alice', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($cCol, new Document([ + '$id' => 'c2', 'name' => 'Bob', + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', 'title' => 'Widget', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', 'title' => 'Gadget', + '$permissions' => [Permission::read(Role::any())], + ])); + + $orders = [ + ['cust_uid' => 'c1', 'prod_uid' => 'p1', 'amount' => 100], + ['cust_uid' => 'c1', 'prod_uid' => 'p1', 'amount' => 200], + ['cust_uid' => 'c1', 'prod_uid' => 'p2', 'amount' => 300], + ['cust_uid' => 'c2', 'prod_uid' => 'p1', 'amount' => 150], + ['cust_uid' => 'c2', 'prod_uid' => 'p2', 'amount' => 250], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // Join both customers and products from orders + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::join($pCol, 'j1', [Query::on('prod_uid', '$id')]), + Query::count('*', 'order_cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc['cust_uid']; + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(3, $mapped['c1']['order_cnt']); + $this->assertEquals(600, $this->intAttribute($mapped['c1'], 'total')); + $this->assertEquals(2, $mapped['c2']['order_cnt']); + $this->assertEquals(400, $this->intAttribute($mapped['c2'], 'total')); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinHavingNotBetween(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhnb_o'; + $cCol = 'jhnb_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 100], + ['cust_uid' => 'c2', 'amount' => 200], + ['cust_uid' => 'c3', 'amount' => 500], + ['cust_uid' => 'c3', 'amount' => 600], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // Sums: c1=10, c2=300, c3=1100 + // NOT BETWEEN 50 AND 500 → c1 (10) and c3 (1100) + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::having([Query::notBetween('total', 50, 500)]), + ]); + + $this->assertCount(2, $results); + $ids = array_map(fn ($d) => $d['cust_uid'], $results); + $this->assertContains('c1', $ids); + $this->assertContains('c3', $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithFilterAndOrder(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jfo_o'; + $cCol = 'jfo_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 500], + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 100], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 900], + ['cust_uid' => 'c3', 'status' => 'done', 'amount' => 200], + ['cust_uid' => 'c3', 'status' => 'done', 'amount' => 300], + ['cust_uid' => 'c3', 'status' => 'open', 'amount' => 10000], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // Filter done only, group by customer, order by total ascending + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('status', ['done']), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::orderAsc('total'), + ]); + + $this->assertCount(3, $results); + $totals = array_map(fn (array $d) => $this->intAttribute($d, 'total'), $results); + $this->assertEquals([500, 600, 900], $totals); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinHavingNotEqual(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhne_o'; + $cCol = 'jhne_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'amount' => 10], + ['cust_uid' => 'c2', 'amount' => 20], + ['cust_uid' => 'c2', 'amount' => 30], + ['cust_uid' => 'c3', 'amount' => 40], + ['cust_uid' => 'c3', 'amount' => 50], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // Counts: c1=1, c2=2, c3=2. HAVING count != 2 → c1 only + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::groupBy(['cust_uid']), + Query::having([Query::notEqual('cnt', 2)]), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('c1', $results[0]['cust_uid']); + $this->assertEquals(1, $results[0]['cnt']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testLeftJoinAllUnmatched(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljau_p'; + $oCol = 'ljau_o'; + $cols = [$pCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'qty', required: true)); + + foreach (['p1', 'p2'] as $pid) { + $database->createDocument($pCol, new Document([ + '$id' => $pid, 'name' => 'Product ' . $pid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + // Orders reference non-existent products + $database->createDocument($oCol, new Document([ + 'prod_uid' => 'nonexistent', 'qty' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->aggregate($pCol, [ + Query::leftJoin($oCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + ]); + + $this->assertCount(2, $results); + foreach ($results as $doc) { + $this->assertEquals(1, $doc['cnt']); + } + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinSameTableDifferentFilters(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jstdf_o'; + $cCol = 'jstdf_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'category', size: 50, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'category' => 'electronics', 'amount' => 500], + ['cust_uid' => 'c1', 'category' => 'books', 'amount' => 20], + ['cust_uid' => 'c1', 'category' => 'books', 'amount' => 30], + ['cust_uid' => 'c2', 'category' => 'electronics', 'amount' => 1000], + ['cust_uid' => 'c2', 'category' => 'electronics', 'amount' => 200], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // Filter electronics only, group by customer + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('category', ['electronics']), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::orderDesc('total'), + ]); + + $this->assertCount(2, $results); + $this->assertEquals('c2', $results[0]['cust_uid']); + $this->assertEquals(1200, $this->intAttribute($results[0], 'total')); + $this->assertEquals('c1', $results[1]['cust_uid']); + $this->assertEquals(500, $this->intAttribute($results[1], 'total')); + + // Now books only + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('category', ['books']), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('c1', $results[0]['cust_uid']); + $this->assertEquals(50, $this->intAttribute($results[0], 'total')); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinGroupByMultipleColumnsWithHaving(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jgmh_o'; + $cCol = 'jgmh_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 100], + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 200], + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 300], + ['cust_uid' => 'c1', 'status' => 'open', 'amount' => 50], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 400], + ['cust_uid' => 'c2', 'status' => 'open', 'amount' => 25], + ['cust_uid' => 'c2', 'status' => 'open', 'amount' => 75], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // GROUP BY cust_uid, status with HAVING count >= 2 + // c1/done (3), c1/open (1), c2/done (1), c2/open (2) + // Should return c1/done and c2/open + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid', 'status']), + Query::having([Query::greaterThanEqual('cnt', 2)]), + ]); + + $this->assertCount(2, $results); + $keys = array_map(function (array $document): string { + $custUid = $document['cust_uid']; + $status = $document['status']; + $this->assertIsString($custUid); + $this->assertIsString($status); + + return $custUid . '_' . $status; + }, $results); + $this->assertContains('c1_done', $keys); + $this->assertContains('c2_open', $keys); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinCountDistinctGrouped(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jcdg_o'; + $cCol = 'jcdg_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'product', size: 50, required: true)); + + foreach (['c1', 'c2'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'product' => 'A'], + ['cust_uid' => 'c1', 'product' => 'A'], + ['cust_uid' => 'c1', 'product' => 'B'], + ['cust_uid' => 'c1', 'product' => 'C'], + ['cust_uid' => 'c2', 'product' => 'A'], + ['cust_uid' => 'c2', 'product' => 'A'], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::countDistinct('product', 'unique_products'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc['cust_uid']; + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(3, $mapped['c1']['unique_products']); + $this->assertEquals(1, $mapped['c2']['unique_products']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinHavingOnSumWithFilter(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhsf_o'; + $cCol = 'jhsf_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'status', size: 20, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $orders = [ + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 100], + ['cust_uid' => 'c1', 'status' => 'done', 'amount' => 200], + ['cust_uid' => 'c1', 'status' => 'open', 'amount' => 9999], + ['cust_uid' => 'c2', 'status' => 'done', 'amount' => 50], + ['cust_uid' => 'c3', 'status' => 'done', 'amount' => 400], + ['cust_uid' => 'c3', 'status' => 'done', 'amount' => 500], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + // Filter to 'done' only, then HAVING sum > 200 + // c1 done sum=300, c2 done sum=50, c3 done sum=900 + // → c1 and c3 match + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::equal('status', ['done']), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::having([Query::greaterThan('total', 200)]), + Query::orderAsc('total'), + ]); + + $this->assertCount(2, $results); + $this->assertEquals('c1', $results[0]['cust_uid']); + $this->assertEquals(300, $this->intAttribute($results[0], 'total')); + $this->assertEquals('c3', $results[1]['cust_uid']); + $this->assertEquals(900, $this->intAttribute($results[1], 'total')); + + $this->cleanupAggCollections($database, $cols); + } + + public function testLeftJoinGroupByWithOrderAndLimit(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljgl_p'; + $oCol = 'ljgl_o'; + $cols = [$pCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'qty', required: true)); + + for ($i = 1; $i <= 5; $i++) { + $pid = 'p' . $i; + $database->createDocument($pCol, new Document([ + '$id' => $pid, 'name' => 'Product ' . $i, + '$permissions' => [Permission::read(Role::any())], + ])); + for ($j = 0; $j < $i; $j++) { + $database->createDocument($oCol, new Document([ + 'prod_uid' => $pid, 'qty' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + + // Get top 3 products by order count, descending + $results = $database->aggregate($pCol, [ + Query::leftJoin($oCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::count('*', 'order_cnt'), + Query::groupBy(['name']), + Query::orderDesc('order_cnt'), + Query::limit(3), + ]); + + $this->assertCount(3, $results); + $counts = []; + foreach ($results as $document) { + $count = $document['order_cnt']; + $this->assertIsNumeric($count); + $counts[] = (int) $count; + } + $this->assertEquals([5, 4, 3], $counts); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinWithEndsWith(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jew_o'; + $cCol = 'jew_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::string(key: 'tag', size: 50, required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + + $orders = [ + ['cust_uid' => 'c1', 'tag' => 'order_express', 'amount' => 100], + ['cust_uid' => 'c1', 'tag' => 'order_express', 'amount' => 200], + ['cust_uid' => 'c1', 'tag' => 'order_standard', 'amount' => 50], + ]; + foreach ($orders as $o) { + $database->createDocument($oCol, new Document(array_merge($o, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::endsWith('tag', 'express'), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]['cnt']); + $this->assertEquals(300, $results[0]['total']); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinHavingLessThanEqual(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $oCol = 'jhle_o'; + $cCol = 'jhle_c'; + $cols = [$oCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + foreach (['c1', 'c2', 'c3'] as $cid) { + $database->createDocument($cCol, new Document([ + '$id' => $cid, 'name' => 'Customer ' . $cid, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + // c1: sum=100, c2: sum=200, c3: sum=300 + foreach (['c1' => [100], 'c2' => [100, 100], 'c3' => [100, 100, 100]] as $cid => $amounts) { + foreach ($amounts as $amt) { + $database->createDocument($oCol, new Document([ + 'cust_uid' => $cid, 'amount' => $amt, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + + // HAVING sum <= 200 → c1 (100) and c2 (200) + $results = $database->aggregate($oCol, [ + Query::join($cCol, 'j0', [Query::on('cust_uid', '$id')]), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::having([Query::lessThanEqual('total', 200)]), + Query::orderAsc('total'), + ]); + + $this->assertCount(2, $results); + $this->assertEquals('c1', $results[0]['cust_uid']); + $c1Total = $results[0]['total']; + $this->assertIsNumeric($c1Total); + $this->assertEquals(100, (int) $c1Total); + $this->assertEquals('c2', $results[1]['cust_uid']); + $c2Total = $results[1]['total']; + $this->assertIsNumeric($c2Total); + $this->assertEquals(200, (int) $c2Total); + + $this->cleanupAggCollections($database, $cols); + } + + public function testRightJoinIncludesUnmatchedRightRows(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_rj_p'; + $rCol = 't8_rj_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'missing', + 'score' => 9, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->getAuthorization()->skip(fn () => $database->find($pCol, [ + Query::rightJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::select(['name']), + ])); + + $this->assertCount(2, $results); + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + \sort($ids); + $this->assertSame(['', 'p1'], $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testCrossJoinCartesianProduct(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $aCol = 't8_xj_a'; + $bCol = 't8_xj_b'; + $cols = [$aCol, $bCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $aCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($aCol, Attribute::string(key: 'label', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $bCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($bCol, Attribute::string(key: 'tag', size: 100, required: true)); + + foreach (['a1', 'a2', 'a3'] as $id) { + $database->createDocument($aCol, new Document([ + '$id' => $id, + 'label' => $id, + '$permissions' => [Permission::read(Role::any())], + ])); + } + foreach (['b1', 'b2'] as $id) { + $database->createDocument($bCol, new Document([ + '$id' => $id, + 'tag' => $id, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->find($aCol, [ + Query::crossJoin($bCol, 'j0'), + ]); + + $this->assertCount(6, $results); + + $this->cleanupAggCollections($database, $cols); + } + + public function testFullOuterJoinIncludesBothUnmatched(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_fo_p'; + $rCol = 't8_fo_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'missing', + 'score' => 9, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->getAuthorization()->skip(fn () => $database->find($pCol, [ + Query::fullOuterJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::select(['name']), + ])); + + $this->assertSame(3, \count($results)); + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + \sort($ids); + $this->assertSame(['', 'p1', 'p2'], $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testFullOuterJoinSelectDoesNotCollapseOneToMany(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_fo_1n_p'; + $rCol = 't8_fo_1n_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 3, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'missing', + 'score' => 9, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->getAuthorization()->skip(fn () => $database->find($pCol, [ + Query::fullOuterJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::select(['name']), + ])); + + $this->assertSame(4, \count($results)); + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + \sort($ids); + $this->assertSame(['', 'p1', 'p1', 'p2'], $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testNaturalJoinThrowsQueryException(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_nj_p'; + $rCol = 't8_nj_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'name', size: 100, required: true)); + + try { + $database->find($pCol, [ + Query::naturalJoin($rCol, 'j0'), + ]); + $this->fail('Expected QueryException for natural join'); + } catch (QueryException $exception) { + $this->assertStringContainsString('Natural joins are not supported', $exception->getMessage()); + } finally { + $this->cleanupAggCollections($database, $cols); + } + } + + public function testJoinOperatorGreaterThan(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $lCol = 't8_jgt_l'; + $rCol = 't8_jgt_r'; + $cols = [$lCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $lCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($lCol, Attribute::integer(key: 'value', required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::integer(key: 'threshold', required: true)); + + foreach ([10, 20, 30] as $value) { + $database->createDocument($lCol, new Document([ + 'value' => $value, + '$permissions' => [Permission::read(Role::any())], + ])); + } + foreach ([15, 25] as $threshold) { + $database->createDocument($rCol, new Document([ + 'threshold' => $threshold, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $results = $database->find($lCol, [ + Query::join($rCol, 'j0', [Query::on('value', 'threshold', '>')]), + ]); + + $this->assertCount(3, $results); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinPreservesUserAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $cCol = 't8_jua_c'; + $oCol = 't8_jua_o'; + $cols = [$cCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', + 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', + 'amount' => 150, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->find($cCol, [ + Query::join($oCol, 'ord', [Query::on('$id', 'cust_uid')]), + Query::select(['name', 'ord.amount']), + ]); + + $this->assertCount(1, $results); + $this->assertSame('c1', $results[0]->getId()); + $this->assertSame('Customer 1', $results[0]->getAttribute('name')); + $amount = $results[0]->getAttribute('ord.amount'); + $this->assertIsNumeric($amount); + $this->assertSame(150, (int) $amount); + + $this->cleanupAggCollections($database, $cols); + } + + public function testChainedJoinsQualifySecondJoinLeft(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $cCol = 't8_jch_c'; + $oCol = 't8_jch_o'; + $iCol = 't8_jch_i'; + $cols = [$cCol, $oCol, $iCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createCollection(Collection::create(id: $iCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($iCol, Attribute::string(key: 'order_uid', required: true)); + $database->createAttribute($iCol, Attribute::string(key: 'sku', size: 100, required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', + 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + '$id' => 'o1', + 'cust_uid' => 'c1', + 'amount' => 150, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($iCol, new Document([ + 'order_uid' => 'o1', + 'sku' => 'widget', + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->find($cCol, [ + Query::join($oCol, 'ord', [Query::on('$id', 'cust_uid')]), + Query::join($iCol, 'itm', [Query::on('ord.$id', 'order_uid')]), + Query::select(['name', 'ord.amount', 'itm.sku']), + ]); + + $this->assertCount(1, $results); + $this->assertSame('c1', $results[0]->getId()); + $chainedAmount = $results[0]->getAttribute('ord.amount'); + $this->assertIsNumeric($chainedAmount); + $this->assertSame(150, (int) $chainedAmount); + $this->assertSame('widget', $results[0]->getAttribute('itm.sku')); + + $this->cleanupAggCollections($database, $cols); + } + + public function testSelectJoinedColumnByAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $cCol = 't8_jsa_c'; + $oCol = 't8_jsa_o'; + $cols = [$cCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'cust_uid', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'c1', + 'name' => 'Customer 1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + 'cust_uid' => 'c1', + 'amount' => 275, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->find($cCol, [ + Query::select(['name', 'ord.amount']), + Query::join($oCol, 'ord', [Query::on('$id', 'cust_uid')]), + ]); + + $this->assertCount(1, $results); + $this->assertSame('Customer 1', $results[0]->getAttribute('name')); + $selectedAmount = $results[0]->getAttribute('ord.amount'); + $this->assertIsNumeric($selectedAmount); + $this->assertSame(275, (int) $selectedAmount); + + $this->cleanupAggCollections($database, $cols); + } + + public function testRightJoinUnmatchedRowSurvivesDocumentSecurity(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_rjds_p'; + $rCol = 't8_rjds_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'missing', + 'score' => 9, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->find($pCol, [ + Query::rightJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::select(['name']), + ]); + + $this->assertSame(2, \count($results)); + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + \sort($ids); + $this->assertSame(['', 'p1'], $ids); + + $unmatched = null; + foreach ($results as $document) { + if ($document->getId() === '') { + $unmatched = $document; + break; + } + } + $this->assertNotNull($unmatched); + $this->assertTrue($unmatched->getAttribute('name') === null || $unmatched->getAttribute('name') === ''); + + $this->cleanupAggCollections($database, $cols); + } + + public function testFullOuterJoinUnmatchedRowsSurviveDocumentSecurity(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_fods_p'; + $rCol = 't8_fods_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'missing', + 'score' => 9, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->find($pCol, [ + Query::fullOuterJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::select(['name']), + ]); + + $this->assertSame(3, \count($results)); + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + \sort($ids); + $this->assertSame(['', 'p1', 'p2'], $ids); + + $this->cleanupAggCollections($database, $cols); + } + + public function testSelfJoinAppliesPermissionToEachAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $col = 't8_sjacl'; + $cols = [$col]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $col, permissions: [Permission::create(Role::any())])); + $database->createAttribute($col, Attribute::string(key: 'payload', size: 100, required: true)); + $database->createAttribute($col, Attribute::string(key: 'code', size: 100, required: true)); + $database->createAttribute($col, Attribute::string(key: 'tag', size: 50, required: true)); + + $database->createDocument($col, new Document([ + '$id' => 'open', + 'payload' => 'open-payload', + 'code' => 'open-code', + 'tag' => 'shared', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($col, new Document([ + '$id' => 'secret', + 'payload' => 'secret-payload', + 'code' => 'secret-code', + 'tag' => 'shared', + '$permissions' => [Permission::read(Role::user('other'))], + ])); + + $authorization = $database->getAuthorization(); + $previousRoles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + $results = $database->find($col, [ + Query::join($col, 'visible', [Query::on('tag', 'tag')]), + Query::join($col, 'hidden', [Query::on('tag', 'tag')]), + Query::select(['visible.payload', 'hidden.code']), + ]); + + $this->assertSame(1, \count($results)); + $this->assertSame('open-payload', $results[0]->getAttribute('visible.payload')); + $this->assertSame('open-code', $results[0]->getAttribute('hidden.code')); + + foreach ($results as $document) { + $this->assertNotSame('secret-payload', $document->getAttribute('visible.payload')); + $this->assertNotSame('secret-code', $document->getAttribute('hidden.code')); + } + } finally { + $authorization->cleanRoles(); + foreach ($previousRoles as $role) { + $authorization->addRole($role); + } + $this->cleanupAggCollections($database, $cols); + } + } + + public function testRightJoinDoesNotLeakUnauthorizedJoinDocument(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $cCol = 't8_rjlk_c'; + $oCol = 't8_rjlk_o'; + $cols = [$cCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'customerId', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'cust1', + 'name' => 'Alice', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($cCol, new Document([ + '$id' => 'cust2', + 'name' => 'Bob', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + '$id' => 'ord-public', + 'customerId' => 'cust1', + 'amount' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + '$id' => 'ord-secret', + 'customerId' => 'cust1', + 'amount' => 999, + '$permissions' => [Permission::read(Role::user('other'))], + ])); + + $authorization = $database->getAuthorization(); + $previousRoles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + $results = $database->find($cCol, [ + Query::rightJoin($oCol, 'j0', [Query::on('$id', 'customerId')]), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + + $amounts = []; + foreach ($results as $document) { + $this->assertNotSame('ord-secret', $document->getId()); + $amount = $document->getAttribute('j0.amount'); + if (\is_numeric($amount)) { + $amount = (int) $amount; + $amounts[] = $amount; + $this->assertNotSame(999, $amount); + } + } + $this->assertContains(10, $amounts); + } finally { + $authorization->cleanRoles(); + foreach ($previousRoles as $role) { + $authorization->addRole($role); + } + $this->cleanupAggCollections($database, $cols); + } + } + + public function testFullOuterJoinDoesNotLeakUnauthorizedJoinDocument(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $cCol = 't8_folk_c'; + $oCol = 't8_folk_o'; + $cols = [$cCol, $oCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $oCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($oCol, Attribute::string(key: 'customerId', required: true)); + $database->createAttribute($oCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($cCol, new Document([ + '$id' => 'cust1', + 'name' => 'Alice', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($cCol, new Document([ + '$id' => 'cust2', + 'name' => 'Bob', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + '$id' => 'ord-public', + 'customerId' => 'cust1', + 'amount' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($oCol, new Document([ + '$id' => 'ord-secret', + 'customerId' => 'cust1', + 'amount' => 999, + '$permissions' => [Permission::read(Role::user('other'))], + ])); + + $authorization = $database->getAuthorization(); + $previousRoles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + $results = $database->find($cCol, [ + Query::fullOuterJoin($oCol, 'j0', [Query::on('$id', 'customerId')]), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + + $amounts = []; + foreach ($results as $document) { + $this->assertNotSame('ord-secret', $document->getId()); + $amount = $document->getAttribute('j0.amount'); + if (\is_numeric($amount)) { + $amount = (int) $amount; + $amounts[] = $amount; + $this->assertNotSame(999, $amount); + } + } + $this->assertContains(10, $amounts); + } finally { + $authorization->cleanRoles(); + foreach ($previousRoles as $role) { + $authorization->addRole($role); + } + $this->cleanupAggCollections($database, $cols); + } + } + + public function testRightJoinUnmatchedRowSurvivesSharedTables(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + if (! $database->getAdapter()->supports(Capability::Schemas)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $sharedTables = $database->hasSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); + + $sharedTablesDb = 'sharedTablesRj_'.static::getTestToken(); + $pCol = 't8_rjst_p'; + $rCol = 't8_rjst_r'; + + try { + if ($database->exists($sharedTablesDb)) { + $database->setDatabase($sharedTablesDb)->delete(); + } + + $database + ->setDatabase($sharedTablesDb) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->setTenant(1); + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + '$id' => 'r-match', + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + '$id' => 'r-unmatched', + 'prod_uid' => 'missing', + 'score' => 9, + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->setTenant(2); + $database->createDocument($rCol, new Document([ + '$id' => 'r-other-tenant', + 'prod_uid' => 'missing', + 'score' => 77, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + '$id' => 'r-other-match', + 'prod_uid' => 'p1', + 'score' => 88, + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->setTenant(1); + $results = $database->find($pCol, [ + Query::rightJoin($rCol, 'rev', [Query::on('$id', 'prod_uid')]), + Query::select(['name', 'rev.score']), + ]); + + $this->assertSame(2, \count($results)); + + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + \sort($ids); + $this->assertSame(['', 'p1'], $ids); + + $scores = []; + $unmatched = null; + foreach ($results as $document) { + $this->assertNotSame('r-other-tenant', $document->getId()); + $this->assertNotSame('r-other-match', $document->getId()); + $score = $document->getAttribute('rev.score'); + if (\is_numeric($score)) { + $score = (int) $score; + $scores[] = $score; + $this->assertNotSame(77, $score); + $this->assertNotSame(88, $score); + } + if ($document->getId() === '') { + $unmatched = $document; + } + } + + $this->assertNotNull($unmatched); + $this->assertTrue($unmatched->getAttribute('name') === null || $unmatched->getAttribute('name') === ''); + \sort($scores); + $this->assertSame([5, 9], $scores); + } finally { + $database->setTenant(null)->setSharedTables(false); + if ($database->exists($sharedTablesDb)) { + $database->delete($sharedTablesDb); + } + $database + ->setSharedTables($sharedTables) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); + } + } + + public function testGetDocumentInnerJoinMatched(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_ijm_p'; + $rCol = 'gd_ijm_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + + $document = $database->getDocument($pCol, 'p1', [ + Query::join($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + + $this->assertSame(false, $document->isEmpty()); + $score = $document->getAttribute('j0.score'); + $this->assertIsNumeric($score); + $this->assertSame(5, (int) $score); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentLeftJoinUnmatchedNullish(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_ljun_p'; + $rCol = 'gd_ljun_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + + $document = $database->getDocument($pCol, 'p2', [ + Query::leftJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + + $this->assertSame(false, $document->isEmpty()); + $this->assertArrayHasKey('j0.score', $document->getArrayCopy()); + $score = $document->getAttribute('j0.score'); + $this->assertTrue($score === null || $score === ''); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentInnerJoinUnmatchedEmpty(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_ijue_p'; + $rCol = 'gd_ijue_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + + $document = $database->getDocument($pCol, 'p2', [ + Query::join($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + + $this->assertSame(true, $document->isEmpty()); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentRightJoinUnmatchedEmpty(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_rjue_p'; + $rCol = 'gd_rjue_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + + $document = $database->getDocument($pCol, 'p2', [ + Query::rightJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + + $this->assertSame(true, $document->isEmpty()); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentOneToManyReturnsFirstRow(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_otm_p'; + $rCol = 'gd_otm_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 3, + '$permissions' => [Permission::read(Role::any())], + ])); + + $document = $database->getDocument($pCol, 'p1', [ + Query::join($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + + $this->assertSame(false, $document->isEmpty()); + $score = $document->getAttribute('j0.score'); + $this->assertIsNumeric($score); + $this->assertContains((int) $score, [5, 3]); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentSelectPlusJoin(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_spj_p'; + $rCol = 'gd_spj_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + + $document = $database->getDocument($pCol, 'p1', [ + Query::join($rCol, 'rev', [Query::on('$id', 'prod_uid')]), + Query::select(['name', 'rev.score']), + ]); + + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('Product p1', $document->getAttribute('name')); + $score = $document->getAttribute('rev.score'); + $this->assertIsNumeric($score); + $this->assertSame(5, (int) $score); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentRejectsCount(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_rc_p'; + $rCol = 'gd_rc_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + + $this->expectException(QueryException::class); + try { + $database->getDocument($pCol, 'p1', [ + Query::join($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::count('*', 'cnt'), + ]); + } finally { + $this->cleanupAggCollections($database, $cols); + } + } + + public function testGetDocumentRejectsNaturalJoin(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_rnj_p'; + $rCol = 'gd_rnj_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'name', size: 100, required: true)); + + try { + $database->getDocument($pCol, 'p1', [ + Query::naturalJoin($rCol, 'j0'), + ]); + $this->fail('Expected QueryException for natural join'); + } catch (QueryException $exception) { + $this->assertStringContainsString('Natural joins are not supported', $exception->getMessage()); + } finally { + $this->cleanupAggCollections($database, $cols); + } + } + + public function testGetDocumentSkipsCacheWhenJoinsPresent(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_sc_p'; + $rCol = 'gd_sc_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->getDocument($pCol, 'p1'); + $document = $database->getDocument($pCol, 'p1', [ + Query::leftJoin($rCol, 'rev', [Query::on('$id', 'prod_uid')]), + Query::select(['rev.score']), + ]); + + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('p1', $document->getId()); + $score = $document->getAttribute('rev.score'); + $this->assertIsNumeric($score); + $this->assertSame(5, (int) $score); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentJoinKeepsMainDocumentId(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_jmid_p'; + $rCol = 'gd_jmid_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + '$id' => 'r1', + 'prod_uid' => 'p1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + + $inner = $database->getDocument($pCol, 'p1', [ + Query::join($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + $this->assertSame(false, $inner->isEmpty()); + $this->assertSame('p1', $inner->getId()); + + $left = $database->getDocument($pCol, 'p1', [ + Query::leftJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + $this->assertSame(false, $left->isEmpty()); + $this->assertSame('p1', $left->getId()); + + $unmatched = $database->getDocument($pCol, 'p2', [ + Query::leftJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + $this->assertSame(false, $unmatched->isEmpty()); + $this->assertSame('p2', $unmatched->getId()); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentFullOuterJoinExistingIdBehavesLikeLeft(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_foj_p'; + $rCol = 'gd_foj_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + + $document = $database->getDocument($pCol, 'p2', [ + Query::fullOuterJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('p2', $document->getId()); + $this->assertArrayHasKey('j0.score', $document->getArrayCopy()); + $score = $document->getAttribute('j0.score'); + $this->assertTrue($score === null || $score === ''); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentFullOuterJoinUnauthorizedJoinStillReturnsMain(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 'gd_fojua_p'; + $rCol = 'gd_fojua_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + '$id' => 'r-secret', + 'prod_uid' => 'p1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('other'))], + ])); + + $authorization = $database->getAuthorization(); + $previousRoles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + $document = $database->getDocument($pCol, 'p1', [ + Query::fullOuterJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('p1', $document->getId()); + $this->assertNotSame('r-secret', $document->getId()); + $score = $document->getAttribute('j0.score'); + if (\is_numeric($score)) { + $score = (int) $score; + $this->assertNotSame(999, $score); + } + + $left = $database->getDocument($pCol, 'p1', [ + Query::leftJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + ]); + $this->assertSame(false, $left->isEmpty()); + $this->assertSame('p1', $left->getId()); + } finally { + $authorization->cleanRoles(); + foreach ($previousRoles as $role) { + $authorization->addRole($role); + } + $this->cleanupAggCollections($database, $cols); + } + } + + public function testFullOuterJoinLimitAppliesToOuterQuery(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_folim_p'; + $rCol = 't8_folim_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($pCol, new Document([ + '$id' => 'p1', + 'name' => 'Product p1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($pCol, new Document([ + '$id' => 'p2', + 'name' => 'Product p2', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'missing1', + 'score' => 8, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($rCol, new Document([ + 'prod_uid' => 'missing2', + 'score' => 9, + '$permissions' => [Permission::read(Role::any())], + ])); + + $results = $database->find($pCol, [ + Query::fullOuterJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::limit(2), + ]); + + $this->assertSame(2, \count($results)); + + $this->cleanupAggCollections($database, $cols); + } + + public function testFullOuterJoinOffsetAppliesToOuterQuery(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $pCol = 't8_fooff_p'; + $rCol = 't8_fooff_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + foreach (['p1', 'p2', 'p3', 'p4'] as $id) { + $database->createDocument($pCol, new Document([ + '$id' => $id, + 'name' => 'Product '.$id, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $queries = [ + Query::fullOuterJoin($rCol, 'j0', [Query::on('$id', 'prod_uid')]), + Query::orderAsc('name'), + ]; + + $full = $database->find($pCol, $queries); + $sliced = $database->find($pCol, [ + ...$queries, + Query::limit(2), + Query::offset(1), + ]); + + $identity = static function (Document $document): string { + $name = $document->getAttribute('name'); + $score = $document->getAttribute('j0.score'); + + return $document->getId().':'.(\is_scalar($name) ? (string) $name : '').':'.(\is_scalar($score) ? (string) $score : ''); + }; + + $this->assertSame(2, \count($sliced)); + $this->assertSame( + \array_slice(\array_map($identity, $full), 1, 2), + \array_map($identity, $sliced) + ); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinCollectionAclRejectsUnauthorizedJoinedCollection(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_nocr_m'; + $jCol = 'jp_nocr_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $mCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $jCol, permissions: [Permission::create(Role::any())], documentSecurity: false)); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($jCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::any())], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $joins = [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + Query::leftJoin($jCol, 'j1', [Query::on('$id', 'mainId')]), + Query::rightJoin($jCol, 'j2', [Query::on('$id', 'mainId')]), + Query::fullOuterJoin($jCol, 'j3', [Query::on('$id', 'mainId')]), + Query::crossJoin($jCol, 'j4'), + ]; + + foreach ($joins as $join) { + try { + $results = $database->find($mCol, [$join]); + foreach ($results as $document) { + $this->assertJoinAttributesAbsent($document); + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + } catch (AuthorizationException|QueryException $exception) { + $this->assertNotSame('', $exception->getMessage()); + } + } + + try { + $document = $database->getDocument($mCol, 'm1', [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + if (! $document->isEmpty()) { + $this->assertJoinAttributesAbsent($document); + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + } catch (AuthorizationException|QueryException $exception) { + $this->assertNotSame('', $exception->getMessage()); + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinCollectionAclAllowsWhenDocumentSecurityOff(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_dsoff_m'; + $jCol = 'jp_dsoff_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $mCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $jCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($jCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j1', + 'mainId' => 'm1', + 'score' => 5, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertContains(5, $this->numericScores($results)); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinCollectionAclAllowsWhenDocumentSecurityOffOnPhysicalIds(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'database_1_collection_1'; + $jCol = 'database_1_collection_2'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $mCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $jCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($jCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('other'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($jCol, 'rev', [Query::on('$id', 'mainId')]), + Query::select(['name', 'rev.score']), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertContains(999, $this->aliasedScores($results)); + + $rewritten = Query::leftJoin('jp_dsoff_public', 'rev', [Query::on('$id', 'mainId')]); + $rewritten->setAttribute($jCol); + $rewrittenResults = $database->find($mCol, [ + $rewritten, + Query::select(['name', 'rev.score']), + ]); + $this->assertGreaterThanOrEqual(1, \count($rewrittenResults)); + $this->assertContains(999, $this->aliasedScores($rewrittenResults)); + + $document = $database->getDocument($mCol, 'm1', [ + Query::leftJoin($jCol, 'rev', [Query::on('$id', 'mainId')]), + Query::select(['name', 'rev.score']), + ]); + $this->assertSame(false, $document->isEmpty()); + $this->assertContains(999, $this->aliasedScores([$document])); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testInnerJoinDoesNotLeakUnauthorizedJoinDocument(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_ij_m'; + $jCol = 'jp_ij_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + foreach ($results as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + $this->assertContains(10, $this->numericScores($results)); + $this->assertSame(false, \in_array(999, $this->numericScores($results), true)); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testLeftJoinUnauthorizedJoinAttributesAreNullish(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_lj_m'; + $jCol = 'jp_lj_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Alice', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'Bob', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + + $this->assertGreaterThanOrEqual(2, \count($results)); + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + $this->assertContains('m1', $ids); + $this->assertContains('m2', $ids); + + foreach ($results as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + $this->assertNullishScore($document); + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testRightJoinDoesNotLeakUnauthorizedMainOrJoin(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_rj_m'; + $jCol = 'jp_rj_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol, mainGranted: false); + + $database->createDocument($mCol, new Document([ + '$id' => 'm-public', + 'name' => 'Public Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm-secret', + 'name' => 'Secret Main', + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm-public', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm-secret', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-unmatched', + 'mainId' => 'missing', + 'score' => 7, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-unmatched-secret', + 'mainId' => 'missing-secret', + 'score' => 888, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::rightJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $scores = $this->numericScores($results); + $this->assertContains(10, $scores); + $this->assertSame(false, \in_array(999, $scores, true)); + $this->assertSame(false, \in_array(888, $scores, true)); + + foreach ($results as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + $this->assertNotSame('m-secret', $document->getId()); + $this->assertNotSame('j-unmatched-secret', $document->getId()); + $name = $document->getAttribute('name'); + $this->assertNotSame('Secret Main', $name); + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testFullOuterJoinFindPermissionMatrix(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_fo_m'; + $jCol = 'jp_fo_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol, mainGranted: false); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Matched', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'Unmatched Left', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm-secret', + 'name' => 'Secret Main', + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-unmatched', + 'mainId' => 'missing', + 'score' => 7, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-unmatched-secret', + 'mainId' => 'missing-secret', + 'score' => 888, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::fullOuterJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + + $ids = \array_map(static fn (Document $document): string => $document->getId(), $results); + $this->assertContains('m1', $ids); + $this->assertContains('m2', $ids); + $this->assertSame(false, \in_array('m-secret', $ids, true)); + $this->assertSame(false, \in_array('j-secret', $ids, true)); + $this->assertSame(false, \in_array('j-unmatched-secret', $ids, true)); + + $scores = $this->numericScores($results); + $this->assertContains(10, $scores); + $this->assertContains(7, $scores); + $this->assertSame(false, \in_array(999, $scores, true)); + $this->assertSame(false, \in_array(888, $scores, true)); + + $unmatchedLeft = null; + $unmatchedRight = null; + foreach ($results as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + $this->assertNotSame('Secret Main', $document->getAttribute('name')); + if ($document->getId() === 'm2') { + $unmatchedLeft = $document; + } + if ($document->getId() === '') { + $score = $document->getAttribute('j0.score'); + if (\is_numeric($score) && (int) $score === 7) { + $unmatchedRight = $document; + } + } + } + + $this->assertNotNull($unmatchedLeft); + $this->assertNullishScore($unmatchedLeft); + $this->assertNotNull($unmatchedRight); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testCrossJoinDoesNotLeakUnauthorizedJoinDocuments(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_xj_m'; + $jCol = 'jp_xj_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'A', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'B', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::crossJoin($jCol, 'j0'), + ]); + + $this->assertSame(2, \count($results)); + foreach ($results as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + $this->assertSame([10, 10], $this->numericScores($results)); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentJoinPermissionMatrix(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_gd_m'; + $jCol = 'jp_gd_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Secret Match', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'Public Match', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm3', + 'name' => 'Unmatched', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm2', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $innerSecret = $database->getDocument($mCol, 'm1', [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(true, $innerSecret->isEmpty()); + + $innerPublic = $database->getDocument($mCol, 'm2', [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $innerPublic->isEmpty()); + $this->assertSame('m2', $innerPublic->getId()); + $this->assertSecretJoinHidden($innerPublic, 'j-secret', 999); + $publicScore = $innerPublic->getAttribute('j0.score'); + $this->assertTrue(\is_numeric($publicScore)); + $this->assertSame(10, (int) $publicScore); + + $leftSecret = $database->getDocument($mCol, 'm1', [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $leftSecret->isEmpty()); + $this->assertSame('m1', $leftSecret->getId()); + $this->assertNotSame('j-secret', $leftSecret->getId()); + $this->assertSecretJoinHidden($leftSecret, 'j-secret', 999); + $this->assertNullishScore($leftSecret); + + $leftPublic = $database->getDocument($mCol, 'm2', [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $leftPublic->isEmpty()); + $this->assertSame('m2', $leftPublic->getId()); + + $rightSecret = $database->getDocument($mCol, 'm1', [ + Query::rightJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + if (! $rightSecret->isEmpty()) { + $this->assertSame('m1', $rightSecret->getId()); + $this->assertSecretJoinHidden($rightSecret, 'j-secret', 999); + } + + $rightPublic = $database->getDocument($mCol, 'm2', [ + Query::rightJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $rightPublic->isEmpty()); + $this->assertSame('m2', $rightPublic->getId()); + $this->assertSecretJoinHidden($rightPublic, 'j-secret', 999); + + $fojSecret = $database->getDocument($mCol, 'm1', [ + Query::fullOuterJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $fojSecret->isEmpty()); + $this->assertSame('m1', $fojSecret->getId()); + $this->assertNotSame('j-secret', $fojSecret->getId()); + $this->assertSecretJoinHidden($fojSecret, 'j-secret', 999); + $this->assertNullishScore($fojSecret); + + $fojPublic = $database->getDocument($mCol, 'm2', [ + Query::fullOuterJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $fojPublic->isEmpty()); + $this->assertSame('m2', $fojPublic->getId()); + $this->assertSecretJoinHidden($fojPublic, 'j-secret', 999); + + $fojUnmatched = $database->getDocument($mCol, 'm3', [ + Query::fullOuterJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $fojUnmatched->isEmpty()); + $this->assertSame('m3', $fojUnmatched->getId()); + $this->assertSecretJoinHidden($fojUnmatched, 'j-secret', 999); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinSelectDoesNotReturnSecretScore(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_sel_m'; + $jCol = 'jp_sel_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $finds = $database->find($mCol, [ + Query::leftJoin($jCol, 'rev', [Query::on('$id', 'mainId')]), + Query::select(['name', 'rev.score']), + ]); + foreach ($finds as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + $this->assertContains(10, $this->numericScores($finds)); + $this->assertSame(false, \in_array(999, $this->numericScores($finds), true)); + + $document = $database->getDocument($mCol, 'm1', [ + Query::leftJoin($jCol, 'rev', [Query::on('$id', 'mainId')]), + Query::select(['rev.score']), + ]); + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('m1', $document->getId()); + $this->assertSecretJoinHidden($document, 'j-secret', 999); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinDoesNotLeakOtherTenantRows(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $sharedTables = $database->hasSharedTables(); + $supportsSchemas = $database->getAdapter()->supports(Capability::Schemas); + if (! $sharedTables && ! $supportsSchemas) { + $this->expectNotToPerformAssertions(); + + return; + } + + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); + $createdDatabase = false; + $sharedTablesDb = 'sharedTablesJp_'.static::getTestToken(); + $mCol = 'jp_tn_m'; + $jCol = 'jp_tn_j'; + $cols = [$mCol, $jCol]; + + try { + if ($supportsSchemas) { + if ($database->exists($sharedTablesDb)) { + $database->setDatabase($sharedTablesDb)->delete(); + } + + $database + ->setDatabase($sharedTablesDb) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); + $createdDatabase = true; + } else { + $database->setTenant(null); + } + + $this->cleanupAggCollections($database, $cols); + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->setTenant(1); + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Tenant One', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'Unmatched Left', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-match', + 'mainId' => 'm1', + 'score' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->setTenant(2); + $database->createDocument($jCol, new Document([ + '$id' => 'j-other-match', + 'mainId' => 'm1', + 'score' => 88, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-other-unmatched', + 'mainId' => 'missing', + 'score' => 77, + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->setTenant(1); + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + foreach ([ + [Query::join($jCol, 'j0', [Query::on('$id', 'mainId')])], + [Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')])], + [Query::fullOuterJoin($jCol, 'j0', [Query::on('$id', 'mainId')])], + ] as $queries) { + $results = $database->find($mCol, $queries); + $this->assertGreaterThanOrEqual(1, \count($results)); + $scores = $this->numericScores($results); + $this->assertSame(false, \in_array(88, $scores, true)); + $this->assertSame(false, \in_array(77, $scores, true)); + foreach ($results as $document) { + $this->assertNotSame('j-other-match', $document->getId()); + $this->assertNotSame('j-other-unmatched', $document->getId()); + $this->assertSecretJoinHidden($document, 'j-other-match', 88); + } + } + }); + } finally { + if ($createdDatabase) { + $database->setTenant(null)->setSharedTables(false); + if ($database->exists($sharedTablesDb)) { + $database->delete($sharedTablesDb); + } + $database + ->setSharedTables($sharedTables) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); + } else { + $database->setTenant(null); + $this->cleanupAggCollections($database, $cols); + $database->setTenant($tenant); + } + } + } + + public function testJoinSecretRowOnlyVisibleToMatchingRole(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_rl_m'; + $jCol = 'jp_rl_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $mCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $jCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($jCol, Attribute::integer(key: 'score', required: true)); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user('jp-acl')), + Permission::read(Role::guests()), + ], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-any', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-guest', + 'mainId' => 'm1', + 'score' => 20, + '$permissions' => [Permission::read(Role::guests())], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $scores = $this->numericScores($results); + $this->assertContains(10, $scores); + $this->assertSame(false, \in_array(999, $scores, true)); + $this->assertSame(false, \in_array(20, $scores, true)); + foreach ($results as $document) { + $this->assertNotSame('j-secret', $document->getId()); + } + }); + + $this->withAuthorizationRoles($database, [Role::user('jp-acl')->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $scores = $this->numericScores($results); + $this->assertContains(999, $scores); + $this->assertSame(false, \in_array(10, $scores, true)); + $this->assertSame(false, \in_array(20, $scores, true)); + }); + + $this->withAuthorizationRoles($database, [Role::guests()->toString()], function () use ($database, $mCol, $jCol): void { + $results = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $scores = $this->numericScores($results); + $this->assertContains(20, $scores); + $this->assertSame(false, \in_array(999, $scores, true)); + $this->assertSame(false, \in_array(10, $scores, true)); + foreach ($results as $document) { + $this->assertNotSame('j-secret', $document->getId()); + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinSkipAuthDoesNotSkipJoinSideAcl(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_sa_m'; + $jCol = 'jp_sa_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $withoutJoin = $database->find($mCol); + $this->assertSame(1, \count($withoutJoin)); + $this->assertSame('m1', $withoutJoin[0]->getId()); + + $inner = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertContains(10, $this->numericScores($inner)); + $this->assertSame(false, \in_array(999, $this->numericScores($inner), true)); + foreach ($inner as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + + $left = $database->find($mCol, [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertGreaterThanOrEqual(1, \count($left)); + foreach ($left as $document) { + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + + $document = $database->getDocument($mCol, 'm1', [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + if (! $document->isEmpty()) { + $this->assertSame('m1', $document->getId()); + $this->assertSecretJoinHidden($document, 'j-secret', 999); + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinFilterOrderHavingOracleDoesNotRevealSecret(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_foh_m'; + $jCol = 'jp_foh_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $join = Query::join($jCol, 'rev', [Query::on('$id', 'mainId')]); + $baseline = $database->find($mCol, [$join]); + $this->assertSame(1, \count($baseline)); + $this->assertContains(10, $this->numericScores($baseline)); + $this->assertSecretJoinPayloadHidden($baseline, 'j-secret', 999); + + $filtered = $database->find($mCol, [ + $join, + Query::equal('rev.score', [999]), + ]); + $this->assertLessThanOrEqual(\count($baseline), \count($filtered)); + $this->assertSecretJoinPayloadHidden($filtered, 'j-secret', 999); + + $ordered = $database->find($mCol, [ + $join, + Query::orderDesc('rev.score'), + ]); + $this->assertSame(\count($baseline), \count($ordered)); + $this->assertSecretJoinPayloadHidden($ordered, 'j-secret', 999); + + if (! $database->getAdapter()->supports(Capability::Aggregations)) { + return; + } + + $aggregated = $database->aggregate($mCol, [ + $join, + Query::max('rev.score', 'max_score'), + Query::groupBy(['name']), + Query::having([Query::greaterThanEqual('max_score', 999)]), + ]); + $this->assertLessThanOrEqual(\count($baseline), \count($aggregated)); + $this->assertSecretJoinPayloadHidden(self::rowDocuments($aggregated), 'j-secret', 999); + + $maxOnly = $database->aggregate($mCol, [ + $join, + Query::max('rev.score', 'max_score'), + Query::groupBy(['name']), + ]); + $this->assertSecretJoinPayloadHidden(self::rowDocuments($maxOnly), 'j-secret', 999); + foreach ($maxOnly as $document) { + $maxScore = $document['max_score']; + if (\is_numeric($maxScore)) { + $this->assertNotSame(999, (int) $maxScore); + } + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinExactCountHidesSecretSiblingOnSameDocument(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_exc_m'; + $jCol = 'jp_exc_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Matched', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($mCol, new Document([ + '$id' => 'm2', + 'name' => 'Unmatched', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('jp-acl'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $inner = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(1, \count($inner)); + $this->assertSame('m1', $inner[0]->getId()); + $this->assertContains(10, $this->numericScores($inner)); + $this->assertSecretJoinPayloadHidden($inner, 'j-secret', 999); + + $publicMains = $database->find($mCol); + $this->assertSame(2, \count($publicMains)); + + $left = $database->find($mCol, [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(\count($publicMains), \count($left)); + $this->assertSecretJoinPayloadHidden($left, 'j-secret', 999); + foreach ($left as $document) { + if ($document->getId() !== 'm1') { + $this->assertNullishScore($document); + } + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinMixedDocumentSecurityHidesSecretOnFindAndGetDocument(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_mds_m'; + $jCol = 'jp_mds_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createMixedJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-public', + 'mainId' => 'm1', + 'score' => 10, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('other'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $inner = $database->find($mCol, [ + Query::join($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(1, \count($inner)); + $this->assertSame('m1', $inner[0]->getId()); + $this->assertContains(10, $this->numericScores($inner)); + $this->assertSecretJoinPayloadHidden($inner, 'j-secret', 999, 'user:other'); + + $left = $database->find($mCol, [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(1, \count($left)); + $this->assertSame('m1', $left[0]->getId()); + $this->assertSecretJoinPayloadHidden($left, 'j-secret', 999, 'user:other'); + + $document = $database->getDocument($mCol, 'm1', [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('m1', $document->getId()); + $this->assertSecretJoinHidden($document, 'j-secret', 999, 'user:other'); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinThreeTableDeniesUnauthorizedCollection(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $aCol = 'jp_3d_a'; + $bCol = 'jp_3d_b'; + $cCol = 'jp_3d_c'; + $cols = [$aCol, $bCol, $cCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $aCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($aCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $bCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($bCol, Attribute::string(key: 'aId', required: true)); + + $database->createCollection(Collection::create(id: $cCol, permissions: [Permission::create(Role::any())], documentSecurity: false)); + $database->createAttribute($cCol, Attribute::string(key: 'bId', required: true)); + $database->createAttribute($cCol, Attribute::string(key: 'secret', size: 100, required: true)); + + $database->createDocument($aCol, new Document([ + '$id' => 'a1', + 'name' => 'Alpha', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($bCol, new Document([ + '$id' => 'b1', + 'aId' => 'a1', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($cCol, new Document([ + '$id' => 'c-secret', + 'bId' => 'b1', + 'secret' => 'c-secret-token', + '$permissions' => [Permission::read(Role::any())], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $aCol, $bCol, $cCol): void { + try { + $results = $database->find($aCol, [ + Query::join($bCol, 'b', [Query::on('$id', 'aId')]), + Query::join($cCol, 'c', [Query::on('b.$id', 'bId')]), + ]); + foreach ($results as $document) { + $encoded = \json_encode($document); + $this->assertNotFalse($encoded); + $this->assertSame(false, \str_contains($encoded, 'c-secret-token')); + $this->assertSame(false, \str_contains($encoded, 'c-secret')); + $this->assertNotSame('c-secret-token', $document->getAttribute('secret')); + } + $this->fail('Join A→B→C must reject unauthorized collection C'); + } catch (AuthorizationException $exception) { + $this->assertSame(true, \str_contains($exception->getMessage(), 'Unauthorized access to joined collection')); + $this->assertSame(true, \str_contains($exception->getMessage(), $cCol)); + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testGetDocumentJoinSkipAuthDoesNotRevealSecret(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jp_gds_m'; + $jCol = 'jp_gds_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $this->createMixedJoinPermissionCollections($database, $mCol, $jCol); + + $database->createDocument($mCol, new Document([ + '$id' => 'm1', + 'name' => 'Main', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($jCol, new Document([ + '$id' => 'j-secret', + 'mainId' => 'm1', + 'score' => 999, + '$permissions' => [Permission::read(Role::user('other'))], + ])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $document = $database->getDocument($mCol, 'm1', [ + Query::leftJoin($jCol, 'j0', [Query::on('$id', 'mainId')]), + ]); + $this->assertSame(false, $document->isEmpty()); + $this->assertSame('m1', $document->getId()); + $this->assertSecretJoinHidden($document, 'j-secret', 999, 'user:other'); + $this->assertNullishScore($document); + }); + + $this->cleanupAggCollections($database, $cols); + } + + /** + * @param list $roles + * @param callable(): void $callback + */ + private function withAuthorizationRoles(Database $database, array $roles, callable $callback): void + { + $authorization = $database->getAuthorization(); + $previousRoles = $authorization->getRoles(); + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + try { + $callback(); + } finally { + $authorization->cleanRoles(); + foreach ($previousRoles as $role) { + $authorization->addRole($role); + } + } + } + + /** + * The joined collection grants no collection-level read, so its rows are + * filtered per document exactly as a direct list would filter them. + */ + private function createJoinPermissionCollections(Database $database, string $main, string $joined, bool $mainGranted = true): void + { + $granted = [Permission::create(Role::any()), Permission::read(Role::any())]; + $documentLevel = [Permission::create(Role::any())]; + + $database->createCollection(Collection::create(id: $main, permissions: $mainGranted ? $granted : $documentLevel)); + $database->createAttribute($main, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $joined, permissions: $documentLevel)); + $database->createAttribute($joined, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($joined, Attribute::integer(key: 'score', required: true)); + } + + private function createMixedJoinPermissionCollections(Database $database, string $main, string $joined): void + { + $database->createCollection(Collection::create(id: $main, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($main, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $joined, permissions: [Permission::create(Role::any())])); + $database->createAttribute($joined, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($joined, Attribute::integer(key: 'score', required: true)); + } + + /** + * @param array $documents + */ + private function assertSecretJoinPayloadHidden(array $documents, string $secretId, int $secretScore, string $forbiddenRole = 'user:jp-acl'): void + { + $payload = []; + foreach ($documents as $document) { + $this->assertSecretJoinHidden($document, $secretId, $secretScore, $forbiddenRole); + $payload[] = $document->getArrayCopy(); + } + + $this->assertEncodedJoinSecretHidden(\json_encode($payload), $secretId, $secretScore, $forbiddenRole); + } + + private function assertSecretJoinHidden(Document $document, string $secretId, int $secretScore, string $forbiddenRole = 'user:jp-acl'): void + { + $this->assertNotSame($secretId, $document->getId()); + + foreach ([...$this->joinedValues($document, 'score'), ...$this->joinedValues($document, 'amount')] as $value) { + if (\is_numeric($value)) { + $this->assertNotSame($secretScore, (int) $value); + } + } + + foreach ($document->getPermissions() as $permission) { + $this->assertSame(false, \str_contains($permission, $secretId)); + $this->assertSame(false, \str_contains($permission, $forbiddenRole)); + } + + $this->assertEncodedJoinSecretHidden(\json_encode($document), $secretId, $secretScore, $forbiddenRole); + } + + private function assertEncodedJoinSecretHidden(string|false $encoded, string $secretId, int $secretScore, string $forbiddenRole): void + { + $this->assertNotFalse($encoded); + $this->assertSame(false, \str_contains($encoded, $secretId)); + $this->assertSame(false, \str_contains($encoded, $forbiddenRole)); + $this->assertSame(false, $this->encodedJsonContainsScalar($encoded, $secretScore)); + } + + private function encodedJsonContainsScalar(string $encoded, int $needle): bool + { + $decoded = \json_decode($encoded, true); + if (! \is_array($decoded)) { + return false; + } + + return $this->jsonContainsScalar($decoded, $needle); + } + + private function jsonContainsScalar(mixed $value, int $needle, string|int|null $key = null): bool + { + if (\is_int($value) || \is_float($value) || (\is_string($value) && \is_numeric($value))) { + if ($this->isIgnoredJoinSecretKey($key)) { + return false; + } + + return (int) $value === $needle; + } + + if (! \is_array($value)) { + return false; + } + + foreach ($value as $childKey => $child) { + if ($this->jsonContainsScalar($child, $needle, $childKey)) { + return true; + } + } + + return false; + } + + private function isIgnoredJoinSecretKey(string|int|null $key): bool + { + return \in_array($key, [ + Document::SEQUENCE, + Document::CREATED_AT, + Document::UPDATED_AT, + Document::TENANT, + Document::COLLECTION, + Document::DISTANCE, + Document::DELETED_AT, + ], true); + } + + private function assertJoinAttributesAbsent(Document $document): void + { + $score = $document->getAttribute('score'); + $this->assertTrue($score === null || $score === ''); + $amount = $document->getAttribute('amount'); + $this->assertTrue($amount === null || $amount === ''); + } + + private function assertNullishScore(Document $document): void + { + $scores = $this->joinedValues($document, 'score'); + $this->assertNotSame([], $scores); + foreach ($scores as $score) { + $this->assertTrue($score === null || $score === ''); + } + } + + /** + * @param array $documents + * @return list + */ + private function numericScores(array $documents): array + { + $scores = []; + foreach ($documents as $document) { + foreach ($this->joinedValues($document, 'score') as $score) { + if (\is_numeric($score)) { + $scores[] = (int) $score; + } + } + } + + return $scores; + } + + /** + * An attribute's values under its bare name and under every join alias. + * + * @return list + */ + private function joinedValues(Document $document, string $attribute): array + { + $values = []; + foreach ($document->getArrayCopy() as $key => $value) { + if ($key === $attribute || \str_ends_with((string) $key, '.'.$attribute)) { + $values[] = $value; + } + } + + return $values; + } + + /** + * @param array $documents + * @return list + */ + private function aliasedScores(array $documents): array + { + $scores = []; + foreach ($documents as $document) { + $score = $document->getAttribute('rev.score') ?? $document->getAttribute('score'); + if (\is_numeric($score)) { + $scores[] = (int) $score; + } + } + + return $scores; + } + + public function testLeftJoinOnFilterKeepsUnmatchedMainRows(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljon_p'; + $rCol = 'ljon_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $rCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($rCol, Attribute::string(key: 'prod_uid', required: true)); + $database->createAttribute($rCol, Attribute::integer(key: 'score', required: true)); + + foreach (['p1' => 'Alpha', 'p2' => 'Beta', 'p3' => 'Gamma'] as $id => $name) { + $database->createDocument($pCol, new Document([ + '$id' => $id, + 'name' => $name, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + foreach ([ + ['prod_uid' => 'p1', 'score' => 5], + ['prod_uid' => 'p2', 'score' => 2], + ] as $review) { + $database->createDocument($rCol, new Document(array_merge($review, [ + '$permissions' => [Permission::read(Role::any())], + ]))); + } + + $results = $database->find($pCol, [ + Query::leftJoin($rCol, 'rev', [ + Query::on('$id', 'prod_uid'), + Query::greaterThanEqual('rev.score', 4), + ]), + Query::select(['name', 'rev.score']), + ]); + + $this->assertCount(3, $results); + $mapped = []; + foreach ($results as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc->getAttribute('rev.score'); + } + $this->assertEquals(5, $mapped['Alpha']); + $this->assertTrue($mapped['Beta'] === null || $mapped['Beta'] === ''); + $this->assertTrue($mapped['Gamma'] === null || $mapped['Gamma'] === ''); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinAliasesNeverCollide(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $first, $second] = $this->createAliasCollections($database); + + $declaredFirst = $database->find($main, [ + Query::join($first, 'j1', [Query::on('$id', 'mainId')]), + Query::join($second, 'j2', [Query::on('$id', 'mainId')]), + Query::select(['name', 'j1.score']), + ]); + $this->assertCount(1, $declaredFirst); + $this->assertSame(1, $this->scoreOf($declaredFirst[0], 'j1.score')); + + $declaredLater = $database->find($main, [ + Query::join($first, 'j1', [Query::on('$id', 'mainId')]), + Query::join($second, 'j0', [Query::on('$id', 'mainId')]), + Query::select(['name', 'j0.score']), + ]); + $this->assertCount(1, $declaredLater); + $this->assertSame(10, $this->scoreOf($declaredLater[0], 'j0.score')); + + foreach ([ + 'the same alias twice' => [ + Query::join($first, 'x', [Query::on('$id', 'mainId')]), + Query::join($second, 'x', [Query::on('$id', 'mainId')]), + ], + 'the main collection alias' => [ + Query::join($first, Query::DEFAULT_ALIAS, [Query::on('$id', 'mainId')]), + ], + ] as $label => $joins) { + $this->assertJoinQueryRejected(fn () => $database->find($main, $joins), "find with {$label}"); + $this->assertJoinQueryRejected(fn () => $database->count($main, $joins), "count with {$label}"); + } + + $this->cleanupAggCollections($database, [$main, $first, $second]); + } + + public function testJoinWithoutSelectReturnsJoinedAttributesUnderTheAlias(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $first] = $this->createAliasCollections($database); + $plain = \array_keys($database->getDocument($main, 'm1')->getArrayCopy()); + $expected = [...$plain, 'ord.$id', 'ord.mainId', 'ord.score', 'ord.secret']; + \sort($expected); + + foreach ([ + 'find' => $database->find($main, [Query::join($first, 'ord', [Query::on('$id', 'mainId')])]), + 'getDocument' => [$database->getDocument($main, 'm1', [Query::leftJoin($first, 'ord', [Query::on('$id', 'mainId')])])], + ] as $label => $rows) { + $this->assertCount(1, $rows, $label); + $keys = \array_keys($rows[0]->getArrayCopy()); + \sort($keys); + $this->assertSame($expected, $keys, $label); + $this->assertSame('m1', $rows[0]->getId(), $label); + $this->assertSame('b1', $rows[0]->getAttribute('ord.$id'), $label); + $this->assertSame(1, $this->scoreOf($rows[0], 'ord.score'), $label); + $this->assertSame('first-secret', $rows[0]->getAttribute('ord.secret'), $label); + } + + $this->cleanupAggCollections($database, $this->aliasCollections()); + } + + public function testJoinedValuesDecodeLikeADirectRead(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->addFilter( + 'joinedSeal', + static fn (mixed $value): mixed => \is_string($value) ? \json_encode(['data' => \base64_encode($value), 'method' => 'base64']) : $value, + static function (mixed $value): mixed { + $payload = \is_string($value) ? \json_decode($value, true) : null; + if (! \is_array($payload) || ! \is_string($payload['data'] ?? null)) { + return $value; + } + + return \base64_decode($payload['data'], true); + }, + ); + + $main = 'jdec_main'; + $joined = 'jdec_joined'; + $this->cleanupAggCollections($database, [$main, $joined]); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $main, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($main, Attribute::string(key: 'name', size: 64, required: true)); + $database->createCollection(Collection::create(id: $joined, permissions: $permissions, documentSecurity: false)); + $database->createAttributes($joined, [ + Attribute::string(key: 'mainId', size: 64, required: true), + Attribute::integer(key: 'total', required: true), + Attribute::float(key: 'price', required: true), + Attribute::boolean(key: 'paid', required: true), + Attribute::datetime(key: 'placedAt', required: true), + Attribute::string(key: 'tags', size: 32, array: true), + Attribute::string(key: 'meta', size: 1024, filters: [Filter::Json]), + Attribute::string(key: 'secret', size: 1024, filters: ['joinedSeal']), + ]); + + $placedAt = ['m1' => '2024-05-06T07:08:09.123+00:00', 'm2' => '2024-05-06T08:00:00.000+00:00', 'm3' => '2024-05-06T09:00:00.000+00:00']; + foreach ($placedAt as $id => $at) { + $database->createDocument($main, new Document(['$id' => $id, 'name' => $id, '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($joined, new Document([ + '$id' => 'j'.$id, + 'mainId' => $id, + 'total' => 10, + 'price' => 2.5, + 'paid' => true, + 'placedAt' => $at, + 'tags' => ['a', 'b'], + 'meta' => ['color' => 'red'], + 'secret' => 'plain-secret', + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $attributes = ['mainId', 'total', 'price', 'paid', 'placedAt', 'tags', 'meta', 'secret']; + $direct = $database->getDocument($joined, 'jm1'); + $join = Query::join($joined, 'dec', [Query::on('$id', 'mainId')]); + $selected = Query::select(['name', ...\array_map(static fn (string $attribute): string => 'dec.'.$attribute, $attributes)]); + + foreach ([ + 'find' => $database->find($main, [$join, Query::equal('$id', ['m1'])]), + 'find with a select' => $database->find($main, [$join, $selected, Query::equal('$id', ['m1'])]), + 'getDocument' => [$database->getDocument($main, 'm1', [$join])], + 'getDocument with a select' => [$database->getDocument($main, 'm1', [$join, $selected])], + ] as $label => $rows) { + $this->assertCount(1, $rows, $label); + $row = $rows[0]; + foreach ($attributes as $attribute) { + $this->assertSame($direct->getAttribute($attribute), $row->getAttribute('dec.'.$attribute), "{$label}: dec.{$attribute}"); + } + $this->assertSame(10, $row->getAttribute('dec.total'), $label); + $this->assertSame(2.5, $row->getAttribute('dec.price'), $label); + $this->assertTrue($row->getAttribute('dec.paid'), $label); + $this->assertSame(['a', 'b'], $row->getAttribute('dec.tags'), $label); + $this->assertSame(['color' => 'red'], $row->getAttribute('dec.meta'), $label); + $this->assertSame('plain-secret', $row->getAttribute('dec.secret'), $label); + } + + $page = [$join, Query::orderAsc('dec.placedAt'), Query::limit(1)]; + $ids = []; + $cursor = null; + for ($attempt = 0; $attempt < 4; $attempt++) { + $rows = $database->find($main, $cursor === null ? $page : [...$page, Query::cursorAfter($cursor)]); + if ($rows === []) { + break; + } + $ids[] = $rows[0]->getId(); + $cursor = $rows[0]; + } + $this->assertSame(['m1', 'm2', 'm3'], $ids, 'A cursor carrying decoded joined values pages by them'); + + $this->cleanupAggCollections($database, [$main, $joined]); + } + + public function testFullOuterJoinThenRightJoinReturnsUnmatchedRowsOnce(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $first, $second] = $this->createAliasCollections($database); + $database->createDocument($second, new Document([ + '$id' => 'c3', + 'mainId' => 'zz', + 'score' => 30, + '$permissions' => [Permission::read(Role::any())], + ])); + + foreach ([ + 'on the main collection' => [ + Query::fullOuterJoin($first, 'b', [Query::on('$id', 'mainId')]), + Query::rightJoin($second, 'c', [Query::on('$id', 'mainId')]), + ], + 'on the full outer joined collection' => [ + Query::fullOuterJoin($first, 'b', [Query::on('$id', 'mainId')]), + Query::rightJoin($second, 'c', [Query::on('b.mainId', 'mainId')]), + ], + ] as $label => $joins) { + $rows = $database->find($main, [...$joins, Query::select(['name', 'b.score', 'c.score'])]); + $values = \array_map( + fn (Document $row): string => (string) \json_encode([ + $row->getAttribute('name'), + $this->scoreOf($row, 'b.score'), + $this->scoreOf($row, 'c.score'), + ]), + $rows, + ); + \sort($values); + + $this->assertSame(['["m1",1,10]', '[null,null,30]'], $values, $label); + $this->assertSame(2, $database->count($main, $joins), $label); + $this->assertEquals(40, $database->sum($main, 'c.score', $joins), $label); + } + + $this->cleanupAggCollections($database, $this->aliasCollections()); + } + + public function testRightJoinOnAnUnmatchedFullOuterJoinRowIsPairedNotDuplicated(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $first, $second] = $this->createAliasCollections($database); + $database->createDocument($first, new Document([ + '$id' => 'b2', + 'mainId' => 'zz', + 'score' => 2, + '$permissions' => [Permission::read(Role::any())], + ])); + foreach (['c2' => 'zz', 'c3' => 'nobody'] as $id => $mainId) { + $database->createDocument($second, new Document([ + '$id' => $id, + 'mainId' => $mainId, + 'score' => 20, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $joins = [ + Query::fullOuterJoin($first, 'b', [Query::on('$id', 'mainId')]), + Query::rightJoin($second, 'c', [Query::on('b.mainId', 'mainId')]), + ]; + $rows = $database->find($main, [...$joins, Query::select(['$id', 'b.$id', 'c.$id'])]); + $values = \array_map( + static fn (Document $row): string => (string) \json_encode([ + $row->getId() !== '' ? $row->getId() : null, + $row->getAttribute('b.$id'), + $row->getAttribute('c.$id'), + ]), + $rows, + ); + \sort($values); + + $this->assertSame(['["m1","b1","c1"]', '[null,"b2","c2"]', '[null,null,"c3"]'], $values); + $this->assertSame(3, $database->count($main, $joins)); + + $this->cleanupAggCollections($database, $this->aliasCollections()); + } + + public function testTwoFullOuterJoinsRunOnlyWhereTheEngineHasThem(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $first, $second] = $this->createAliasCollections($database); + $joins = [ + Query::fullOuterJoin($first, 'b', [Query::on('$id', 'mainId')]), + Query::fullOuterJoin($second, 'c', [Query::on('b.mainId', 'mainId')]), + ]; + + if ($database->getAdapter() instanceof Postgres) { + $rows = $database->find($main, [...$joins, Query::select(['$id', 'b.$id', 'c.$id'])]); + $this->assertCount(1, $rows); + $this->assertSame('m1', $rows[0]->getId()); + $this->assertSame('b1', $rows[0]->getAttribute('b.$id')); + $this->assertSame('c1', $rows[0]->getAttribute('c.$id')); + $this->assertSame(1, $database->count($main, $joins)); + } else { + $message = $this->assertJoinQueryRejected(fn () => $database->find($main, $joins), 'find'); + $this->assertSame('A query can hold only one full outer join on this database', $message); + $this->assertJoinQueryRejected(fn () => $database->count($main, $joins), 'count'); + } + + $this->cleanupAggCollections($database, $this->aliasCollections()); + } + + public function testVectorSearchPagesByAJoinedAttributeWithACursor(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Joins) || ! $adapter->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $main = 'vjc_main'; + $meta = 'vjc_meta'; + $this->cleanupAggCollections($database, [$main, $meta]); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $main, permissions: $permissions)); + $database->createAttribute($main, Attribute::vector(key: 'embedding', dimensions: 3, required: true)); + $database->createCollection(Collection::create(id: $meta, permissions: $permissions)); + $database->createAttribute($meta, Attribute::string(key: 'mainId', size: 64, required: true)); + $database->createAttribute($meta, Attribute::integer(key: 'score', required: true)); + + foreach ([ + 'near-high' => [[1.0, 0.0, 0.0], 20], + 'near-low' => [[1.0, 0.0, 0.0], 10], + 'side-low' => [[0.0, 1.0, 0.0], 5], + 'side-high' => [[0.0, 1.0, 0.0], 50], + 'far' => [[-1.0, 0.0, 0.0], 1], + ] as $id => [$embedding, $score]) { + $database->createDocument($main, new Document([ + '$id' => $id, + 'embedding' => $embedding, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument($meta, new Document([ + 'mainId' => $id, + 'score' => $score, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $queries = [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::join($meta, 'meta', [Query::on('$id', 'mainId')]), + Query::orderAsc('meta.score'), + Query::limit(2), + ]; + + $ids = []; + $cursor = null; + for ($page = 0; $page < 4; $page++) { + $rows = $database->find($main, $cursor === null ? $queries : [...$queries, Query::cursorAfter($cursor)]); + if ($rows === []) { + break; + } + foreach ($rows as $row) { + $ids[] = $row->getId(); + } + $cursor = $rows[\count($rows) - 1]; + } + + $this->assertSame(['near-low', 'near-high', 'side-low', 'side-high', 'far'], $ids); + + $this->cleanupAggCollections($database, [$main, $meta]); + } + + public function testFullOuterJoinAggregatesCountEveryRowOnce(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $joined] = $this->createFullOuterJoinAggregateCollections($database); + $join = Query::fullOuterJoin($joined, 'b', [Query::on('link', 'link')]); + + $rows = $database->aggregate($main, [ + $join, + Query::count('*', 'rows'), + Query::count('b.$id', 'joined'), + Query::countDistinct('b.category', 'categories'), + Query::sum('b.score', 'total'), + Query::avg('b.score', 'mean'), + Query::min('b.score', 'low'), + Query::max('b.score', 'high'), + Query::sum('score', 'mainTotal'), + ]); + + $this->assertCount(1, $rows); + $expected = ['rows' => 6, 'joined' => 4, 'categories' => 2, 'total' => 22, 'low' => 4, 'high' => 7, 'mainTotal' => 70]; + foreach ($expected as $key => $value) { + $this->assertSame($value, $this->intAttribute($rows[0], $key), $key); + } + $this->assertEqualsWithDelta(5.5, $this->numericAttribute($rows[0], 'mean'), 0.001); + + $empty = $database->aggregate($main, [ + $join, + Query::equal('category', ['none']), + Query::count('*', 'rows'), + Query::sum('b.score', 'total'), + Query::max('b.score', 'high'), + ]); + + $this->assertCount(1, $empty); + $this->assertSame(0, $this->intAttribute($empty[0], 'rows')); + $this->assertNull($empty[0]['total']); + $this->assertNull($empty[0]['high']); + + $this->cleanupAggCollections($database, $this->fullOuterJoinAggregateCollections()); + } + + public function testFullOuterJoinGroupsHavingAndPagesCountEveryRowOnce(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $joined] = $this->createFullOuterJoinAggregateCollections($database); + $grouped = [ + Query::fullOuterJoin($joined, 'b', [Query::on('link', 'link')]), + Query::groupBy(['b.category']), + Query::count('*', 'rows'), + Query::sum('b.score', 'total'), + Query::sum('score', 'mainTotal'), + Query::orderDesc('rows'), + ]; + $this->assertSame( + [[null, 3, 6, 50], ['p', 2, 9, 10], ['q', 1, 7, 10]], + $this->fullOuterJoinGroups($database->aggregate($main, $grouped)), + ); + $this->assertSame( + [[null, 3, 6, 50], ['p', 2, 9, 10]], + $this->fullOuterJoinGroups($database->aggregate($main, [...$grouped, Query::having([Query::greaterThan('rows', 1)])])), + ); + $this->assertSame( + [['p', 2, 9, 10], ['q', 1, 7, 10]], + $this->fullOuterJoinGroups($database->aggregate($main, [...$grouped, Query::limit(2), Query::offset(1)])), + ); + + $this->cleanupAggCollections($database, $this->fullOuterJoinAggregateCollections()); + } + + public function testFullOuterJoinDistinctReturnsAValueBothSidesHoldOnce(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $joined] = $this->createFullOuterJoinAggregateCollections($database); + $join = Query::fullOuterJoin($joined, 'b', [Query::on('link', 'link')]); + + $all = $this->joinedCategories($database->find($main, [$join, Query::distinct(), Query::select(['b.category'])])); + \sort($all); + $this->assertSame([null, 'p', 'q'], $all); + + $this->assertSame(['q'], $this->joinedCategories($database->find($main, [ + $join, + Query::isNotNull('b.category'), + Query::distinct(), + Query::select(['b.category']), + Query::orderAsc('b.category'), + Query::limit(1), + Query::offset(1), + ]))); + + $this->cleanupAggCollections($database, $this->fullOuterJoinAggregateCollections()); + } + + public function testFullOuterJoinDistinctOrderedByAnUnselectedAttributeIsRejectedWhereEmulated(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins) || $database->getAdapter() instanceof Postgres) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $joined] = $this->createFullOuterJoinAggregateCollections($database); + + $message = $this->assertJoinQueryRejected(fn () => $database->find($main, [ + Query::fullOuterJoin($joined, 'b', [Query::on('link', 'link')]), + Query::distinct(), + Query::select(['b.category']), + Query::orderAsc('score'), + ]), 'find'); + $this->assertSame('A distinct() query over a full outer join can only be ordered by a selected attribute on this database, and score is not selected', $message); + + $this->cleanupAggCollections($database, $this->fullOuterJoinAggregateCollections()); + } + + public function testFullOuterJoinUnaliasedAggregatesComeBackUnderTheirDefaultNames(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $joined] = $this->createFullOuterJoinAggregateCollections($database); + $aggregates = [Query::count(), Query::sum('b.score'), Query::max('score')]; + + $left = $database->aggregate($main, [Query::leftJoin($joined, 'b', [Query::on('link', 'link')]), ...$aggregates]); + $full = $database->aggregate($main, [Query::fullOuterJoin($joined, 'b', [Query::on('link', 'link')]), ...$aggregates]); + + $this->assertCount(1, $left); + $this->assertCount(1, $full); + $this->assertSame(['count', 'sum_b_score', 'max_score'], \array_keys($left[0])); + $this->assertSame(\array_keys($left[0]), \array_keys($full[0])); + $this->assertSame( + [6, 22, 30], + \array_map(static fn (mixed $value): int => \is_numeric($value) ? (int) $value : -1, \array_values($full[0])), + ); + + $this->cleanupAggCollections($database, $this->fullOuterJoinAggregateCollections()); + } + + /** + * @param list> $rows + * @return list + */ + private function fullOuterJoinGroups(array $rows): array + { + return \array_map( + fn (array $row): array => [ + $row['category'] ?? null, + $this->intAttribute($row, 'rows'), + $this->intAttribute($row, 'total'), + $this->intAttribute($row, 'mainTotal'), + ], + $rows, + ); + } + + /** + * @param array $rows + * @return list + */ + private function joinedCategories(array $rows): array + { + return \array_values(\array_map(static fn (Document $row): mixed => $row->getAttribute('b.category'), $rows)); + } + + /** + * @return list + */ + private function fullOuterJoinAggregateCollections(): array + { + return ['foja_main', 'foja_joined']; + } + + /** + * m1 matches b1 and b4, m2 and m3 match nothing and nothing matches b2 and b3, so an emulated full + * outer join returns rows from both halves, and equal categories (null among them) from both. + * + * @return list + */ + private function createFullOuterJoinAggregateCollections(Database $database): array + { + $collections = $this->fullOuterJoinAggregateCollections(); + [$main, $joined] = $collections; + $this->cleanupAggCollections($database, $collections); + + $rows = [ + $main => ['m1' => ['1', 'p', 10], 'm2' => ['2', 'q', 20], 'm3' => ['5', 'p', 30]], + $joined => ['b1' => ['1', 'p', 4], 'b2' => ['3', 'p', 5], 'b3' => ['6', null, 6], 'b4' => ['1', 'q', 7]], + ]; + foreach ($rows as $collection => $documents) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'link', size: 16, required: true), + Attribute::string(key: 'category', size: 16, required: false), + Attribute::integer(key: 'score', required: true), + ], + permissions: $collection === $main + ? [Permission::create(Role::any())] + : [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: true, + )); + + foreach ($documents as $id => [$link, $category, $score]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + 'link' => $link, + 'category' => $category, + 'score' => $score, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + + return $collections; + } + + /** + * @return list + */ + private function aliasCollections(): array + { + return ['jal_main', 'jal_first', 'jal_second']; + } + + /** + * @return list + */ + private function createAliasCollections(Database $database): array + { + $collections = $this->aliasCollections(); + [$main, $first, $second] = $collections; + $this->cleanupAggCollections($database, $collections); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $main, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($main, Attribute::string(key: 'name', size: 64, required: true)); + foreach ([$first, $second] as $joined) { + $database->createCollection(Collection::create(id: $joined, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($joined, Attribute::string(key: 'mainId', size: 64, required: true)); + $database->createAttribute($joined, Attribute::integer(key: 'score', required: true)); + $database->createAttribute($joined, Attribute::string(key: 'secret', size: 64, required: false)); + } + + $database->createDocument($main, new Document(['$id' => 'm1', 'name' => 'm1', '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($first, new Document(['$id' => 'b1', 'mainId' => 'm1', 'score' => 1, 'secret' => 'first-secret', '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($second, new Document(['$id' => 'c1', 'mainId' => 'm1', 'score' => 10, 'secret' => 'second-secret', '$permissions' => [Permission::read(Role::any())]])); + + return $collections; + } + + private function scoreOf(Document $document, string $key): ?int + { + $score = $document->getAttribute($key); + + return \is_numeric($score) ? (int) $score : null; + } + + /** + * @param callable(): mixed $query + */ + private function assertJoinQueryRejected(callable $query, string $label): string + { + try { + $query(); + } catch (QueryException $exception) { + return $exception->getMessage(); + } + + $this->fail("Accepted {$label}"); + } + + public function testJoinBareAggregateAttributeAmbiguousAcrossJoinsIsRejected(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders, $refunds] = $collections = $this->seedJoinedAttributeCollections($database, 'jbaa'); + $joins = [ + Query::join($orders, 'alpha', [Query::on('$id', 'customerId')]), + Query::join($refunds, 'beta', [Query::on('$id', 'customerId')]), + ]; + + foreach ([ + [Query::sum('amount', 'total')], + [Query::count('*', 'rows'), Query::groupBy(['amount'])], + ] as $aggregation) { + try { + $database->aggregate($customers, [...$joins, ...$aggregation]); + $this->fail('A bare attribute two joins declare was bound to one of them'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute "amount" is ambiguous across joins; qualify it with a join alias', $error->getMessage()); + } + } + + $results = $database->aggregate($customers, [ + ...$joins, + Query::sum('alpha.amount', 'ordered'), + Query::sum('beta.amount', 'refunded'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(150, $this->intAttribute($results[0], 'ordered')); + $this->assertSame(10, $this->intAttribute($results[0], 'refunded')); + + $this->cleanupAggCollections($database, $collections); + } + + public function testJoinBareAggregateAttributeResolvesToTheDeclaringJoin(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders, , $notes] = $collections = $this->seedJoinedAttributeCollections($database, 'jbar'); + + $results = $database->aggregate($customers, [ + Query::join($notes, 'note', [Query::on('$id', 'customerId')]), + Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertSame(150, $this->intAttribute($results[0], 'total')); + + $results = $database->aggregate($customers, [ + Query::join($notes, 'j0', [Query::on('$id', 'customerId')]), + Query::join($orders, 'j1', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + Query::groupBy(['status']), + ]); + $totals = []; + foreach ($results as $result) { + $status = $result['status']; + $this->assertIsString($status); + $totals[$status] = $this->intAttribute($result, 'total'); + } + \ksort($totals); + $this->assertSame(['open' => 50, 'paid' => 100], $totals); + + $results = $database->aggregate($customers, [ + Query::leftJoin($notes, 'note', [Query::on('$id', 'customerId')]), + Query::count('$id', 'customers'), + ]); + $this->assertCount(1, $results); + $this->assertSame(2, $this->intAttribute($results[0], 'customers')); + + $this->cleanupAggCollections($database, $collections); + } + + public function testJoinSearchOnJoinedAttributeRequiresFulltextIndex(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins) || ! $database->getAdapter()->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders, , $notes] = $collections = $this->seedJoinedAttributeCollections($database, 'jsfi'); + $unindexed = [ + Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]), + Query::search('purchase.memo', 'gift'), + ]; + + foreach ([ + 'find' => fn () => $database->find($customers, $unindexed), + 'count' => fn () => $database->count($customers, $unindexed), + ] as $method => $read) { + try { + $read(); + $this->fail($method.'() searched a joined attribute without a fulltext index'); + } catch (QueryException $error) { + $this->assertSame('Searching by attribute "purchase.memo" requires a fulltext index.', $error->getMessage(), $method); + } + } + + $results = $database->find($customers, [ + Query::join($notes, 'note', [Query::on('$id', 'customerId')]), + Query::search('note.body', 'needle'), + Query::select(['name']), + ]); + $this->assertSame(['first'], \array_map(static fn (Document $document): string => $document->getId(), $results)); + + $this->cleanupAggCollections($database, $collections); + } + + /** + * @return array{0: string, 1: string, 2: string, 3: string} + */ + private function seedJoinedAttributeCollections(Database $database, string $prefix): array + { + $collections = [$prefix.'_c', $prefix.'_o', $prefix.'_r', $prefix.'_n']; + [$customers, $orders, $refunds, $notes] = $collections; + $this->cleanupAggCollections($database, $collections); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $customers, permissions: $permissions)); + $database->createAttribute($customers, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(Collection::create(id: $orders, permissions: $permissions)); + $database->createAttribute($orders, Attribute::string(key: 'customerId', size: 64, required: true)); + $database->createAttribute($orders, Attribute::integer(key: 'amount', required: true)); + $database->createAttribute($orders, Attribute::string(key: 'status', size: 32, required: true)); + $database->createAttribute($orders, Attribute::string(key: 'memo', size: 256, required: true)); + + $database->createCollection(Collection::create(id: $refunds, permissions: $permissions)); + $database->createAttribute($refunds, Attribute::string(key: 'customerId', size: 64, required: true)); + $database->createAttribute($refunds, Attribute::integer(key: 'amount', required: true)); + + $database->createCollection(Collection::create(id: $notes, permissions: $permissions)); + $database->createAttribute($notes, Attribute::string(key: 'customerId', size: 64, required: true)); + $database->createAttribute($notes, Attribute::string(key: 'body', size: 256, required: true)); + if ($database->getAdapter()->supports(Capability::IndexFulltext)) { + $database->createIndex($notes, Index::fulltext(key: 'body_fulltext', attributes: ['body'])); + } + + $rows = [ + [$customers, 'first', ['name' => 'First']], + [$customers, 'second', ['name' => 'Second']], + [$orders, 'paid', ['customerId' => 'first', 'amount' => 100, 'status' => 'paid', 'memo' => 'gift wrapped']], + [$orders, 'open', ['customerId' => 'first', 'amount' => 50, 'status' => 'open', 'memo' => 'pending']], + [$orders, 'other', ['customerId' => 'second', 'amount' => 7, 'status' => 'paid', 'memo' => 'plain']], + [$refunds, 'refund', ['customerId' => 'first', 'amount' => 5]], + [$notes, 'note', ['customerId' => 'first', 'body' => 'a needle in a haystack']], + ]; + foreach ($rows as [$collection, $id, $attributes]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } + + return [$customers, $orders, $refunds, $notes]; + } + + public function testJoinParityKeepsMainRowsReadableThroughCollectionGrant(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jpar_grant_m'; + $jCol = 'jpar_grant_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $granted = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $mCol, permissions: $granted)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createAttribute($mCol, Attribute::integer(key: 'visits', required: true)); + $database->createCollection(Collection::create(id: $jCol, permissions: $granted)); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($jCol, Attribute::string(key: 'bio', size: 100, required: true)); + + $database->createDocument($mCol, new Document(['$id' => 'open', 'name' => 'Open', 'visits' => 1, '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($mCol, new Document(['$id' => 'bare', 'name' => 'Bare', 'visits' => 10, '$permissions' => []])); + $database->createDocument($jCol, new Document(['$id' => 'open-profile', 'mainId' => 'open', 'bio' => 'Hello', '$permissions' => [Permission::read(Role::any())]])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $join = Query::leftJoin($jCol, 'profile', [Query::on('$id', 'mainId')]); + + $this->assertSame(['bare', 'open'], $this->joinParityIds($database->find($mCol))); + $this->assertSame( + ['bare', 'open'], + $this->joinParityIds($database->find($mCol, [$join, Query::select(['name', 'profile.bio'])])), + 'A left join is additive: it must not hide a row the collection grant makes readable', + ); + $this->assertSame(2, $database->count($mCol)); + $this->assertSame(2, $database->count($mCol, [$join])); + $this->assertSame(11, (int) $database->sum($mCol, 'visits')); + $this->assertSame(11, (int) $database->sum($mCol, 'visits', [$join])); + $this->assertSame('bare', $database->getDocument($mCol, 'bare')->getId()); + $this->assertSame('bare', $database->getDocument($mCol, 'bare', [$join])->getId()); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinParityShowsEveryRowOfAGrantedJoinedCollection(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jpar_all_m'; + $jCol = 'jpar_all_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $granted = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $mCol, permissions: $granted)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(Collection::create(id: $jCol, permissions: $granted)); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($jCol, Attribute::integer(key: 'amount', required: true)); + + $database->createDocument($mCol, new Document(['$id' => 'customer', 'name' => 'Customer', '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($jCol, new Document(['$id' => 'public-order', 'mainId' => 'customer', 'amount' => 100, '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($jCol, new Document(['$id' => 'secret-order', 'mainId' => 'customer', 'amount' => 9999, '$permissions' => [Permission::read(Role::user('other'))]])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $join = Query::join($jCol, 'ord', [Query::on('$id', 'mainId')]); + + $this->assertSame([100, 9999], $this->joinParityIntegers($database->find($jCol), 'amount')); + $this->assertSame( + [100, 9999], + $this->joinParityIntegers($database->find($mCol, [$join, Query::select(['name', 'ord.amount'])]), 'ord.amount'), + 'The collection grant makes every order readable directly, so the join must show every order', + ); + $this->assertSame($database->count($jCol), $database->count($mCol, [$join])); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinParityFiltersAJoinedCollectionPerDocumentWithoutGrant(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jpar_doc_m'; + $jCol = 'jpar_doc_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $mCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(Collection::create(id: $jCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($jCol, Attribute::string(key: 'text', size: 100, required: true)); + + $database->createDocument($mCol, new Document(['$id' => 'customer', 'name' => 'Customer', '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($jCol, new Document(['$id' => 'alice-note', 'mainId' => 'customer', 'text' => 'mine', '$permissions' => [Permission::read(Role::user('alice'))]])); + $database->createDocument($jCol, new Document(['$id' => 'bob-note', 'mainId' => 'customer', 'text' => 'theirs', '$permissions' => [Permission::read(Role::user('bob'))]])); + + $this->withAuthorizationRoles($database, [Role::any()->toString(), Role::user('alice')->toString()], function () use ($database, $mCol, $jCol): void { + $join = Query::join($jCol, 'note', [Query::on('$id', 'mainId')]); + + $this->assertSame(['mine'], $this->joinParityStrings($database->find($jCol), 'text')); + $this->assertSame( + ['mine'], + $this->joinParityStrings($database->find($mCol, [$join, Query::select(['name', 'note.text'])]), 'note.text'), + 'Without a collection grant the joined rows are filtered per document, exactly like a direct list', + ); + $this->assertSame($database->count($jCol), $database->count($mCol, [$join])); + $this->assertSame( + 'mine', + $database->getDocument($mCol, 'customer', [$join, Query::select(['name', 'note.text'])])->getAttribute('note.text'), + ); + }); + + $this->cleanupAggCollections($database, $cols); + } + + public function testJoinParityRejectsAJoinedCollectionWithoutGrantOrDocumentSecurity(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $mCol = 'jpar_deny_m'; + $jCol = 'jpar_deny_j'; + $cols = [$mCol, $jCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(Collection::create(id: $mCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createAttribute($mCol, Attribute::integer(key: 'visits', required: true)); + $database->createCollection(Collection::create(id: $jCol, permissions: [Permission::create(Role::any())], documentSecurity: false)); + $database->createAttribute($jCol, Attribute::string(key: 'mainId', required: true)); + + $database->createDocument($mCol, new Document(['$id' => 'customer', 'name' => 'Customer', 'visits' => 1, '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($jCol, new Document(['$id' => 'entry', 'mainId' => 'customer', '$permissions' => [Permission::read(Role::any())]])); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $jCol): void { + $join = Query::join($jCol, 'ledger', [Query::on('$id', 'mainId')]); + $reads = [ + 'direct find' => fn () => $database->find($jCol), + 'find' => fn () => $database->find($mCol, [$join]), + 'count' => fn () => $database->count($mCol, [$join]), + 'sum' => fn () => $database->sum($mCol, 'visits', [$join]), + 'getDocument' => fn () => $database->getDocument($mCol, 'customer', [$join]), + ]; + + foreach ($reads as $read => $callback) { + try { + $callback(); + $this->fail("{$read} must reject a collection readable neither at collection nor at document level"); + } catch (AuthorizationException $exception) { + $this->assertNotSame('', $exception->getMessage()); + } + } + }); + + $this->cleanupAggCollections($database, $cols); + } + + /** + * @param array $documents + * @return list + */ + private function joinParityIds(array $documents): array + { + $ids = \array_values(\array_unique(\array_map(static fn (Document $document): string => $document->getId(), $documents))); + \sort($ids); + + return $ids; + } + + /** + * @param array $documents + * @return list + */ + private function joinParityIntegers(array $documents, string $attribute): array + { + $values = []; + foreach ($documents as $document) { + $value = $document->getAttribute($attribute); + if (\is_numeric($value)) { + $values[] = (int) $value; + } + } + \sort($values); + + return $values; + } + + /** + * @param array $documents + * @return list + */ + private function joinParityStrings(array $documents, string $attribute): array + { + $values = []; + foreach ($documents as $document) { + $value = $document->getAttribute($attribute); + if (\is_string($value)) { + $values[] = $value; + } + } + \sort($values); + + return $values; + } + + public function testSharedTablesJoinsReadOnlyTheSelectedTenantsRows(): void + { + $database = static::getDatabase(); + if (! $database->hasSharedTables() || ! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collections = ['jtn_authors', 'jtn_books', 'jtn_reviews']; + [$authors, $books] = $collections; + $tenant = $database->getTenant(); + + $rowsByJoin = [ + [Method::Join, [ + 1 => [['one-a1', 11]], + 2 => [['two-a1', 21], ['two-a2', 22]], + ]], + [Method::LeftJoin, [ + 1 => [['one-a1', 11], ['one-a2', null]], + 2 => [['two-a1', 21], ['two-a2', 22], ['two-shared', null]], + ]], + [Method::RightJoin, [ + 1 => [['one-a1', 11], [null, 12], [null, 13]], + 2 => [['two-a1', 21], ['two-a2', 22]], + ]], + [Method::FullOuterJoin, [ + 1 => [['one-a1', 11], ['one-a2', null], [null, 12], [null, 13]], + 2 => [['two-a1', 21], ['two-a2', 22], ['two-shared', null]], + ]], + [Method::CrossJoin, [ + 1 => [['one-a1', 11], ['one-a1', 12], ['one-a1', 13], ['one-a2', 11], ['one-a2', 12], ['one-a2', 13]], + 2 => [['two-a1', 21], ['two-a1', 22], ['two-a2', 21], ['two-a2', 22], ['two-shared', 21], ['two-shared', 22]], + ]], + ]; + + try { + $this->seedJoinTenancyFixture($database, ...$collections); + + foreach ($rowsByJoin as [$method, $rowsByTenant]) { + foreach ($rowsByTenant as $selected => $rows) { + $database->setTenant($selected); + $join = fn (): Query => $this->joinTenancyJoin($method, $books, 'book'); + + $this->assertSame( + $this->joinTenancySorted($rows), + $this->joinTenancyRows($database->find($authors, [$join(), Query::select(['name', 'book.pages'])]), ['book.pages']), + "Tenant {$selected} must read exactly its own rows through a {$method->value}", + ); + $this->assertSame( + \count($rows), + $database->count($authors, [$join()]), + "Tenant {$selected} must count exactly its own rows through a {$method->value}", + ); + $this->assertSame( + \array_sum(\array_map(static fn (array $row): int => $row[1] ?? 0, $rows)), + $database->sum($authors, 'book.pages', [$join()]), + "Tenant {$selected} must sum exactly its own rows through a {$method->value}", + ); + } + + $database->setTenant(1); + $queries = fn (): array => [$this->joinTenancyJoin($method, $books, 'book'), Query::select(['name', 'book.pages'])]; + $this->assertSame('one-a1', $database->getDocument($authors, 'a1', $queries())->getAttribute('name')); + $this->assertTrue( + $database->getDocument($authors, 'shared', $queries())->isEmpty(), + "Tenant 1 must not read tenant 2's document through a {$method->value}", + ); + $this->assertTrue( + $database->getDocument($authors, 'legacy', $queries())->isEmpty(), + "Tenant 1 must not read a tenantless document through a {$method->value}", + ); + } + } finally { + $database->setTenant(null); + $this->cleanupAggCollections($database, $collections); + $database->setTenant($tenant); + } + } + + /** + * A join no index serves and a join an index serves, inner and left, read only the selected tenant's rows: + * reviews are indexed by author, books are not. + */ + public function testSharedTablesJoinsWithAndWithoutAnIndexReadOnlyTheSelectedTenantsRows(): void + { + $database = static::getDatabase(); + if (! $database->hasSharedTables() || ! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collections = ['jti_authors', 'jti_books', 'jti_reviews']; + [$authors, $books, $reviews] = $collections; + $tenant = $database->getTenant(); + + $rowsByChain = [ + [Method::Join, Method::Join, [ + 1 => [['one-a1', 11, 5]], + 2 => [], + ]], + [Method::LeftJoin, Method::LeftJoin, [ + 1 => [['one-a1', 11, 5], ['one-a2', null, 4]], + 2 => [['two-a1', 21, null], ['two-a2', 22, null], ['two-shared', null, 1]], + ]], + [Method::Join, Method::LeftJoin, [ + 1 => [['one-a1', 11, 5]], + 2 => [['two-a1', 21, null], ['two-a2', 22, null]], + ]], + [Method::LeftJoin, Method::Join, [ + 1 => [['one-a1', 11, 5], ['one-a2', null, 4]], + 2 => [['two-shared', null, 1]], + ]], + ]; + + try { + $this->seedJoinTenancyFixture($database, ...$collections); + $database->createIndex($reviews, Index::key('author_key', ['authorId'])); + + foreach ($rowsByChain as [$first, $second, $rowsByTenant]) { + $joins = fn (): array => [ + $this->joinTenancyJoin($first, $books, 'book'), + $this->joinTenancyJoin($second, $reviews, 'review'), + ]; + $label = "{$first->value} unindexed books then {$second->value} indexed reviews"; + + foreach ($rowsByTenant as $selected => $rows) { + $database->setTenant($selected); + + $this->assertSame( + $this->joinTenancySorted($rows), + $this->joinTenancyRows( + $database->find($authors, [...$joins(), Query::select(['name', 'book.pages', 'review.stars'])]), + ['book.pages', 'review.stars'], + ), + "Tenant {$selected} must read exactly its own rows through {$label}", + ); + $this->assertSame(\count($rows), $database->count($authors, $joins()), "Tenant {$selected} must count exactly its own rows through {$label}"); + $this->assertSame( + \array_sum(\array_map(static fn (array $row): int => $row[1] ?? 0, $rows)), + $database->sum($authors, 'book.pages', $joins()), + "Tenant {$selected} must sum exactly its own rows through {$label}", + ); + } + + $database->setTenant(1); + $queries = fn (): array => [...$joins(), Query::select(['name', 'book.pages', 'review.stars'])]; + $this->assertSame('one-a1', $database->getDocument($authors, 'a1', $queries())->getAttribute('name')); + $this->assertTrue($database->getDocument($authors, 'shared', $queries())->isEmpty(), "Tenant 1 must not read tenant 2's document through {$label}"); + $this->assertTrue($database->getDocument($authors, 'legacy', $queries())->isEmpty(), "Tenant 1 must not read a tenantless document through {$label}"); + } + } finally { + $database->setTenant(null); + $this->cleanupAggCollections($database, $collections); + $database->setTenant($tenant); + } + } + + public function testSharedTablesChainedJoinsKeepTheSelectedTenantsUnmatchedRows(): void + { + $database = static::getDatabase(); + if (! $database->hasSharedTables() || ! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collections = ['jtc_authors', 'jtc_books', 'jtc_reviews']; + [$authors, $books, $reviews] = $collections; + $tenant = $database->getTenant(); + + $rowsByChain = [ + [Method::Join, Method::RightJoin, [ + 1 => [['one-a1', 11, 5], [null, null, 4], [null, null, 3], [null, null, 2]], + 2 => [[null, null, 1]], + ]], + [Method::RightJoin, Method::RightJoin, [ + 1 => [['one-a1', 11, 5], [null, null, 4], [null, null, 3], [null, null, 2]], + 2 => [[null, null, 1]], + ]], + [Method::LeftJoin, Method::FullOuterJoin, [ + 1 => [['one-a1', 11, 5], ['one-a2', null, 4], [null, null, 3], [null, null, 2]], + 2 => [['two-a1', 21, null], ['two-a2', 22, null], ['two-shared', null, 1]], + ]], + [Method::CrossJoin, Method::RightJoin, [ + 1 => [ + ['one-a1', 11, 5], ['one-a1', 12, 5], ['one-a1', 13, 5], + ['one-a2', 11, 4], ['one-a2', 12, 4], ['one-a2', 13, 4], + [null, null, 3], [null, null, 2], + ], + 2 => [['two-shared', 21, 1], ['two-shared', 22, 1]], + ]], + ]; + + try { + $this->seedJoinTenancyFixture($database, ...$collections); + + foreach ($rowsByChain as [$first, $second, $rowsByTenant]) { + foreach ($rowsByTenant as $selected => $rows) { + $database->setTenant($selected); + $joins = fn (): array => [ + $this->joinTenancyJoin($first, $books, 'book'), + $this->joinTenancyJoin($second, $reviews, 'review'), + ]; + $label = "{$first->value} books then {$second->value} reviews"; + + $this->assertSame( + $this->joinTenancySorted($rows), + $this->joinTenancyRows( + $database->find($authors, [...$joins(), Query::select(['name', 'book.pages', 'review.stars'])]), + ['book.pages', 'review.stars'], + ), + "Tenant {$selected} must read exactly its own rows through {$label}", + ); + $this->assertSame( + \count($rows), + $database->count($authors, $joins()), + "Tenant {$selected} must count exactly its own rows through {$label}", + ); + } + } + } finally { + $database->setTenant(null); + $this->cleanupAggCollections($database, $collections); + $database->setTenant($tenant); + } + } + + /** + * Two tenants reusing the same document ids, plus one legacy row per collection that has no + * tenant at all. Tenant 1's book b2 names an author only tenant 2 has, b3 the tenantless author, + * and review r4 an author only tenant 2 has; its author a2 has books only in tenant 2. + */ + private function seedJoinTenancyFixture(Database $database, string $authors, string $books, string $reviews): void + { + $database->setTenant(null); + $this->cleanupAggCollections($database, [$authors, $books, $reviews]); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $authors, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($authors, Attribute::string(key: 'name', size: 64, required: true)); + foreach ([$books => 'pages', $reviews => 'stars'] as $collection => $number) { + $database->createCollection(Collection::create(id: $collection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($collection, Attribute::string(key: 'authorId', size: 64, required: true)); + $database->createAttribute($collection, Attribute::integer(key: $number, required: true)); + } + + $tenantless = 3; + $rows = [ + 1 => [ + $authors => ['a1' => ['name' => 'one-a1'], 'a2' => ['name' => 'one-a2']], + $books => [ + 'b1' => ['authorId' => 'a1', 'pages' => 11], + 'b2' => ['authorId' => 'shared', 'pages' => 12], + 'b3' => ['authorId' => 'legacy', 'pages' => 13], + ], + $reviews => [ + 'r1' => ['authorId' => 'a1', 'stars' => 5], + 'r2' => ['authorId' => 'a2', 'stars' => 4], + 'r3' => ['authorId' => 'ghost', 'stars' => 3], + 'r4' => ['authorId' => 'shared', 'stars' => 2], + ], + ], + 2 => [ + $authors => ['a1' => ['name' => 'two-a1'], 'a2' => ['name' => 'two-a2'], 'shared' => ['name' => 'two-shared']], + $books => [ + 'b1' => ['authorId' => 'a1', 'pages' => 21], + 'b2' => ['authorId' => 'a2', 'pages' => 22], + ], + $reviews => ['r1' => ['authorId' => 'shared', 'stars' => 1]], + ], + $tenantless => [ + $authors => ['legacy' => ['name' => 'no-tenant']], + $books => ['orphan' => ['authorId' => 'a1', 'pages' => 99]], + $reviews => ['stale' => ['authorId' => 'a2', 'stars' => 9]], + ], + ]; + + foreach ($rows as $owner => $documentsByCollection) { + $database->setTenant($owner); + foreach ($documentsByCollection as $collection => $documents) { + foreach ($documents as $id => $attributes) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } + } + } + + $database->setTenant($tenantless); + $database->getAuthorization()->skip(function () use ($database, $rows, $tenantless): void { + foreach ($rows[$tenantless] as $collection => $documents) { + $database->from($collection) + ->set([Document::TENANT => null]) + ->filter([Query::equal(Document::ID, \array_keys($documents)), Query::equal(Document::TENANT, [$tenantless])]) + ->update() + ->execute(); + } + }); + } + + private function joinTenancyJoin(Method $method, string $collection, string $alias): Query + { + return match ($method) { + Method::Join => Query::join($collection, $alias, [Query::on('$id', 'authorId')]), + Method::LeftJoin => Query::leftJoin($collection, $alias, [Query::on('$id', 'authorId')]), + Method::RightJoin => Query::rightJoin($collection, $alias, [Query::on('$id', 'authorId')]), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $alias, [Query::on('$id', 'authorId')]), + Method::CrossJoin => Query::crossJoin($collection, $alias), + default => throw new \InvalidArgumentException("{$method->value} is not a join"), + }; + } + + /** + * @param array $documents + * @param list $numbers + * @return list> + */ + private function joinTenancyRows(array $documents, array $numbers): array + { + return $this->joinTenancySorted(\array_map(static function (Document $document) use ($numbers): array { + $name = $document->getAttribute('name'); + $row = [\is_string($name) && $name !== '' ? $name : null]; + foreach ($numbers as $number) { + $value = $document->getAttribute($number); + $row[] = \is_numeric($value) ? (int) $value : null; + } + + return $row; + }, $documents)); + } + + /** + * @param array> $rows + * @return list> + */ + private function joinTenancySorted(array $rows): array + { + \usort($rows, static fn (array $left, array $right): int => \json_encode($left) <=> \json_encode($right)); + + return $rows; + } + + /** + * Chains of joins over collections read per document return what the same joins return over + * the documents direct reads return: an unreadable document neither hides a row an outer join + * keeps nor pairs with it, so a review of an unreadable author comes back like a review of an + * author that does not exist. + */ + public function testJoinChainsReadWhatDirectReadsAllow(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $secured = ['jcv_authors', 'jcv_books', 'jcv_reviews', 'jcv_extras']; + $direct = ['jcvd_authors', 'jcvd_books', 'jcvd_reviews', 'jcvd_extras']; + + $chains = [ + 'a right join' => [[Method::RightJoin, 1, '$id']], + 'a full outer join' => [[Method::FullOuterJoin, 1, '$id']], + 'an inner join, then a right join' => [[Method::Join, 1, '$id'], [Method::RightJoin, 2, '$id']], + 'a left join, then a right join' => [[Method::LeftJoin, 1, '$id'], [Method::RightJoin, 2, '$id']], + 'a cross join, then a right join' => [[Method::CrossJoin, 3, ''], [Method::RightJoin, 2, '$id']], + 'a right join, then a right join on it' => [[Method::RightJoin, 1, '$id'], [Method::RightJoin, 2, 'book.authorId']], + 'a full outer join, then a right join' => [[Method::FullOuterJoin, 1, '$id'], [Method::RightJoin, 2, '$id']], + 'a right join, then a full outer join on it' => [[Method::RightJoin, 1, '$id'], [Method::FullOuterJoin, 2, 'book.authorId']], + ]; + + try { + $this->seedJoinChainVisibility($database, $secured, documentSecurity: true); + $this->seedJoinChainVisibility($database, $direct, documentSecurity: false); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $chains, $secured, $direct): void { + foreach ($chains as $label => $chain) { + $this->assertSame( + $this->joinChainVisibilityRead($database, $direct, $chain), + $this->joinChainVisibilityRead($database, $secured, $chain), + "{$label} must return what the same joins return over what direct reads return", + ); + } + }); + } finally { + $this->cleanupAggCollections($database, [...$secured, ...$direct]); + } + } + + /** + * A right join that follows a cross join, or a right join its ON references, must not pair its + * rows with another tenant's rows of the earlier table: they would vanish instead of coming back + * unmatched, and what a tenant reads would depend on another tenant's keys. A full outer join + * combined with a right join reads what the tenant's own database reads too. + */ + public function testSharedTablesJoinChainsKeepRowsOnlyAnotherTenantMatches(): void + { + $database = static::getDatabase(); + if (! $database->hasSharedTables() || ! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collections = ['jtx_authors', 'jtx_books', 'jtx_reviews']; + [$authors, $books, $reviews] = $collections; + $extras = 'jtx_extras'; + $tenant = $database->getTenant(); + + $rowsByChain = [ + 'a cross join, then a right join' => [ + 'joins' => [Query::crossJoin($extras, 'extra'), Query::rightJoin($reviews, 'review', [Query::on('$id', 'authorId')])], + 'numbers' => ['extra.weight', 'review.stars'], + 'rows' => [ + 1 => [[null, null, 5], [null, null, 4], [null, null, 3], [null, null, 2]], + 2 => [['two-shared', 7, 1]], + ], + ], + 'a cross join, then a right join on it' => [ + 'joins' => [Query::crossJoin($extras, 'extra'), Query::rightJoin($reviews, 'review', [Query::on('extra.authorId', 'authorId')])], + 'numbers' => ['extra.weight', 'review.stars'], + 'rows' => [ + 1 => [[null, null, 5], [null, null, 4], [null, null, 3], [null, null, 2]], + 2 => [['two-a1', 7, 1], ['two-a2', 7, 1], ['two-shared', 7, 1]], + ], + ], + 'a right join, then a right join on it' => [ + 'joins' => [Query::rightJoin($books, 'book', [Query::on('$id', 'authorId')]), Query::rightJoin($reviews, 'review', [Query::on('book.authorId', 'authorId')])], + 'numbers' => ['book.pages', 'review.stars'], + 'rows' => [ + 1 => [['one-a1', 11, 5], [null, null, 4], [null, null, 3], [null, 12, 2]], + 2 => [[null, null, 1]], + ], + ], + 'a full outer join, then a right join' => [ + 'joins' => [Query::fullOuterJoin($books, 'book', [Query::on('$id', 'authorId')]), Query::rightJoin($reviews, 'review', [Query::on('$id', 'authorId')])], + 'numbers' => ['book.pages', 'review.stars'], + 'rows' => [ + 1 => [['one-a1', 11, 5], ['one-a2', null, 4], [null, null, 2], [null, null, 3]], + 2 => [['two-shared', null, 1]], + ], + ], + 'a right join, then a full outer join on it' => [ + 'joins' => [Query::rightJoin($books, 'book', [Query::on('$id', 'authorId')]), Query::fullOuterJoin($reviews, 'review', [Query::on('book.authorId', 'authorId')])], + 'numbers' => ['book.pages', 'review.stars'], + 'rows' => [ + 1 => [['one-a1', 11, 5], [null, 12, 2], [null, 13, null], [null, null, 3], [null, null, 4]], + 2 => [['two-a1', 21, null], ['two-a2', 22, null], [null, null, 1]], + ], + ], + ]; + + try { + $this->seedJoinTenancyFixture($database, ...$collections); + $this->seedJoinTenancyExtras($database, $extras); + + foreach ($rowsByChain as $label => ['joins' => $joins, 'numbers' => $numbers, 'rows' => $rowsByTenant]) { + foreach ($rowsByTenant as $selected => $rows) { + $database->setTenant($selected); + + $this->assertSame( + $this->joinTenancySorted($rows), + $this->joinTenancyRows( + $database->find($authors, [...$joins, Query::select(['name', ...$numbers])]), + $numbers, + ), + "Tenant {$selected} must read through {$label} what its own database would return", + ); + $this->assertSame(\count($rows), $database->count($authors, $joins), "Tenant {$selected} must count through {$label} what its own database would count"); + $this->assertSame( + \array_sum(\array_column($rows, 2)), + (int) $database->sum($authors, $numbers[1], $joins), + "Tenant {$selected} must sum through {$label} what its own database would sum", + ); + } + } + } finally { + $database->setTenant(null); + $this->cleanupAggCollections($database, [...$collections, $extras]); + $database->setTenant($tenant); + } + } + + /** + * The documents of testJoinChainsReadWhatDirectReadsAllow: with document security every + * collection shows only the documents the caller holds read on, and the unreadable ones share + * keys with readable ones. Without it, the collections hold exactly the documents a direct read + * of the others returns. + * + * @param array{string, string, string, string} $collections authors, books, reviews, extras + */ + private function seedJoinChainVisibility(Database $database, array $collections, bool $documentSecurity): void + { + [$authors, $books, $reviews, $extras] = $collections; + $this->cleanupAggCollections($database, $collections); + + $permissions = $documentSecurity + ? [Permission::create(Role::any())] + : [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $authors, permissions: $permissions, documentSecurity: $documentSecurity)); + $database->createAttribute($authors, Attribute::string(key: 'name', size: 64, required: true)); + foreach ([$books => 'pages', $reviews => 'stars', $extras => 'weight'] as $collection => $number) { + $database->createCollection(Collection::create(id: $collection, permissions: $permissions, documentSecurity: $documentSecurity)); + $database->createAttribute($collection, Attribute::string(key: 'authorId', size: 64, required: true)); + $database->createAttribute($collection, Attribute::integer(key: $number, required: true)); + } + + $documents = [ + $authors => [ + 'a1' => [['name' => 'a1'], true], + 'a2' => [['name' => 'a2'], true], + 'hidden' => [['name' => 'hidden'], false], + ], + $books => [ + 'b1' => [['authorId' => 'a1', 'pages' => 1], true], + 'b2' => [['authorId' => 'a2', 'pages' => 2], false], + 'b3' => [['authorId' => 'hidden', 'pages' => 3], true], + 'b4' => [['authorId' => 'ghost', 'pages' => 4], true], + 'b5' => [['authorId' => 'a1', 'pages' => 5], false], + ], + $reviews => [ + 'r1' => [['authorId' => 'a1', 'stars' => 10], true], + 'r2' => [['authorId' => 'a2', 'stars' => 20], true], + 'r3' => [['authorId' => 'hidden', 'stars' => 30], true], + 'r4' => [['authorId' => 'ghost', 'stars' => 40], true], + 'r5' => [['authorId' => 'a2', 'stars' => 50], false], + ], + $extras => [ + 'x1' => [['authorId' => 'a1', 'weight' => 100], false], + ], + ]; + + foreach ($documents as $collection => $rows) { + foreach ($rows as $id => [$attributes, $readable]) { + if (! $readable && ! $documentSecurity) { + continue; + } + + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [$readable ? Permission::read(Role::any()) : Permission::read(Role::user('someone-else'))], + ...$attributes, + ])); + } + } + } + + /** + * @param array{string, string, string, string} $collections authors, books, reviews, extras + * @param list $chain Each join's method, collection index and ON column + * @return array{rows: list>, count: int, sum: int|float}|string + */ + private function joinChainVisibilityRead(Database $database, array $collections, array $chain): array|string + { + $numbers = []; + $joins = []; + foreach ($chain as [$method, $collection, $on]) { + [$alias, $number] = [1 => ['book', 'pages'], 2 => ['review', 'stars'], 3 => ['extra', 'weight']][$collection]; + $numbers[] = $alias.'.'.$number; + $joins[] = match ($method) { + Method::Join => Query::join($collections[$collection], $alias, [Query::on($on, 'authorId')]), + Method::LeftJoin => Query::leftJoin($collections[$collection], $alias, [Query::on($on, 'authorId')]), + Method::RightJoin => Query::rightJoin($collections[$collection], $alias, [Query::on($on, 'authorId')]), + Method::FullOuterJoin => Query::fullOuterJoin($collections[$collection], $alias, [Query::on($on, 'authorId')]), + Method::CrossJoin => Query::crossJoin($collections[$collection], $alias), + default => throw new \InvalidArgumentException("{$method->value} is not a join"), + }; + } + + try { + return [ + 'rows' => $this->joinTenancyRows( + $database->find($collections[0], [...$joins, Query::select(['name', ...$numbers]), Query::limit(100)]), + $numbers, + ), + 'count' => $database->count($collections[0], $joins), + 'sum' => $database->sum($collections[0], $numbers[\count($numbers) - 1], $joins), + ]; + } catch (QueryException) { + return 'rejected'; + } + } + + /** + * Extras only tenant 2 and a tenantless row have, so tenant 1's cross join with them is empty. + */ + private function seedJoinTenancyExtras(Database $database, string $extras): void + { + $database->setTenant(null); + $database->createCollection(Collection::create( + id: $extras, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + $database->createAttribute($extras, Attribute::string(key: 'authorId', size: 64, required: true)); + $database->createAttribute($extras, Attribute::integer(key: 'weight', required: true)); + + $tenantless = 3; + foreach ([2 => ['x1', 'shared', 7], $tenantless => ['x9', 'a2', 9]] as $owner => [$id, $authorId, $weight]) { + $database->setTenant($owner); + $database->createDocument($extras, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'authorId' => $authorId, + 'weight' => $weight, + ])); + } + + $database->getAuthorization()->skip(function () use ($database, $extras, $tenantless): void { + $database->from($extras) + ->set([Document::TENANT => null]) + ->filter([Query::equal(Document::ID, ['x9']), Query::equal(Document::TENANT, [$tenantless])]) + ->update() + ->execute(); + }); + } + + /** + * The builder declares a join alias quoted, so the tenant and permission conditions added for + * it must name it quoted too: PostgreSQL folds an unquoted mixed-case alias to lower case and + * then finds no table by that name. + */ + public function testMixedCaseJoinAliasesReadWhatLowerCaseAliasesRead(): void + { + $this->assertJoinAliasesReadWhatLowerCaseAliasesRead(['jam_authors', 'jam_books', 'jam_reviews', 'jam_extras'], [1 => 'Book', 2 => 'Review', 3 => 'Extra']); + } + + /** + * A reserved word is a valid join alias once quoted, as the builder declares it, but not where + * a tenant or permission condition names it unquoted. + */ + public function testReservedWordJoinAliasesReadWhatLowerCaseAliasesRead(): void + { + $this->assertJoinAliasesReadWhatLowerCaseAliasesRead(['jar_authors', 'jar_books', 'jar_reviews', 'jar_extras'], [1 => 'order', 2 => 'group', 3 => 'select']); + } + + /** + * Every join type and the chains whose later right join repeats earlier tables' conditions, + * over collections read per document, under the adapter's tenancy. + * + * @param array{string, string, string, string} $collections authors, books, reviews, extras + * @param array{1: string, 2: string, 3: string} $aliases The alias of books, reviews and extras + */ + private function assertJoinAliasesReadWhatLowerCaseAliasesRead(array $collections, array $aliases): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $chains = [ + 'an inner join' => [[Method::Join, 1, null]], + 'a left join' => [[Method::LeftJoin, 1, null]], + 'a right join' => [[Method::RightJoin, 1, null]], + 'a full outer join' => [[Method::FullOuterJoin, 1, null]], + 'a cross join' => [[Method::CrossJoin, 1, null]], + 'an inner join, then a right join' => [[Method::Join, 1, null], [Method::RightJoin, 2, null]], + 'a cross join, then a right join' => [[Method::CrossJoin, 3, null], [Method::RightJoin, 2, null]], + 'a right join, then a right join on it' => [[Method::RightJoin, 1, null], [Method::RightJoin, 2, 1]], + ]; + + try { + $this->seedJoinChainVisibility($database, $collections, documentSecurity: true); + + $this->withAuthorizationRoles($database, [Role::any()->toString()], function () use ($database, $collections, $aliases, $chains): void { + foreach ($chains as $label => $chain) { + $expected = $this->joinAliasRead($database, $collections, $chain, [1 => 'book', 2 => 'review', 3 => 'extra']); + + $this->assertNotSame([], $expected['rows'], "{$label} must return rows for the comparison to mean anything"); + $this->assertSame( + $expected, + $this->joinAliasRead($database, $collections, $chain, $aliases), + "{$label} aliased ".\implode(', ', $aliases).' must read what it reads with lower-case aliases', + ); + } + }); + } finally { + $this->cleanupAggCollections($database, $collections); + } + } + + /** + * @param array{string, string, string, string} $collections authors, books, reviews, extras + * @param list $chain Each join's method, the index of the collection it joins, and the + * index of the joined collection its ON names, or null for the main one + * @param array{1: string, 2: string, 3: string} $aliases The alias of books, reviews and extras + * @return array{rows: list>, count: int, sum: int|float, document: list>} + */ + private function joinAliasRead(Database $database, array $collections, array $chain, array $aliases): array + { + $numbers = []; + $joins = []; + foreach ($chain as [$method, $collection, $on]) { + $alias = $aliases[$collection]; + $numbers[] = $alias.'.'.[1 => 'pages', 2 => 'stars', 3 => 'weight'][$collection]; + $left = $on === null ? '$id' : $aliases[$on].'.authorId'; + $joins[] = match ($method) { + Method::Join => Query::join($collections[$collection], $alias, [Query::on($left, 'authorId')]), + Method::LeftJoin => Query::leftJoin($collections[$collection], $alias, [Query::on($left, 'authorId')]), + Method::RightJoin => Query::rightJoin($collections[$collection], $alias, [Query::on($left, 'authorId')]), + Method::FullOuterJoin => Query::fullOuterJoin($collections[$collection], $alias, [Query::on($left, 'authorId')]), + Method::CrossJoin => Query::crossJoin($collections[$collection], $alias), + default => throw new \InvalidArgumentException("{$method->value} is not a join"), + }; + } + $selection = Query::select(['name', ...$numbers]); + + return [ + 'rows' => $this->joinTenancyRows($database->find($collections[0], [...$joins, $selection, Query::limit(100)]), $numbers), + 'count' => $database->count($collections[0], $joins), + 'sum' => $database->sum($collections[0], $numbers[\count($numbers) - 1], $joins), + 'document' => $this->joinTenancyRows([$database->getDocument($collections[0], 'a1', [...$joins, $selection])], $numbers), + ]; + } + + public function testAttributeNamedLikeAFullOuterJoinOrderColumnIsRead(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $joined] = $this->createOrderColumnCollections($database); + $note = 'foj_ord_note'; + + $this->assertSame('first', $database->getDocument($main, 'm1')->getAttribute($note), 'getDocument'); + $this->assertSame('first', $database->getDocument($main, 'm1', [Query::select([$note])])->getAttribute($note), 'getDocument with a select'); + $this->assertSame(['first', 'second'], $this->orderColumnValues($database->find($main), $note), 'find'); + $this->assertSame(['first', 'second'], $this->orderColumnValues($database->getAuthorization()->skip(fn (): array => $database->find($main)), $note), 'find without authorization'); + $this->assertSame(['second'], $this->orderColumnValues($database->find($main, [Query::equal($note, ['second'])]), $note), 'find filtered by the attribute'); + $this->assertSame(['second', 'first'], $this->orderColumnValues($database->find($main, [Query::orderDesc($note)]), $note), 'find ordered by the attribute'); + + $this->assertOrderColumnFullOuterJoin($database, $main, $joined, 'j', [ + 'ascending' => [Query::orderAsc($note), $note, ['first', 'second']], + 'descending' => [Query::orderDesc($note), $note, ['second', 'first']], + ]); + + $this->cleanupAggCollections($database, $this->orderColumnCollections()); + } + + public function testJoinAliasNamedLikeAFullOuterJoinOrderColumnReturnsItsColumns(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$main, $joined] = $this->createOrderColumnCollections($database); + $alias = 'foj_ord_x'; + + $this->assertSame( + [['m1', 'first', 'j1', 1]], + $this->orderColumnSummaries($database->find($main, [Query::join($joined, $alias, [Query::on('link', 'link')])]), $alias), + 'find', + ); + $this->assertSame( + [['m1', 'first', 'j1', 1]], + $this->orderColumnSummaries([$database->getDocument($main, 'm1', [Query::leftJoin($joined, $alias, [Query::on('link', 'link')])])], $alias), + 'getDocument', + ); + + $this->assertOrderColumnFullOuterJoin($database, $main, $joined, $alias, [ + 'ascending' => [Query::orderAsc("{$alias}.score"), "{$alias}.score", [1, 3]], + 'descending' => [Query::orderDesc("{$alias}.score"), "{$alias}.score", [3, 1]], + ]); + + $this->cleanupAggCollections($database, $this->orderColumnCollections()); + } + + /** + * Every order returns each row of the full outer join once with its values, orders the rows that + * hold a value (engines place nulls apart), and gives each row the columns a left join gives it. + * + * @param array}> $orders + */ + private function assertOrderColumnFullOuterJoin(Database $database, string $main, string $joined, string $alias, array $orders): void + { + $leftJoined = $database->find($main, [Query::leftJoin($joined, $alias, [Query::on('link', 'link')])]); + $this->assertCount(2, $leftJoined); + $columns = $this->orderColumnKeys($leftJoined[0]); + + foreach ($orders as $label => [$order, $key, $ordered]) { + $rows = $database->find($main, [Query::fullOuterJoin($joined, $alias, [Query::on('link', 'link')]), $order]); + + $summaries = $this->orderColumnSummaries($rows, $alias); + \usort($summaries, static fn (array $left, array $right): int => \strcmp((string) \json_encode($left), (string) \json_encode($right))); + $this->assertSame([['', null, 'j2', 3], ['m1', 'first', 'j1', 1], ['m2', 'second', null, null]], $summaries, $label); + + $values = \array_map(static fn (mixed $value): mixed => \is_numeric($value) ? (int) $value : $value, $this->orderColumnValues($rows, $key)); + $this->assertSame($ordered, \array_values(\array_filter($values, static fn (mixed $value): bool => $value !== null)), $label); + + foreach ($rows as $row) { + $this->assertSame($columns, $this->orderColumnKeys($row), $label); + } + } + } + + /** + * @param array $rows + * @return list + */ + private function orderColumnValues(array $rows, string $key): array + { + return \array_values(\array_map(static fn (Document $row): mixed => $row->getAttribute($key), $rows)); + } + + /** + * @param array $rows + * @return list + */ + private function orderColumnSummaries(array $rows, string $alias): array + { + return \array_values(\array_map( + fn (Document $row): array => [ + $row->getId(), + $row->getAttribute('foj_ord_note'), + $row->getAttribute("{$alias}.\$id"), + $this->scoreOf($row, "{$alias}.score"), + ], + $rows, + )); + } + + /** + * @return list + */ + private function orderColumnKeys(Document $row): array + { + $keys = \array_map(\strval(...), \array_keys($row->getArrayCopy())); + \sort($keys); + + return $keys; + } + + /** + * @return list + */ + private function orderColumnCollections(): array + { + return ['fojo_main', 'fojo_joined']; + } + + /** + * m1 matches j1 and nothing matches m2 or j2, so a full outer join returns a row of each kind, and + * an emulated one returns rows from both of its halves. + * + * @return list + */ + private function createOrderColumnCollections(Database $database): array + { + $collections = $this->orderColumnCollections(); + [$main, $joined] = $collections; + $this->cleanupAggCollections($database, $collections); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: $main, + attributes: [ + Attribute::string(key: 'link', size: 16, required: true), + Attribute::string(key: 'foj_ord_note', size: 64, required: false), + ], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: $joined, + attributes: [ + Attribute::string(key: 'link', size: 16, required: true), + Attribute::integer(key: 'score', required: true), + ], + permissions: $permissions, + documentSecurity: false, + )); + + foreach (['m1' => ['1', 'first'], 'm2' => ['2', 'second']] as $id => [$link, $note]) { + $database->createDocument($main, new Document(['$id' => $id, 'link' => $link, 'foj_ord_note' => $note])); + } + foreach (['j1' => ['1', 1], 'j2' => ['3', 3]] as $id => [$link, $score]) { + $database->createDocument($joined, new Document(['$id' => $id, 'link' => $link, 'score' => $score])); + } + + return $collections; + } + + public function testJoinColumnTheJoinedCollectionDoesNotDeclareIsAnInvalidQuery(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders] = $collections = $this->seedJoinedAttributeCollections($database, 'jcnd'); + $join = Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]); + + $reads = [ + 'filter' => fn () => $database->find($customers, [$join, Query::equal('purchase.nothing', ['x'])]), + 'select' => fn () => $database->find($customers, [$join, Query::select(['name', 'purchase.nothing'])]), + 'order' => fn () => $database->find($customers, [$join, Query::orderAsc('purchase.nothing')]), + 'count()' => fn () => $database->count($customers, [$join, Query::equal('purchase.nothing', ['x'])]), + 'sum()' => fn () => $database->sum($customers, 'purchase.amount', [$join, Query::equal('purchase.nothing', ['x'])]), + ]; + if ($database->getAdapter()->supports(Capability::Aggregations)) { + $reads['aggregate'] = fn () => $database->aggregate($customers, [$join, Query::countDistinct('purchase.nothing', 'total')]); + $reads['groupBy'] = fn () => $database->aggregate($customers, [$join, Query::count('*', 'rows'), Query::groupBy(['purchase.nothing'])]); + } + + foreach ($reads as $type => $read) { + try { + $read(); + $this->fail('A '.$type.' on a column the joined collection does not declare reached the engine'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute not found in schema: purchase.nothing', $error->getMessage(), $type); + } + } + + try { + $database->find($customers, [$join, Query::equal('purchase.$permissions', ['read("any")'])]); + $this->fail('A filter on joined permissions was accepted although a filter on the main permissions is not'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute not found in schema: purchase.$permissions', $error->getMessage()); + } + + $results = $database->find($customers, [ + $join, + Query::equal('purchase.$id', ['paid', 'open']), + Query::between('purchase.$createdAt', '1970-01-01', '2099-12-31'), + Query::between('purchase.amount', 10, 500), + Query::select(['name', 'purchase.$id', 'purchase.$permissions', 'purchase.$createdAt', 'purchase.$sequence']), + Query::orderAsc('purchase.amount'), + ]); + $this->assertSame(['open', 'paid'], \array_map(static fn (Document $document): mixed => $document->getAttribute('purchase.$id'), $results)); + + $this->cleanupAggCollections($database, $collections); + } + + public function testGetDocumentJoinConditionIsValidatedAsAListingValidatesIt(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, , , $notes] = $collections = $this->seedJoinedAttributeCollections($database, 'jgdc'); + + foreach ([ + 'join condition' => [Query::leftJoin($notes, 'note', [Query::on('$id', 'customerId'), Query::equal('note.nothing', ['x'])])], + 'select' => [Query::leftJoin($notes, 'note', [Query::on('$id', 'customerId')]), Query::select(['name', 'note.nothing'])], + ] as $type => $queries) { + try { + $database->getDocument($customers, 'first', $queries); + $this->fail('getDocument() sent a '.$type.' on a column the joined collection does not declare to the engine'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute not found in schema: note.nothing', $error->getMessage(), $type); + } + } + + try { + $database->getDocument($customers, 'first', [ + Query::leftJoin($notes, 'note', [Query::on('$id', 'customerId')]), + Query::equal('name', ['First']), + ]); + $this->fail('getDocument() accepted a filter outside a join condition'); + } catch (QueryException $error) { + $this->assertSame('Invalid query method: equal', $error->getMessage()); + } + + $document = $database->getDocument($customers, 'first', [ + Query::leftJoin($notes, 'note', [Query::on('$id', 'customerId'), Query::equal('note.body', ['a needle in a haystack'])]), + ]); + $this->assertSame('first', $document->getId()); + + $this->cleanupAggCollections($database, $collections); + } + + public function testJoinArithmeticAggregateOfAJoinedStringIsAnInvalidQuery(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins) || ! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders, , $notes] = $collections = $this->seedJoinedAttributeCollections($database, 'jnsa'); + $joins = [ + Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]), + Query::join($notes, 'note', [Query::on('$id', 'customerId')]), + ]; + + foreach ([ + 'Aggregate sum requires a numeric attribute that is not an array: purchase.status' => Query::sum('purchase.status', 'total'), + 'Aggregate sum requires a numeric attribute that is not an array: status' => Query::sum('status', 'total'), + 'Aggregate avg requires a numeric attribute that is not an array: note.body' => Query::avg('note.body', 'average'), + 'Aggregate stddev requires a numeric attribute that is not an array: purchase.memo' => Query::stddev('purchase.memo', 'spread'), + 'Aggregate bitAnd requires a numeric attribute that is not an array: purchase.$createdAt' => Query::bitAnd('purchase.$createdAt', 'bits'), + ] as $message => $aggregate) { + try { + $database->aggregate($customers, [...$joins, $aggregate]); + $this->fail('An aggregate over a joined attribute that holds no number reached the engine: '.$message); + } catch (QueryException $error) { + $this->assertSame('Invalid query: '.$message, $error->getMessage()); + } + } + + $results = $database->aggregate($customers, [...$joins, Query::sum('purchase.amount', 'total')]); + $this->assertCount(1, $results); + $this->assertSame(150, $this->intAttribute($results[0], 'total')); + + $this->cleanupAggCollections($database, $collections); + } + + public function testJoinedInternalAttributesGroupTheJoinedRows(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins) || ! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders] = $collections = $this->seedJoinedAttributeCollections($database, 'jiag'); + + foreach ([ + 'inner join' => Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]), + 'left join' => Query::leftJoin($orders, 'purchase', [Query::on('$id', 'customerId')]), + 'full outer join' => Query::fullOuterJoin($orders, 'purchase', [Query::on('$id', 'customerId')]), + ] as $type => $join) { + foreach ([Document::ID, Document::SEQUENCE, Document::CREATED_AT, Document::UPDATED_AT, Document::PERMISSIONS] as $attribute) { + $total = 0; + foreach ($database->aggregate($customers, [$join, Query::count('*', 'rows'), Query::groupBy(['purchase.'.$attribute])]) as $group) { + $this->assertArrayHasKey(Storage::column($attribute), $group, $type.' grouped by purchase.'.$attribute); + $total += $this->intAttribute($group, 'rows'); + } + $this->assertSame(3, $total, $type.' grouped by purchase.'.$attribute); + } + } + + $groups = $database->aggregate($customers, [ + Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('purchase.amount', 'total'), + Query::groupBy(['purchase.$id']), + Query::orderAsc('purchase.$id'), + ]); + $this->assertSame(['open', 'other', 'paid'], \array_map(static fn (array $group): mixed => $group[Storage::UID], $groups)); + $this->assertSame([50, 7, 100], \array_map(fn (array $group): int => $this->intAttribute($group, 'total'), $groups)); + + $this->cleanupAggCollections($database, $collections); + } + + public function testJoinConditionNamingNoColumnIsAnInvalidQuery(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders, , $notes] = $collections = $this->seedJoinedAttributeCollections($database, 'jcnc'); + $purchase = Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]); + $notFound = 'Invalid query: Attribute not found in schema: '; + + foreach ([ + 'an unknown right column' => [[Query::join($orders, 'purchase', [Query::on('$id', 'nothing')])], $notFound.'nothing'], + 'an unknown left column' => [[Query::join($orders, 'purchase', [Query::on('nothing', 'customerId')])], $notFound.'nothing'], + 'an unknown right column of an on condition' => [[Query::leftJoin($notes, 'note', [Query::on('$id', 'nothing')])], $notFound.'nothing'], + 'an unknown left column of an on condition' => [[Query::leftJoin($notes, 'note', [Query::on('nothing', 'customerId')])], $notFound.'nothing'], + 'an unknown column of an earlier join' => [[$purchase, Query::join($notes, 'note', [Query::on('purchase.nothing', 'customerId')])], $notFound.'purchase.nothing'], + 'a join declared after it' => [ + [Query::join($notes, 'note', [Query::on('purchase.customerId', 'customerId')]), $purchase], + 'Invalid query: The left column of a join condition must belong to the main collection or to a join declared before it: purchase.customerId', + ], + ] as $shape => [$joins, $message]) { + foreach ([ + 'find()' => fn () => $database->find($customers, $joins), + 'count()' => fn () => $database->count($customers, $joins), + 'sum()' => fn () => $database->sum($customers, '$sequence', $joins), + 'getDocument()' => fn () => $database->getDocument($customers, 'first', $joins), + ] as $read => $call) { + try { + $call(); + $this->fail($read.' sent a join condition that names no column to the engine: '.$shape); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage(), $read.': '.$shape); + } + } + } + + $this->assertSame(3, $database->count($customers, [Query::leftJoin($orders, 'purchase', [Query::on('$id', 'purchase.customerId')])])); + $this->assertSame(2, $database->count($customers, [$purchase, Query::join($notes, 'note', [Query::on('purchase.customerId', 'customerId')])]), 'a join names the columns of the join before it'); + + $this->cleanupAggCollections($database, $collections); + } + + public function testSumRejectsAnAttributeASumAggregateRejects(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + return; + } + + $joins = $database->getAdapter()->supports(Capability::Joins); + [$customers, $orders] = $collections = $this->seedJoinedAttributeCollections($database, 'jsum'); + $purchase = Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]); + $notFound = 'Invalid query: Attribute not found in schema: '; + $numeric = 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: '; + + $rejected = [ + 'an unknown attribute' => [$customers, 'nothing', [], $notFound.'nothing'], + 'a string' => [$orders, 'status', [], $numeric.'status'], + 'an internal attribute' => [$orders, '$sequence', [], $numeric.'$sequence'], + ]; + if ($joins) { + $rejected += [ + 'a joined string' => [$customers, 'purchase.status', [$purchase], $numeric.'purchase.status'], + 'an unknown joined attribute' => [$customers, 'purchase.nothing', [$purchase], $notFound.'purchase.nothing'], + 'a string only a join declares, unqualified' => [$customers, 'status', [$purchase], $numeric.'status'], + ]; + } + + foreach ($rejected as $shape => [$collection, $attribute, $queries, $message]) { + try { + $database->sum($collection, $attribute, $queries); + $this->fail('sum() added up '.$shape); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage(), $shape); + } + } + + $this->assertSame(157, $database->sum($orders, 'amount')); + if ($joins) { + $this->assertSame(157, $database->sum($customers, 'purchase.amount', [$purchase])); + $this->assertSame(157, $database->sum($customers, 'amount', [$purchase])); + } + + $this->cleanupAggCollections($database, $collections); + } + + public function testTenantIsReadOnlyWhereTheTablesHoldIt(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins) || ! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders] = $collections = $this->seedJoinedAttributeCollections($database, 'jten'); + $purchase = Query::join($orders, 'purchase', [Query::on('$id', 'customerId')]); + + foreach ([ + 'count' => [Query::count('$collection', 'total')], + 'groupBy' => [Query::count('*', 'rows'), Query::groupBy(['$collection'])], + ] as $shape => $queries) { + try { + $database->aggregate($customers, $queries); + $this->fail('a '.$shape.' of $collection, which no table holds, reached the engine'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute not found in schema: $collection', $error->getMessage(), $shape); + } + } + + $reads = [ + '$tenant' => [ + 'count' => fn (): array => $database->aggregate($customers, [Query::count('$tenant', 'total')]), + 'groupBy' => fn (): array => $database->aggregate($customers, [Query::count('*', 'total'), Query::groupBy(['$tenant'])]), + ], + 'purchase.$tenant' => [ + 'count' => fn (): array => $database->aggregate($customers, [$purchase, Query::count('purchase.$tenant', 'total')]), + 'groupBy' => fn (): array => $database->aggregate($customers, [$purchase, Query::count('*', 'total'), Query::groupBy(['purchase.$tenant'])]), + ], + ]; + + if (! $database->hasSharedTables()) { + foreach ($reads as $attribute => $shapes) { + foreach ($shapes as $shape => $read) { + try { + $read(); + $this->fail('a '.$shape.' of '.$attribute.' reached a table that does not hold it'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute not found in schema: '.$attribute, $error->getMessage(), $shape); + } + } + } + + try { + $database->find($customers, [$purchase, Query::select(['name', 'purchase.$tenant'])]); + $this->fail('a select of purchase.$tenant reached a table that does not hold it'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Attribute not found in schema: purchase.$tenant', $error->getMessage()); + } + + $this->cleanupAggCollections($database, $collections); + + return; + } + + $tenant = (string) $database->getTenant(); + foreach (['$tenant' => 2, 'purchase.$tenant' => 3] as $attribute => $rows) { + $counted = $reads[$attribute]['count'](); + $this->assertCount(1, $counted, $attribute); + $this->assertSame($rows, $this->intAttribute($counted[0], 'total'), $attribute); + + $grouped = $reads[$attribute]['groupBy'](); + $this->assertCount(1, $grouped, $attribute); + $this->assertSame($rows, $this->intAttribute($grouped[0], 'total'), $attribute); + $value = $grouped[0][Storage::TENANT]; + $this->assertIsScalar($value, $attribute); + $this->assertSame($tenant, (string) $value, $attribute); + } + + foreach ($database->find($customers, [$purchase, Query::select(['name', 'purchase.$tenant'])]) as $customer) { + $value = $customer->getAttribute('purchase.$tenant'); + $this->assertIsScalar($value); + $this->assertSame($tenant, (string) $value); + } + + $this->cleanupAggCollections($database, $collections); + } + + public function testSqliteJoinPlansSearchAnIndexPerAlias(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'sqlite_join_plans'; + $this->cleanupAggCollections($database, [$collection]); + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 64, required: true)); + + $documents = []; + for ($position = 0; $position < 50; $position++) { + $documents[] = new Document(['$id' => 'doc'.$position, 'name' => 'name'.$position]); + } + $database->createDocuments($collection, $documents); + + $table = '`'.$database->getNamespace().'_'.$collection.'`'; + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + + try { + for ($joins = 1; $joins <= 4; $joins++) { + $queries = []; + for ($join = 1; $join <= $joins; $join++) { + $queries[] = Query::join($collection, 'p'.$join, [Query::on('$id', '$id')]); + } + + $profiler->reset(); + $this->assertCount(25, $database->find($collection, $queries), $joins.' self-joins'); + + $plans = 0; + foreach ($profiler->getLogs() as $log) { + if (! \str_contains($log->query, 'SELECT') || ! \str_contains($log->query, $table.' AS `p1`')) { + continue; + } + $plans++; + + $details = \array_map( + static function (Document $row): string { + $detail = $row->getAttribute('detail'); + self::assertIsString($detail); + + return $detail; + }, + $adapter->rawQuery('EXPLAIN QUERY PLAN '.$log->query), + ); + $report = $log->query."\n ".\implode("\n ", $details); + + for ($join = 1; $join <= $joins; $join++) { + $lookups = \array_filter($details, static fn (string $detail): bool => \str_starts_with($detail, 'SEARCH p'.$join.' ')); + $this->assertCount(1, $lookups, 'Alias p'.$join.' must be searched through an index: '.$report); + $this->assertStringContainsString('_uid=?', (string) \current($lookups), 'Alias p'.$join.' must be looked up by id: '.$report); + } + foreach ($details as $detail) { + $this->assertStringNotContainsString('AUTOMATIC', $detail, $report); + $this->assertDoesNotMatchRegularExpression('/^SCAN p\d+\b/', $detail, $report); + } + } + $this->assertSame(1, $plans, $joins.' self-joins must read the collection in one statement'); + } + } finally { + $database->setProfiling(false); + $this->cleanupAggCollections($database, [$collection]); + } + } + + public function testSqliteJoinedSearchUsesTheJoinedFulltextIndex(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter() instanceof SQLite) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authors = 'sqlite_search_authors'; + $posts = 'sqlite_search_posts'; + $collections = [$authors, $posts]; + $this->cleanupAggCollections($database, $collections); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $authors, permissions: $permissions)); + $database->createAttribute($authors, Attribute::string(key: 'name', size: 64, required: true)); + $database->createCollection(Collection::create(id: $posts, permissions: $permissions)); + $database->createAttribute($posts, Attribute::string(key: 'authorId', size: 64, required: true)); + $database->createAttribute($posts, Attribute::string(key: 'body', size: 256, required: true)); + $database->createIndex($posts, Index::fulltext(key: 'body_fulltext', attributes: ['body'])); + + $bodies = [ + 'brown' => 'the quick brown fox', + 'lazy' => 'a lazy dog sleeps', + 'foxes' => 'foxes run at night', + 'phrase' => 'quick fox', + ]; + foreach ($bodies as $author => $body) { + $database->createDocument($authors, new Document(['$id' => $author, 'name' => $author])); + $database->createDocument($posts, new Document(['$id' => 'post_'.$author, 'authorId' => $author, 'body' => $body])); + } + + $join = Query::join($posts, 'post', [Query::on('$id', 'authorId')]); + $sorted = static function (array $ids): array { + /** @var array $ids */ + \sort($ids); + + return $ids; + }; + + foreach (['quick fox', '"quick fox"', 'lazy'] as $term) { + $matching = $sorted(\array_map( + static function (Document $post): string { + $author = $post->getAttribute('authorId'); + self::assertIsString($author); + + return $author; + }, + $database->find($posts, [Query::search('body', $term)]), + )); + $this->assertNotSame([], $matching, $term); + + $found = $sorted(\array_map( + static fn (Document $author): string => $author->getId(), + $database->find($authors, [$join, Query::search('post.body', $term)]), + )); + $this->assertSame($matching, $found, $term); + $this->assertSame(\count($matching), $database->count($authors, [$join, Query::search('post.body', $term)]), $term); + + $complement = $sorted(\array_values(\array_diff(\array_keys($bodies), $matching))); + $found = $sorted(\array_map( + static fn (Document $author): string => $author->getId(), + $database->find($authors, [$join, Query::notSearch('post.body', $term)]), + )); + $this->assertSame($complement, $found, $term); + } + + $this->assertSame(['brown', 'foxes', 'phrase'], $sorted(\array_map( + static fn (Document $author): string => $author->getId(), + $database->find($authors, [$join, Query::search('post.body', 'quick fox')]), + ))); + + $this->cleanupAggCollections($database, $collections); + } + + public function testJoinedFiltersMatchWhatTheJoinedCollectionMatches(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $themes = 'jconv_themes'; + $tickets = 'jconv_tickets'; + $collections = [$themes, $tickets]; + $this->cleanupAggCollections($database, $collections); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $themes, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($themes, Attribute::string(key: 'tags', size: 32, array: true)); + $database->createAttribute($themes, Attribute::datetime(key: 'when')); + $database->createCollection(Collection::create(id: $tickets, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($tickets, Attribute::string(key: 'theme', size: 64)); + $database->createAttribute($tickets, Attribute::integer(key: 'amount')); + + foreach ([ + 't1' => [['banana'], '2024-01-01T09:00:00.000+00:00'], + 't2' => [['a', 'b'], '2024-01-01T07:00:00.000+00:00'], + 't3' => [['b', 'c'], '2024-01-01T11:00:00.000+00:00'], + ] as $id => [$tags, $when]) { + $database->createDocument($themes, new Document(['$id' => $id, 'tags' => $tags, 'when' => $when])); + } + foreach (['k1' => ['t1', 1], 'k2' => ['t2', 10], 'k3' => ['t3', 100], 'k4' => ['missing', 1000]] as $id => [$theme, $amount]) { + $database->createDocument($tickets, new Document(['$id' => $id, 'theme' => $theme, 'amount' => $amount])); + } + + $later = '2024-01-01T10:00:00.000+02:00'; + $filters = [ + 'containsAny' => [Query::containsAny('th.tags', ['a']), Query::containsAny('tags', ['a']), ['k2']], + 'containsAll' => [Query::containsAll('th.tags', ['a', 'b']), Query::containsAll('tags', ['a', 'b']), ['k2']], + 'notContains' => [Query::notContains('th.tags', ['a']), Query::notContains('tags', ['a']), ['k1', 'k3']], + 'greaterThan with an offset' => [Query::greaterThan('th.when', $later), Query::greaterThan('when', $later), ['k1', 'k3']], + 'equal in UTC' => [Query::equal('th.when', ['2024-01-01T09:00:00.000+00:00']), Query::equal('when', ['2024-01-01T09:00:00.000+00:00']), ['k1']], + 'equal with an offset' => [Query::equal('th.when', ['2024-01-01T11:00:00.000+02:00']), Query::equal('when', ['2024-01-01T11:00:00.000+02:00']), ['k1']], + ]; + $amounts = ['k1' => 1, 'k2' => 10, 'k3' => 100, 'k4' => 1000]; + $ids = static function (array $documents): array { + /** @var array $documents */ + $ids = \array_map(static fn (Document $document): string => $document->getId(), $documents); + \sort($ids); + + return $ids; + }; + $themeOf = ['t1' => 'k1', 't2' => 'k2', 't3' => 'k3']; + + foreach ($filters as $name => [$joined, $direct, $expected]) { + $this->assertSame($expected, \array_map( + static fn (string $theme): string => $themeOf[$theme], + $ids($database->find($themes, [$direct])), + ), $name.': the same filter on the joined collection'); + + $join = Query::join($themes, 'th', [Query::on('theme', '$id')]); + $this->assertSame($expected, $ids($database->find($tickets, [$join, $joined])), $name.': find()'); + $this->assertSame(\count($expected), $database->count($tickets, [$join, $joined]), $name.': count()'); + $this->assertEquals( + \array_sum(\array_map(static fn (string $ticket): int => $amounts[$ticket], $expected)), + $database->sum($tickets, 'amount', [$join, $joined]), + $name.': sum()', + ); + + if ($joined->getMethod() !== Method::ContainsAll) { + $onList = Query::join($themes, 'th', [Query::on('theme', '$id'), $joined]); + $this->assertSame($expected, $ids($database->find($tickets, [$onList])), $name.': find() with the filter in the ON list'); + $this->assertSame(\count($expected), $database->count($tickets, [$onList]), $name.': count() with the filter in the ON list'); + } + } + + $grouped = $database->aggregate($tickets, [ + Query::join($themes, 'th', [Query::on('theme', '$id')]), + Query::count('*', 'total'), + Query::groupBy(['th.when']), + Query::having([Query::greaterThan('th.when', $later)]), + ]); + $this->assertCount(2, $grouped, 'having on a joined grouped datetime'); + + $this->cleanupAggCollections($database, $collections); + } + + /** + * @return iterable}> + */ + public static function joinCursorShapes(): iterable + { + $inner = ['a1/n1', 'a1/n2', 'a1/n3', 'a2/n4', 'a2/n6']; + + yield 'inner join' => [Method::Join, $inner]; + yield 'left join' => [Method::LeftJoin, [...$inner, 'a3/-']]; + yield 'right join' => [Method::RightJoin, [...$inner, '-/n5']]; + yield 'full outer join' => [Method::FullOuterJoin, [...$inner, 'a3/-', '-/n5']]; + } + + /** + * @param list $rows + */ + #[DataProvider('joinCursorShapes')] + public function testJoinCursorPagesEveryJoinedRowOnce(Method $join, array $rows): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$authors, $notes] = $this->seedJoinCursorFixture($database); + $joinQuery = new Query($join, $notes, [Query::on('$id', 'author')], 'n'); + + foreach ([ + 'joined ascending' => [Query::orderAsc('n.score')], + 'joined descending' => [Query::orderDesc('n.score')], + 'main attribute' => [Query::orderAsc('score')], + 'default order' => [], + ] as $label => $order) { + $queries = [$joinQuery, ...$order]; + $all = \array_values($database->find($authors, [...$queries, Query::limit(100)])); + $keys = \array_map($this->joinCursorKey(...), $all); + $sorted = $keys; + \sort($sorted); + $expected = $rows; + \sort($expected); + $this->assertSame($expected, $sorted, "{$label}: the unpaged read returns each joined row once"); + + foreach ($all as $index => $row) { + $this->assertSame(\array_slice($keys, $index + 1), $this->joinCursorKeys($database, $authors, [...$queries, Query::cursorAfter($row)]), "{$label}: after {$keys[$index]}"); + $this->assertSame(\array_slice($keys, 0, $index), $this->joinCursorKeys($database, $authors, [...$queries, Query::cursorBefore($row)]), "{$label}: before {$keys[$index]}"); + } + + $forward = []; + $cursor = null; + for ($page = 0; $page <= \count($all); $page++) { + $batch = $database->find($authors, [...$queries, Query::limit(2), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + \array_push($forward, ...\array_map($this->joinCursorKey(...), $batch)); + if (\count($batch) < 2) { + break; + } + $cursor = $batch[1]; + } + $this->assertSame($keys, $forward, "{$label}: paging forward in pages of two"); + + $backward = []; + $cursor = $all[\count($all) - 1]; + for ($page = 0; $page <= \count($all); $page++) { + $batch = $database->find($authors, [...$queries, Query::limit(2), Query::cursorBefore($cursor)]); + $backward = [...\array_map($this->joinCursorKey(...), $batch), ...$backward]; + if (\count($batch) < 2) { + break; + } + $cursor = $batch[0]; + } + $this->assertSame(\array_slice($keys, 0, -1), $backward, "{$label}: paging backward in pages of two from the last row"); + + $this->assertSame([], $database->find($authors, [...$queries, Query::cursorAfter($all[\count($all) - 1])]), "{$label}: after the last row"); + } + + $this->cleanupAggCollections($database, [$authors, $notes]); + } + + public function testJoinCursorRefusesACursorThatDoesNotNameTheRow(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$authors, $notes] = $this->seedJoinCursorFixture($database); + $join = Query::join($notes, 'n', [Query::on('$id', 'author')]); + + $withoutValue = $database->find($authors, [$join, Query::orderAsc('n.score'), Query::limit(1)])[0]; + $withoutValue->removeAttribute('n.score'); + $otherShape = $database->find($authors, [Query::join($notes, 'other', [Query::on('$id', 'author')]), Query::orderAsc('score'), Query::limit(1)])[0]; + + foreach ([ + 'a cursor without its joined order value' => [$withoutValue, [$join, Query::orderAsc('n.score')], 'n.score'], + 'a cursor from another join shape' => [$otherShape, [$join, Query::orderAsc('score')], 'n.$id'], + 'a document read without the join' => [$database->getDocument($authors, 'a1'), [$join, Query::orderAsc('score')], 'n.$id'], + ] as $label => [$cursor, $queries, $missing]) { + try { + $database->find($authors, [...$queries, Query::cursorAfter($cursor)]); + $this->fail("{$label} is refused"); + } catch (OrderException $exception) { + $this->assertSame($missing, $exception->getAttribute(), $label); + $this->assertStringContainsString("Cursor has no value for order attribute '{$missing}'", $exception->getMessage(), $label); + } + } + + $this->cleanupAggCollections($database, [$authors, $notes]); + } + + public function testJoinCursorPagesADistinctReadByItsOrderValues(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins) || ! $database->getAdapter()->supports(Capability::Aggregations)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$authors, $notes] = $this->seedJoinCursorFixture($database); + + foreach ([ + 'distinct read' => [$notes, [Query::distinct(), Query::select(['label']), Query::orderAsc('label')], 'label', ['x', 'y', 'z']], + 'distinct read over a join' => [$authors, [Query::join($notes, 'n', [Query::on('$id', 'author')]), Query::distinct(), Query::select(['n.label']), Query::orderDesc('n.label')], 'n.label', ['y', 'x']], + ] as $label => [$collection, $queries, $attribute, $values]) { + $paged = []; + $cursor = null; + for ($page = 0; $page <= \count($values); $page++) { + $rows = $database->find($collection, [...$queries, Query::limit(1), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + if ($rows === []) { + break; + } + $paged[] = $rows[0]->getAttribute($attribute); + $cursor = $rows[0]; + } + $this->assertSame($values, $paged, $label); + } + + $iterated = []; + foreach ($database->cursor($notes, [Query::distinct(), Query::select(['label']), Query::orderAsc('label')], batchSize: 1) as $row) { + $iterated[] = $row->getAttribute('label'); + if (\count($iterated) > 3) { + break; + } + } + $this->assertSame(['x', 'y', 'z'], $iterated); + + $queries = [Query::distinct(), Query::select(['label', 'score']), Query::orderAsc('label')]; + try { + $database->find($notes, [...$queries, Query::cursorAfter($database->find($notes, [...$queries, Query::limit(1)])[0])]); + $this->fail('A distinct read whose order leaves a selected attribute out cannot be paged'); + } catch (QueryException $exception) { + $this->assertStringContainsString("'score' is not ordered", $exception->getMessage()); + } + + $this->cleanupAggCollections($database, [$authors, $notes]); + } + + public function testJoinedGetDocumentPairsTheLowestSequenceJoinedRow(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$authors, $notes] = $this->seedJoinCursorFixture($database); + $drafts = 'jcur_drafts'; + $this->cleanupAggCollections($database, [$drafts]); + $database->createCollection(Collection::create( + id: $drafts, + attributes: [Attribute::string(key: 'author', size: 16), Attribute::string(key: 'label', size: 16)], + indexes: [Index::key('author_label', ['author', 'label'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + foreach (['d-first' => 'z', 'd-second' => 'm', 'd-third' => 'a'] as $id => $label) { + $database->createDocument($drafts, new Document(['$id' => $id, 'author' => 'a1', 'label' => $label, '$permissions' => [Permission::read(Role::any())]])); + } + + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin] as $join) { + $document = $database->getDocument($authors, 'a1', [new Query($join, $drafts, [Query::on('$id', 'author')], 'd')]); + $this->assertSame('d-first', $document->getAttribute('d.$id'), $join->value); + } + + $this->cleanupAggCollections($database, [$authors, $notes, $drafts]); + } + + public function testCursorIterationBuildsEachBatchFromTheCallerQueries(): void + { + $database = static::getDatabase(); + $items = 'jcur_items'; + $this->cleanupAggCollections($database, [$items]); + $database->createCollection(Collection::create( + id: $items, + attributes: [Attribute::string(key: 'name', size: 16)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + for ($number = 1; $number <= 10; $number++) { + $id = \sprintf('i%02d', $number); + $database->createDocument($items, new Document(['$id' => $id, 'name' => $id, '$permissions' => [Permission::read(Role::any())]])); + } + + $all = ['i01', 'i02', 'i03', 'i04', 'i05', 'i06', 'i07', 'i08', 'i09', 'i10']; + foreach ([ + 'an offset applies once' => [[Query::offset(2)], \array_slice($all, 2)], + 'a cursor starts the iteration, which then ends' => [[Query::cursorAfter($database->getDocument($items, 'i04'))], \array_slice($all, 4)], + 'a limit caps the iteration' => [[Query::limit(4)], \array_slice($all, 0, 4)], + 'a limit and an offset' => [[Query::offset(5), Query::limit(4)], \array_slice($all, 5, 4)], + ] as $label => [$queries, $expected]) { + foreach ([1, 3, 100] as $batchSize) { + $ids = []; + foreach ($database->cursor($items, $queries, $batchSize) as $item) { + $ids[] = $item->getId(); + if (\count($ids) > 20) { + break; + } + } + $this->assertSame($expected, $ids, "{$label}, batches of {$batchSize}"); + } + } + + try { + $database->find($items, [Query::cursorAfter(new Document(['$collection' => $items, 'name' => 'i01']))]); + $this->fail('A read without joins still refuses a cursor document without an id'); + } catch (QueryException $exception) { + $this->assertStringContainsString('Invalid cursor', $exception->getMessage()); + } + + $this->cleanupAggCollections($database, [$items]); + } + + /** + * @return array{string, string} + */ + private function seedJoinCursorFixture(Database $database): array + { + $authors = 'jcur_authors'; + $notes = 'jcur_notes'; + $this->cleanupAggCollections($database, [$authors, $notes]); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: $authors, + attributes: [Attribute::string(key: 'name', size: 16), Attribute::integer(key: 'score')], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: $notes, + attributes: [Attribute::string(key: 'author', size: 16), Attribute::integer(key: 'score'), Attribute::string(key: 'label', size: 16)], + permissions: $permissions, + )); + + foreach (['a1' => 1, 'a2' => 2, 'a3' => 3] as $id => $score) { + $database->createDocument($authors, new Document(['$id' => $id, 'name' => $id, 'score' => $score, '$permissions' => [Permission::read(Role::any())]])); + } + foreach ([ + 'n1' => ['a1', 1, 'x'], + 'n2' => ['a1', 1, 'x'], + 'n3' => ['a1', 2, 'y'], + 'n4' => ['a2', 1, 'y'], + 'n5' => ['zz', 9, 'z'], + 'n6' => ['a2', null, 'x'], + ] as $id => [$author, $score, $label]) { + $database->createDocument($notes, new Document(['$id' => $id, 'author' => $author, 'score' => $score, 'label' => $label, '$permissions' => [Permission::read(Role::any())]])); + } + + return [$authors, $notes]; + } + + /** + * @param list $queries + * @return list + */ + private function joinCursorKeys(Database $database, string $collection, array $queries): array + { + return \array_values(\array_map($this->joinCursorKey(...), $database->find($collection, [...$queries, Query::limit(100)]))); + } + + private function joinCursorKey(Document $row): string + { + $joined = $row->getAttribute('n.$id'); + + return ($row->getId() === '' ? '-' : $row->getId()).'/'.(\is_string($joined) ? $joined : '-'); + } + + /** + * An order on a bare name only the join declares (`label`) pages like the qualified `n.label`: + * after and before every row, in pages of two both ways, through tied labels and the rows an + * outer join left without a note. A name two joins declare is refused. + * + * @param list $rows + */ + #[DataProvider('joinCursorShapes')] + public function testJoinCursorPagesAlongABareJoinedOrder(Method $join, array $rows): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$authors, $notes] = $this->seedJoinCursorFixture($database); + $joinQuery = new Query($join, $notes, [Query::on('$id', 'author')], 'n'); + + foreach (['ascending' => true, 'descending' => false] as $label => $ascending) { + $queries = [$joinQuery, $ascending ? Query::orderAsc('label') : Query::orderDesc('label')]; + $qualified = [$joinQuery, $ascending ? Query::orderAsc('n.label') : Query::orderDesc('n.label')]; + + $keys = $this->joinCursorKeys($database, $authors, $qualified); + $sorted = $keys; + \sort($sorted); + $expected = $rows; + \sort($expected); + $this->assertSame($expected, $sorted, "{$label}: the qualified read returns each joined row once"); + $this->assertSame($keys, $this->joinCursorKeys($database, $authors, $queries), "{$label}: the bare name orders by the joined attribute"); + + $all = \array_values($database->find($authors, [...$queries, Query::limit(100)])); + foreach ($all as $index => $row) { + $this->assertSame(\array_slice($keys, $index + 1), $this->joinCursorKeys($database, $authors, [...$queries, Query::cursorAfter($row)]), "{$label}: after {$keys[$index]}"); + $this->assertSame(\array_slice($keys, 0, $index), $this->joinCursorKeys($database, $authors, [...$queries, Query::cursorBefore($row)]), "{$label}: before {$keys[$index]}"); + } + + $forward = []; + $cursor = null; + for ($page = 0; $page <= \count($all); $page++) { + $batch = $database->find($authors, [...$queries, Query::limit(2), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + \array_push($forward, ...\array_map($this->joinCursorKey(...), $batch)); + if (\count($batch) < 2) { + break; + } + $cursor = $batch[1]; + } + $this->assertSame($keys, $forward, "{$label}: paging forward in pages of two"); + + $backward = []; + $cursor = $all[\count($all) - 1]; + for ($page = 0; $page <= \count($all); $page++) { + $batch = $database->find($authors, [...$queries, Query::limit(2), Query::cursorBefore($cursor)]); + $backward = [...\array_map($this->joinCursorKey(...), $batch), ...$backward]; + if (\count($batch) < 2) { + break; + } + $cursor = $batch[0]; + } + $this->assertSame(\array_slice($keys, 0, -1), $backward, "{$label}: paging backward in pages of two from the last row"); + } + + $twice = [Query::leftJoin($notes, 'n', [Query::on('$id', 'author')]), Query::leftJoin($notes, 'm', [Query::on('$id', 'author')])]; + $cursor = $database->find($authors, [...$twice, Query::orderAsc('n.label'), Query::limit(1)])[0]; + try { + $database->find($authors, [...$twice, Query::orderAsc('label'), Query::cursorAfter($cursor)]); + $this->fail('A bare name two joins declare must be refused, not read from one of them'); + } catch (QueryException $exception) { + $this->assertStringContainsString('Attribute "label" is ambiguous across joins; qualify it with a join alias', $exception->getMessage()); + } + + $this->cleanupAggCollections($database, [$authors, $notes]); + } + + /** + * `alias.*` returns the joined row as a direct read of the joined collection returns it: its `$id`, `$sequence`, + * `$createdAt`, `$updatedAt` and `$permissions` next to its attributes, never its `$tenant`, alone, next to main + * attributes and next to `*`. A joined internal attribute named next to `*` is returned as well. A row an outer + * join left without a note holds null for each of them. + * + * @param list $rows + */ + #[DataProvider('joinCursorShapes')] + public function testJoinWildcardSelectReturnsTheJoinedInternalAttributes(Method $join, array $rows): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$authors, $notes] = $this->seedJoinCursorFixture($database); + $joinQuery = new Query($join, $notes, [Query::on('$id', 'author')], 'n'); + + $direct = []; + foreach ($database->find($notes, [Query::limit(100)]) as $note) { + $direct[$note->getId()] = $note; + } + + $internals = [Document::ID, Document::SEQUENCE, Document::CREATED_AT, Document::UPDATED_AT, Document::PERMISSIONS]; + $expected = $rows; + \sort($expected); + + foreach ([ + 'the alias wildcard' => [['n.*'], $internals], + 'a main attribute and the alias wildcard' => [['name', 'n.*'], $internals], + 'every attribute and the alias wildcard' => [['*', 'n.*'], $internals], + 'every attribute and the joined creation time' => [['*', 'n.$createdAt'], [Document::ID, Document::CREATED_AT]], + 'every attribute, the joined sequence and update time' => [['*', 'n.$sequence', 'n.$updatedAt'], [Document::ID, Document::SEQUENCE, Document::UPDATED_AT]], + ] as $label => [$select, $returned]) { + $found = $database->find($authors, [$joinQuery, Query::select($select), Query::limit(100)]); + $keys = \array_map($this->joinCursorKey(...), $found); + \sort($keys); + $this->assertSame($expected, $keys, "{$label}: each joined row once, with its joined \$id"); + + foreach ($found as $row) { + $this->assertFalse($row->offsetExists('n.'.Document::TENANT), "{$label}: the joined \$tenant is not returned"); + + $id = $row->getAttribute('n.$id'); + if ($id === null) { + foreach ($returned as $internal) { + $this->assertTrue($row->offsetExists('n.'.$internal), "{$label}: an unmatched row returns n.{$internal}"); + $this->assertNull($row->getAttribute('n.'.$internal), "{$label}: an unmatched row holds null for n.{$internal}"); + } + + continue; + } + + $this->assertIsString($id); + $note = $direct[$id]; + foreach ($returned as $internal) { + $this->assertNotNull($note->getAttribute($internal), "{$label}: the direct read returns {$internal}"); + $this->assertSame($note->getAttribute($internal), $row->getAttribute('n.'.$internal), "{$label}: n.{$internal} of {$id} as a direct read returns it"); + } + + if (\in_array('n.*', $select, true)) { + $this->assertSame($note->getAttribute('label'), $row->getAttribute('n.label'), "{$label}: the joined attributes of {$id}"); + } + if (\in_array('*', $select, true)) { + $this->assertSame($row->getId() === '' ? null : $row->getId(), $row->getAttribute('name'), "{$label}: every main attribute"); + $this->assertSame($note->getAttribute('label'), $row->getAttribute('n.label'), "{$label}: the joined attributes of {$id} next to *"); + } + } + } + + $this->cleanupAggCollections($database, [$authors, $notes]); + } + + /** + * A joined read ordered by a joined internal attribute pages without a select naming that attribute: without a + * select, with `*`, with `alias.*` and with main attributes next to `alias.*`, in pages of two after and before + * every page, each joined row exactly once in both directions, also through `cursor()`. A read selecting + * `alias.*` pages along the default order as well, which orders by the joined `$id`. + * + * @param list $rows + */ + #[DataProvider('joinCursorShapes')] + public function testJoinCursorPagesAlongAJoinedInternalAttribute(Method $join, array $rows): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$authors, $notes] = $this->seedJoinCursorFixture($database); + $joinQuery = new Query($join, $notes, [Query::on('$id', 'author')], 'n'); + $expected = $rows; + \sort($expected); + + foreach ([ + 'joined sequence ascending' => [Query::orderAsc('n.$sequence')], + 'joined sequence descending' => [Query::orderDesc('n.$sequence')], + 'joined creation time ascending' => [Query::orderAsc('n.$createdAt')], + 'joined creation time descending' => [Query::orderDesc('n.$createdAt')], + 'default order' => [], + ] as $orderLabel => $order) { + foreach ([ + 'no select' => [], + 'every attribute' => [Query::select(['*'])], + 'the alias wildcard' => [Query::select(['n.*'])], + 'a main attribute and the alias wildcard' => [Query::select(['name', 'n.*'])], + ] as $selectLabel => $select) { + $label = "{$orderLabel}, {$selectLabel}"; + $queries = [$joinQuery, ...$select, ...$order]; + + $all = \array_values($database->find($authors, [...$queries, Query::limit(100)])); + $keys = \array_map($this->joinCursorKey(...), $all); + $sorted = $keys; + \sort($sorted); + $this->assertSame($expected, $sorted, "{$label}: the unpaged read returns each joined row once"); + + $forward = []; + $cursor = null; + $pages = 0; + for ($page = 0; $page <= \count($all); $page++) { + $batch = $database->find($authors, [...$queries, Query::limit(2), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + \array_push($forward, ...\array_map($this->joinCursorKey(...), $batch)); + $pages++; + if (\count($batch) < 2) { + break; + } + $cursor = $batch[1]; + } + $this->assertGreaterThanOrEqual(3, $pages, "{$label}: the read spans at least three pages"); + $this->assertSame($keys, $forward, "{$label}: paging forward in pages of two"); + + $backward = []; + $cursor = $all[\count($all) - 1]; + for ($page = 0; $page <= \count($all); $page++) { + $batch = $database->find($authors, [...$queries, Query::limit(2), Query::cursorBefore($cursor)]); + $backward = [...\array_map($this->joinCursorKey(...), $batch), ...$backward]; + if (\count($batch) < 2) { + break; + } + $cursor = $batch[0]; + } + $this->assertSame(\array_slice($keys, 0, -1), $backward, "{$label}: paging backward in pages of two from the last row"); + + $iterated = []; + foreach ($database->cursor($authors, $queries, 2) as $row) { + $iterated[] = $this->joinCursorKey($row); + if (\count($iterated) > \count($all)) { + break; + } + } + $this->assertSame($keys, $iterated, "{$label}: cursor() in batches of two"); + } + } + + $this->cleanupAggCollections($database, [$authors, $notes]); + } + + public function testFullOuterJoinInRandomOrderReturnsEveryRow(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $customers = 'j47a_random_customers'; + $notes = 'j47a_random_notes'; + $this->cleanupAggCollections($database, [$customers, $notes]); + + $database->createCollection(Collection::create(id: $customers, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($customers, Attribute::string(key: 'name', size: 16, required: true)); + $database->createCollection(Collection::create(id: $notes, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($notes, Attribute::string(key: 'customerId', size: 16, required: true)); + $database->createAttribute($notes, Attribute::string(key: 'body', size: 16, required: true)); + + foreach (['c1', 'c2', 'c3'] as $customer) { + $database->createDocument($customers, new Document(['$id' => $customer, 'name' => $customer])); + } + foreach (['n1' => 'c1', 'n2' => 'c1', 'n3' => 'c2', 'n4' => 'cx'] as $note => $customer) { + $database->createDocument($notes, new Document(['$id' => $note, 'customerId' => $customer, 'body' => $note])); + } + + $join = Query::fullOuterJoin($notes, 'note', [Query::on('$id', 'customerId')]); + $select = Query::select(['name', 'note.body']); + /** + * @param array $documents + * @return list + */ + $rows = static function (array $documents): array { + /** @var array $documents */ + $rows = []; + foreach ($documents as $document) { + $rows[] = \json_encode([$document->getAttribute('name'), $document->getAttribute('note.body')], JSON_THROW_ON_ERROR); + } + \sort($rows); + + return $rows; + }; + + try { + $expected = $rows($database->find($customers, [$join, $select])); + $this->assertCount(5, $expected); + $this->assertSame($expected, $rows($database->find($customers, [$join, $select, Query::orderRandom(), Query::limit(100)]))); + } finally { + $this->cleanupAggCollections($database, [$customers, $notes]); + } + } + + /** + * A left-joined read ordered by main attributes up to a unique one returns every window and every cursor page of + * the unpaged read: MariaDB and MySQL pick the main rows a page can reach before they join them, through main + * documents without a readable note, hidden main documents, ties and nulls in the main order. + */ + public function testLeftJoinedPagesMatchTheUnpagedRead(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authors = 'j65_authors'; + $notes = 'j65_notes'; + $this->cleanupAggCollections($database, [$authors, $notes]); + $permissions = [Permission::create(Role::any())]; + $database->createCollection(Collection::create( + id: $authors, + attributes: [Attribute::string(key: 'name', size: 16), Attribute::integer(key: 'score', required: false)], + permissions: $permissions, + documentSecurity: true, + )); + $database->createCollection(Collection::create( + id: $notes, + attributes: [Attribute::string(key: 'author', size: 16), Attribute::integer(key: 'score', required: false)], + permissions: $permissions, + documentSecurity: true, + )); + + $readable = [Permission::read(Role::any())]; + $hidden = [Permission::read(Role::user('j65-nobody'))]; + foreach ([ + 'a1' => [2, true], 'a2' => [1, true], 'a3' => [null, true], 'a4' => [2, false], 'a5' => [3, true], + 'a6' => [1, true], 'a7' => [null, false], 'a8' => [2, true], 'a9' => [4, true], + ] as $id => [$score, $visible]) { + $database->createDocument($authors, new Document(['$id' => $id, 'name' => 'author '.$id, 'score' => $score, '$permissions' => $visible ? $readable : $hidden])); + } + foreach ([ + 'n1' => ['a1', 1, true], 'n2' => ['a1', 2, true], 'n3' => ['a1', 1, true], 'n4' => ['a2', 5, true], + 'n5' => ['a3', null, true], 'n6' => ['a3', 2, false], 'n7' => ['a4', 1, true], 'n8' => ['a5', 1, false], + 'n9' => ['a6', 3, true], 'n10' => ['a6', 3, true], 'n11' => ['a9', 1, true], 'n12' => ['zz', 1, true], + ] as $id => [$author, $score, $visible]) { + $database->createDocument($notes, new Document(['$id' => $id, 'author' => $author, 'score' => $score, '$permissions' => $visible ? $readable : $hidden])); + } + + $join = Query::leftJoin($notes, 'n', [Query::on('$id', 'author')]); + + try { + foreach ([ + 'default order' => [], + 'score' => [Query::orderAsc('score')], + 'score descending' => [Query::orderDesc('score')], + '$id descending' => [Query::orderDesc('$id')], + 'score, then the joined score' => [Query::orderAsc('score'), Query::orderAsc('$sequence'), Query::orderDesc('n.score')], + ] as $label => $order) { + $queries = [$join, ...$order]; + $all = \array_values($database->find($authors, [...$queries, Query::limit(100)])); + $keys = \array_map($this->joinCursorKey(...), $all); + $this->assertCount(10, $keys, "{$label}: every visible author with each visible note, or none"); + + foreach ([1, 2, 3] as $limit) { + for ($offset = 0; $offset <= \count($keys); $offset++) { + $this->assertSame( + \array_slice($keys, $offset, $limit), + \array_map($this->joinCursorKey(...), $database->find($authors, [...$queries, Query::limit($limit), Query::offset($offset)])), + "{$label}: limit {$limit}, offset {$offset}", + ); + } + } + + foreach ($all as $index => $row) { + foreach ([1, 2] as $limit) { + $this->assertSame( + \array_slice($keys, $index + 1, $limit), + \array_map($this->joinCursorKey(...), $database->find($authors, [...$queries, Query::cursorAfter($row), Query::limit($limit)])), + "{$label}: {$limit} after {$keys[$index]}", + ); + $this->assertSame( + \array_slice($keys, \max(0, $index - $limit), \min($limit, $index)), + \array_map($this->joinCursorKey(...), $database->find($authors, [...$queries, Query::cursorBefore($row), Query::limit($limit)])), + "{$label}: {$limit} before {$keys[$index]}", + ); + } + } + + $iterated = []; + foreach ($database->cursor($authors, $queries, 2) as $row) { + $iterated[] = $this->joinCursorKey($row); + } + $this->assertSame($keys, $iterated, "{$label}: cursor()"); + } + + if ($database->getAdapter()->supports(Capability::IndexFulltext)) { + $database->createIndex($authors, Index::fulltext(key: 'j65_name', attributes: ['name'])); + $queries = [$join, Query::search('name', 'author')]; + $keys = \array_map($this->joinCursorKey(...), \array_values($database->find($authors, [...$queries, Query::limit(100)]))); + $this->assertCount(10, $keys, 'search: every visible author with each visible note, or none'); + for ($offset = 0; $offset <= \count($keys); $offset++) { + $this->assertSame( + \array_slice($keys, $offset, 2), + \array_map($this->joinCursorKey(...), $database->find($authors, [...$queries, Query::limit(2), Query::offset($offset)])), + "search: limit 2, offset {$offset}", + ); + } + } + } finally { + $this->cleanupAggCollections($database, [$authors, $notes]); + } + } + + /** + * Inner-joined reads, left-joined reads filtered on joined attributes and searched reads, ordered by main + * attributes up to a unique one, return every window and every cursor page of the unpaged read, through authors + * whose notes are all hidden or fail the filter, hidden authors, and ties and nulls in the main order. MariaDB and + * MySQL pick the main rows a left-joined searched page can reach before they join them, running the search there + * only; the other reads keep the whole join. + */ + public function testInnerAndFilteredJoinedPagesMatchTheUnpagedRead(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authors = 'j67_authors'; + $notes = 'j67_notes'; + $this->cleanupAggCollections($database, [$authors, $notes]); + $permissions = [Permission::create(Role::any())]; + $database->createCollection(Collection::create( + id: $authors, + attributes: [Attribute::string(key: 'name', size: 16), Attribute::integer(key: 'score', required: false)], + permissions: $permissions, + documentSecurity: true, + )); + $database->createCollection(Collection::create( + id: $notes, + attributes: [Attribute::string(key: 'author', size: 16), Attribute::integer(key: 'score', required: false)], + permissions: $permissions, + documentSecurity: true, + )); + + $readable = [Permission::read(Role::any())]; + $hidden = [Permission::read(Role::user('j67-nobody'))]; + foreach ([ + 'a1' => [2, true], 'a2' => [1, true], 'a3' => [null, true], 'a4' => [2, false], 'a5' => [3, true], + 'a6' => [1, true], 'a7' => [null, false], 'a8' => [2, true], 'a9' => [4, true], + ] as $id => [$score, $visible]) { + $metal = \in_array($id, ['a1', 'a3', 'a4', 'a6', 'a9'], true) ? 'gold' : 'iron'; + $database->createDocument($authors, new Document(['$id' => $id, 'name' => "author {$id} {$metal}", 'score' => $score, '$permissions' => $visible ? $readable : $hidden])); + } + foreach ([ + 'n1' => ['a1', 1, true], 'n2' => ['a1', 2, true], 'n3' => ['a1', 1, true], 'n4' => ['a2', 5, true], + 'n5' => ['a3', null, true], 'n6' => ['a3', 2, false], 'n7' => ['a4', 1, true], 'n8' => ['a5', 1, false], + 'n9' => ['a6', 3, true], 'n10' => ['a6', 3, true], 'n11' => ['a9', 1, true], 'n12' => ['zz', 1, true], + ] as $id => [$author, $score, $visible]) { + $database->createDocument($notes, new Document(['$id' => $id, 'author' => $author, 'score' => $score, '$permissions' => $visible ? $readable : $hidden])); + } + + $inner = Query::join($notes, 'n', [Query::on('$id', 'author')]); + $left = Query::leftJoin($notes, 'n', [Query::on('$id', 'author')]); + $everyNote = ['a1/n1', 'a1/n2', 'a1/n3', 'a2/n4', 'a3/n5', 'a6/n10', 'a6/n9', 'a9/n11']; + $reads = [ + 'inner join' => [[$inner], $everyNote], + 'left join, notes with a score of at least 1' => [[$left, Query::greaterThanEqual('n.score', 1)], ['a1/n1', 'a1/n2', 'a1/n3', 'a2/n4', 'a6/n10', 'a6/n9', 'a9/n11']], + 'inner join, notes without a score or below 3' => [[$inner, Query::or([Query::isNull('n.score'), Query::lessThan('n.score', 3)])], ['a1/n1', 'a1/n2', 'a1/n3', 'a3/n5', 'a9/n11']], + ]; + + try { + if ($database->getAdapter()->supports(Capability::IndexFulltext)) { + $database->createIndex($authors, Index::fulltext(key: 'j67_name', attributes: ['name'])); + $reads['inner join, searched'] = [[$inner, Query::search('name', 'gold')], ['a1/n1', 'a1/n2', 'a1/n3', 'a3/n5', 'a6/n10', 'a6/n9', 'a9/n11']]; + $reads['left join, searched'] = [[$left, Query::search('name', 'iron')], ['a2/n4', 'a5/-', 'a8/-']]; + } + + foreach ($reads as $read => [$queries, $expected]) { + foreach ([ + 'default order' => [], + 'score' => [Query::orderAsc('score')], + 'score descending' => [Query::orderDesc('score')], + '$id descending' => [Query::orderDesc('$id')], + ] as $order => $orders) { + $label = "{$read}, {$order}"; + $all = \array_values($database->find($authors, [...$queries, ...$orders, Query::limit(100)])); + $keys = \array_map($this->joinCursorKey(...), $all); + $sorted = $keys; + \sort($sorted); + $this->assertSame($expected, $sorted, "{$label}: every matching visible author with each matching visible note"); + + foreach ([1, 2, 3] as $limit) { + for ($offset = 0; $offset <= \count($keys); $offset++) { + $this->assertSame( + \array_slice($keys, $offset, $limit), + \array_map($this->joinCursorKey(...), $database->find($authors, [...$queries, ...$orders, Query::limit($limit), Query::offset($offset)])), + "{$label}: limit {$limit}, offset {$offset}", + ); + } + } + + foreach ($all as $index => $row) { + foreach ([1, 2] as $limit) { + $this->assertSame( + \array_slice($keys, $index + 1, $limit), + \array_map($this->joinCursorKey(...), $database->find($authors, [...$queries, ...$orders, Query::cursorAfter($row), Query::limit($limit)])), + "{$label}: {$limit} after {$keys[$index]}", + ); + $this->assertSame( + \array_slice($keys, \max(0, $index - $limit), \min($limit, $index)), + \array_map($this->joinCursorKey(...), $database->find($authors, [...$queries, ...$orders, Query::cursorBefore($row), Query::limit($limit)])), + "{$label}: {$limit} before {$keys[$index]}", + ); + } + } + + $iterated = []; + foreach ($database->cursor($authors, [...$queries, ...$orders], 2) as $row) { + $iterated[] = $this->joinCursorKey($row); + } + $this->assertSame($keys, $iterated, "{$label}: cursor()"); + } + } + } finally { + $this->cleanupAggCollections($database, [$authors, $notes]); + } + } +} diff --git a/tests/e2e/Adapter/Scopes/MetadataCacheTests.php b/tests/e2e/Adapter/Scopes/MetadataCacheTests.php new file mode 100644 index 0000000000..938716da7a --- /dev/null +++ b/tests/e2e/Adapter/Scopes/MetadataCacheTests.php @@ -0,0 +1,432 @@ +createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 128)], + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + $database->createDocument($collection, new Document([ + '$id' => 'warm', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'warm', + ])); + + $this->assertSame('warm', $database->getDocument($collection, 'warm')->getAttribute('name')); + + return $collection; + } + + /** + * @return list + */ + private function definedAttributes(Database $database, string $collection): array + { + return $database->getCollection($collection)->attributes(); + } + + /** + * @return array + */ + private function definedAttributeKeys(Database $database, string $collection): array + { + return \array_map( + fn (Attribute $attribute) => $attribute->key, + $this->definedAttributes($database, $collection) + ); + } + + /** + * @return array + */ + private function definedIndexKeys(Database $database, string $collection): array + { + return \array_map( + fn (Index $index) => $index->key, + $database->getCollection($collection)->indexes() + ); + } + + public function testCreateAttributeIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->warmedCollection($database, 'createattr'); + + $database->createAttribute($collection, Attribute::string(key: 'nickname', size: 128)); + + $this->assertContains('nickname', $this->definedAttributeKeys($database, $collection)); + + $database->createDocument($collection, new Document([ + '$id' => 'after', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'after', + 'nickname' => 'nick', + ])); + + $this->assertSame('nick', $database->getDocument($collection, 'after')->getAttribute('nickname')); + } + + public function testDeleteAttributeIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->warmedCollection($database, 'delattr'); + $database->createAttribute($collection, Attribute::string(key: 'doomed', size: 128)); + $this->assertContains('doomed', $this->definedAttributeKeys($database, $collection)); + + $this->assertSame('warm', $database->getDocument($collection, 'warm')->getAttribute('name')); + + $database->deleteAttribute($collection, 'doomed'); + + $this->assertNotContains('doomed', $this->definedAttributeKeys($database, $collection)); + + $failed = false; + try { + $database->createDocument($collection, new Document([ + '$id' => 'orphan', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'orphan', + 'doomed' => 'value', + ])); + } catch (Throwable) { + $failed = true; + } + + $this->assertTrue($failed, 'a write to the deleted attribute was accepted against the stale schema'); + } + + public function testUpdateAttributeIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + if ( + ! $database->getAdapter()->supports(Capability::DefinedAttributes) + || ! $database->getAdapter()->supports(Capability::AttributeResizing) + ) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->warmedCollection($database, 'updattr'); + + $database->updateAttribute($collection, 'name', new AttributeUpdate(size: 2048)); + + $sizes = []; + foreach ($this->definedAttributes($database, $collection) as $attribute) { + $sizes[$attribute->key] = $attribute->size; + } + + $this->assertSame(2048, $sizes['name']); + + $long = \str_repeat('a', 1024); + $database->createDocument($collection, new Document([ + '$id' => 'long', + '$permissions' => [Permission::read(Role::any())], + 'name' => $long, + ])); + + $this->assertSame($long, $database->getDocument($collection, 'long')->getAttribute('name')); + } + + public function testRenameAttributeIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->warmedCollection($database, 'renattr'); + + $database->renameAttribute($collection, 'name', 'label'); + + $keys = $this->definedAttributeKeys($database, $collection); + $this->assertContains('label', $keys); + $this->assertNotContains('name', $keys); + + $this->assertSame('warm', $database->getDocument($collection, 'warm')->getAttribute('label')); + } + + public function testCreateIndexIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + $collection = $this->warmedCollection($database, 'createidx'); + + $database->createIndex($collection, Index::key(key: 'byName', attributes: ['name'], lengths: [128])); + + $this->assertContains('byName', $this->definedIndexKeys($database, $collection)); + } + + public function testDeleteIndexIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + $collection = $this->warmedCollection($database, 'delidx'); + $database->createIndex($collection, Index::key(key: 'byName', attributes: ['name'], lengths: [128])); + $this->assertContains('byName', $this->definedIndexKeys($database, $collection)); + + $this->assertSame('warm', $database->getDocument($collection, 'warm')->getAttribute('name')); + + $database->deleteIndex($collection, 'byName'); + + $this->assertNotContains('byName', $this->definedIndexKeys($database, $collection)); + } + + public function testUpdateCollectionIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + $collection = $this->warmedCollection($database, 'updcoll'); + + $this->assertFalse($database->getCollection($collection)->getAttribute('documentSecurity')); + + $database->updateCollection($collection, new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + + $updated = $database->getCollection($collection); + $this->assertTrue($updated->getAttribute('documentSecurity')); + $this->assertSame(['any'], $updated->getPermissionsByType(PermissionType::Read)); + } + + public function testDeleteCollectionIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + $collection = $this->warmedCollection($database, 'delcoll'); + + $database->deleteCollection($collection); + + $this->assertNull($database->findCollection($collection)); + + $this->expectException(NotFoundException::class); + $database->getDocument($collection, 'warm'); + } + + public function testCreateRelationshipIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parent = $this->warmedCollection($database, 'relparent'); + $child = $this->warmedCollection($database, 'relchild'); + + $database->createRelationship($parent, Relationship::oneToOne( + relatedCollection: $child, + twoWay: false, + key: 'child', + )); + + $this->assertContains('child', $this->definedAttributeKeys($database, $parent)); + + $database->createDocument($parent, new Document([ + '$id' => 'linked', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'linked', + 'child' => [ + '$id' => 'linkedchild', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'linkedchild', + ], + ])); + + $related = $database->getDocument($parent, 'linked')->getAttribute('child'); + $this->assertInstanceOf(Document::class, $related); + $this->assertSame('linkedchild', $related->getAttribute('name')); + } + + public function testDeleteRelationshipIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parent = $this->warmedCollection($database, 'unrelparent'); + $child = $this->warmedCollection($database, 'unrelchild'); + + $database->createRelationship($parent, Relationship::oneToOne( + relatedCollection: $child, + twoWay: true, + key: 'child', + twoWayKey: 'parent', + )); + + $this->assertSame('warm', $database->getDocument($parent, 'warm')->getAttribute('name')); + $this->assertSame('warm', $database->getDocument($child, 'warm')->getAttribute('name')); + + $database->deleteRelationship($parent, 'child'); + + $this->assertNotContains('child', $this->definedAttributeKeys($database, $parent)); + $this->assertNotContains('parent', $this->definedAttributeKeys($database, $child)); + } + + public function testCollectionDefinitionsAreScopedToTheirDatabase(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Schemas)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $schema = $database->getDatabase(); + $suffix = \substr(\uniqid(), -8); + $first = 'scopedone'.$suffix; + $second = 'scopedtwo'.$suffix; + $collection = 'scoped'; + + try { + $database->setDatabase($first)->create(); + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'first', size: 128)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + $database->setDatabase($second)->create(); + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'second', size: 128)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + $this->assertSame(['second'], $this->definedAttributeKeys($database, $collection)); + + $database->setDatabase($first); + + $this->assertSame(['first'], $this->definedAttributeKeys($database, $collection)); + + $database->createDocument($collection, new Document([ + '$id' => 'row', + '$permissions' => [Permission::read(Role::any())], + 'first' => 'value', + ])); + + $this->assertSame('value', $database->getDocument($collection, 'row')->getAttribute('first')); + } finally { + foreach ([$first, $second] as $name) { + if ($database->exists($name)) { + $database->delete($name); + } + } + $database->setDatabase($schema); + } + } + + public function testRolledBackCollectionUpdateNeverReachesTheNextRead(): void + { + $database = $this->getDatabase(); + + if (! $this->supportsAtomicTransactions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->warmedCollection($database, 'rollback'); + + $rolledBack = false; + try { + $database->withTransaction(function () use ($database, $collection): void { + $database->updateCollection($collection, new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + $database->getCollection($collection); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException $error) { + $rolledBack = $error->getMessage() === 'rollback'; + } + + $this->assertTrue($rolledBack, 'the transaction was expected to roll back'); + + $this->assertFalse( + $database->getCollection($collection)->getAttribute('documentSecurity'), + 'a rolled back schema change was served from the cache' + ); + + $database->updateCollection($collection, new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + + $this->assertTrue( + $database->getCollection($collection)->getAttribute('documentSecurity'), + 'the rollback left the cached definition unable to observe a later commit' + ); + } +} diff --git a/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php b/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php new file mode 100644 index 0000000000..958c91e556 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php @@ -0,0 +1,349 @@ +createDatabaseWithoutThePermissionsHook(); + $collection = $this->createAliceOnlyCollection($database); + + $this->assumeRolesOf($database, 'bob'); + $this->assertSame([], $database->find($collection)); + $this->assertSame(0, $database->count($collection)); + $this->assertSame(0, $database->sum($collection, 'count')); + + $this->assumeRolesOf($database, 'alice'); + $this->assertSame(['alice'], \array_map(fn (Document $document) => $document->getId(), $database->find($collection))); + $this->assertSame(1, $database->count($collection)); + $this->assertSame(5, $database->sum($collection, 'count')); + } + + public function testBulkWritesWithoutThePermissionsHookSkipDocumentsTheCallerCannotChange(): void + { + $database = $this->createDatabaseWithoutThePermissionsHook(); + $collection = $this->createAliceOnlyCollection($database); + + $this->assumeRolesOf($database, 'bob'); + $this->assertSame(0, $database->updateDocuments($collection, new Document(['count' => 42]))); + $this->assertSame(0, $database->deleteDocuments($collection)); + + $this->assertSame( + [5], + $database->getAuthorization()->skip(fn (): array => \array_map( + fn (Document $document) => $document->getAttribute('count'), + $database->find($collection), + )), + ); + } + + private function createDatabaseWithoutThePermissionsHook(): Database + { + $lane = $this->getDatabase(); + + $adapter = new Mongo(new Client($this->testDatabase, 'mongo', 27017, 'root', 'password', false)); + $adapter->setSchemaless(! $lane->getAdapter()->supports(Capability::DefinedAttributes)); + + $database = (new Database($adapter, new Cache(new None()))) + ->setAuthorization(new Authorization()) + ->setDatabase($this->testDatabase) + ->setSharedTables($lane->hasSharedTables()) + ->setTenant($lane->getTenant()) + ->setNamespace('unhooked_'.\uniqid()); + + $database->create(); + + $this->assertSame([], \array_filter($adapter->getWriteHooks(), static fn (Write $hook): bool => $hook instanceof Permissions)); + + return $database; + } + + private function createAliceOnlyCollection(Database $database): string + { + $collection = 'aliceOnly'; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::integer(key: 'count', required: true)], + permissions: [], + documentSecurity: true, + )); + + $database->getAuthorization()->skip(fn () => $database->createDocument($collection, new Document([ + '$id' => 'alice', + '$permissions' => [ + Permission::read(Role::user('alice')), + Permission::update(Role::user('alice')), + Permission::delete(Role::user('alice')), + ], + 'count' => 5, + ]))); + + return $collection; + } + + private function assumeRolesOf(Database $database, string $user): void + { + $authorization = $database->getAuthorization(); + + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::users()->toString()); + $authorization->addRole(Role::user($user)->toString()); + } + + public function testStartsWithAndEndsWithAreAnchored(): void + { + $database = $this->getDatabase(); + $collection = $this->createNamesCollection($database, ['foobar', 'barfoo', 'Foobar', 'barfoobar']); + + $this->assertSame(['foobar'], $this->namesOf($database->find($collection, [Query::startsWith('name', 'foo')]))); + $this->assertSame(['barfoo'], $this->namesOf($database->find($collection, [Query::endsWith('name', 'foo')]))); + $this->assertSame(1, $database->count($collection, [Query::startsWith('name', 'foo')])); + + $database->deleteCollection($collection); + } + + public function testContainsAllWorksOnFind(): void + { + $database = $this->getDatabase(); + $collection = $this->createNamesCollection($database, ['foobar', 'barfoo', 'foobaz']); + + $this->assertSame(['barfoo', 'foobar'], $this->namesOf($database->find($collection, [Query::containsAll('tags', ['foo', 'bar'])]))); + $this->assertSame(2, $database->count($collection, [Query::containsAll('tags', ['foo', 'bar'])])); + + $database->deleteCollection($collection); + } + + public function testCountReportsDriverErrors(): void + { + $database = $this->getDatabase(); + $collection = $this->createNamesCollection($database, ['foobar']); + + try { + $database->getAdapter()->count($database->getCollection($collection), [Query::regex('name', '(')]); + $this->fail('count() must report the driver error for an invalid regular expression instead of 0'); + } catch (MongoException $e) { + $this->assertNotSame(0, $e->getCode()); + } finally { + $database->deleteCollection($collection); + } + } + + public function testDottedAttributesSurviveRenameAndDelete(): void + { + $database = $this->getDatabase(); + $collection = 'dotted_'.\uniqid(); + + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'a.b', size: 16), + Attribute::string(key: 'x.y', size: 16), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + $database->createDocument($collection, new Document(['$id' => 'first', 'a.b' => 'renamed', 'x.y' => 'deleted'])); + + $database->updateAttribute($collection, 'a.b', new AttributeUpdate(key: 'c')); + $this->assertSame('renamed', $database->getDocument($collection, 'first')->getAttribute('c')); + + $database->deleteAttribute($collection, 'x.y'); + $database->createAttribute($collection, Attribute::string(key: 'x.y', size: 16)); + $this->assertNull($database->getDocument($collection, 'first')->getAttribute('x.y')); + + $database->deleteCollection($collection); + } + + public function testOrderRandomIsRejectedAsAQueryError(): void + { + $database = $this->getDatabase(); + $collection = $this->createNamesCollection($database, ['foobar']); + + try { + foreach ([ + fn (): array => $database->find($collection, [Query::orderRandom()]), + fn (): array => $database->skipValidation(fn (): array => $database->find($collection, [Query::orderRandom()])), + ] as $find) { + try { + $find(); + $this->fail('orderRandom() must be rejected as a query error where the adapter cannot order by random'); + } catch (QueryException $e) { + $this->assertStringContainsString('Random order is not supported', $e->getMessage()); + } + } + } finally { + $database->deleteCollection($collection); + } + } + + public function testSumOnADottedAttributeMatchesTheCountedRows(): void + { + $database = $this->getDatabase(); + $collection = 'dotted_sum_'.\uniqid(); + + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::integer(key: 'score.value'), + Attribute::string(key: 'group.name', size: 16), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + foreach ([[1, 'a'], [2, 'a'], [4, 'b']] as [$score, $group]) { + $database->createDocument($collection, new Document(['score.value' => $score, 'group.name' => $group])); + } + + $queries = [Query::equal('group.name', ['a'])]; + + $this->assertSame(2, $database->count($collection, $queries)); + $this->assertSame(3, $database->sum($collection, 'score.value', $queries)); + $this->assertSame(7, $database->sum($collection, 'score.value')); + + $database->deleteCollection($collection); + } + + /** + * @param list $names + */ + private function createNamesCollection(Database $database, array $names): string + { + $collection = 'names_'.\uniqid(); + + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'tags', size: 16, array: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + foreach ($names as $name) { + $database->createDocument($collection, new Document(['name' => $name, 'tags' => \str_split($name, 3)])); + } + + return $collection; + } + + /** + * @param array $documents + * @return list + */ + private function namesOf(array $documents): array + { + $names = []; + foreach ($documents as $document) { + $name = $document->getAttribute('name'); + $this->assertIsString($name); + $names[] = $name; + } + \sort($names); + + return $names; + } + + public function testFiltersMatchALiteralDollarWord(): void + { + $database = $this->getDatabase(); + $collection = 'dollar_words'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'label', size: 64, required: true)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + try { + foreach (['lead' => '$USD 10', 'tail' => '10 $USD', 'plain' => 'plain'] as $id => $label) { + $database->createDocument($collection, new Document(['$id' => $id, 'label' => $label])); + } + + $idsOf = function (Query $query) use ($database, $collection): array { + $ids = \array_map(fn (Document $document): string => $document->getId(), $database->find($collection, [$query])); + \sort($ids); + + return $ids; + }; + + $this->assertSame(['lead', 'tail'], $idsOf(Query::contains('label', ['$USD']))); + $this->assertSame(['plain'], $idsOf(Query::notContains('label', ['$USD']))); + $this->assertSame(['plain', 'tail'], $idsOf(Query::notStartsWith('label', '$USD'))); + $this->assertSame(['lead', 'plain'], $idsOf(Query::notEndsWith('label', '$USD'))); + } finally { + $database->deleteCollection($collection); + } + } + + public function testContainsFamilyMatchesLikeTheOtherEngines(): void + { + $database = $this->getDatabase(); + $collection = 'contains_family'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + Attribute::string(key: 'tags', size: 32, required: false, array: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + try { + $documents = [ + 'captain' => ['name' => 'Captain America', 'tags' => ['comics', 'action']], + 'work' => ['name' => 'Work in Progress', 'tags' => ['drama']], + 'kids' => ['name' => 'Frozen', 'tags' => ['kids']], + 'untagged' => ['name' => 'Untitled', 'tags' => []], + ]; + foreach ($documents as $id => $attributes) { + $database->createDocument($collection, new Document(['$id' => $id, ...$attributes])); + } + + $idsOf = function (Query $query) use ($database, $collection): array { + $ids = \array_map(fn (Document $document): string => $document->getId(), $database->find($collection, [$query])); + \sort($ids); + + return $ids; + }; + + $this->assertSame(['captain', 'kids'], $idsOf(Query::contains('tags', ['comics', 'kids']))); + $this->assertSame(['captain', 'kids'], $idsOf(Query::containsAny('tags', ['comics', 'kids']))); + $this->assertSame(['kids', 'untagged', 'work'], $idsOf(Query::notContains('tags', ['comics']))); + $this->assertSame(['captain', 'work'], $idsOf(Query::contains('name', ['Captain', 'Work']))); + $this->assertSame(['captain', 'work'], $idsOf(Query::containsAny('name', ['Captain', 'Work']))); + $this->assertSame(['kids', 'untagged', 'work'], $idsOf(Query::notContains('name', ['Captain']))); + $this->assertSame(['kids', 'untagged'], $database->skipValidation(fn (): array => $idsOf(Query::notEqual('name', ['Captain America', 'Work in Progress'])))); + } finally { + $database->deleteCollection($collection); + } + } +} diff --git a/tests/e2e/Adapter/Scopes/MySQLJoinPlanTests.php b/tests/e2e/Adapter/Scopes/MySQLJoinPlanTests.php new file mode 100644 index 0000000000..c1b4040a95 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/MySQLJoinPlanTests.php @@ -0,0 +1,382 @@ +|semijoin|weedout|Remove duplicates from input/i'; + + public function testEightCheckedSelfJoinsKeepTheJoinOrderSearchSmall(): void + { + $database = $this->getDatabase(); + $customers = 'checked_self_joins'; + $database->createCollection(Collection::create(id: $customers, permissions: [Permission::create(Role::any())], documentSecurity: true)); + + try { + $this->seed($database, $customers, [Role::any(), Role::user(self::HIDDEN)]); + + $this->assertJoinOrderSearchStaysSmall($database, $customers, \array_map( + static fn (int $peer): Query => Query::join($customers, 'peer'.$peer, [Query::on('$id', '$id')]), + \range(1, 8), + )); + } finally { + $database->deleteCollection($customers); + } + } + + public function testEightJoinsOfWhichFourAreCheckedKeepTheJoinOrderSearchSmall(): void + { + $database = $this->getDatabase(); + $customers = 'partly_checked_joins'; + $labels = 'partly_checked_labels'; + $database->createCollection(Collection::create(id: $customers, permissions: [Permission::create(Role::any())], documentSecurity: true)); + $database->createCollection(Collection::create(id: $labels, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + + try { + $this->seed($database, $customers, [Role::any(), Role::user(self::HIDDEN)]); + $this->seed($database, $labels, [Role::any(), Role::any()]); + + $this->assertJoinOrderSearchStaysSmall($database, $customers, [ + ...\array_map( + static fn (int $peer): Query => Query::join($customers, 'peer'.$peer, [Query::on('$id', '$id')]), + \range(1, 4), + ), + ...\array_map( + static fn (int $label): Query => Query::join($labels, 'label'.$label, [Query::on('name', 'name')]), + \range(1, 4), + ), + ]); + } finally { + $database->deleteCollection($labels); + $database->deleteCollection($customers); + } + } + + /** + * A semi-joined check in an outer join's ON clause is run by scanning its materialised rows once + * per outer row (seconds with one checked link, a timeout with two), so every outer-joined check + * carries NO_SEMIJOIN below LARGE_JOIN too, and the plan runs none of them as a semi-join. + */ + public function testLeftJoinedChecksStaySubqueries(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + $this->assertInstanceOf(RawQuery::class, $adapter); + + $customers = 'left_joined_customers'; + $database->createCollection(Collection::create(id: $customers, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $links = \array_map(static fn (int $link): string => 'left_joined_link'.$link, \range(1, self::OUTER_JOIN_LINKS)); + foreach ($links as $link) { + $database->createCollection(Collection::create(id: $link, permissions: [Permission::create(Role::any())], documentSecurity: true)); + } + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + + try { + $this->seed($database, $customers, [Role::any(), Role::any()]); + foreach ($links as $link) { + $this->seed($database, $link, [Role::any(), Role::user(self::HIDDEN)]); + } + + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::user('caller')->toString()); + + for ($count = 1; $count <= self::OUTER_JOIN_LINKS; $count++) { + $joins = \array_map( + static fn (int $link): Query => Query::leftJoin($links[$link - 1], 'c'.$link, [Query::on('name', 'name')]), + \range(1, $count), + ); + + [$found, $statement] = $this->tracing($adapter, fn (): array => $database->find($customers, [ + ...$joins, + Query::orderAsc('name'), + Query::limit(100), + ])); + + $expected = []; + foreach ([...self::READABLE, self::HIDDEN] as $id) { + $expected[] = [$id, $id === self::HIDDEN ? null : $id]; + } + \usort($expected, static fn (array $left, array $right): int => \strcmp(\ucfirst($left[0]), \ucfirst($right[0]))); + $this->assertSame($expected, \array_map( + static fn (Document $document): array => [$document->getId(), $document->getAttribute('c'.$count.'.$id')], + $found, + ), 'A left join keeps every customer and pairs only the rows the caller may read'); + + $this->assertSame($count, \substr_count($statement, '/*+ NO_SEMIJOIN() */'), 'Every left-joined check carries NO_SEMIJOIN: '.$statement); + + $plan = $this->treePlan($adapter, $statement); + $this->assertDoesNotMatchRegularExpression(self::SEMI_JOIN_PLAN, $plan, 'A left-joined check ran as a semi-join with '.$count.' links: '.$plan); + } + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + foreach ($links as $link) { + $database->deleteCollection($link); + } + $database->deleteCollection($customers); + } + } + + /** + * Under shared tables every index but `_uid`'s and the primary key leads with `_tenant`, and MySQL looks a + * joined table's rows up by an equality on the tenant alone, once per row the join pairs, when no index + * serves the join: the tenant's whole table every time. The join reads like the one over a table of its + * own: no lookup is considered for the table no index serves, the lookup its index serves still is. + */ + public function testJoinNoIndexServesIsNotLookedUpByTheTenant(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + $this->assertInstanceOf(RawQuery::class, $adapter); + + $customers = 'tenant_lookup_customers'; + $labels = 'tenant_lookup_labels'; + $tags = 'tenant_lookup_tags'; + $open = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: $customers, permissions: [Permission::create(Role::any())], documentSecurity: true)); + $database->createCollection(Collection::create(id: $labels, permissions: $open, documentSecurity: false)); + $database->createCollection(Collection::create(id: $tags, permissions: $open, documentSecurity: false)); + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + + try { + $this->seed($database, $customers, [Role::any(), Role::user(self::HIDDEN)]); + $this->seed($database, $labels, [Role::any(), Role::any()]); + $this->seed($database, $tags, [Role::any(), Role::any()]); + $database->createIndex($tags, Index::key('name_key', ['name'])); + + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::user('caller')->toString()); + + [$found, $trace] = $this->traced($adapter, '`named`', fn (): array => $database->find($customers, [ + Query::join($labels, 'named', [Query::on('name', 'name')]), + Query::join($tags, 'tagged', [Query::on('name', 'name')]), + Query::select(['name']), + Query::limit(100), + ])); + + $ids = \array_map(static fn (Document $document): string => $document->getId(), $found); + \sort($ids); + $this->assertSame(self::READABLE, $ids); + + $this->assertSame([], $this->lookups($trace, 'named'), 'A join no index serves is not looked up'); + $this->assertContains('name_key', $this->lookups($trace, 'tagged'), 'A join its index serves is looked up through it'); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + foreach ([$tags, $labels, $customers] as $collection) { + $database->deleteCollection($collection); + } + } + } + + /** + * Runs $read with the optimizer trace on and returns its result and the trace of the statement naming $marker. + * + * @param callable(): array $read + * @return array{array, array} + */ + private function traced(RawQuery $adapter, string $marker, callable $read): array + { + $adapter->rawMutation("SET SESSION optimizer_trace = 'enabled=on', optimizer_trace_offset = -5, optimizer_trace_limit = 5, optimizer_trace_max_mem_size = 67108864"); + + try { + $found = $read(); + $traces = $adapter->rawQuery('SELECT QUERY, TRACE FROM information_schema.OPTIMIZER_TRACE'); + } finally { + $adapter->rawMutation("SET SESSION optimizer_trace = 'enabled=off'"); + } + + $matching = []; + foreach ($traces as $trace) { + $query = $trace->getAttribute('QUERY'); + $text = $trace->getAttribute('TRACE'); + if (\is_string($query) && \is_string($text) && \str_contains($query, $marker)) { + $matching[] = $text; + } + } + $this->assertNotSame([], $matching, 'The optimizer trace must hold the read'); + + $trace = \json_decode($matching[\array_key_last($matching)], true); + $this->assertIsArray($trace, 'The optimizer trace must fit its memory'); + + return [$found, $trace]; + } + + /** + * The indexes the optimizer considered looking the rows of $alias up through. + * + * @param array $trace + * @return list + */ + private function lookups(array $trace, string $alias): array + { + $indexes = []; + $walk = static function (mixed $node) use (&$walk, &$indexes, $alias): void { + if (! \is_array($node)) { + return; + } + + $table = $node['table'] ?? null; + $best = $node['best_access_path'] ?? null; + $paths = \is_array($best) ? ($best['considered_access_paths'] ?? null) : null; + if (\is_string($table) && \str_ends_with($table, '`'.$alias.'`') && \is_array($paths)) { + foreach ($paths as $path) { + if (\is_array($path) && \in_array($path['access_type'] ?? null, ['ref', 'eq_ref'], true) && \is_string($path['index'] ?? null)) { + $indexes[$path['index']] = true; + } + } + } + + foreach ($node as $child) { + $walk($child); + } + }; + $walk($trace); + + return \array_keys($indexes); + } + + /** + * Runs $read with the optimizer trace on and returns its result and the SELECT the server received, + * with its values in place (the adapter emulates prepared statements). + * + * @param callable(): array $read + * @return array{array, string} + */ + private function tracing(RawQuery $adapter, callable $read): array + { + $adapter->rawMutation("SET SESSION optimizer_trace = 'enabled=on', optimizer_trace_offset = -5, optimizer_trace_limit = 5"); + + try { + $found = $read(); + $traces = $adapter->rawQuery('SELECT QUERY FROM information_schema.OPTIMIZER_TRACE'); + } finally { + $adapter->rawMutation("SET SESSION optimizer_trace = 'enabled=off'"); + } + + $statements = \array_values(\array_filter( + \array_map(static function (Document $trace): string { + $query = $trace->getAttribute('QUERY'); + self::assertIsString($query); + + return $query; + }, $traces), + static fn (string $query): bool => \str_contains($query, 'LEFT JOIN'), + )); + $this->assertNotSame([], $statements, 'The optimizer trace must hold the read'); + + return [$found, $statements[\array_key_last($statements)]]; + } + + private function treePlan(RawQuery $adapter, string $statement): string + { + $rows = $adapter->rawQuery('EXPLAIN FORMAT=TREE '.$statement); + $this->assertCount(1, $rows); + $plan = $rows[0]->getAttribute('EXPLAIN'); + $this->assertIsString($plan); + + return $plan; + } + + /** + * @param array{Role, Role} $readers Who may read the readable documents, and who the hidden one + */ + private function seed(Database $database, string $collection, array $readers): void + { + $adapter = $database->getAdapter(); + $this->assertInstanceOf(RawQuery::class, $adapter); + + $database->createAttribute($collection, Attribute::string(key: 'name', size: 64, required: true)); + + // InnoDB recalculates index statistics in the background, at most every ten seconds, so a + // read right after these writes plans with the empty table's. Held there, every run does. + foreach ([$collection, Storage::permissionsTable($collection)] as $table) { + $adapter->rawMutation('ALTER TABLE `'.$database->getDatabase().'`.`'.$database->getNamespace().'_'.$table.'` STATS_AUTO_RECALC = 0'); + } + + [$readable, $hidden] = $readers; + foreach ([...self::READABLE, self::HIDDEN] as $id) { + $database->createDocument($collection, new Document([ + '$id' => $id, + 'name' => \ucfirst($id), + '$permissions' => [Permission::read($id === self::HIDDEN ? $hidden : $readable)], + ])); + } + } + + /** + * @param list $joins + */ + private function assertJoinOrderSearchStaysSmall(Database $database, string $collection, array $joins): void + { + $adapter = $database->getAdapter(); + $this->assertInstanceOf(RawQuery::class, $adapter); + + $queries = [...$joins, Query::select(['name']), Query::limit(100)]; + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::user('caller')->toString()); + + try { + $found = \array_map(static fn (Document $document): string => $document->getId(), $database->find($collection, $queries)); + $findPlans = $this->partialPlans($adapter); + $total = $database->count($collection, $queries); + $countPlans = $this->partialPlans($adapter); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + } + + \sort($found); + $this->assertSame(self::READABLE, $found); + $this->assertSame(\count(self::READABLE), $total); + $this->assertLessThan(self::MAX_PARTIAL_PLANS, $findPlans, 'Partial plans the optimizer built for find()'); + $this->assertLessThan(self::MAX_PARTIAL_PLANS, $countPlans, 'Partial plans the optimizer built for count()'); + } + + private function partialPlans(RawQuery $adapter): int + { + $status = $adapter->rawQuery("SHOW SESSION STATUS LIKE 'Last_query_partial_plans'"); + $this->assertCount(1, $status); + + $plans = $status[0]->getAttribute('Value'); + $this->assertIsNumeric($plans); + $this->assertGreaterThan(0, (int) $plans, 'The status must describe the read on this session'); + + return (int) $plans; + } +} diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index eb49409dc8..d1d45231a4 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -3,6 +3,9 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -10,35 +13,47 @@ use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Index; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; trait ObjectAttributeTests { + private function createAttribute(Database $database, string $collectionId, string $attributeId, ColumnType $type, int $size, bool $required, mixed $default = null): void + { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + return; + } + + $created = $database->createAttribute($collectionId, Attribute::fromArray(['key' => $attributeId, 'type' => $type, 'size' => $size, 'required' => $required, 'default' => $default])); + $this->assertSame($attributeId, $created->key); + } + /** - * Helper function to create an attribute if adapter supports attributes, - * otherwise returns true to allow tests to continue - * - * @param Database $database - * @param string $collectionId - * @param string $attributeId - * @param string $type - * @param int $size - * @param bool $required - * @param mixed $default - * @return bool + * @param array $data + * @param non-empty-list $path */ - private function createAttribute(Database $database, string $collectionId, string $attributeId, string $type, int $size, bool $required, $default = null): bool + private function nestedMetaValue(array $data, array $path): mixed { - if (!$database->getAdapter()->getSupportForAttributes()) { - return true; + $current = $data; + $lastIndex = count($path) - 1; + foreach ($path as $index => $key) { + $this->assertArrayHasKey($key, $current); + if ($index === $lastIndex) { + return $current[$key]; + } + $nested = $current[$key]; + $this->assertIsArray($nested); + $current = $nested; } - $result = $database->createAttribute($collectionId, $attributeId, $type, $size, $required, $default); - $this->assertEquals(true, $result); - return $result; + return $current; } public function testObjectAttribute(): void @@ -47,15 +62,15 @@ public function testObjectAttribute(): void $database = static::getDatabase(); // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { + if (! $database->getAdapter()->supports(Capability::Objects)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Create object attribute - $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); // Test 1: Create and read document with object attribute $doc1 = $database->createDocument($collectionId, new Document([ @@ -66,20 +81,25 @@ public function testObjectAttribute(): void 'skills' => ['react', 'node'], 'user' => [ 'info' => [ - 'country' => 'IN' - ] - ] - ] + 'country' => 'IN', + ], + ], + ], ])); $this->assertIsArray($doc1->getAttribute('meta')); - $this->assertEquals(25, $doc1->getAttribute('meta')['age']); - $this->assertEquals(['react', 'node'], $doc1->getAttribute('meta')['skills']); - $this->assertEquals('IN', $doc1->getAttribute('meta')['user']['info']['country']); + $meta = $doc1->getArray('meta'); + $this->assertEquals(25, $meta['age']); + $this->assertEquals(['react', 'node'], $meta['skills']); + $user = $meta['user'] ?? null; + $this->assertIsArray($user); + $info = $user['info'] ?? null; + $this->assertIsArray($info); + $this->assertEquals('IN', $info['country']); // Test 2: Query::equal with simple key-value pair $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 25]]) + Query::equal('meta', [['age' => 25]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); @@ -89,17 +109,17 @@ public function testObjectAttribute(): void Query::equal('meta', [[ 'user' => [ 'info' => [ - 'country' => 'IN' - ] - ] - ]]) + 'country' => 'IN', + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); - // Test 4: Query::contains for array element + // Test 4: Query::containsAny for array element $results = $database->find($collectionId, [ - Query::contains('meta', [['skills' => 'react']]) + Query::containsAny('meta', [['skills' => 'react']]), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); @@ -113,15 +133,15 @@ public function testObjectAttribute(): void 'skills' => ['python', 'java'], 'user' => [ 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ])); // Test 6: Query should return only doc1 $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 25]]) + Query::equal('meta', [['age' => 25]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); @@ -131,10 +151,10 @@ public function testObjectAttribute(): void Query::equal('meta', [[ 'user' => [ 'info' => [ - 'country' => 'US' - ] - ] - ]]) + 'country' => 'US', + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc2', $results[0]->getId()); @@ -148,39 +168,44 @@ public function testObjectAttribute(): void 'skills' => ['react', 'node', 'typescript'], 'user' => [ 'info' => [ - 'country' => 'CA' - ] - ] - ] + 'country' => 'CA', + ], + ], + ], ])); - $this->assertEquals(26, $updatedDoc->getAttribute('meta')['age']); - $this->assertEquals(['react', 'node', 'typescript'], $updatedDoc->getAttribute('meta')['skills']); - $this->assertEquals('CA', $updatedDoc->getAttribute('meta')['user']['info']['country']); + $updatedMeta = $updatedDoc->getArray('meta'); + $this->assertEquals(26, $updatedMeta['age']); + $this->assertEquals(['react', 'node', 'typescript'], $updatedMeta['skills']); + $updatedUser = $updatedMeta['user'] ?? null; + $this->assertIsArray($updatedUser); + $updatedInfo = $updatedUser['info'] ?? null; + $this->assertIsArray($updatedInfo); + $this->assertEquals('CA', $updatedInfo['country']); // Test 9: Query updated document $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 26]]) + Query::equal('meta', [['age' => 26]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); - // Test 10: Query with multiple conditions using contains + // Test 10: Query with multiple conditions using containsAny $results = $database->find($collectionId, [ - Query::contains('meta', [['skills' => 'typescript']]) + Query::containsAny('meta', [['skills' => 'typescript']]), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); // Test 11: Negative test - query that shouldn't match $results = $database->find($collectionId, [ - Query::equal('meta', [['age' => 99]]) + Query::equal('meta', [['age' => 99]]), ]); $this->assertCount(0, $results); // Test 11d: notEqual on scalar inside object should exclude doc1 $results = $database->find($collectionId, [ - Query::notEqual('meta', ['age' => 26]) + Query::notEqual('meta', ['age' => 26]), ]); // Should return doc2 only $this->assertCount(1, $results); @@ -189,7 +214,7 @@ public function testObjectAttribute(): void try { // test -> not equal allows one value only $results = $database->find($collectionId, [ - Query::notEqual('meta', [['age' => 26], ['age' => 27]]) + Query::notEqual('meta', [['age' => 26], ['age' => 27]]), ]); $this->fail('No query thrown'); } catch (Exception $e) { @@ -201,10 +226,10 @@ public function testObjectAttribute(): void Query::notEqual('meta', [ 'user' => [ 'info' => [ - 'country' => 'CA' - ] - ] - ]) + 'country' => 'CA', + ], + ], + ]), ]); // Should return doc2 only $this->assertCount(1, $results); @@ -214,24 +239,29 @@ public function testObjectAttribute(): void $fetchedDoc = $database->getDocument($collectionId, 'doc1'); $this->assertEquals('doc1', $fetchedDoc->getId()); $this->assertIsArray($fetchedDoc->getAttribute('meta')); - $this->assertEquals(26, $fetchedDoc->getAttribute('meta')['age']); - $this->assertEquals(['react', 'node', 'typescript'], $fetchedDoc->getAttribute('meta')['skills']); - $this->assertEquals('CA', $fetchedDoc->getAttribute('meta')['user']['info']['country']); + $fetchedMeta = $fetchedDoc->getArray('meta'); + $this->assertEquals(26, $fetchedMeta['age']); + $this->assertEquals(['react', 'node', 'typescript'], $fetchedMeta['skills']); + $fetchedUser = $fetchedMeta['user'] ?? null; + $this->assertIsArray($fetchedUser); + $fetchedInfo = $fetchedUser['info'] ?? null; + $this->assertIsArray($fetchedInfo); + $this->assertEquals('CA', $fetchedInfo['country']); // Test 11b: Test Query::select to limit returned attributes $results = $database->find($collectionId, [ Query::select(['$id', 'meta']), - Query::equal('meta', [['age' => 26]]) + Query::equal('meta', [['age' => 26]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc1', $results[0]->getId()); $this->assertIsArray($results[0]->getAttribute('meta')); - $this->assertEquals(26, $results[0]->getAttribute('meta')['age']); + $this->assertEquals(26, $results[0]->getArray('meta')['age']); // Test 11c: Test Query::select with only $id (exclude meta) $results = $database->find($collectionId, [ Query::select(['$id']), - Query::equal('meta', [['age' => 30]]) + Query::equal('meta', [['age' => 30]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc2', $results[0]->getId()); @@ -242,7 +272,7 @@ public function testObjectAttribute(): void $doc3 = $database->createDocument($collectionId, new Document([ '$id' => 'doc3', '$permissions' => [Permission::read(Role::any())], - 'meta' => null + 'meta' => null, ])); $this->assertNull($doc3->getAttribute('meta')); @@ -250,7 +280,7 @@ public function testObjectAttribute(): void $doc4 = $database->createDocument($collectionId, new Document([ '$id' => 'doc4', '$permissions' => [Permission::read(Role::any())], - 'meta' => [] + 'meta' => [], ])); $this->assertIsArray($doc4->getAttribute('meta')); $this->assertEmpty($doc4->getAttribute('meta')); @@ -264,14 +294,14 @@ public function testObjectAttribute(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ] + 'level5' => 'deep_value', + ], + ], + ], + ], + ], ])); - $this->assertEquals('deep_value', $doc5->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); + $this->assertEquals('deep_value', $this->nestedMetaValue($doc5->getArray('meta'), ['level1', 'level2', 'level3', 'level4', 'level5'])); // Test 15: Query deeply nested structure $results = $database->find($collectionId, [ @@ -280,12 +310,12 @@ public function testObjectAttribute(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ]]) + 'level5' => 'deep_value', + ], + ], + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc5', $results[0]->getId()); @@ -297,12 +327,12 @@ public function testObjectAttribute(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ]]) + 'level5' => 'deep_value', + ], + ], + ], + ], + ]]), ]); $this->assertCount(1, $results); @@ -317,73 +347,73 @@ public function testObjectAttribute(): void 'boolean' => true, 'null_value' => null, 'array' => [1, 2, 3], - 'object' => ['key' => 'value'] - ] + 'object' => ['key' => 'value'], + ], ])); - $this->assertEquals('text', $doc6->getAttribute('meta')['string']); - $this->assertEquals(42, $doc6->getAttribute('meta')['number']); - $this->assertEquals(3.14, $doc6->getAttribute('meta')['float']); - $this->assertTrue($doc6->getAttribute('meta')['boolean']); - $this->assertNull($doc6->getAttribute('meta')['null_value']); + $this->assertEquals('text', $doc6->getArray('meta')['string']); + $this->assertEquals(42, $doc6->getArray('meta')['number']); + $this->assertEquals(3.14, $doc6->getArray('meta')['float']); + $this->assertTrue($doc6->getArray('meta')['boolean']); + $this->assertNull($doc6->getArray('meta')['null_value']); // Test 18: Query with boolean value $results = $database->find($collectionId, [ - Query::equal('meta', [['boolean' => true]]) + Query::equal('meta', [['boolean' => true]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc6', $results[0]->getId()); // Test 19: Query with numeric value $results = $database->find($collectionId, [ - Query::equal('meta', [['number' => 42]]) + Query::equal('meta', [['number' => 42]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc6', $results[0]->getId()); // Test 20: Query with float value $results = $database->find($collectionId, [ - Query::equal('meta', [['float' => 3.14]]) + Query::equal('meta', [['float' => 3.14]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc6', $results[0]->getId()); - // Test 21: Test contains with multiple array elements + // Test 21: Test containsAny with multiple array elements $doc7 = $database->createDocument($collectionId, new Document([ '$id' => 'doc7', '$permissions' => [Permission::read(Role::any())], 'meta' => [ - 'tags' => ['php', 'javascript', 'python', 'go', 'rust'] - ] + 'tags' => ['php', 'javascript', 'python', 'go', 'rust'], + ], ])); $results = $database->find($collectionId, [ - Query::contains('meta', [['tags' => 'rust']]) + Query::containsAny('meta', [['tags' => 'rust']]), ]); $this->assertCount(1, $results); $this->assertEquals('doc7', $results[0]->getId()); - // Test 22: Test contains with numeric array element + // Test 22: Test containsAny with numeric array element $doc8 = $database->createDocument($collectionId, new Document([ '$id' => 'doc8', '$permissions' => [Permission::read(Role::any())], 'meta' => [ - 'scores' => [85, 90, 95, 100] - ] + 'scores' => [85, 90, 95, 100], + ], ])); $results = $database->find($collectionId, [ - Query::contains('meta', [['scores' => 95]]) + Query::containsAny('meta', [['scores' => 95]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc8', $results[0]->getId()); - // Test 23: Negative test - contains query that shouldn't match + // Test 23: Negative test - containsAny query that shouldn't match $results = $database->find($collectionId, [ - Query::contains('meta', [['tags' => 'kotlin']]) + Query::containsAny('meta', [['tags' => 'kotlin']]), ]); $this->assertCount(0, $results); // Test 23b: notContains should exclude doc7 (which has 'rust') $results = $database->find($collectionId, [ - Query::notContains('meta', [['tags' => 'rust']]) + Query::notContains('meta', [['tags' => 'rust']]), ]); // Should not include doc7; returns others (at least doc1, doc2, ...) $this->assertGreaterThanOrEqual(1, count($results)); @@ -402,24 +432,27 @@ public function testObjectAttribute(): void [ 'name' => 'Project A', 'technologies' => ['react', 'node'], - 'active' => true + 'active' => true, ], [ 'name' => 'Project B', 'technologies' => ['vue', 'python'], - 'active' => false - ] + 'active' => false, + ], ], - 'company' => 'TechCorp' - ] + 'company' => 'TechCorp', + ], ])); - $this->assertIsArray($doc9->getAttribute('meta')['projects']); - $this->assertCount(2, $doc9->getAttribute('meta')['projects']); - $this->assertEquals('Project A', $doc9->getAttribute('meta')['projects'][0]['name']); + $projects = $doc9->getArray('meta')['projects'] ?? null; + $this->assertIsArray($projects); + $this->assertCount(2, $projects); + $project = $projects[0] ?? null; + $this->assertIsArray($project); + $this->assertEquals('Project A', $project['name']); // Test 25: Query using equal with nested key $results = $database->find($collectionId, [ - Query::equal('meta', [['company' => 'TechCorp']]) + Query::equal('meta', [['company' => 'TechCorp']]), ]); $this->assertCount(1, $results); $this->assertEquals('doc9', $results[0]->getId()); @@ -431,15 +464,15 @@ public function testObjectAttribute(): void [ 'name' => 'Project A', 'technologies' => ['react', 'node'], - 'active' => true + 'active' => true, ], [ 'name' => 'Project B', 'technologies' => ['vue', 'python'], - 'active' => false - ] - ] - ]]) + 'active' => false, + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc9', $results[0]->getId()); @@ -451,15 +484,15 @@ public function testObjectAttribute(): void 'meta' => [ 'description' => 'Test with "quotes" and \'apostrophes\'', 'emoji' => '🚀🎉', - 'symbols' => '@#$%^&*()' - ] + 'symbols' => '@#$%^&*()', + ], ])); - $this->assertEquals('Test with "quotes" and \'apostrophes\'', $doc10->getAttribute('meta')['description']); - $this->assertEquals('🚀🎉', $doc10->getAttribute('meta')['emoji']); + $this->assertEquals('Test with "quotes" and \'apostrophes\'', $doc10->getArray('meta')['description']); + $this->assertEquals('🚀🎉', $doc10->getArray('meta')['emoji']); // Test 27: Query with special characters $results = $database->find($collectionId, [ - Query::equal('meta', [['emoji' => '🚀🎉']]) + Query::equal('meta', [['emoji' => '🚀🎉']]), ]); $this->assertCount(1, $results); $this->assertEquals('doc10', $results[0]->getId()); @@ -471,19 +504,19 @@ public function testObjectAttribute(): void 'meta' => [ 'config' => [ 'theme' => 'dark', - 'language' => 'en' - ] - ] + 'language' => 'en', + ], + ], ])); $results = $database->find($collectionId, [ - Query::equal('meta', [['config' => ['theme' => 'dark', 'language' => 'en']]]) + Query::equal('meta', [['config' => ['theme' => 'dark', 'language' => 'en']]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc11', $results[0]->getId()); // Test 29: Negative test - partial object match should still work (containment) $results = $database->find($collectionId, [ - Query::equal('meta', [['config' => ['theme' => 'dark']]]) + Query::equal('meta', [['config' => ['theme' => 'dark']]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc11', $results[0]->getId()); @@ -492,7 +525,7 @@ public function testObjectAttribute(): void $updatedDoc11 = $database->updateDocument($collectionId, 'doc11', new Document([ '$id' => 'doc11', '$permissions' => [Permission::read(Role::any())], - 'meta' => [] + 'meta' => [], ])); $this->assertIsArray($updatedDoc11->getAttribute('meta')); $this->assertEmpty($updatedDoc11->getAttribute('meta')); @@ -505,16 +538,17 @@ public function testObjectAttribute(): void 'matrix' => [ [1, 2, 3], [4, 5, 6], - [7, 8, 9] - ] - ] + [7, 8, 9], + ], + ], ])); - $this->assertIsArray($doc12->getAttribute('meta')['matrix']); - $this->assertEquals([1, 2, 3], $doc12->getAttribute('meta')['matrix'][0]); + $matrix = $doc12->getArray('meta')['matrix'] ?? null; + $this->assertIsArray($matrix); + $this->assertEquals([1, 2, 3], $matrix[0]); - // Test 32: Contains query with nested array + // Test 32: ContainsAny query with nested array $results = $database->find($collectionId, [ - Query::contains('meta', [['matrix' => [[4, 5, 6]]]]) + Query::containsAny('meta', [['matrix' => [[4, 5, 6]]]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc12', $results[0]->getId()); @@ -522,13 +556,13 @@ public function testObjectAttribute(): void // Test 33: Test getDocument with various documents $fetchedDoc6 = $database->getDocument($collectionId, 'doc6'); $this->assertEquals('doc6', $fetchedDoc6->getId()); - $this->assertEquals('text', $fetchedDoc6->getAttribute('meta')['string']); - $this->assertEquals(42, $fetchedDoc6->getAttribute('meta')['number']); - $this->assertTrue($fetchedDoc6->getAttribute('meta')['boolean']); + $this->assertEquals('text', $fetchedDoc6->getArray('meta')['string']); + $this->assertEquals(42, $fetchedDoc6->getArray('meta')['number']); + $this->assertTrue($fetchedDoc6->getArray('meta')['boolean']); $fetchedDoc10 = $database->getDocument($collectionId, 'doc10'); - $this->assertEquals('🚀🎉', $fetchedDoc10->getAttribute('meta')['emoji']); - $this->assertEquals('Test with "quotes" and \'apostrophes\'', $fetchedDoc10->getAttribute('meta')['description']); + $this->assertEquals('🚀🎉', $fetchedDoc10->getArray('meta')['emoji']); + $this->assertEquals('Test with "quotes" and \'apostrophes\'', $fetchedDoc10->getArray('meta')['description']); // Test 34: Test Query::select with complex nested structures $results = $database->find($collectionId, [ @@ -538,21 +572,21 @@ public function testObjectAttribute(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ]]) + 'level5' => 'deep_value', + ], + ], + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('doc5', $results[0]->getId()); - $this->assertEquals('deep_value', $results[0]->getAttribute('meta')['level1']['level2']['level3']['level4']['level5']); + $this->assertEquals('deep_value', $this->nestedMetaValue($results[0]->getArray('meta'), ['level1', 'level2', 'level3', 'level4', 'level5'])); // Test 35: Test selecting multiple documents and verifying object attributes $allDocs = $database->find($collectionId, [ Query::select(['$id', 'meta']), - Query::limit(25) + Query::limit(25), ]); $this->assertGreaterThan(10, count($allDocs)); @@ -567,11 +601,11 @@ public function testObjectAttribute(): void // Test 36: Test Query::select with only meta attribute $results = $database->find($collectionId, [ Query::select(['meta']), - Query::equal('meta', [['tags' => ['php', 'javascript', 'python', 'go', 'rust']]]) + Query::equal('meta', [['tags' => ['php', 'javascript', 'python', 'go', 'rust']]]), ]); $this->assertCount(1, $results); $this->assertIsArray($results[0]->getAttribute('meta')); - $this->assertEquals(['php', 'javascript', 'python', 'go', 'rust'], $results[0]->getAttribute('meta')['tags']); + $this->assertEquals(['php', 'javascript', 'python', 'go', 'rust'], $results[0]->getArray('meta')['tags']); // Clean up $database->deleteCollection($collectionId); @@ -582,19 +616,18 @@ public function testObjectAttributeGinIndex(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForObjectIndexes()) { + if (! $database->getAdapter()->supports(Capability::IndexObject)) { $this->markTestSkipped('Adapter does not support object indexes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Create object attribute - $this->createAttribute($database, $collectionId, 'data', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'data', ColumnType::Object, 0, false); // Test 1: Create Object index on object attribute - $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::INDEX_OBJECT, ['data']); - $this->assertTrue($ginIndex); + $database->createIndex($collectionId, Index::object(key: 'idx_data_gin', attribute: 'data')); // Test 2: Create documents with JSONB data $doc1 = $database->createDocument($collectionId, new Document([ @@ -604,10 +637,10 @@ public function testObjectAttributeGinIndex(): void 'tags' => ['php', 'javascript', 'python'], 'config' => [ 'env' => 'production', - 'debug' => false + 'debug' => false, ], - 'version' => '1.0.0' - ] + 'version' => '1.0.0', + ], ])); $doc2 = $database->createDocument($collectionId, new Document([ @@ -617,39 +650,39 @@ public function testObjectAttributeGinIndex(): void 'tags' => ['java', 'kotlin', 'scala'], 'config' => [ 'env' => 'development', - 'debug' => true + 'debug' => true, ], - 'version' => '2.0.0' - ] + 'version' => '2.0.0', + ], ])); // Test 3: Query with equal on indexed JSONB column $results = $database->find($collectionId, [ - Query::equal('data', [['config' => ['env' => 'production']]]) + Query::equal('data', [['config' => ['env' => 'production']]]), ]); $this->assertCount(1, $results); $this->assertEquals('gin1', $results[0]->getId()); // Test 4: Query with contains on indexed JSONB column $results = $database->find($collectionId, [ - Query::contains('data', [['tags' => 'php']]) + new Query(Method::Contains, 'data', [['tags' => 'php']]), ]); $this->assertCount(1, $results); $this->assertEquals('gin1', $results[0]->getId()); // Test 5: Verify Object index improves performance for containment queries $results = $database->find($collectionId, [ - Query::contains('data', [['tags' => 'kotlin']]) + new Query(Method::Contains, 'data', [['tags' => 'kotlin']]), ]); $this->assertCount(1, $results); $this->assertEquals('gin2', $results[0]->getId()); // Test 6: Try to create Object index on non-object attribute (should fail) - $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); + $this->createAttribute($database, $collectionId, 'name', ColumnType::String, 255, false); $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_name_gin', Database::INDEX_OBJECT, ['name']); + $database->createIndex($collectionId, Index::object(key: 'idx_name_gin', attribute: 'name')); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -658,11 +691,11 @@ public function testObjectAttributeGinIndex(): void $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index on non-object attribute'); // Test 7: Try to create Object index on multiple attributes (should fail) - $this->createAttribute($database, $collectionId, 'metadata', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'metadata', ColumnType::Object, 0, false); $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_multi_gin', Database::INDEX_OBJECT, ['data', 'metadata']); + $database->createIndex($collectionId, Index::fromArray(['key' => 'idx_multi_gin', 'type' => IndexType::Object, 'attributes' => ['data', 'metadata']])); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -673,7 +706,7 @@ public function testObjectAttributeGinIndex(): void // Test 8: Try to create Object index with orders (should fail) $exceptionThrown = false; try { - $database->createIndex($collectionId, 'idx_ordered_gin', Database::INDEX_OBJECT, ['metadata'], [], [Database::ORDER_ASC]); + $database->createIndex($collectionId, Index::fromArray(['key' => 'idx_ordered_gin', 'type' => IndexType::Object, 'attributes' => ['metadata'], 'orders' => [OrderDirection::Asc]])); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -690,60 +723,33 @@ public function testObjectAttributeInvalidCases(): void /** @var Database $database */ $database = static::getDatabase(); - // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::Objects) || ! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); - - // Create object attribute - $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); - // Test 1: Try to create document with string instead of object (should fail) - $exceptionThrown = false; - try { - $database->createDocument($collectionId, new Document([ - '$id' => 'invalid1', - '$permissions' => [Permission::read(Role::any())], - 'meta' => 'this is a string not an object' - ])); - } catch (\Exception $e) { - $exceptionThrown = true; - $this->assertInstanceOf(StructureException::class, $e); - } - $this->assertTrue($exceptionThrown, 'Expected Structure exception for string value'); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); - // Test 2: Try to create document with integer instead of object (should fail) - $exceptionThrown = false; - try { - $database->createDocument($collectionId, new Document([ - '$id' => 'invalid2', - '$permissions' => [Permission::read(Role::any())], - 'meta' => 12345 - ])); - } catch (\Exception $e) { - $exceptionThrown = true; - $this->assertInstanceOf(StructureException::class, $e); - } - $this->assertTrue($exceptionThrown, 'Expected Structure exception for integer value'); - - // Test 3: Try to create document with boolean instead of object (should fail) - $exceptionThrown = false; - try { - $database->createDocument($collectionId, new Document([ - '$id' => 'invalid3', - '$permissions' => [Permission::read(Role::any())], - 'meta' => true - ])); - } catch (\Exception $e) { - $exceptionThrown = true; - $this->assertInstanceOf(StructureException::class, $e); + $invalidValues = [ + 'invalid1' => 'this is a string not an object', + 'invalid2' => 12345, + 'invalid3' => true, + ]; + foreach ($invalidValues as $id => $value) { + try { + $database->createDocument($collectionId, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'meta' => $value, + ])); + $this->fail('Expected Structure exception for a '.\get_debug_type($value).' value'); + } catch (\Throwable $exception) { + $this->assertInstanceOf(StructureException::class, $exception); + } } - $this->assertTrue($exceptionThrown, 'Expected Structure exception for boolean value'); - // Test 4: Create valid document for query tests $database->createDocument($collectionId, new Document([ '$id' => 'valid1', '$permissions' => [Permission::read(Role::any())], @@ -752,135 +758,131 @@ public function testObjectAttributeInvalidCases(): void 'age' => 30, 'settings' => [ 'notifications' => true, - 'theme' => 'dark' - ] - ] + 'theme' => 'dark', + ], + ], ])); - // Test 5: Query with non-matching nested structure $results = $database->find($collectionId, [ - Query::equal('meta', [['settings' => ['notifications' => false]]]) + Query::equal('meta', [['settings' => ['notifications' => false]]]), ]); $this->assertCount(0, $results, 'Should not match when nested value differs'); - // Test 6: Query with non-existent key $results = $database->find($collectionId, [ - Query::equal('meta', [['nonexistent' => 'value']]) + Query::equal('meta', [['settings' => ['notifications' => true]]]), + ]); + $this->assertSame(['valid1'], array_map(fn (Document $document) => $document->getId(), $results), 'Should match when the nested value is equal'); + + $results = $database->find($collectionId, [ + Query::equal('meta', [['nonexistent' => 'value']]), ]); $this->assertCount(0, $results, 'Should not match non-existent keys'); - // Test 7: Contains query with non-matching array element $database->createDocument($collectionId, new Document([ '$id' => 'valid2', '$permissions' => [Permission::read(Role::any())], 'meta' => [ - 'fruits' => ['apple', 'banana', 'orange'] - ] + 'fruits' => ['apple', 'banana', 'orange'], + ], ])); $results = $database->find($collectionId, [ - Query::contains('meta', [['fruits' => 'grape']]) + Query::containsAny('meta', [['fruits' => 'grape']]), ]); $this->assertCount(0, $results, 'Should not match non-existent array element'); - // Test 8: Test order preservation in nested objects - $doc = $database->createDocument($collectionId, new Document([ + $results = $database->find($collectionId, [ + Query::containsAny('meta', [['fruits' => 'banana']]), + ]); + $this->assertSame(['valid2'], array_map(fn (Document $document) => $document->getId(), $results), 'Should match an existing array element'); + + $orderTest = $database->createDocument($collectionId, new Document([ '$id' => 'order_test', '$permissions' => [Permission::read(Role::any())], 'meta' => [ 'z_last' => 'value', 'a_first' => 'value', - 'm_middle' => 'value' - ] + 'm_middle' => 'value', + ], ])); - $meta = $doc->getAttribute('meta'); + $meta = $orderTest->getAttribute('meta'); $this->assertIsArray($meta); - // Note: JSON objects don't guarantee key order, but we can verify all keys exist $this->assertArrayHasKey('z_last', $meta); $this->assertArrayHasKey('a_first', $meta); $this->assertArrayHasKey('m_middle', $meta); - // Test 9: Test with very large nested structure $largeStructure = []; - for ($i = 0; $i < 50; $i++) { - $largeStructure["key_$i"] = [ - 'id' => $i, - 'name' => "Item $i", - 'values' => range(1, 10) + for ($index = 0; $index < 50; $index++) { + $largeStructure["key_{$index}"] = [ + 'id' => $index, + 'name' => "Item {$index}", + 'values' => range(1, 10), ]; } - $docLarge = $database->createDocument($collectionId, new Document([ + $large = $database->createDocument($collectionId, new Document([ '$id' => 'large_structure', '$permissions' => [Permission::read(Role::any())], - 'meta' => $largeStructure + 'meta' => $largeStructure, ])); - $this->assertIsArray($docLarge->getAttribute('meta')); - $this->assertCount(50, $docLarge->getAttribute('meta')); + $this->assertIsArray($large->getAttribute('meta')); + $this->assertCount(50, $large->getArray('meta')); - // Test 10: Query within large structure $results = $database->find($collectionId, [ - Query::equal('meta', [['key_25' => ['id' => 25, 'name' => 'Item 25', 'values' => range(1, 10)]]]) + Query::equal('meta', [['key_25' => ['id' => 25, 'name' => 'Item 25', 'values' => range(1, 10)]]]), ]); $this->assertCount(1, $results); - $this->assertEquals('large_structure', $results[0]->getId()); + $this->assertSame('large_structure', $results[0]->getId()); - // Test 11: Test getDocument with large structure - $fetchedLargeDoc = $database->getDocument($collectionId, 'large_structure'); - $this->assertEquals('large_structure', $fetchedLargeDoc->getId()); - $this->assertIsArray($fetchedLargeDoc->getAttribute('meta')); - $this->assertCount(50, $fetchedLargeDoc->getAttribute('meta')); - $this->assertEquals(25, $fetchedLargeDoc->getAttribute('meta')['key_25']['id']); - $this->assertEquals('Item 25', $fetchedLargeDoc->getAttribute('meta')['key_25']['name']); + $fetchedLarge = $database->getDocument($collectionId, 'large_structure'); + $this->assertSame('large_structure', $fetchedLarge->getId()); + $this->assertIsArray($fetchedLarge->getAttribute('meta')); + $this->assertCount(50, $fetchedLarge->getArray('meta')); + $this->assertSame(25, $this->nestedMetaValue($fetchedLarge->getArray('meta'), ['key_25', 'id'])); + $this->assertSame('Item 25', $this->nestedMetaValue($fetchedLarge->getArray('meta'), ['key_25', 'name'])); - // Test 12: Test Query::select with valid document $results = $database->find($collectionId, [ Query::select(['$id', 'meta']), - Query::equal('meta', [['name' => 'John']]) + Query::equal('meta', [['name' => 'John']]), ]); $this->assertCount(1, $results); - $this->assertEquals('valid1', $results[0]->getId()); + $this->assertSame('valid1', $results[0]->getId()); $this->assertIsArray($results[0]->getAttribute('meta')); - $this->assertEquals('John', $results[0]->getAttribute('meta')['name']); - $this->assertEquals(30, $results[0]->getAttribute('meta')['age']); + $this->assertSame('John', $this->nestedMetaValue($results[0]->getArray('meta'), ['name'])); + $this->assertSame(30, $this->nestedMetaValue($results[0]->getArray('meta'), ['age'])); - // Test 13: Test getDocument returns proper structure $fetchedValid1 = $database->getDocument($collectionId, 'valid1'); - $this->assertEquals('valid1', $fetchedValid1->getId()); + $this->assertSame('valid1', $fetchedValid1->getId()); $this->assertIsArray($fetchedValid1->getAttribute('meta')); - $this->assertEquals('John', $fetchedValid1->getAttribute('meta')['name']); - $this->assertTrue($fetchedValid1->getAttribute('meta')['settings']['notifications']); - $this->assertEquals('dark', $fetchedValid1->getAttribute('meta')['settings']['theme']); + $this->assertSame('John', $this->nestedMetaValue($fetchedValid1->getArray('meta'), ['name'])); + $this->assertTrue($this->nestedMetaValue($fetchedValid1->getArray('meta'), ['settings', 'notifications'])); + $this->assertSame('dark', $this->nestedMetaValue($fetchedValid1->getArray('meta'), ['settings', 'theme'])); - // Test 14: Test Query::select excluding meta $results = $database->find($collectionId, [ Query::select(['$id', '$permissions']), - Query::equal('meta', [['fruits' => ['apple', 'banana', 'orange']]]) + Query::equal('meta', [['fruits' => ['apple', 'banana', 'orange']]]), ]); $this->assertCount(1, $results); - $this->assertEquals('valid2', $results[0]->getId()); - // Meta should be empty when not selected - $this->assertEmpty($results[0]->getAttribute('meta')); + $this->assertSame('valid2', $results[0]->getId()); + $this->assertEmpty($results[0]->getAttribute('meta'), 'An unselected object attribute must not be returned'); - // Test 15: Test getDocument with non-existent ID returns empty document - $nonExistent = $database->getDocument($collectionId, 'does_not_exist'); - $this->assertTrue($nonExistent->isEmpty()); + $this->assertTrue($database->getDocument($collectionId, 'does_not_exist')->isEmpty()); - // Test 16: with multiple json $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->createAttribute($database, $collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings); + $this->createAttribute($database, $collectionId, 'settings', ColumnType::Object, 0, false, $defaultSettings); $database->createDocument($collectionId, new Document(['$permissions' => [Permission::read(Role::any())]])); - $database->createDocument($collectionId, new Document(['settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']], '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($collectionId, new Document([ + 'settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']], + '$permissions' => [Permission::read(Role::any())], + ])); $results = $database->find($collectionId, [ - Query::equal('settings', [['config' => ['theme' => 'light']], ['config' => ['theme' => 'dark']]]) + Query::equal('settings', [['config' => ['theme' => 'light']], ['config' => ['theme' => 'dark']]]), ]); $this->assertCount(2, $results); $results = $database->find($collectionId, [ - // Containment: both documents have config.lang == 'en' - Query::contains('settings', [['config' => ['lang' => 'en']]]) + Query::containsAny('settings', [['config' => ['lang' => 'en']]]), ]); $this->assertCount(2, $results); - // Clean up $database->deleteCollection($collectionId); } @@ -889,78 +891,51 @@ public function testObjectAttributeDefaults(): void /** @var Database $database */ $database = static::getDatabase(); - // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::Objects) || ! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); - - // 1) Default empty object - $this->createAttribute($database, $collectionId, 'metaDefaultEmpty', Database::VAR_OBJECT, 0, false, []); - - // 2) Default nested object - $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->createAttribute($database, $collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings); - - // 3) Required without default (should fail when missing) - $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, true, null); - - // 4) Required with default (should auto-populate) - $this->createAttribute($database, $collectionId, 'profile2', Database::VAR_OBJECT, 0, false, ['name' => 'anon']); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); - // 5) Explicit null default - $this->createAttribute($database, $collectionId, 'misc', Database::VAR_OBJECT, 0, false, null); + $this->createAttribute($database, $collectionId, 'metaDefaultEmpty', ColumnType::Object, 0, false, []); + $this->createAttribute($database, $collectionId, 'settings', ColumnType::Object, 0, false, ['config' => ['theme' => 'light', 'lang' => 'en']]); + $this->createAttribute($database, $collectionId, 'profile', ColumnType::Object, 0, true, null); + $this->createAttribute($database, $collectionId, 'profile2', ColumnType::Object, 0, false, ['name' => 'anon']); + $this->createAttribute($database, $collectionId, 'misc', ColumnType::Object, 0, false, null); - // Create document missing all above attributes - $exceptionThrown = false; try { - $doc = $database->createDocument($collectionId, new Document([ + $database->createDocument($collectionId, new Document([ '$id' => 'def1', '$permissions' => [Permission::read(Role::any())], ])); - // Should not reach here because 'profile' is required and missing - } catch (\Exception $e) { - $exceptionThrown = true; - $this->assertInstanceOf(StructureException::class, $e); + $this->fail('Expected Structure exception for missing required object attribute'); + } catch (\Throwable $exception) { + $this->assertInstanceOf(StructureException::class, $exception); } - $this->assertTrue($exceptionThrown, 'Expected Structure exception for missing required object attribute'); - // Create document providing required 'profile' but omit others to test defaults - $doc = $database->createDocument($collectionId, new Document([ + $document = $database->createDocument($collectionId, new Document([ '$id' => 'def2', '$permissions' => [Permission::read(Role::any())], 'profile' => ['name' => 'provided'], ])); - // metaDefaultEmpty should default to [] - $this->assertIsArray($doc->getAttribute('metaDefaultEmpty')); - $this->assertEmpty($doc->getAttribute('metaDefaultEmpty')); - - // settings should default to nested object - $this->assertIsArray($doc->getAttribute('settings')); - $this->assertEquals('light', $doc->getAttribute('settings')['config']['theme']); - $this->assertEquals('en', $doc->getAttribute('settings')['config']['lang']); - - // profile provided explicitly - $this->assertEquals('provided', $doc->getAttribute('profile')['name']); + $this->assertIsArray($document->getAttribute('metaDefaultEmpty')); + $this->assertEmpty($document->getAttribute('metaDefaultEmpty')); + $this->assertIsArray($document->getAttribute('settings')); + $this->assertSame('light', $this->nestedMetaValue($document->getArray('settings'), ['config', 'theme'])); + $this->assertSame('en', $this->nestedMetaValue($document->getArray('settings'), ['config', 'lang'])); + $this->assertSame('provided', $this->nestedMetaValue($document->getArray('profile'), ['name'])); + $this->assertIsArray($document->getAttribute('profile2')); + $this->assertSame('anon', $this->nestedMetaValue($document->getArray('profile2'), ['name'])); + $this->assertNull($document->getAttribute('misc')); - // profile2 required with default should be auto-populated - $this->assertIsArray($doc->getAttribute('profile2')); - $this->assertEquals('anon', $doc->getAttribute('profile2')['name']); - - // misc explicit null default remains null when omitted - $this->assertNull($doc->getAttribute('misc')); - - // Query defaults work $results = $database->find($collectionId, [ - Query::equal('settings', [['config' => ['theme' => 'light']]]) + Query::equal('settings', [['config' => ['theme' => 'light']]]), ]); - $this->assertCount(1, $results); - $this->assertEquals('def2', $results[0]->getId()); + $this->assertCount(1, $results, 'A materialised object default must be stored, not only returned'); + $this->assertSame('def2', $results[0]->getId()); - // Clean up $database->deleteCollection($collectionId); } @@ -969,13 +944,13 @@ public function testObjectAttributeIntegersBeyondInt32(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForObject()) { + if (!$database->getAdapter()->supports(Capability::Objects)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); - $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); // An object attribute has no per-key schema, so there is no typed cast // to lean on: whatever the adapter decodes is what reaches the client. @@ -991,10 +966,13 @@ public function testObjectAttributeIntegersBeyondInt32(): void $database->purgeCachedDocument($collectionId, 'bigInts'); $meta = $database->getDocument($collectionId, 'bigInts')->getAttribute('meta'); + $this->assertIsArray($meta); + $nested = $meta['nested'] ?? null; + $this->assertIsArray($nested); $this->assertIsInt($meta['small']); $this->assertIsInt($meta['count']); - $this->assertIsInt($meta['nested']['deep']); + $this->assertIsInt($nested['deep']); $this->assertEquals([ 'small' => -42, 'count' => -3408048000, @@ -1014,13 +992,13 @@ public function testObjectAttributeEmptyObject(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForObject()) { + if (!$database->getAdapter()->supports(Capability::Objects)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); - $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); $created = $database->createDocument($collectionId, new Document([ '$id' => 'emptyObject', @@ -1060,13 +1038,13 @@ public function testObjectAttributeNestedEmptyObjects(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForObject()) { + if (!$database->getAdapter()->supports(Capability::Objects)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); - $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); $created = $database->createDocument($collectionId, new Document([ '$id' => 'nestedEmptyObjects', @@ -1078,28 +1056,38 @@ public function testObjectAttributeNestedEmptyObjects(): void ], ])); $createdMeta = $created->getAttribute('meta'); + $this->assertIsArray($createdMeta); + $createdArray = $createdMeta['arr'] ?? null; + $this->assertIsArray($createdArray); $this->assertSame('{}', json_encode($createdMeta['inner'])); - $this->assertSame('{}', json_encode($createdMeta['arr'][0])); - $this->assertSame('{"x":1}', json_encode($createdMeta['arr'][1])); + $this->assertSame('{}', json_encode($createdArray[0])); + $this->assertSame('{"x":1}', json_encode($createdArray[1])); $this->assertSame('[]', json_encode($createdMeta['emptyArray'])); $database->purgeCachedDocument($collectionId, 'nestedEmptyObjects'); $readMeta = $database->getDocument($collectionId, 'nestedEmptyObjects')->getAttribute('meta'); + $this->assertIsArray($readMeta); + $readArray = $readMeta['arr'] ?? null; + $this->assertIsArray($readArray); $this->assertSame('{}', json_encode($readMeta['inner'])); - $this->assertSame('{}', json_encode($readMeta['arr'][0])); - $this->assertSame('{"x":1}', json_encode($readMeta['arr'][1])); + $this->assertSame('{}', json_encode($readArray[0])); + $this->assertSame('{"x":1}', json_encode($readArray[1])); $this->assertSame('[]', json_encode($readMeta['emptyArray'])); $cached = $database->getDocument($collectionId, 'nestedEmptyObjects'); $cachedMeta = $cached->getAttribute('meta'); + $this->assertIsArray($cachedMeta); + $cachedArray = $cachedMeta['arr'] ?? null; + $this->assertIsArray($cachedArray); $this->assertSame('{}', json_encode($cachedMeta['inner'])); - $this->assertSame('{}', json_encode($cachedMeta['arr'][0])); - $this->assertSame('{"x":1}', json_encode($cachedMeta['arr'][1])); + $this->assertSame('{}', json_encode($cachedArray[0])); + $this->assertSame('{"x":1}', json_encode($cachedArray[1])); $this->assertSame('[]', json_encode($cachedMeta['emptyArray'])); $updatedMeta = $cachedMeta; $updatedMeta['inner'] = new \stdClass(); - $updatedMeta['arr'][0] = new \stdClass(); + $cachedArray[0] = new \stdClass(); + $updatedMeta['arr'] = $cachedArray; $updated = $database->updateDocument($collectionId, 'nestedEmptyObjects', new Document([ 'meta' => $updatedMeta, ])); @@ -1114,17 +1102,18 @@ public function testMetadataWithVector(): void $database = static::getDatabase(); // Skip if adapter doesn't support either vectors or object attributes - if (!$database->getAdapter()->getSupportForVectors() || !$database->getAdapter()->getSupportForObject()) { + if (! $database->getAdapter()->supports(Capability::Vectors) || ! $database->getAdapter()->supports(Capability::Objects)) { $this->expectNotToPerformAssertions(); + return; } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Attributes: 3D vector and nested metadata object - $this->createAttribute($database, $collectionId, 'embedding', Database::VAR_VECTOR, 3, true); - $this->createAttribute($database, $collectionId, 'metadata', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'embedding', ColumnType::Vector, 3, true); + $this->createAttribute($database, $collectionId, 'metadata', ColumnType::Object, 0, false); // Seed documents $docA = $database->createDocument($collectionId, new Document([ @@ -1136,20 +1125,20 @@ public function testMetadataWithVector(): void 'user' => [ 'info' => [ 'country' => 'IN', - 'score' => 100 - ] - ] + 'score' => 100, + ], + ], ], 'tags' => ['ai', 'ml', 'db'], 'settings' => [ 'prefs' => [ 'theme' => 'dark', 'features' => [ - 'experimental' => true - ] - ] - ] - ] + 'experimental' => true, + ], + ], + ], + ], ])); $docB = $database->createDocument($collectionId, new Document([ @@ -1161,17 +1150,17 @@ public function testMetadataWithVector(): void 'user' => [ 'info' => [ 'country' => 'US', - 'score' => 80 - ] - ] + 'score' => 80, + ], + ], ], 'tags' => ['search', 'analytics'], 'settings' => [ 'prefs' => [ - 'theme' => 'light' - ] - ] - ] + 'theme' => 'light', + ], + ], + ], ])); $docC = $database->createDocument($collectionId, new Document([ @@ -1183,26 +1172,26 @@ public function testMetadataWithVector(): void 'user' => [ 'info' => [ 'country' => 'CA', - 'score' => 60 - ] - ] + 'score' => 60, + ], + ], ], 'tags' => ['ml', 'cv'], 'settings' => [ 'prefs' => [ 'theme' => 'dark', 'features' => [ - 'experimental' => false - ] - ] - ] - ] + 'experimental' => false, + ], + ], + ], + ], ])); // 1) Vector similarity: closest to [0.0, 0.0, 1.0] should be vecA $results = $database->find($collectionId, [ Query::vectorCosine('embedding', [0.0, 0.0, 1.0]), - Query::limit(1) + Query::limit(1), ]); $this->assertCount(1, $results); $this->assertEquals('vecA', $results[0]->getId()); @@ -1213,20 +1202,20 @@ public function testMetadataWithVector(): void 'profile' => [ 'user' => [ 'info' => [ - 'country' => 'IN' - ] - ] - ] - ]]) + 'country' => 'IN', + ], + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('vecA', $results[0]->getId()); // 3) Contains on nested array inside metadata $results = $database->find($collectionId, [ - Query::contains('metadata', [[ - 'tags' => 'ml' - ]]) + new Query(Method::Contains, 'metadata', [[ + 'tags' => 'ml', + ]]), ]); $this->assertCount(2, $results); // vecA, vecC both have 'ml' in tags @@ -1236,11 +1225,11 @@ public function testMetadataWithVector(): void Query::equal('metadata', [[ 'settings' => [ 'prefs' => [ - 'theme' => 'light' - ] - ] + 'theme' => 'light', + ], + ], ]]), - Query::limit(1) + Query::limit(1), ]); $this->assertCount(1, $results); $this->assertEquals('vecB', $results[0]->getId()); @@ -1251,11 +1240,11 @@ public function testMetadataWithVector(): void 'settings' => [ 'prefs' => [ 'features' => [ - 'experimental' => true - ] - ] - ] - ]]) + 'experimental' => true, + ], + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('vecA', $results[0]->getId()); @@ -1269,27 +1258,25 @@ public function testNestedObjectAttributeIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->markTestSkipped('Adapter does not support attributes (schemaful required for nested object attribute indexes)'); } - if (!$database->getAdapter()->getSupportForObjectIndexes()) { + if (! $database->getAdapter()->supports(Capability::IndexObject)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Base attributes - $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, false); - $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); + $this->createAttribute($database, $collectionId, 'profile', ColumnType::Object, 0, false); + $this->createAttribute($database, $collectionId, 'name', ColumnType::String, 255, false); // 1) KEY index on a nested object path (dot notation) - // 2) UNIQUE index on a nested object path should enforce uniqueness on insert - $created = $database->createIndex($collectionId, 'idx_profile_email_unique', Database::INDEX_UNIQUE, ['profile.user.email']); - $this->assertTrue($created); + $database->createIndex($collectionId, Index::unique(key: 'idx_profile_email_unique', attributes: ['profile.user.email'])); $database->createDocument($collectionId, new Document([ '$id' => 'nest1', @@ -1298,10 +1285,10 @@ public function testNestedObjectAttributeIndexes(): void 'user' => [ 'email' => 'a@example.com', 'info' => [ - 'country' => 'IN' - ] - ] - ] + 'country' => 'IN', + ], + ], + ], ])); try { @@ -1312,10 +1299,10 @@ public function testNestedObjectAttributeIndexes(): void 'user' => [ 'email' => 'a@example.com', // duplicate 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ])); $this->fail('Expected Duplicate exception for UNIQUE index on nested object path'); } catch (Exception $e) { @@ -1324,14 +1311,14 @@ public function testNestedObjectAttributeIndexes(): void // 3) INDEX_OBJECT must NOT be allowed on nested paths try { - $database->createIndex($collectionId, 'idx_profile_nested_object', Database::INDEX_OBJECT, ['profile.user.email']); + $database->createIndex($collectionId, Index::object(key: 'idx_profile_nested_object', attribute: 'profile.user.email')); } catch (Exception $e) { $this->assertInstanceOf(IndexException::class, $e); } // 4) Nested path indexes must only be allowed when base attribute is VAR_OBJECT try { - $database->createIndex($collectionId, 'idx_name_nested', Database::INDEX_KEY, ['name.first']); + $database->createIndex($collectionId, Index::key(key: 'idx_name_nested', attributes: ['name.first'])); $this->fail('Expected Type exception for nested index on non-object base attribute'); } catch (Exception $e) { $this->assertInstanceOf(IndexException::class, $e); @@ -1345,24 +1332,23 @@ public function testQueryNestedAttribute(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->markTestSkipped('Adapter does not support attributes (schemaful required for nested object attribute indexes)'); } - if (!$database->getAdapter()->getSupportForObjectIndexes()) { + if (! $database->getAdapter()->supports(Capability::IndexObject)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Base attributes - $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, false); - $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); + $this->createAttribute($database, $collectionId, 'profile', ColumnType::Object, 0, false); + $this->createAttribute($database, $collectionId, 'name', ColumnType::String, 255, false); // Create index on nested email path - $created = $database->createIndex($collectionId, 'idx_profile_email', Database::INDEX_KEY, ['profile.user.email']); - $this->assertTrue($created); + $database->createIndex($collectionId, Index::key(key: 'idx_profile_email', attributes: ['profile.user.email'])); // Seed documents with different nested values $database->createDocuments($collectionId, [ @@ -1374,11 +1360,11 @@ public function testQueryNestedAttribute(): void 'email' => 'alice@example.com', 'info' => [ 'country' => 'IN', - 'city' => 'BLR' - ] - ] + 'city' => 'BLR', + ], + ], ], - 'name' => 'Alice' + 'name' => 'Alice', ]), new Document([ '$id' => 'd2', @@ -1388,11 +1374,11 @@ public function testQueryNestedAttribute(): void 'email' => 'bob@example.com', 'info' => [ 'country' => 'US', - 'city' => 'NYC' - ] - ] + 'city' => 'NYC', + ], + ], ], - 'name' => 'Bob' + 'name' => 'Bob', ]), new Document([ '$id' => 'd3', @@ -1402,38 +1388,38 @@ public function testQueryNestedAttribute(): void 'email' => 'carol@test.org', 'info' => [ 'country' => 'CA', - 'city' => 'TOR' - ] - ] + 'city' => 'TOR', + ], + ], ], - 'name' => 'Carol' - ]) + 'name' => 'Carol', + ]), ]); // Equal on nested email $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['bob@example.com']) + Query::equal('profile.user.email', ['bob@example.com']), ]); $this->assertCount(1, $results); $this->assertEquals('d2', $results[0]->getId()); // Starts with on nested email $results = $database->find($collectionId, [ - Query::startsWith('profile.user.email', 'alice@') + Query::startsWith('profile.user.email', 'alice@'), ]); $this->assertCount(1, $results); $this->assertEquals('d1', $results[0]->getId()); // Ends with on nested email $results = $database->find($collectionId, [ - Query::endsWith('profile.user.email', 'test.org') + Query::endsWith('profile.user.email', 'test.org'), ]); $this->assertCount(1, $results); $this->assertEquals('d3', $results[0]->getId()); // Contains on nested country (as text) $results = $database->find($collectionId, [ - Query::contains('profile.user.info.country', ['US']) + Query::containsString('profile.user.info.country', ['US']), ]); $this->assertCount(1, $results); $this->assertEquals('d2', $results[0]->getId()); @@ -1443,7 +1429,7 @@ public function testQueryNestedAttribute(): void Query::and([ Query::equal('profile.user.info.country', ['IN']), Query::endsWith('profile.user.email', 'example.com'), - ]) + ]), ]); $this->assertCount(1, $results); $this->assertEquals('d1', $results[0]->getId()); @@ -1453,7 +1439,7 @@ public function testQueryNestedAttribute(): void Query::or([ Query::equal('profile.user.info.country', ['CA']), Query::startsWith('profile.user.email', 'bob@'), - ]) + ]), ]); $this->assertCount(2, $results); $ids = \array_map(fn (Document $d) => $d->getId(), $results); @@ -1462,7 +1448,7 @@ public function testQueryNestedAttribute(): void // NOT: exclude emails ending with example.com $results = $database->find($collectionId, [ - Query::notEndsWith('profile.user.email', 'example.com') + Query::notEndsWith('profile.user.email', 'example.com'), ]); $this->assertCount(1, $results); $this->assertEquals('d3', $results[0]->getId()); @@ -1470,26 +1456,94 @@ public function testQueryNestedAttribute(): void $database->deleteCollection($collectionId); } + public function testObjectPathKeysOutsideTheAllowedCharsAreRefused(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Objects) || ! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); + $this->createAttribute($database, $collectionId, 'secret', ColumnType::String, 64, false); + + $database->createDocuments($collectionId, [ + new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => ['a' => 'x'], + 'secret' => 's1', + ]), + new Document([ + '$id' => 'd2', + '$permissions' => [Permission::read(Role::any())], + 'meta' => ['a' => 'y'], + 'secret' => 's2', + ]), + ]); + + $paths = [ + "meta.a' IN ('x') OR secret='s2' OR 'x", + "meta.a'||(select 1)||'", + "meta.a' OR 1=1 --", + "meta.a'b.c", + ]; + + foreach ($paths as $path) { + foreach ([Query::equal($path, ['x']), Query::or([Query::equal('meta.a', ['x']), Query::startsWith($path, 'x')])] as $query) { + try { + $database->find($collectionId, [$query]); + $this->fail('Expected the path to be refused: '.$path); + } catch (QueryException $error) { + $this->assertStringContainsString('Invalid object path', $error->getMessage()); + } + } + } + + foreach ($paths as $path) { + foreach ([Query::equal($path, ['x']), Query::or([Query::equal('meta.a', ['x']), Query::startsWith($path, 'x')])] as $query) { + try { + /** @var array $found */ + $found = $database->skipValidation(fn () => $database->find($collectionId, [$query])); + $counted = $database->skipValidation(fn () => $database->count($collectionId, [$query])); + } catch (QueryException) { + continue; + } + + $ids = \array_map(static fn (Document $document): string => $document->getId(), $found); + $this->assertNotContains('d2', $ids, 'A path that names no stored key matched d2: '.$path); + $this->assertLessThanOrEqual(1, $counted, 'A path that names no stored key counted d2: '.$path); + } + } + + $results = $database->find($collectionId, [Query::equal('meta.a', ['x'])]); + $this->assertSame(['d1'], \array_map(static fn (Document $document): string => $document->getId(), $results)); + + $database->deleteCollection($collectionId); + } + public function testNestedObjectAttributeEdgeCases(): void { /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForObject()) { + if (! $database->getAdapter()->supports(Capability::Objects)) { $this->markTestSkipped('Adapter does not support object attributes'); } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Base attributes - $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, false); - $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); - $this->createAttribute($database, $collectionId, 'age', Database::VAR_INTEGER, 0, false); + $this->createAttribute($database, $collectionId, 'profile', ColumnType::Object, 0, false); + $this->createAttribute($database, $collectionId, 'name', ColumnType::String, 255, false); + $this->createAttribute($database, $collectionId, 'age', ColumnType::Integer, 0, false); // Edge Case 1: Deep nesting (5 levels deep) - $created = $database->createIndex($collectionId, 'idx_deep_nest', Database::INDEX_KEY, ['profile.level1.level2.level3.level4.value']); - $this->assertTrue($created); + $database->createIndex($collectionId, Index::key(key: 'idx_deep_nest', attributes: ['profile.level1.level2.level3.level4.value'])); $database->createDocuments($collectionId, [ new Document([ @@ -1500,12 +1554,12 @@ public function testNestedObjectAttributeEdgeCases(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'value' => 'deep_value_1' - ] - ] - ] - ] - ] + 'value' => 'deep_value_1', + ], + ], + ], + ], + ], ]), new Document([ '$id' => 'deep2', @@ -1515,19 +1569,19 @@ public function testNestedObjectAttributeEdgeCases(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'value' => 'deep_value_2' - ] - ] - ] - ] - ] - ]) + 'value' => 'deep_value_2', + ], + ], + ], + ], + ], + ]), ]); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { try { $database->find($collectionId, [ - Query::equal('profile.level1.level2.level3.level4.value', [10]) + Query::equal('profile.level1.level2.level3.level4.value', [10]), ]); $this->fail('Expected nesting as string'); } catch (Exception $e) { @@ -1537,18 +1591,15 @@ public function testNestedObjectAttributeEdgeCases(): void } $results = $database->find($collectionId, [ - Query::equal('profile.level1.level2.level3.level4.value', ['deep_value_1']) + Query::equal('profile.level1.level2.level3.level4.value', ['deep_value_1']), ]); $this->assertCount(1, $results); $this->assertEquals('deep1', $results[0]->getId()); // Edge Case 2: Multiple nested indexes on same base attribute - $created = $database->createIndex($collectionId, 'idx_email', Database::INDEX_KEY, ['profile.user.email']); - $this->assertTrue($created); - $created = $database->createIndex($collectionId, 'idx_country', Database::INDEX_KEY, ['profile.user.info.country']); - $this->assertTrue($created); - $created = $database->createIndex($collectionId, 'idx_city', Database::INDEX_KEY, ['profile.user.info.city']); - $this->assertTrue($created); + $database->createIndex($collectionId, Index::key(key: 'idx_email', attributes: ['profile.user.email'])); + $database->createIndex($collectionId, Index::key(key: 'idx_country', attributes: ['profile.user.info.country'])); + $database->createIndex($collectionId, Index::key(key: 'idx_city', attributes: ['profile.user.info.city'])); $database->createDocuments($collectionId, [ new Document([ @@ -1559,10 +1610,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'email' => 'multi1@test.com', 'info' => [ 'country' => 'US', - 'city' => 'NYC' - ] - ] - ] + 'city' => 'NYC', + ], + ], + ], ]), new Document([ '$id' => 'multi2', @@ -1572,30 +1623,30 @@ public function testNestedObjectAttributeEdgeCases(): void 'email' => 'multi2@test.com', 'info' => [ 'country' => 'CA', - 'city' => 'TOR' - ] - ] - ] - ]) + 'city' => 'TOR', + ], + ], + ], + ]), ]); // Query using first nested index $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['multi1@test.com']) + Query::equal('profile.user.email', ['multi1@test.com']), ]); $this->assertCount(1, $results); $this->assertEquals('multi1', $results[0]->getId()); // Query using second nested index $results = $database->find($collectionId, [ - Query::equal('profile.user.info.country', ['US']) + Query::equal('profile.user.info.country', ['US']), ]); $this->assertCount(1, $results); $this->assertEquals('multi1', $results[0]->getId()); // Query using third nested index $results = $database->find($collectionId, [ - Query::equal('profile.user.info.city', ['TOR']) + Query::equal('profile.user.info.city', ['TOR']), ]); $this->assertCount(1, $results); $this->assertEquals('multi2', $results[0]->getId()); @@ -1609,10 +1660,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => null, // null value 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'null2', @@ -1621,21 +1672,21 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ // missing email key entirely 'info' => [ - 'country' => 'CA' - ] - ] - ] + 'country' => 'CA', + ], + ], + ], ]), new Document([ '$id' => 'null3', '$permissions' => [Permission::read(Role::any())], - 'profile' => null // entire profile is null - ]) + 'profile' => null, // entire profile is null + ]), ]); // Query for null email should not match null1 (null values typically don't match equal queries) $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['non-existent@test.com']) + Query::equal('profile.user.email', ['non-existent@test.com']), ]); // Should not include null1, null2, or null3 foreach ($results as $doc) { @@ -1655,10 +1706,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'alice.mixed@test.com', 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'mixed2', @@ -1669,21 +1720,21 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'bob.mixed@test.com', 'info' => [ - 'country' => 'CA' - ] - ] - ] - ]) + 'country' => 'CA', + ], + ], + ], + ]), ]); // Create indexes on regular attributes - $database->createIndex($collectionId, 'idx_name', Database::INDEX_KEY, ['name']); - $database->createIndex($collectionId, 'idx_age', Database::INDEX_KEY, ['age']); + $database->createIndex($collectionId, Index::key(key: 'idx_name', attributes: ['name'])); + $database->createIndex($collectionId, Index::key(key: 'idx_age', attributes: ['age'])); // Combined query: nested path + regular attribute $results = $database->find($collectionId, [ Query::equal('profile.user.info.country', ['US']), - Query::equal('name', ['Alice']) + Query::equal('name', ['Alice']), ]); $this->assertCount(1, $results); $this->assertEquals('mixed1', $results[0]->getId()); @@ -1692,8 +1743,8 @@ public function testNestedObjectAttributeEdgeCases(): void $results = $database->find($collectionId, [ Query::and([ Query::equal('profile.user.email', ['bob.mixed@test.com']), - Query::equal('age', [30]) - ]) + Query::equal('age', [30]), + ]), ]); $this->assertCount(1, $results); $this->assertEquals('mixed2', $results[0]->getId()); @@ -1708,15 +1759,15 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'alice.updated@test.com', // changed email 'info' => [ - 'country' => 'CA' // changed country - ] - ] - ] + 'country' => 'CA', // changed country + ], + ], + ], ])); // Query with old email should not match $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['alice.mixed@test.com']) + Query::equal('profile.user.email', ['alice.mixed@test.com']), ]); foreach ($results as $doc) { $this->assertNotEquals('mixed1', $doc->getId()); @@ -1724,14 +1775,14 @@ public function testNestedObjectAttributeEdgeCases(): void // Query with new email should match $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['alice.updated@test.com']) + Query::equal('profile.user.email', ['alice.updated@test.com']), ]); $this->assertCount(1, $results); $this->assertEquals('mixed1', $results[0]->getId()); // Query with new country should match $results = $database->find($collectionId, [ - Query::equal('profile.user.info.country', ['CA']) + Query::equal('profile.user.info.country', ['CA']), ]); $this->assertGreaterThanOrEqual(2, count($results)); // Should include mixed1 and mixed2 @@ -1745,10 +1796,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'email' => 'noindex1@test.com', 'info' => [ 'country' => 'US', - 'phone' => '+1234567890' // no index on this path - ] - ] - ] + 'phone' => '+1234567890', // no index on this path + ], + ], + ], ]), new Document([ '$id' => 'noindex2', @@ -1758,16 +1809,16 @@ public function testNestedObjectAttributeEdgeCases(): void 'email' => 'noindex2@test.com', 'info' => [ 'country' => 'CA', - 'phone' => '+9876543210' // no index on this path - ] - ] - ] - ]) + 'phone' => '+9876543210', // no index on this path + ], + ], + ], + ]), ]); // Query on non-indexed nested path should still work $results = $database->find($collectionId, [ - Query::equal('profile.user.info.phone', ['+1234567890']) + Query::equal('profile.user.info.phone', ['+1234567890']), ]); $this->assertCount(1, $results); $this->assertEquals('noindex1', $results[0]->getId()); @@ -1783,10 +1834,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'info' => [ 'country' => 'US', 'city' => 'NYC', - 'zip' => '10001' - ] - ] - ] + 'zip' => '10001', + ], + ], + ], ]), new Document([ '$id' => 'complex2', @@ -1797,10 +1848,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'info' => [ 'country' => 'US', 'city' => 'LAX', - 'zip' => '90001' - ] - ] - ] + 'zip' => '90001', + ], + ], + ], ]), new Document([ '$id' => 'complex3', @@ -1811,19 +1862,19 @@ public function testNestedObjectAttributeEdgeCases(): void 'info' => [ 'country' => 'CA', 'city' => 'TOR', - 'zip' => 'M5H1A1' - ] - ] - ] - ]) + 'zip' => 'M5H1A1', + ], + ], + ], + ]), ]); // Complex AND with multiple nested paths $results = $database->find($collectionId, [ Query::and([ Query::equal('profile.user.info.country', ['US']), - Query::equal('profile.user.info.city', ['NYC']) - ]) + Query::equal('profile.user.info.city', ['NYC']), + ]), ]); $this->assertCount(2, $results); @@ -1832,13 +1883,13 @@ public function testNestedObjectAttributeEdgeCases(): void $results = $database->find($collectionId, [ Query::or([ Query::equal('profile.user.info.city', ['NYC']), - Query::equal('profile.user.info.city', ['TOR']) - ]) + Query::equal('profile.user.info.city', ['TOR']), + ]), ]); $this->assertCount(4, $results); $ids = \array_map(fn (Document $d) => $d->getId(), $results); \sort($ids); - $this->assertEquals(['complex1', 'complex3','multi1','multi2'], $ids); + $this->assertEquals(['complex1', 'complex3', 'multi1', 'multi2'], $ids); // Complex nested AND/OR combination $results = $database->find($collectionId, [ @@ -1846,9 +1897,9 @@ public function testNestedObjectAttributeEdgeCases(): void Query::equal('profile.user.info.country', ['US']), Query::or([ Query::equal('profile.user.info.city', ['NYC']), - Query::equal('profile.user.info.city', ['LAX']) - ]) - ]) + Query::equal('profile.user.info.city', ['LAX']), + ]), + ]), ]); $this->assertCount(3, $results); $ids = \array_map(fn (Document $d) => $d->getId(), $results); @@ -1864,10 +1915,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'a@order.com', 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'order2', @@ -1876,10 +1927,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'b@order.com', 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'order3', @@ -1888,17 +1939,17 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'c@order.com', 'info' => [ - 'country' => 'US' - ] - ] - ] - ]) + 'country' => 'US', + ], + ], + ], + ]), ]); // Limit with nested query $results = $database->find($collectionId, [ Query::equal('profile.user.info.country', ['US']), - Query::limit(2) + Query::limit(2), ]); $this->assertCount(2, $results); @@ -1906,7 +1957,7 @@ public function testNestedObjectAttributeEdgeCases(): void $results = $database->find($collectionId, [ Query::equal('profile.user.info.country', ['US']), Query::offset(1), - Query::limit(1) + Query::limit(1), ]); $this->assertCount(1, $results); @@ -1919,16 +1970,16 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => '', // empty string 'info' => [ - 'country' => 'US' - ] - ] - ] - ]) + 'country' => 'US', + ], + ], + ], + ]), ]); // Query for empty string $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['']) + Query::equal('profile.user.email', ['']), ]); $this->assertGreaterThanOrEqual(1, count($results)); $found = false; @@ -1945,24 +1996,22 @@ public function testNestedObjectAttributeEdgeCases(): void // Query should still work without index (just slower) $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['alice.updated@test.com']) + Query::equal('profile.user.email', ['alice.updated@test.com']), ]); $this->assertGreaterThanOrEqual(1, count($results)); // Re-create index - $created = $database->createIndex($collectionId, 'idx_email_recreated', Database::INDEX_KEY, ['profile.user.email']); - $this->assertTrue($created); + $database->createIndex($collectionId, Index::key(key: 'idx_email_recreated', attributes: ['profile.user.email'])); // Query should still work with recreated index $results = $database->find($collectionId, [ - Query::equal('profile.user.email', ['alice.updated@test.com']) + Query::equal('profile.user.email', ['alice.updated@test.com']), ]); $this->assertGreaterThanOrEqual(1, count($results)); // Edge Case 11: UNIQUE index with updates (duplicate prevention) - if ($database->getAdapter()->getSupportForIdenticalIndexes()) { - $created = $database->createIndex($collectionId, 'idx_unique_email', Database::INDEX_UNIQUE, ['profile.user.email']); - $this->assertTrue($created); + if ($database->getAdapter()->supports(Capability::IndexIdentical)) { + $database->createIndex($collectionId, Index::unique(key: 'idx_unique_email', attributes: ['profile.user.email'])); // Try to create duplicate try { @@ -1973,10 +2022,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'alice.updated@test.com', // duplicate 'info' => [ - 'country' => 'XX' - ] - ] - ] + 'country' => 'XX', + ], + ], + ], ])); $this->fail('Expected Duplicate exception for UNIQUE index'); } catch (Exception $e) { @@ -1994,10 +2043,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'email' => 'text1@example.org', 'info' => [ 'country' => 'United States', - 'city' => 'New York City' - ] - ] - ] + 'city' => 'New York City', + ], + ], + ], ]), new Document([ '$id' => 'text2', @@ -2007,23 +2056,23 @@ public function testNestedObjectAttributeEdgeCases(): void 'email' => 'text2@test.com', 'info' => [ 'country' => 'United Kingdom', - 'city' => 'London' - ] - ] - ] - ]) + 'city' => 'London', + ], + ], + ], + ]), ]); // startsWith on nested path $results = $database->find($collectionId, [ - Query::startsWith('profile.user.email', 'text1@') + Query::startsWith('profile.user.email', 'text1@'), ]); $this->assertCount(1, $results); $this->assertEquals('text1', $results[0]->getId()); - // contains on nested path + // containsString on nested path $results = $database->find($collectionId, [ - Query::contains('profile.user.info.country', ['United']) + Query::containsString('profile.user.info.country', ['United']), ]); $this->assertGreaterThanOrEqual(2, count($results)); diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 76a6d883a2..0bf19d41ac 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -2,6 +2,9 @@ namespace Tests\E2E\Adapter\Scopes; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -9,33 +12,32 @@ use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Type as TypeException; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Operator; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; trait OperatorTests { public function testUpdateWithOperators(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection with various attribute types $collectionId = 'test_operators'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 100, false, 'test'); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'name', size: 100, default: 'test')); // Create test document $doc = $database->createDocument($collectionId, new Document([ @@ -112,7 +114,7 @@ public function testUpdateWithOperators(): void $this->assertEquals([0, 1, 99, 2, 3, 4, 5], $updated->getAttribute('numbers')); // Test insert at end - $numbers = $updated->getAttribute('numbers'); + $numbers = $updated->getArray('numbers'); $lastIndex = count($numbers); $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ 'numbers' => Operator::arrayInsert($lastIndex, 100) @@ -125,21 +127,20 @@ public function testUpdateWithOperators(): void public function testUpdateDocumentsWithOperators(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_batch_operators'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'category', Database::VAR_STRING, 50, true); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'category', size: 50, required: true)); // Create multiple test documents $docs = []; @@ -172,7 +173,7 @@ public function testUpdateDocumentsWithOperators(): void foreach ($updated as $doc) { $originalCount = (int) str_replace('doc_', '', $doc->getId()) * 10; $this->assertEquals($originalCount + 5, $doc->getAttribute('count')); - $this->assertContains('batch_updated', $doc->getAttribute('tags')); + $this->assertContains('batch_updated', $doc->getArray('tags')); $this->assertEquals('updated', $doc->getAttribute('category')); } @@ -202,39 +203,38 @@ public function testUpdateDocumentsWithOperators(): void public function testUpdateDocumentsWithAllOperators(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create comprehensive test collection $collectionId = 'test_all_operators_bulk'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); // Create attributes for all operator types - $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 10); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 5.0); - $database->createAttribute($collectionId, 'multiplier', Database::VAR_FLOAT, 0, false, 2.0); - $database->createAttribute($collectionId, 'divisor', Database::VAR_FLOAT, 0, false, 100.0); - $database->createAttribute($collectionId, 'remainder', Database::VAR_INTEGER, 0, false, 20); - $database->createAttribute($collectionId, 'power_val', Database::VAR_FLOAT, 0, false, 2.0); - $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 255, false, 'Title'); - $database->createAttribute($collectionId, 'content', Database::VAR_STRING, 500, false, 'old content'); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'categories', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'duplicates', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'intersect_items', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'diff_items', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'filter_numbers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false, false); - $database->createAttribute($collectionId, 'last_update', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); - $database->createAttribute($collectionId, 'next_update', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); - $database->createAttribute($collectionId, 'now_field', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createAttribute($collectionId, Attribute::integer(key: 'counter', default: 10)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 5.0)); + $database->createAttribute($collectionId, Attribute::double(key: 'multiplier', default: 2.0)); + $database->createAttribute($collectionId, Attribute::double(key: 'divisor', default: 100.0)); + $database->createAttribute($collectionId, Attribute::integer(key: 'remainder', default: 20)); + $database->createAttribute($collectionId, Attribute::double(key: 'power_val', default: 2.0)); + $database->createAttribute($collectionId, Attribute::string(key: 'title', default: 'Title')); + $database->createAttribute($collectionId, Attribute::string(key: 'content', size: 500, default: 'old content')); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'categories', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'duplicates', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'intersect_items', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'diff_items', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::integer(key: 'filter_numbers', array: true)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'active', default: false)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'last_update')); + $database->createAttribute($collectionId, Attribute::datetime(key: 'next_update')); + $database->createAttribute($collectionId, Attribute::datetime(key: 'now_field')); // Create test documents $docs = []; @@ -307,10 +307,10 @@ public function testUpdateDocumentsWithAllOperators(): void $this->assertEquals(4.0, $doc1->getAttribute('power_val')); // 2^2 $this->assertEquals('Title 1 - Updated', $doc1->getAttribute('title')); $this->assertEquals('new content 1', $doc1->getAttribute('content')); - $this->assertContains('bulk', $doc1->getAttribute('tags')); - $this->assertContains('priority', $doc1->getAttribute('categories')); - $this->assertNotContains('shared', $doc1->getAttribute('items')); - $this->assertCount(4, $doc1->getAttribute('duplicates')); // Should have unique values + $this->assertContains('bulk', $doc1->getArray('tags')); + $this->assertContains('priority', $doc1->getArray('categories')); + $this->assertNotContains('shared', $doc1->getArray('items')); + $this->assertCount(4, $doc1->getArray('duplicates')); // Should have unique values $this->assertEquals([1, 2, 99, 3, 4, 5], $doc1->getAttribute('numbers')); // arrayInsert at index 2 $this->assertEquals(['b', 'c'], $doc1->getAttribute('intersect_items')); // arrayIntersect $this->assertEquals(['x', 'w'], $doc1->getAttribute('diff_items')); // arrayDiff (removed y, z) @@ -352,22 +352,21 @@ public function testUpdateDocumentsWithAllOperators(): void public function testUpdateDocumentsOperatorsWithQueries(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_operators_with_queries'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'category', Database::VAR_STRING, 50, true); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false, false); + $database->createAttribute($collectionId, Attribute::string(key: 'category', size: 50, required: true)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'active', default: false)); // Create test documents for ($i = 1; $i <= 5; $i++) { @@ -436,17 +435,17 @@ public function testUpdateDocumentsOperatorsWithSelect(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'test_operators_with_select'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'category', Database::VAR_STRING, 50, true); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, Attribute::string(key: 'category', size: 50, required: true)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); for ($i = 1; $i <= 3; $i++) { $database->createDocument($collectionId, new Document([ @@ -498,15 +497,15 @@ public function testUpdateDocumentsOperatorsBatchLargerThanDefaultLimit(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'test_operators_large_batch'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); // More documents than find()'s default limit (25) so the refetch must page/limit correctly. $total = 60; @@ -555,7 +554,7 @@ public function testUpdateDocumentOperatorDoesNotDoubleDecodeFilters(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } @@ -565,21 +564,38 @@ public function testUpdateDocumentOperatorDoesNotDoubleDecodeFilters(): void $database->addFilter( 'operator_double_decode', function (mixed $value) { + if (! \is_scalar($value) && $value !== null) { + throw new \InvalidArgumentException('Operator filter input must be scalar or null'); + } + return json_encode(['data' => base64_encode((string) $value)]); }, - function (mixed $value) { + function (mixed $value): ?string { if (is_null($value)) { - return; + return null; } + if (! \is_string($value)) { + throw new \InvalidArgumentException('Encoded operator filter value must be a string'); + } + $decoded = json_decode($value, true); - return base64_decode($decoded['data']); + if (! \is_array($decoded) || ! \is_string($decoded['data'] ?? null)) { + throw new \InvalidArgumentException('Encoded operator filter payload is invalid'); + } + + $plain = base64_decode($decoded['data'], true); + if ($plain === false) { + throw new \InvalidArgumentException('Encoded operator filter payload is not valid base64'); + } + + return $plain; } ); $collectionId = 'test_operator_double_decode'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'secret', Database::VAR_STRING, 128, false, filters: ['operator_double_decode']); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::string(key: 'secret', size: 128, filters: ['operator_double_decode'])); $database->createDocument($collectionId, new Document([ '$id' => 'doc1', @@ -607,21 +623,20 @@ function (mixed $value) { public function testOperatorErrorHandling(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_operator_errors'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'text_field', Database::VAR_STRING, 100, true); - $database->createAttribute($collectionId, 'number_field', Database::VAR_INTEGER, 0, true); - $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, Attribute::string(key: 'text_field', size: 100, required: true)); + $database->createAttribute($collectionId, Attribute::integer(key: 'number_field', required: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'array_field', size: 50, array: true)); // Create test document $doc = $database->createDocument($collectionId, new Document([ @@ -646,20 +661,19 @@ public function testOperatorErrorHandling(): void public function testOperatorArrayErrorHandling(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_array_operator_errors'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'text_field', Database::VAR_STRING, 100, true); - $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, Attribute::string(key: 'text_field', size: 100, required: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'array_field', size: 50, array: true)); // Create test document $doc = $database->createDocument($collectionId, new Document([ @@ -683,19 +697,18 @@ public function testOperatorArrayErrorHandling(): void public function testOperatorInsertErrorHandling(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_insert_operator_errors'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, Attribute::string(key: 'array_field', size: 50, array: true)); // Create test document $doc = $database->createDocument($collectionId, new Document([ @@ -721,25 +734,24 @@ public function testOperatorInsertErrorHandling(): void public function testOperatorValidationEdgeCases(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create comprehensive test collection $collectionId = 'test_operator_edge_cases'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); // Create various attribute types for testing - $database->createAttribute($collectionId, 'string_field', Database::VAR_STRING, 100, false, 'default'); - $database->createAttribute($collectionId, 'int_field', Database::VAR_INTEGER, 0, false, 10); - $database->createAttribute($collectionId, 'float_field', Database::VAR_FLOAT, 0, false, 1.5); - $database->createAttribute($collectionId, 'bool_field', Database::VAR_BOOLEAN, 0, false, false); - $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'date_field', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createAttribute($collectionId, Attribute::string(key: 'string_field', size: 100, default: 'default')); + $database->createAttribute($collectionId, Attribute::integer(key: 'int_field', default: 10)); + $database->createAttribute($collectionId, Attribute::double(key: 'float_field', default: 1.5)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'bool_field', default: false)); + $database->createAttribute($collectionId, Attribute::string(key: 'array_field', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'date_field')); // Create test document $doc = $database->createDocument($collectionId, new Document([ @@ -810,17 +822,16 @@ public function testOperatorValidationEdgeCases(): void public function testOperatorDivisionModuloByZero(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_division_zero'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'number', Database::VAR_FLOAT, 0, false, 100.0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'number', default: 100.0)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'zero_test_doc', @@ -866,17 +877,16 @@ public function testOperatorDivisionModuloByZero(): void public function testOperatorArrayInsertOutOfBounds(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_insert_bounds'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'bounds_test_doc', @@ -912,18 +922,17 @@ public function testOperatorArrayInsertOutOfBounds(): void public function testOperatorValueLimits(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_operator_limits'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 10); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 5.0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'counter', default: 10)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 5.0)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'limits_test_doc', @@ -969,18 +978,17 @@ public function testOperatorValueLimits(): void public function testOperatorArrayFilterValidation(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_filter'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'filter_test_doc', @@ -1007,18 +1015,17 @@ public function testOperatorArrayFilterValidation(): void public function testOperatorReplaceValidation(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_replace'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, 'default text'); - $database->createAttribute($collectionId, 'number', Database::VAR_INTEGER, 0, false, 0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', default: 'default text')); + $database->createAttribute($collectionId, Attribute::integer(key: 'number', default: 0)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'replace_test_doc', @@ -1055,19 +1062,18 @@ public function testOperatorReplaceValidation(): void public function testOperatorNullValueHandling(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_null_handling'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'nullable_int', Database::VAR_INTEGER, 0, false, null, false, false); - $database->createAttribute($collectionId, 'nullable_string', Database::VAR_STRING, 100, false, null, false, false); - $database->createAttribute($collectionId, 'nullable_bool', Database::VAR_BOOLEAN, 0, false, null, false, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'nullable_int', signed: false)); + $database->createAttribute($collectionId, Attribute::string(key: 'nullable_string', size: 100)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'nullable_bool')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'null_test_doc', @@ -1112,20 +1118,19 @@ public function testOperatorNullValueHandling(): void public function testOperatorComplexScenarios(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_complex_operators'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'stats', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'metadata', Database::VAR_STRING, 100, false, null, true, true); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'stats', array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'metadata', size: 100, array: true)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); + $database->createAttribute($collectionId, Attribute::string(key: 'name', default: '')); // Create document with complex data $doc = $database->createDocument($collectionId, new Document([ @@ -1141,7 +1146,7 @@ public function testOperatorComplexScenarios(): void $updated = $database->updateDocument($collectionId, 'complex_test_doc', new Document([ 'stats' => Operator::arrayUnique() // Should remove duplicate 20s ])); - $stats = $updated->getAttribute('stats'); + $stats = $updated->getArray('stats'); $this->assertCount(4, $stats); // [10, 20, 30, 40] $this->assertEquals([10, 20, 30, 40], $stats); @@ -1172,17 +1177,16 @@ public function testOperatorComplexScenarios(): void public function testOperatorIncrement(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_increment_operator'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1214,17 +1218,16 @@ public function testOperatorIncrement(): void public function testOperatorStringConcat(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_string_concat_operator'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 255, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'title', default: '')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1256,17 +1259,16 @@ public function testOperatorStringConcat(): void public function testOperatorModulo(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_modulo_operator'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'number', Database::VAR_INTEGER, 0, false, 0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'number', default: 0)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1286,17 +1288,16 @@ public function testOperatorModulo(): void public function testOperatorToggle(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_toggle_operator'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'active', default: false)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1320,21 +1321,19 @@ public function testOperatorToggle(): void $database->deleteCollection($collectionId); } - public function testOperatorArrayUnique(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_unique_operator'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1346,7 +1345,7 @@ public function testOperatorArrayUnique(): void 'items' => Operator::arrayUnique() ])); - $result = $updated->getAttribute('items'); + $result = $updated->getArray('items'); $this->assertCount(3, $result); $this->assertContains('a', $result); $this->assertContains('b', $result); @@ -1359,20 +1358,19 @@ public function testOperatorArrayUnique(): void public function testOperatorIncrementComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Setup collection $collectionId = 'operator_increment_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count')); + $database->createAttribute($collectionId, Attribute::double(key: 'score')); + $database->createAttribute($collectionId, Attribute::string(key: 'text')); // Success case - integer $doc = $database->createDocument($collectionId, new Document([ @@ -1418,17 +1416,16 @@ public function testOperatorIncrementComprehensive(): void public function testOperatorDecrementComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_decrement_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1463,17 +1460,16 @@ public function testOperatorDecrementComprehensive(): void public function testOperatorMultiplyComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_multiply_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1498,17 +1494,16 @@ public function testOperatorMultiplyComprehensive(): void public function testOperatorDivideComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_divide_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1533,17 +1528,16 @@ public function testOperatorDivideComprehensive(): void public function testOperatorModuloComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_modulo_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'number', Database::VAR_INTEGER, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'number')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1562,17 +1556,16 @@ public function testOperatorModuloComprehensive(): void public function testOperatorPowerComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_power_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'number', Database::VAR_FLOAT, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'number')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -1606,14 +1599,14 @@ public function testOperatorBoundedShrinkApplies(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_bounded_shrink'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', default: 0.0)); $database->createDocument($collectionId, new Document([ '$id' => 'shrink_doc', @@ -1659,16 +1652,16 @@ public function testOperatorGuardIsPerColumn(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_guard_per_column'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 100, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); + $database->createAttribute($collectionId, Attribute::string(key: 'name', size: 100, default: '')); $database->createDocument($collectionId, new Document([ '$id' => 'doc', @@ -1701,15 +1694,15 @@ public function testOperatorGuardIsPerRowInBatch(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_guard_per_row'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); foreach ([['d1', 10], ['d2', 48]] as [$id, $count]) { $database->createDocument($collectionId, new Document([ @@ -1744,14 +1737,14 @@ public function testOperatorBoundIsInclusive(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_bound_inclusive'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'counter', default: 0)); $database->createDocument($collectionId, new Document([ '$id' => 'doc', @@ -1791,14 +1784,14 @@ public function testOperatorPowerOnZeroOrNegativeBase(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_power_edge'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', default: 0.0)); // The square root of a negative number is not a real number, so -4 is left as -4. $database->createDocument($collectionId, new Document([ @@ -1842,14 +1835,14 @@ public function testOperatorUnboundedPowerOnUndefinedBase(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_power_undefined'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', default: 0.0)); // [id, starting value, operator]. Each result is mathematically undefined. $undefined = [ @@ -1885,7 +1878,9 @@ public function testOperatorUnboundedPowerOnUndefinedBase(): void // base) must still not store a wrong real number: the value is either untouched or // an explicit "not a number" marker (NULL / NaN). Verify with a fresh read. $stored = $database->getDocument($collectionId, $id)->getAttribute('value'); - $safe = $stored === null || $stored == $start || !\is_finite((float) $stored); + $safe = $stored === null + || $stored == $start + || (\is_numeric($stored) && ! \is_finite((float) $stored)); $this->assertTrue($safe, "{$id}: undefined power neither raised a LimitException nor left a safe value; stored " . \var_export($stored, true)); } } @@ -1912,14 +1907,14 @@ public function testOperatorBoundedPowerComputesWithinMax(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_bounded_power'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', default: 0.0)); // [id, starting value, operator, expected stored value]. $cases = [ @@ -1959,14 +1954,14 @@ public function testOperatorArraySizeLimit(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_array_size_limit'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); $database->createDocument($collectionId, new Document([ '$id' => 'doc', @@ -2008,14 +2003,14 @@ public function testOperatorArrayFilterRejectsUnknownCondition(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_filter_unknown_cond'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); $database->createDocument($collectionId, new Document([ '$id' => 'doc', @@ -2044,14 +2039,14 @@ public function testOperatorArrayFilterAllConditions(): void { $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'operator_filter_all_conditions'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); $database->createDocument($collectionId, new Document([ '$id' => 'doc', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], @@ -2074,7 +2069,9 @@ public function testOperatorArrayFilterAllConditions(): void $updated = $database->updateDocument($collectionId, 'doc', new Document([ 'numbers' => Operator::arrayFilter($condition, $compare), ])); - $this->assertEquals($expected, \array_values($updated->getAttribute('numbers')), "arrayFilter('{$condition}') gave the wrong result"); + $numbers = $updated->getAttribute('numbers'); + $this->assertIsArray($numbers); + $this->assertEquals($expected, \array_values($numbers), "arrayFilter('{$condition}') gave the wrong result"); } $database->deleteCollection($collectionId); @@ -2082,17 +2079,16 @@ public function testOperatorArrayFilterAllConditions(): void public function testOperatorStringConcatComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_concat_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -2121,17 +2117,16 @@ public function testOperatorStringConcatComprehensive(): void public function testOperatorReplaceComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_replace_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text')); // Success case - single replacement $doc = $database->createDocument($collectionId, new Document([ @@ -2162,17 +2157,16 @@ public function testOperatorReplaceComprehensive(): void public function testOperatorArrayAppendComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_append_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -2211,17 +2205,16 @@ public function testOperatorArrayAppendComprehensive(): void public function testOperatorArrayPrependComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_prepend_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -2240,17 +2233,16 @@ public function testOperatorArrayPrependComprehensive(): void public function testOperatorArrayInsertComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_insert_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); // Success case - middle insertion $doc = $database->createDocument($collectionId, new Document([ @@ -2272,7 +2264,7 @@ public function testOperatorArrayInsertComprehensive(): void $this->assertEquals([0, 1, 2, 3, 4], $updated->getAttribute('numbers')); // Success case - end insertion - $numbers = $updated->getAttribute('numbers'); + $numbers = $updated->getArray('numbers'); $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ 'numbers' => Operator::arrayInsert(count($numbers), 5) ])); @@ -2284,17 +2276,16 @@ public function testOperatorArrayInsertComprehensive(): void public function testOperatorArrayRemoveComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_remove_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Success case - single occurrence $doc = $database->createDocument($collectionId, new Document([ @@ -2332,17 +2323,16 @@ public function testOperatorArrayRemoveComprehensive(): void public function testOperatorArrayUniqueComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_unique_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Success case - with duplicates $doc = $database->createDocument($collectionId, new Document([ @@ -2354,7 +2344,7 @@ public function testOperatorArrayUniqueComprehensive(): void 'items' => Operator::arrayUnique() ])); - $result = $updated->getAttribute('items'); + $result = $updated->getArray('items'); sort($result); // Sort for consistent comparison $this->assertEquals(['a', 'b', 'c'], $result); @@ -2375,17 +2365,16 @@ public function testOperatorArrayUniqueComprehensive(): void public function testOperatorArrayIntersectComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_intersect_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -2397,7 +2386,7 @@ public function testOperatorArrayIntersectComprehensive(): void 'items' => Operator::arrayIntersect(['b', 'c', 'e']) ])); - $result = $updated->getAttribute('items'); + $result = $updated->getArray('items'); sort($result); $this->assertEquals(['b', 'c'], $result); @@ -2413,17 +2402,16 @@ public function testOperatorArrayIntersectComprehensive(): void public function testOperatorArrayDiffComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_diff_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -2435,7 +2423,7 @@ public function testOperatorArrayDiffComprehensive(): void 'items' => Operator::arrayDiff(['b', 'd']) ])); - $result = $updated->getAttribute('items'); + $result = $updated->getArray('items'); sort($result); $this->assertEquals(['a', 'c'], $result); @@ -2444,7 +2432,7 @@ public function testOperatorArrayDiffComprehensive(): void 'items' => Operator::arrayDiff([]) ])); - $result = $updated->getAttribute('items'); + $result = $updated->getArray('items'); sort($result); $this->assertEquals(['a', 'c'], $result); // Should remain unchanged @@ -2453,18 +2441,17 @@ public function testOperatorArrayDiffComprehensive(): void public function testOperatorArrayFilterComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_filter_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'mixed', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'mixed', size: 50, array: true)); // Success case - equals condition $doc = $database->createDocument($collectionId, new Document([ @@ -2513,18 +2500,17 @@ public function testOperatorArrayFilterComprehensive(): void public function testOperatorArrayFilterNumericComparisons(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_filter_numeric_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'integers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'floats', Database::VAR_FLOAT, 0, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'integers', array: true)); + $database->createAttribute($collectionId, Attribute::double(key: 'floats', array: true)); // Create document with various numeric values $doc = $database->createDocument($collectionId, new Document([ @@ -2570,17 +2556,16 @@ public function testOperatorArrayFilterNumericComparisons(): void public function testOperatorToggleComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_toggle_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'active')); // Success case - true to false $doc = $database->createDocument($collectionId, new Document([ @@ -2618,17 +2603,16 @@ public function testOperatorToggleComprehensive(): void public function testOperatorDateAddDaysComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_date_add_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'date', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'date')); // Success case - positive days $doc = $database->createDocument($collectionId, new Document([ @@ -2654,17 +2638,16 @@ public function testOperatorDateAddDaysComprehensive(): void public function testOperatorDateSubDaysComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_date_sub_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'date', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'date')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -2683,17 +2666,16 @@ public function testOperatorDateSubDaysComprehensive(): void public function testOperatorDateSetNowComprehensive(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'operator_date_now_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'timestamp', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'timestamp')); // Success case $doc = $database->createDocument($collectionId, new Document([ @@ -2706,6 +2688,7 @@ public function testOperatorDateSetNowComprehensive(): void ])); $result = $updated->getAttribute('timestamp'); + $this->assertIsString($result); $this->assertNotEmpty($result); // Verify it's a recent timestamp (within last minute) @@ -2717,24 +2700,22 @@ public function testOperatorDateSetNowComprehensive(): void $database->deleteCollection($collectionId); } - public function testMixedOperators(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'mixed_operators_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false); - $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count')); + $database->createAttribute($collectionId, Attribute::double(key: 'score')); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'name')); + $database->createAttribute($collectionId, Attribute::boolean(key: 'active')); // Test multiple operators in one update $doc = $database->createDocument($collectionId, new Document([ @@ -2765,18 +2746,17 @@ public function testMixedOperators(): void public function testOperatorsBatch(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'batch_operators_test'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); - $database->createAttribute($collectionId, 'category', Database::VAR_STRING, 50, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count')); + $database->createAttribute($collectionId, Attribute::string(key: 'category', size: 50)); // Create multiple documents $docs = []; @@ -2817,16 +2797,16 @@ public function testOperatorsBatch(): void */ public function testArrayInsertAtBeginning(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'test_array_insert_beginning'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], @@ -2860,16 +2840,16 @@ public function testArrayInsertAtBeginning(): void */ public function testArrayInsertAtMiddle(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'test_array_insert_middle'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'items', array: true)); $doc = $database->createDocument($collectionId, new Document([ '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], @@ -2903,16 +2883,16 @@ public function testArrayInsertAtMiddle(): void */ public function testArrayInsertAtEnd(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'test_array_insert_end'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], @@ -2922,7 +2902,7 @@ public function testArrayInsertAtEnd(): void $this->assertEquals(['apple', 'banana', 'cherry'], $doc->getAttribute('items')); // Attempt to insert at end (index = length) - $items = $doc->getAttribute('items'); + $items = $doc->getArray('items'); $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ 'items' => Operator::arrayInsert(count($items), 'date') ])); @@ -2947,16 +2927,16 @@ public function testArrayInsertAtEnd(): void */ public function testArrayInsertMultipleOperations(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'test_array_insert_multiple'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); $doc = $database->createDocument($collectionId, new Document([ '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], @@ -3024,31 +3004,29 @@ public function testArrayInsertMultipleOperations(): void */ public function testOperatorIncrementExceedsMaxValue(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_increment_max_violation'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); // Create an integer attribute with a maximum value of 100 // Using size=4 (signed int) with max constraint through Range validator - $database->createAttribute($collectionId, 'score', Database::VAR_INTEGER, 4, false, 0, false, false); + $database->createAttribute($collectionId, Attribute::integer(key: 'score', default: 0, signed: false)); // Get the collection to verify attribute was created - $collection = $database->getCollection($collectionId); - $attributes = $collection->getAttribute('attributes', []); - $scoreAttr = null; - foreach ($attributes as $attr) { - if ($attr['$id'] === 'score') { - $scoreAttr = $attr; + $score = null; + foreach ($database->getCollection($collectionId)->attributes() as $attribute) { + if ($attribute->key === 'score') { + $score = $attribute; break; } } + $this->assertNotNull($score); // Create a document with score at 95 (within valid range) $doc = $database->createDocument($collectionId, new Document([ @@ -3087,6 +3065,7 @@ public function testOperatorIncrementExceedsMaxValue(): void // Refetch to get the actual computed value from the database $refetched = $database->getDocument($collectionId, $doc2->getId()); $finalScore = $refetched->getAttribute('score'); + $this->assertIsNumeric($finalScore); // Document the bug: The value should not exceed MAX_INT $this->assertLessThanOrEqual( @@ -3112,19 +3091,18 @@ public function testOperatorIncrementExceedsMaxValue(): void */ public function testOperatorConcatExceedsMaxLength(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_concat_length_violation'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); // Create a string attribute with max length of 20 characters - $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 20, false, ''); + $database->createAttribute($collectionId, Attribute::string(key: 'title', size: 20, default: '')); // Create a document with a 15-character title (within limit) $doc = $database->createDocument($collectionId, new Document([ @@ -3132,8 +3110,10 @@ public function testOperatorConcatExceedsMaxLength(): void 'title' => 'Hello World' // 11 characters ])); - $this->assertEquals('Hello World', $doc->getAttribute('title')); - $this->assertEquals(11, strlen($doc->getAttribute('title'))); + $title = $doc->getAttribute('title'); + $this->assertIsString($title); + $this->assertEquals('Hello World', $title); + $this->assertEquals(11, strlen($title)); // BUG EXPOSED: Concat a 15-character string to make total length 26 (exceeds max of 20) // This should throw a StructureException for exceeding max length, @@ -3146,6 +3126,7 @@ public function testOperatorConcatExceedsMaxLength(): void // Refetch to get the actual computed value from the database $refetched = $database->getDocument($collectionId, $doc->getId()); $finalTitle = $refetched->getAttribute('title'); + $this->assertIsString($finalTitle); $finalLength = strlen($finalTitle); // Document the bug: The resulting string should not exceed 20 characters @@ -3171,19 +3152,18 @@ public function testOperatorConcatExceedsMaxLength(): void */ public function testOperatorMultiplyViolatesRange(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_multiply_range_violation'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); // Create a signed integer attribute (max value = Database::MAX_INT = 2147483647) - $database->createAttribute($collectionId, 'quantity', Database::VAR_INTEGER, 4, false, 1, false, false); + $database->createAttribute($collectionId, Attribute::integer(key: 'quantity', default: 1, signed: false)); // Create a document with quantity that when multiplied will exceed MAX_INT $doc = $database->createDocument($collectionId, new Document([ @@ -3204,6 +3184,7 @@ public function testOperatorMultiplyViolatesRange(): void // Refetch to get the actual computed value from the database $refetched = $database->getDocument($collectionId, $doc->getId()); $finalQuantity = $refetched->getAttribute('quantity'); + $this->assertIsNumeric($finalQuantity); // Document the bug: The value should not exceed MAX_INT $this->assertLessThanOrEqual( @@ -3233,17 +3214,16 @@ public function testOperatorMultiplyViolatesRange(): void public function testOperatorMultiplyWithNegativeMultiplier(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_multiply_negative'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value')); // Test negative multiplier without max limit $doc1 = $database->createDocument($collectionId, new Document([ @@ -3315,17 +3295,16 @@ public function testOperatorMultiplyWithNegativeMultiplier(): void public function testOperatorDivideWithNegativeDivisor(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_divide_negative'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value')); // Test negative divisor without min limit $doc1 = $database->createDocument($collectionId, new Document([ @@ -3387,20 +3366,19 @@ public function testOperatorDivideWithNegativeDivisor(): void */ public function testOperatorArrayAppendViolatesItemConstraints(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_item_type_violation'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); // Create an array attribute for integers with max value constraint // Each item should be an integer within the valid range - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 4, false, null, true, true); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); // Create a document with valid integer array $doc = $database->createDocument($collectionId, new Document([ @@ -3428,8 +3406,10 @@ public function testOperatorArrayAppendViolatesItemConstraints(): void // Refetch to get the actual computed value from the database $refetched = $database->getDocument($collectionId, $doc2->getId()); - $finalNumbers = $refetched->getAttribute('numbers'); + $finalNumbers = $refetched->getArray('numbers'); $lastNumber = end($finalNumbers); + $this->assertNotFalse($lastNumber); + $this->assertIsNumeric($lastNumber); // Document the bug: Array items should not exceed MAX_INT $this->assertLessThanOrEqual( @@ -3462,10 +3442,11 @@ public function testOperatorArrayAppendViolatesItemConstraints(): void // Refetch to get the actual computed value from the database $refetched = $database->getDocument($collectionId, $doc3->getId()); - $finalNumbers = $refetched->getAttribute('numbers'); + $finalNumbers = $refetched->getArray('numbers'); // Document the bug: ALL array items should be validated foreach ($finalNumbers as $num) { + $this->assertIsNumeric($num); $this->assertLessThanOrEqual( Database::MAX_INT, $num, @@ -3494,18 +3475,17 @@ public function testOperatorArrayAppendViolatesItemConstraints(): void public function testOperatorWithExtremeIntegerValues(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_extreme_integers'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'bigint_max', Database::VAR_INTEGER, 8, true); - $database->createAttribute($collectionId, 'bigint_min', Database::VAR_INTEGER, 8, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::bigInteger(key: 'bigint_max', required: true)); + $database->createAttribute($collectionId, Attribute::bigInteger(key: 'bigint_min', required: true)); $maxValue = PHP_INT_MAX - 1000; // Near max but with room $minValue = PHP_INT_MIN + 1000; // Near min but with room @@ -3543,17 +3523,16 @@ public function testOperatorWithExtremeIntegerValues(): void public function testOperatorPowerWithNegativeExponent(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_negative_power'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); // Create document with value 8 $doc = $database->createDocument($collectionId, new Document([ @@ -3579,17 +3558,16 @@ public function testOperatorPowerWithNegativeExponent(): void public function testOperatorPowerWithFractionalExponent(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_fractional_power'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); // Create document with value 16 $doc = $database->createDocument($collectionId, new Document([ @@ -3626,17 +3604,16 @@ public function testOperatorPowerWithFractionalExponent(): void public function testOperatorWithEmptyStrings(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_empty_strings'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', default: '')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'empty_str_doc', @@ -3683,17 +3660,16 @@ public function testOperatorWithEmptyStrings(): void public function testOperatorWithUnicodeCharacters(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_unicode'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 500, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', size: 500, default: '')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'unicode_doc', @@ -3721,7 +3697,9 @@ public function testOperatorWithUnicodeCharacters(): void $updated = $database->updateDocument($collectionId, 'unicode_doc', new Document([ 'text' => Operator::stringConcat(' ☕') ])); - $this->assertStringContainsString('☕', $updated->getAttribute('text')); + $unicodeText = $updated->getAttribute('text'); + $this->assertIsString($unicodeText); + $this->assertStringContainsString('☕', $unicodeText); $database->deleteCollection($collectionId); } @@ -3733,17 +3711,16 @@ public function testOperatorWithUnicodeCharacters(): void public function testOperatorArrayOperationsOnEmptyArrays(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_empty_arrays'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'empty_array_doc', @@ -3803,17 +3780,16 @@ public function testOperatorArrayOperationsOnEmptyArrays(): void public function testOperatorArrayWithNullAndSpecialValues(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_special_values'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'mixed', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'mixed', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'special_values_doc', @@ -3825,10 +3801,10 @@ public function testOperatorArrayWithNullAndSpecialValues(): void $updated = $database->updateDocument($collectionId, 'special_values_doc', new Document([ 'mixed' => Operator::arrayUnique() ])); - $this->assertContains('', $updated->getAttribute('mixed')); - $this->assertContains('text', $updated->getAttribute('mixed')); + $this->assertContains('', $updated->getArray('mixed')); + $this->assertContains('text', $updated->getArray('mixed')); // Should have only 2 unique values: '' and 'text' - $this->assertCount(2, $updated->getAttribute('mixed')); + $this->assertCount(2, $updated->getArray('mixed')); // Test remove empty string $database->updateDocument($collectionId, 'special_values_doc', new Document([ @@ -3838,7 +3814,7 @@ public function testOperatorArrayWithNullAndSpecialValues(): void $updated = $database->updateDocument($collectionId, 'special_values_doc', new Document([ 'mixed' => Operator::arrayRemove('') ])); - $this->assertNotContains('', $updated->getAttribute('mixed')); + $this->assertNotContains('', $updated->getArray('mixed')); $this->assertEquals(['a', 'b'], $updated->getAttribute('mixed')); $database->deleteCollection($collectionId); @@ -3851,17 +3827,16 @@ public function testOperatorArrayWithNullAndSpecialValues(): void public function testOperatorModuloWithNegativeNumbers(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_negative_modulo'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_INTEGER, 0, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'value', required: true)); // Test -17 % 5 (different languages handle this differently) $doc = $database->createDocument($collectionId, new Document([ @@ -3899,17 +3874,16 @@ public function testOperatorModuloWithNegativeNumbers(): void public function testOperatorFloatPrecisionLoss(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_float_precision'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'precision_doc', @@ -3951,17 +3925,16 @@ public function testOperatorFloatPrecisionLoss(): void public function testOperatorWithVeryLongStrings(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_long_strings'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 70000, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', size: 70000, default: '')); // Create a long string (10k characters) $longString = str_repeat('A', 10000); @@ -3978,6 +3951,7 @@ public function testOperatorWithVeryLongStrings(): void ])); $result = $updated->getAttribute('text'); + $this->assertIsString($result); $this->assertEquals(20000, strlen($result)); $this->assertStringStartsWith('AAA', $result); $this->assertStringEndsWith('BBB', $result); @@ -3988,6 +3962,7 @@ public function testOperatorWithVeryLongStrings(): void ])); $result = $updated->getAttribute('text'); + $this->assertIsString($result); $this->assertStringNotContainsString('A', $result); $this->assertStringContainsString('X', $result); @@ -4001,17 +3976,16 @@ public function testOperatorWithVeryLongStrings(): void public function testOperatorDateAtYearBoundaries(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_date_boundaries'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'date', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'date')); // Test date at end of year $doc = $database->createDocument($collectionId, new Document([ @@ -4026,6 +4000,7 @@ public function testOperatorDateAtYearBoundaries(): void ])); $resultDate = $updated->getAttribute('date'); + $this->assertIsString($resultDate); $this->assertStringStartsWith('2024-01-01', $resultDate); // Test leap year: Feb 28, 2024 + 1 day = Feb 29, 2024 (leap year) @@ -4038,6 +4013,7 @@ public function testOperatorDateAtYearBoundaries(): void ])); $resultDate = $updated->getAttribute('date'); + $this->assertIsString($resultDate); $this->assertStringStartsWith('2024-02-29', $resultDate); // Test non-leap year: Feb 28, 2023 + 1 day = Mar 1, 2023 @@ -4050,6 +4026,7 @@ public function testOperatorDateAtYearBoundaries(): void ])); $resultDate = $updated->getAttribute('date'); + $this->assertIsString($resultDate); $this->assertStringStartsWith('2023-03-01', $resultDate); // Test large day addition (cross multiple months) @@ -4062,6 +4039,7 @@ public function testOperatorDateAtYearBoundaries(): void ])); $resultDate = $updated->getAttribute('date'); + $this->assertIsString($resultDate); $this->assertStringStartsWith('2024-01-01', $resultDate); $database->deleteCollection($collectionId); @@ -4074,17 +4052,16 @@ public function testOperatorDateAtYearBoundaries(): void public function testOperatorArrayInsertAtExactBoundaries(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_insert_boundaries'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'boundary_insert_doc', @@ -4118,18 +4095,17 @@ public function testOperatorArrayInsertAtExactBoundaries(): void public function testOperatorSequentialApplications(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_sequential_ops'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'counter', default: 0)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', default: '')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'sequential_doc', @@ -4185,17 +4161,16 @@ public function testOperatorSequentialApplications(): void public function testOperatorWithZeroValues(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_zero_values'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'zero_doc', @@ -4241,17 +4216,16 @@ public function testOperatorWithZeroValues(): void public function testOperatorArrayIntersectAndDiffWithEmptyResults(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_empty_results'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'empty_result_doc', @@ -4291,17 +4265,16 @@ public function testOperatorArrayIntersectAndDiffWithEmptyResults(): void public function testOperatorReplaceMultipleOccurrences(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_replace_multiple'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, ''); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', default: '')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'replace_multi_doc', @@ -4335,17 +4308,16 @@ public function testOperatorReplaceMultipleOccurrences(): void public function testOperatorIncrementDecrementWithPreciseFloats(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_precise_floats'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'precise_doc', @@ -4379,17 +4351,16 @@ public function testOperatorIncrementDecrementWithPreciseFloats(): void public function testOperatorArrayWithSingleElement(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_single_element'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'single_elem_doc', @@ -4439,17 +4410,16 @@ public function testOperatorArrayWithSingleElement(): void public function testOperatorToggleFromDefaultValue(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_toggle_default'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'flag', Database::VAR_BOOLEAN, 0, false, false); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'flag', default: false)); // Create doc without setting flag (should use default false) $doc = $database->createDocument($collectionId, new Document([ @@ -4482,18 +4452,17 @@ public function testOperatorToggleFromDefaultValue(): void public function testOperatorWithAttributeConstraints(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_attribute_constraints'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); // Integer with size 0 (32-bit INT) - $database->createAttribute($collectionId, 'small_int', Database::VAR_INTEGER, 0, true); + $database->createAttribute($collectionId, Attribute::integer(key: 'small_int', required: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'constraint_doc', @@ -4523,21 +4492,20 @@ public function testOperatorWithAttributeConstraints(): void public function testBulkUpdateWithOperatorsCallbackReceivesFreshData(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_bulk_callback'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); // Create multiple test documents for ($i = 1; $i <= 5; $i++) { @@ -4559,17 +4527,22 @@ public function testBulkUpdateWithOperatorsCallbackReceivesFreshData(): void 'tags' => Operator::arrayAppend(['updated']) ]), [], - Database::INSERT_BATCH_SIZE, - function (Document $doc, Document $old) use (&$callbackResults) { + Database::BATCH_SIZE, + function (Document $doc, ?Document $old) use (&$callbackResults) { // Verify callback receives fresh computed values, not Operator objects $this->assertIsInt($doc->getAttribute('count')); $this->assertIsFloat($doc->getAttribute('score')); $this->assertIsArray($doc->getAttribute('tags')); // Verify values are actually computed - $expectedCount = $old->getAttribute('count') + 7; - $expectedScore = $old->getAttribute('score') * 2; - $expectedTags = array_merge($old->getAttribute('tags'), ['updated']); + $this->assertNotNull($old, 'an update hands onNext the document it replaced'); + $oldCount = $old->getAttribute('count'); + $oldScore = $old->getAttribute('score'); + $this->assertIsNumeric($oldCount); + $this->assertIsNumeric($oldScore); + $expectedCount = $oldCount + 7; + $expectedScore = $oldScore * 2; + $expectedTags = array_merge($old->getArray('tags'), ['updated']); $this->assertEquals($expectedCount, $doc->getAttribute('count')); $this->assertEquals($expectedScore, $doc->getAttribute('score')); @@ -4589,21 +4562,20 @@ function (Document $doc, Document $old) use (&$callbackResults) { public function testBulkUpsertWithOperatorsCallbackReceivesFreshData(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_upsert_callback'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', default: 0.0)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Create existing documents $database->createDocument($collectionId, new Document([ @@ -4652,7 +4624,7 @@ public function testBulkUpsertWithOperatorsCallbackReceivesFreshData(): void $count = $database->upsertDocuments( $collectionId, $documents, - Database::INSERT_BATCH_SIZE, + Database::BATCH_SIZE, function (Document $doc, ?Document $old) use (&$callbackResults) { // Verify callback receives fresh computed values, not Operator objects $this->assertIsInt($doc->getAttribute('count')); @@ -4686,21 +4658,20 @@ function (Document $doc, ?Document $old) use (&$callbackResults) { public function testSingleUpsertWithOperators(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection $collectionId = 'test_single_upsert'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); // Test upsert with operators on new document (insert) $doc = $database->upsertDocument($collectionId, new Document([ @@ -4753,25 +4724,24 @@ public function testSingleUpsertWithOperators(): void public function testUpsertOperatorsOnNewDocuments(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - // Create test collection with all attribute types needed for operators $collectionId = 'test_upsert_new_ops'; - $database->createCollection($collectionId); + $database->createCollection(Collection::create(id: $collectionId)); - $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'price', Database::VAR_FLOAT, 0, false, 0.0); - $database->createAttribute($collectionId, 'quantity', Database::VAR_INTEGER, 0, false, 0); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); - $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 100, false, ''); + $database->createAttribute($collectionId, Attribute::integer(key: 'counter', default: 0)); + $database->createAttribute($collectionId, Attribute::double(key: 'score', default: 0.0)); + $database->createAttribute($collectionId, Attribute::double(key: 'price', default: 0.0)); + $database->createAttribute($collectionId, Attribute::integer(key: 'quantity', default: 0)); + $database->createAttribute($collectionId, Attribute::string(key: 'tags', size: 50, array: true)); + $database->createAttribute($collectionId, Attribute::integer(key: 'numbers', array: true)); + $database->createAttribute($collectionId, Attribute::string(key: 'name', size: 100, default: '')); // Test 1: INCREMENT on new document (should use 0 as default) $doc1 = $database->upsertDocument($collectionId, new Document([ @@ -4886,37 +4856,37 @@ public function testUpsertOperatorsOnNewDocuments(): void public function testUpsertDocumentsWithAllOperators(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } $collectionId = 'test_upsert_all_operators'; $attributes = [ - new Document(['$id' => 'counter', 'type' => Database::VAR_INTEGER, 'size' => 0, 'required' => false, 'default' => 10, 'signed' => true, 'array' => false]), - new Document(['$id' => 'score', 'type' => Database::VAR_FLOAT, 'size' => 0, 'required' => false, 'default' => 5.0, 'signed' => true, 'array' => false]), - new Document(['$id' => 'multiplier', 'type' => Database::VAR_FLOAT, 'size' => 0, 'required' => false, 'default' => 2.0, 'signed' => true, 'array' => false]), - new Document(['$id' => 'divisor', 'type' => Database::VAR_FLOAT, 'size' => 0, 'required' => false, 'default' => 100.0, 'signed' => true, 'array' => false]), - new Document(['$id' => 'remainder', 'type' => Database::VAR_INTEGER, 'size' => 0, 'required' => false, 'default' => 20, 'signed' => true, 'array' => false]), - new Document(['$id' => 'power_val', 'type' => Database::VAR_FLOAT, 'size' => 0, 'required' => false, 'default' => 2.0, 'signed' => true, 'array' => false]), - new Document(['$id' => 'title', 'type' => Database::VAR_STRING, 'size' => 255, 'required' => false, 'default' => 'Title', 'signed' => true, 'array' => false]), - new Document(['$id' => 'content', 'type' => Database::VAR_STRING, 'size' => 500, 'required' => false, 'default' => 'old content', 'signed' => true, 'array' => false]), - new Document(['$id' => 'tags', 'type' => Database::VAR_STRING, 'size' => 50, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'categories', 'type' => Database::VAR_STRING, 'size' => 50, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'items', 'type' => Database::VAR_STRING, 'size' => 50, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'duplicates', 'type' => Database::VAR_STRING, 'size' => 50, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'numbers', 'type' => Database::VAR_INTEGER, 'size' => 0, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'intersect_items', 'type' => Database::VAR_STRING, 'size' => 50, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'diff_items', 'type' => Database::VAR_STRING, 'size' => 50, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'filter_numbers', 'type' => Database::VAR_INTEGER, 'size' => 0, 'required' => false, 'default' => null, 'signed' => true, 'array' => true]), - new Document(['$id' => 'active', 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'required' => false, 'default' => false, 'signed' => true, 'array' => false]), - new Document(['$id' => 'date_field1', 'type' => Database::VAR_DATETIME, 'size' => 0, 'required' => false, 'default' => null, 'signed' => true, 'array' => false, 'format' => '', 'filters' => ['datetime']]), - new Document(['$id' => 'date_field2', 'type' => Database::VAR_DATETIME, 'size' => 0, 'required' => false, 'default' => null, 'signed' => true, 'array' => false, 'format' => '', 'filters' => ['datetime']]), - new Document(['$id' => 'date_field3', 'type' => Database::VAR_DATETIME, 'size' => 0, 'required' => false, 'default' => null, 'signed' => true, 'array' => false, 'format' => '', 'filters' => ['datetime']]), + Attribute::integer(key: 'counter', default: 10), + Attribute::double(key: 'score', default: 5.0), + Attribute::double(key: 'multiplier', default: 2.0), + Attribute::double(key: 'divisor', default: 100.0), + Attribute::integer(key: 'remainder', default: 20), + Attribute::double(key: 'power_val', default: 2.0), + Attribute::string(key: 'title', default: 'Title'), + Attribute::string(key: 'content', size: 500, default: 'old content'), + Attribute::string(key: 'tags', size: 50, array: true), + Attribute::string(key: 'categories', size: 50, array: true), + Attribute::string(key: 'items', size: 50, array: true), + Attribute::string(key: 'duplicates', size: 50, array: true), + Attribute::integer(key: 'numbers', array: true), + Attribute::string(key: 'intersect_items', size: 50, array: true), + Attribute::string(key: 'diff_items', size: 50, array: true), + Attribute::integer(key: 'filter_numbers', array: true), + Attribute::boolean(key: 'active', default: false), + Attribute::datetime(key: 'date_field1'), + Attribute::datetime(key: 'date_field2'), + Attribute::datetime(key: 'date_field3'), ]; - $database->createCollection($collectionId, $attributes); + $database->createCollection(Collection::create(id: $collectionId, attributes: $attributes)); $database->createDocument($collectionId, new Document([ '$id' => 'upsert_doc_1', @@ -5062,10 +5032,10 @@ public function testUpsertDocumentsWithAllOperators(): void $this->assertEquals(4.0, $doc1->getAttribute('power_val')); // 2^2 $this->assertEquals('Title 1 - Updated', $doc1->getAttribute('title')); $this->assertEquals('new content 1', $doc1->getAttribute('content')); - $this->assertContains('upsert', $doc1->getAttribute('tags')); - $this->assertContains('priority', $doc1->getAttribute('categories')); - $this->assertNotContains('shared', $doc1->getAttribute('items')); - $this->assertCount(4, $doc1->getAttribute('duplicates')); // Should have unique values + $this->assertContains('upsert', $doc1->getArray('tags')); + $this->assertContains('priority', $doc1->getArray('categories')); + $this->assertNotContains('shared', $doc1->getArray('items')); + $this->assertCount(4, $doc1->getArray('duplicates')); // Should have unique values $this->assertEquals([1, 2, 99, 3, 4, 5], $doc1->getAttribute('numbers')); // arrayInsert at index 2 $this->assertEquals(['b', 'c'], $doc1->getAttribute('intersect_items')); // arrayIntersect $this->assertEquals(['x', 'w'], $doc1->getAttribute('diff_items')); // arrayDiff (removed y, z) @@ -5117,17 +5087,16 @@ public function testUpsertDocumentsWithAllOperators(): void public function testOperatorArrayEmptyResultsNotNull(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_array_not_null'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'items', size: 50, array: true)); // Test ARRAY_UNIQUE on empty array returns [] not NULL $doc1 = $database->createDocument($collectionId, new Document([ @@ -5179,17 +5148,16 @@ public function testOperatorArrayEmptyResultsNotNull(): void public function testUpdateDocumentsWithOperatorsCacheInvalidation(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOperators()) { + if (!$database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'test_operator_cache'; - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'counter', default: 0)); // Create a document $doc = $database->createDocument($collectionId, new Document([ @@ -5231,4 +5199,98 @@ public function testUpdateDocumentsWithOperatorsCacheInvalidation(): void $database->deleteCollection($collectionId); } + + public function testOperatorWholeFloatLimitHoldsAndAFractionalLimitIsRefused(): void + { + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Operators)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionId = 'operator_integer_limits'; + $database->createCollection(Collection::create(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::bigInteger(key: 'counter')); + $database->createAttribute($collectionId, Attribute::integer(key: 'count')); + + try { + $database->createDocument($collectionId, new Document([ + '$id' => 'doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => PHP_INT_MAX - 5, + 'count' => 100, + ])); + + $updated = $database->updateDocument($collectionId, 'doc', new Document([ + 'counter' => Operator::increment(10, 9.0e18), + ])); + + $this->assertSame(PHP_INT_MAX - 5, $updated->getAttribute('counter')); + $this->assertSame(PHP_INT_MAX - 5, $database->getDocument($collectionId, 'doc')->getAttribute('counter')); + + try { + $database->updateDocument($collectionId, 'doc', new Document([ + 'count' => Operator::increment(5, 102.4), + ])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame( + "Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4", + $exception->getMessage(), + ); + } + + $this->assertSame(100, $database->getDocument($collectionId, 'doc')->getAttribute('count')); + } finally { + $database->deleteCollection($collectionId); + } + } + + public function testPowerWithANumericTextExponent(): void + { + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Operators)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionId = 'j47a_power_numeric_text'; + if ($database->collectionExists($collectionId)) { + $database->deleteCollection($collectionId); + } + $database->createCollection(Collection::create(id: $collectionId, permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: false)); + $database->createAttribute($collectionId, Attribute::integer(key: 'count')); + $database->createAttribute($collectionId, Attribute::double(key: 'ratio')); + + try { + $database->createDocument($collectionId, new Document(['$id' => 'first', 'count' => 3, 'ratio' => 1.5])); + + $updated = $database->updateDocument($collectionId, 'first', new Document([ + 'count' => Operator::power('2'), + 'ratio' => Operator::power('2'), + ])); + $this->assertSame(9, $updated->getAttribute('count')); + $this->assertEquals(2.25, $updated->getAttribute('ratio')); + + $updated = $database->updateDocument($collectionId, 'first', new Document(['count' => Operator::power('2', '50')])); + $this->assertSame(9, $updated->getAttribute('count')); + + try { + $database->updateDocument($collectionId, 'first', new Document(['count' => Operator::power('two')])); + $this->fail('A non-numeric exponent must be refused'); + } catch (StructureException $error) { + $this->assertStringContainsString('value must be numeric', $error->getMessage()); + } + + $fetched = $database->getDocument($collectionId, 'first'); + $this->assertSame(9, $fetched->getAttribute('count')); + $this->assertEquals(2.25, $fetched->getAttribute('ratio')); + } finally { + $database->deleteCollection($collectionId); + } + } } diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 97e55633fc..13abd57bf1 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -3,15 +3,28 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\Mongo; use Utopia\Database\Adapter\SQL; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Change; +use Utopia\Database\Collection; +use Utopia\Database\CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Hook\Tenancy; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\RelationshipType; +use Utopia\Database\Role; +use Utopia\Database\Storage; trait PermissionTests { @@ -22,8 +35,9 @@ public function testUpdatingASharedDefinitionKeepsItsPermissionRowsTenantless(): // Only the SQL adapters keep permissions in a side table that carries // its own tenant column; Mongo stores them on the document itself. - if (!$database->getSharedTables() || !$database->getAdapter() instanceof SQL) { + if (! $database->hasSharedTables() || ! $database->getAdapter() instanceof SQL) { $this->expectNotToPerformAssertions(); + return; } @@ -34,7 +48,11 @@ public function testUpdatingASharedDefinitionKeepsItsPermissionRowsTenantless(): // A shared pool's system collections are created once with no // tenant, so every tenant on the pool reads the one definition. $database->setTenant(null); - $database->createCollection($collection, [], [], [Permission::read(Role::any())], false); + $database->createCollection(Collection::create( + id: $collection, + permissions: [Permission::read(Role::any())], + documentSecurity: false, + )); // A per-project pass rewrites that definition while it holds one // project's tenant. The rows it writes belong to the document, not @@ -45,19 +63,667 @@ public function testUpdatingASharedDefinitionKeepsItsPermissionRowsTenantless(): '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - // Permission filtering reads the permission rows, so tenanting them - // to 989 hides the shared definition from every other tenant. - $database->setTenant(990); - $found = $database->find(Database::METADATA, [Query::equal('$id', [$collection])]); + // Permission filtering reads the permission rows, so tenanting them + // to 989 hides the shared definition from every other tenant. + $database->setTenant(990); + $found = $database->find(Database::METADATA, [Query::equal('$id', [$collection])]); + + $this->assertCount( + 1, + $found, + 'A shared definition updated under one tenant must stay visible to the rest of the pool.', + ); + } finally { + $database->setTenant($tenant); + } + } + + public function testUpsertedPermissionsAreStoredUnderTheTenant(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $database->hasSharedTables() || ! $adapter instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'upsertPermsTenant'; + $reader = Role::user('upsertReader'); + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + + // An adapter that has not written yet, like every one a pool lends out, holds no tenant hook. + $adapter->removeWriteHook(Tenancy::class); + + $database->upsertDocuments($collection, [new Document([ + '$id' => 'upserted', + 'title' => 'upserted', + '$permissions' => [Permission::read($reader)], + ])]); + + $authorization = $database->getAuthorization(); + + $stored = $authorization->skip(static function () use ($adapter, $collection): array { + $statement = $adapter->builder()->from(Storage::permissionsTable($collection)) + ->select([Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION]) + ->filter([Query::equal(Storage::PERMISSIONS_DOCUMENT, ['upserted'])]) + ->build(); + + return \array_map( + static fn (Document $row): array => $row->getArrayCopy(), + $adapter->rawQuery($statement->query, $statement->bindings), + ); + }); + + $this->assertSame( + [[Storage::PERMISSIONS_TYPE => PermissionType::Read->value, Storage::PERMISSIONS_PERMISSION => $reader->toString()]], + $stored, + 'An upsert must store its permission rows under the tenant, where the permission hook and filter look for them', + ); + + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole($reader->toString()); + + try { + $found = $database->find($collection); + $count = $database->count($collection); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + } + + $this->assertSame( + ['upserted'], + \array_map(static fn (Document $document): string => $document->getId(), $found), + 'A reader holding the document read permission must find an upserted document', + ); + $this->assertSame(1, $count); + } + + public function testAnAdapterUpsertStoresADocumentWithoutATenantUnderTheSelectedTenant(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $database->hasSharedTables() || ! $adapter instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'upsertRowTenant'; + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + )); + + $adapter->upsertDocuments($database->getCollection($collection), [ + new Change(new Document(), new Document([ + '$id' => 'upserted', + 'title' => 'upserted', + '$permissions' => [Permission::read(Role::any())], + ])), + ]); + + $this->assertSame( + 'upserted', + $database->getDocument($collection, 'upserted')->getAttribute('title'), + 'A document without a tenant is stored under the selected tenant, as createDocuments() stores it', + ); + } + + public function testTenantPerDocumentUpsertWithoutASelectedTenantAppliesPermissionsUnderTheDocumentTenant(): void + { + $this->withTenantPerDocumentNotes('tpdRevokeNoTenant', function (Database $database): void { + $database->upsertDocuments('notes', [$this->tenantPerDocumentNote(5, ['alice'])]); + + $this->assertTenantPerDocumentRevokedBobUnderTenant5($database); + }); + } + + public function testTenantPerDocumentUpsertUnderAnotherSelectedTenantAppliesPermissionsUnderTheDocumentTenant(): void + { + $this->withTenantPerDocumentNotes('tpdRevokeOtherTenant', function (Database $database): void { + $database->withTenant( + 6, + fn (): int => $database->upsertDocuments('notes', [$this->tenantPerDocumentNote(5, ['alice'])]), + ); + + $this->assertTenantPerDocumentRevokedBobUnderTenant5($database); + }); + } + + public function testTenantPerDocumentUpsertBatchAppliesPermissionsPerDocumentTenant(): void + { + $this->withTenantPerDocumentNotes('tpdRevokeBatch', function (Database $database): void { + $database->upsertDocuments('notes', [ + $this->tenantPerDocumentNote(5, ['alice']), + $this->tenantPerDocumentNote(6, ['alice', 'bob'], 'retitled'), + ]); + + $this->assertTenantPerDocumentRevokedBobUnderTenant5($database); + }); + } + + public function testTenantPerDocumentRolledBackUpsertRestoresPermissionsUnderTheDocumentTenant(): void + { + $this->withTenantPerDocumentNotes('tpdRevokeRollback', function (Database $database): void { + try { + $database->withTransaction(function () use ($database): void { + $database->upsertDocuments('notes', [$this->tenantPerDocumentNote(5, ['alice'])]); + + throw new Exception('Roll the revoke back'); + }); + } catch (Exception $exception) { + $this->assertSame('Roll the revoke back', $exception->getMessage()); + } + + if ($database->getAdapter() instanceof SQL) { + $this->assertSame( + [5 => ['user:alice', 'user:bob'], 6 => ['user:alice', 'user:bob']], + $this->tenantPerDocumentGrants($database), + ); + } + $this->assertSame( + [5 => ['note'], 6 => ['note']], + $this->tenantPerDocumentReadable($database, 'bob'), + 'A rolled back revoke must restore the grant under the document\'s own tenant', + ); + }); + } + + /** + * Runs a scenario in a schema of its own where no tenant is selected and each document carries + * its own: tenants 5 and 6 each hold a document `note` that alice and bob can read. + * + * @param callable(Database): void $scenario + */ + private function withTenantPerDocumentNotes(string $schema, callable $scenario): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if ( + ! $database->hasSharedTables() + || ! $adapter->hasFeature(Feature\Upserts::class) + || ! $adapter->supports(Capability::Schemas) + ) { + $this->expectNotToPerformAssertions(); + + return; + } + + $tenantPerDocument = $database->isTenantPerDocument(); + $tenant = $database->getTenant(); + $namespace = $database->getNamespace(); + $current = $database->getDatabase(); + $schema .= '_'.static::getTestToken(); + + if ($database->exists($schema)) { + $database->delete($schema); + } + + try { + $database + ->setDatabase($schema) + ->setNamespace('') + ->setTenant(null) + ->create(); + + $database->createCollection(Collection::create( + id: 'notes', + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: true, + )); + + $database->setTenantPerDocument(true); + foreach ([5, 6] as $documentTenant) { + $database->createDocument('notes', $this->tenantPerDocumentNote($documentTenant, ['alice', 'bob'])); + } + + $this->assertSame( + [5 => ['note'], 6 => ['note']], + $this->tenantPerDocumentReadable($database, 'bob'), + 'A document created with no tenant selected must be readable through its grants under its own tenant', + ); + + $scenario($database); + } finally { + $database + ->setTenantPerDocument($tenantPerDocument) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($current); + } + } + + private function assertTenantPerDocumentRevokedBobUnderTenant5(Database $database): void + { + if ($database->getAdapter() instanceof SQL) { + $this->assertSame( + [5 => ['user:alice'], 6 => ['user:alice', 'user:bob']], + $this->tenantPerDocumentGrants($database), + 'Revoking bob on tenant 5\'s document must remove tenant 5\'s row and no other tenant\'s', + ); + } + $this->assertSame([5 => [], 6 => ['note']], $this->tenantPerDocumentReadable($database, 'bob')); + $this->assertSame([5 => ['note'], 6 => ['note']], $this->tenantPerDocumentReadable($database, 'alice')); + } + + /** + * @param list $readers + */ + private function tenantPerDocumentNote(int $tenant, array $readers, string $title = 'first'): Document + { + return new Document([ + '$id' => 'note', + '$tenant' => $tenant, + 'title' => $title, + '$permissions' => \array_map( + static fn (string $reader): string => Permission::read(Role::user($reader)), + $readers, + ), + ]); + } + + /** + * @return array> + */ + private function tenantPerDocumentReadable(Database $database, string $reader): array + { + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::user($reader)->toString()); + + try { + $readable = []; + foreach ([5, 6] as $tenant) { + $readable[$tenant] = $this->documentIds($database->withTenant($tenant, fn (): array => $database->find('notes'))); + } + + return $readable; + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + } + } + + /** + * @return array> + */ + private function tenantPerDocumentGrants(Database $database): array + { + $adapter = $database->getAdapter(); + $this->assertInstanceOf(SQL::class, $adapter); + + $grants = []; + foreach ([5, 6] as $tenant) { + $statement = $database->withTenant($tenant, fn () => $adapter->builder()->from(Storage::permissionsTable('notes')) + ->select([Storage::PERMISSIONS_PERMISSION]) + ->filter([ + Query::equal(Storage::PERMISSIONS_DOCUMENT, ['note']), + Query::equal(Storage::PERMISSIONS_TYPE, [PermissionType::Read->value]), + ]) + ->build()); + + $permissions = []; + foreach ($adapter->rawQuery($statement->query, $statement->bindings) as $row) { + $permission = $row->getAttribute(Storage::PERMISSIONS_PERMISSION); + $this->assertIsString($permission); + $permissions[] = $permission; + } + \sort($permissions); + $grants[$tenant] = $permissions; + } + + return $grants; + } + + private static string $collSecurityCollection = ''; + + private static string $collSecurityParentCollection = ''; + + private static string $collSecurityOneToOneCollection = ''; + + private static string $collSecurityOneToManyCollection = ''; + + private static string $collUpdateCollection = ''; + + protected function getCollSecurityCollection(): string + { + if (self::$collSecurityCollection === '') { + self::$collSecurityCollection = 'collectionSecurity_' . uniqid(); + } + return self::$collSecurityCollection; + } + + protected function getCollSecurityParentCollection(): string + { + if (self::$collSecurityParentCollection === '') { + self::$collSecurityParentCollection = 'csParent_' . uniqid(); + } + return self::$collSecurityParentCollection; + } + + protected function getCollSecurityOneToOneCollection(): string + { + if (self::$collSecurityOneToOneCollection === '') { + self::$collSecurityOneToOneCollection = 'csO2O_' . uniqid(); + } + return self::$collSecurityOneToOneCollection; + } + + protected function getCollSecurityOneToManyCollection(): string + { + if (self::$collSecurityOneToManyCollection === '') { + self::$collSecurityOneToManyCollection = 'csO2M_' . uniqid(); + } + return self::$collSecurityOneToManyCollection; + } + + protected function getCollUpdateCollection(): string + { + if (self::$collUpdateCollection === '') { + self::$collUpdateCollection = 'collectionUpdate_' . uniqid(); + } + return self::$collUpdateCollection; + } + + private static bool $collPermFixtureInit = false; + + /** @var array{collectionId: string, docId: string}|null */ + private static ?array $collPermFixtureData = null; + + private static bool $relPermFixtureInit = false; + + /** @var array{collectionId: string, oneToOneId: string, oneToManyId: string, docId: string}|null */ + private static ?array $relPermFixtureData = null; + + private static bool $collUpdateFixtureInit = false; + + /** @var array{collectionId: string}|null */ + private static ?array $collUpdateFixtureData = null; + + /** + * Create the $this->getCollSecurityCollection() collection with a document. + * Combines the setup from testCollectionPermissions + testCollectionPermissionsCreateWorks. + * + * @return array{collectionId: string, docId: string} + */ + protected function initCollectionPermissionFixture(): array + { + if (self::$collPermFixtureInit && self::$collPermFixtureData !== null) { + /** @var Database $database */ + $database = $this->getDatabase(); + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + $doc = $database->getDocument(self::$collPermFixtureData['collectionId'], self::$collPermFixtureData['docId']); + if (!$doc->isEmpty()) { + return self::$collPermFixtureData; + } + self::$collPermFixtureInit = false; + } + + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $database->deleteCollection($this->getCollSecurityCollection()); + } catch (\Throwable) { + } + + $collection = $database->createCollection(Collection::create(id: $this->getCollSecurityCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], documentSecurity: false)); + + $database->createAttribute($collection->getId(), Attribute::string(key: 'test')); + + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + + $document = $database->createDocument($collection->getId(), new Document([ + '$id' => \Utopia\Database\Id::unique(), + '$permissions' => [ + Permission::read(Role::user('random')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'lorem', + ])); + + self::$collPermFixtureInit = true; + self::$collPermFixtureData = [ + 'collectionId' => $collection->getId(), + 'docId' => $document->getId(), + ]; + + return self::$collPermFixtureData; + } + + /** + * Create the relationship permission test collections with a document. + * Combines testCollectionPermissionsRelationships + testCollectionPermissionsRelationshipsCreateWorks. + * + * @return array{collectionId: string, oneToOneId: string, oneToManyId: string, docId: string} + */ + protected function initRelationshipPermissionFixture(): array + { + if (self::$relPermFixtureInit && self::$relPermFixtureData !== null) { + /** @var Database $database */ + $database = $this->getDatabase(); + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + $doc = $database->getDocument(self::$relPermFixtureData['collectionId'], self::$relPermFixtureData['docId']); + if (!$doc->isEmpty()) { + return self::$relPermFixtureData; + } + self::$relPermFixtureInit = false; + } + + /** @var Database $database */ + $database = $this->getDatabase(); + + foreach ([$this->getCollSecurityParentCollection(), $this->getCollSecurityOneToOneCollection(), $this->getCollSecurityOneToManyCollection()] as $col) { + try { + $database->deleteCollection($col); + } catch (\Throwable) { + } + } + + $collection = $database->createCollection(Collection::create(id: $this->getCollSecurityParentCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ])); + + $database->createAttribute($collection->getId(), Attribute::string(key: 'test')); + + $collectionOneToOne = $database->createCollection(Collection::create(id: $this->getCollSecurityOneToOneCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ])); + + $database->createAttribute($collectionOneToOne->getId(), Attribute::string(key: 'test')); + + $database->createRelationship($collection->getId(), Relationship::oneToOne(relatedCollection: $collectionOneToOne->getId(), key: RelationshipType::OneToOne->value, onDelete: RelationshipDeleteAction::Cascade)); + + $collectionOneToMany = $database->createCollection(Collection::create(id: $this->getCollSecurityOneToManyCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ])); + + $database->createAttribute($collectionOneToMany->getId(), Attribute::string(key: 'test')); + + $database->createRelationship($collection->getId(), Relationship::oneToMany(relatedCollection: $collectionOneToMany->getId(), key: RelationshipType::OneToMany->value, onDelete: RelationshipDeleteAction::Cascade)); + + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + + $document = $database->createDocument($collection->getId(), new Document([ + '$id' => \Utopia\Database\Id::unique(), + '$permissions' => [ + Permission::read(Role::user('random')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'lorem', + RelationshipType::OneToOne->value => [ + '$id' => \Utopia\Database\Id::unique(), + '$permissions' => [ + Permission::read(Role::user('random')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'lorem ipsum', + ], + RelationshipType::OneToMany->value => [ + [ + '$id' => \Utopia\Database\Id::unique(), + '$permissions' => [ + Permission::read(Role::user('random')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'lorem ipsum', + ], [ + '$id' => \Utopia\Database\Id::unique(), + '$permissions' => [ + Permission::read(Role::user('torsten')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'dolor', + ], + ], + ])); + + self::$relPermFixtureInit = true; + self::$relPermFixtureData = [ + 'collectionId' => $collection->getId(), + 'oneToOneId' => $collectionOneToOne->getId(), + 'oneToManyId' => $collectionOneToMany->getId(), + 'docId' => $document->getId(), + ]; + + return self::$relPermFixtureData; + } + + /** + * Create the $this->getCollUpdateCollection() collection. + * Replicates the setup from testCollectionUpdate in CollectionTests. + * + * @return array{collectionId: string} + */ + protected function initCollectionUpdateFixture(): array + { + if (self::$collUpdateFixtureInit && self::$collUpdateFixtureData !== null) { + return self::$collUpdateFixtureData; + } + + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $database->deleteCollection($this->getCollUpdateCollection()); + } catch (\Throwable) { + } + + $collection = $database->createCollection(Collection::create(id: $this->getCollUpdateCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], documentSecurity: false)); + + $database->updateCollection($this->getCollUpdateCollection(), new CollectionUpdate(permissions: [], documentSecurity: true)); + + self::$collUpdateFixtureInit = true; + self::$collUpdateFixtureData = [ + 'collectionId' => $collection->getId(), + ]; + + return self::$collUpdateFixtureData; + } + + public function testCollectionPermissionsRelationships(): void + { + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + /** @var Database $database */ + $database = $this->getDatabase(); + + $collection = $database->createCollection(Collection::create(id: $this->getCollSecurityParentCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ])); + + $this->assertSame($this->getCollSecurityParentCollection(), $collection->getId()); + + $this->assertSame('test', $database->createAttribute($collection->getId(), Attribute::string(key: 'test'))->key); + + $collectionOneToOne = $database->createCollection(Collection::create(id: $this->getCollSecurityOneToOneCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ])); + + $this->assertSame($this->getCollSecurityOneToOneCollection(), $collectionOneToOne->getId()); + + $this->assertSame('test', $database->createAttribute($collectionOneToOne->getId(), Attribute::string(key: 'test'))->key); + + $this->assertSame(RelationshipType::OneToOne->value, $database->createRelationship($collection->getId(), Relationship::oneToOne(relatedCollection: $collectionOneToOne->getId(), key: RelationshipType::OneToOne->value, onDelete: RelationshipDeleteAction::Cascade))->key); + + $collectionOneToMany = $database->createCollection(Collection::create(id: $this->getCollSecurityOneToManyCollection(), permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ])); + + $this->assertSame($this->getCollSecurityOneToManyCollection(), $collectionOneToMany->getId()); - $this->assertCount( - 1, - $found, - 'A shared definition updated under one tenant must stay visible to the rest of the pool.', - ); - } finally { - $database->setTenant($tenant); - } + $this->assertSame('test', $database->createAttribute($collectionOneToMany->getId(), Attribute::string(key: 'test'))->key); + + $this->assertSame(RelationshipType::OneToMany->value, $database->createRelationship($collection->getId(), Relationship::oneToMany(relatedCollection: $collectionOneToMany->getId(), key: RelationshipType::OneToMany->value, onDelete: RelationshipDeleteAction::Cascade))->key); } public function testUnsetPermissions(): void @@ -65,14 +731,8 @@ public function testUnsetPermissions(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); - $this->assertTrue($database->createAttribute( - collection: __FUNCTION__, - id: 'president', - type: Database::VAR_STRING, - size: 255, - required: false - )); + $database->createCollection(Collection::create(id: __FUNCTION__)); + $this->assertSame('president', $database->createAttribute(__FUNCTION__, Attribute::string(key: 'president'))->key); $permissions = [ Permission::read(Role::any()), @@ -219,7 +879,7 @@ public function testCreateDocumentsEmptyPermission(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); + $database->createCollection(Collection::create(id: __FUNCTION__)); /** * Validate the decode function does not add $permissions null entry when no permissions are provided @@ -248,15 +908,16 @@ public function testCreateDocumentsEmptyPermission(): void } } - public function testReadPermissionsFailure(): Document + public function testReadPermissionsFailure(): void { + $this->initDocumentsFixture(); $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->createDocument('documents', new Document([ + $document = $database->createDocument($this->getDocumentsCollection(), new Document([ '$permissions' => [ Permission::read(Role::user('1')), Permission::create(Role::user('1')), @@ -281,17 +942,17 @@ public function testReadPermissionsFailure(): Document $this->assertEquals(true, $document->isEmpty()); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - - return $document; } - public function testNoChangeUpdateDocumentWithoutPermission(): Document + public function testNoChangeUpdateDocumentWithoutPermission(): void { + $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->createDocument('documents', new Document([ - '$id' => ID::unique(), + $document = $database->createDocument($this->getDocumentsCollection(), new Document([ + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()) ], @@ -307,7 +968,7 @@ public function testNoChangeUpdateDocumentWithoutPermission(): Document ])); $updatedDocument = $database->updateDocument( - 'documents', + $this->getDocumentsCollection(), $document->getId(), $document ); @@ -316,8 +977,8 @@ public function testNoChangeUpdateDocumentWithoutPermission(): Document // It should also not throw any authorization exception without any permission because of no change. $this->assertEquals($updatedDocument->getUpdatedAt(), $document->getUpdatedAt()); - $document = $database->createDocument('documents', new Document([ - '$id' => ID::unique(), + $document = $database->createDocument($this->getDocumentsCollection(), new Document([ + '$id' => Id::unique(), '$permissions' => [], 'string' => 'text📝', 'integer_signed' => -Database::MAX_INT, @@ -333,15 +994,13 @@ public function testNoChangeUpdateDocumentWithoutPermission(): Document // Should throw exception, because nothing was updated, but there was no read permission try { $database->updateDocument( - 'documents', + $this->getDocumentsCollection(), $document->getId(), $document ); } catch (Exception $e) { $this->assertInstanceOf(AuthorizationException::class, $e); } - - return $document; } public function testUpdateDocumentsPermissions(): void @@ -349,21 +1008,16 @@ public function testUpdateDocumentsPermissions(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { + if (! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); return; } $collection = 'testUpdateDocumentsPerms'; - $database->createCollection($collection, attributes: [ - new Document([ - '$id' => ID::custom('string'), - 'type' => Database::VAR_STRING, - 'size' => 767, - 'required' => true, - ]) - ], permissions: [], documentSecurity: true); + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'string', size: 767, required: true) + ])); // Test we can bulk update permissions we have access to $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { @@ -468,38 +1122,27 @@ public function testUpdateDocumentsPermissions(): void } } - public function testCollectionPermissions(): Document + public function testCollectionPermissions(): void { /** @var Database $database */ $database = $this->getDatabase(); - $collection = $database->createCollection('collectionSecurity', permissions: [ + $collection = $database->createCollection(Collection::create(id: $this->getCollSecurityCollection(), permissions: [ Permission::create(Role::users()), Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users()) - ], documentSecurity: false); - - $this->assertInstanceOf(Document::class, $collection); + ], documentSecurity: false)); - $this->assertTrue($database->createAttribute( - collection: $collection->getId(), - id: 'test', - type: Database::VAR_STRING, - size: 255, - required: false - )); + $this->assertSame($this->getCollSecurityCollection(), $collection->getId()); - return $collection; + $this->assertSame('test', $database->createAttribute($collection->getId(), Attribute::string(key: 'test'))->key); } - /** - * @param array $data - * @depends testCollectionPermissionsCreateWorks - */ - public function testCollectionPermissionsCountThrowsException(array $data): void + public function testCollectionPermissionsCountThrowsException(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); @@ -508,21 +1151,17 @@ public function testCollectionPermissionsCountThrowsException(array $data): void $database = $this->getDatabase(); try { - $database->count($collection->getId()); + $database->count($collectionId); $this->fail('Failed to throw exception'); } catch (\Throwable $th) { $this->assertInstanceOf(AuthorizationException::class, $th); } } - /** - * @depends testCollectionPermissionsCreateWorks - * @param array $data - * @return array - */ - public function testCollectionPermissionsCountWorks(array $data): array + public function testCollectionPermissionsCountWorks(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -531,19 +1170,15 @@ public function testCollectionPermissionsCountWorks(array $data): array $database = $this->getDatabase(); $count = $database->count( - $collection->getId() + $collectionId ); $this->assertNotEmpty($count); - - return $data; } - - /** - * @depends testCollectionPermissions - */ - public function testCollectionPermissionsCreateThrowsException(Document $collection): void + public function testCollectionPermissionsCreateThrowsException(): void { + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); $this->expectException(AuthorizationException::class); @@ -551,8 +1186,8 @@ public function testCollectionPermissionsCreateThrowsException(Document $collect /** @var Database $database */ $database = $this->getDatabase(); - $database->createDocument($collection->getId(), new Document([ - '$id' => ID::unique(), + $database->createDocument($collectionId, new Document([ + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -562,20 +1197,18 @@ public function testCollectionPermissionsCreateThrowsException(Document $collect ])); } - /** - * @depends testCollectionPermissions - * @return array - */ - public function testCollectionPermissionsCreateWorks(Document $collection): array + public function testCollectionPermissionsCreateWorks(): void { + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->createDocument($collection->getId(), new Document([ - '$id' => ID::unique(), + $document = $database->createDocument($collectionId, new Document([ + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::user('random')), Permission::update(Role::user('random')), @@ -583,18 +1216,16 @@ public function testCollectionPermissionsCreateWorks(Document $collection): arra ], 'test' => 'lorem' ])); - $this->assertInstanceOf(Document::class, $document); + $this->assertSame('lorem', $document->getAttribute('test')); - return [$collection, $document]; + $database->deleteDocument($collectionId, $document->getId()); } - /** - * @param array $data - * @depends testCollectionPermissionsUpdateWorks - */ - public function testCollectionPermissionsDeleteThrowsException(array $data): void + public function testCollectionPermissionsDeleteThrowsException(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); @@ -605,18 +1236,16 @@ public function testCollectionPermissionsDeleteThrowsException(array $data): voi $database = $this->getDatabase(); $database->deleteDocument( - $collection->getId(), - $document->getId() + $collectionId, + $docId ); } - /** - * @param array $data - * @depends testCollectionPermissionsUpdateWorks - */ - public function testCollectionPermissionsDeleteWorks(array $data): void + public function testCollectionPermissionsDeleteWorks(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -625,8 +1254,8 @@ public function testCollectionPermissionsDeleteWorks(array $data): void $database = $this->getDatabase(); $this->assertTrue($database->deleteDocument( - $collection->getId(), - $document->getId() + $collectionId, + $docId )); } @@ -636,18 +1265,15 @@ public function testCollectionPermissionsExceptions(): void $database = $this->getDatabase(); $this->expectException(DatabaseException::class); - $database->createCollection('collectionSecurity', permissions: [ + $database->createCollection(Collection::create(id: $this->getCollSecurityCollection(), permissions: [ 'i dont work' - ]); + ])); } - /** - * @param array $data - * @depends testCollectionPermissionsCreateWorks - */ - public function testCollectionPermissionsFindThrowsException(array $data): void + public function testCollectionPermissionsFindThrowsException(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); @@ -657,17 +1283,13 @@ public function testCollectionPermissionsFindThrowsException(array $data): void /** @var Database $database */ $database = $this->getDatabase(); - $database->find($collection->getId()); + $database->find($collectionId); } - /** - * @depends testCollectionPermissionsCreateWorks - * @param array $data - * @return array - */ - public function testCollectionPermissionsFindWorks(array $data): array + public function testCollectionPermissionsFindWorks(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -675,28 +1297,24 @@ public function testCollectionPermissionsFindWorks(array $data): array /** @var Database $database */ $database = $this->getDatabase(); - $documents = $database->find($collection->getId()); + $documents = $database->find($collectionId); $this->assertNotEmpty($documents); $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::user('random')->toString()); try { - $database->find($collection->getId()); + $database->find($collectionId); $this->fail('Failed to throw exception'); } catch (AuthorizationException) { } - - return $data; } - /** - * @depends testCollectionPermissionsCreateWorks - * @param array $data - */ - public function testCollectionPermissionsGetThrowsException(array $data): void + public function testCollectionPermissionsGetThrowsException(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); @@ -705,21 +1323,17 @@ public function testCollectionPermissionsGetThrowsException(array $data): void $database = $this->getDatabase(); $document = $database->getDocument( - $collection->getId(), - $document->getId(), + $collectionId, + $docId, ); - $this->assertInstanceOf(Document::class, $document); $this->assertTrue($document->isEmpty()); } - /** - * @depends testCollectionPermissionsCreateWorks - * @param array $data - * @return array - */ - public function testCollectionPermissionsGetWorks(array $data): array + public function testCollectionPermissionsGetWorks(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -728,100 +1342,21 @@ public function testCollectionPermissionsGetWorks(array $data): array $database = $this->getDatabase(); $document = $database->getDocument( - $collection->getId(), - $document->getId() + $collectionId, + $docId ); - $this->assertInstanceOf(Document::class, $document); $this->assertFalse($document->isEmpty()); - - return $data; } - /** - * @return array - */ - public function testCollectionPermissionsRelationships(): array + public function testCollectionPermissionsRelationshipsCountWorks(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - - $collection = $database->createCollection('collectionSecurity.Parent', permissions: [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()) - ], documentSecurity: true); - - $this->assertInstanceOf(Document::class, $collection); - - $this->assertTrue($database->createAttribute( - collection: $collection->getId(), - id: 'test', - type: Database::VAR_STRING, - size: 255, - required: false - )); - - $collectionOneToOne = $database->createCollection('collectionSecurity.OneToOne', permissions: [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()) - ], documentSecurity: true); - - $this->assertInstanceOf(Document::class, $collectionOneToOne); - - $this->assertTrue($database->createAttribute( - collection: $collectionOneToOne->getId(), - id: 'test', - type: Database::VAR_STRING, - size: 255, - required: false - )); - - $this->assertTrue($database->createRelationship( - collection: $collection->getId(), - relatedCollection: $collectionOneToOne->getId(), - type: Database::RELATION_ONE_TO_ONE, - id: Database::RELATION_ONE_TO_ONE, - onDelete: Database::RELATION_MUTATE_CASCADE - )); - - $collectionOneToMany = $database->createCollection('collectionSecurity.OneToMany', permissions: [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()) - ], documentSecurity: true); - - $this->assertInstanceOf(Document::class, $collectionOneToMany); - - $this->assertTrue($database->createAttribute( - collection: $collectionOneToMany->getId(), - id: 'test', - type: Database::VAR_STRING, - size: 255, - required: false - )); - - $this->assertTrue($database->createRelationship( - collection: $collection->getId(), - relatedCollection: $collectionOneToMany->getId(), - type: Database::RELATION_ONE_TO_MANY, - id: Database::RELATION_ONE_TO_MANY, - onDelete: Database::RELATION_MUTATE_CASCADE - )); - - return [$collection, $collectionOneToOne, $collectionOneToMany]; - } + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } - /** - * @depends testCollectionPermissionsRelationshipsCreateWorks - * @param array $data - */ - public function testCollectionPermissionsRelationshipsCountWorks(array $data): void - { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -830,7 +1365,7 @@ public function testCollectionPermissionsRelationshipsCountWorks(array $data): v $database = $this->getDatabase(); $documents = $database->count( - $collection->getId() + $collectionId ); $this->assertEquals(1, $documents); @@ -839,7 +1374,7 @@ public function testCollectionPermissionsRelationshipsCountWorks(array $data): v $this->getDatabase()->getAuthorization()->addRole(Role::user('random')->toString()); $documents = $database->count( - $collection->getId() + $collectionId ); $this->assertEquals(1, $documents); @@ -848,19 +1383,21 @@ public function testCollectionPermissionsRelationshipsCountWorks(array $data): v $this->getDatabase()->getAuthorization()->addRole(Role::user('unknown')->toString()); $documents = $database->count( - $collection->getId() + $collectionId ); $this->assertEquals(0, $documents); } - /** - * @depends testCollectionPermissionsRelationships - * @param array $data - */ - public function testCollectionPermissionsRelationshipsCreateThrowsException(array $data): void + public function testCollectionPermissionsRelationshipsCreateThrowsException(): void { - [$collection, $collectionOneToOne, $collectionOneToMany] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); @@ -869,8 +1406,8 @@ public function testCollectionPermissionsRelationshipsCreateThrowsException(arra /** @var Database $database */ $database = $this->getDatabase(); - $database->createDocument($collection->getId(), new Document([ - '$id' => ID::unique(), + $database->createDocument($collectionId, new Document([ + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()) @@ -879,13 +1416,16 @@ public function testCollectionPermissionsRelationshipsCreateThrowsException(arra ])); } - /** - * @param array $data - * @depends testCollectionPermissionsRelationshipsUpdateWorks - */ - public function testCollectionPermissionsRelationshipsDeleteThrowsException(array $data): void + public function testCollectionPermissionsRelationshipsDeleteThrowsException(): void { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); @@ -895,36 +1435,38 @@ public function testCollectionPermissionsRelationshipsDeleteThrowsException(arra /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->deleteDocument( - $collection->getId(), - $document->getId() + $database->deleteDocument( + $collectionId, + $docId ); } - /** - * @depends testCollectionPermissionsRelationships - * @param array $data - * @return array - */ - public function testCollectionPermissionsRelationshipsCreateWorks(array $data): array + public function testCollectionPermissionsRelationshipsCreateWorks(): void { - [$collection, $collectionOneToOne, $collectionOneToMany] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; + $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->createDocument($collection->getId(), new Document([ - '$id' => ID::unique(), + $document = $database->createDocument($collectionId, new Document([ + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::user('random')), Permission::update(Role::user('random')), Permission::delete(Role::user('random')) ], 'test' => 'lorem', - Database::RELATION_ONE_TO_ONE => [ - '$id' => ID::unique(), + RelationshipType::OneToOne->value => [ + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::user('random')), Permission::update(Role::user('random')), @@ -932,9 +1474,9 @@ public function testCollectionPermissionsRelationshipsCreateWorks(array $data): ], 'test' => 'lorem ipsum' ], - Database::RELATION_ONE_TO_MANY => [ + RelationshipType::OneToMany->value => [ [ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::user('random')), Permission::update(Role::user('random')), @@ -942,7 +1484,7 @@ public function testCollectionPermissionsRelationshipsCreateWorks(array $data): ], 'test' => 'lorem ipsum' ], [ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::user('torsten')), Permission::update(Role::user('random')), @@ -952,18 +1494,21 @@ public function testCollectionPermissionsRelationshipsCreateWorks(array $data): ] ], ])); - $this->assertInstanceOf(Document::class, $document); + $this->assertFalse($document->isEmpty()); - return [...$data, $document]; + $database->deleteDocument($collectionId, $document->getId()); } - /** - * @param array $data - * @depends testCollectionPermissionsRelationshipsUpdateWorks - */ - public function testCollectionPermissionsRelationshipsDeleteWorks(array $data): void + public function testCollectionPermissionsRelationshipsDeleteWorks(): void { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -972,18 +1517,20 @@ public function testCollectionPermissionsRelationshipsDeleteWorks(array $data): $database = $this->getDatabase(); $this->assertTrue($database->deleteDocument( - $collection->getId(), - $document->getId() + $collectionId, + $docId )); } - /** - * @depends testCollectionPermissionsRelationshipsCreateWorks - * @param array $data - */ - public function testCollectionPermissionsRelationshipsFindWorks(array $data): void + public function testCollectionPermissionsRelationshipsFindWorks(): void { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -991,58 +1538,49 @@ public function testCollectionPermissionsRelationshipsFindWorks(array $data): vo /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - $documents = $database->find( - $collection->getId() + $collectionId ); - $this->assertIsArray($documents); $this->assertCount(1, $documents); $document = $documents[0]; - $this->assertInstanceOf(Document::class, $document); - $this->assertInstanceOf(Document::class, $document->getAttribute(Database::RELATION_ONE_TO_ONE)); - $this->assertIsArray($document->getAttribute(Database::RELATION_ONE_TO_MANY)); - $this->assertCount(2, $document->getAttribute(Database::RELATION_ONE_TO_MANY)); + $this->assertFalse($document->getDocument(RelationshipType::OneToOne->value)->isEmpty()); + $this->assertCount(2, $document->getDocuments(RelationshipType::OneToMany->value)); $this->assertFalse($document->isEmpty()); $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::user('random')->toString()); $documents = $database->find( - $collection->getId() + $collectionId ); - $this->assertIsArray($documents); $this->assertCount(1, $documents); $document = $documents[0]; - $this->assertInstanceOf(Document::class, $document); - $this->assertInstanceOf(Document::class, $document->getAttribute(Database::RELATION_ONE_TO_ONE)); - $this->assertIsArray($document->getAttribute(Database::RELATION_ONE_TO_MANY)); - $this->assertCount(1, $document->getAttribute(Database::RELATION_ONE_TO_MANY)); + $this->assertFalse($document->getDocument(RelationshipType::OneToOne->value)->isEmpty()); + $this->assertCount(1, $document->getDocuments(RelationshipType::OneToMany->value)); $this->assertFalse($document->isEmpty()); $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::user('unknown')->toString()); $documents = $database->find( - $collection->getId() + $collectionId ); - $this->assertIsArray($documents); $this->assertCount(0, $documents); } - /** - * @param array $data - * @depends testCollectionPermissionsRelationshipsCreateWorks - */ - public function testCollectionPermissionsRelationshipsGetThrowsException(array $data): void + public function testCollectionPermissionsRelationshipsGetThrowsException(): void { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); @@ -1051,21 +1589,22 @@ public function testCollectionPermissionsRelationshipsGetThrowsException(array $ $database = $this->getDatabase(); $document = $database->getDocument( - $collection->getId(), - $document->getId(), + $collectionId, + $docId, ); - $this->assertInstanceOf(Document::class, $document); $this->assertTrue($document->isEmpty()); } - /** - * @depends testCollectionPermissionsRelationshipsCreateWorks - * @param array $data - * @return array - */ - public function testCollectionPermissionsRelationshipsGetWorks(array $data): array + public function testCollectionPermissionsRelationshipsGetWorks(): void { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -1073,70 +1612,69 @@ public function testCollectionPermissionsRelationshipsGetWorks(array $data): arr /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return []; - } - $document = $database->getDocument( - $collection->getId(), - $document->getId() + $collectionId, + $docId ); - $this->assertInstanceOf(Document::class, $document); - $this->assertInstanceOf(Document::class, $document->getAttribute(Database::RELATION_ONE_TO_ONE)); - $this->assertIsArray($document->getAttribute(Database::RELATION_ONE_TO_MANY)); - $this->assertCount(2, $document->getAttribute(Database::RELATION_ONE_TO_MANY)); + $this->assertFalse($document->getDocument(RelationshipType::OneToOne->value)->isEmpty()); + $this->assertCount(2, $document->getDocuments(RelationshipType::OneToMany->value)); $this->assertFalse($document->isEmpty()); $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::user('random')->toString()); $document = $database->getDocument( - $collection->getId(), - $document->getId() + $collectionId, + $docId ); - $this->assertInstanceOf(Document::class, $document); - $this->assertInstanceOf(Document::class, $document->getAttribute(Database::RELATION_ONE_TO_ONE)); - $this->assertIsArray($document->getAttribute(Database::RELATION_ONE_TO_MANY)); - $this->assertCount(1, $document->getAttribute(Database::RELATION_ONE_TO_MANY)); + $this->assertFalse($document->getDocument(RelationshipType::OneToOne->value)->isEmpty()); + $this->assertCount(1, $document->getDocuments(RelationshipType::OneToMany->value)); $this->assertFalse($document->isEmpty()); - - return $data; } - /** - * @param array $data - * @depends testCollectionPermissionsRelationshipsCreateWorks - */ - public function testCollectionPermissionsRelationshipsUpdateThrowsException(array $data): void + public function testCollectionPermissionsRelationshipsUpdateThrowsException(): void { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; - - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } - $this->expectException(AuthorizationException::class); + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->updateDocument( - $collection->getId(), - $document->getId(), - $document->setAttribute('test', $document->getAttribute('test').'new_value') + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + $document = $database->getDocument($collectionId, $docId); + + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + + $test = $document->getAttribute('test'); + $this->assertIsString($test); + $this->expectException(AuthorizationException::class); + $database->updateDocument( + $collectionId, + $docId, + $document->setAttribute('test', $test.'new_value') ); } - /** - * @depends testCollectionPermissionsRelationshipsCreateWorks - * @param array $data - * @return array - */ - public function testCollectionPermissionsRelationshipsUpdateWorks(array $data): array + public function testCollectionPermissionsRelationshipsUpdateWorks(): void { - [$collection, $collectionOneToOne, $collectionOneToMany, $document] = $data; + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $data = $this->initRelationshipPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -1144,58 +1682,55 @@ public function testCollectionPermissionsRelationshipsUpdateWorks(array $data): /** @var Database $database */ $database = $this->getDatabase(); - $database->updateDocument( - $collection->getId(), - $document->getId(), + $document = $database->getDocument($collectionId, $docId); + + $updated = $database->updateDocument( + $collectionId, + $docId, $document ); - - $this->assertTrue(true); + $this->assertFalse($updated->isEmpty()); $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::user('random')->toString()); - $database->updateDocument( - $collection->getId(), - $document->getId(), + $updated = $database->updateDocument( + $collectionId, + $docId, $document->setAttribute('test', 'ipsum') ); - - $this->assertTrue(true); - - return $data; + $this->assertSame('ipsum', $updated->getAttribute('test')); } - /** - * @param array $data - * @depends testCollectionPermissionsCreateWorks - */ - public function testCollectionPermissionsUpdateThrowsException(array $data): void + public function testCollectionPermissionsUpdateThrowsException(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; + + /** @var Database $database */ + $database = $this->getDatabase(); + + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + $document = $database->getDocument($collectionId, $docId); $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); $this->expectException(AuthorizationException::class); - /** @var Database $database */ - $database = $this->getDatabase(); - - $document = $database->updateDocument( - $collection->getId(), - $document->getId(), - $document->setAttribute('test', 'lorem') + $database->updateDocument( + $collectionId, + $docId, + $document->setAttribute('test', 'changed_value') ); } - /** - * @depends testCollectionPermissionsCreateWorks - * @param array $data - * @return array - */ - public function testCollectionPermissionsUpdateWorks(array $data): array + public function testCollectionPermissionsUpdateWorks(): void { - [$collection, $document] = $data; + $data = $this->initCollectionPermissionFixture(); + $collectionId = $data['collectionId']; + $docId = $data['docId']; $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); @@ -1203,28 +1738,27 @@ public function testCollectionPermissionsUpdateWorks(array $data): array /** @var Database $database */ $database = $this->getDatabase(); - $this->assertInstanceOf(Document::class, $database->updateDocument( - $collection->getId(), - $document->getId(), - $document->setAttribute('test', 'ipsum') - )); + $document = $database->getDocument($collectionId, $docId); - return $data; + $updated = $database->updateDocument( + $collectionId, + $docId, + $document->setAttribute('test', 'ipsum') + ); + $this->assertSame('ipsum', $updated->getAttribute('test')); } - - /** - * @depends testCollectionUpdate - */ - public function testCollectionUpdatePermissionsThrowException(Document $collection): void + public function testCollectionUpdatePermissionsThrowException(): void { + $data = $this->initCollectionUpdateFixture(); + $collectionId = $data['collectionId']; $this->expectException(DatabaseException::class); /** @var Database $database */ $database = $this->getDatabase(); - $database->updateCollection($collection->getId(), permissions: [ + $database->updateCollection($collectionId, new CollectionUpdate(permissions: [ 'i dont work' - ], documentSecurity: false); + ], documentSecurity: false)); } public function testWritePermissions(): void @@ -1232,11 +1766,11 @@ public function testWritePermissions(): void $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); $database = $this->getDatabase(); - $database->createCollection('animals', permissions: [ + $database->createCollection(Collection::create(id: 'animals', permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); + ])); - $database->createAttribute('animals', 'type', Database::VAR_STRING, 128, true); + $database->createAttribute('animals', Attribute::string(key: 'type', size: 128, required: true)); $dog = $database->createDocument('animals', new Document([ '$id' => 'dog', @@ -1306,7 +1840,7 @@ public function testCreateRelationDocumentWithoutUpdatePermission(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (!($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); return; } @@ -1314,25 +1848,20 @@ public function testCreateRelationDocumentWithoutUpdatePermission(): void $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::user('a')->toString()); - $database->createCollection('parentRelationTest', [], [], [ + $database->createCollection(Collection::create(id: 'parentRelationTest', permissions: [ Permission::read(Role::user('a')), Permission::create(Role::user('a')), Permission::update(Role::user('a')), Permission::delete(Role::user('a')) - ]); - $database->createCollection('childRelationTest', [], [], [ + ])); + $database->createCollection(Collection::create(id: 'childRelationTest', permissions: [ Permission::create(Role::user('a')), Permission::read(Role::user('a')), - ]); - $database->createAttribute('parentRelationTest', 'name', Database::VAR_STRING, 255, false); - $database->createAttribute('childRelationTest', 'name', Database::VAR_STRING, 255, false); - - $database->createRelationship( - collection: 'parentRelationTest', - relatedCollection: 'childRelationTest', - type: Database::RELATION_ONE_TO_MANY, - id: 'children' - ); + ])); + $database->createAttribute('parentRelationTest', Attribute::string(key: 'name')); + $database->createAttribute('childRelationTest', Attribute::string(key: 'name')); + + $database->createRelationship('parentRelationTest', Relationship::oneToMany(relatedCollection: 'childRelationTest', key: 'children')); // Create document with relationship with nested data $parent = $database->createDocument('parentRelationTest', new Document([ @@ -1345,7 +1874,9 @@ public function testCreateRelationDocumentWithoutUpdatePermission(): void ], ], ])); - $this->assertEquals('child1', $parent->getAttribute('children')[0]->getId()); + $children = $parent->getDocuments('children'); + $this->assertNotEmpty($children); + $this->assertEquals('child1', $children[0]->getId()); $parent->setAttribute('children', [ [ '$id' => 'child2', @@ -1353,7 +1884,9 @@ public function testCreateRelationDocumentWithoutUpdatePermission(): void ]); $updatedParent = $database->updateDocument('parentRelationTest', 'parent1', $parent); - $this->assertEquals('child2', $updatedParent->getAttribute('children')[0]->getId()); + $updatedChildren = $updatedParent->getDocuments('children'); + $this->assertNotEmpty($updatedChildren); + $this->assertEquals('child2', $updatedChildren[0]->getId()); $database->deleteCollection('parentRelationTest'); $database->deleteCollection('childRelationTest'); @@ -1364,12 +1897,12 @@ public function testDocumentPermissionRolesAreMatchedExactly(): void /** @var Database $database */ $database = $this->getDatabase(); $authorization = $database->getAuthorization(); - $collection = 'perm_exact_' . uniqid(); + $collection = 'perm_exact_'.uniqid(); - $database->createCollection($collection, permissions: [ + $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); - $database->createAttribute($collection, 'amount', Database::VAR_INTEGER, 0, true); + ])); + $database->createAttribute($collection, Attribute::integer(key: 'amount', required: true)); $authorization->skip(function () use ($database, $collection): void { $database->createDocument($collection, new Document([ @@ -1404,7 +1937,7 @@ public function testDocumentPermissionRolesAreMatchedExactly(): void $this->assertSame('literal', $database->getDocument($collection, 'literal')->getId()); $authorization->cleanRoles(); - $authorization->addRole(Role::user('a' . \str_repeat('.', 19))->toString()); + $authorization->addRole(Role::user('a'.\str_repeat('.', 19))->toString()); $this->assertSame([], $this->documentIds($database->find($collection))); $this->assertSame(0, $database->count($collection)); @@ -1434,4 +1967,106 @@ private function documentIds(array $documents): array )); } + public function testNoRolesReadsNoDocumentOfADocumentSecurityCollection(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + $collection = 'perm_no_roles_'.uniqid(); + + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + ], documentSecurity: true)); + $database->createAttribute($collection, Attribute::integer(key: 'amount', required: true)); + + $authorization->skip(function () use ($database, $collection): void { + $database->createDocument($collection, new Document([ + '$id' => 'public', + '$permissions' => [Permission::read(Role::any())], + 'amount' => 10, + ])); + $database->createDocument($collection, new Document([ + '$id' => 'private', + '$permissions' => [Permission::read(Role::user('owner'))], + 'amount' => 20, + ])); + }); + + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + + try { + $found = $database->find($collection); + $count = $database->count($collection); + $public = $database->getDocument($collection, 'public'); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + } + + $this->assertSame([], $this->documentIds($found)); + $this->assertSame(0, $count); + $this->assertTrue($public->isEmpty()); + + $database->deleteCollection($collection); + } + + public function testDocumentSecurityReadsWorkUnderADigitLeadingDatabaseAndNamespace(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $digitLeadingDatabase = '1db_'.static::getTestToken(); + + if ($database->exists($digitLeadingDatabase)) { + $database->delete($digitLeadingDatabase); + } + + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + $database + ->setDatabase($digitLeadingDatabase) + ->setNamespace('1ns'.uniqid()) + ->create(); + + $collection = 'perm_digit_leading'; + $database->createCollection(Collection::create(id: $collection, permissions: [ + Permission::create(Role::any()), + ], documentSecurity: true)); + $database->createAttribute($collection, Attribute::string(key: 'title', size: 64)); + + $database->createDocument($collection, new Document([ + '$id' => 'public', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Readable', + ])); + $database->createDocument($collection, new Document([ + '$id' => 'private', + '$permissions' => [Permission::read(Role::user('owner'))], + 'title' => 'Hidden', + ])); + + $this->assertSame(['public'], $this->documentIds($database->find($collection))); + $this->assertSame(1, $database->count($collection)); + $this->assertSame('public', $database->getDocument($collection, 'public')->getId()); + $this->assertTrue($database->getDocument($collection, 'private')->isEmpty()); + + $database->delete($digitLeadingDatabase); + } finally { + $database + ->setNamespace($namespace) + ->setDatabase($schema); + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + } + } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 2e4ecf89f9..da5ab9dcdd 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -3,70 +3,94 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; +use Swoole\Coroutine; +use Swoole\Runtime; +use Tests\E2E\Adapter\Scopes\Relationships\ChildSideTests; use Tests\E2E\Adapter\Scopes\Relationships\ManyToManyTests; use Tests\E2E\Adapter\Scopes\Relationships\ManyToOneTests; use Tests\E2E\Adapter\Scopes\Relationships\OneToManyTests; use Tests\E2E\Adapter\Scopes\Relationships\OneToOneTests; +use Tests\E2E\Adapter\Support\EventRecorder; +use Throwable; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; +use Utopia\Database\CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Relationship as RelationshipException; -use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Role; +use Utopia\Query\Method; + +use function Swoole\Coroutine\run; trait RelationshipTests { - use OneToOneTests; - use OneToManyTests; - use ManyToOneTests; + use ChildSideTests; use ManyToManyTests; + use ManyToOneTests; + use OneToManyTests; + use OneToOneTests; /** - * @return array + * @return array */ public static function relationshipQueryValueLimitProvider(): array { $cases = []; - foreach ([Database::RELATION_ONE_TO_ONE, Database::RELATION_ONE_TO_MANY, Database::RELATION_MANY_TO_ONE, Database::RELATION_MANY_TO_MANY] as $type) { + foreach (RelationshipType::cases() as $type) { foreach (['parents', 'children'] as $collection) { - $cases[$type . ' ' . $collection] = [$type, $collection, 3]; + $cases[$type->value . ' ' . $collection] = [$type, $collection, 3]; } } - // One parent can exceed the value limit when fetching its related documents. - $cases['manyToMany related documents'] = [Database::RELATION_MANY_TO_MANY, 'parents', 1]; + $cases['manyToMany related documents'] = [RelationshipType::ManyToMany, 'parents', 1]; return $cases; } - /** - * @dataProvider relationshipQueryValueLimitProvider - */ - public function testRelationshipQueryValueLimit(string $type, string $collection, int $limit): void + #[DataProvider('relationshipQueryValueLimitProvider')] + public function testRelationshipQueryValueLimit(RelationshipType $type, string $collection, int $limit): void { + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $parents = ID::unique(); - $children = ID::unique(); + $parents = Id::unique(); + $children = Id::unique(); $permissions = [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ]; - $database->createCollection($parents, permissions: $permissions); - $database->createCollection($children, permissions: $permissions); - $database->createRelationship($parents, $children, $type, true, 'children', 'parents'); + $database->createCollection(Collection::create(id: $parents, permissions: $permissions)); + $database->createCollection(Collection::create(id: $children, permissions: $permissions)); + $database->createRelationship($parents, Relationship::fromArray([ + 'relatedCollection' => $children, + 'relationType' => $type, + 'twoWay' => true, + 'key' => 'children', + 'twoWayKey' => 'parents', + ])); foreach (['child1', 'child2', 'child3'] as $id) { $database->createDocument($children, new Document(['$id' => $id])); @@ -74,8 +98,8 @@ public function testRelationshipQueryValueLimit(string $type, string $collection for ($i = 1; $i <= 3; $i++) { $related = match ($type) { - Database::RELATION_MANY_TO_MANY => ['child1', 'child2', 'child3'], - Database::RELATION_ONE_TO_MANY => ['child' . $i], + RelationshipType::ManyToMany => ['child1', 'child2', 'child3'], + RelationshipType::OneToMany => ['child' . $i], default => 'child' . $i, }; $database->createDocument($parents, new Document([ @@ -92,11 +116,15 @@ public function testRelationshipQueryValueLimit(string $type, string $collection $this->assertCount($limit, $documents); foreach ($documents as $document) { - $related = $document->getAttribute($collection === 'parents' ? 'children' : 'parents'); - $related = $related instanceof Document ? [$related] : $related; + $key = $collection === 'parents' ? 'children' : 'parents'; + $related = $document->getDocuments($key); + if ($related === []) { + $single = $document->getDocument($key); + $related = $single->isEmpty() ? [] : [$single]; + } $ids = \array_map(fn (Document $related) => $related->getId(), $related); $prefix = $collection === 'parents' ? 'child' : 'parent'; - $expected = $type === Database::RELATION_MANY_TO_MANY + $expected = $type === RelationshipType::ManyToMany ? [$prefix . '1', $prefix . '2', $prefix . '3'] : [$prefix . \substr($document->getId(), -1)]; $this->assertEqualsCanonicalizing($expected, $ids); @@ -116,68 +144,61 @@ public function testZoo(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('zoo'); - $database->createAttribute('zoo', 'name', Database::VAR_STRING, 256, true); + $database->createCollection(Collection::create(id: 'zoo')); + $database->createAttribute('zoo', Attribute::string(key: 'name', size: 256, required: true)); - $database->createCollection('veterinarians'); - $database->createAttribute('veterinarians', 'fullname', Database::VAR_STRING, 256, true); + $database->createCollection(Collection::create(id: 'veterinarians')); + $database->createAttribute('veterinarians', Attribute::string(key: 'fullname', size: 256, required: true)); - $database->createCollection('presidents'); - $database->createAttribute('presidents', 'firstName', Database::VAR_STRING, 256, true); - $database->createAttribute('presidents', 'lastName', Database::VAR_STRING, 256, true); - $database->createRelationship( - collection: 'presidents', + $database->createCollection(Collection::create(id: 'presidents')); + $database->createAttribute('presidents', Attribute::string(key: 'firstName', size: 256, required: true)); + $database->createAttribute('presidents', Attribute::string(key: 'lastName', size: 256, required: true)); + $database->createRelationship('presidents', Relationship::manyToMany( relatedCollection: 'veterinarians', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'votes', + key: 'votes', twoWayKey: 'presidents' - ); - - $database->createCollection('__animals'); - $database->createAttribute('__animals', 'name', Database::VAR_STRING, 256, true); - $database->createAttribute('__animals', 'age', Database::VAR_INTEGER, 0, false); - $database->createAttribute('__animals', 'price', Database::VAR_FLOAT, 0, false); - $database->createAttribute('__animals', 'dateOfBirth', Database::VAR_DATETIME, 0, true, filters:['datetime']); - $database->createAttribute('__animals', 'longtext', Database::VAR_STRING, 100000000, false); - $database->createAttribute('__animals', 'isActive', Database::VAR_BOOLEAN, 0, false, default: true); - $database->createAttribute('__animals', 'integers', Database::VAR_INTEGER, 0, false, array: true); - $database->createAttribute('__animals', 'email', Database::VAR_STRING, 255, false); - $database->createAttribute('__animals', 'ip', Database::VAR_STRING, 255, false); - $database->createAttribute('__animals', 'url', Database::VAR_STRING, 255, false); - $database->createAttribute('__animals', 'enum', Database::VAR_STRING, 255, false); - - $database->createRelationship( - collection: 'presidents', + )); + + $database->createCollection(Collection::create(id: '__animals')); + $database->createAttribute('__animals', Attribute::string(key: 'name', size: 256, required: true)); + $database->createAttribute('__animals', Attribute::integer(key: 'age')); + $database->createAttribute('__animals', Attribute::double(key: 'price')); + $database->createAttribute('__animals', Attribute::datetime(key: 'dateOfBirth', required: true)); + $database->createAttribute('__animals', Attribute::string(key: 'longtext', size: 100000000)); + $database->createAttribute('__animals', Attribute::boolean(key: 'isActive', default: true)); + $database->createAttribute('__animals', Attribute::integer(key: 'integers', array: true)); + $database->createAttribute('__animals', Attribute::string(key: 'email')); + $database->createAttribute('__animals', Attribute::string(key: 'ip')); + $database->createAttribute('__animals', Attribute::string(key: 'url')); + $database->createAttribute('__animals', Attribute::string(key: 'enum')); + + $database->createRelationship('presidents', Relationship::oneToOne( relatedCollection: '__animals', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'animal', + key: 'animal', twoWayKey: 'president' - ); + )); - $database->createRelationship( - collection: 'veterinarians', + $database->createRelationship('veterinarians', Relationship::oneToMany( relatedCollection: '__animals', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'animals', + key: 'animals', twoWayKey: 'veterinarian' - ); + )); - $database->createRelationship( - collection: '__animals', + $database->createRelationship('__animals', Relationship::manyToOne( relatedCollection: 'zoo', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'zoo', + key: 'zoo', twoWayKey: 'animals' - ); + )); $zoo = $database->createDocument('zoo', new Document([ '$id' => 'zoo1', @@ -185,7 +206,7 @@ public function testZoo(): void Permission::read(Role::any()), Permission::update(Role::any()), ], - 'name' => 'Bronx Zoo' + 'name' => 'Bronx Zoo', ])); $this->assertEquals('zoo1', $zoo->getId()); @@ -314,18 +335,18 @@ public function testZoo(): void $this->assertEquals('zoo1', $zoo->getId()); $this->assertEquals('Bronx Zoo', $zoo->getAttribute('name')); $this->assertArrayHasKey('animals', $zoo); - $this->assertEquals(2, count($zoo->getAttribute('animals'))); - $this->assertArrayHasKey('president', $zoo->getAttribute('animals')[0]); - $this->assertArrayHasKey('veterinarian', $zoo->getAttribute('animals')[0]); + $this->assertCount(2, $zoo->getDocuments('animals')); + $this->assertArrayHasKey('president', $zoo->getDocuments('animals')[0]); + $this->assertArrayHasKey('veterinarian', $zoo->getDocuments('animals')[0]); $zoo = $database->findOne('zoo'); $this->assertEquals('zoo1', $zoo->getId()); $this->assertEquals('Bronx Zoo', $zoo->getAttribute('name')); $this->assertArrayHasKey('animals', $zoo); - $this->assertEquals(2, count($zoo->getAttribute('animals'))); - $this->assertArrayHasKey('president', $zoo->getAttribute('animals')[0]); - $this->assertArrayHasKey('veterinarian', $zoo->getAttribute('animals')[0]); + $this->assertCount(2, $zoo->getDocuments('animals')); + $this->assertArrayHasKey('president', $zoo->getDocuments('animals')[0]); + $this->assertArrayHasKey('veterinarian', $zoo->getDocuments('animals')[0]); /** * Check Veterinarians data @@ -334,25 +355,25 @@ public function testZoo(): void $this->assertEquals('dr.pol', $veterinarian->getId()); $this->assertArrayHasKey('presidents', $veterinarian); - $this->assertEquals(1, count($veterinarian->getAttribute('presidents'))); - $this->assertArrayHasKey('animal', $veterinarian->getAttribute('presidents')[0]); + $this->assertCount(1, $veterinarian->getDocuments('presidents')); + $this->assertArrayHasKey('animal', $veterinarian->getDocuments('presidents')[0]); $this->assertArrayHasKey('animals', $veterinarian); - $this->assertEquals(1, count($veterinarian->getAttribute('animals'))); - $this->assertArrayHasKey('zoo', $veterinarian->getAttribute('animals')[0]); - $this->assertArrayHasKey('president', $veterinarian->getAttribute('animals')[0]); + $this->assertCount(1, $veterinarian->getDocuments('animals')); + $this->assertArrayHasKey('zoo', $veterinarian->getDocuments('animals')[0]); + $this->assertArrayHasKey('president', $veterinarian->getDocuments('animals')[0]); $veterinarian = $database->findOne('veterinarians', [ - Query::equal('$id', ['dr.pol']) + Query::equal('$id', ['dr.pol']), ]); $this->assertEquals('dr.pol', $veterinarian->getId()); $this->assertArrayHasKey('presidents', $veterinarian); - $this->assertEquals(1, count($veterinarian->getAttribute('presidents'))); - $this->assertArrayHasKey('animal', $veterinarian->getAttribute('presidents')[0]); + $this->assertCount(1, $veterinarian->getDocuments('presidents')); + $this->assertArrayHasKey('animal', $veterinarian->getDocuments('presidents')[0]); $this->assertArrayHasKey('animals', $veterinarian); - $this->assertEquals(1, count($veterinarian->getAttribute('animals'))); - $this->assertArrayHasKey('zoo', $veterinarian->getAttribute('animals')[0]); - $this->assertArrayHasKey('president', $veterinarian->getAttribute('animals')[0]); + $this->assertCount(1, $veterinarian->getDocuments('animals')); + $this->assertArrayHasKey('zoo', $veterinarian->getDocuments('animals')[0]); + $this->assertArrayHasKey('president', $veterinarian->getDocuments('animals')[0]); /** * Check Animals data @@ -361,25 +382,25 @@ public function testZoo(): void $this->assertEquals('iguana', $animal->getId()); $this->assertArrayHasKey('zoo', $animal); - $this->assertEquals('Bronx Zoo', $animal['zoo']->getAttribute('name')); + $this->assertEquals('Bronx Zoo', $animal->getDocument('zoo')->getAttribute('name')); $this->assertArrayHasKey('veterinarian', $animal); - $this->assertEquals('dr.pol', $animal['veterinarian']->getId()); - $this->assertArrayHasKey('presidents', $animal['veterinarian']); + $this->assertEquals('dr.pol', $animal->getDocument('veterinarian')->getId()); + $this->assertArrayHasKey('presidents', $animal->getDocument('veterinarian')); $this->assertArrayHasKey('president', $animal); - $this->assertEquals('bush', $animal['president']->getId()); + $this->assertEquals('bush', $animal->getDocument('president')->getId()); $animal = $database->findOne('__animals', [ - Query::equal('$id', ['tiger']) + Query::equal('$id', ['tiger']), ]); $this->assertEquals('tiger', $animal->getId()); $this->assertArrayHasKey('zoo', $animal); - $this->assertEquals('Bronx Zoo', $animal['zoo']->getAttribute('name')); + $this->assertEquals('Bronx Zoo', $animal->getDocument('zoo')->getAttribute('name')); $this->assertArrayHasKey('veterinarian', $animal); - $this->assertEquals('dr.seuss', $animal['veterinarian']->getId()); - $this->assertArrayHasKey('presidents', $animal['veterinarian']); + $this->assertEquals('dr.seuss', $animal->getDocument('veterinarian')->getId()); + $this->assertArrayHasKey('presidents', $animal->getDocument('veterinarian')); $this->assertArrayHasKey('president', $animal); - $this->assertEquals('biden', $animal['president']->getId()); + $this->assertEquals('biden', $animal->getDocument('president')->getId()); /** * Check President data @@ -389,32 +410,32 @@ public function testZoo(): void $this->assertEquals('trump', $president->getId()); $this->assertArrayHasKey('animal', $president); $this->assertArrayHasKey('votes', $president); - $this->assertEquals(2, count($president['votes'])); + $this->assertCount(2, $president->getDocuments('votes')); /** * Check President data */ $president = $database->findOne('presidents', [ - Query::equal('$id', ['bush']) + Query::equal('$id', ['bush']), ]); $this->assertEquals('bush', $president->getId()); $this->assertArrayHasKey('animal', $president); $this->assertArrayHasKey('votes', $president); - $this->assertEquals(0, count($president['votes'])); + $this->assertCount(0, $president->getDocuments('votes')); $president = $database->findOne('presidents', [ Query::select([ '*', 'votes.*', ]), - Query::equal('$id', ['trump']) + Query::equal('$id', ['trump']), ]); $this->assertEquals('trump', $president->getId()); $this->assertArrayHasKey('votes', $president); - $this->assertEquals(2, count($president['votes'])); - $this->assertArrayNotHasKey('animals', $president['votes'][0]); // Not exist + $this->assertCount(2, $president->getDocuments('votes')); + $this->assertArrayNotHasKey('animals', $president->getDocuments('votes')[0]); // Not exist $president = $database->findOne('presidents', [ Query::select([ @@ -422,13 +443,13 @@ public function testZoo(): void 'votes.*', 'votes.animals.*', ]), - Query::equal('$id', ['trump']) + Query::equal('$id', ['trump']), ]); $this->assertEquals('trump', $president->getId()); $this->assertArrayHasKey('votes', $president); - $this->assertEquals(2, count($president['votes'])); - $this->assertArrayHasKey('animals', $president['votes'][0]); // Exist + $this->assertCount(2, $president->getDocuments('votes')); + $this->assertArrayHasKey('animals', $president->getDocuments('votes')[0]); // Exist /** * Check Selects queries @@ -447,7 +468,7 @@ public function testZoo(): void [ Query::select([ 'animals.*', - ]) + ]), ] ); @@ -455,7 +476,7 @@ public function testZoo(): void $this->assertArrayHasKey('animals', $veterinarian); $this->assertArrayNotHasKey('presidents', $veterinarian); - $animal = $veterinarian['animals'][0]; + $animal = $veterinarian->getDocuments('animals')[0]; $this->assertArrayHasKey('president', $animal); $this->assertEquals('bush', $animal->getAttribute('president')); // Check president is a value @@ -469,7 +490,7 @@ public function testZoo(): void 'animals.*', 'animals.zoo.*', 'animals.president.*', - ]) + ]), ] ); @@ -477,12 +498,12 @@ public function testZoo(): void $this->assertArrayHasKey('animals', $veterinarian); $this->assertArrayNotHasKey('presidents', $veterinarian); - $animal = $veterinarian['animals'][0]; + $animal = $veterinarian->getDocuments('animals')[0]; $this->assertArrayHasKey('president', $animal); - $this->assertEquals('Bush', $animal->getAttribute('president')->getAttribute('lastName')); // Check president is an object + $this->assertEquals('Bush', $animal->getDocument('president')->getAttribute('lastName')); // Check president is an object $this->assertArrayHasKey('zoo', $animal); - $this->assertEquals('Bronx Zoo', $animal->getAttribute('zoo')->getAttribute('name')); // Check zoo is an object + $this->assertEquals('Bronx Zoo', $animal->getDocument('zoo')->getAttribute('name')); // Check zoo is an object } public function testSimpleRelationshipPopulation(): void @@ -490,26 +511,25 @@ public function testSimpleRelationshipPopulation(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Simple test case: user -> post (one-to-many) - $database->createCollection('usersSimple'); - $database->createCollection('postsSimple'); + $database->createCollection(Collection::create(id: 'usersSimple')); + $database->createCollection(Collection::create(id: 'postsSimple')); - $database->createAttribute('usersSimple', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('postsSimple', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('usersSimple', Attribute::string(key: 'name', required: true)); + $database->createAttribute('postsSimple', Attribute::string(key: 'title', required: true)); - $database->createRelationship( - collection: 'usersSimple', + $database->createRelationship('usersSimple', Relationship::oneToMany( relatedCollection: 'postsSimple', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'posts', + key: 'posts', twoWayKey: 'author' - ); + )); // Create some data $user = $database->createDocument('usersSimple', new Document([ @@ -534,27 +554,19 @@ public function testSimpleRelationshipPopulation(): void // fetch user with posts populated $fetchedUser = $database->getDocument('usersSimple', 'user1'); - $posts = $fetchedUser->getAttribute('posts', []); + $posts = $fetchedUser->getDocuments('posts'); + $populatedPosts = $fetchedUser->getAttribute('posts'); - // Basic assertions - $this->assertIsArray($posts, 'Posts should be an array'); $this->assertCount(2, $posts, 'Should have 2 posts'); + $this->assertIsArray($populatedPosts); + $this->assertInstanceOf(Document::class, $populatedPosts[0] ?? null, 'First post should be a Document object'); + $this->assertEquals('First Post', $posts[0]->getAttribute('title'), 'First post title should be populated'); - if (!empty($posts)) { - $this->assertInstanceOf(Document::class, $posts[0], 'First post should be a Document object'); - $this->assertEquals('First Post', $posts[0]->getAttribute('title'), 'First post title should be populated'); - } - - // fetch posts with author populated $fetchedPosts = $database->find('postsSimple'); $this->assertCount(2, $fetchedPosts, 'Should fetch 2 posts'); - - if (!empty($fetchedPosts)) { - $author = $fetchedPosts[0]->getAttribute('author'); - $this->assertInstanceOf(Document::class, $author, 'Author should be a Document object'); - $this->assertEquals('John Doe', $author->getAttribute('name'), 'Author name should be populated'); - } + $this->assertInstanceOf(Document::class, $fetchedPosts[0]->getAttribute('author'), 'Author should be a Document object'); + $this->assertEquals('John Doe', $fetchedPosts[0]->getDocument('author')->getAttribute('name'), 'Author name should be populated'); } public function testDeleteRelatedCollection(): void @@ -562,165 +574,112 @@ public function testDeleteRelatedCollection(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('c1'); - $database->createCollection('c2'); + $database->createCollection(Collection::create(id: 'c1')); + $database->createCollection(Collection::create(id: 'c2')); // ONE_TO_ONE - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_ONE, - ); + $database->createRelationship('c1', Relationship::oneToOne(relatedCollection: 'c2')); - $this->assertEquals(true, $database->deleteCollection('c1')); + $database->deleteCollection('c1'); $collection = $database->getCollection('c2'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c1'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_ONE, - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); - $this->assertEquals(true, $database->deleteCollection('c2')); + $database->createCollection(Collection::create(id: 'c1')); + $database->createRelationship('c1', Relationship::oneToOne(relatedCollection: 'c2')); + + $database->deleteCollection('c2'); $collection = $database->getCollection('c1'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c2'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); - $this->assertEquals(true, $database->deleteCollection('c1')); + $database->createCollection(Collection::create(id: 'c2')); + $database->createRelationship('c1', Relationship::oneToOne(relatedCollection: 'c2', twoWay: true)); + + $database->deleteCollection('c1'); $collection = $database->getCollection('c2'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c1'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); - $this->assertEquals(true, $database->deleteCollection('c2')); + $database->createCollection(Collection::create(id: 'c1')); + $database->createRelationship('c1', Relationship::oneToOne(relatedCollection: 'c2', twoWay: true)); + + $database->deleteCollection('c2'); $collection = $database->getCollection('c1'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); // ONE_TO_MANY - $database->createCollection('c2'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_MANY, - ); + $database->createCollection(Collection::create(id: 'c2')); + $database->createRelationship('c1', Relationship::oneToMany(relatedCollection: 'c2')); - $this->assertEquals(true, $database->deleteCollection('c1')); + $database->deleteCollection('c1'); $collection = $database->getCollection('c2'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c1'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_MANY, - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); + + $database->createCollection(Collection::create(id: 'c1')); + $database->createRelationship('c1', Relationship::oneToMany(relatedCollection: 'c2')); - $this->assertEquals(true, $database->deleteCollection('c2')); + $database->deleteCollection('c2'); $collection = $database->getCollection('c1'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c2'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); + + $database->createCollection(Collection::create(id: 'c2')); + $database->createRelationship('c1', Relationship::oneToMany(relatedCollection: 'c2', twoWay: true)); - $this->assertEquals(true, $database->deleteCollection('c1')); + $database->deleteCollection('c1'); $collection = $database->getCollection('c2'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c1'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); + + $database->createCollection(Collection::create(id: 'c1')); + $database->createRelationship('c1', Relationship::oneToMany(relatedCollection: 'c2', twoWay: true)); - $this->assertEquals(true, $database->deleteCollection('c2')); + $database->deleteCollection('c2'); $collection = $database->getCollection('c1'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); // RELATION_MANY_TO_ONE - $database->createCollection('c2'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_MANY_TO_ONE, - ); + $database->createCollection(Collection::create(id: 'c2')); + $database->createRelationship('c1', Relationship::manyToOne(relatedCollection: 'c2')); - $this->assertEquals(true, $database->deleteCollection('c1')); + $database->deleteCollection('c1'); $collection = $database->getCollection('c2'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c1'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_MANY_TO_ONE, - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); - $this->assertEquals(true, $database->deleteCollection('c2')); + $database->createCollection(Collection::create(id: 'c1')); + $database->createRelationship('c1', Relationship::manyToOne(relatedCollection: 'c2')); + + $database->deleteCollection('c2'); $collection = $database->getCollection('c1'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c2'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); - $this->assertEquals(true, $database->deleteCollection('c1')); + $database->createCollection(Collection::create(id: 'c2')); + $database->createRelationship('c1', Relationship::manyToOne(relatedCollection: 'c2', twoWay: true)); + + $database->deleteCollection('c1'); $collection = $database->getCollection('c2'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); - - $database->createCollection('c1'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true - ); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); - $this->assertEquals(true, $database->deleteCollection('c2')); + $database->createCollection(Collection::create(id: 'c1')); + $database->createRelationship('c1', Relationship::manyToOne(relatedCollection: 'c2', twoWay: true)); + + $database->deleteCollection('c2'); $collection = $database->getCollection('c1'); - $this->assertCount(0, $collection->getAttribute('attributes')); - $this->assertCount(0, $collection->getAttribute('indexes')); + $this->assertCount(0, $collection->attributes()); + $this->assertCount(0, $collection->indexes()); } public function testVirtualRelationsAttributes(): void @@ -728,24 +687,20 @@ public function testVirtualRelationsAttributes(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('v1'); - $database->createCollection('v2'); + $database->createCollection(Collection::create(id: 'v1')); + $database->createCollection(Collection::create(id: 'v2')); /** * RELATION_ONE_TO_ONE * TwoWay is false no attribute is created on v2 */ - $database->createRelationship( - collection: 'v1', - relatedCollection: 'v2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: false - ); + $database->createRelationship('v1', Relationship::oneToOne(relatedCollection: 'v2')); try { $database->createDocument('v2', new Document([ @@ -765,7 +720,7 @@ public function testVirtualRelationsAttributes(): void 'v1' => [ '$id' => 'test', '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -794,9 +749,9 @@ public function testVirtualRelationsAttributes(): void '$id' => 'woman', '$permissions' => [ Permission::update(Role::any()), - Permission::read(Role::any()) - ] - ] + Permission::read(Role::any()), + ], + ], ])); $this->assertEquals('man', $doc->getId()); @@ -806,8 +761,8 @@ public function testVirtualRelationsAttributes(): void '$permissions' => [], 'v2' => [[ '$id' => 'woman', - '$permissions' => [] - ]] + '$permissions' => [], + ]], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -820,12 +775,7 @@ public function testVirtualRelationsAttributes(): void * RELATION_ONE_TO_MANY * No attribute is created in V1 collection */ - $database->createRelationship( - collection: 'v1', - relatedCollection: 'v2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true - ); + $database->createRelationship('v1', Relationship::oneToMany(relatedCollection: 'v2', twoWay: true)); try { $database->createDocument('v1', new Document([ @@ -834,7 +784,7 @@ public function testVirtualRelationsAttributes(): void 'v2' => [ // Expecting Array of arrays or array of strings, object provided '$id' => 'test', '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -858,7 +808,7 @@ public function testVirtualRelationsAttributes(): void 'v1' => [[ // Expecting a string or an object ,array provided '$id' => 'test', '$permissions' => [], - ]] + ]], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -876,9 +826,9 @@ public function testVirtualRelationsAttributes(): void 'v1' => [ '$id' => 'v1_uid', '$permissions' => [ - Permission::update(Role::any()) + Permission::update(Role::any()), ], - ] + ], ])); $this->assertEquals('v2_uid', $doc->getId()); @@ -886,14 +836,13 @@ public function testVirtualRelationsAttributes(): void /** * Test update */ - try { $database->updateDocument('v1', 'v1_uid', new Document([ '$permissions' => [], 'v2' => [ // Expecting array of arrays or array of strings, object given '$id' => 'v2_uid', '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -903,7 +852,7 @@ public function testVirtualRelationsAttributes(): void try { $database->updateDocument('v1', 'v1_uid', new Document([ '$permissions' => [], - 'v2' => 'v2_uid' + 'v2' => 'v2_uid', ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -916,7 +865,7 @@ public function testVirtualRelationsAttributes(): void 'v1' => [ '$id' => null, // Invalid value '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -929,7 +878,7 @@ public function testVirtualRelationsAttributes(): void */ try { $database->find('v2', [ - //@phpstan-ignore-next-line + // @phpstan-ignore-next-line Query::equal('v1', [['doc1']]), ]); $this->fail('Failed to throw exception'); @@ -952,12 +901,7 @@ public function testVirtualRelationsAttributes(): void * RELATION_MANY_TO_ONE * No attribute is created in V2 collection */ - $database->createRelationship( - collection: 'v1', - relatedCollection: 'v2', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true - ); + $database->createRelationship('v1', Relationship::manyToOne(relatedCollection: 'v2', twoWay: true)); try { $database->createDocument('v1', new Document([ @@ -966,7 +910,7 @@ public function testVirtualRelationsAttributes(): void 'v2' => [[ // Expecting an object or a string array provided '$id' => 'test', '$permissions' => [], - ]] + ]], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -990,7 +934,7 @@ public function testVirtualRelationsAttributes(): void 'v1' => [ // Expecting an array, object provided '$id' => 'test', '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -1021,7 +965,7 @@ public function testVirtualRelationsAttributes(): void Permission::update(Role::any()), Permission::read(Role::any()), ], - ] + ], ])); $this->assertEquals('doc1', $doc->getId()); @@ -1042,7 +986,7 @@ public function testVirtualRelationsAttributes(): void try { $database->updateDocument('v2', 'doc2', new Document([ '$permissions' => [], - 'v1' => null + 'v1' => null, ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -1055,14 +999,12 @@ public function testVirtualRelationsAttributes(): void * RELATION_MANY_TO_MANY * No attribute on V1/v2 collections only on junction table */ - $database->createRelationship( - collection: 'v1', + $database->createRelationship('v1', Relationship::manyToMany( relatedCollection: 'v2', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'students', + key: 'students', twoWayKey: 'classes' - ); + )); try { $database->createDocument('v1', new Document([ @@ -1091,7 +1033,7 @@ public function testVirtualRelationsAttributes(): void 'classes' => [ // Expected array, object provided '$id' => 'test', '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -1119,7 +1061,6 @@ public function testVirtualRelationsAttributes(): void /** * Success for later test update */ - $doc = $database->createDocument('v1', new Document([ '$id' => 'class1', '$permissions' => [ @@ -1131,17 +1072,17 @@ public function testVirtualRelationsAttributes(): void '$id' => 'Richard', '$permissions' => [ Permission::update(Role::any()), - Permission::read(Role::any()) - ] + Permission::read(Role::any()), + ], ], [ '$id' => 'Bill', '$permissions' => [ Permission::update(Role::any()), - Permission::read(Role::any()) - ] - ] - ] + Permission::read(Role::any()), + ], + ], + ], ])); $this->assertEquals('class1', $doc->getId()); @@ -1156,9 +1097,9 @@ public function testVirtualRelationsAttributes(): void '$id' => 'Richard', '$permissions' => [ Permission::update(Role::any()), - Permission::read(Role::any()) - ] - ] + Permission::read(Role::any()), + ], + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -1171,7 +1112,7 @@ public function testVirtualRelationsAttributes(): void Permission::update(Role::any()), Permission::read(Role::any()), ], - 'students' => 'Richard' + 'students' => 'Richard', ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -1179,88 +1120,38 @@ public function testVirtualRelationsAttributes(): void } } - public function testStructureValidationAfterRelationsAttribute(): void + public function testNoChangeUpdateDocumentWithRelationWithoutPermission(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); - return; - } - if (!$database->getAdapter()->getSupportForAttributes()) { - // Schemaless mode allows unknown attributes, so structure validation won't reject them - $this->expectNotToPerformAssertions(); return; } - $database->createCollection("structure_1", [], [], [Permission::create(Role::any())]); - $database->createCollection("structure_2", [], [], [Permission::create(Role::any())]); - - $database->createRelationship( - collection: "structure_1", - relatedCollection: "structure_2", - type: Database::RELATION_ONE_TO_ONE, - ); - - try { - $database->createDocument('structure_1', new Document([ - '$permissions' => [ + for ($level = 1; $level <= 5; $level++) { + $database->createCollection(Collection::create( + id: "unchanged_level{$level}", + attributes: [Attribute::string(key: 'name', size: 100)], + permissions: [ Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::delete(Role::any()), ], - 'structure_2' => '100', - 'name' => 'Frozen', // Unknown attribute 'name' after relation attribute - ])); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - } - - - public function testNoChangeUpdateDocumentWithRelationWithoutPermission(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - $attribute = new Document([ - '$id' => ID::custom("name"), - 'type' => Database::VAR_STRING, - 'size' => 100, - 'required' => false, - 'default' => null, - 'signed' => false, - 'array' => false, - 'filters' => [], - ]); - - $permissions = [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::delete(Role::any()), - ]; - for ($i = 1; $i < 6; $i++) { - $database->createCollection("level{$i}", [$attribute], [], $permissions); + )); } - for ($i = 1; $i < 5; $i++) { - $collectionId = $i; - $relatedCollectionId = $i + 1; - $database->createRelationship( - collection: "level{$collectionId}", - relatedCollection: "level{$relatedCollectionId}", - type: Database::RELATION_ONE_TO_ONE, - id: "level{$relatedCollectionId}" - ); + for ($level = 1; $level < 5; $level++) { + $next = $level + 1; + $database->createRelationship("unchanged_level{$level}", Relationship::oneToOne( + relatedCollection: "unchanged_level{$next}", + key: "level{$next}", + )); } - // Create document with relationship with nested data - $level1 = $database->createDocument('level1', new Document([ + $level1 = $database->createDocument('unchanged_level1', new Document([ '$id' => 'level1', '$permissions' => [], 'name' => 'Level 1', @@ -1280,66 +1171,58 @@ public function testNoChangeUpdateDocumentWithRelationWithoutPermission(): void '$id' => 'level5', '$permissions' => [], 'name' => 'Level 5', - ] + ], ], ], ], ])); - $database->updateDocument('level1', $level1->getId(), new Document($level1->getArrayCopy())); - $updatedLevel1 = $database->getDocument('level1', $level1->getId()); - $this->assertEquals($level1, $updatedLevel1); + + $database->updateDocument('unchanged_level1', 'level1', new Document($level1->getArrayCopy())); + $this->assertEquals($level1, $database->getDocument('unchanged_level1', 'level1')); try { - $database->updateDocument('level1', $level1->getId(), $level1->setAttribute('name', 'haha')); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(AuthorizationException::class, $e); + $database->updateDocument('unchanged_level1', 'level1', $level1->setAttribute('name', 'haha')); + $this->fail('Changing a document without update permission must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame("No permissions provided for action 'update'", $exception->getMessage()); } + $level1->setAttribute('name', 'Level 1'); - $database->updateCollection('level3', [ + $database->updateCollection('unchanged_level3', new CollectionUpdate(permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), - ], false); - $level2 = $level1->getAttribute('level2'); - $level3 = $level2->getAttribute('level3'); + ], documentSecurity: false)); - $level3->setAttribute('name', 'updated value'); - $level2->setAttribute('level3', $level3); - $level1->setAttribute('level2', $level2); + $level1->getDocument('level2')->getDocument('level3')->setAttribute('name', 'updated value'); - $level1 = $database->updateDocument('level1', $level1->getId(), $level1); - $this->assertEquals('updated value', $level1['level2']['level3']['name']); + $level1 = $database->updateDocument('unchanged_level1', 'level1', $level1); + $this->assertSame('updated value', $level1->getDocument('level2')->getDocument('level3')->getAttribute('name')); + $this->assertSame('updated value', $database->getDocument('unchanged_level3', 'level3')->getAttribute('name')); - for ($i = 1; $i < 6; $i++) { - $database->deleteCollection("level{$i}"); + for ($level = 1; $level <= 5; $level++) { + $database->deleteCollection("unchanged_level{$level}"); } } - - public function testUpdateAttributeRenameRelationshipTwoWay(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('rnRsTestA'); - $database->createCollection('rnRsTestB'); + $database->createCollection(Collection::create(id: 'rnRsTestA')); + $database->createCollection(Collection::create(id: 'rnRsTestB')); - $database->createAttribute('rnRsTestB', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('rnRsTestB', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - 'rnRsTestA', - 'rnRsTestB', - Database::RELATION_ONE_TO_ONE, - true - ); + $database->createRelationship('rnRsTestA', Relationship::oneToOne(relatedCollection: 'rnRsTestB', twoWay: true)); $docA = $database->createDocument('rnRsTestA', new Document([ '$permissions' => [ @@ -1350,8 +1233,8 @@ public function testUpdateAttributeRenameRelationshipTwoWay(): void ], 'rnRsTestB' => [ '$id' => 'b1', - 'name' => 'B1' - ] + 'name' => 'B1', + ], ])); $docB = $database->getDocument('rnRsTestB', 'b1'); @@ -1361,21 +1244,21 @@ public function testUpdateAttributeRenameRelationshipTwoWay(): void // Rename attribute $database->updateRelationship( collection: 'rnRsTestA', - id: 'rnRsTestB', - newKey: 'rnRsTestB_renamed' + key: 'rnRsTestB', + update: new RelationshipUpdate(key: 'rnRsTestB_renamed') ); // Rename again $database->updateRelationship( collection: 'rnRsTestA', - id: 'rnRsTestB_renamed', - newKey: 'rnRsTestB_renamed_2' + key: 'rnRsTestB_renamed', + update: new RelationshipUpdate(key: 'rnRsTestB_renamed_2') ); // Check our data is OK $docA = $database->getDocument('rnRsTestA', $docA->getId()); $this->assertArrayHasKey('rnRsTestB_renamed_2', $docA->getAttributes()); - $this->assertEquals($docB->getId(), $docA->getAttribute('rnRsTestB_renamed_2')['$id']); + $this->assertEquals($docB->getId(), $docA->getDocument('rnRsTestB_renamed_2')->getId()); } public function testNoInvalidKeysWithRelationships(): void @@ -1383,74 +1266,72 @@ public function testNoInvalidKeysWithRelationships(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('species'); - $database->createCollection('creatures'); - $database->createCollection('characteristics'); - $database->createAttribute('species', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('creatures', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('characteristics', 'name', Database::VAR_STRING, 255, true); + foreach (['species', 'creatures', 'characteristics'] as $collection) { + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', required: true)); + } - $database->createRelationship( - collection: 'species', + $database->createRelationship('species', Relationship::oneToOne( relatedCollection: 'creatures', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'creature', - twoWayKey:'species' - ); - $database->createRelationship( - collection: 'creatures', + key: 'creature', + twoWayKey: 'species', + )); + $database->createRelationship('creatures', Relationship::oneToOne( relatedCollection: 'characteristics', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'characteristic', - twoWayKey:'creature' - ); + key: 'characteristic', + twoWayKey: 'creature', + )); $species = $database->createDocument('species', new Document([ - '$id' => ID::custom('1'), + '$id' => Id::custom('1'), '$permissions' => [ Permission::read(Role::any()), ], 'name' => 'Canine', 'creature' => [ - '$id' => ID::custom('1'), + '$id' => Id::custom('1'), '$permissions' => [ Permission::read(Role::any()), ], 'name' => 'Dog', 'characteristic' => [ - '$id' => ID::custom('1'), + '$id' => Id::custom('1'), '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), ], 'name' => 'active', - ] - ] + ], + ], ])); + $database->updateDocument('species', $species->getId(), new Document([ - '$id' => ID::custom('1'), + '$id' => Id::custom('1'), '$collection' => 'species', 'creature' => [ - '$id' => ID::custom('1'), + '$id' => Id::custom('1'), '$collection' => 'creatures', 'characteristic' => [ - '$id' => ID::custom('1'), + '$id' => Id::custom('1'), 'name' => 'active', '$collection' => 'characteristics', - ] - ] + ], + ], ])); - $updatedSpecies = $database->getDocument('species', $species->getId()); + $this->assertEquals($species, $database->getDocument('species', $species->getId())); - $this->assertEquals($species, $updatedSpecies); + foreach (['species', 'creatures', 'characteristics'] as $collection) { + $database->deleteCollection($collection); + } } public function testSelectRelationshipAttributes(): void @@ -1458,27 +1339,26 @@ public function testSelectRelationshipAttributes(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('make'); - $database->createCollection('model'); + $database->createCollection(Collection::create(id: 'make')); + $database->createCollection(Collection::create(id: 'model')); - $database->createAttribute('make', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('make', 'origin', Database::VAR_STRING, 255, true); - $database->createAttribute('model', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('model', 'year', Database::VAR_INTEGER, 0, true); + $database->createAttribute('make', Attribute::string(key: 'name', required: true)); + $database->createAttribute('make', Attribute::string(key: 'origin', required: true)); + $database->createAttribute('model', Attribute::string(key: 'name', required: true)); + $database->createAttribute('model', Attribute::integer(key: 'year', required: true)); - $database->createRelationship( - collection: 'make', + $database->createRelationship('make', Relationship::oneToMany( relatedCollection: 'model', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'models', - twoWayKey: 'make', - ); + key: 'models', + twoWayKey: 'make' + )); $database->createDocument('make', new Document([ '$id' => 'ford', @@ -1517,11 +1397,11 @@ public function testSelectRelationshipAttributes(): void } $this->assertEquals('Ford', $make['name']); - $this->assertEquals(2, \count($make['models'])); - $this->assertEquals('Fiesta', $make['models'][0]['name']); - $this->assertEquals('Focus', $make['models'][1]['name']); - $this->assertArrayNotHasKey('year', $make['models'][0]); - $this->assertArrayNotHasKey('year', $make['models'][1]); + $this->assertCount(2, $make->getDocuments('models')); + $this->assertEquals('Fiesta', $make->getDocuments('models')[0]->getAttribute('name')); + $this->assertEquals('Focus', $make->getDocuments('models')[1]->getAttribute('name')); + $this->assertArrayNotHasKey('year', $make->getDocuments('models')[0]); + $this->assertArrayNotHasKey('year', $make->getDocuments('models')[1]); $this->assertArrayHasKey('$id', $make); $this->assertArrayHasKey('$sequence', $make); $this->assertArrayHasKey('$permissions', $make); @@ -1636,11 +1516,11 @@ public function testSelectRelationshipAttributes(): void } $this->assertEquals('Ford', $make['name']); - $this->assertEquals(2, \count($make['models'])); - $this->assertArrayNotHasKey('name', $make['models'][0]); - $this->assertArrayNotHasKey('name', $make['models'][1]); - $this->assertEquals(2010, $make['models'][0]['year']); - $this->assertEquals(2011, $make['models'][1]['year']); + $this->assertCount(2, $make->getDocuments('models')); + $this->assertArrayNotHasKey('name', $make->getDocuments('models')[0]); + $this->assertArrayNotHasKey('name', $make->getDocuments('models')[1]); + $this->assertEquals(2010, $make->getDocuments('models')[0]->getAttribute('year')); + $this->assertEquals(2011, $make->getDocuments('models')[1]->getAttribute('year')); // Select all parent attributes, all child attributes $make = $database->findOne('make', [ @@ -1652,11 +1532,11 @@ public function testSelectRelationshipAttributes(): void } $this->assertEquals('Ford', $make['name']); - $this->assertEquals(2, \count($make['models'])); - $this->assertEquals('Fiesta', $make['models'][0]['name']); - $this->assertEquals('Focus', $make['models'][1]['name']); - $this->assertEquals(2010, $make['models'][0]['year']); - $this->assertEquals(2011, $make['models'][1]['year']); + $this->assertCount(2, $make->getDocuments('models')); + $this->assertEquals('Fiesta', $make->getDocuments('models')[0]->getAttribute('name')); + $this->assertEquals('Focus', $make->getDocuments('models')[1]->getAttribute('name')); + $this->assertEquals(2010, $make->getDocuments('models')[0]->getAttribute('year')); + $this->assertEquals(2011, $make->getDocuments('models')[1]->getAttribute('year')); // Select all parent attributes, all child attributes // Must select parent if selecting children @@ -1669,11 +1549,11 @@ public function testSelectRelationshipAttributes(): void } $this->assertEquals('Ford', $make['name']); - $this->assertEquals(2, \count($make['models'])); - $this->assertEquals('Fiesta', $make['models'][0]['name']); - $this->assertEquals('Focus', $make['models'][1]['name']); - $this->assertEquals(2010, $make['models'][0]['year']); - $this->assertEquals(2011, $make['models'][1]['year']); + $this->assertCount(2, $make->getDocuments('models')); + $this->assertEquals('Fiesta', $make->getDocuments('models')[0]->getAttribute('name')); + $this->assertEquals('Focus', $make->getDocuments('models')[1]->getAttribute('name')); + $this->assertEquals(2010, $make->getDocuments('models')[0]->getAttribute('year')); + $this->assertEquals(2011, $make->getDocuments('models')[1]->getAttribute('year')); // Select all parent attributes, no child attributes $make = $database->findOne('make', [ @@ -1692,7 +1572,7 @@ public function testSelectRelationshipAttributes(): void ]); $this->assertEquals('Ford', $make['name']); - $this->assertEquals(2, \count($make['models'])); + $this->assertCount(2, $make->getDocuments('models')); /* * FROM CHILD TO PARENT @@ -1704,8 +1584,8 @@ public function testSelectRelationshipAttributes(): void ]); $this->assertEquals('Fiesta', $model['name']); - $this->assertEquals('Ford', $model['make']['name']); - $this->assertArrayNotHasKey('origin', $model['make']); + $this->assertEquals('Ford', $model->getDocument('make')->getAttribute('name')); + $this->assertArrayNotHasKey('origin', $model->getDocument('make')); $this->assertArrayNotHasKey('year', $model); $this->assertArrayHasKey('name', $model); @@ -1715,7 +1595,7 @@ public function testSelectRelationshipAttributes(): void ]); $this->assertEquals('Fiesta', $model['name']); - $this->assertEquals('Ford', $model['make']['name']); + $this->assertEquals('Ford', $model->getDocument('make')->getAttribute('name')); $this->assertArrayHasKey('year', $model); // Select all parent attributes, all child attributes @@ -1724,9 +1604,9 @@ public function testSelectRelationshipAttributes(): void ]); $this->assertEquals('Fiesta', $model['name']); - $this->assertEquals('Ford', $model['make']['name']); + $this->assertEquals('Ford', $model->getDocument('make')->getAttribute('name')); $this->assertArrayHasKey('year', $model); - $this->assertArrayHasKey('name', $model['make']); + $this->assertArrayHasKey('name', $model->getDocument('make')); // Select all parent attributes, no child attributes $model = $database->findOne('model', [ @@ -1743,8 +1623,8 @@ public function testSelectRelationshipAttributes(): void ]); $this->assertEquals('Fiesta', $model['name']); - $this->assertEquals('Ford', $model['make']['name']); - $this->assertEquals('USA', $model['make']['origin']); + $this->assertEquals('Ford', $model->getDocument('make')->getAttribute('name')); + $this->assertEquals('USA', $model->getDocument('make')->getAttribute('origin')); } public function testInheritRelationshipPermissions(): void @@ -1752,34 +1632,27 @@ public function testInheritRelationshipPermissions(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('lawns', permissions: [Permission::create(Role::any())], documentSecurity: true); - $database->createCollection('trees', permissions: [Permission::create(Role::any())], documentSecurity: true); - $database->createCollection('birds', permissions: [Permission::create(Role::any())], documentSecurity: true); + $database->createCollection(Collection::create(id: 'lawns', permissions: [Permission::create(Role::any())])); + $database->createCollection(Collection::create(id: 'trees', permissions: [Permission::create(Role::any())])); + $database->createCollection(Collection::create(id: 'birds', permissions: [Permission::create(Role::any())])); - $database->createAttribute('lawns', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('trees', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('birds', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('lawns', Attribute::string(key: 'name', required: true)); + $database->createAttribute('trees', Attribute::string(key: 'name', required: true)); + $database->createAttribute('birds', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'lawns', + $database->createRelationship('lawns', Relationship::oneToMany( relatedCollection: 'trees', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, twoWayKey: 'lawn', - onDelete: Database::RELATION_MUTATE_CASCADE, - ); - $database->createRelationship( - collection: 'trees', - relatedCollection: 'birds', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); + onDelete: RelationshipDeleteAction::Cascade + )); + $database->createRelationship('trees', Relationship::manyToMany(relatedCollection: 'birds', twoWay: true, onDelete: RelationshipDeleteAction::SetNull)); $permissions = [ Permission::read(Role::any()), @@ -1812,595 +1685,243 @@ public function testInheritRelationshipPermissions(): void $lawn1 = $database->getDocument('lawns', 'lawn1'); $this->assertEquals($permissions, $lawn1->getPermissions()); - $this->assertEquals($permissions, $lawn1['trees'][0]->getPermissions()); - $this->assertEquals($permissions, $lawn1['trees'][0]['birds'][0]->getPermissions()); - $this->assertEquals($permissions, $lawn1['trees'][0]['birds'][1]->getPermissions()); + $this->assertEquals($permissions, $lawn1->getDocuments('trees')[0]->getPermissions()); + $this->assertEquals($permissions, $lawn1->getDocuments('trees')[0]->getDocuments('birds')[0]->getPermissions()); + $this->assertEquals($permissions, $lawn1->getDocuments('trees')[0]->getDocuments('birds')[1]->getPermissions()); $tree1 = $database->getDocument('trees', 'tree1'); $this->assertEquals($permissions, $tree1->getPermissions()); - $this->assertEquals($permissions, $tree1['lawn']->getPermissions()); - $this->assertEquals($permissions, $tree1['birds'][0]->getPermissions()); - $this->assertEquals($permissions, $tree1['birds'][1]->getPermissions()); + $this->assertEquals($permissions, $tree1->getDocument('lawn')->getPermissions()); + $this->assertEquals($permissions, $tree1->getDocuments('birds')[0]->getPermissions()); + $this->assertEquals($permissions, $tree1->getDocuments('birds')[1]->getPermissions()); } - /** - * @depends testInheritRelationshipPermissions - */ public function testEnforceRelationshipPermissions(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); - return; - } - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - $lawn1 = $database->getDocument('lawns', 'lawn1'); - $this->assertEquals('Lawn 1', $lawn1['name']); - - // Try update root document - try { - $database->updateDocument( - 'lawns', - $lawn1->getId(), - $lawn1->setAttribute('name', 'Lawn 1 Updated') - ); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Missing "update" permission for role "user:user1". Only "["any"]" scopes are allowed and "["user:user1"]" was given.', $e->getMessage()); - } - // Try delete root document - try { - $database->deleteDocument( - 'lawns', - $lawn1->getId(), - ); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Missing "delete" permission for role "user:user2". Only "["any"]" scopes are allowed and "["user:user2"]" was given.', $e->getMessage()); + return; } - $tree1 = $database->getDocument('trees', 'tree1'); + $lawns = 'enforce_lawns'; + $trees = 'enforce_trees'; + $birds = 'enforce_birds'; - // Try update nested document - try { - $database->updateDocument( - 'trees', - $tree1->getId(), - $tree1->setAttribute('name', 'Tree 1 Updated') - ); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Missing "update" permission for role "user:user1". Only "["any"]" scopes are allowed and "["user:user1"]" was given.', $e->getMessage()); + foreach ([$lawns, $trees, $birds] as $collection) { + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::create(Role::any())])); + $database->createAttribute($collection, Attribute::string(key: 'name', required: true)); } - // Try delete nested document - try { - $database->deleteDocument( - 'trees', - $tree1->getId(), - ); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Missing "delete" permission for role "user:user2". Only "["any"]" scopes are allowed and "["user:user2"]" was given.', $e->getMessage()); - } + $database->createRelationship($lawns, Relationship::oneToMany( + relatedCollection: $trees, + twoWay: true, + key: 'trees', + twoWayKey: 'lawn', + onDelete: RelationshipDeleteAction::Cascade, + )); + $database->createRelationship($trees, Relationship::manyToMany( + relatedCollection: $birds, + twoWay: true, + key: 'birds', + twoWayKey: 'trees', + onDelete: RelationshipDeleteAction::SetNull, + )); - $bird1 = $database->getDocument('birds', 'bird1'); + $database->createDocument($lawns, new Document([ + '$id' => 'lawn1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user('user1')), + Permission::update(Role::user('user1')), + Permission::delete(Role::user('user2')), + ], + 'name' => 'Lawn 1', + 'trees' => [ + [ + '$id' => 'tree1', + 'name' => 'Tree 1', + 'birds' => [ + ['$id' => 'bird1', 'name' => 'Bird 1'], + ['$id' => 'bird2', 'name' => 'Bird 2'], + ], + ], + ], + ])); - // Try update multi-level nested document - try { - $database->updateDocument( - 'birds', - $bird1->getId(), - $bird1->setAttribute('name', 'Bird 1 Updated') - ); - $this->fail('Failed to throw exception when updating document with missing permissions'); - } catch (Exception $e) { - $this->assertEquals('Missing "update" permission for role "user:user1". Only "["any"]" scopes are allowed and "["user:user1"]" was given.', $e->getMessage()); - } + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); - // Try delete multi-level nested document try { - $database->deleteDocument( - 'birds', - $bird1->getId(), - ); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Missing "delete" permission for role "user:user2". Only "["any"]" scopes are allowed and "["user:user2"]" was given.', $e->getMessage()); - } - - $this->getDatabase()->getAuthorization()->addRole(Role::user('user1')->toString()); - - $bird1 = $database->getDocument('birds', 'bird1'); - - // Try update multi-level nested document - $bird1 = $database->updateDocument( - 'birds', - $bird1->getId(), - $bird1->setAttribute('name', 'Bird 1 Updated') - ); - - $this->assertEquals('Bird 1 Updated', $bird1['name']); - - $this->getDatabase()->getAuthorization()->addRole(Role::user('user2')->toString()); + $this->assertSame('Lawn 1', $database->getDocument($lawns, 'lawn1')->getAttribute('name')); + + foreach ([$lawns => 'lawn1', $trees => 'tree1', $birds => 'bird1'] as $collection => $id) { + $document = $database->getDocument($collection, $id); + + try { + $database->updateDocument($collection, $id, $document->setAttribute('name', 'Updated')); + $this->fail("Updating {$id} without the user:user1 role must be rejected"); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "update" permission for role "user:user1". Only "["any"]" scopes are allowed and "["user:user1"]" was given.', $exception->getMessage()); + } + + try { + $database->deleteDocument($collection, $id); + $this->fail("Deleting {$id} without the user:user2 role must be rejected"); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "delete" permission for role "user:user2". Only "["any"]" scopes are allowed and "["user:user2"]" was given.', $exception->getMessage()); + } + } - // Try delete multi-level nested document - $deleted = $database->deleteDocument( - 'birds', - $bird1->getId(), - ); + $authorization->addRole(Role::user('user1')->toString()); - $this->assertEquals(true, $deleted); - $tree1 = $database->getDocument('trees', 'tree1'); - $this->assertEquals(1, count($tree1['birds'])); + $bird1 = $database->getDocument($birds, 'bird1'); + $bird1 = $database->updateDocument($birds, 'bird1', $bird1->setAttribute('name', 'Bird 1 Updated')); + $this->assertSame('Bird 1 Updated', $bird1->getAttribute('name')); - // Try update nested document - $tree1 = $database->updateDocument( - 'trees', - $tree1->getId(), - $tree1->setAttribute('name', 'Tree 1 Updated') - ); + $authorization->addRole(Role::user('user2')->toString()); - $this->assertEquals('Tree 1 Updated', $tree1['name']); + $this->assertTrue($database->deleteDocument($birds, 'bird1')); + $tree1 = $database->getDocument($trees, 'tree1'); + $this->assertCount(1, $tree1->getDocuments('birds')); + $this->assertSame('bird2', $tree1->getDocuments('birds')[0]->getId()); - // Try delete nested document - $deleted = $database->deleteDocument( - 'trees', - $tree1->getId(), - ); + $tree1 = $database->updateDocument($trees, 'tree1', $tree1->setAttribute('name', 'Tree 1 Updated')); + $this->assertSame('Tree 1 Updated', $tree1->getAttribute('name')); - $this->assertEquals(true, $deleted); - $lawn1 = $database->getDocument('lawns', 'lawn1'); - $this->assertEquals(0, count($lawn1['trees'])); + $this->assertTrue($database->deleteDocument($trees, 'tree1')); + $lawn1 = $database->getDocument($lawns, 'lawn1'); + $this->assertSame('Lawn 1', $lawn1->getAttribute('name')); + $this->assertSame([], $lawn1->getAttribute('trees')); - // Create document with no permissions - $database->createDocument('lawns', new Document([ - '$id' => 'lawn2', - 'name' => 'Lawn 2', - 'trees' => [ - [ - '$id' => 'tree2', - 'name' => 'Tree 2', - 'birds' => [ - [ - '$id' => 'bird3', - 'name' => 'Bird 3', + $database->createDocument($lawns, new Document([ + '$id' => 'lawn2', + 'name' => 'Lawn 2', + 'trees' => [ + [ + '$id' => 'tree2', + 'name' => 'Tree 2', + 'birds' => [ + ['$id' => 'bird3', 'name' => 'Bird 3'], ], ], ], - ], - ])); - - $lawn2 = $database->getDocument('lawns', 'lawn2'); - $this->assertEquals(true, $lawn2->isEmpty()); + ])); - $tree2 = $database->getDocument('trees', 'tree2'); - $this->assertEquals(true, $tree2->isEmpty()); + foreach ([$lawns => 'lawn2', $trees => 'tree2', $birds => 'bird3'] as $collection => $id) { + $this->assertTrue($database->getDocument($collection, $id)->isEmpty(), "{$id} must not be readable without a read permission"); + $this->assertFalse($authorization->skip(fn () => $database->getDocument($collection, $id))->isEmpty(), "{$id} must have been created"); + } + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } - $bird3 = $database->getDocument('birds', 'bird3'); - $this->assertEquals(true, $bird3->isEmpty()); + foreach ([$lawns, $trees, $birds] as $collection) { + $database->deleteCollection($collection); + } + } } - public function testCreateRelationshipMissingCollection(): void + public function testCreateEmptyValueRelationship(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $this->expectException(Exception::class); - $this->expectExceptionMessage('Collection not found'); + $database->createCollection(Collection::create(id: 'null1')); + $database->createCollection(Collection::create(id: 'null2')); - $database->createRelationship( - collection: 'missing', - relatedCollection: 'missing', - type: Database::RELATION_ONE_TO_MANY, + $database->createRelationship('null1', Relationship::oneToOne( + relatedCollection: 'null2', twoWay: true, - ); - } + )); + $database->createRelationship('null1', Relationship::oneToMany( + relatedCollection: 'null2', + twoWay: true, + key: 'null3', + twoWayKey: 'null4', + )); + $database->createRelationship('null1', Relationship::manyToOne( + relatedCollection: 'null2', + twoWay: true, + key: 'null4', + twoWayKey: 'null5', + )); + $database->createRelationship('null1', Relationship::manyToMany( + relatedCollection: 'null2', + twoWay: true, + key: 'null6', + twoWayKey: 'null7', + )); + + $cases = [ + 'oneToOne parent' => ['null1', 'null2', null], + 'oneToOne child' => ['null2', 'null1', null], + 'oneToMany parent' => ['null1', 'null3', []], + 'oneToMany child' => ['null2', 'null4', null], + 'manyToOne parent' => ['null1', 'null4', null], + 'manyToOne child' => ['null2', 'null5', []], + 'manyToMany parent' => ['null1', 'null6', []], + 'manyToMany child' => ['null2', 'null7', []], + ]; - public function testCreateRelationshipMissingRelatedCollection(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + foreach ($cases as $case => [$collection, $key, $expected]) { + $created = $database->createDocument($collection, new Document([ + '$id' => Id::unique(), + $key => null, + ])); + $this->assertSame($expected, $created->getAttribute($key), "{$case}: created value"); - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; + $stored = $database->getAuthorization()->skip(fn () => $database->getDocument($collection, $created->getId())); + $this->assertSame($expected, $stored->getAttribute($key), "{$case}: stored value"); } - $database->createCollection('test'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Related collection not found'); - - $database->createRelationship( - collection: 'test', - relatedCollection: 'missing', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $database->deleteCollection('null1'); + $database->deleteCollection('null2'); } - public function testCreateDuplicateRelationship(): void + public function testUpdateDocumentsRelationships(): void { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! $this->supportsBulkWrites() || ! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('test1'); - $database->createCollection('test2'); + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - $database->createRelationship( - collection: 'test1', - relatedCollection: 'test2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $this->getDatabase()->createCollection(Collection::create(id: 'testUpdateDocumentsRelationships1', attributes: [ + Attribute::string(key: 'string', size: 767, required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Attribute already exists'); - - $database->createRelationship( - collection: 'test1', - relatedCollection: 'test2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); - } - - public function testCreateInvalidRelationship(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('test3'); - $database->createCollection('test4'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Invalid relationship type'); - - $database->createRelationship( - collection: 'test3', - relatedCollection: 'test4', - type: 'invalid', - twoWay: true, - ); - } - - - public function testDeleteMissingRelationship(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - try { - $database->deleteRelationship('test', 'test2'); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertEquals('Relationship not found', $e->getMessage()); - } - } - - public function testCreateInvalidIntValueRelationship(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('invalid1'); - $database->createCollection('invalid2'); - - $database->createRelationship( - collection: 'invalid1', - relatedCollection: 'invalid2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - - $this->expectException(RelationshipException::class); - $this->expectExceptionMessage('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); - - $database->createDocument('invalid1', new Document([ - '$id' => ID::unique(), - 'invalid2' => 10, - ])); - } - - /** - * @depends testCreateInvalidIntValueRelationship - */ - public function testCreateInvalidObjectValueRelationship(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $this->expectException(RelationshipException::class); - $this->expectExceptionMessage('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); - - $database->createDocument('invalid1', new Document([ - '$id' => ID::unique(), - 'invalid2' => new \stdClass(), - ])); - } - - /** - * @depends testCreateInvalidIntValueRelationship - */ - public function testCreateInvalidArrayIntValueRelationship(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createRelationship( - collection: 'invalid1', - relatedCollection: 'invalid2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - id: 'invalid3', - twoWayKey: 'invalid4', - ); - - $this->expectException(RelationshipException::class); - $this->expectExceptionMessage('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); - - $database->createDocument('invalid1', new Document([ - '$id' => ID::unique(), - 'invalid3' => [10], - ])); - } - - public function testCreateInvalidOneWayChildArrayValueRelationship(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('reverse1'); - $database->createCollection('reverse2'); - - $database->createRelationship( - collection: 'reverse1', - relatedCollection: 'reverse2', - type: Database::RELATION_ONE_TO_ONE, - ); - - $this->expectException(RelationshipException::class); - $this->expectExceptionMessage('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); - - $database->createDocument('reverse2', new Document([ - '$id' => ID::unique(), - 'reverse1' => ['name' => 'reverse'], - ])); - } - - public function testCreateEmptyValueRelationship(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('null1'); - $database->createCollection('null2'); - - $database->createRelationship( - collection: 'null1', - relatedCollection: 'null2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - $database->createRelationship( - collection: 'null1', - relatedCollection: 'null2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - id: 'null3', - twoWayKey: 'null4', - ); - $database->createRelationship( - collection: 'null1', - relatedCollection: 'null2', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'null4', - twoWayKey: 'null5', - ); - $database->createRelationship( - collection: 'null1', - relatedCollection: 'null2', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - id: 'null6', - twoWayKey: 'null7', - ); - - $document = $database->createDocument('null1', new Document([ - '$id' => ID::unique(), - 'null2' => null, - ])); - - $this->assertEquals(null, $document->getAttribute('null2')); - - $document = $database->createDocument('null2', new Document([ - '$id' => ID::unique(), - 'null1' => null, - ])); - - $this->assertEquals(null, $document->getAttribute('null1')); - - $document = $database->createDocument('null1', new Document([ - '$id' => ID::unique(), - 'null3' => null, - ])); - - // One to many will be empty array instead of null - $this->assertEquals([], $document->getAttribute('null3')); - - $document = $database->createDocument('null2', new Document([ - '$id' => ID::unique(), - 'null4' => null, - ])); - - $this->assertEquals(null, $document->getAttribute('null4')); - - $document = $database->createDocument('null1', new Document([ - '$id' => ID::unique(), - 'null4' => null, - ])); - - $this->assertEquals(null, $document->getAttribute('null4')); - - $document = $database->createDocument('null2', new Document([ - '$id' => ID::unique(), - 'null5' => null, - ])); - - $this->assertEquals([], $document->getAttribute('null5')); - - $document = $database->createDocument('null1', new Document([ - '$id' => ID::unique(), - 'null6' => null, - ])); - - $this->assertEquals([], $document->getAttribute('null6')); - - $document = $database->createDocument('null2', new Document([ - '$id' => ID::unique(), - 'null7' => null, - ])); - - $this->assertEquals([], $document->getAttribute('null7')); - } - - public function testUpdateRelationshipToExistingKey(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('ovens'); - $database->createCollection('cakes'); - - $database->createAttribute('ovens', 'maxTemp', Database::VAR_INTEGER, 0, true); - $database->createAttribute('ovens', 'owner', Database::VAR_STRING, 255, true); - $database->createAttribute('cakes', 'height', Database::VAR_INTEGER, 0, true); - $database->createAttribute('cakes', 'colour', Database::VAR_STRING, 255, true); - - $database->createRelationship( - collection: 'ovens', - relatedCollection: 'cakes', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - id: 'cakes', - twoWayKey: 'oven' - ); - - try { - $database->updateRelationship('ovens', 'cakes', newKey: 'owner'); - $this->fail('Failed to throw exception'); - } catch (DuplicateException $e) { - $this->assertEquals('Relationship already exists', $e->getMessage()); - } - - try { - $database->updateRelationship('ovens', 'cakes', newTwoWayKey: 'height'); - $this->fail('Failed to throw exception'); - } catch (DuplicateException $e) { - $this->assertEquals('Related attribute already exists', $e->getMessage()); - } - } - - public function testUpdateDocumentsRelationships(): void - { - if (!$this->getDatabase()->getAdapter()->getSupportForBatchOperations() || !$this->getDatabase()->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - - $this->getDatabase()->createCollection('testUpdateDocumentsRelationships1', attributes: [ - new Document([ - '$id' => ID::custom('string'), - 'type' => Database::VAR_STRING, - 'size' => 767, - 'required' => true, - ]) - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - - $this->getDatabase()->createCollection('testUpdateDocumentsRelationships2', attributes: [ - new Document([ - '$id' => ID::custom('string'), - 'type' => Database::VAR_STRING, - 'size' => 767, - 'required' => true, - ]) + $this->getDatabase()->createCollection(Collection::create(id: 'testUpdateDocumentsRelationships2', attributes: [ + Attribute::string(key: 'string', size: 767, required: true), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $this->getDatabase()->createRelationship( - collection: 'testUpdateDocumentsRelationships1', - relatedCollection: 'testUpdateDocumentsRelationships2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $this->getDatabase()->createRelationship('testUpdateDocumentsRelationships1', Relationship::oneToOne(relatedCollection: 'testUpdateDocumentsRelationships2', twoWay: true)); $this->getDatabase()->createDocument('testUpdateDocumentsRelationships1', new Document([ '$id' => 'doc1', @@ -2410,11 +1931,11 @@ public function testUpdateDocumentsRelationships(): void $this->getDatabase()->createDocument('testUpdateDocumentsRelationships2', new Document([ '$id' => 'doc1', 'string' => 'text📝', - 'testUpdateDocumentsRelationships1' => 'doc1' + 'testUpdateDocumentsRelationships1' => 'doc1', ])); $sisterDocument = $this->getDatabase()->getDocument('testUpdateDocumentsRelationships2', 'doc1'); - $this->assertNotNull($sisterDocument); + $this->assertFalse($sisterDocument->isEmpty()); $this->getDatabase()->updateDocuments('testUpdateDocumentsRelationships1', new Document([ 'string' => 'text📝 updated', @@ -2422,50 +1943,45 @@ public function testUpdateDocumentsRelationships(): void $document = $this->getDatabase()->findOne('testUpdateDocumentsRelationships1'); - $this->assertNotFalse($document); + $this->assertFalse($document->isEmpty()); $this->assertEquals('text📝 updated', $document->getAttribute('string')); $sisterDocument = $this->getDatabase()->getDocument('testUpdateDocumentsRelationships2', 'doc1'); - $this->assertNotNull($sisterDocument); + $this->assertFalse($sisterDocument->isEmpty()); - $relationalDocument = $sisterDocument->getAttribute('testUpdateDocumentsRelationships1'); + $relationalDocument = $sisterDocument->getDocument('testUpdateDocumentsRelationships1'); $this->assertEquals('text📝 updated', $relationalDocument->getAttribute('string')); // Check relationship value updating between each other. $this->getDatabase()->deleteRelationship('testUpdateDocumentsRelationships1', 'testUpdateDocumentsRelationships2'); - $this->getDatabase()->createRelationship( - collection: 'testUpdateDocumentsRelationships1', - relatedCollection: 'testUpdateDocumentsRelationships2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $this->getDatabase()->createRelationship('testUpdateDocumentsRelationships1', Relationship::oneToMany(relatedCollection: 'testUpdateDocumentsRelationships2', twoWay: true)); for ($i = 2; $i < 11; $i++) { $this->getDatabase()->createDocument('testUpdateDocumentsRelationships1', new Document([ - '$id' => 'doc' . $i, + '$id' => 'doc'.$i, 'string' => 'text📝', ])); $this->getDatabase()->createDocument('testUpdateDocumentsRelationships2', new Document([ - '$id' => 'doc' . $i, + '$id' => 'doc'.$i, 'string' => 'text📝', - 'testUpdateDocumentsRelationships1' => 'doc' . $i + 'testUpdateDocumentsRelationships1' => 'doc'.$i, ])); } $this->getDatabase()->updateDocuments('testUpdateDocumentsRelationships2', new Document([ - 'testUpdateDocumentsRelationships1' => null + 'testUpdateDocumentsRelationships1' => null, ])); $this->getDatabase()->updateDocuments('testUpdateDocumentsRelationships2', new Document([ - 'testUpdateDocumentsRelationships1' => 'doc1' + 'testUpdateDocumentsRelationships1' => 'doc1', ])); $documents = $this->getDatabase()->find('testUpdateDocumentsRelationships2'); foreach ($documents as $document) { - $this->assertEquals('doc1', $document->getAttribute('testUpdateDocumentsRelationships1')->getId()); + $this->assertEquals('doc1', $document->getDocument('testUpdateDocumentsRelationships1')->getId()); } } @@ -2474,205 +1990,94 @@ public function testUpdateDocumentWithRelationships(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('userProfiles', [ - new Document([ - '$id' => ID::custom('username'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + $database->createCollection(Collection::create(id: 'userProfiles', attributes: [ + Attribute::string(key: 'username', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('links', [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'links', attributes: [ + Attribute::string(key: 'title', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('videos', [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'videos', attributes: [ + Attribute::string(key: 'title', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('products', [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'products', attributes: [ + Attribute::string(key: 'title', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('settings', [ - new Document([ - '$id' => ID::custom('metaTitle'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'settings', attributes: [ + Attribute::string(key: 'metaTitle', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('appearance', [ - new Document([ - '$id' => ID::custom('metaTitle'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'appearance', attributes: [ + Attribute::string(key: 'metaTitle', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('group', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'group', attributes: [ + Attribute::string(key: 'name', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('community', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 700, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'community', attributes: [ + Attribute::string(key: 'name', size: 700), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'links', - type: Database::RELATION_ONE_TO_MANY, - id: 'links' - ); + $database->createRelationship('userProfiles', Relationship::oneToMany(relatedCollection: 'links', key: 'links')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'videos', - type: Database::RELATION_ONE_TO_MANY, - id: 'videos' - ); + $database->createRelationship('userProfiles', Relationship::oneToMany(relatedCollection: 'videos', key: 'videos')); - $database->createRelationship( - collection: 'userProfiles', + $database->createRelationship('userProfiles', Relationship::oneToMany( relatedCollection: 'products', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'products', - twoWayKey: 'userProfile', - ); + key: 'products', + twoWayKey: 'userProfile' + )); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'settings', - type: Database::RELATION_ONE_TO_ONE, - id: 'settings' - ); + $database->createRelationship('userProfiles', Relationship::oneToOne(relatedCollection: 'settings', key: 'settings')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'appearance', - type: Database::RELATION_ONE_TO_ONE, - id: 'appearance' - ); + $database->createRelationship('userProfiles', Relationship::oneToOne(relatedCollection: 'appearance', key: 'appearance')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'group', - type: Database::RELATION_MANY_TO_ONE, - id: 'group' - ); + $database->createRelationship('userProfiles', Relationship::manyToOne(relatedCollection: 'group', key: 'group')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'community', - type: Database::RELATION_MANY_TO_ONE, - id: 'community' - ); + $database->createRelationship('userProfiles', Relationship::manyToOne(relatedCollection: 'community', key: 'community')); $profile = $database->createDocument('userProfiles', new Document([ '$id' => '1', @@ -2712,13 +2117,13 @@ public function testUpdateDocumentWithRelationships(): void 'name' => 'Community 1', ], ])); - $this->assertEquals('link1', $profile->getAttribute('links')[0]->getId()); - $this->assertEquals('settings1', $profile->getAttribute('settings')->getId()); - $this->assertEquals('group1', $profile->getAttribute('group')->getId()); - $this->assertEquals('community1', $profile->getAttribute('community')->getId()); - $this->assertEquals('video1', $profile->getAttribute('videos')[0]->getId()); - $this->assertEquals('product1', $profile->getAttribute('products')[0]->getId()); - $this->assertEquals('appearance1', $profile->getAttribute('appearance')->getId()); + $this->assertEquals('link1', $profile->getDocuments('links')[0]->getId()); + $this->assertEquals('settings1', $profile->getDocument('settings')->getId()); + $this->assertEquals('group1', $profile->getDocument('group')->getId()); + $this->assertEquals('community1', $profile->getDocument('community')->getId()); + $this->assertEquals('video1', $profile->getDocuments('videos')[0]->getId()); + $this->assertEquals('product1', $profile->getDocuments('products')[0]->getId()); + $this->assertEquals('appearance1', $profile->getDocument('appearance')->getId()); $profile->setAttribute('links', [ [ @@ -2739,15 +2144,15 @@ public function testUpdateDocumentWithRelationships(): void $updatedProfile = $database->updateDocument('userProfiles', '1', $profile); - $this->assertEquals('New Link Value', $updatedProfile->getAttribute('links')[0]->getAttribute('title')); - $this->assertEquals('New Meta Title', $updatedProfile->getAttribute('settings')->getAttribute('metaTitle')); - $this->assertEquals('New Group Name', $updatedProfile->getAttribute('group')->getAttribute('name')); + $this->assertEquals('New Link Value', $updatedProfile->getDocuments('links')[0]->getAttribute('title')); + $this->assertEquals('New Meta Title', $updatedProfile->getDocument('settings')->getAttribute('metaTitle')); + $this->assertEquals('New Group Name', $updatedProfile->getDocument('group')->getAttribute('name')); // This is the point of test, related documents should be present if they are not updated - $this->assertEquals('Video 1', $updatedProfile->getAttribute('videos')[0]->getAttribute('title')); - $this->assertEquals('Product 1', $updatedProfile->getAttribute('products')[0]->getAttribute('title')); - $this->assertEquals('Meta Title', $updatedProfile->getAttribute('appearance')->getAttribute('metaTitle')); - $this->assertEquals('Community 1', $updatedProfile->getAttribute('community')->getAttribute('name')); + $this->assertEquals('Video 1', $updatedProfile->getDocuments('videos')[0]->getAttribute('title')); + $this->assertEquals('Product 1', $updatedProfile->getDocuments('products')[0]->getAttribute('title')); + $this->assertEquals('Meta Title', $updatedProfile->getDocument('appearance')->getAttribute('metaTitle')); + $this->assertEquals('Community 1', $updatedProfile->getDocument('community')->getAttribute('name')); // updating document using two way key in one to many relationship $product = $database->getDocument('products', 'product1'); @@ -2756,10 +2161,10 @@ public function testUpdateDocumentWithRelationships(): void 'username' => 'updated user value', ]); $updatedProduct = $database->updateDocument('products', 'product1', $product); - $this->assertEquals('updated user value', $updatedProduct->getAttribute('userProfile')->getAttribute('username')); + $this->assertEquals('updated user value', $updatedProduct->getDocument('userProfile')->getAttribute('username')); $this->assertEquals('Product 1', $updatedProduct->getAttribute('title')); $this->assertEquals('product1', $updatedProduct->getId()); - $this->assertEquals('1', $updatedProduct->getAttribute('userProfile')->getId()); + $this->assertEquals('1', $updatedProduct->getDocument('userProfile')->getId()); $database->deleteCollection('userProfiles'); $database->deleteCollection('links'); @@ -2780,39 +2185,28 @@ public function testMultiDocumentNestedRelationships(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Create collections: car -> customer -> inspection - $database->createCollection('car'); - $database->createAttribute('car', 'plateNumber', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'car')); + $database->createAttribute('car', Attribute::string(key: 'plateNumber', required: true)); - $database->createCollection('customer'); - $database->createAttribute('customer', 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'customer')); + $database->createAttribute('customer', Attribute::string(key: 'name', required: true)); - $database->createCollection('inspection'); - $database->createAttribute('inspection', 'type', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'inspection')); + $database->createAttribute('inspection', Attribute::string(key: 'type', required: true)); // Create relationships // car -> customer (many to one, one-way to avoid circular references) - $database->createRelationship( - collection: 'car', - relatedCollection: 'customer', - type: Database::RELATION_MANY_TO_ONE, - twoWay: false, - id: 'customer', - ); + $database->createRelationship('car', Relationship::manyToOne(relatedCollection: 'customer', key: 'customer')); // customer -> inspection (one to many, one-way) - $database->createRelationship( - collection: 'customer', - relatedCollection: 'inspection', - type: Database::RELATION_ONE_TO_MANY, - twoWay: false, - id: 'inspections', - ); + $database->createRelationship('customer', Relationship::oneToMany(relatedCollection: 'inspection', key: 'inspections')); // Create test data - customers with inspections first $database->createDocument('inspection', new Document([ @@ -2928,21 +2322,21 @@ public function testMultiDocumentNestedRelationships(): void $this->assertCount(3, $cars); $this->assertEquals('ABC123', $cars[0]['plateNumber']); - $this->assertEquals('Customer 1', $cars[0]['customer']['name']); - $this->assertCount(2, $cars[0]['customer']['inspections']); - $this->assertEquals('annual', $cars[0]['customer']['inspections'][0]['type']); - $this->assertEquals('safety', $cars[0]['customer']['inspections'][1]['type']); + $this->assertEquals('Customer 1', $cars[0]->getDocument('customer')->getAttribute('name')); + $this->assertCount(2, $cars[0]->getDocument('customer')->getDocuments('inspections')); + $this->assertEquals('annual', $cars[0]->getDocument('customer')->getDocuments('inspections')[0]->getAttribute('type')); + $this->assertEquals('safety', $cars[0]->getDocument('customer')->getDocuments('inspections')[1]->getAttribute('type')); $this->assertEquals('DEF456', $cars[1]['plateNumber']); - $this->assertEquals('Customer 2', $cars[1]['customer']['name']); - $this->assertCount(2, $cars[1]['customer']['inspections']); - $this->assertEquals('emissions', $cars[1]['customer']['inspections'][0]['type']); - $this->assertEquals('annual', $cars[1]['customer']['inspections'][1]['type']); + $this->assertEquals('Customer 2', $cars[1]->getDocument('customer')->getAttribute('name')); + $this->assertCount(2, $cars[1]->getDocument('customer')->getDocuments('inspections')); + $this->assertEquals('emissions', $cars[1]->getDocument('customer')->getDocuments('inspections')[0]->getAttribute('type')); + $this->assertEquals('annual', $cars[1]->getDocument('customer')->getDocuments('inspections')[1]->getAttribute('type')); $this->assertEquals('GHI789', $cars[2]['plateNumber']); - $this->assertEquals('Customer 3', $cars[2]['customer']['name']); - $this->assertCount(1, $cars[2]['customer']['inspections']); - $this->assertEquals('safety', $cars[2]['customer']['inspections'][0]['type']); + $this->assertEquals('Customer 3', $cars[2]->getDocument('customer')->getAttribute('name')); + $this->assertCount(1, $cars[2]->getDocument('customer')->getDocuments('inspections')); + $this->assertEquals('safety', $cars[2]->getDocument('customer')->getDocuments('inspections')[0]->getAttribute('type')); // Test with createDocuments as well $database->deleteDocument('car', 'car1'); @@ -2980,9 +2374,9 @@ public function testMultiDocumentNestedRelationships(): void // Verify all cars still have nested relationships after batch create $this->assertCount(3, $cars); - $this->assertCount(2, $cars[0]['customer']['inspections']); - $this->assertCount(2, $cars[1]['customer']['inspections']); - $this->assertCount(1, $cars[2]['customer']['inspections']); + $this->assertCount(2, $cars[0]->getDocument('customer')->getDocuments('inspections')); + $this->assertCount(2, $cars[1]->getDocument('customer')->getDocuments('inspections')); + $this->assertCount(1, $cars[2]->getDocument('customer')->getDocuments('inspections')); // Clean up $database->deleteCollection('inspection'); @@ -3000,39 +2394,36 @@ public function testNestedDocumentCreationWithDepthHandling(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Create three collections with chained relationships: Order -> Product -> Store - $database->createCollection('orderDepthTest'); - $database->createCollection('productDepthTest'); - $database->createCollection('storeDepthTest'); + $database->createCollection(Collection::create(id: 'orderDepthTest')); + $database->createCollection(Collection::create(id: 'productDepthTest')); + $database->createCollection(Collection::create(id: 'storeDepthTest')); - $database->createAttribute('orderDepthTest', 'orderNumber', Database::VAR_STRING, 255, true); - $database->createAttribute('productDepthTest', 'productName', Database::VAR_STRING, 255, true); - $database->createAttribute('storeDepthTest', 'storeName', Database::VAR_STRING, 255, true); + $database->createAttribute('orderDepthTest', Attribute::string(key: 'orderNumber', required: true)); + $database->createAttribute('productDepthTest', Attribute::string(key: 'productName', required: true)); + $database->createAttribute('storeDepthTest', Attribute::string(key: 'storeName', required: true)); // Order -> Product (many-to-one) - $database->createRelationship( - collection: 'orderDepthTest', + $database->createRelationship('orderDepthTest', Relationship::manyToOne( relatedCollection: 'productDepthTest', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'product', + key: 'product', twoWayKey: 'orders' - ); + )); // Product -> Store (many-to-one) - $database->createRelationship( - collection: 'productDepthTest', + $database->createRelationship('productDepthTest', Relationship::manyToOne( relatedCollection: 'storeDepthTest', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'store', + key: 'store', twoWayKey: 'products' - ); + )); // First, create a store that will be referenced by the nested product $store = $database->createDocument('storeDepthTest', new Document([ @@ -3135,28 +2526,27 @@ public function testRelationshipTypeQueries(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Create author -> posts relationship - $database->createCollection('authorsFilter'); - $database->createCollection('postsFilter'); + $database->createCollection(Collection::create(id: 'authorsFilter')); + $database->createCollection(Collection::create(id: 'postsFilter')); - $database->createAttribute('authorsFilter', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('authorsFilter', 'age', Database::VAR_INTEGER, 0, true); - $database->createAttribute('postsFilter', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('postsFilter', 'published', Database::VAR_BOOLEAN, 0, true); + $database->createAttribute('authorsFilter', Attribute::string(key: 'name', required: true)); + $database->createAttribute('authorsFilter', Attribute::integer(key: 'age', required: true)); + $database->createAttribute('postsFilter', Attribute::string(key: 'title', required: true)); + $database->createAttribute('postsFilter', Attribute::boolean(key: 'published', required: true)); - $database->createRelationship( - collection: 'authorsFilter', + $database->createRelationship('authorsFilter', Relationship::oneToMany( relatedCollection: 'postsFilter', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'posts', + key: 'posts', twoWayKey: 'author' - ); + )); // Create test data $author1 = $database->createDocument('authorsFilter', new Document([ @@ -3222,21 +2612,19 @@ public function testRelationshipTypeQueries(): void $database->deleteCollection('authorsFilter'); $database->deleteCollection('postsFilter'); - $database->createCollection('usersOto'); - $database->createCollection('profilesOto'); + $database->createCollection(Collection::create(id: 'usersOto')); + $database->createCollection(Collection::create(id: 'profilesOto')); - $database->createAttribute('usersOto', 'username', Database::VAR_STRING, 255, true); - $database->createAttribute('profilesOto', 'bio', Database::VAR_STRING, 255, true); + $database->createAttribute('usersOto', Attribute::string(key: 'username', required: true)); + $database->createAttribute('profilesOto', Attribute::string(key: 'bio', required: true)); // ONE_TO_ONE with twoWay=true - $database->createRelationship( - collection: 'usersOto', + $database->createRelationship('usersOto', Relationship::oneToOne( relatedCollection: 'profilesOto', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'profile', + key: 'profile', twoWayKey: 'user' - ); + )); $user1 = $database->createDocument('usersOto', new Document([ '$id' => 'user1', @@ -3269,21 +2657,19 @@ public function testRelationshipTypeQueries(): void $database->deleteCollection('usersOto'); $database->deleteCollection('profilesOto'); - $database->createCollection('commentsMto'); - $database->createCollection('usersMto'); + $database->createCollection(Collection::create(id: 'commentsMto')); + $database->createCollection(Collection::create(id: 'usersMto')); - $database->createAttribute('commentsMto', 'content', Database::VAR_STRING, 255, true); - $database->createAttribute('usersMto', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('commentsMto', Attribute::string(key: 'content', required: true)); + $database->createAttribute('usersMto', Attribute::string(key: 'name', required: true)); // MANY_TO_ONE with twoWay=true - $database->createRelationship( - collection: 'commentsMto', + $database->createRelationship('commentsMto', Relationship::manyToOne( relatedCollection: 'usersMto', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'commenter', + key: 'commenter', twoWayKey: 'comments' - ); + )); $userA = $database->createDocument('usersMto', new Document([ '$id' => 'userA', @@ -3322,21 +2708,19 @@ public function testRelationshipTypeQueries(): void $database->deleteCollection('commentsMto'); $database->deleteCollection('usersMto'); - $database->createCollection('studentsMtm'); - $database->createCollection('coursesMtm'); + $database->createCollection(Collection::create(id: 'studentsMtm')); + $database->createCollection(Collection::create(id: 'coursesMtm')); - $database->createAttribute('studentsMtm', 'studentName', Database::VAR_STRING, 255, true); - $database->createAttribute('coursesMtm', 'courseName', Database::VAR_STRING, 255, true); + $database->createAttribute('studentsMtm', Attribute::string(key: 'studentName', required: true)); + $database->createAttribute('coursesMtm', Attribute::string(key: 'courseName', required: true)); // MANY_TO_MANY - $database->createRelationship( - collection: 'studentsMtm', + $database->createRelationship('studentsMtm', Relationship::manyToMany( relatedCollection: 'coursesMtm', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'enrolledCourses', + key: 'enrolledCourses', twoWayKey: 'students' - ); + )); $student1 = $database->createDocument('studentsMtm', new Document([ '$id' => 'student1', @@ -3378,25 +2762,24 @@ public function testQueryByRelationshipId(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('usersRelId'); - $database->createCollection('postsRelId'); + $database->createCollection(Collection::create(id: 'usersRelId')); + $database->createCollection(Collection::create(id: 'postsRelId')); - $database->createAttribute('usersRelId', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('postsRelId', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('usersRelId', Attribute::string(key: 'name', required: true)); + $database->createAttribute('postsRelId', Attribute::string(key: 'title', required: true)); - $database->createRelationship( - collection: 'postsRelId', + $database->createRelationship('postsRelId', Relationship::manyToOne( relatedCollection: 'usersRelId', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'user', + key: 'user', twoWayKey: 'posts' - ); + )); // Create test users $user1 = $database->createDocument('usersRelId', new Document([ @@ -3481,20 +2864,18 @@ public function testQueryByRelationshipId(): void $database->deleteCollection('postsRelId'); // Test ONE_TO_ONE relationship - query profile by user.$id - $database->createCollection('usersOtoId'); - $database->createCollection('profilesOtoId'); + $database->createCollection(Collection::create(id: 'usersOtoId')); + $database->createCollection(Collection::create(id: 'profilesOtoId')); - $database->createAttribute('usersOtoId', 'username', Database::VAR_STRING, 255, true); - $database->createAttribute('profilesOtoId', 'bio', Database::VAR_STRING, 255, true); + $database->createAttribute('usersOtoId', Attribute::string(key: 'username', required: true)); + $database->createAttribute('profilesOtoId', Attribute::string(key: 'bio', required: true)); - $database->createRelationship( - collection: 'usersOtoId', + $database->createRelationship('usersOtoId', Relationship::oneToOne( relatedCollection: 'profilesOtoId', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'profile', + key: 'profile', twoWayKey: 'user' - ); + )); $userOto1 = $database->createDocument('usersOtoId', new Document([ '$id' => 'userOto1', @@ -3534,20 +2915,18 @@ public function testQueryByRelationshipId(): void $database->deleteCollection('profilesOtoId'); // Test MANY_TO_MANY relationship - query projects by developer.$id - $database->createCollection('developersMtmId'); - $database->createCollection('projectsMtmId'); + $database->createCollection(Collection::create(id: 'developersMtmId')); + $database->createCollection(Collection::create(id: 'projectsMtmId')); - $database->createAttribute('developersMtmId', 'devName', Database::VAR_STRING, 255, true); - $database->createAttribute('projectsMtmId', 'projectName', Database::VAR_STRING, 255, true); + $database->createAttribute('developersMtmId', Attribute::string(key: 'devName', required: true)); + $database->createAttribute('projectsMtmId', Attribute::string(key: 'projectName', required: true)); - $database->createRelationship( - collection: 'developersMtmId', + $database->createRelationship('developersMtmId', Relationship::manyToMany( relatedCollection: 'projectsMtmId', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'projects', + key: 'projects', twoWayKey: 'developers' - ); + )); $dev1 = $database->createDocument('developersMtmId', new Document([ '$id' => 'dev1', @@ -3686,30 +3065,29 @@ public function testRelationshipFilterQueries(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Setup test collections - $database->createCollection('productsQt'); - $database->createCollection('vendorsQt'); - - $database->createAttribute('productsQt', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('productsQt', 'price', Database::VAR_FLOAT, 0, true); - $database->createAttribute('vendorsQt', 'company', Database::VAR_STRING, 255, true); - $database->createAttribute('vendorsQt', 'rating', Database::VAR_FLOAT, 0, true); - $database->createAttribute('vendorsQt', 'email', Database::VAR_STRING, 255, true); - $database->createAttribute('vendorsQt', 'verified', Database::VAR_BOOLEAN, 0, true); - - $database->createRelationship( - collection: 'productsQt', + $database->createCollection(Collection::create(id: 'productsQt')); + $database->createCollection(Collection::create(id: 'vendorsQt')); + + $database->createAttribute('productsQt', Attribute::string(key: 'name', required: true)); + $database->createAttribute('productsQt', Attribute::double(key: 'price', required: true)); + $database->createAttribute('vendorsQt', Attribute::string(key: 'company', required: true)); + $database->createAttribute('vendorsQt', Attribute::double(key: 'rating', required: true)); + $database->createAttribute('vendorsQt', Attribute::string(key: 'email', required: true)); + $database->createAttribute('vendorsQt', Attribute::boolean(key: 'verified', required: true)); + + $database->createRelationship('productsQt', Relationship::manyToOne( relatedCollection: 'vendorsQt', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'vendor', + key: 'vendor', twoWayKey: 'products' - ); + )); // Create test vendors $database->createDocument('vendorsQt', new Document([ @@ -3766,70 +3144,70 @@ public function testRelationshipFilterQueries(): void // Query::equal() $products = $database->find('productsQt', [ - Query::equal('vendor.company', ['Acme Corp']) + Query::equal('vendor.company', ['Acme Corp']), ]); $this->assertCount(1, $products); $this->assertEquals('product1', $products[0]->getId()); // Query::notEqual() $products = $database->find('productsQt', [ - Query::notEqual('vendor.company', ['Budget Vendors']) + Query::notEqual('vendor.company', ['Budget Vendors']), ]); $this->assertCount(2, $products); // Query::lessThan() $products = $database->find('productsQt', [ - Query::lessThan('vendor.rating', 4.0) + Query::lessThan('vendor.rating', 4.0), ]); $this->assertCount(2, $products); // vendor2 (3.8) and vendor3 (2.5) // Query::lessThanEqual() $products = $database->find('productsQt', [ - Query::lessThanEqual('vendor.rating', 3.8) + Query::lessThanEqual('vendor.rating', 3.8), ]); $this->assertCount(2, $products); // Query::greaterThan() $products = $database->find('productsQt', [ - Query::greaterThan('vendor.rating', 4.0) + Query::greaterThan('vendor.rating', 4.0), ]); $this->assertCount(1, $products); $this->assertEquals('product1', $products[0]->getId()); // Query::greaterThanEqual() $products = $database->find('productsQt', [ - Query::greaterThanEqual('vendor.rating', 3.8) + Query::greaterThanEqual('vendor.rating', 3.8), ]); $this->assertCount(2, $products); // vendor1 (4.5) and vendor2 (3.8) // Query::startsWith() $products = $database->find('productsQt', [ - Query::startsWith('vendor.email', 'sales@') + Query::startsWith('vendor.email', 'sales@'), ]); $this->assertCount(1, $products); $this->assertEquals('product1', $products[0]->getId()); // Query::endsWith() $products = $database->find('productsQt', [ - Query::endsWith('vendor.email', '.com') + Query::endsWith('vendor.email', '.com'), ]); $this->assertCount(3, $products); - // Query::contains() + // Query::containsString() $products = $database->find('productsQt', [ - Query::contains('vendor.company', ['Corp']) + Query::containsString('vendor.company', ['Corp']), ]); $this->assertCount(1, $products); $this->assertEquals('product1', $products[0]->getId()); // Boolean query $products = $database->find('productsQt', [ - Query::equal('vendor.verified', [true]) + Query::equal('vendor.verified', [true]), ]); $this->assertCount(2, $products); // vendor1 and vendor2 are verified $products = $database->find('productsQt', [ - Query::equal('vendor.verified', [false]) + Query::equal('vendor.verified', [false]), ]); $this->assertCount(1, $products); $this->assertEquals('product3', $products[0]->getId()); @@ -3838,7 +3216,7 @@ public function testRelationshipFilterQueries(): void $products = $database->find('productsQt', [ Query::greaterThan('vendor.rating', 3.0), Query::equal('vendor.verified', [true]), - Query::startsWith('vendor.company', 'Acme') + Query::startsWith('vendor.company', 'Acme'), ]); $this->assertCount(1, $products); $this->assertEquals('product1', $products[0]->getId()); @@ -3853,36 +3231,36 @@ public function testRelationshipSpatialQueries(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } // Create Restaurants -> Suppliers relationship with spatial attributes - $database->createCollection('restaurantsSpatial'); - $database->createCollection('suppliersSpatial'); + $database->createCollection(Collection::create(id: 'restaurantsSpatial')); + $database->createCollection(Collection::create(id: 'suppliersSpatial')); - $database->createAttribute('restaurantsSpatial', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('restaurantsSpatial', 'location', Database::VAR_POINT, 0, true); + $database->createAttribute('restaurantsSpatial', Attribute::string(key: 'name', required: true)); + $database->createAttribute('restaurantsSpatial', Attribute::point(key: 'location', required: true)); - $database->createAttribute('suppliersSpatial', 'company', Database::VAR_STRING, 255, true); - $database->createAttribute('suppliersSpatial', 'warehouseLocation', Database::VAR_POINT, 0, true); - $database->createAttribute('suppliersSpatial', 'deliveryArea', Database::VAR_POLYGON, 0, true); - $database->createAttribute('suppliersSpatial', 'deliveryRoute', Database::VAR_LINESTRING, 0, true); + $database->createAttribute('suppliersSpatial', Attribute::string(key: 'company', required: true)); + $database->createAttribute('suppliersSpatial', Attribute::point(key: 'warehouseLocation', required: true)); + $database->createAttribute('suppliersSpatial', Attribute::polygon(key: 'deliveryArea', required: true)); + $database->createAttribute('suppliersSpatial', Attribute::lineString(key: 'deliveryRoute', required: true)); - $database->createRelationship( - collection: 'restaurantsSpatial', + $database->createRelationship('restaurantsSpatial', Relationship::manyToOne( relatedCollection: 'suppliersSpatial', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'supplier', + key: 'supplier', twoWayKey: 'restaurants' - ); + )); // Create suppliers with spatial data (coordinates are [longitude, latitude]) $supplier1 = $database->createDocument('suppliersSpatial', new Document([ @@ -3895,13 +3273,13 @@ public function testRelationshipSpatialQueries(): void [-73.9, 40.7], [-73.9, 40.8], [-74.1, 40.8], - [-74.1, 40.7] + [-74.1, 40.7], ], 'deliveryRoute' => [ [-74.0060, 40.7128], [-73.9851, 40.7589], - [-73.9857, 40.7484] - ] + [-73.9857, 40.7484], + ], ])); $supplier2 = $database->createDocument('suppliersSpatial', new Document([ @@ -3914,13 +3292,13 @@ public function testRelationshipSpatialQueries(): void [-118.1, 34.0], [-118.1, 34.1], [-118.3, 34.1], - [-118.3, 34.0] + [-118.3, 34.0], ], 'deliveryRoute' => [ [-118.2437, 34.0522], [-118.2468, 34.0407], - [-118.2456, 34.0336] - ] + [-118.2456, 34.0336], + ], ])); $supplier3 = $database->createDocument('suppliersSpatial', new Document([ @@ -3933,13 +3311,13 @@ public function testRelationshipSpatialQueries(): void [-104.8, 39.7], [-104.8, 39.8], [-105.1, 39.8], - [-105.1, 39.7] + [-105.1, 39.7], ], 'deliveryRoute' => [ [-104.9903, 39.7392], [-104.9847, 39.7294], - [-104.9708, 39.7197] - ] + [-104.9708, 39.7197], + ], ])); // Create restaurants @@ -3948,7 +3326,7 @@ public function testRelationshipSpatialQueries(): void '$permissions' => [Permission::read(Role::any())], 'name' => 'NYC Diner', 'location' => [-74.0060, 40.7128], - 'supplier' => 'supplier1' + 'supplier' => 'supplier1', ])); $database->createDocument('restaurantsSpatial', new Document([ @@ -3956,7 +3334,7 @@ public function testRelationshipSpatialQueries(): void '$permissions' => [Permission::read(Role::any())], 'name' => 'LA Bistro', 'location' => [-118.2437, 34.0522], - 'supplier' => 'supplier2' + 'supplier' => 'supplier2', ])); $database->createDocument('restaurantsSpatial', new Document([ @@ -3964,46 +3342,46 @@ public function testRelationshipSpatialQueries(): void '$permissions' => [Permission::read(Role::any())], 'name' => 'Denver Steakhouse', 'location' => [-104.9903, 39.7392], - 'supplier' => 'supplier3' + 'supplier' => 'supplier3', ])); // distanceLessThan on relationship point attribute $restaurants = $database->find('restaurantsSpatial', [ - Query::distanceLessThan('supplier.warehouseLocation', [-74.0060, 40.7128], 1.0) + Query::distanceLessThan('supplier.warehouseLocation', [-74.0060, 40.7128], 1.0), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); // distanceEqual on relationship point attribute $restaurants = $database->find('restaurantsSpatial', [ - Query::distanceEqual('supplier.warehouseLocation', [-74.0060, 40.7128], 0.0) + Query::distanceEqual('supplier.warehouseLocation', [-74.0060, 40.7128], 0.0), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); // distanceGreaterThan on relationship point attribute $restaurants = $database->find('restaurantsSpatial', [ - Query::distanceGreaterThan('supplier.warehouseLocation', [-74.0060, 40.7128], 10.0) + Query::distanceGreaterThan('supplier.warehouseLocation', [-74.0060, 40.7128], 10.0), ]); $this->assertCount(2, $restaurants); // LA and Denver suppliers // distanceNotEqual on relationship point attribute $restaurants = $database->find('restaurantsSpatial', [ - Query::distanceNotEqual('supplier.warehouseLocation', [-74.0060, 40.7128], 0.0) + Query::distanceNotEqual('supplier.warehouseLocation', [-74.0060, 40.7128], 0.0), ]); $this->assertCount(2, $restaurants); // LA and Denver - // contains on relationship polygon attribute (point inside polygon) + // covers on relationship polygon attribute (point inside polygon) $restaurants = $database->find('restaurantsSpatial', [ - Query::contains('supplier.deliveryArea', [[-74.0, 40.75]]) + new Query(Method::Contains, 'supplier.deliveryArea', [[-74.0, 40.75]]), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); - // contains on relationship linestring attribute + // covers on relationship linestring attribute // Note: ST_Contains on linestrings is implementation-dependent (some DBs require exact point-on-line) $restaurants = $database->find('restaurantsSpatial', [ - Query::contains('supplier.deliveryRoute', [[-74.0060, 40.7128]]) + new Query(Method::Contains, 'supplier.deliveryRoute', [[-74.0060, 40.7128]]), ]); // Verify query executes (result count depends on DB spatial implementation) $this->assertGreaterThanOrEqual(0, count($restaurants)); @@ -4014,10 +3392,10 @@ public function testRelationshipSpatialQueries(): void [-74.00, 40.72], [-74.00, 40.77], [-74.05, 40.77], - [-74.05, 40.72] + [-74.05, 40.72], ]; $restaurants = $database->find('restaurantsSpatial', [ - Query::intersects('supplier.deliveryArea', [$testPolygon]) + Query::intersects('supplier.deliveryArea', [$testPolygon]), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); @@ -4026,10 +3404,10 @@ public function testRelationshipSpatialQueries(): void // Note: Linestring intersection semantics vary by DB (MariaDB/MySQL/PostgreSQL differ) $testLine = [ [-74.01, 40.71], - [-73.99, 40.76] + [-73.99, 40.76], ]; $restaurants = $database->find('restaurantsSpatial', [ - Query::intersects('supplier.deliveryRoute', [$testLine]) + Query::intersects('supplier.deliveryRoute', [$testLine]), ]); // Verify query executes (result count depends on DB spatial implementation) $this->assertGreaterThanOrEqual(0, count($restaurants)); @@ -4037,10 +3415,10 @@ public function testRelationshipSpatialQueries(): void // crosses on relationship linestring $crossingLine = [ [-74.05, 40.70], - [-73.95, 40.80] + [-73.95, 40.80], ]; $restaurants = $database->find('restaurantsSpatial', [ - Query::crosses('supplier.deliveryRoute', [$crossingLine]) + Query::crosses('supplier.deliveryRoute', [$crossingLine]), ]); // Result depends on actual geometry intersection @@ -4050,10 +3428,10 @@ public function testRelationshipSpatialQueries(): void [-74.00, 40.75], [-74.00, 40.85], [-74.05, 40.85], - [-74.05, 40.75] + [-74.05, 40.75], ]; $restaurants = $database->find('restaurantsSpatial', [ - Query::overlaps('supplier.deliveryArea', [$overlappingPolygon]) + Query::overlaps('supplier.deliveryArea', [$overlappingPolygon]), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); @@ -4064,10 +3442,10 @@ public function testRelationshipSpatialQueries(): void [-73.9, 40.8], [-73.9, 40.9], [-74.1, 40.9], - [-74.1, 40.8] + [-74.1, 40.8], ]; $restaurants = $database->find('restaurantsSpatial', [ - Query::touches('supplier.deliveryArea', [$touchingPolygon]) + Query::touches('supplier.deliveryArea', [$touchingPolygon]), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); @@ -4075,7 +3453,7 @@ public function testRelationshipSpatialQueries(): void // Multiple spatial queries combined $restaurants = $database->find('restaurantsSpatial', [ Query::distanceLessThan('supplier.warehouseLocation', [-74.0060, 40.7128], 1.0), - Query::contains('supplier.deliveryArea', [[-74.0, 40.75]]) + new Query(Method::Contains, 'supplier.deliveryArea', [[-74.0, 40.75]]), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); @@ -4083,14 +3461,14 @@ public function testRelationshipSpatialQueries(): void // Spatial query combined with regular query $restaurants = $database->find('restaurantsSpatial', [ Query::distanceLessThan('supplier.warehouseLocation', [-74.0060, 40.7128], 1.0), - Query::equal('supplier.company', ['Fresh Foods Inc']) + Query::equal('supplier.company', ['Fresh Foods Inc']), ]); $this->assertCount(1, $restaurants); $this->assertEquals('rest1', $restaurants[0]->getId()); // count with spatial relationship query $count = $database->count('restaurantsSpatial', [ - Query::distanceLessThan('supplier.warehouseLocation', [-74.0060, 40.7128], 1.0) + Query::distanceLessThan('supplier.warehouseLocation', [-74.0060, 40.7128], 1.0), ]); $this->assertEquals(1, $count); @@ -4107,29 +3485,28 @@ public function testRelationshipVirtualQueries(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Setup ONE_TO_MANY relationship - $database->createCollection('teamsParent'); - $database->createCollection('membersParent'); + $database->createCollection(Collection::create(id: 'teamsParent')); + $database->createCollection(Collection::create(id: 'membersParent')); - $database->createAttribute('teamsParent', 'teamName', Database::VAR_STRING, 255, true); - $database->createAttribute('teamsParent', 'active', Database::VAR_BOOLEAN, 0, true); - $database->createAttribute('membersParent', 'memberName', Database::VAR_STRING, 255, true); - $database->createAttribute('membersParent', 'role', Database::VAR_STRING, 255, true); - $database->createAttribute('membersParent', 'senior', Database::VAR_BOOLEAN, 0, true); + $database->createAttribute('teamsParent', Attribute::string(key: 'teamName', required: true)); + $database->createAttribute('teamsParent', Attribute::boolean(key: 'active', required: true)); + $database->createAttribute('membersParent', Attribute::string(key: 'memberName', required: true)); + $database->createAttribute('membersParent', Attribute::string(key: 'role', required: true)); + $database->createAttribute('membersParent', Attribute::boolean(key: 'senior', required: true)); - $database->createRelationship( - collection: 'teamsParent', + $database->createRelationship('teamsParent', Relationship::oneToMany( relatedCollection: 'membersParent', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'members', + key: 'members', twoWayKey: 'team' - ); + )); // Create teams $database->createDocument('teamsParent', new Document([ @@ -4177,21 +3554,21 @@ public function testRelationshipVirtualQueries(): void // Find teams that have senior engineers $teams = $database->find('teamsParent', [ Query::equal('members.role', ['Engineer']), - Query::equal('members.senior', [true]) + Query::equal('members.senior', [true]), ]); $this->assertCount(1, $teams); $this->assertEquals('team1', $teams[0]->getId()); // Find teams with managers $teams = $database->find('teamsParent', [ - Query::equal('members.role', ['Manager']) + Query::equal('members.role', ['Manager']), ]); $this->assertCount(1, $teams); $this->assertEquals('team2', $teams[0]->getId()); // Find teams with members named 'Alice' $teams = $database->find('teamsParent', [ - Query::startsWith('members.memberName', 'A') + Query::startsWith('members.memberName', 'A'), ]); $this->assertCount(1, $teams); $this->assertEquals('team1', $teams[0]->getId()); @@ -4199,7 +3576,7 @@ public function testRelationshipVirtualQueries(): void // No teams with junior managers $teams = $database->find('teamsParent', [ Query::equal('members.role', ['Manager']), - Query::equal('members.senior', [true]) + Query::equal('members.senior', [true]), ]); $this->assertCount(0, $teams); @@ -4216,28 +3593,27 @@ public function testRelationshipQueryEdgeCases(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Setup test collections - $database->createCollection('ordersEdge'); - $database->createCollection('customersEdge'); + $database->createCollection(Collection::create(id: 'ordersEdge')); + $database->createCollection(Collection::create(id: 'customersEdge')); - $database->createAttribute('ordersEdge', 'orderNumber', Database::VAR_STRING, 255, true); - $database->createAttribute('ordersEdge', 'total', Database::VAR_FLOAT, 0, true); - $database->createAttribute('customersEdge', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('customersEdge', 'age', Database::VAR_INTEGER, 0, true); + $database->createAttribute('ordersEdge', Attribute::string(key: 'orderNumber', required: true)); + $database->createAttribute('ordersEdge', Attribute::double(key: 'total', required: true)); + $database->createAttribute('customersEdge', Attribute::string(key: 'name', required: true)); + $database->createAttribute('customersEdge', Attribute::integer(key: 'age', required: true)); - $database->createRelationship( - collection: 'ordersEdge', + $database->createRelationship('ordersEdge', Relationship::manyToOne( relatedCollection: 'customersEdge', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'customer', + key: 'customer', twoWayKey: 'orders' - ); + )); // Create customer $database->createDocument('customersEdge', new Document([ @@ -4258,25 +3634,22 @@ public function testRelationshipQueryEdgeCases(): void // No matching results $orders = $database->find('ordersEdge', [ - Query::equal('customer.name', ['Jane Doe']) + Query::equal('customer.name', ['Jane Doe']), ]); $this->assertCount(0, $orders); // Impossible condition (combines to empty set) $orders = $database->find('ordersEdge', [ Query::equal('customer.name', ['John Doe']), - Query::equal('customer.age', [25]) // John is 30, not 25 + Query::equal('customer.age', [25]), // John is 30, not 25 ]); $this->assertCount(0, $orders); - // Non-existent relationship attribute try { $database->find('ordersEdge', [ - Query::equal('nonexistent.attribute', ['value']) + Query::equal('nonexistent.attribute', ['value']), ]); - } catch (\Exception $e) { - // Expected - non-existent relationship - $this->assertTrue(true); + } catch (\Throwable) { } // Null or missing relationship @@ -4289,14 +3662,14 @@ public function testRelationshipQueryEdgeCases(): void ])); $orders = $database->find('ordersEdge', [ - Query::equal('customer.name', ['John Doe']) + Query::equal('customer.name', ['John Doe']), ]); $this->assertCount(1, $orders); // Combining relationship query with regular query $orders = $database->find('ordersEdge', [ Query::equal('customer.name', ['John Doe']), - Query::greaterThan('total', 75.00) + Query::greaterThan('total', 75.00), ]); $this->assertCount(1, $orders); $this->assertEquals('order1', $orders[0]->getId()); @@ -4305,7 +3678,7 @@ public function testRelationshipQueryEdgeCases(): void $orders = $database->find('ordersEdge', [ Query::equal('customer.name', ['John Doe']), Query::limit(1), - Query::offset(0) + Query::offset(0), ]); $this->assertCount(1, $orders); @@ -4321,29 +3694,28 @@ public function testRelationshipManyToManyComplex(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Setup MANY_TO_MANY - $database->createCollection('developersMtm'); - $database->createCollection('projectsMtm'); + $database->createCollection(Collection::create(id: 'developersMtm')); + $database->createCollection(Collection::create(id: 'projectsMtm')); - $database->createAttribute('developersMtm', 'devName', Database::VAR_STRING, 255, true); - $database->createAttribute('developersMtm', 'experience', Database::VAR_INTEGER, 0, true); - $database->createAttribute('projectsMtm', 'projectName', Database::VAR_STRING, 255, true); - $database->createAttribute('projectsMtm', 'budget', Database::VAR_FLOAT, 0, true); - $database->createAttribute('projectsMtm', 'priority', Database::VAR_STRING, 50, true); + $database->createAttribute('developersMtm', Attribute::string(key: 'devName', required: true)); + $database->createAttribute('developersMtm', Attribute::integer(key: 'experience', required: true)); + $database->createAttribute('projectsMtm', Attribute::string(key: 'projectName', required: true)); + $database->createAttribute('projectsMtm', Attribute::double(key: 'budget', required: true)); + $database->createAttribute('projectsMtm', Attribute::string(key: 'priority', size: 50, required: true)); - $database->createRelationship( - collection: 'developersMtm', + $database->createRelationship('developersMtm', Relationship::manyToMany( relatedCollection: 'projectsMtm', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'assignedProjects', + key: 'assignedProjects', twoWayKey: 'assignedDevelopers' - ); + )); // Create developers $dev1 = $database->createDocument('developersMtm', new Document([ @@ -4381,33 +3753,33 @@ public function testRelationshipManyToManyComplex(): void // Find developers on high priority projects $developers = $database->find('developersMtm', [ - Query::equal('assignedProjects.priority', ['high']) + Query::equal('assignedProjects.priority', ['high']), ]); $this->assertCount(2, $developers); // Both assigned to proj1 // Find developers on high budget projects $developers = $database->find('developersMtm', [ - Query::greaterThan('assignedProjects.budget', 50000.00) + Query::greaterThan('assignedProjects.budget', 50000.00), ]); $this->assertCount(2, $developers); // Find projects with experienced developers $projects = $database->find('projectsMtm', [ - Query::greaterThanEqual('assignedDevelopers.experience', 10) + Query::greaterThanEqual('assignedDevelopers.experience', 10), ]); $this->assertCount(1, $projects); $this->assertEquals('proj1', $projects[0]->getId()); // Find projects with junior developers $projects = $database->find('projectsMtm', [ - Query::lessThan('assignedDevelopers.experience', 5) + Query::lessThan('assignedDevelopers.experience', 5), ]); $this->assertCount(2, $projects); // Both projects have dev2 // Combined queries $projects = $database->find('projectsMtm', [ Query::equal('assignedDevelopers.devName', ['Junior Dev']), - Query::equal('priority', ['low']) + Query::equal('priority', ['low']), ]); $this->assertCount(1, $projects); $this->assertEquals('proj2', $projects[0]->getId()); @@ -4422,8 +3794,9 @@ public function testNestedRelationshipQueriesMultipleDepths(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -4432,71 +3805,63 @@ public function testNestedRelationshipQueriesMultipleDepths(): void // Also: Employees -> Department (MANY_TO_ONE) // Level 0: Companies - $database->createCollection('companiesNested'); - $database->createAttribute('companiesNested', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('companiesNested', 'industry', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'companiesNested')); + $database->createAttribute('companiesNested', Attribute::string(key: 'name', required: true)); + $database->createAttribute('companiesNested', Attribute::string(key: 'industry', required: true)); // Level 1: Employees - $database->createCollection('employeesNested'); - $database->createAttribute('employeesNested', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('employeesNested', 'role', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'employeesNested')); + $database->createAttribute('employeesNested', Attribute::string(key: 'name', required: true)); + $database->createAttribute('employeesNested', Attribute::string(key: 'role', required: true)); // Level 1b: Departments (for MANY_TO_ONE) - $database->createCollection('departmentsNested'); - $database->createAttribute('departmentsNested', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('departmentsNested', 'budget', Database::VAR_INTEGER, 0, true); + $database->createCollection(Collection::create(id: 'departmentsNested')); + $database->createAttribute('departmentsNested', Attribute::string(key: 'name', required: true)); + $database->createAttribute('departmentsNested', Attribute::integer(key: 'budget', required: true)); // Level 2: Projects - $database->createCollection('projectsNested'); - $database->createAttribute('projectsNested', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('projectsNested', 'status', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'projectsNested')); + $database->createAttribute('projectsNested', Attribute::string(key: 'title', required: true)); + $database->createAttribute('projectsNested', Attribute::string(key: 'status', required: true)); // Level 3: Tasks - $database->createCollection('tasksNested'); - $database->createAttribute('tasksNested', 'description', Database::VAR_STRING, 255, true); - $database->createAttribute('tasksNested', 'priority', Database::VAR_STRING, 255, true); - $database->createAttribute('tasksNested', 'completed', Database::VAR_BOOLEAN, 0, true); + $database->createCollection(Collection::create(id: 'tasksNested')); + $database->createAttribute('tasksNested', Attribute::string(key: 'description', required: true)); + $database->createAttribute('tasksNested', Attribute::string(key: 'priority', required: true)); + $database->createAttribute('tasksNested', Attribute::boolean(key: 'completed', required: true)); // Create relationships // Companies -> Employees (ONE_TO_MANY) - $database->createRelationship( - collection: 'companiesNested', + $database->createRelationship('companiesNested', Relationship::oneToMany( relatedCollection: 'employeesNested', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'employees', + key: 'employees', twoWayKey: 'company' - ); + )); // Employees -> Department (MANY_TO_ONE) - $database->createRelationship( - collection: 'employeesNested', + $database->createRelationship('employeesNested', Relationship::manyToOne( relatedCollection: 'departmentsNested', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'department', + key: 'department', twoWayKey: 'employees' - ); + )); // Employees -> Projects (ONE_TO_MANY) - $database->createRelationship( - collection: 'employeesNested', + $database->createRelationship('employeesNested', Relationship::oneToMany( relatedCollection: 'projectsNested', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'projects', + key: 'projects', twoWayKey: 'employee' - ); + )); // Projects -> Tasks (ONE_TO_MANY) - $database->createRelationship( - collection: 'projectsNested', + $database->createRelationship('projectsNested', Relationship::oneToMany( relatedCollection: 'tasksNested', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'tasks', + key: 'tasks', twoWayKey: 'project' - ); + )); // Create test data $dept1 = $database->createDocument('departmentsNested', new Document([ @@ -4678,29 +4043,28 @@ public function testCountAndSumWithRelationshipQueries(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Create Author -> Posts relationship with view count - $database->createCollection('authorsCount'); - $database->createCollection('postsCount'); + $database->createCollection(Collection::create(id: 'authorsCount')); + $database->createCollection(Collection::create(id: 'postsCount')); - $database->createAttribute('authorsCount', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('authorsCount', 'age', Database::VAR_INTEGER, 0, true); - $database->createAttribute('postsCount', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('postsCount', 'views', Database::VAR_INTEGER, 0, true); - $database->createAttribute('postsCount', 'published', Database::VAR_BOOLEAN, 0, true); + $database->createAttribute('authorsCount', Attribute::string(key: 'name', required: true)); + $database->createAttribute('authorsCount', Attribute::integer(key: 'age', required: true)); + $database->createAttribute('postsCount', Attribute::string(key: 'title', required: true)); + $database->createAttribute('postsCount', Attribute::integer(key: 'views', required: true)); + $database->createAttribute('postsCount', Attribute::boolean(key: 'published', required: true)); - $database->createRelationship( - collection: 'authorsCount', + $database->createRelationship('authorsCount', Relationship::oneToMany( relatedCollection: 'postsCount', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'posts', + key: 'posts', twoWayKey: 'author' - ); + )); // Create test data $author1 = $database->createDocument('authorsCount', new Document([ @@ -4836,26 +4200,29 @@ public function testCountAndSumWithRelationshipQueries(): void */ public function testOrderAndCursorWithRelationshipQueries(): void { + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + return; + } + /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('authorsOrder'); - $database->createCollection('postsOrder'); + $database->createCollection(Collection::create(id: 'authorsOrder')); + $database->createCollection(Collection::create(id: 'postsOrder')); - $database->createAttribute('authorsOrder', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('authorsOrder', 'age', Database::VAR_INTEGER, 0, true); + $database->createAttribute('authorsOrder', Attribute::string(key: 'name', required: true)); + $database->createAttribute('authorsOrder', Attribute::integer(key: 'age', required: true)); - $database->createAttribute('postsOrder', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('postsOrder', 'views', Database::VAR_INTEGER, 0, true); + $database->createAttribute('postsOrder', Attribute::string(key: 'title', required: true)); + $database->createAttribute('postsOrder', Attribute::integer(key: 'views', required: true)); - $database->createRelationship( - collection: 'postsOrder', + $database->createRelationship('postsOrder', Relationship::manyToOne( relatedCollection: 'authorsOrder', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'author', + key: 'author', twoWayKey: 'postsOrder' - ); + )); // Create authors $alice = $database->createDocument('authorsOrder', new Document([ @@ -4900,7 +4267,7 @@ public function testOrderAndCursorWithRelationshipQueries(): void $caught = false; try { $database->find('postsOrder', [ - Query::orderAsc('author.name') + Query::orderAsc('author.name'), ]); } catch (\Throwable $e) { $caught = true; @@ -4912,12 +4279,12 @@ public function testOrderAndCursorWithRelationshipQueries(): void $caught = false; try { $firstPost = $database->findOne('postsOrder', [ - Query::orderAsc('title') + Query::orderAsc('title'), ]); $database->find('postsOrder', [ Query::orderAsc('author.name'), - Query::cursorAfter($firstPost) + Query::cursorAfter($firstPost), ]); } catch (\Throwable $e) { $caught = true; @@ -4925,23 +4292,539 @@ public function testOrderAndCursorWithRelationshipQueries(): void } $this->assertTrue($caught, 'Should throw exception for nested order attribute with cursor'); - // Clean up $database->deleteCollection('authorsOrder'); $database->deleteCollection('postsOrder'); } /** - * deleteDocument() fires an update for every document on the other side of a two-way relationship - * whose relationship the delete changed, including the ones it never writes to. + * @return array + */ + public static function relatedDocumentsBeyondQueryValueLimitProvider(): array + { + return [ + 'oneToMany cascade' => [RelationshipType::OneToMany, RelationshipDeleteAction::Cascade], + 'manyToOne cascade' => [RelationshipType::ManyToOne, RelationshipDeleteAction::Cascade], + 'manyToMany cascade' => [RelationshipType::ManyToMany, RelationshipDeleteAction::Cascade], + 'manyToMany setNull' => [RelationshipType::ManyToMany, RelationshipDeleteAction::SetNull], + ]; + } + + #[DataProvider('relatedDocumentsBeyondQueryValueLimitProvider')] + public function testDeleteReachesMoreRelatedDocumentsThanTheQueryValueLimit(RelationshipType $type, RelationshipDeleteAction $onDelete): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $children = Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create(id: $parents, permissions: $permissions)); + $database->createCollection(Collection::create(id: $children, permissions: $permissions)); + if ($type === RelationshipType::ManyToOne) { + $database->createRelationship($children, Relationship::manyToOne(relatedCollection: $parents, key: 'parent', twoWay: true, twoWayKey: 'children', onDelete: $onDelete)); + } else { + $database->createRelationship($parents, Relationship::fromArray(['relatedCollection' => $children, 'relationType' => $type, 'twoWay' => true, 'key' => 'children', 'twoWayKey' => 'parent', 'onDelete' => $onDelete])); + } + + $childIds = ['child1', 'child2', 'child3']; + if ($type === RelationshipType::ManyToMany) { + foreach ($childIds as $id) { + $database->createDocument($children, new Document(['$id' => $id, '$permissions' => [Permission::read(Role::any())]])); + } + $database->createDocument($parents, new Document(['$id' => 'parent1', 'children' => $childIds])); + } else { + $database->createDocument($parents, new Document(['$id' => 'parent1'])); + foreach ($childIds as $id) { + $database->createDocument($children, new Document(['$id' => $id, 'parent' => 'parent1', '$permissions' => [Permission::read(Role::any())]])); + } + } + + $max = $database->getMaxQueryValues(); + $database->setMaxQueryValues(2); + + try { + $this->assertTrue($database->deleteDocument($parents, 'parent1')); + } finally { + $database->setMaxQueryValues($max); + } + + $this->assertTrue($database->getDocument($parents, 'parent1')->isEmpty()); + + $remaining = \array_map(fn (Document $child) => $child->getId(), $database->find($children)); + \sort($remaining); + + if ($onDelete === RelationshipDeleteAction::Cascade) { + $this->assertSame([], $remaining); + } else { + $this->assertSame($childIds, $remaining); + foreach ($childIds as $id) { + $this->assertSame([], $database->getDocument($children, $id)->getAttribute('parent')); + } + } + + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + + public function testCascadeDeleteRollsBackWhenARelatedDocumentCannotBeDeleted(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $children = Id::unique(); + $database->createCollection(Collection::create(id: $parents, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship($parents, Relationship::oneToMany( + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: RelationshipDeleteAction::Cascade, + )); + + $database->createDocument($parents, new Document(['$id' => 'parent1'])); + $database->createDocument($children, new Document([ + '$id' => 'deletable', + 'parent' => 'parent1', + '$permissions' => [Permission::read(Role::any()), Permission::delete(Role::any())], + ])); + $database->createDocument($children, new Document([ + '$id' => 'protected', + 'parent' => 'parent1', + '$permissions' => [Permission::read(Role::any()), Permission::delete(Role::user('cascadeAdmin'))], + ])); + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + try { + $database->deleteDocument($parents, 'parent1'); + $this->fail('Cascading into a child the caller may not delete must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "delete" permission for role "user:cascadeAdmin". Only "["any"]" scopes are allowed and "["user:cascadeAdmin"]" was given.', $exception->getMessage()); + } + + $children1 = \array_map(fn (Document $child) => $child->getId(), $database->getDocument($parents, 'parent1')->getDocuments('children')); + \sort($children1); + $this->assertSame(['deletable', 'protected'], $children1); + + $authorization->addRole(Role::user('cascadeAdmin')->toString()); + + $this->assertTrue($database->deleteDocument($parents, 'parent1')); + $this->assertSame([], $database->find($children)); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + } + + public function testLinkingARelatedDocumentWithoutUpdatePermissionIsRejected(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $children = Id::unique(); + $database->createCollection(Collection::create(id: $parents, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship($parents, Relationship::oneToMany( + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: RelationshipDeleteAction::SetNull, + )); + + $database->createDocument($parents, new Document(['$id' => 'parent1'])); + $database->createDocument($children, new Document([ + '$id' => 'updatable', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + $database->createDocument($children, new Document([ + '$id' => 'readonly', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::user('linkAdmin'))], + ])); + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + try { + $database->updateDocument($parents, 'parent1', new Document(['children' => ['updatable', 'readonly']])); + $this->fail('Linking a child the caller may not update must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "update" permission for role "user:linkAdmin". Only "["any"]" scopes are allowed and "["user:linkAdmin"]" was given.', $exception->getMessage()); + } + + $this->assertSame([], $database->getDocument($parents, 'parent1')->getAttribute('children')); + $this->assertNull($database->getDocument($children, 'updatable')->getAttribute('parent')); + + $authorization->addRole(Role::user('linkAdmin')->toString()); + + $database->updateDocument($parents, 'parent1', new Document(['children' => ['updatable', 'readonly']])); + + $linked = \array_map(fn (Document $child) => $child->getId(), $database->getDocument($parents, 'parent1')->getDocuments('children')); + \sort($linked); + $this->assertSame(['readonly', 'updatable'], $linked); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + } + + public function testTwoWayOneToOneLinkByDocumentStoresTheBackReference(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $children = Id::unique(); + $toys = Id::unique(); + $parts = Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + foreach ([$parents, $children, $toys, $parts] as $collection) { + $database->createCollection(Collection::create(id: $collection, permissions: $permissions)); + } + $database->createRelationship($parents, Relationship::oneToOne(relatedCollection: $children, twoWay: true, key: 'partner', twoWayKey: 'parent')); + $database->createRelationship($children, Relationship::oneToOne(relatedCollection: $toys, twoWay: true, key: 'toy', twoWayKey: 'owner')); + $database->createRelationship($toys, Relationship::oneToOne(relatedCollection: $parts, twoWay: true, key: 'part', twoWayKey: 'toy')); + $link = function (string $collection, string $id, string $key) use ($database): ?string { + $value = $database->skipRelationships(fn () => $database->getDocument($collection, $id))->getAttribute($key); + if ($value instanceof Document) { + return $value->getId(); + } + $this->assertTrue($value === null || \is_string($value)); + + return $value; + }; + + try { + foreach (['c1', 'c2', 'c3', 'c4', 'c5'] as $id) { + $database->createDocument($children, new Document(['$id' => $id])); + } + $database->createDocument($parents, new Document(['$id' => 'p1', 'partner' => 'c3'])); + foreach (['p2', 'p3', 'p4', 'p5', 'p6'] as $id) { + $database->createDocument($parents, new Document(['$id' => $id])); + } + + $database->updateDocument($parents, 'p2', new Document(['partner' => new Document(['$id' => 'c1'])])); + $this->assertSame('c1', $link($parents, 'p2', 'partner')); + $this->assertSame('p2', $link($children, 'c1', 'parent')); + + $database->updateDocument($children, 'c2', new Document(['parent' => new Document(['$id' => 'p3'])])); + $this->assertSame('p3', $link($children, 'c2', 'parent')); + $this->assertSame('c2', $link($parents, 'p3', 'partner')); + + $database->updateDocument($parents, 'p4', new Document(['partner' => 'c4'])); + $this->assertSame('c4', $link($parents, 'p4', 'partner')); + $this->assertSame('p4', $link($children, 'c4', 'parent')); + + $error = null; + try { + $database->updateDocument($parents, 'p5', new Document(['partner' => new Document(['$id' => 'c3'])])); + } catch (Throwable $caught) { + $error = $caught; + } + $this->assertInstanceOf(DuplicateException::class, $error, 'Linking a document that is already linked elsewhere was accepted'); + $this->assertNull($link($parents, 'p5', 'partner')); + $this->assertSame('p1', $link($children, 'c3', 'parent')); + $this->assertSame('c3', $link($parents, 'p1', 'partner')); + + $database->createDocument($parents, new Document([ + '$id' => 'p8', + 'partner' => ['$id' => 'c8', 'toy' => ['$id' => 't8', 'part' => ['$id' => 'x8']]], + ])); + $database->updateDocument($parents, 'p6', new Document([ + 'partner' => new Document(['$id' => 'c5', 'toy' => ['$id' => 't5', 'part' => ['$id' => 'x5']]]), + ])); + foreach (['created' => ['p8', 'c8', 't8', 'x8'], 'linked' => ['p6', 'c5', 't5', 'x5']] as $case => [$parent, $child, $toy, $part]) { + $this->assertSame($child, $link($parents, $parent, 'partner'), $case); + $this->assertSame($parent, $link($children, $child, 'parent'), $case); + $this->assertSame($toy, $link($children, $child, 'toy'), $case); + $this->assertSame($child, $link($toys, $toy, 'owner'), $case); + $this->assertNull($link($toys, $toy, 'part'), $case); + $this->assertTrue($database->getDocument($parts, $part)->isEmpty(), $case.': the level past the relation depth limit was written'); + } + } finally { + foreach ([$parents, $children, $toys, $parts] as $collection) { + $database->deleteCollection($collection); + } + } + } + + public function testNestedUpdateCannotLinkARelatedDocumentWithoutUpdatePermission(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $grandparents = Id::unique(); + $parents = Id::unique(); + $children = Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create(id: $grandparents, permissions: $permissions)); + $database->createCollection(Collection::create(id: $parents, permissions: $permissions)); + $database->createCollection(Collection::create(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship($grandparents, Relationship::oneToOne( + relatedCollection: $parents, + key: 'parent', + onDelete: RelationshipDeleteAction::SetNull, + )); + $database->createRelationship($parents, Relationship::oneToMany( + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: RelationshipDeleteAction::SetNull, + )); + + $database->createDocument($children, new Document([ + '$id' => 'readonly', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::user('nestedLinkAdmin'))], + ])); + $database->createDocument($parents, new Document(['$id' => 'parent1'])); + $database->createDocument($grandparents, new Document(['$id' => 'grandparent1', 'parent' => 'parent1'])); + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + try { + $database->updateDocument($grandparents, 'grandparent1', new Document([ + 'parent' => new Document(['$id' => 'parent1', 'children' => ['readonly']]), + ])); + $this->fail('Linking a child the caller may not update must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "update" permission for role "user:nestedLinkAdmin". Only "["any"]" scopes are allowed and "["user:nestedLinkAdmin"]" was given.', $exception->getMessage()); + } + + $this->assertSame([], $database->getDocument($parents, 'parent1')->getAttribute('children')); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + $database->deleteCollection($grandparents); + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + } + + public function testRelinkingAnUnchangedRelatedDocumentNeedsOnlyReadPermission(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $children = Id::unique(); + $database->createCollection(Collection::create(id: $parents, attributes: [Attribute::string(key: 'name', size: 64)], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + $database->createRelationship($parents, Relationship::oneToMany( + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: RelationshipDeleteAction::SetNull, + )); + + $database->createDocument($parents, new Document(['$id' => 'parent1', 'name' => 'before'])); + $database->createDocument($children, new Document(['$id' => 'child1', 'parent' => 'parent1'])); + + $parent = $database->updateDocument($parents, 'parent1', new Document(['name' => 'after', 'children' => ['child1']])); + + $this->assertSame('after', $parent->getAttribute('name')); + $this->assertSame(['child1'], \array_map(fn (Document $child) => $child->getId(), $database->getDocument($parents, 'parent1')->getDocuments('children'))); + + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + + public function testTwoWayOneToOneLinkChecksTheDocumentsOwnCollection(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $children = Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create(id: $parents, permissions: $permissions)); + $database->createCollection(Collection::create(id: $children, permissions: $permissions)); + $database->createRelationship($parents, Relationship::oneToOne( + relatedCollection: $children, + twoWay: true, + key: 'partner', + twoWayKey: 'parent', + )); + $link = function (string $collection, string $id, string $key) use ($database): ?string { + $value = $database->skipRelationships(fn () => $database->getDocument($collection, $id))->getAttribute($key); + $this->assertTrue($value === null || \is_string($value)); + + return $value; + }; + + try { + foreach (['a', 'b', 'L', 'free'] as $id) { + $database->createDocument($children, new Document(['$id' => $id])); + } + $database->createDocument($parents, new Document(['$id' => 'x', 'partner' => 'L'])); + $database->createDocument($parents, new Document(['$id' => 'a', 'partner' => 'b'])); + $database->createDocument($parents, new Document(['$id' => 'c'])); + $database->createDocument($parents, new Document(['$id' => 'd'])); + + $database->updateDocument($parents, 'c', new Document(['partner' => 'a'])); + $this->assertSame('a', $link($parents, 'c', 'partner')); + $this->assertSame('c', $link($children, 'a', 'parent')); + $this->assertSame('a', $link($children, 'b', 'parent')); + + $duplicates = [ + 'id' => fn () => $database->updateDocument($parents, 'd', new Document(['partner' => 'L'])), + 'document' => fn () => $database->updateDocument($parents, 'd', new Document(['partner' => new Document(['$id' => 'L'])])), + 'child side' => fn () => $database->updateDocument($children, 'free', new Document(['parent' => 'x'])), + ]; + foreach ($duplicates as $case => $write) { + try { + $write(); + $this->fail($case.': linking a document that is already linked elsewhere was accepted'); + } catch (DuplicateException $exception) { + $this->assertSame(DuplicateException::class, $exception::class, $case); + $this->assertSame('Document already has a related document', $exception->getMessage(), $case); + } + } + $this->assertNull($link($parents, 'd', 'partner')); + $this->assertNull($link($children, 'free', 'parent')); + $this->assertSame('x', $link($children, 'L', 'parent')); + + $database->updateDocument($parents, 'x', new Document(['partner' => null])); + $database->updateDocument($parents, 'd', new Document(['partner' => 'L'])); + $this->assertNull($link($parents, 'x', 'partner')); + $this->assertSame('L', $link($parents, 'd', 'partner')); + $this->assertSame('d', $link($children, 'L', 'parent')); + } finally { + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + } + + /** + * deleteDocument() fires Event::DocumentUpdate for every document on the other side of a two-way + * relationship whose relationship the delete changed, including the ones it never writes to. */ public function testDeleteDocumentRelatedUpdateEvent(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -4957,18 +4840,16 @@ public function testDeleteDocumentRelatedUpdateEvent(): void Permission::delete(Role::any()), ]; - $database->createCollection('related_parent', permissions: $collectionPermissions, documentSecurity: true); - $database->createCollection('related_child', permissions: $collectionPermissions, documentSecurity: true); + $database->createCollection(Collection::create(id: 'related_parent', permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(Collection::create(id: 'related_child', permissions: $collectionPermissions, documentSecurity: true)); - $database->createRelationship( - collection: 'related_parent', + $database->createRelationship('related_parent', Relationship::oneToMany( relatedCollection: 'related_child', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'children', + key: 'children', twoWayKey: 'parent', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); + onDelete: RelationshipDeleteAction::SetNull, + )); foreach (['child1', 'child2'] as $childId) { $database->createDocument('related_child', new Document([ @@ -4983,217 +4864,447 @@ public function testDeleteDocumentRelatedUpdateEvent(): void 'children' => ['child1', 'child2'], ])); - // By id to look a peer up, in order so a peer fired twice fails - $reported = []; - $fired = []; - $database->on(Database::EVENT_DOCUMENT_UPDATE, 'related-test', function (string $event, Document $related) use (&$reported, &$fired) { - $reported[$related->getId()] = $related; - $fired[] = $related->getId(); - }); + $ids = static fn (Document ...$documents): array => \array_map(fn (Document $document): string => $document->getId(), $documents); - // The Database is shared across the suite, so the listener must not outlive a failure - try { - // Deleting the parent clears each child's reference, so each is reported once as the delete left it - $database->deleteDocument('related_parent', 'parent1'); - - $this->assertEqualsCanonicalizing(['child1', 'child2'], $fired); - $this->assertEquals('related_child', $reported['child1']->getCollection()); - $this->assertEquals( - $database->getDocument('related_child', 'child1')->getUpdatedAt(), - $reported['child1']->getUpdatedAt(), - ); - - // Deleting a child writes nothing to the parent, whose relationship still changed - $database->createDocument('related_parent', new Document([ - '$id' => 'parent2', - '$permissions' => $documentPermissions, - 'children' => ['child1'], - ])); + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent1'); + $recorder->stop(); + $related = $recorder->getDocuments(Event::DocumentUpdate); - $fired = []; - $database->deleteDocument('related_child', 'child1'); + $this->assertEqualsCanonicalizing(['child1', 'child2'], $ids(...$related)); + $this->assertCount(2, $related); + foreach ($related as $document) { + $this->assertSame('related_child', $document->getCollection()); + $this->assertSame($database->getDocument('related_child', $document->getId())->getUpdatedAt(), $document->getUpdatedAt()); + } - $this->assertEquals(['parent2'], $fired); - $this->assertEquals('related_parent', $reported['parent2']->getCollection()); + $database->createDocument('related_parent', new Document([ + '$id' => 'parent2', + '$permissions' => $documentPermissions, + 'children' => ['child1'], + ])); - // A cascaded document is gone, so it is not reported as changed - $database->updateRelationship( - collection: 'related_parent', - id: 'children', - onDelete: Database::RELATION_MUTATE_CASCADE, - ); + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_child', 'child1'); + $recorder->stop(); + $related = $recorder->getDocuments(Event::DocumentUpdate); - $database->createDocument('related_child', new Document([ - '$id' => 'child3', - '$permissions' => $documentPermissions, - 'parent' => 'parent2', - ])); + $this->assertSame(['parent2'], $ids(...$related)); + $this->assertSame('related_parent', $related[0]->getCollection()); - $fired = []; - $database->deleteDocument('related_parent', 'parent2'); + $database->updateRelationship( + collection: 'related_parent', + key: 'children', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade), + ); - $this->assertEquals([], $fired); - $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + $database->createDocument('related_child', new Document([ + '$id' => 'child3', + '$permissions' => $documentPermissions, + 'parent' => 'parent2', + ])); - // Restrict allows deleting a child, and the parent still loses its reference to it - $database->updateRelationship( - collection: 'related_parent', - id: 'children', - onDelete: Database::RELATION_MUTATE_RESTRICT, - ); + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent2'); + $recorder->stop(); - $database->createDocument('related_parent', new Document([ - '$id' => 'parent4', - '$permissions' => $documentPermissions, - ])); + $this->assertSame([], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); + $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); - $database->createDocument('related_child', new Document([ - '$id' => 'child4', - '$permissions' => $documentPermissions, - 'parent' => 'parent4', - ])); + $database->updateRelationship( + collection: 'related_parent', + key: 'children', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Restrict), + ); - $fired = []; - $database->deleteDocument('related_child', 'child4'); - - $this->assertEquals(['parent4'], $fired); - - // A one-way peer exposes no relationship, so it is not reported whether or not the delete wrote to it - $database->createCollection('related_oneway', permissions: $collectionPermissions, documentSecurity: true); - - $database->createRelationship( - collection: 'related_parent', - relatedCollection: 'related_oneway', - type: Database::RELATION_ONE_TO_MANY, - twoWay: false, - id: 'strays', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); - - $database->createRelationship( - collection: 'related_parent', - relatedCollection: 'related_oneway', - type: Database::RELATION_MANY_TO_ONE, - twoWay: false, - id: 'stray', - twoWayKey: 'strayOf', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); - - $database->createDocument('related_parent', new Document([ - '$id' => 'parent3', - '$permissions' => $documentPermissions, - ])); + $database->createDocument('related_parent', new Document([ + '$id' => 'parent4', + '$permissions' => $documentPermissions, + ])); - $database->createDocument('related_oneway', new Document([ - '$id' => 'stray1', - '$permissions' => $documentPermissions, - ])); + $database->createDocument('related_child', new Document([ + '$id' => 'child4', + '$permissions' => $documentPermissions, + 'parent' => 'parent4', + ])); - $database->updateDocument('related_parent', 'parent3', new Document([ - 'strays' => ['stray1'], - 'stray' => 'stray1', - ])); + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_child', 'child4'); + $recorder->stop(); - $fired = []; - $database->deleteDocument('related_parent', 'parent3'); - - $this->assertEquals([], $fired); - $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); - - // Reached through set-null but cascaded away through another relationship, so it is gone, not changed - $database->createCollection('related_pair', permissions: $collectionPermissions, documentSecurity: true); - - $database->createRelationship( - collection: 'related_parent', - relatedCollection: 'related_pair', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'owner', - twoWayKey: 'owned', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); - - $database->createRelationship( - collection: 'related_parent', - relatedCollection: 'related_pair', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - id: 'buddy', - twoWayKey: 'buddyOf', - onDelete: Database::RELATION_MUTATE_CASCADE, - ); - - $database->createDocument('related_pair', new Document([ - '$id' => 'pair1', - '$permissions' => $documentPermissions, - ])); + $this->assertSame(['parent4'], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); - $database->createDocument('related_parent', new Document([ - '$id' => 'parent5', - '$permissions' => $documentPermissions, - 'owner' => 'pair1', - 'buddy' => 'pair1', - ])); + $database->createCollection(Collection::create(id: 'related_oneway', permissions: $collectionPermissions, documentSecurity: true)); - $fired = []; - $database->deleteDocument('related_parent', 'parent5'); - - $this->assertEquals([], $fired); - $this->assertTrue($database->getDocument('related_pair', 'pair1')->isEmpty()); - - // Removed further down a cascade chain, so it is gone, not changed, while its sibling survives - $database->updateRelationship( - collection: 'related_parent', - id: 'children', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); - - $database->createRelationship( - collection: 'related_pair', - relatedCollection: 'related_child', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - id: 'tail', - twoWayKey: 'tailOf', - onDelete: Database::RELATION_MUTATE_CASCADE, - ); + $database->createRelationship('related_parent', Relationship::oneToMany( + relatedCollection: 'related_oneway', + key: 'strays', + onDelete: RelationshipDeleteAction::SetNull, + )); - $database->createDocument('related_child', new Document([ - '$id' => 'child5', - '$permissions' => $documentPermissions, - ])); + $database->createRelationship('related_parent', Relationship::manyToOne( + relatedCollection: 'related_oneway', + key: 'stray', + twoWayKey: 'strayOf', + onDelete: RelationshipDeleteAction::SetNull, + )); - $database->createDocument('related_child', new Document([ - '$id' => 'child6', - '$permissions' => $documentPermissions, - ])); + $database->createDocument('related_parent', new Document([ + '$id' => 'parent3', + '$permissions' => $documentPermissions, + ])); - $database->createDocument('related_pair', new Document([ - '$id' => 'pair2', - '$permissions' => $documentPermissions, - 'tail' => 'child5', - ])); + $database->createDocument('related_oneway', new Document([ + '$id' => 'stray1', + '$permissions' => $documentPermissions, + ])); - $database->createDocument('related_parent', new Document([ - '$id' => 'parent6', - '$permissions' => $documentPermissions, - 'children' => ['child5', 'child6'], - 'buddy' => 'pair2', - ])); + $database->updateDocument('related_parent', 'parent3', new Document([ + 'strays' => ['stray1'], + 'stray' => 'stray1', + ])); - $fired = []; - $database->deleteDocument('related_parent', 'parent6'); + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent3'); + $recorder->stop(); - $this->assertEquals(['child6'], $fired); - $this->assertTrue($database->getDocument('related_child', 'child5')->isEmpty()); - } finally { - $database->on(Database::EVENT_DOCUMENT_UPDATE, 'related-test', null); - } + $this->assertSame([], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); + $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); + + $database->createCollection(Collection::create(id: 'related_pair', permissions: $collectionPermissions, documentSecurity: true)); + + $database->createRelationship('related_parent', Relationship::manyToOne( + relatedCollection: 'related_pair', + twoWay: true, + key: 'owner', + twoWayKey: 'owned', + onDelete: RelationshipDeleteAction::SetNull, + )); + + $database->createRelationship('related_parent', Relationship::oneToOne( + relatedCollection: 'related_pair', + twoWay: true, + key: 'buddy', + twoWayKey: 'buddyOf', + onDelete: RelationshipDeleteAction::Cascade, + )); + + $database->createDocument('related_pair', new Document([ + '$id' => 'pair1', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent5', + '$permissions' => $documentPermissions, + 'owner' => 'pair1', + 'buddy' => 'pair1', + ])); + + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent5'); + $recorder->stop(); + + $this->assertSame([], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); + $this->assertTrue($database->getDocument('related_pair', 'pair1')->isEmpty()); + + $database->updateRelationship( + collection: 'related_parent', + key: 'children', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull), + ); + + $database->createRelationship('related_pair', Relationship::oneToOne( + relatedCollection: 'related_child', + twoWay: true, + key: 'tail', + twoWayKey: 'tailOf', + onDelete: RelationshipDeleteAction::Cascade, + )); + + $database->createDocument('related_child', new Document([ + '$id' => 'child5', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_child', new Document([ + '$id' => 'child6', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_pair', new Document([ + '$id' => 'pair2', + '$permissions' => $documentPermissions, + 'tail' => 'child5', + ])); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent6', + '$permissions' => $documentPermissions, + 'children' => ['child5', 'child6'], + 'buddy' => 'pair2', + ])); + + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent6'); + $recorder->stop(); + + $this->assertSame(['child6'], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); + $this->assertTrue($database->getDocument('related_child', 'child5')->isEmpty()); $database->deleteCollection('related_parent'); $database->deleteCollection('related_child'); $database->deleteCollection('related_oneway'); $database->deleteCollection('related_pair'); } + + public function testParallelPopulationInsideATransactionReadsTheTransactionsRows(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! \extension_loaded('swoole') || ! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $children = Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create(id: $parents, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions)); + $database->createCollection(Collection::create(id: $children, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions)); + $database->createRelationship($parents, Relationship::manyToMany( + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parents', + )); + + $childIds = ['child1', 'child2', 'child3', 'child4', 'child5']; + $max = $database->getMaxQueryValues(); + $hookFlags = Runtime::getHookFlags(); + $runtimeFlags = Coroutine::getOptions()['hook_flags'] ?? SWOOLE_HOOK_ALL; + $populated = []; + $failure = null; + + $database->setMaxQueryValues(2); + Coroutine::set(['hook_flags' => $hookFlags]); + + try { + run(function () use ($database, $parents, $children, $childIds, &$populated, &$failure): void { + try { + $populated['transaction'] = $database->withTransaction(function () use ($database, $parents, $childIds): array { + $database->createDocument($parents, new Document([ + '$id' => 'parent', + 'name' => 'parent', + 'children' => \array_map( + static fn (string $id): Document => new Document(['$id' => $id, 'name' => $id]), + $childIds, + ), + ])); + + return $this->childIdsOf($database->find($parents, [Query::equal('$id', ['parent'])])); + }); + $populated['committed'] = $this->childIdsOf($database->find($parents, [Query::equal('$id', ['parent'])])); + $populated['children'] = \count($database->find($children, [Query::limit(10)])); + } catch (\Throwable $error) { + $failure = $error; + } + }); + } finally { + Coroutine::set(['hook_flags' => $runtimeFlags]); + Runtime::setHookFlags($hookFlags); + $database->setMaxQueryValues($max); + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + + if ($failure !== null) { + throw $failure; + } + + $this->assertSame(['transaction' => $childIds, 'committed' => $childIds, 'children' => 5], $populated); + } + + /** + * @param array $parents + * @return array + */ + private function childIdsOf(array $parents): array + { + $this->assertCount(1, $parents); + $ids = \array_map(static fn (Document $child): string => $child->getId(), $parents[0]->getDocuments('children')); + \sort($ids); + + return $ids; + } + + public function testNestedPathFilterThroughAOneToManyHopStaysWithinTheQueryValueLimit(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = 'nestedHopParents'; + $children = 'nestedHopChildren'; + $labels = 'nestedHopLabels'; + $database->createCollection(Collection::create(id: $parents, permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(Collection::create(id: $children, permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(Collection::create(id: $labels, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createRelationship($parents, Relationship::oneToMany(relatedCollection: $children, twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship($children, Relationship::oneToMany(relatedCollection: $labels, twoWay: true, key: 'labels', twoWayKey: 'child', onDelete: RelationshipDeleteAction::SetNull)); + + foreach (\range(1, 4) as $number) { + $database->createDocument($parents, new Document(['$id' => "parent{$number}"])); + $database->createDocument($children, new Document(['$id' => "child{$number}", 'parent' => "parent{$number}"])); + $database->createDocument($labels, new Document(['$id' => "label{$number}", 'name' => $number === 4 ? 'other' : 'match', 'child' => "child{$number}"])); + } + + $max = $database->getMaxQueryValues(); + $database->setMaxQueryValues(2); + + try { + $ids = \array_map(fn (Document $parent): string => $parent->getId(), $database->find($parents, [Query::equal('children.labels.name', ['match'])])); + \sort($ids); + + $this->assertSame(['parent1', 'parent2', 'parent3'], $ids); + } finally { + $database->setMaxQueryValues($max); + $database->deleteCollection($parents); + $database->deleteCollection($children); + $database->deleteCollection($labels); + } + } + + public function testSelectingNestedAttributesThroughTheChildSideOfAManyToOne(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $stores = 'nestedSelectStores'; + $products = 'nestedSelectProducts'; + $database->createCollection(Collection::create(id: $stores, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(Collection::create(id: $products, attributes: [Attribute::string(key: 'name', size: 64), Attribute::string(key: 'sku', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createRelationship($products, Relationship::manyToOne(relatedCollection: $stores, twoWay: true, key: 'store', twoWayKey: 'products', onDelete: RelationshipDeleteAction::SetNull)); + + try { + $database->createDocument($stores, new Document(['$id' => 'store1', 'name' => 'Store 1'])); + foreach (['product1', 'product2'] as $id) { + $database->createDocument($products, new Document(['$id' => $id, 'name' => "Name {$id}", 'sku' => "sku-{$id}", 'store' => 'store1'])); + } + + $reads = [ + 'getDocument' => $database->getDocument($stores, 'store1', [Query::select(['*', 'products.name'])]), + 'findOne' => $database->findOne($stores, [Query::select(['*', 'products.name'])]), + ]; + foreach ($reads as $read => $store) { + $this->assertSame('Store 1', $store->getAttribute('name'), $read); + $ids = \array_map(fn (Document $product): string => $product->getId(), $store->getDocuments('products')); + \sort($ids); + $this->assertSame(['product1', 'product2'], $ids, $read); + foreach ($store->getDocuments('products') as $product) { + $this->assertSame("Name {$product->getId()}", $product->getAttribute('name'), $read); + $this->assertFalse($product->offsetExists('sku'), "{$read} must return only the selected attribute of {$product->getId()}"); + $this->assertFalse($product->offsetExists('store'), "{$read} must not return the back-reference of {$product->getId()}"); + } + } + + $store = $database->getDocument($stores, 'store1', [Query::select(['*', 'products.'])]); + $this->assertCount(2, $store->getDocuments('products')); + foreach ($store->getDocuments('products') as $product) { + $this->assertSame("sku-{$product->getId()}", $product->getAttribute('sku'), 'A trailing dot selects every attribute of the related documents'); + } + } finally { + $database->deleteCollection($stores); + $database->deleteCollection($products); + } + } + + public function testContainsAllOnRelationshipEdgeCases(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $projects = 'containsAllProjects'; + $developers = 'containsAllDevelopers'; + $database->createCollection(Collection::create(id: $projects, permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(Collection::create(id: $developers, attributes: [Attribute::string(key: 'devName', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createRelationship($projects, Relationship::manyToMany(relatedCollection: $developers, twoWay: true, key: 'developers', twoWayKey: 'projects', onDelete: RelationshipDeleteAction::SetNull)); + + try { + foreach (['dev1' => 'Alice', 'dev2' => 'Bob', 'dev3' => 'Carol'] as $id => $name) { + $database->createDocument($developers, new Document(['$id' => $id, 'devName' => $name])); + } + $database->createDocument($projects, new Document(['$id' => 'project1', 'developers' => ['dev1', 'dev2']])); + $database->createDocument($projects, new Document(['$id' => 'project2', 'developers' => ['dev1', 'dev3']])); + + $found = function (Query $query) use ($database, $projects): array { + $ids = \array_map(fn (Document $project): string => $project->getId(), $database->find($projects, [$query])); + \sort($ids); + + return $ids; + }; + + $this->assertSame(['project1'], $found(Query::containsAll('developers.$id', ['dev2']))); + $this->assertSame(['project2'], $found(Query::containsAll('developers.$id', ['dev1', 'dev3']))); + $this->assertSame([], $found(Query::containsAll('developers.$id', ['dev1', 'nobody']))); + $this->assertSame([], $found(Query::containsAll('developers.$id', ['dev2', 'dev3']))); + $this->assertSame([], $found(Query::equal('developers.devName', ['Nobody']))); + + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + try { + $database->find($projects, [Query::equal('developers.unknownAttribute', ['x'])]); + $this->fail('A filter on an unknown related attribute must be rejected'); + } catch (QueryException $exception) { + $this->assertStringContainsString('unknownAttribute', $exception->getMessage()); + } + } + } finally { + $database->deleteCollection($projects); + $database->deleteCollection($developers); + } + } + + /** + * @return list + */ + private function relationshipCoveragePermissions(): array + { + return [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ChildSideTests.php b/tests/e2e/Adapter/Scopes/Relationships/ChildSideTests.php new file mode 100644 index 0000000000..cd62cabffa --- /dev/null +++ b/tests/e2e/Adapter/Scopes/Relationships/ChildSideTests.php @@ -0,0 +1,275 @@ + + */ + public static function childSideRelationshipTypes(): array + { + return [ + 'oneToMany' => [RelationshipType::OneToMany], + 'manyToOne' => [RelationshipType::ManyToOne], + 'manyToMany' => [RelationshipType::ManyToMany], + ]; + } + + #[DataProvider('childSideRelationshipTypes')] + public function testChildSideUpdateRenamesBothKeys(RelationshipType $type): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$parents, $children] = $this->createChildSideRelationship($database, $type); + + $updated = $database->updateRelationship($children, 'back', new RelationshipUpdate(key: 'owner', twoWayKey: 'items')); + + $this->assertSame($parents, $updated->relatedCollection); + $this->assertSame($type, $updated->type); + $this->assertSame('owner', $updated->key); + $this->assertSame('items', $updated->twoWayKey); + + $this->assertNull($this->childSideAttribute($database, $children, 'back')); + $this->assertNull($this->childSideAttribute($database, $parents, 'related')); + + $child = $this->childSideAttribute($database, $children, 'owner'); + $this->assertSame(RelationshipSide::Child, $child?->side); + $this->assertSame('items', $child->relationship?->twoWayKey); + + $parent = $this->childSideAttribute($database, $parents, 'items'); + $this->assertSame(RelationshipSide::Parent, $parent?->side); + $this->assertSame('owner', $parent->relationship?->twoWayKey); + + $this->assertSame(['c1'], $this->childSideIds($database->getDocument($parents, 'p1')->getAttribute('items'))); + $this->assertSame(['p1'], $this->childSideIds($database->getDocument($children, 'c1')->getAttribute('owner'))); + } + + #[DataProvider('childSideRelationshipTypes')] + public function testChildSideUpdateChangesTheDeleteActionOfBothSides(RelationshipType $type): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$parents, $children] = $this->createChildSideRelationship($database, $type); + + $updated = $database->updateRelationship($children, 'back', new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade)); + + $this->assertSame(RelationshipDeleteAction::Cascade, $updated->onDelete); + $this->assertSame('back', $updated->key); + $this->assertSame('related', $updated->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Cascade, $this->childSideAttribute($database, $children, 'back')?->relationship?->onDelete); + $this->assertSame(RelationshipDeleteAction::Cascade, $this->childSideAttribute($database, $parents, 'related')?->relationship?->onDelete); + + if ($type === RelationshipType::ManyToOne) { + $database->deleteDocument($children, 'c1'); + $this->assertTrue($database->getDocument($parents, 'p1')->isEmpty()); + } else { + $database->deleteDocument($parents, 'p1'); + $this->assertTrue($database->getDocument($children, 'c1')->isEmpty()); + } + } + + #[DataProvider('childSideRelationshipTypes')] + public function testChildSideDeleteRemovesBothSides(RelationshipType $type): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$parents, $children] = $this->createChildSideRelationship($database, $type); + $junction = '_'.$database->getCollection($parents)->getSequence().'_'.$database->getCollection($children)->getSequence(); + + $database->deleteRelationship($children, 'back'); + + $this->assertNull($this->childSideAttribute($database, $children, 'back')); + $this->assertNull($this->childSideAttribute($database, $parents, 'related')); + + $parent = $database->getDocument($parents, 'p1'); + $child = $database->getDocument($children, 'c1'); + $this->assertFalse($parent->isEmpty()); + $this->assertFalse($child->isEmpty()); + $this->assertNull($parent->getAttribute('related')); + $this->assertNull($child->getAttribute('back')); + + if ($type === RelationshipType::ManyToMany) { + $this->assertNull($database->findCollection($junction)); + } + + $database->createRelationship($parents, $this->childSideDefinition($type, $children)); + + $this->assertSame([], $this->childSideIds($database->getDocument($parents, 'p1')->getAttribute('related'))); + $this->assertSame([], $this->childSideIds($database->getDocument($children, 'c1')->getAttribute('back'))); + } + + public function testOneWayOneToOneChildSideUpdateRenamesTheParentKey(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$parents, $children] = $this->createOneWayOneToOne($database); + + $updated = $database->updateRelationship($children, 'back', new RelationshipUpdate(key: 'owner', twoWayKey: 'item')); + + $this->assertSame('owner', $updated->key); + $this->assertSame('item', $updated->twoWayKey); + $this->assertNull($this->childSideAttribute($database, $parents, 'related')); + $this->assertSame(RelationshipSide::Parent, $this->childSideAttribute($database, $parents, 'item')?->side); + $this->assertSame(RelationshipSide::Child, $this->childSideAttribute($database, $children, 'owner')?->side); + $this->assertSame(['c1'], $this->childSideIds($database->getDocument($parents, 'p1')->getAttribute('item'))); + + $database->updateDocument($parents, 'p1', new Document(['item' => null])); + $this->assertSame([], $this->childSideIds($database->getDocument($parents, 'p1')->getAttribute('item'))); + } + + public function testOneWayOneToOneChildSideDeleteRemovesTheParentKey(): void + { + $database = $this->getDatabase(); + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + [$parents, $children] = $this->createOneWayOneToOne($database); + + $database->deleteRelationship($children, 'back'); + + $this->assertNull($this->childSideAttribute($database, $children, 'back')); + $this->assertNull($this->childSideAttribute($database, $parents, 'related')); + $this->assertNull($database->getDocument($parents, 'p1')->getAttribute('related')); + + $database->createRelationship($parents, Relationship::oneToOne($children, key: 'related', twoWayKey: 'back')); + $database->updateDocument($parents, 'p1', new Document(['related' => 'c1'])); + + $this->assertSame(['c1'], $this->childSideIds($database->getDocument($parents, 'p1')->getAttribute('related'))); + } + + /** + * Two collections related one-way one-to-one from the first under "related", the child side stored as "back", + * with the parent document linked to the child. + * + * @return array{string, string} + */ + private function createOneWayOneToOne(Database $database): array + { + $parents = 'parents_'.Id::unique(); + $children = 'children_'.Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create($parents, attributes: [Attribute::string('name', 64)], permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create($children, attributes: [Attribute::string('name', 64)], permissions: $permissions, documentSecurity: false)); + $database->createRelationship($parents, Relationship::oneToOne($children, key: 'related', twoWayKey: 'back')); + + $database->createDocument($children, new Document(['$id' => 'c1', 'name' => 'child'])); + $database->createDocument($parents, new Document(['$id' => 'p1', 'name' => 'parent', 'related' => 'c1'])); + + return [$parents, $children]; + } + + /** + * Two collections related two-way from the first, the parent, under the key "related", with the child side + * stored as "back", and one document on each side linked to the other. + * + * @return array{string, string} + */ + private function createChildSideRelationship(Database $database, RelationshipType $type): array + { + $parents = 'parents_'.Id::unique(); + $children = 'children_'.Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create($parents, attributes: [Attribute::string('name', 64)], permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create($children, attributes: [Attribute::string('name', 64)], permissions: $permissions, documentSecurity: false)); + $database->createRelationship($parents, $this->childSideDefinition($type, $children)); + + $database->createDocument($children, new Document(['$id' => 'c1', 'name' => 'child'])); + $database->createDocument($parents, new Document([ + '$id' => 'p1', + 'name' => 'parent', + 'related' => $type === RelationshipType::ManyToOne ? 'c1' : ['c1'], + ])); + + return [$parents, $children]; + } + + private function childSideDefinition(RelationshipType $type, string $children): Relationship + { + return match ($type) { + RelationshipType::OneToOne => Relationship::oneToOne($children, key: 'related', twoWay: true, twoWayKey: 'back'), + RelationshipType::OneToMany => Relationship::oneToMany($children, key: 'related', twoWay: true, twoWayKey: 'back'), + RelationshipType::ManyToOne => Relationship::manyToOne($children, key: 'related', twoWay: true, twoWayKey: 'back'), + RelationshipType::ManyToMany => Relationship::manyToMany($children, key: 'related', twoWay: true, twoWayKey: 'back'), + }; + } + + private function childSideAttribute(Database $database, string $collection, string $key): ?Attribute + { + foreach ($database->getCollection($collection)->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + return null; + } + + /** + * @return list + */ + private function childSideIds(mixed $value): array + { + $ids = []; + foreach (\is_array($value) ? $value : [$value] as $item) { + if ($item instanceof Document && ! $item->isEmpty()) { + $ids[] = $item->getId(); + } elseif (\is_string($item) && $item !== '') { + $ids[] = $item; + } + } + \sort($ids); + + return $ids; + } +} diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 73783270e2..af969aa0cd 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -3,14 +3,25 @@ namespace Tests\E2E\Adapter\Scopes\Relationships; use Exception; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Structure; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Operator; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Role; +use Utopia\Query\Schema\ColumnType; trait ManyToManyTests { @@ -19,38 +30,33 @@ public function testManyToManyOneWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('playlist'); - $database->createCollection('song'); + $database->createCollection(Collection::create(id: 'playlist')); + $database->createCollection(Collection::create(id: 'song')); - $database->createAttribute('playlist', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('song', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('song', 'length', Database::VAR_INTEGER, 0, true); + $database->createAttribute('playlist', Attribute::string(key: 'name', required: true)); + $database->createAttribute('song', Attribute::string(key: 'name', required: true)); + $database->createAttribute('song', Attribute::integer(key: 'length', required: true)); - $database->createRelationship( - collection: 'playlist', - relatedCollection: 'song', - type: Database::RELATION_MANY_TO_MANY, - id: 'songs' - ); + $database->createRelationship('playlist', Relationship::manyToMany(relatedCollection: 'song', key: 'songs')); - // Check metadata for collection $collection = $database->getCollection('playlist'); - $attributes = $collection->getAttribute('attributes', []); - - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'songs') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('songs', $attribute['$id']); - $this->assertEquals('songs', $attribute['key']); - $this->assertEquals('song', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_MANY_TO_MANY, $attribute['options']['relationType']); - $this->assertEquals(false, $attribute['options']['twoWay']); - $this->assertEquals('playlist', $attribute['options']['twoWayKey']); + + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'songs') { + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('songs', $attribute->key); + $this->assertSame('song', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToMany, $relationship->type); + $this->assertSame(false, $relationship->twoWay); + $this->assertSame('playlist', $relationship->twoWayKey); } } @@ -97,33 +103,35 @@ public function testManyToManyOneWayRelationship(): void ], 'name' => 'Playlist 2', 'songs' => [ - 'song2' - ] + 'song2', + ], ])); // Update a document with non existing related document. It should not get added to the list. - $database->updateDocument('playlist', 'playlist1', $playlist1->setAttribute('songs', ['song1','no-song'])); + $database->updateDocument('playlist', 'playlist1', $playlist1->setAttribute('songs', ['song1', 'no-song'])); $playlist1Document = $database->getDocument('playlist', 'playlist1'); // Assert document does not contain non existing relation document. - $this->assertEquals(1, \count($playlist1Document->getAttribute('songs'))); + $this->assertEquals(1, \count($playlist1Document->getDocuments('songs'))); $documents = $database->find('playlist', [ Query::select(['name']), - Query::limit(1) + Query::limit(1), ]); $this->assertArrayNotHasKey('songs', $documents[0]); // Get document with relationship $playlist = $database->getDocument('playlist', 'playlist1'); - $songs = $playlist->getAttribute('songs', []); - $this->assertEquals('song1', $songs[0]['$id']); + $songs = $playlist->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('song1', $songs[0]->getId()); $this->assertArrayNotHasKey('playlist', $songs[0]); $playlist = $database->getDocument('playlist', 'playlist2'); - $songs = $playlist->getAttribute('songs', []); - $this->assertEquals('song2', $songs[0]['$id']); + $songs = $playlist->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('song2', $songs[0]->getId()); $this->assertArrayNotHasKey('playlist', $songs[0]); // Get related document @@ -139,22 +147,26 @@ public function testManyToManyOneWayRelationship(): void // Select related document attributes $playlist = $database->findOne('playlist', [ - Query::select(['*', 'songs.name']) + Query::select(['*', 'songs.name']), ]); if ($playlist->isEmpty()) { throw new Exception('Playlist not found'); } - $this->assertEquals('Song 1', $playlist->getAttribute('songs')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('length', $playlist->getAttribute('songs')[0]); + $songs = $playlist->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('Song 1', $songs[0]->getAttribute('name')); + $this->assertArrayNotHasKey('length', $songs[0]); $playlist = $database->getDocument('playlist', 'playlist1', [ - Query::select(['*', 'songs.name']) + Query::select(['*', 'songs.name']), ]); - $this->assertEquals('Song 1', $playlist->getAttribute('songs')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('length', $playlist->getAttribute('songs')[0]); + $songs = $playlist->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('Song 1', $songs[0]->getAttribute('name')); + $this->assertArrayNotHasKey('length', $songs[0]); // Update root document attribute without altering relationship $playlist1 = $database->updateDocument( @@ -168,7 +180,8 @@ public function testManyToManyOneWayRelationship(): void $this->assertEquals('Playlist 1 Updated', $playlist1->getAttribute('name')); // Update nested document attribute - $songs = $playlist1->getAttribute('songs', []); + $songs = $playlist1->getDocuments('songs'); + $this->assertNotEmpty($songs); $songs[0]->setAttribute('name', 'Song 1 Updated'); $playlist1 = $database->updateDocument( @@ -177,9 +190,13 @@ public function testManyToManyOneWayRelationship(): void $playlist1->setAttribute('songs', $songs) ); - $this->assertEquals('Song 1 Updated', $playlist1->getAttribute('songs')[0]->getAttribute('name')); + $songs = $playlist1->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('Song 1 Updated', $songs[0]->getAttribute('name')); $playlist1 = $database->getDocument('playlist', 'playlist1'); - $this->assertEquals('Song 1 Updated', $playlist1->getAttribute('songs')[0]->getAttribute('name')); + $songs = $playlist1->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('Song 1 Updated', $songs[0]->getAttribute('name')); // Create new document with no relationship $playlist5 = $database->createDocument('playlist', new Document([ @@ -220,13 +237,17 @@ public function testManyToManyOneWayRelationship(): void 'songs' => [ 'song1', 'song2', - 'song5' - ] + 'song5', + ], ])); - $this->assertEquals('Song 5', $playlist5->getAttribute('songs')[0]->getAttribute('name')); + $songs = $playlist5->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('Song 5', $songs[0]->getAttribute('name')); $playlist5 = $database->getDocument('playlist', 'playlist5'); - $this->assertEquals('Song 5', $playlist5->getAttribute('songs')[0]->getAttribute('name')); + $songs = $playlist5->getDocuments('songs'); + $this->assertNotEmpty($songs); + $this->assertEquals('Song 5', $songs[0]->getAttribute('name')); // Update document with new related document $database->updateDocument( @@ -239,13 +260,14 @@ public function testManyToManyOneWayRelationship(): void $database->updateRelationship( 'playlist', 'songs', - 'newSongs' + new RelationshipUpdate(key: 'newSongs') ); // Get document with new relationship key $playlist = $database->getDocument('playlist', 'playlist1'); - $songs = $playlist->getAttribute('newSongs'); - $this->assertEquals('song2', $songs[0]['$id']); + $songs = $playlist->getDocuments('newSongs'); + $this->assertNotEmpty($songs); + $this->assertEquals('song2', $songs[0]->getId()); // Create new document with no relationship $database->createDocument('playlist', new Document([ @@ -276,8 +298,8 @@ public function testManyToManyOneWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'playlist', - id: 'newSongs', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newSongs', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); $playlist1 = $database->getDocument('playlist', 'playlist1'); @@ -294,13 +316,13 @@ public function testManyToManyOneWayRelationship(): void // Check relation was set to null $playlist1 = $database->getDocument('playlist', 'playlist1'); - $this->assertEquals(0, \count($playlist1->getAttribute('newSongs'))); + $this->assertEquals(0, \count($playlist1->getDocuments('newSongs'))); // Change on delete to cascade $database->updateRelationship( collection: 'playlist', - id: 'newSongs', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newSongs', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -330,54 +352,40 @@ public function testManyToManyTwoWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); - return; - } - $database->createCollection('students'); - $database->createCollection('classes'); - - $database->createAttribute('students', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('classes', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('classes', 'number', Database::VAR_INTEGER, 0, true); - - $database->createRelationship( - collection: 'students', - relatedCollection: 'classes', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); - - // Check metadata for collection - $collection = $database->getCollection('students'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'students') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('students', $attribute['$id']); - $this->assertEquals('students', $attribute['key']); - $this->assertEquals('students', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_MANY_TO_MANY, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('classes', $attribute['options']['twoWayKey']); - } + return; } - // Check metadata for related collection - $collection = $database->getCollection('classes'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'classes') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('classes', $attribute['$id']); - $this->assertEquals('classes', $attribute['key']); - $this->assertEquals('classes', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_MANY_TO_MANY, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('students', $attribute['options']['twoWayKey']); - } - } + $database->createCollection(Collection::create(id: 'students')); + $database->createCollection(Collection::create(id: 'classes')); + + $database->createAttribute('students', Attribute::string(key: 'name', required: true)); + $database->createAttribute('classes', Attribute::string(key: 'name', required: true)); + $database->createAttribute('classes', Attribute::integer(key: 'number', required: true)); + + $database->createRelationship('students', Relationship::manyToMany(relatedCollection: 'classes', twoWay: true)); + + $attribute = \array_find($database->getCollection('students')->attributes(), static fn (Attribute $attribute): bool => $attribute->key === 'classes'); + $this->assertNotNull($attribute); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame(ColumnType::Relationship, $attribute->type); + $this->assertSame('classes', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToMany, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('students', $relationship->twoWayKey); + + $attribute = \array_find($database->getCollection('classes')->attributes(), static fn (Attribute $attribute): bool => $attribute->key === 'students'); + $this->assertNotNull($attribute); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame(ColumnType::Relationship, $attribute->type); + $this->assertSame('students', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToMany, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('classes', $relationship->twoWayKey); // Create document with relationship with nested data $student1 = $database->createDocument('students', new Document([ @@ -407,7 +415,7 @@ public function testManyToManyTwoWayRelationship(): void $student1Document = $database->getDocument('students', 'student1'); // Assert document does not contain non existing relation document. - $this->assertEquals(1, \count($student1Document->getAttribute('classes'))); + $this->assertEquals(1, \count($student1Document->getDocuments('classes'))); // Create document with relationship with related ID $database->createDocument('classes', new Document([ @@ -430,7 +438,7 @@ public function testManyToManyTwoWayRelationship(): void ], 'name' => 'Student 2', 'classes' => [ - 'class2' + 'class2', ], ])); @@ -453,7 +461,7 @@ public function testManyToManyTwoWayRelationship(): void Permission::delete(Role::any()), ], 'name' => 'Student 3', - ] + ], ], ])); $database->createDocument('students', new Document([ @@ -463,7 +471,7 @@ public function testManyToManyTwoWayRelationship(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Student 4' + 'name' => 'Student 4', ])); $database->createDocument('classes', new Document([ '$id' => 'class4', @@ -476,70 +484,82 @@ public function testManyToManyTwoWayRelationship(): void 'name' => 'Class 4', 'number' => 4, 'students' => [ - 'student4' + 'student4', ], ])); // Get document with relationship $student = $database->getDocument('students', 'student1'); - $classes = $student->getAttribute('classes', []); - $this->assertEquals('class1', $classes[0]['$id']); + $classes = $student->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('class1', $classes[0]->getId()); $this->assertArrayNotHasKey('students', $classes[0]); $student = $database->getDocument('students', 'student2'); - $classes = $student->getAttribute('classes', []); - $this->assertEquals('class2', $classes[0]['$id']); + $classes = $student->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('class2', $classes[0]->getId()); $this->assertArrayNotHasKey('students', $classes[0]); $student = $database->getDocument('students', 'student3'); - $classes = $student->getAttribute('classes', []); - $this->assertEquals('class3', $classes[0]['$id']); + $classes = $student->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('class3', $classes[0]->getId()); $this->assertArrayNotHasKey('students', $classes[0]); $student = $database->getDocument('students', 'student4'); - $classes = $student->getAttribute('classes', []); - $this->assertEquals('class4', $classes[0]['$id']); + $classes = $student->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('class4', $classes[0]->getId()); $this->assertArrayNotHasKey('students', $classes[0]); // Get related document $class = $database->getDocument('classes', 'class1'); - $student = $class->getAttribute('students'); - $this->assertEquals('student1', $student[0]['$id']); - $this->assertArrayNotHasKey('classes', $student[0]); + $students = $class->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('student1', $students[0]->getId()); + $this->assertArrayNotHasKey('classes', $students[0]); $class = $database->getDocument('classes', 'class2'); - $student = $class->getAttribute('students'); - $this->assertEquals('student2', $student[0]['$id']); - $this->assertArrayNotHasKey('classes', $student[0]); + $students = $class->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('student2', $students[0]->getId()); + $this->assertArrayNotHasKey('classes', $students[0]); $class = $database->getDocument('classes', 'class3'); - $student = $class->getAttribute('students'); - $this->assertEquals('student3', $student[0]['$id']); - $this->assertArrayNotHasKey('classes', $student[0]); + $students = $class->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('student3', $students[0]->getId()); + $this->assertArrayNotHasKey('classes', $students[0]); $class = $database->getDocument('classes', 'class4'); - $student = $class->getAttribute('students'); - $this->assertEquals('student4', $student[0]['$id']); - $this->assertArrayNotHasKey('classes', $student[0]); + $students = $class->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('student4', $students[0]->getId()); + $this->assertArrayNotHasKey('classes', $students[0]); // Select related document attributes $student = $database->findOne('students', [ - Query::select(['*', 'classes.name']) + Query::select(['*', 'classes.name']), ]); if ($student->isEmpty()) { throw new Exception('Student not found'); } - $this->assertEquals('Class 1', $student->getAttribute('classes')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('number', $student->getAttribute('classes')[0]); + $classes = $student->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('Class 1', $classes[0]->getAttribute('name')); + $this->assertArrayNotHasKey('number', $classes[0]); $student = $database->getDocument('students', 'student1', [ - Query::select(['*', 'classes.name']) + Query::select(['*', 'classes.name']), ]); - $this->assertEquals('Class 1', $student->getAttribute('classes')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('number', $student->getAttribute('classes')[0]); + $classes = $student->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('Class 1', $classes[0]->getAttribute('name')); + $this->assertArrayNotHasKey('number', $classes[0]); // Update root document attribute without altering relationship $student1 = $database->updateDocument( @@ -565,7 +585,8 @@ public function testManyToManyTwoWayRelationship(): void $this->assertEquals('Class 2 Updated', $class2->getAttribute('name')); // Update nested document attribute - $classes = $student1->getAttribute('classes', []); + $classes = $student1->getDocuments('classes'); + $this->assertNotEmpty($classes); $classes[0]->setAttribute('name', 'Class 1 Updated'); $student1 = $database->updateDocument( @@ -574,12 +595,17 @@ public function testManyToManyTwoWayRelationship(): void $student1->setAttribute('classes', $classes) ); - $this->assertEquals('Class 1 Updated', $student1->getAttribute('classes')[0]->getAttribute('name')); + $classes = $student1->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('Class 1 Updated', $classes[0]->getAttribute('name')); $student1 = $database->getDocument('students', 'student1'); - $this->assertEquals('Class 1 Updated', $student1->getAttribute('classes')[0]->getAttribute('name')); + $classes = $student1->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('Class 1 Updated', $classes[0]->getAttribute('name')); // Update inverse nested document attribute - $students = $class2->getAttribute('students', []); + $students = $class2->getDocuments('students'); + $this->assertNotEmpty($students); $students[0]->setAttribute('name', 'Student 2 Updated'); $class2 = $database->updateDocument( @@ -588,9 +614,13 @@ public function testManyToManyTwoWayRelationship(): void $class2->setAttribute('students', $students) ); - $this->assertEquals('Student 2 Updated', $class2->getAttribute('students')[0]->getAttribute('name')); + $students = $class2->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('Student 2 Updated', $students[0]->getAttribute('name')); $class2 = $database->getDocument('classes', 'class2'); - $this->assertEquals('Student 2 Updated', $class2->getAttribute('students')[0]->getAttribute('name')); + $students = $class2->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('Student 2 Updated', $students[0]->getAttribute('name')); // Create new document with no relationship $student5 = $database->createDocument('students', new Document([ @@ -619,9 +649,13 @@ public function testManyToManyTwoWayRelationship(): void ])]) ); - $this->assertEquals('Class 5', $student5->getAttribute('classes')[0]->getAttribute('name')); + $classes = $student5->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('Class 5', $classes[0]->getAttribute('name')); $student5 = $database->getDocument('students', 'student5'); - $this->assertEquals('Class 5', $student5->getAttribute('classes')[0]->getAttribute('name')); + $classes = $student5->getDocuments('classes'); + $this->assertNotEmpty($classes); + $this->assertEquals('Class 5', $classes[0]->getAttribute('name')); // Create child document with no relationship $class6 = $database->createDocument('classes', new Document([ @@ -650,9 +684,13 @@ public function testManyToManyTwoWayRelationship(): void ])]) ); - $this->assertEquals('Student 6', $class6->getAttribute('students')[0]->getAttribute('name')); + $students = $class6->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('Student 6', $students[0]->getAttribute('name')); $class6 = $database->getDocument('classes', 'class6'); - $this->assertEquals('Student 6', $class6->getAttribute('students')[0]->getAttribute('name')); + $students = $class6->getDocuments('students'); + $this->assertNotEmpty($students); + $this->assertEquals('Student 6', $students[0]->getAttribute('name')); // Update document with new related document $database->updateDocument( @@ -674,19 +712,23 @@ public function testManyToManyTwoWayRelationship(): void $database->updateRelationship( 'students', 'classes', - 'newClasses', - 'newStudents' + new RelationshipUpdate( + key: 'newClasses', + twoWayKey: 'newStudents', + ) ); // Get document with new relationship key - $students = $database->getDocument('students', 'student1'); - $classes = $students->getAttribute('newClasses'); - $this->assertEquals('class2', $classes[0]['$id']); + $student = $database->getDocument('students', 'student1'); + $classes = $student->getDocuments('newClasses'); + $this->assertNotEmpty($classes); + $this->assertEquals('class2', $classes[0]->getId()); // Get inverse document with new relationship key $class = $database->getDocument('classes', 'class1'); - $students = $class->getAttribute('newStudents'); - $this->assertEquals('student1', $students[0]['$id']); + $students = $class->getDocuments('newStudents'); + $this->assertNotEmpty($students); + $this->assertEquals('student1', $students[0]->getId()); // Create new document with no relationship $database->createDocument('students', new Document([ @@ -717,8 +759,8 @@ public function testManyToManyTwoWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'students', - id: 'newClasses', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newClasses', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); $student1 = $database->getDocument('students', 'student1'); @@ -735,13 +777,13 @@ public function testManyToManyTwoWayRelationship(): void // Check relation was set to null $student1 = $database->getDocument('students', 'student1'); - $this->assertEquals(0, \count($student1->getAttribute('newClasses'))); + $this->assertEquals(0, \count($student1->getDocuments('newClasses'))); // Change on delete to cascade $database->updateRelationship( collection: 'students', - id: 'newClasses', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newClasses', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -784,33 +826,27 @@ public function testNestedManyToMany_OneToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('stones'); - $database->createCollection('hearths'); - $database->createCollection('plots'); + $database->createCollection(Collection::create(id: 'stones')); + $database->createCollection(Collection::create(id: 'hearths')); + $database->createCollection(Collection::create(id: 'plots')); - $database->createAttribute('stones', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('hearths', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('plots', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('stones', Attribute::string(key: 'name', required: true)); + $database->createAttribute('hearths', Attribute::string(key: 'name', required: true)); + $database->createAttribute('plots', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'stones', - relatedCollection: 'hearths', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: 'hearths', + $database->createRelationship('stones', Relationship::manyToMany(relatedCollection: 'hearths', twoWay: true)); + $database->createRelationship('hearths', Relationship::oneToOne( relatedCollection: 'plots', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'plot', + key: 'plot', twoWayKey: 'hearth' - ); + )); $database->createDocument('stones', new Document([ '$id' => 'stone1', @@ -851,13 +887,14 @@ public function testNestedManyToMany_OneToOneRelationship(): void ])); $stone1 = $database->getDocument('stones', 'stone1'); - $this->assertEquals(2, \count($stone1['hearths'])); - $this->assertEquals('hearth1', $stone1['hearths'][0]['$id']); - $this->assertEquals('hearth2', $stone1['hearths'][1]['$id']); - $this->assertArrayNotHasKey('stone', $stone1['hearths'][0]); - $this->assertEquals('plot1', $stone1['hearths'][0]['plot']['$id']); - $this->assertEquals('plot2', $stone1['hearths'][1]['plot']['$id']); - $this->assertArrayNotHasKey('hearth', $stone1['hearths'][0]['plot']); + $hearths = $stone1->getDocuments('hearths'); + $this->assertCount(2, $hearths); + $this->assertEquals('hearth1', $hearths[0]->getId()); + $this->assertEquals('hearth2', $hearths[1]->getId()); + $this->assertArrayNotHasKey('stone', $hearths[0]); + $this->assertEquals('plot1', $hearths[0]->getDocument('plot')->getId()); + $this->assertEquals('plot2', $hearths[1]->getDocument('plot')->getId()); + $this->assertArrayNotHasKey('hearth', $hearths[0]->getDocument('plot')); $database->createDocument('plots', new Document([ '$id' => 'plot3', @@ -884,10 +921,13 @@ public function testNestedManyToMany_OneToOneRelationship(): void ])); $plot3 = $database->getDocument('plots', 'plot3'); - $this->assertEquals('hearth3', $plot3['hearth']['$id']); - $this->assertArrayNotHasKey('plot', $plot3['hearth']); - $this->assertEquals('stone2', $plot3['hearth']['stones'][0]['$id']); - $this->assertArrayNotHasKey('hearths', $plot3['hearth']['stones'][0]); + $hearth = $plot3->getDocument('hearth'); + $this->assertEquals('hearth3', $hearth->getId()); + $this->assertArrayNotHasKey('plot', $hearth); + $stones = $hearth->getDocuments('stones'); + $this->assertNotEmpty($stones); + $this->assertEquals('stone2', $stones[0]->getId()); + $this->assertArrayNotHasKey('hearths', $stones[0]); } public function testNestedManyToMany_OneToManyRelationship(): void @@ -895,33 +935,27 @@ public function testNestedManyToMany_OneToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('groups'); - $database->createCollection('tounaments'); - $database->createCollection('prizes'); + $database->createCollection(Collection::create(id: 'groups')); + $database->createCollection(Collection::create(id: 'tounaments')); + $database->createCollection(Collection::create(id: 'prizes')); - $database->createAttribute('groups', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('tounaments', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('prizes', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('groups', Attribute::string(key: 'name', required: true)); + $database->createAttribute('tounaments', Attribute::string(key: 'name', required: true)); + $database->createAttribute('prizes', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'groups', - relatedCollection: 'tounaments', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: 'tounaments', + $database->createRelationship('groups', Relationship::manyToMany(relatedCollection: 'tounaments', twoWay: true)); + $database->createRelationship('tounaments', Relationship::oneToMany( relatedCollection: 'prizes', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'prizes', + key: 'prizes', twoWayKey: 'tounament' - ); + )); $database->createDocument('groups', new Document([ '$id' => 'group1', @@ -980,14 +1014,16 @@ public function testNestedManyToMany_OneToManyRelationship(): void ])); $group1 = $database->getDocument('groups', 'group1'); - $this->assertEquals(2, \count($group1['tounaments'])); - $this->assertEquals('tounament1', $group1['tounaments'][0]['$id']); - $this->assertEquals('tounament2', $group1['tounaments'][1]['$id']); - $this->assertArrayNotHasKey('group', $group1['tounaments'][0]); - $this->assertEquals(2, \count($group1['tounaments'][0]['prizes'])); - $this->assertEquals('prize1', $group1['tounaments'][0]['prizes'][0]['$id']); - $this->assertEquals('prize2', $group1['tounaments'][0]['prizes'][1]['$id']); - $this->assertArrayNotHasKey('tounament', $group1['tounaments'][0]['prizes'][0]); + $tounaments = $group1->getDocuments('tounaments'); + $this->assertCount(2, $tounaments); + $this->assertEquals('tounament1', $tounaments[0]->getId()); + $this->assertEquals('tounament2', $tounaments[1]->getId()); + $this->assertArrayNotHasKey('group', $tounaments[0]); + $prizes = $tounaments[0]->getDocuments('prizes'); + $this->assertCount(2, $prizes); + $this->assertEquals('prize1', $prizes[0]->getId()); + $this->assertEquals('prize2', $prizes[1]->getId()); + $this->assertArrayNotHasKey('tounament', $prizes[0]); } public function testNestedManyToMany_ManyToOneRelationship(): void @@ -995,33 +1031,27 @@ public function testNestedManyToMany_ManyToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('platforms'); - $database->createCollection('games'); - $database->createCollection('publishers'); + $database->createCollection(Collection::create(id: 'platforms')); + $database->createCollection(Collection::create(id: 'games')); + $database->createCollection(Collection::create(id: 'publishers')); - $database->createAttribute('platforms', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('games', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('publishers', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('platforms', Attribute::string(key: 'name', required: true)); + $database->createAttribute('games', Attribute::string(key: 'name', required: true)); + $database->createAttribute('publishers', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'platforms', - relatedCollection: 'games', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: 'games', + $database->createRelationship('platforms', Relationship::manyToMany(relatedCollection: 'games', twoWay: true)); + $database->createRelationship('games', Relationship::manyToOne( relatedCollection: 'publishers', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'publisher', + key: 'publisher', twoWayKey: 'games' - ); + )); $database->createDocument('platforms', new Document([ '$id' => 'platform1', @@ -1058,17 +1088,18 @@ public function testNestedManyToMany_ManyToOneRelationship(): void 'name' => 'Publisher 2', ], ], - ] + ], ])); $platform1 = $database->getDocument('platforms', 'platform1'); - $this->assertEquals(2, \count($platform1['games'])); - $this->assertEquals('game1', $platform1['games'][0]['$id']); - $this->assertEquals('game2', $platform1['games'][1]['$id']); - $this->assertArrayNotHasKey('platforms', $platform1['games'][0]); - $this->assertEquals('publisher1', $platform1['games'][0]['publisher']['$id']); - $this->assertEquals('publisher2', $platform1['games'][1]['publisher']['$id']); - $this->assertArrayNotHasKey('games', $platform1['games'][0]['publisher']); + $games = $platform1->getDocuments('games'); + $this->assertCount(2, $games); + $this->assertEquals('game1', $games[0]->getId()); + $this->assertEquals('game2', $games[1]->getId()); + $this->assertArrayNotHasKey('platforms', $games[0]); + $this->assertEquals('publisher1', $games[0]->getDocument('publisher')->getId()); + $this->assertEquals('publisher2', $games[1]->getDocument('publisher')->getId()); + $this->assertArrayNotHasKey('games', $games[0]->getDocument('publisher')); $database->createDocument('publishers', new Document([ '$id' => 'publisher3', @@ -1090,18 +1121,21 @@ public function testNestedManyToMany_ManyToOneRelationship(): void Permission::read(Role::any()), ], 'name' => 'Platform 2', - ] + ], ], ], ], ])); $publisher3 = $database->getDocument('publishers', 'publisher3'); - $this->assertEquals(1, \count($publisher3['games'])); - $this->assertEquals('game3', $publisher3['games'][0]['$id']); - $this->assertArrayNotHasKey('publisher', $publisher3['games'][0]); - $this->assertEquals('platform2', $publisher3['games'][0]['platforms'][0]['$id']); - $this->assertArrayNotHasKey('games', $publisher3['games'][0]['platforms'][0]); + $games = $publisher3->getDocuments('games'); + $this->assertCount(1, $games); + $this->assertEquals('game3', $games[0]->getId()); + $this->assertArrayNotHasKey('publisher', $games[0]); + $platforms = $games[0]->getDocuments('platforms'); + $this->assertNotEmpty($platforms); + $this->assertEquals('platform2', $platforms[0]->getId()); + $this->assertArrayNotHasKey('games', $platforms[0]); } public function testNestedManyToMany_ManyToManyRelationship(): void @@ -1109,33 +1143,27 @@ public function testNestedManyToMany_ManyToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('sauces'); - $database->createCollection('pizzas'); - $database->createCollection('toppings'); + $database->createCollection(Collection::create(id: 'sauces')); + $database->createCollection(Collection::create(id: 'pizzas')); + $database->createCollection(Collection::create(id: 'toppings')); - $database->createAttribute('sauces', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('pizzas', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('toppings', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('sauces', Attribute::string(key: 'name', required: true)); + $database->createAttribute('pizzas', Attribute::string(key: 'name', required: true)); + $database->createAttribute('toppings', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'sauces', - relatedCollection: 'pizzas', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: 'pizzas', + $database->createRelationship('sauces', Relationship::manyToMany(relatedCollection: 'pizzas', twoWay: true)); + $database->createRelationship('pizzas', Relationship::manyToMany( relatedCollection: 'toppings', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'toppings', + key: 'toppings', twoWayKey: 'pizzas' - ); + )); $database->createDocument('sauces', new Document([ '$id' => 'sauce1', @@ -1190,22 +1218,25 @@ public function testNestedManyToMany_ManyToManyRelationship(): void ], ], ], - ] + ], ])); $sauce1 = $database->getDocument('sauces', 'sauce1'); - $this->assertEquals(2, \count($sauce1['pizzas'])); - $this->assertEquals('pizza1', $sauce1['pizzas'][0]['$id']); - $this->assertEquals('pizza2', $sauce1['pizzas'][1]['$id']); - $this->assertArrayNotHasKey('sauces', $sauce1['pizzas'][0]); - $this->assertEquals(2, \count($sauce1['pizzas'][0]['toppings'])); - $this->assertEquals('topping1', $sauce1['pizzas'][0]['toppings'][0]['$id']); - $this->assertEquals('topping2', $sauce1['pizzas'][0]['toppings'][1]['$id']); - $this->assertArrayNotHasKey('pizzas', $sauce1['pizzas'][0]['toppings'][0]); - $this->assertEquals(2, \count($sauce1['pizzas'][1]['toppings'])); - $this->assertEquals('topping3', $sauce1['pizzas'][1]['toppings'][0]['$id']); - $this->assertEquals('topping4', $sauce1['pizzas'][1]['toppings'][1]['$id']); - $this->assertArrayNotHasKey('pizzas', $sauce1['pizzas'][1]['toppings'][0]); + $pizzas = $sauce1->getDocuments('pizzas'); + $this->assertCount(2, $pizzas); + $this->assertEquals('pizza1', $pizzas[0]->getId()); + $this->assertEquals('pizza2', $pizzas[1]->getId()); + $this->assertArrayNotHasKey('sauces', $pizzas[0]); + $pizza1Toppings = $pizzas[0]->getDocuments('toppings'); + $this->assertCount(2, $pizza1Toppings); + $this->assertEquals('topping1', $pizza1Toppings[0]->getId()); + $this->assertEquals('topping2', $pizza1Toppings[1]->getId()); + $this->assertArrayNotHasKey('pizzas', $pizza1Toppings[0]); + $pizza2Toppings = $pizzas[1]->getDocuments('toppings'); + $this->assertCount(2, $pizza2Toppings); + $this->assertEquals('topping3', $pizza2Toppings[0]->getId()); + $this->assertEquals('topping4', $pizza2Toppings[1]->getId()); + $this->assertArrayNotHasKey('pizzas', $pizza2Toppings[0]); } public function testManyToManyRelationshipKeyWithSymbols(): void @@ -1213,42 +1244,42 @@ public function testManyToManyRelationshipKeyWithSymbols(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('$symbols_coll.ection7'); - $database->createCollection('$symbols_coll.ection8'); + $database->createCollection(Collection::create(id: '$symbols_coll.ection7')); + $database->createCollection(Collection::create(id: '$symbols_coll.ection8')); - $database->createRelationship( - collection: '$symbols_coll.ection7', - relatedCollection: '$symbols_coll.ection8', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $database->createRelationship('$symbols_coll.ection7', Relationship::manyToMany(relatedCollection: '$symbols_coll.ection8', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection8', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc2 = $database->createDocument('$symbols_coll.ection7', new Document([ - '$id' => ID::unique(), - '$symbols_coll.ection8' => [$doc1->getId()], + '$id' => Id::unique(), + 'symbols_collection8' => [$doc1->getId()], '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc1 = $database->getDocument('$symbols_coll.ection8', $doc1->getId()); $doc2 = $database->getDocument('$symbols_coll.ection7', $doc2->getId()); - $this->assertEquals($doc2->getId(), $doc1->getAttribute('$symbols_coll.ection7')[0]->getId()); - $this->assertEquals($doc1->getId(), $doc2->getAttribute('$symbols_coll.ection8')[0]->getId()); + $relatedFromDoc1 = $doc1->getDocuments('symbols_collection7'); + $this->assertNotEmpty($relatedFromDoc1); + $this->assertEquals($doc2->getId(), $relatedFromDoc1[0]->getId()); + $relatedFromDoc2 = $doc2->getDocuments('symbols_collection8'); + $this->assertNotEmpty($relatedFromDoc2); + $this->assertEquals($doc1->getId(), $relatedFromDoc2[0]->getId()); } public function testRecreateManyToManyOneWayRelationshipFromChild(): void @@ -1256,65 +1287,42 @@ public function testRecreateManyToManyOneWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - ); + $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - ); + $relationship = $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToManyTwoWayRelationshipFromParent(): void @@ -1322,67 +1330,42 @@ public function testRecreateManyToManyTwoWayRelationshipFromParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToManyTwoWayRelationshipFromChild(): void @@ -1390,67 +1373,42 @@ public function testRecreateManyToManyTwoWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToManyOneWayRelationshipFromParent(): void @@ -1458,65 +1416,42 @@ public function testRecreateManyToManyOneWayRelationshipFromParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - ); + $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - ); + $relationship = $database->createRelationship($one, Relationship::manyToMany(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testSelectManyToMany(): void @@ -1524,26 +1459,22 @@ public function testSelectManyToMany(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('select_m2m_collection1'); - $database->createCollection('select_m2m_collection2'); + $database->createCollection(Collection::create(id: 'select_m2m_collection1')); + $database->createCollection(Collection::create(id: 'select_m2m_collection2')); - $database->createAttribute('select_m2m_collection1', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('select_m2m_collection1', 'type', Database::VAR_STRING, 255, true); - $database->createAttribute('select_m2m_collection2', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('select_m2m_collection2', 'type', Database::VAR_STRING, 255, true); + $database->createAttribute('select_m2m_collection1', Attribute::string(key: 'name', required: true)); + $database->createAttribute('select_m2m_collection1', Attribute::string(key: 'type', required: true)); + $database->createAttribute('select_m2m_collection2', Attribute::string(key: 'name', required: true)); + $database->createAttribute('select_m2m_collection2', Attribute::string(key: 'type', required: true)); // Many-to-Many Relationship - $database->createRelationship( - collection: 'select_m2m_collection1', - relatedCollection: 'select_m2m_collection2', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true - ); + $database->createRelationship('select_m2m_collection1', Relationship::manyToMany(relatedCollection: 'select_m2m_collection2', twoWay: true)); // Create documents in the first collection $doc1 = $database->createDocument('select_m2m_collection1', new Document([ @@ -1588,7 +1519,7 @@ public function testSelectManyToMany(): void $this->assertEquals('Document 1', $docs[0]->getAttribute('name')); $this->assertArrayNotHasKey('type', $docs[0]); - $relatedDocs = $docs[0]->getAttribute('select_m2m_collection2'); + $relatedDocs = $docs[0]->getDocuments('select_m2m_collection2'); $this->assertCount(2, $relatedDocs); $this->assertEquals('Related Document 1', $relatedDocs[0]->getAttribute('name')); @@ -1602,51 +1533,42 @@ public function testSelectAcrossMultipleCollections(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Create collections - $database->createCollection('artists', permissions: [ + $database->createCollection(Collection::create(id: 'artists', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ], documentSecurity: false); - $database->createCollection('albums', permissions: [ + Permission::delete(Role::any()), + ], documentSecurity: false)); + $database->createCollection(Collection::create(id: 'albums', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ], documentSecurity: false); - $database->createCollection('tracks', permissions: [ + Permission::delete(Role::any()), + ], documentSecurity: false)); + $database->createCollection(Collection::create(id: 'tracks', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ], documentSecurity: false); + Permission::delete(Role::any()), + ], documentSecurity: false)); // Add attributes - $database->createAttribute('artists', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('albums', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('tracks', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('tracks', 'duration', Database::VAR_INTEGER, 0, true); + $database->createAttribute('artists', Attribute::string(key: 'name', required: true)); + $database->createAttribute('albums', Attribute::string(key: 'name', required: true)); + $database->createAttribute('tracks', Attribute::string(key: 'title', required: true)); + $database->createAttribute('tracks', Attribute::integer(key: 'duration', required: true)); // Create relationships - $database->createRelationship( - collection: 'artists', - relatedCollection: 'albums', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true - ); + $database->createRelationship('artists', Relationship::manyToMany(relatedCollection: 'albums', twoWay: true)); - $database->createRelationship( - collection: 'albums', - relatedCollection: 'tracks', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true - ); + $database->createRelationship('albums', Relationship::manyToMany(relatedCollection: 'tracks', twoWay: true)); // Create documents $database->createDocument('artists', new Document([ @@ -1666,8 +1588,8 @@ public function testSelectAcrossMultipleCollections(): void '$id' => 'track2', 'title' => 'Hit Song 2', 'duration' => 220, - ] - ] + ], + ], ], [ '$id' => 'album2', @@ -1677,15 +1599,15 @@ public function testSelectAcrossMultipleCollections(): void '$id' => 'track3', 'title' => 'Ballad 3', 'duration' => 240, - ] - ] - ] - ] + ], + ], + ], + ], ])); // Query with nested select $artists = $database->find('artists', [ - Query::select(['name', 'albums.name', 'albums.tracks.title']) + Query::select(['name', 'albums.name', 'albums.tracks.title']), ]); $this->assertCount(1, $artists); @@ -1693,29 +1615,29 @@ public function testSelectAcrossMultipleCollections(): void $this->assertEquals('The Great Artist', $artist->getAttribute('name')); $this->assertArrayHasKey('albums', $artist->getArrayCopy()); - $albums = $artist->getAttribute('albums'); + $albums = $artist->getDocuments('albums'); $this->assertCount(2, $albums); $album1 = $albums[0]; $this->assertEquals('First Album', $album1->getAttribute('name')); - $this->assertArrayHasKey('tracks', $album1->getArrayCopy()); - $this->assertArrayNotHasKey('artists', $album1->getArrayCopy()); + $this->assertArrayHasKey('tracks', $album1); + $this->assertArrayNotHasKey('artists', $album1); $album2 = $albums[1]; $this->assertEquals('Second Album', $album2->getAttribute('name')); - $this->assertArrayHasKey('tracks', $album2->getArrayCopy()); + $this->assertArrayHasKey('tracks', $album2); - $album1Tracks = $album1->getAttribute('tracks'); + $album1Tracks = $album1->getDocuments('tracks'); $this->assertCount(2, $album1Tracks); $this->assertEquals('Hit Song 1', $album1Tracks[0]->getAttribute('title')); - $this->assertArrayNotHasKey('duration', $album1Tracks[0]->getArrayCopy()); + $this->assertArrayNotHasKey('duration', $album1Tracks[0]); $this->assertEquals('Hit Song 2', $album1Tracks[1]->getAttribute('title')); - $this->assertArrayNotHasKey('duration', $album1Tracks[1]->getArrayCopy()); + $this->assertArrayNotHasKey('duration', $album1Tracks[1]); - $album2Tracks = $album2->getAttribute('tracks'); + $album2Tracks = $album2->getDocuments('tracks'); $this->assertCount(1, $album2Tracks); $this->assertEquals('Ballad 3', $album2Tracks[0]->getAttribute('title')); - $this->assertArrayNotHasKey('duration', $album2Tracks[0]->getArrayCopy()); + $this->assertArrayNotHasKey('duration', $album2Tracks[0]); } public function testDeleteBulkDocumentsManyToManyRelationship(): void @@ -1723,25 +1645,21 @@ public function testDeleteBulkDocumentsManyToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_m2m'); - $this->getDatabase()->createCollection('bulk_delete_library_m2m'); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_person_m2m')); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_library_m2m')); - $this->getDatabase()->createAttribute('bulk_delete_person_m2m', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_m2m', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_m2m', 'area', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_person_m2m', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_m2m', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_m2m', Attribute::string(key: 'area', required: true)); // Many-to-Many Relationship - $this->getDatabase()->createRelationship( - collection: 'bulk_delete_person_m2m', - relatedCollection: 'bulk_delete_library_m2m', - type: Database::RELATION_MANY_TO_MANY, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $this->getDatabase()->createRelationship('bulk_delete_person_m2m', Relationship::manyToMany(relatedCollection: 'bulk_delete_library_m2m')); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_m2m', new Document([ '$id' => 'person1', @@ -1776,7 +1694,7 @@ public function testDeleteBulkDocumentsManyToManyRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_m2m', 'person1'); - $libraries = $person1->getAttribute('bulk_delete_library_m2m'); + $libraries = $person1->getDocuments('bulk_delete_library_m2m'); $this->assertCount(2, $libraries); // Delete person @@ -1795,36 +1713,32 @@ public function testDeleteBulkDocumentsManyToManyRelationship(): void $this->getDatabase()->deleteDocuments('bulk_delete_person_m2m'); $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_m2m')); } + public function testUpdateParentAndChild_ManyToMany(): void { /** @var Database $database */ $database = $this->getDatabase(); if ( - !$database->getAdapter()->getSupportForRelationships() || - !$database->getAdapter()->getSupportForBatchOperations() + ! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || + ! $this->supportsBulkWrites() ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_m2m'; $childCollection = 'child_combined_m2m'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); - - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'parentNumber', Database::VAR_INTEGER, 0, false); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::integer(key: 'parentNumber')); - $database->createRelationship( - collection: $parentCollection, - relatedCollection: $childCollection, - type: Database::RELATION_MANY_TO_MANY, - id: 'parentNumber' - ); + $database->createRelationship($parentCollection, Relationship::manyToMany(relatedCollection: $childCollection, key: 'parentNumber')); $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -1879,31 +1793,26 @@ public function testUpdateParentAndChild_ManyToMany(): void $database->deleteCollection($childCollection); } - public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToMany(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_relationship_many_to_many'; $childCollection = 'child_relationship_many_to_many'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: $parentCollection, - relatedCollection: $childCollection, - type: Database::RELATION_MANY_TO_MANY, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $database->createRelationship($parentCollection, Relationship::manyToMany(relatedCollection: $childCollection)); $parent = $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -1922,8 +1831,8 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToMa Permission::delete(Role::any()), ], 'name' => 'Child 1', - ] - ] + ], + ], ])); try { @@ -1945,27 +1854,26 @@ public function testPartialUpdateManyToManyBothSides(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('partial_students'); - $database->createCollection('partial_courses'); + $database->createCollection(Collection::create(id: 'partial_students')); + $database->createCollection(Collection::create(id: 'partial_courses')); - $database->createAttribute('partial_students', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('partial_students', 'grade', Database::VAR_STRING, 10, false); - $database->createAttribute('partial_courses', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('partial_courses', 'credits', Database::VAR_INTEGER, 0, false); + $database->createAttribute('partial_students', Attribute::string(key: 'name', required: true)); + $database->createAttribute('partial_students', Attribute::string(key: 'grade', size: 10)); + $database->createAttribute('partial_courses', Attribute::string(key: 'title', required: true)); + $database->createAttribute('partial_courses', Attribute::integer(key: 'credits')); - $database->createRelationship( - collection: 'partial_students', + $database->createRelationship('partial_students', Relationship::manyToMany( relatedCollection: 'partial_courses', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'partial_courses', + key: 'partial_courses', twoWayKey: 'partial_students' - ); + )); // Create student with courses $database->createDocument('partial_students', new Document([ @@ -1990,7 +1898,7 @@ public function testPartialUpdateManyToManyBothSides(): void $student = $database->getDocument('partial_students', 'student1'); $this->assertEquals('David', $student->getAttribute('name'), 'Name should be preserved'); $this->assertEquals('A+', $student->getAttribute('grade'), 'Grade should be updated'); - $this->assertCount(2, $student->getAttribute('partial_courses'), 'Courses should be preserved'); + $this->assertCount(2, $student->getDocuments('partial_courses'), 'Courses should be preserved'); // Partial update from course side - update credits only, preserve students $database->updateDocument('partial_courses', 'course1', new Document([ @@ -2003,7 +1911,7 @@ public function testPartialUpdateManyToManyBothSides(): void $course = $database->getDocument('partial_courses', 'course1'); $this->assertEquals('Math', $course->getAttribute('title'), 'Title should be preserved'); $this->assertEquals(5, $course->getAttribute('credits'), 'Credits should be updated'); - $this->assertCount(1, $course->getAttribute('partial_students'), 'Students should be preserved'); + $this->assertCount(1, $course->getDocuments('partial_students'), 'Students should be preserved'); $database->deleteCollection('partial_students'); $database->deleteCollection('partial_courses'); @@ -2014,27 +1922,26 @@ public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('tags'); - $database->createCollection('articles'); + $database->createCollection(Collection::create(id: 'tags')); + $database->createCollection(Collection::create(id: 'articles')); - $database->createAttribute('tags', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('tags', 'color', Database::VAR_STRING, 50, false); - $database->createAttribute('articles', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('articles', 'published', Database::VAR_BOOLEAN, 0, false); + $database->createAttribute('tags', Attribute::string(key: 'name', required: true)); + $database->createAttribute('tags', Attribute::string(key: 'color', size: 50)); + $database->createAttribute('articles', Attribute::string(key: 'title', required: true)); + $database->createAttribute('articles', Attribute::boolean(key: 'published')); - $database->createRelationship( - collection: 'articles', + $database->createRelationship('articles', Relationship::manyToMany( relatedCollection: 'tags', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'tags', + key: 'tags', twoWayKey: 'articles' - ); + )); // Create article with tags $database->createDocument('articles', new Document([ @@ -2065,7 +1972,7 @@ public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void $article = $database->getDocument('articles', 'article1'); $this->assertEquals('Great Article', $article->getAttribute('title')); $this->assertFalse($article->getAttribute('published')); - $this->assertCount(2, $article->getAttribute('tags')); + $this->assertCount(2, $article->getDocuments('tags')); // Update from tag side using DOCUMENT objects $database->createDocument('articles', new Document([ @@ -2088,7 +1995,7 @@ public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void $tag = $database->getDocument('tags', 'tag1'); $this->assertEquals('Tech', $tag->getAttribute('name')); $this->assertEquals('blue', $tag->getAttribute('color')); - $this->assertCount(2, $tag->getAttribute('articles')); + $this->assertCount(2, $tag->getDocuments('articles')); $database->deleteCollection('tags'); $database->deleteCollection('articles'); @@ -2097,158 +2004,90 @@ public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void public function testManyToManyRelationshipWithArrayOperators(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); - return; - } - if (!$database->getAdapter()->getSupportForOperators()) { - $this->expectNotToPerformAssertions(); return; } - // Cleanup any leftover collections from previous runs - try { - $database->deleteCollection('library'); - } catch (\Throwable $e) { - } - try { - $database->deleteCollection('book'); - } catch (\Throwable $e) { - } - - $database->createCollection('library'); - $database->createCollection('book'); + $database->createCollection(Collection::create(id: 'operator_library')); + $database->createCollection(Collection::create(id: 'operator_book')); - $database->createAttribute('library', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('book', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('operator_library', Attribute::string(key: 'name', required: true)); + $database->createAttribute('operator_book', Attribute::string(key: 'title', required: true)); - $database->createRelationship( - collection: 'library', - relatedCollection: 'book', - type: Database::RELATION_MANY_TO_MANY, + $database->createRelationship('operator_library', Relationship::manyToMany( + relatedCollection: 'operator_book', twoWay: true, - id: 'books', - twoWayKey: 'libraries' - ); + key: 'books', + twoWayKey: 'libraries', + )); - // Create some books - $book1 = $database->createDocument('book', new Document([ - '$id' => 'book1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Book 1', - ])); - - $book2 = $database->createDocument('book', new Document([ - '$id' => 'book2', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Book 2', - ])); - - $book3 = $database->createDocument('book', new Document([ - '$id' => 'book3', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Book 3', - ])); - - $book4 = $database->createDocument('book', new Document([ - '$id' => 'book4', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Book 4', - ])); + $permissions = [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ]; + + foreach (['book1' => 'Book 1', 'book2' => 'Book 2', 'book3' => 'Book 3', 'book4' => 'Book 4'] as $id => $title) { + $database->createDocument('operator_book', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'title' => $title, + ])); + } - // Create library with one book - $library = $database->createDocument('library', new Document([ + $library = $database->createDocument('operator_library', new Document([ '$id' => 'library1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], + '$permissions' => $permissions, 'name' => 'Library 1', 'books' => ['book1'], ])); - $this->assertCount(1, $library->getAttribute('books')); - $this->assertEquals('book1', $library->getAttribute('books')[0]->getId()); + $this->assertCount(1, $library->getDocuments('books')); + $this->assertSame('book1', $library->getDocuments('books')[0]->getId()); - // Test arrayAppend - add a single book - $library = $database->updateDocument('library', 'library1', new Document([ - 'books' => \Utopia\Database\Operator::arrayAppend(['book2']), - ])); + $relatedIds = function (string $collection, string $id, string $key) use ($database): array { + $ids = \array_map( + fn (Document $related): string => $related->getId(), + $database->getDocument($collection, $id)->getDocuments($key), + ); + \sort($ids); - $library = $database->getDocument('library', 'library1'); - $this->assertCount(2, $library->getAttribute('books')); - $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); - $this->assertContains('book1', $bookIds); - $this->assertContains('book2', $bookIds); + return $ids; + }; - // Test arrayAppend - add multiple books - $library = $database->updateDocument('library', 'library1', new Document([ - 'books' => \Utopia\Database\Operator::arrayAppend(['book3', 'book4']), + $database->updateDocument('operator_library', 'library1', new Document([ + 'books' => Operator::arrayAppend(['book2']), ])); + $this->assertSame(['book1', 'book2'], $relatedIds('operator_library', 'library1', 'books')); - $library = $database->getDocument('library', 'library1'); - $this->assertCount(4, $library->getAttribute('books')); - $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); - $this->assertContains('book1', $bookIds); - $this->assertContains('book2', $bookIds); - $this->assertContains('book3', $bookIds); - $this->assertContains('book4', $bookIds); - - // Test arrayRemove - remove a single book - $library = $database->updateDocument('library', 'library1', new Document([ - 'books' => \Utopia\Database\Operator::arrayRemove('book2'), + $database->updateDocument('operator_library', 'library1', new Document([ + 'books' => Operator::arrayAppend(['book3', 'book4']), ])); + $this->assertSame(['book1', 'book2', 'book3', 'book4'], $relatedIds('operator_library', 'library1', 'books')); - $library = $database->getDocument('library', 'library1'); - $this->assertCount(3, $library->getAttribute('books')); - $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); - $this->assertContains('book1', $bookIds); - $this->assertNotContains('book2', $bookIds); - $this->assertContains('book3', $bookIds); - $this->assertContains('book4', $bookIds); - - // Test arrayRemove - remove multiple books at once - $library = $database->updateDocument('library', 'library1', new Document([ - 'books' => \Utopia\Database\Operator::arrayRemove(['book3', 'book4']), + $database->updateDocument('operator_library', 'library1', new Document([ + 'books' => Operator::arrayRemove('book2'), ])); + $this->assertSame(['book1', 'book3', 'book4'], $relatedIds('operator_library', 'library1', 'books')); - $library = $database->getDocument('library', 'library1'); - $this->assertCount(1, $library->getAttribute('books')); - $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); - $this->assertContains('book1', $bookIds); - $this->assertNotContains('book3', $bookIds); - $this->assertNotContains('book4', $bookIds); - - // Test arrayPrepend - add books - // Note: Order is not guaranteed for many-to-many relationships as they use junction tables - $library = $database->updateDocument('library', 'library1', new Document([ - 'books' => \Utopia\Database\Operator::arrayPrepend(['book2']), + $database->updateDocument('operator_library', 'library1', new Document([ + 'books' => Operator::arrayRemove(['book3', 'book4']), ])); + $this->assertSame(['book1'], $relatedIds('operator_library', 'library1', 'books')); + $this->assertSame([], $relatedIds('operator_book', 'book3', 'libraries')); + $this->assertSame([], $relatedIds('operator_book', 'book4', 'libraries')); - $library = $database->getDocument('library', 'library1'); - $this->assertCount(2, $library->getAttribute('books')); - $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); - $this->assertContains('book1', $bookIds); - $this->assertContains('book2', $bookIds); + $database->updateDocument('operator_library', 'library1', new Document([ + 'books' => Operator::arrayPrepend(['book2']), + ])); + $this->assertSame(['book1', 'book2'], $relatedIds('operator_library', 'library1', 'books')); + $this->assertSame(['library1'], $relatedIds('operator_book', 'book2', 'libraries')); - // Cleanup - $database->deleteCollection('library'); - $database->deleteCollection('book'); + $database->deleteCollection('operator_library'); + $database->deleteCollection('operator_book'); } /** @@ -2261,70 +2100,75 @@ public function testNestedManyToManyRelationshipQueries(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } + // Clean up if collections already exist from other tests + foreach (['brands', 'products', 'tags'] as $col) { + try { + $database->deleteCollection($col); + } catch (\Throwable) { + } + } + // 3-level many-to-many chain: brands <-> products <-> tags - $database->createCollection('brands'); - $database->createCollection('products'); - $database->createCollection('tags'); + $database->createCollection(Collection::create(id: 'brands')); + $database->createCollection(Collection::create(id: 'products')); + $database->createCollection(Collection::create(id: 'tags')); - $database->createAttribute('brands', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('products', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('tags', 'label', Database::VAR_STRING, 255, true); + $database->createAttribute('brands', Attribute::string(key: 'name', required: true)); + $database->createAttribute('products', Attribute::string(key: 'title', required: true)); + $database->createAttribute('tags', Attribute::string(key: 'label', required: true)); - $database->createRelationship( - collection: 'brands', + $database->createRelationship('brands', Relationship::manyToMany( relatedCollection: 'products', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'products', - twoWayKey: 'brands', - ); + key: 'products', + twoWayKey: 'brands' + )); - $database->createRelationship( - collection: 'products', + $database->createRelationship('products', Relationship::manyToMany( relatedCollection: 'tags', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'tags', - twoWayKey: 'products', - ); + key: 'tags', + twoWayKey: 'products' + )); // Seed data $database->createDocument('tags', new Document([ '$id' => 'tag_eco', - '$permissions' => [Permission::read(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'label' => 'Eco-Friendly', ])); $database->createDocument('tags', new Document([ '$id' => 'tag_premium', - '$permissions' => [Permission::read(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'label' => 'Premium', ])); $database->createDocument('tags', new Document([ '$id' => 'tag_sale', - '$permissions' => [Permission::read(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'label' => 'Sale', ])); $database->createDocument('products', new Document([ '$id' => 'prod_a', - '$permissions' => [Permission::read(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Product A', 'tags' => ['tag_eco', 'tag_premium'], ])); $database->createDocument('products', new Document([ '$id' => 'prod_b', - '$permissions' => [Permission::read(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Product B', 'tags' => ['tag_sale'], ])); $database->createDocument('products', new Document([ '$id' => 'prod_c', - '$permissions' => [Permission::read(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Product C', 'tags' => ['tag_eco'], ])); @@ -2342,14 +2186,14 @@ public function testNestedManyToManyRelationshipQueries(): void 'products' => ['prod_c'], ])); - // --- 1-level deep: query brands by product title (many-to-many) --- + // 1-level deep: query brands by product title (many-to-many) $brands = $database->find('brands', [ Query::equal('products.title', ['Product A']), ]); $this->assertCount(1, $brands); $this->assertEquals('brand_x', $brands[0]->getId()); - // --- 2-level deep: query brands by product→tag label (many-to-many→many-to-many) --- + // 2-level deep: query brands by product→tag label (many-to-many→many-to-many) // "Eco-Friendly" tag is on prod_a (BrandX) and prod_c (BrandY) $brands = $database->find('brands', [ Query::equal('products.tags.label', ['Eco-Friendly']), @@ -2373,7 +2217,7 @@ public function testNestedManyToManyRelationshipQueries(): void $this->assertCount(1, $brands); $this->assertEquals('brand_x', $brands[0]->getId()); - // --- 2-level deep from the child side: query tags by product→brand name --- + // 2-level deep from the child side: query tags by product→brand name $tags = $database->find('tags', [ Query::equal('products.brands.name', ['BrandY']), ]); @@ -2389,7 +2233,7 @@ public function testNestedManyToManyRelationshipQueries(): void $this->assertContains('tag_premium', $tagIds); $this->assertContains('tag_sale', $tagIds); - // --- No match returns empty --- + // No match returns empty $brands = $database->find('brands', [ Query::equal('products.tags.label', ['NonExistent']), ]); @@ -2400,4 +2244,322 @@ public function testNestedManyToManyRelationshipQueries(): void $database->deleteCollection('products'); $database->deleteCollection('tags'); } + + public function testDeleteDocumentsWithASelectCascadesToChildren_ManyToMany(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'm2m_select_cascade_parent'; + $childCollection = 'm2m_select_cascade_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($parentCollection, Relationship::manyToMany(relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::Cascade)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}"])); + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}", 'children' => ["child{$suffix}"]])); + } + + $deleted = $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + + $this->assertSame(1, $deleted); + $this->assertSame(['parent1'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')])), "The deleted parent's child must be deleted with it"); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } + + public function testDeleteDocumentsWithASelectHonoursRestrict_ManyToMany(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'm2m_select_restrict_parent'; + $childCollection = 'm2m_select_restrict_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($parentCollection, Relationship::manyToMany(relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::Restrict)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}"])); + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}", 'children' => ["child{$suffix}"]])); + } + + try { + $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + $this->fail('Deleting a parent with a related document must be restricted'); + } catch (RestrictedException $exception) { + $this->assertSame('Cannot delete document because it has at least one related document.', $exception->getMessage()); + } + + $this->assertSame(['parent1', 'parent2'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1', 'child2'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')]))); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } + + public function testLinkingAManyToManyDocumentNeedsUpdatePermission(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $grandparents = Id::unique(); + $parents = Id::unique(); + $tags = Id::unique(); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create(id: $grandparents, permissions: $permissions)); + $database->createCollection(Collection::create(id: $parents, permissions: $permissions)); + $database->createCollection(Collection::create(id: $tags, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship($grandparents, Relationship::oneToOne(relatedCollection: $parents, key: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship($parents, Relationship::manyToMany(relatedCollection: $tags, twoWay: true, key: 'tags', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)); + + $database->createDocument($tags, new Document([ + '$id' => 'readonly', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::user('tagAdmin'))], + ])); + $database->createDocument($parents, new Document(['$id' => 'parent1'])); + $database->createDocument($grandparents, new Document(['$id' => 'grandparent1', 'parent' => 'parent1'])); + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + $links = [ + 'an ID through an update' => fn () => $database->updateDocument($parents, 'parent1', new Document(['tags' => ['readonly']])), + 'a document through an update' => fn () => $database->updateDocument($parents, 'parent1', new Document(['tags' => [new Document(['$id' => 'readonly'])]])), + 'an ID through a nested update' => fn () => $database->updateDocument($grandparents, 'grandparent1', new Document([ + 'parent' => new Document(['$id' => 'parent1', 'tags' => ['readonly']]), + ])), + 'an ID through a create' => fn () => $database->createDocument($parents, new Document(['$id' => 'parent2', 'tags' => ['readonly']])), + 'a document through a create' => fn () => $database->createDocument($parents, new Document(['$id' => 'parent3', 'tags' => [new Document(['$id' => 'readonly'])]])), + 'an ID through a nested create' => fn () => $database->createDocument($grandparents, new Document([ + '$id' => 'grandparent2', + 'parent' => new Document(['$id' => 'parent4', 'tags' => ['readonly']]), + ])), + ]; + + try { + foreach ($links as $link => $write) { + try { + $write(); + $this->fail("Linking {$link} to a document the caller may not update must be rejected"); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "update" permission for role "user:tagAdmin". Only "["any"]" scopes are allowed and "["user:tagAdmin"]" was given.', $exception->getMessage(), $link); + } + } + + $this->assertSame([], $database->getDocument($parents, 'parent1')->getAttribute('tags')); + $this->assertSame([], $database->getDocument($tags, 'readonly')->getAttribute('parents')); + $this->assertSame(['parent1'], \array_map(fn (Document $parent) => $parent->getId(), $database->find($parents))); + + $authorization->addRole(Role::user('tagAdmin')->toString()); + + $database->updateDocument($parents, 'parent1', new Document(['tags' => ['readonly']])); + + $this->assertSame(['readonly'], \array_map(fn (Document $tag) => $tag->getId(), $database->getDocument($parents, 'parent1')->getDocuments('tags'))); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + $database->deleteCollection($grandparents); + $database->deleteCollection($parents); + $database->deleteCollection($tags); + } + } + + public function testKeepingOrUnlinkingAManyToManyDocumentNeedsNoUpdatePermission(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parents = Id::unique(); + $tags = Id::unique(); + $database->createCollection(Collection::create(id: $parents, attributes: [Attribute::string(key: 'name', size: 64)], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $tags, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship($parents, Relationship::manyToMany(relatedCollection: $tags, twoWay: true, key: 'tags', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)); + + $database->createDocument($tags, new Document([ + '$id' => 'readonly', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::user('keepTagAdmin'))], + ])); + $database->getAuthorization()->skip(fn () => $database->createDocument($parents, new Document(['$id' => 'parent1', 'tags' => ['readonly']]))); + + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + $database->updateDocument($parents, 'parent1', new Document(['name' => 'kept', 'tags' => ['readonly']])); + $this->assertSame(['readonly'], \array_map(fn (Document $tag) => $tag->getId(), $database->getDocument($parents, 'parent1')->getDocuments('tags'))); + + $database->updateDocument($parents, 'parent1', new Document(['tags' => []])); + $this->assertSame([], $database->getDocument($parents, 'parent1')->getAttribute('tags')); + $this->assertSame([], $database->getDocument($tags, 'readonly')->getAttribute('parents')); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + $database->deleteCollection($parents); + $database->deleteCollection($tags); + } + } + + public function testRenamingAOneWayManyToManyTwoWayKeyKeepsRelations(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $books = Id::unique(); + $authors = Id::unique(); + $this->createManyToManyRenameCollections($database, $books, $authors); + $database->createRelationship($books, Relationship::manyToMany(relatedCollection: $authors, key: 'authors', twoWayKey: 'books')); + + try { + $database->createDocument($books, new Document(['$id' => 'dune', 'authors' => [new Document(['$id' => 'herbert'])]])); + + $this->assertSame('works', $database->updateRelationship($books, 'authors', new RelationshipUpdate(twoWayKey: 'works'))->twoWayKey); + + $this->assertSame(['herbert'], $this->relatedDocumentIds($database, $books, 'dune', 'authors')); + + $database->createDocument($books, new Document(['$id' => 'emma', 'authors' => [new Document(['$id' => 'austen']), 'herbert']])); + + $this->assertSame(['austen', 'herbert'], $this->relatedDocumentIds($database, $books, 'emma', 'authors')); + $this->assertSame(['herbert'], $this->relatedDocumentIds($database, $books, 'dune', 'authors')); + + $this->assertSame('writers', $database->updateRelationship($books, 'authors', new RelationshipUpdate(key: 'writers'))->key); + + $this->assertSame(['austen', 'herbert'], $this->relatedDocumentIds($database, $books, 'emma', 'writers')); + } finally { + $database->deleteCollection($books); + $database->deleteCollection($authors); + } + } + + public function testRenamingATwoWayManyToManyKeyFromTheChildSideKeepsRelations(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $books = Id::unique(); + $authors = Id::unique(); + $this->createManyToManyRenameCollections($database, $books, $authors); + $database->createRelationship($books, Relationship::manyToMany(relatedCollection: $authors, twoWay: true, key: 'authors', twoWayKey: 'books')); + + try { + $database->createDocument($books, new Document(['$id' => 'dune', 'authors' => [new Document(['$id' => 'herbert'])]])); + + $this->assertSame('works', $database->updateRelationship($authors, 'books', new RelationshipUpdate(key: 'works'))->key); + + $this->assertSame(['dune'], $this->relatedDocumentIds($database, $authors, 'herbert', 'works')); + $this->assertSame(['herbert'], $this->relatedDocumentIds($database, $books, 'dune', 'authors')); + + $database->createDocument($books, new Document(['$id' => 'emma', 'authors' => ['herbert']])); + + $this->assertSame(['dune', 'emma'], $this->relatedDocumentIds($database, $authors, 'herbert', 'works')); + } finally { + $database->deleteCollection($books); + $database->deleteCollection($authors); + } + } + + private function createManyToManyRenameCollections(Database $database, string $books, string $authors): void + { + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $books, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $authors, permissions: $permissions, documentSecurity: false)); + } + + /** + * @return list + */ + private function relatedDocumentIds(Database $database, string $collection, string $id, string $key): array + { + $ids = \array_map(fn (Document $document) => $document->getId(), $database->getDocument($collection, $id)->getDocuments($key)); + \sort($ids); + + return $ids; + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php index 498780a564..25cd09b171 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php @@ -3,14 +3,22 @@ namespace Tests\E2E\Adapter\Scopes\Relationships; use Exception; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Structure; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Role; +use Utopia\Query\Schema\ColumnType; trait ManyToOneTests { @@ -19,53 +27,49 @@ public function testManyToOneOneWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('review'); - $database->createCollection('movie'); + $database->createCollection(Collection::create(id: 'review')); + $database->createCollection(Collection::create(id: 'movie')); - $database->createAttribute('review', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('movie', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('movie', 'length', Database::VAR_INTEGER, 0, true, formatOptions: ['min' => 0, 'max' => 999]); - $database->createAttribute('movie', 'date', Database::VAR_DATETIME, 0, false, filters: ['datetime']); - $database->createAttribute('review', 'date', Database::VAR_DATETIME, 0, false, filters: ['datetime']); - $database->createRelationship( - collection: 'review', - relatedCollection: 'movie', - type: Database::RELATION_MANY_TO_ONE, - twoWayKey: 'reviews' - ); + $database->createAttribute('review', Attribute::string(key: 'name', required: true)); + $database->createAttribute('movie', Attribute::string(key: 'name', required: true)); + $database->createAttribute('movie', Attribute::integer(key: 'length', required: true)); + $database->createAttribute('movie', Attribute::datetime(key: 'date')); + $database->createAttribute('review', Attribute::datetime(key: 'date')); + $database->createRelationship('review', Relationship::manyToOne(relatedCollection: 'movie', twoWayKey: 'reviews')); // Check metadata for collection $collection = $database->getCollection('review'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'movie') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('movie', $attribute['$id']); - $this->assertEquals('movie', $attribute['key']); - $this->assertEquals('movie', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_MANY_TO_ONE, $attribute['options']['relationType']); - $this->assertEquals(false, $attribute['options']['twoWay']); - $this->assertEquals('reviews', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'movie') { + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('movie', $attribute->key); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame('movie', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToOne, $relationship->type); + $this->assertSame(false, $relationship->twoWay); + $this->assertSame('reviews', $relationship->twoWayKey); } } // Check metadata for related collection $collection = $database->getCollection('movie'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'reviews') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('reviews', $attribute['$id']); - $this->assertEquals('reviews', $attribute['key']); - $this->assertEquals('review', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_MANY_TO_ONE, $attribute['options']['relationType']); - $this->assertEquals(false, $attribute['options']['twoWay']); - $this->assertEquals('movie', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'reviews') { + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('reviews', $attribute->key); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame('review', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToOne, $relationship->type); + $this->assertSame(false, $relationship->twoWay); + $this->assertSame('movie', $relationship->twoWayKey); } } @@ -140,12 +144,12 @@ public function testManyToOneOneWayRelationship(): void // Get document with relationship $review = $database->getDocument('review', 'review1'); - $movie = $review->getAttribute('movie', []); - $this->assertEquals('movie1', $movie['$id']); + $movie = $review->getDocument('movie'); + $this->assertEquals('movie1', $movie->getId()); $this->assertArrayNotHasKey('reviews', $movie); $documents = $database->find('review', [ - Query::select(['date', 'movie.date']) + Query::select(['date', 'movie.date']), ]); $this->assertCount(3, $documents); @@ -153,14 +157,18 @@ public function testManyToOneOneWayRelationship(): void $document = $documents[0]; $this->assertArrayHasKey('date', $document); $this->assertArrayHasKey('movie', $document); - $this->assertArrayHasKey('date', $document->getAttribute('movie')); + $this->assertArrayHasKey('date', $document->getDocument('movie')); $this->assertArrayNotHasKey('name', $document); - $this->assertEquals(29, strlen($document['date'])); // checks filter - $this->assertEquals(29, strlen($document['movie']['date'])); + $date = $document->getAttribute('date'); + $this->assertIsString($date); + $this->assertEquals(29, strlen($date)); + $movieDate = $document->getDocument('movie')->getAttribute('date'); + $this->assertIsString($movieDate); + $this->assertEquals(29, strlen($movieDate)); $review = $database->getDocument('review', 'review2'); - $movie = $review->getAttribute('movie', []); - $this->assertEquals('movie2', $movie['$id']); + $movie = $review->getDocument('movie'); + $this->assertEquals('movie2', $movie->getId()); $this->assertArrayNotHasKey('reviews', $movie); // Get related document @@ -176,22 +184,22 @@ public function testManyToOneOneWayRelationship(): void // Select related document attributes $review = $database->findOne('review', [ - Query::select(['*', 'movie.name']) + Query::select(['*', 'movie.name']), ]); if ($review->isEmpty()) { throw new Exception('Review not found'); } - $this->assertEquals('Movie 1', $review->getAttribute('movie')->getAttribute('name')); - $this->assertArrayNotHasKey('length', $review->getAttribute('movie')); + $this->assertEquals('Movie 1', $review->getDocument('movie')->getAttribute('name')); + $this->assertArrayNotHasKey('length', $review->getDocument('movie')); $review = $database->getDocument('review', 'review1', [ - Query::select(['*', 'movie.name']) + Query::select(['*', 'movie.name']), ]); - $this->assertEquals('Movie 1', $review->getAttribute('movie')->getAttribute('name')); - $this->assertArrayNotHasKey('length', $review->getAttribute('movie')); + $this->assertEquals('Movie 1', $review->getDocument('movie')->getAttribute('name')); + $this->assertArrayNotHasKey('length', $review->getDocument('movie')); // Update root document attribute without altering relationship $review1 = $database->updateDocument( @@ -205,7 +213,7 @@ public function testManyToOneOneWayRelationship(): void $this->assertEquals('Review 1 Updated', $review1->getAttribute('name')); // Update nested document attribute - $movie = $review1->getAttribute('movie'); + $movie = $review1->getDocument('movie'); $movie->setAttribute('name', 'Movie 1 Updated'); $review1 = $database->updateDocument( @@ -214,9 +222,9 @@ public function testManyToOneOneWayRelationship(): void $review1->setAttribute('movie', $movie) ); - $this->assertEquals('Movie 1 Updated', $review1->getAttribute('movie')->getAttribute('name')); + $this->assertEquals('Movie 1 Updated', $review1->getDocument('movie')->getAttribute('name')); $review1 = $database->getDocument('review', 'review1'); - $this->assertEquals('Movie 1 Updated', $review1->getAttribute('movie')->getAttribute('name')); + $this->assertEquals('Movie 1 Updated', $review1->getDocument('movie')->getAttribute('name')); // Create new document with no relationship $review5 = $database->createDocument('review', new Document([ @@ -245,9 +253,9 @@ public function testManyToOneOneWayRelationship(): void ])) ); - $this->assertEquals('Movie 5', $review5->getAttribute('movie')->getAttribute('name')); + $this->assertEquals('Movie 5', $review5->getDocument('movie')->getAttribute('name')); $review5 = $database->getDocument('review', 'review5'); - $this->assertEquals('Movie 5', $review5->getAttribute('movie')->getAttribute('name')); + $this->assertEquals('Movie 5', $review5->getDocument('movie')->getAttribute('name')); // Update document with new related document $database->updateDocument( @@ -260,13 +268,13 @@ public function testManyToOneOneWayRelationship(): void $database->updateRelationship( 'review', 'movie', - 'newMovie', + new RelationshipUpdate(key: 'newMovie'), ); // Get document with new relationship key $review = $database->getDocument('review', 'review1'); - $movie = $review->getAttribute('newMovie'); - $this->assertEquals('movie2', $movie['$id']); + $movie = $review->getDocument('newMovie'); + $this->assertEquals('movie2', $movie->getId()); // Reset values $review1 = $database->getDocument('review', 'review1'); @@ -307,8 +315,8 @@ public function testManyToOneOneWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'review', - id: 'newMovie', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newMovie', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); // Delete child, set parent relationship to null @@ -321,8 +329,8 @@ public function testManyToOneOneWayRelationship(): void // Change on delete to cascade $database->updateRelationship( collection: 'review', - id: 'newMovie', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newMovie', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete child, will delete parent @@ -335,7 +343,6 @@ public function testManyToOneOneWayRelationship(): void $library = $database->getDocument('review', 'review2'); $this->assertEquals(true, $library->isEmpty()); - // Delete relationship $database->deleteRelationship( 'review', @@ -353,60 +360,49 @@ public function testManyToOneTwoWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('product'); - $database->createCollection('store'); + $database->createCollection(Collection::create(id: 'product')); + $database->createCollection(Collection::create(id: 'store')); - $database->createAttribute('store', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('store', 'opensAt', Database::VAR_STRING, 5, true); + $database->createAttribute('store', Attribute::string(key: 'name', required: true)); + $database->createAttribute('store', Attribute::string(key: 'opensAt', size: 5, required: true)); - $database->createAttribute( - collection: 'product', - id: 'name', - type: Database::VAR_STRING, - size: 255, - required: true - ); + $database->createAttribute('product', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'product', - relatedCollection: 'store', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - twoWayKey: 'products' - ); + $database->createRelationship('product', Relationship::manyToOne(relatedCollection: 'store', twoWay: true, twoWayKey: 'products')); // Check metadata for collection $collection = $database->getCollection('product'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'store') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('store', $attribute['$id']); - $this->assertEquals('store', $attribute['key']); - $this->assertEquals('store', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_MANY_TO_ONE, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('products', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'store') { + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('store', $attribute->key); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame('store', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToOne, $relationship->type); + $this->assertSame(true, $relationship->twoWay); + $this->assertSame('products', $relationship->twoWayKey); } } // Check metadata for related collection $collection = $database->getCollection('store'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'products') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('products', $attribute['$id']); - $this->assertEquals('products', $attribute['key']); - $this->assertEquals('product', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_MANY_TO_ONE, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('store', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'products') { + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('products', $attribute->key); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame('product', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToOne, $relationship->type); + $this->assertSame(true, $relationship->twoWay); + $this->assertSame('store', $relationship->twoWayKey); } } @@ -510,44 +506,44 @@ public function testManyToOneTwoWayRelationship(): void // Get document with relationship $product = $database->getDocument('product', 'product1'); - $store = $product->getAttribute('store', []); - $this->assertEquals('store1', $store['$id']); + $store = $product->getDocument('store'); + $this->assertEquals('store1', $store->getId()); $this->assertArrayNotHasKey('products', $store); $product = $database->getDocument('product', 'product2'); - $store = $product->getAttribute('store', []); - $this->assertEquals('store2', $store['$id']); + $store = $product->getDocument('store'); + $this->assertEquals('store2', $store->getId()); $this->assertArrayNotHasKey('products', $store); $product = $database->getDocument('product', 'product3'); - $store = $product->getAttribute('store', []); - $this->assertEquals('store3', $store['$id']); + $store = $product->getDocument('store'); + $this->assertEquals('store3', $store->getId()); $this->assertArrayNotHasKey('products', $store); $product = $database->getDocument('product', 'product4'); - $store = $product->getAttribute('store', []); - $this->assertEquals('store4', $store['$id']); + $store = $product->getDocument('store'); + $this->assertEquals('store4', $store->getId()); $this->assertArrayNotHasKey('products', $store); // Get related document $store = $database->getDocument('store', 'store1'); - $products = $store->getAttribute('products'); - $this->assertEquals('product1', $products[0]['$id']); + $products = $store->getDocuments('products'); + $this->assertEquals('product1', $products[0]->getId()); $this->assertArrayNotHasKey('store', $products[0]); $store = $database->getDocument('store', 'store2'); - $products = $store->getAttribute('products'); - $this->assertEquals('product2', $products[0]['$id']); + $products = $store->getDocuments('products'); + $this->assertEquals('product2', $products[0]->getId()); $this->assertArrayNotHasKey('store', $products[0]); $store = $database->getDocument('store', 'store3'); - $products = $store->getAttribute('products'); - $this->assertEquals('product3', $products[0]['$id']); + $products = $store->getDocuments('products'); + $this->assertEquals('product3', $products[0]->getId()); $this->assertArrayNotHasKey('store', $products[0]); $store = $database->getDocument('store', 'store4'); - $products = $store->getAttribute('products'); - $this->assertEquals('product4', $products[0]['$id']); + $products = $store->getDocuments('products'); + $this->assertEquals('product4', $products[0]->getId()); $this->assertArrayNotHasKey('store', $products[0]); $products = $database->find('product'); @@ -556,22 +552,22 @@ public function testManyToOneTwoWayRelationship(): void // Select related document attributes $product = $database->findOne('product', [ - Query::select(['*', 'store.name']) + Query::select(['*', 'store.name']), ]); if ($product->isEmpty()) { throw new Exception('Product not found'); } - $this->assertEquals('Store 1', $product->getAttribute('store')->getAttribute('name')); - $this->assertArrayNotHasKey('opensAt', $product->getAttribute('store')); + $this->assertEquals('Store 1', $product->getDocument('store')->getAttribute('name')); + $this->assertArrayNotHasKey('opensAt', $product->getDocument('store')); $product = $database->getDocument('product', 'product1', [ - Query::select(['*', 'store.name']) + Query::select(['*', 'store.name']), ]); - $this->assertEquals('Store 1', $product->getAttribute('store')->getAttribute('name')); - $this->assertArrayNotHasKey('opensAt', $product->getAttribute('store')); + $this->assertEquals('Store 1', $product->getDocument('store')->getAttribute('name')); + $this->assertArrayNotHasKey('opensAt', $product->getDocument('store')); // Update root document attribute without altering relationship $product1 = $database->updateDocument( @@ -597,7 +593,7 @@ public function testManyToOneTwoWayRelationship(): void $this->assertEquals('Store 1 Updated', $store1->getAttribute('name')); // Update nested document attribute - $store = $product1->getAttribute('store'); + $store = $product1->getDocument('store'); $store->setAttribute('name', 'Store 1 Updated'); $product1 = $database->updateDocument( @@ -606,12 +602,12 @@ public function testManyToOneTwoWayRelationship(): void $product1->setAttribute('store', $store) ); - $this->assertEquals('Store 1 Updated', $product1->getAttribute('store')->getAttribute('name')); + $this->assertEquals('Store 1 Updated', $product1->getDocument('store')->getAttribute('name')); $product1 = $database->getDocument('product', 'product1'); - $this->assertEquals('Store 1 Updated', $product1->getAttribute('store')->getAttribute('name')); + $this->assertEquals('Store 1 Updated', $product1->getDocument('store')->getAttribute('name')); // Update inverse nested document attribute - $product = $store1->getAttribute('products')[0]; + $product = $store1->getDocuments('products')[0]; $product->setAttribute('name', 'Product 1 Updated'); $store1 = $database->updateDocument( @@ -620,9 +616,9 @@ public function testManyToOneTwoWayRelationship(): void $store1->setAttribute('products', [$product]) ); - $this->assertEquals('Product 1 Updated', $store1->getAttribute('products')[0]->getAttribute('name')); + $this->assertEquals('Product 1 Updated', $store1->getDocuments('products')[0]->getAttribute('name')); $store1 = $database->getDocument('store', 'store1'); - $this->assertEquals('Product 1 Updated', $store1->getAttribute('products')[0]->getAttribute('name')); + $this->assertEquals('Product 1 Updated', $store1->getDocuments('products')[0]->getAttribute('name')); // Create new document with no relationship $product5 = $database->createDocument('product', new Document([ @@ -651,9 +647,9 @@ public function testManyToOneTwoWayRelationship(): void ])) ); - $this->assertEquals('Store 5', $product5->getAttribute('store')->getAttribute('name')); + $this->assertEquals('Store 5', $product5->getDocument('store')->getAttribute('name')); $product5 = $database->getDocument('product', 'product5'); - $this->assertEquals('Store 5', $product5->getAttribute('store')->getAttribute('name')); + $this->assertEquals('Store 5', $product5->getDocument('store')->getAttribute('name')); // Create new child document with no relationship $store6 = $database->createDocument('store', new Document([ @@ -682,9 +678,9 @@ public function testManyToOneTwoWayRelationship(): void ])]) ); - $this->assertEquals('Product 6', $store6->getAttribute('products')[0]->getAttribute('name')); + $this->assertEquals('Product 6', $store6->getDocuments('products')[0]->getAttribute('name')); $store6 = $database->getDocument('store', 'store6'); - $this->assertEquals('Product 6', $store6->getAttribute('products')[0]->getAttribute('name')); + $this->assertEquals('Product 6', $store6->getDocuments('products')[0]->getAttribute('name')); // Update document with new related document $database->updateDocument( @@ -715,19 +711,21 @@ public function testManyToOneTwoWayRelationship(): void $database->updateRelationship( 'product', 'store', - 'newStore', - 'newProducts' + new RelationshipUpdate( + key: 'newStore', + twoWayKey: 'newProducts', + ) ); // Get document with new relationship key $store = $database->getDocument('store', 'store2'); - $products = $store->getAttribute('newProducts'); - $this->assertEquals('product1', $products[0]['$id']); + $products = $store->getDocuments('newProducts'); + $this->assertEquals('product1', $products[0]->getId()); // Get inverse document with new relationship key $product = $database->getDocument('product', 'product1'); - $store = $product->getAttribute('newStore'); - $this->assertEquals('store2', $store['$id']); + $store = $product->getDocument('newStore'); + $this->assertEquals('store2', $store->getId()); // Reset relationships $store1 = $database->getDocument('store', 'store1'); @@ -771,8 +769,8 @@ public function testManyToOneTwoWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'product', - id: 'newStore', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newStore', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); // Delete child, set parent relationship to null @@ -803,8 +801,8 @@ public function testManyToOneTwoWayRelationship(): void // Change on delete to cascade $database->updateRelationship( collection: 'product', - id: 'newStore', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newStore', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete child, will delete parent @@ -839,34 +837,27 @@ public function testNestedManyToOne_OneToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('towns'); - $database->createCollection('homelands'); - $database->createCollection('capitals'); + $database->createCollection(Collection::create(id: 'towns')); + $database->createCollection(Collection::create(id: 'homelands')); + $database->createCollection(Collection::create(id: 'capitals')); - $database->createAttribute('towns', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('homelands', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('capitals', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('towns', Attribute::string(key: 'name', required: true)); + $database->createAttribute('homelands', Attribute::string(key: 'name', required: true)); + $database->createAttribute('capitals', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'towns', - relatedCollection: 'homelands', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'homeland' - ); - $database->createRelationship( - collection: 'homelands', + $database->createRelationship('towns', Relationship::manyToOne(relatedCollection: 'homelands', twoWay: true, key: 'homeland')); + $database->createRelationship('homelands', Relationship::oneToOne( relatedCollection: 'capitals', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'capital', + key: 'capital', twoWayKey: 'homeland' - ); + )); $database->createDocument('towns', new Document([ '$id' => 'town1', @@ -891,10 +882,12 @@ public function testNestedManyToOne_OneToOneRelationship(): void ])); $town1 = $database->getDocument('towns', 'town1'); - $this->assertEquals('homeland1', $town1['homeland']['$id']); - $this->assertArrayNotHasKey('towns', $town1['homeland']); - $this->assertEquals('capital1', $town1['homeland']['capital']['$id']); - $this->assertArrayNotHasKey('homeland', $town1['homeland']['capital']); + $homeland = $town1->getDocument('homeland'); + $this->assertEquals('homeland1', $homeland->getId()); + $this->assertArrayNotHasKey('towns', $homeland); + $capital = $homeland->getDocument('capital'); + $this->assertEquals('capital1', $capital->getId()); + $this->assertArrayNotHasKey('homeland', $capital); $database->createDocument('capitals', new Document([ '$id' => 'capital2', @@ -928,11 +921,13 @@ public function testNestedManyToOne_OneToOneRelationship(): void ])); $capital2 = $database->getDocument('capitals', 'capital2'); - $this->assertEquals('homeland2', $capital2['homeland']['$id']); - $this->assertArrayNotHasKey('capital', $capital2['homeland']); - $this->assertEquals(2, \count($capital2['homeland']['towns'])); - $this->assertEquals('town2', $capital2['homeland']['towns'][0]['$id']); - $this->assertEquals('town3', $capital2['homeland']['towns'][1]['$id']); + $homeland = $capital2->getDocument('homeland'); + $this->assertEquals('homeland2', $homeland->getId()); + $this->assertArrayNotHasKey('capital', $homeland); + $towns = $homeland->getDocuments('towns'); + $this->assertCount(2, $towns); + $this->assertEquals('town2', $towns[0]->getId()); + $this->assertEquals('town3', $towns[1]->getId()); } public function testNestedManyToOne_OneToManyRelationship(): void @@ -940,34 +935,27 @@ public function testNestedManyToOne_OneToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('players'); - $database->createCollection('teams'); - $database->createCollection('supporters'); + $database->createCollection(Collection::create(id: 'players')); + $database->createCollection(Collection::create(id: 'teams')); + $database->createCollection(Collection::create(id: 'supporters')); - $database->createAttribute('players', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('teams', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('supporters', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('players', Attribute::string(key: 'name', required: true)); + $database->createAttribute('teams', Attribute::string(key: 'name', required: true)); + $database->createAttribute('supporters', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'players', - relatedCollection: 'teams', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'team' - ); - $database->createRelationship( - collection: 'teams', + $database->createRelationship('players', Relationship::manyToOne(relatedCollection: 'teams', twoWay: true, key: 'team')); + $database->createRelationship('teams', Relationship::oneToMany( relatedCollection: 'supporters', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'supporters', + key: 'supporters', twoWayKey: 'team' - ); + )); $database->createDocument('players', new Document([ '$id' => 'player1', @@ -1001,11 +989,13 @@ public function testNestedManyToOne_OneToManyRelationship(): void ])); $player1 = $database->getDocument('players', 'player1'); - $this->assertEquals('team1', $player1['team']['$id']); - $this->assertArrayNotHasKey('players', $player1['team']); - $this->assertEquals(2, \count($player1['team']['supporters'])); - $this->assertEquals('supporter1', $player1['team']['supporters'][0]['$id']); - $this->assertEquals('supporter2', $player1['team']['supporters'][1]['$id']); + $team = $player1->getDocument('team'); + $this->assertEquals('team1', $team->getId()); + $this->assertArrayNotHasKey('players', $team); + $supporters = $team->getDocuments('supporters'); + $this->assertCount(2, $supporters); + $this->assertEquals('supporter1', $supporters[0]->getId()); + $this->assertEquals('supporter2', $supporters[1]->getId()); $database->createDocument('supporters', new Document([ '$id' => 'supporter3', @@ -1039,11 +1029,13 @@ public function testNestedManyToOne_OneToManyRelationship(): void ])); $supporter3 = $database->getDocument('supporters', 'supporter3'); - $this->assertEquals('team2', $supporter3['team']['$id']); - $this->assertArrayNotHasKey('supporters', $supporter3['team']); - $this->assertEquals(2, \count($supporter3['team']['players'])); - $this->assertEquals('player2', $supporter3['team']['players'][0]['$id']); - $this->assertEquals('player3', $supporter3['team']['players'][1]['$id']); + $team = $supporter3->getDocument('team'); + $this->assertEquals('team2', $team->getId()); + $this->assertArrayNotHasKey('supporters', $team); + $players = $team->getDocuments('players'); + $this->assertCount(2, $players); + $this->assertEquals('player2', $players[0]->getId()); + $this->assertEquals('player3', $players[1]->getId()); } public function testNestedManyToOne_ManyToOne(): void @@ -1051,33 +1043,22 @@ public function testNestedManyToOne_ManyToOne(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('cows'); - $database->createCollection('farms'); - $database->createCollection('farmer'); + $database->createCollection(Collection::create(id: 'cows')); + $database->createCollection(Collection::create(id: 'farms')); + $database->createCollection(Collection::create(id: 'farmer')); - $database->createAttribute('cows', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('farms', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('farmer', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('cows', Attribute::string(key: 'name', required: true)); + $database->createAttribute('farms', Attribute::string(key: 'name', required: true)); + $database->createAttribute('farmer', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'cows', - relatedCollection: 'farms', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'farm' - ); - $database->createRelationship( - collection: 'farms', - relatedCollection: 'farmer', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'farmer' - ); + $database->createRelationship('cows', Relationship::manyToOne(relatedCollection: 'farms', twoWay: true, key: 'farm')); + $database->createRelationship('farms', Relationship::manyToOne(relatedCollection: 'farmer', twoWay: true, key: 'farmer')); $database->createDocument('cows', new Document([ '$id' => 'cow1', @@ -1102,10 +1083,12 @@ public function testNestedManyToOne_ManyToOne(): void ])); $cow1 = $database->getDocument('cows', 'cow1'); - $this->assertEquals('farm1', $cow1['farm']['$id']); - $this->assertArrayNotHasKey('cows', $cow1['farm']); - $this->assertEquals('farmer1', $cow1['farm']['farmer']['$id']); - $this->assertArrayNotHasKey('farms', $cow1['farm']['farmer']); + $farm = $cow1->getDocument('farm'); + $this->assertEquals('farm1', $farm->getId()); + $this->assertArrayNotHasKey('cows', $farm); + $farmer = $farm->getDocument('farmer'); + $this->assertEquals('farmer1', $farmer->getId()); + $this->assertArrayNotHasKey('farms', $farmer); $database->createDocument('farmer', new Document([ '$id' => 'farmer2', @@ -1141,11 +1124,13 @@ public function testNestedManyToOne_ManyToOne(): void ])); $farmer2 = $database->getDocument('farmer', 'farmer2'); - $this->assertEquals('farm2', $farmer2['farms'][0]['$id']); - $this->assertArrayNotHasKey('farmer', $farmer2['farms'][0]); - $this->assertEquals(2, \count($farmer2['farms'][0]['cows'])); - $this->assertEquals('cow2', $farmer2['farms'][0]['cows'][0]['$id']); - $this->assertEquals('cow3', $farmer2['farms'][0]['cows'][1]['$id']); + $farms = $farmer2->getDocuments('farms'); + $this->assertEquals('farm2', $farms[0]->getId()); + $this->assertArrayNotHasKey('farmer', $farms[0]); + $cows = $farms[0]->getDocuments('cows'); + $this->assertCount(2, $cows); + $this->assertEquals('cow2', $cows[0]->getId()); + $this->assertEquals('cow3', $cows[1]->getId()); } public function testNestedManyToOne_ManyToManyRelationship(): void @@ -1153,32 +1138,22 @@ public function testNestedManyToOne_ManyToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('books'); - $database->createCollection('entrants'); - $database->createCollection('rooms'); + $database->createCollection(Collection::create(id: 'books')); + $database->createCollection(Collection::create(id: 'entrants')); + $database->createCollection(Collection::create(id: 'rooms')); - $database->createAttribute('books', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('entrants', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('rooms', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('books', Attribute::string(key: 'name', required: true)); + $database->createAttribute('entrants', Attribute::string(key: 'name', required: true)); + $database->createAttribute('rooms', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'books', - relatedCollection: 'entrants', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'entrant' - ); - $database->createRelationship( - collection: 'entrants', - relatedCollection: 'rooms', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'entrants', twoWay: true, key: 'entrant')); + $database->createRelationship('entrants', Relationship::manyToMany(relatedCollection: 'rooms', twoWay: true)); $database->createDocument('books', new Document([ '$id' => 'book1', @@ -1212,11 +1187,13 @@ public function testNestedManyToOne_ManyToManyRelationship(): void ])); $book1 = $database->getDocument('books', 'book1'); - $this->assertEquals('entrant1', $book1['entrant']['$id']); - $this->assertArrayNotHasKey('books', $book1['entrant']); - $this->assertEquals(2, \count($book1['entrant']['rooms'])); - $this->assertEquals('class1', $book1['entrant']['rooms'][0]['$id']); - $this->assertEquals('class2', $book1['entrant']['rooms'][1]['$id']); + $entrant = $book1->getDocument('entrant'); + $this->assertEquals('entrant1', $entrant->getId()); + $this->assertArrayNotHasKey('books', $entrant); + $rooms = $entrant->getDocuments('rooms'); + $this->assertCount(2, $rooms); + $this->assertEquals('class1', $rooms[0]->getId()); + $this->assertEquals('class2', $rooms[1]->getId()); } public function testExceedMaxDepthManyToOneParent(): void @@ -1224,8 +1201,9 @@ public function testExceedMaxDepthManyToOneParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -1234,29 +1212,14 @@ public function testExceedMaxDepthManyToOneParent(): void $level3Collection = 'level3ManyToOneParent'; $level4Collection = 'level4ManyToOneParent'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(Collection::create(id: $level1Collection)); + $database->createCollection(Collection::create(id: $level2Collection)); + $database->createCollection(Collection::create(id: $level3Collection)); + $database->createCollection(Collection::create(id: $level4Collection)); - $database->createRelationship( - collection: $level1Collection, - relatedCollection: $level2Collection, - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); - $database->createRelationship( - collection: $level2Collection, - relatedCollection: $level3Collection, - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); - $database->createRelationship( - collection: $level3Collection, - relatedCollection: $level4Collection, - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); + $database->createRelationship($level1Collection, Relationship::manyToOne(relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship($level2Collection, Relationship::manyToOne(relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship($level3Collection, Relationship::manyToOne(relatedCollection: $level4Collection, twoWay: true)); $level1 = $database->createDocument($level1Collection, new Document([ '$id' => 'level1', @@ -1275,31 +1238,31 @@ public function testExceedMaxDepthManyToOneParent(): void ], ])); $this->assertArrayHasKey($level2Collection, $level1); - $this->assertEquals('level2', $level1[$level2Collection]->getId()); - $this->assertArrayHasKey($level3Collection, $level1[$level2Collection]); - $this->assertEquals('level3', $level1[$level2Collection][$level3Collection]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][$level3Collection]); + $this->assertEquals('level2', $level1->getDocument($level2Collection)->getId()); + $this->assertArrayHasKey($level3Collection, $level1->getDocument($level2Collection)); + $this->assertEquals('level3', $level1->getDocument($level2Collection)->getDocument($level3Collection)->getId()); + $this->assertArrayNotHasKey($level4Collection, $level1->getDocument($level2Collection)->getDocument($level3Collection)); // Confirm the 4th level document does not exist $level3 = $database->getDocument($level3Collection, 'level3'); - $this->assertNull($level3[$level4Collection]); + $this->assertNull($level3->getAttribute($level4Collection)); // Create level 4 document $level3->setAttribute($level4Collection, new Document([ '$id' => 'level4', ])); $level3 = $database->updateDocument($level3Collection, $level3->getId(), $level3); - $this->assertEquals('level4', $level3[$level4Collection]->getId()); + $this->assertEquals('level4', $level3->getDocument($level4Collection)->getId()); $level3 = $database->getDocument($level3Collection, 'level3'); - $this->assertEquals('level4', $level3[$level4Collection]->getId()); + $this->assertEquals('level4', $level3->getDocument($level4Collection)->getId()); // Exceed fetch depth $level1 = $database->getDocument($level1Collection, 'level1'); $this->assertArrayHasKey($level2Collection, $level1); - $this->assertEquals('level2', $level1[$level2Collection]->getId()); - $this->assertArrayHasKey($level3Collection, $level1[$level2Collection]); - $this->assertEquals('level3', $level1[$level2Collection][$level3Collection]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][$level3Collection]); + $this->assertEquals('level2', $level1->getDocument($level2Collection)->getId()); + $this->assertArrayHasKey($level3Collection, $level1->getDocument($level2Collection)); + $this->assertEquals('level3', $level1->getDocument($level2Collection)->getDocument($level3Collection)->getId()); + $this->assertArrayNotHasKey($level4Collection, $level1->getDocument($level2Collection)->getDocument($level3Collection)); } public function testManyToOneRelationshipKeyWithSymbols(): void @@ -1307,109 +1270,81 @@ public function testManyToOneRelationshipKeyWithSymbols(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('$symbols_coll.ection5'); - $database->createCollection('$symbols_coll.ection6'); + $database->createCollection(Collection::create(id: '$symbols_coll.ection5')); + $database->createCollection(Collection::create(id: '$symbols_coll.ection6')); - $database->createRelationship( - collection: '$symbols_coll.ection5', - relatedCollection: '$symbols_coll.ection6', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); + $database->createRelationship('$symbols_coll.ection5', Relationship::manyToOne(relatedCollection: '$symbols_coll.ection6', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection6', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc2 = $database->createDocument('$symbols_coll.ection5', new Document([ - '$id' => ID::unique(), - '$symbols_coll.ection6' => $doc1->getId(), + '$id' => Id::unique(), + 'symbols_collection6' => $doc1->getId(), '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc1 = $database->getDocument('$symbols_coll.ection6', $doc1->getId()); $doc2 = $database->getDocument('$symbols_coll.ection5', $doc2->getId()); - $this->assertEquals($doc2->getId(), $doc1->getAttribute('$symbols_coll.ection5')[0]->getId()); - $this->assertEquals($doc1->getId(), $doc2->getAttribute('$symbols_coll.ection6')->getId()); + $this->assertEquals($doc2->getId(), $doc1->getDocuments('symbols_collection5')[0]->getId()); + $this->assertEquals($doc1->getId(), $doc2->getDocument('symbols_collection6')->getId()); } - public function testRecreateManyToOneOneWayRelationshipFromParent(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - ); + $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - ); + $relationship = $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToOneOneWayRelationshipFromChild(): void @@ -1417,65 +1352,42 @@ public function testRecreateManyToOneOneWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - ); + $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - ); + $relationship = $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToOneTwoWayRelationshipFromParent(): void @@ -1483,134 +1395,85 @@ public function testRecreateManyToOneTwoWayRelationshipFromParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); + $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } + public function testRecreateManyToOneTwoWayRelationshipFromChild(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); + $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::manyToOne(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testDeleteBulkDocumentsManyToOneRelationship(): void @@ -1618,25 +1481,21 @@ public function testDeleteBulkDocumentsManyToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_m2o'); - $this->getDatabase()->createCollection('bulk_delete_library_m2o'); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_person_m2o')); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_library_m2o')); - $this->getDatabase()->createAttribute('bulk_delete_person_m2o', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_m2o', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_m2o', 'area', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_person_m2o', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_m2o', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_m2o', Attribute::string(key: 'area', required: true)); // Many-to-One Relationship - $this->getDatabase()->createRelationship( - collection: 'bulk_delete_person_m2o', - relatedCollection: 'bulk_delete_library_m2o', - type: Database::RELATION_MANY_TO_ONE, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $this->getDatabase()->createRelationship('bulk_delete_person_m2o', Relationship::manyToOne(relatedCollection: 'bulk_delete_library_m2o')); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_m2o', new Document([ '$id' => 'person1', @@ -1668,12 +1527,12 @@ public function testDeleteBulkDocumentsManyToOneRelationship(): void 'name' => 'Person 2', 'bulk_delete_library_m2o' => [ '$id' => 'library1', - ] + ], ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_m2o', 'person1'); - $library = $person1->getAttribute('bulk_delete_library_m2o'); - $this->assertEquals('library1', $library['$id']); + $library = $person1->getDocument('bulk_delete_library_m2o'); + $this->assertEquals('library1', $library->getId()); // Delete library try { @@ -1696,34 +1555,32 @@ public function testDeleteBulkDocumentsManyToOneRelationship(): void $this->getDatabase()->deleteDocuments('bulk_delete_person_m2o'); $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_m2o')); } + public function testUpdateParentAndChild_ManyToOne(): void { /** @var Database $database */ $database = $this->getDatabase(); if ( - !$database->getAdapter()->getSupportForRelationships() || - !$database->getAdapter()->getSupportForBatchOperations() + ! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || + ! $this->supportsBulkWrites() ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_m2o'; $childCollection = 'child_combined_m2o'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'parentNumber', Database::VAR_INTEGER, 0, false); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::integer(key: 'parentNumber')); - $database->createRelationship( - collection: $childCollection, - relatedCollection: $parentCollection, - type: Database::RELATION_MANY_TO_ONE, - ); + $database->createRelationship($childCollection, Relationship::manyToOne(relatedCollection: $parentCollection)); $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -1783,25 +1640,21 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToOn /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_relationship_error_many_to_one'; $childCollection = 'child_relationship_error_many_to_one'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: $childCollection, - relatedCollection: $parentCollection, - type: Database::RELATION_MANY_TO_ONE, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $database->createRelationship($childCollection, Relationship::manyToOne(relatedCollection: $parentCollection)); $parent = $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -1821,7 +1674,7 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToOn Permission::delete(Role::any()), ], 'name' => 'Child 1', - $parentCollection => 'parent1' + $parentCollection => 'parent1', ])); try { @@ -1843,26 +1696,25 @@ public function testPartialUpdateManyToOneParentSide(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('companies'); - $database->createCollection('employees'); + $database->createCollection(Collection::create(id: 'companies')); + $database->createCollection(Collection::create(id: 'employees')); - $database->createAttribute('companies', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('employees', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 0, false); + $database->createAttribute('companies', Attribute::string(key: 'name', required: true)); + $database->createAttribute('employees', Attribute::string(key: 'name', required: true)); + $database->createAttribute('employees', Attribute::integer(key: 'salary')); - $database->createRelationship( - collection: 'employees', + $database->createRelationship('employees', Relationship::manyToOne( relatedCollection: 'companies', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'company', + key: 'company', twoWayKey: 'employees' - ); + )); // Create company $database->createDocument('companies', new Document([ @@ -1897,7 +1749,7 @@ public function testPartialUpdateManyToOneParentSide(): void $emp = $database->getDocument('employees', 'emp1'); $this->assertEquals('Alice', $emp->getAttribute('name'), 'Name should be preserved'); $this->assertEquals(120000, $emp->getAttribute('salary'), 'Salary should be updated'); - $this->assertEquals('company1', $emp->getAttribute('company')->getId(), 'Company relationship should be preserved'); + $this->assertEquals('company1', $emp->getDocument('company')->getId(), 'Company relationship should be preserved'); // Partial update - change only company relationship $database->updateDocument('employees', 'emp1', new Document([ @@ -1910,7 +1762,7 @@ public function testPartialUpdateManyToOneParentSide(): void $emp = $database->getDocument('employees', 'emp1'); $this->assertEquals('Alice', $emp->getAttribute('name'), 'Name should be preserved'); $this->assertEquals(120000, $emp->getAttribute('salary'), 'Salary should be preserved'); - $this->assertEquals('company2', $emp->getAttribute('company')->getId(), 'Company should be updated'); + $this->assertEquals('company2', $emp->getDocument('company')->getId(), 'Company should be updated'); $database->deleteCollection('companies'); $database->deleteCollection('employees'); @@ -1921,26 +1773,25 @@ public function testPartialUpdateManyToOneChildSide(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('departments'); - $database->createCollection('staff'); + $database->createCollection(Collection::create(id: 'departments')); + $database->createCollection(Collection::create(id: 'staff')); - $database->createAttribute('departments', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('departments', 'budget', Database::VAR_INTEGER, 0, false); - $database->createAttribute('staff', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('departments', Attribute::string(key: 'name', required: true)); + $database->createAttribute('departments', Attribute::integer(key: 'budget')); + $database->createAttribute('staff', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'staff', + $database->createRelationship('staff', Relationship::manyToOne( relatedCollection: 'departments', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'department', + key: 'department', twoWayKey: 'staff' - ); + )); // Create department with staff $database->createDocument('departments', new Document([ @@ -1965,7 +1816,7 @@ public function testPartialUpdateManyToOneChildSide(): void $dept = $database->getDocument('departments', 'dept1'); $this->assertEquals('Engineering', $dept->getAttribute('name'), 'Name should be preserved'); $this->assertEquals(1200000, $dept->getAttribute('budget'), 'Budget should be updated'); - $this->assertCount(2, $dept->getAttribute('staff'), 'Staff should be preserved'); + $this->assertCount(2, $dept->getDocuments('staff'), 'Staff should be preserved'); $database->deleteCollection('departments'); $database->deleteCollection('staff'); @@ -1990,20 +1841,18 @@ public function testManyToOneSetNullAfterSelectDelete(): void Permission::delete(Role::any()), ]; - $database->createCollection('mto_select_parent', permissions: $collectionPermissions, documentSecurity: true); - $database->createCollection('mto_select_child', permissions: $collectionPermissions, documentSecurity: true); - $database->createAttribute('mto_select_parent', 'name', Database::VAR_STRING, 255, false); - $database->createAttribute('mto_select_child', 'name', Database::VAR_STRING, 255, false); + $database->createCollection(Collection::create(id: 'mto_select_parent', permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(Collection::create(id: 'mto_select_child', permissions: $collectionPermissions, documentSecurity: true)); + $database->createAttribute('mto_select_parent', Attribute::string(key: 'name', size: 255)); + $database->createAttribute('mto_select_child', Attribute::string(key: 'name', size: 255)); - $database->createRelationship( - collection: 'mto_select_parent', + $database->createRelationship('mto_select_parent', Relationship::manyToOne( relatedCollection: 'mto_select_child', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'child', + key: 'child', twoWayKey: 'parent', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); + onDelete: RelationshipDeleteAction::SetNull, + )); $database->createDocument('mto_select_child', new Document([ '$id' => 'child1', @@ -2039,27 +1888,25 @@ public function testManyToOneSetNullClearsUnreadableReferences(): void $database = static::getDatabase(); // No collection-level read: only document permissions grant access - $database->createCollection('mto_hidden_product', permissions: [ + $database->createCollection(Collection::create(id: 'mto_hidden_product', permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); - $database->createCollection('mto_hidden_store', permissions: [ + ], documentSecurity: true)); + $database->createCollection(Collection::create(id: 'mto_hidden_store', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::delete(Role::any()), - ], documentSecurity: true); + ], documentSecurity: true)); - $database->createAttribute('mto_hidden_product', 'name', Database::VAR_STRING, 255, false); - $database->createAttribute('mto_hidden_store', 'name', Database::VAR_STRING, 255, false); + $database->createAttribute('mto_hidden_product', Attribute::string(key: 'name', size: 255)); + $database->createAttribute('mto_hidden_store', Attribute::string(key: 'name', size: 255)); - $database->createRelationship( - collection: 'mto_hidden_product', + $database->createRelationship('mto_hidden_product', Relationship::manyToOne( relatedCollection: 'mto_hidden_store', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'store', + key: 'store', twoWayKey: 'products', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); + onDelete: RelationshipDeleteAction::SetNull, + )); $database->createDocument('mto_hidden_store', new Document([ '$id' => 'store1', @@ -2091,4 +1938,88 @@ public function testManyToOneSetNullClearsUnreadableReferences(): void $this->assertFalse($hidden->isEmpty()); $this->assertNull($hidden->getAttribute('store')); } + + public function testDeleteDocumentsWithASelectCascadesToChildren_ManyToOne(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'm2o_select_cascade_parent'; + $childCollection = 'm2o_select_cascade_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($childCollection, Relationship::manyToOne(relatedCollection: $parentCollection, twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: RelationshipDeleteAction::Cascade)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}"])); + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}", 'parent' => "parent{$suffix}"])); + } + + $deleted = $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + + $this->assertSame(1, $deleted); + $this->assertSame(['parent1'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')])), "The deleted parent's child must be deleted with it"); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } + + public function testDeleteDocumentsWithASelectHonoursRestrict_ManyToOne(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'm2o_select_restrict_parent'; + $childCollection = 'm2o_select_restrict_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($childCollection, Relationship::manyToOne(relatedCollection: $parentCollection, twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: RelationshipDeleteAction::Restrict)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}"])); + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}", 'parent' => "parent{$suffix}"])); + } + + try { + $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + $this->fail('Deleting a parent with a related document must be restricted'); + } catch (RestrictedException $exception) { + $this->assertSame('Cannot delete document because it has at least one related document.', $exception->getMessage()); + } + + $this->assertSame(['parent1', 'parent2'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1', 'child2'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')]))); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php index 6a13a2d0c5..bd7d161ca7 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -3,14 +3,24 @@ namespace Tests\E2E\Adapter\Scopes\Relationships; use Exception; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Structure; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Operator; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Role; +use Utopia\Query\Schema\ColumnType; trait OneToManyTests { @@ -19,38 +29,33 @@ public function testOneToManyOneWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('artist'); - $database->createCollection('album'); + $database->createCollection(Collection::create(id: 'artist')); + $database->createCollection(Collection::create(id: 'album')); - $database->createAttribute('artist', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('album', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('album', 'price', Database::VAR_FLOAT, 0, true); + $database->createAttribute('artist', Attribute::string(key: 'name', required: true)); + $database->createAttribute('album', Attribute::string(key: 'name', required: true)); + $database->createAttribute('album', Attribute::double(key: 'price', required: true)); - $database->createRelationship( - collection: 'artist', - relatedCollection: 'album', - type: Database::RELATION_ONE_TO_MANY, - id: 'albums' - ); + $database->createRelationship('artist', Relationship::oneToMany(relatedCollection: 'album', key: 'albums')); // Check metadata for collection $collection = $database->getCollection('artist'); - $attributes = $collection->getAttribute('attributes', []); - - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'albums') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('albums', $attribute['$id']); - $this->assertEquals('albums', $attribute['key']); - $this->assertEquals('album', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_ONE_TO_MANY, $attribute['options']['relationType']); - $this->assertEquals(false, $attribute['options']['twoWay']); - $this->assertEquals('artist', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'albums') { + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('albums', $attribute->key); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame('album', $relationship->relatedCollection); + $this->assertSame(RelationshipType::OneToMany, $relationship->type); + $this->assertSame(false, $relationship->twoWay); + $this->assertSame('artist', $relationship->twoWayKey); } } @@ -68,7 +73,7 @@ public function testOneToManyOneWayRelationship(): void '$id' => 'album1', '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) + Permission::update(Role::any()), ], 'name' => 'Album 1', 'price' => 9.99, @@ -81,7 +86,7 @@ public function testOneToManyOneWayRelationship(): void $artist1Document = $database->getDocument('artist', 'artist1'); // Assert document does not contain non existing relation document. - $this->assertEquals(1, \count($artist1Document->getAttribute('albums'))); + $this->assertEquals(1, \count($artist1Document->getDocuments('albums'))); // Create document with relationship with related ID $database->createDocument('album', new Document([ @@ -112,27 +117,27 @@ public function testOneToManyOneWayRelationship(): void ], 'name' => 'Album 3', 'price' => 33.33, - ] - ] + ], + ], ])); $documents = $database->find('artist', [ Query::select(['name']), - Query::limit(1) + Query::limit(1), ]); $this->assertArrayNotHasKey('albums', $documents[0]); // Get document with relationship $artist = $database->getDocument('artist', 'artist1'); - $albums = $artist->getAttribute('albums', []); - $this->assertEquals('album1', $albums[0]['$id']); + $albums = $artist->getDocuments('albums'); + $this->assertEquals('album1', $albums[0]->getId()); $this->assertArrayNotHasKey('artist', $albums[0]); $artist = $database->getDocument('artist', 'artist2'); - $albums = $artist->getAttribute('albums', []); - $this->assertEquals('album2', $albums[0]['$id']); + $albums = $artist->getDocuments('albums'); + $this->assertEquals('album2', $albums[0]->getId()); $this->assertArrayNotHasKey('artist', $albums[0]); - $this->assertEquals('album33', $albums[1]['$id']); + $this->assertEquals('album33', $albums[1]->getId()); $this->assertCount(2, $albums); // Get related document @@ -148,22 +153,22 @@ public function testOneToManyOneWayRelationship(): void // Select related document attributes $artist = $database->findOne('artist', [ - Query::select(['*', 'albums.name']) + Query::select(['*', 'albums.name']), ]); if ($artist->isEmpty()) { $this->fail('Artist not found'); } - $this->assertEquals('Album 1', $artist->getAttribute('albums')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('price', $artist->getAttribute('albums')[0]); + $this->assertEquals('Album 1', $artist->getDocuments('albums')[0]->getAttribute('name')); + $this->assertArrayNotHasKey('price', $artist->getDocuments('albums')[0]); $artist = $database->getDocument('artist', 'artist1', [ - Query::select(['*', 'albums.name']) + Query::select(['*', 'albums.name']), ]); - $this->assertEquals('Album 1', $artist->getAttribute('albums')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('price', $artist->getAttribute('albums')[0]); + $this->assertEquals('Album 1', $artist->getDocuments('albums')[0]->getAttribute('name')); + $this->assertArrayNotHasKey('price', $artist->getDocuments('albums')[0]); // Update root document attribute without altering relationship $artist1 = $database->updateDocument( @@ -177,7 +182,7 @@ public function testOneToManyOneWayRelationship(): void $this->assertEquals('Artist 1 Updated', $artist1->getAttribute('name')); // Update nested document attribute - $albums = $artist1->getAttribute('albums', []); + $albums = $artist1->getDocuments('albums'); $albums[0]->setAttribute('name', 'Album 1 Updated'); $artist1 = $database->updateDocument( @@ -186,20 +191,20 @@ public function testOneToManyOneWayRelationship(): void $artist1->setAttribute('albums', $albums) ); - $this->assertEquals('Album 1 Updated', $artist1->getAttribute('albums')[0]->getAttribute('name')); + $this->assertEquals('Album 1 Updated', $artist1->getDocuments('albums')[0]->getAttribute('name')); $artist1 = $database->getDocument('artist', 'artist1'); - $this->assertEquals('Album 1 Updated', $artist1->getAttribute('albums')[0]->getAttribute('name')); + $this->assertEquals('Album 1 Updated', $artist1->getDocuments('albums')[0]->getAttribute('name')); - $albumId = $artist1->getAttribute('albums')[0]->getAttribute('$id'); + $albumId = $artist1->getDocuments('albums')[0]->getId(); $albumDocument = $database->getDocument('album', $albumId); $albumDocument->setAttribute('name', 'Album 1 Updated!!!'); $database->updateDocument('album', $albumDocument->getId(), $albumDocument); $albumDocument = $database->getDocument('album', $albumDocument->getId()); $artist1 = $database->getDocument('artist', $artist1->getId()); - $this->assertEquals('Album 1 Updated!!!', $albumDocument['name']); - $this->assertEquals($albumDocument->getId(), $artist1->getAttribute('albums')[0]->getId()); - $this->assertEquals($albumDocument->getAttribute('name'), $artist1->getAttribute('albums')[0]->getAttribute('name')); + $this->assertEquals('Album 1 Updated!!!', $albumDocument->getAttribute('name')); + $this->assertEquals($albumDocument->getId(), $artist1->getDocuments('albums')[0]->getId()); + $this->assertEquals($albumDocument->getAttribute('name'), $artist1->getDocuments('albums')[0]->getAttribute('name')); // Create new document with no relationship $artist3 = $database->createDocument('artist', new Document([ @@ -228,9 +233,9 @@ public function testOneToManyOneWayRelationship(): void ])]) ); - $this->assertEquals('Album 3', $artist3->getAttribute('albums')[0]->getAttribute('name')); + $this->assertEquals('Album 3', $artist3->getDocuments('albums')[0]->getAttribute('name')); $artist3 = $database->getDocument('artist', 'artist3'); - $this->assertEquals('Album 3', $artist3->getAttribute('albums')[0]->getAttribute('name')); + $this->assertEquals('Album 3', $artist3->getDocuments('albums')[0]->getAttribute('name')); // Update document with new related documents, will remove existing relations $database->updateDocument( @@ -250,13 +255,13 @@ public function testOneToManyOneWayRelationship(): void $database->updateRelationship( 'artist', 'albums', - 'newAlbums' + new RelationshipUpdate(key: 'newAlbums') ); // Get document with new relationship key $artist = $database->getDocument('artist', 'artist1'); - $albums = $artist->getAttribute('newAlbums'); - $this->assertEquals('album1', $albums[0]['$id']); + $albums = $artist->getDocuments('newAlbums'); + $this->assertEquals('album1', $albums[0]->getId()); // Create new document with no relationship $database->createDocument('artist', new Document([ @@ -287,8 +292,8 @@ public function testOneToManyOneWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'artist', - id: 'newAlbums', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newAlbums', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); // Delete parent, set child relationship to null @@ -308,8 +313,8 @@ public function testOneToManyOneWayRelationship(): void // Change on delete to cascade $database->updateRelationship( collection: 'artist', - id: 'newAlbums', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newAlbums', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -323,15 +328,15 @@ public function testOneToManyOneWayRelationship(): void $this->assertEquals(true, $library->isEmpty()); $albums = []; - for ($i = 1 ; $i <= 50 ; $i++) { + for ($i = 1; $i <= 50; $i++) { $albums[] = [ - '$id' => 'album_' . $i, + '$id' => 'album_'.$i, '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'album ' . $i . ' ' . 'Artist 100', + 'name' => 'album '.$i.' '.'Artist 100', 'price' => 100, ]; } @@ -342,15 +347,15 @@ public function testOneToManyOneWayRelationship(): void Permission::delete(Role::any()), ], 'name' => 'Artist 100', - 'newAlbums' => $albums + 'newAlbums' => $albums, ])); $artist = $database->getDocument('artist', $artist->getId()); - $this->assertCount(50, $artist->getAttribute('newAlbums')); + $this->assertCount(50, $artist->getDocuments('newAlbums')); $albums = $database->find('album', [ Query::equal('artist', [$artist->getId()]), - Query::limit(999) + Query::limit(999), ]); $this->assertCount(50, $albums); @@ -363,13 +368,13 @@ public function testOneToManyOneWayRelationship(): void $database->deleteDocument('album', 'album_1'); $artist = $database->getDocument('artist', $artist->getId()); - $this->assertCount(49, $artist->getAttribute('newAlbums')); + $this->assertCount(49, $artist->getDocuments('newAlbums')); $database->deleteDocument('artist', $artist->getId()); $albums = $database->find('album', [ Query::equal('artist', [$artist->getId()]), - Query::limit(999) + Query::limit(999), ]); $this->assertCount(0, $albums); @@ -391,53 +396,48 @@ public function testOneToManyTwoWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('customer'); - $database->createCollection('account'); + $database->createCollection(Collection::create(id: 'customer')); + $database->createCollection(Collection::create(id: 'account')); - $database->createAttribute('customer', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('account', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('account', 'number', Database::VAR_STRING, 255, true); + $database->createAttribute('customer', Attribute::string(key: 'name', required: true)); + $database->createAttribute('account', Attribute::string(key: 'name', required: true)); + $database->createAttribute('account', Attribute::string(key: 'number', required: true)); - $database->createRelationship( - collection: 'customer', - relatedCollection: 'account', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - id: 'accounts' - ); + $database->createRelationship('customer', Relationship::oneToMany(relatedCollection: 'account', twoWay: true, key: 'accounts')); // Check metadata for collection $collection = $database->getCollection('customer'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'accounts') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('accounts', $attribute['$id']); - $this->assertEquals('accounts', $attribute['key']); - $this->assertEquals('account', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_ONE_TO_MANY, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('customer', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'accounts') { + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('accounts', $attribute->key); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame('account', $relationship->relatedCollection); + $this->assertSame(RelationshipType::OneToMany, $relationship->type); + $this->assertSame(true, $relationship->twoWay); + $this->assertSame('customer', $relationship->twoWayKey); } } // Check metadata for related collection $collection = $database->getCollection('account'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'customer') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('customer', $attribute['$id']); - $this->assertEquals('customer', $attribute['key']); - $this->assertEquals('customer', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_ONE_TO_MANY, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('accounts', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'customer') { + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('customer', $attribute->key); + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertSame('customer', $relationship->relatedCollection); + $this->assertSame(RelationshipType::OneToMany, $relationship->type); + $this->assertSame(true, $relationship->twoWay); + $this->assertSame('accounts', $relationship->twoWayKey); } } @@ -465,11 +465,11 @@ public function testOneToManyTwoWayRelationship(): void ])); // Update a document with non existing related document. It should not get added to the list. - $database->updateDocument('customer', 'customer1', $customer1->setAttribute('accounts', ['account1','no-account'])); + $database->updateDocument('customer', 'customer1', $customer1->setAttribute('accounts', ['account1', 'no-account'])); $customer1Document = $database->getDocument('customer', 'customer1'); // Assert document does not contain non existing relation document. - $this->assertEquals(1, \count($customer1Document->getAttribute('accounts'))); + $this->assertEquals(1, \count($customer1Document->getDocuments('accounts'))); // Create document with relationship with related ID $account2 = $database->createDocument('account', new Document([ @@ -491,8 +491,8 @@ public function testOneToManyTwoWayRelationship(): void ], 'name' => 'Customer 2', 'accounts' => [ - 'account2' - ] + 'account2', + ], ])); // Create from child side @@ -512,8 +512,8 @@ public function testOneToManyTwoWayRelationship(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Customer 3' - ] + 'name' => 'Customer 3', + ], ])); $database->createDocument('customer', new Document([ '$id' => 'customer4', @@ -533,49 +533,49 @@ public function testOneToManyTwoWayRelationship(): void ], 'name' => 'Account 4', 'number' => '123456789', - 'customer' => 'customer4' + 'customer' => 'customer4', ])); // Get documents with relationship $customer = $database->getDocument('customer', 'customer1'); - $accounts = $customer->getAttribute('accounts', []); - $this->assertEquals('account1', $accounts[0]['$id']); + $accounts = $customer->getDocuments('accounts'); + $this->assertEquals('account1', $accounts[0]->getId()); $this->assertArrayNotHasKey('customer', $accounts[0]); $customer = $database->getDocument('customer', 'customer2'); - $accounts = $customer->getAttribute('accounts', []); - $this->assertEquals('account2', $accounts[0]['$id']); + $accounts = $customer->getDocuments('accounts'); + $this->assertEquals('account2', $accounts[0]->getId()); $this->assertArrayNotHasKey('customer', $accounts[0]); $customer = $database->getDocument('customer', 'customer3'); - $accounts = $customer->getAttribute('accounts', []); - $this->assertEquals('account3', $accounts[0]['$id']); + $accounts = $customer->getDocuments('accounts'); + $this->assertEquals('account3', $accounts[0]->getId()); $this->assertArrayNotHasKey('customer', $accounts[0]); $customer = $database->getDocument('customer', 'customer4'); - $accounts = $customer->getAttribute('accounts', []); - $this->assertEquals('account4', $accounts[0]['$id']); + $accounts = $customer->getDocuments('accounts'); + $this->assertEquals('account4', $accounts[0]->getId()); $this->assertArrayNotHasKey('customer', $accounts[0]); // Get related documents $account = $database->getDocument('account', 'account1'); - $customer = $account->getAttribute('customer'); - $this->assertEquals('customer1', $customer['$id']); + $customer = $account->getDocument('customer'); + $this->assertEquals('customer1', $customer->getId()); $this->assertArrayNotHasKey('accounts', $customer); $account = $database->getDocument('account', 'account2'); - $customer = $account->getAttribute('customer'); - $this->assertEquals('customer2', $customer['$id']); + $customer = $account->getDocument('customer'); + $this->assertEquals('customer2', $customer->getId()); $this->assertArrayNotHasKey('accounts', $customer); $account = $database->getDocument('account', 'account3'); - $customer = $account->getAttribute('customer'); - $this->assertEquals('customer3', $customer['$id']); + $customer = $account->getDocument('customer'); + $this->assertEquals('customer3', $customer->getId()); $this->assertArrayNotHasKey('accounts', $customer); $account = $database->getDocument('account', 'account4'); - $customer = $account->getAttribute('customer'); - $this->assertEquals('customer4', $customer['$id']); + $customer = $account->getDocument('customer'); + $this->assertEquals('customer4', $customer->getId()); $this->assertArrayNotHasKey('accounts', $customer); $customers = $database->find('customer'); @@ -584,22 +584,22 @@ public function testOneToManyTwoWayRelationship(): void // Select related document attributes $customer = $database->findOne('customer', [ - Query::select(['*', 'accounts.name']) + Query::select(['*', 'accounts.name']), ]); if ($customer->isEmpty()) { throw new Exception('Customer not found'); } - $this->assertEquals('Account 1', $customer->getAttribute('accounts')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('number', $customer->getAttribute('accounts')[0]); + $this->assertEquals('Account 1', $customer->getDocuments('accounts')[0]->getAttribute('name')); + $this->assertArrayNotHasKey('number', $customer->getDocuments('accounts')[0]); $customer = $database->getDocument('customer', 'customer1', [ - Query::select(['*', 'accounts.name']) + Query::select(['*', 'accounts.name']), ]); - $this->assertEquals('Account 1', $customer->getAttribute('accounts')[0]->getAttribute('name')); - $this->assertArrayNotHasKey('number', $customer->getAttribute('accounts')[0]); + $this->assertEquals('Account 1', $customer->getDocuments('accounts')[0]->getAttribute('name')); + $this->assertArrayNotHasKey('number', $customer->getDocuments('accounts')[0]); // Update root document attribute without altering relationship $customer1 = $database->updateDocument( @@ -626,7 +626,7 @@ public function testOneToManyTwoWayRelationship(): void $this->assertEquals('Account 2 Updated', $account2->getAttribute('name')); // Update nested document attribute - $accounts = $customer1->getAttribute('accounts', []); + $accounts = $customer1->getDocuments('accounts'); $accounts[0]->setAttribute('name', 'Account 1 Updated'); $customer1 = $database->updateDocument( @@ -635,9 +635,9 @@ public function testOneToManyTwoWayRelationship(): void $customer1->setAttribute('accounts', $accounts) ); - $this->assertEquals('Account 1 Updated', $customer1->getAttribute('accounts')[0]->getAttribute('name')); + $this->assertEquals('Account 1 Updated', $customer1->getDocuments('accounts')[0]->getAttribute('name')); $customer1 = $database->getDocument('customer', 'customer1'); - $this->assertEquals('Account 1 Updated', $customer1->getAttribute('accounts')[0]->getAttribute('name')); + $this->assertEquals('Account 1 Updated', $customer1->getDocuments('accounts')[0]->getAttribute('name')); // Update inverse nested document attribute $account2 = $database->updateDocument( @@ -646,14 +646,14 @@ public function testOneToManyTwoWayRelationship(): void $account2->setAttribute( 'customer', $account2 - ->getAttribute('customer') + ->getDocument('customer') ->setAttribute('name', 'Customer 2 Updated') ) ); - $this->assertEquals('Customer 2 Updated', $account2->getAttribute('customer')->getAttribute('name')); + $this->assertEquals('Customer 2 Updated', $account2->getDocument('customer')->getAttribute('name')); $account2 = $database->getDocument('account', 'account2'); - $this->assertEquals('Customer 2 Updated', $account2->getAttribute('customer')->getAttribute('name')); + $this->assertEquals('Customer 2 Updated', $account2->getDocument('customer')->getAttribute('name')); // Create new document with no relationship $customer5 = $database->createDocument('customer', new Document([ @@ -682,9 +682,9 @@ public function testOneToManyTwoWayRelationship(): void ])]) ); - $this->assertEquals('Account 5', $customer5->getAttribute('accounts')[0]->getAttribute('name')); + $this->assertEquals('Account 5', $customer5->getDocuments('accounts')[0]->getAttribute('name')); $customer5 = $database->getDocument('customer', 'customer5'); - $this->assertEquals('Account 5', $customer5->getAttribute('accounts')[0]->getAttribute('name')); + $this->assertEquals('Account 5', $customer5->getDocuments('accounts')[0]->getAttribute('name')); // Create new child document with no relationship $account6 = $database->createDocument('account', new Document([ @@ -713,9 +713,9 @@ public function testOneToManyTwoWayRelationship(): void ])) ); - $this->assertEquals('Customer 6', $account6->getAttribute('customer')->getAttribute('name')); + $this->assertEquals('Customer 6', $account6->getDocument('customer')->getAttribute('name')); $account6 = $database->getDocument('account', 'account6'); - $this->assertEquals('Customer 6', $account6->getAttribute('customer')->getAttribute('name')); + $this->assertEquals('Customer 6', $account6->getDocument('customer')->getAttribute('name')); // Update document with new related document, will remove existing relations $database->updateDocument( @@ -742,19 +742,21 @@ public function testOneToManyTwoWayRelationship(): void $database->updateRelationship( 'customer', 'accounts', - 'newAccounts', - 'newCustomer' + new RelationshipUpdate( + key: 'newAccounts', + twoWayKey: 'newCustomer', + ) ); // Get document with new relationship key $customer = $database->getDocument('customer', 'customer1'); - $accounts = $customer->getAttribute('newAccounts'); - $this->assertEquals('account1', $accounts[0]['$id']); + $accounts = $customer->getDocuments('newAccounts'); + $this->assertEquals('account1', $accounts[0]->getId()); // Get inverse document with new relationship key $account = $database->getDocument('account', 'account1'); - $customer = $account->getAttribute('newCustomer'); - $this->assertEquals('customer1', $customer['$id']); + $customer = $account->getDocument('newCustomer'); + $this->assertEquals('customer1', $customer->getId()); // Create new document with no relationship $database->createDocument('customer', new Document([ @@ -785,8 +787,8 @@ public function testOneToManyTwoWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'customer', - id: 'newAccounts', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newAccounts', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); // Delete parent, set child relationship to null @@ -806,8 +808,8 @@ public function testOneToManyTwoWayRelationship(): void // Change on delete to cascade $database->updateRelationship( collection: 'customer', - id: 'newAccounts', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newAccounts', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -842,34 +844,27 @@ public function testNestedOneToMany_OneToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('countries'); - $database->createCollection('cities'); - $database->createCollection('mayors'); + $database->createCollection(Collection::create(id: 'countries')); + $database->createCollection(Collection::create(id: 'cities')); + $database->createCollection(Collection::create(id: 'mayors')); - $database->createAttribute('cities', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('countries', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('mayors', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('cities', Attribute::string(key: 'name', required: true)); + $database->createAttribute('countries', Attribute::string(key: 'name', required: true)); + $database->createAttribute('mayors', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'countries', - relatedCollection: 'cities', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - twoWayKey: 'country' - ); - $database->createRelationship( - collection: 'cities', + $database->createRelationship('countries', Relationship::oneToMany(relatedCollection: 'cities', twoWay: true, twoWayKey: 'country')); + $database->createRelationship('cities', Relationship::oneToOne( relatedCollection: 'mayors', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'mayor', + key: 'mayor', twoWayKey: 'city' - ); + )); $database->createDocument('countries', new Document([ '$id' => 'country1', @@ -913,36 +908,36 @@ public function testNestedOneToMany_OneToOneRelationship(): void ])); $documents = $database->find('countries', [ - Query::limit(1) + Query::limit(1), ]); - $this->assertEquals('Mayor 1', $documents[0]['cities'][0]['mayor']['name']); + $this->assertEquals('Mayor 1', $documents[0]->getDocuments('cities')[0]->getDocument('mayor')->getAttribute('name')); $documents = $database->find('countries', [ Query::select(['name']), - Query::limit(1) + Query::limit(1), ]); $this->assertArrayHasKey('name', $documents[0]); $this->assertArrayNotHasKey('cities', $documents[0]); $documents = $database->find('countries', [ Query::select(['*']), - Query::limit(1) + Query::limit(1), ]); $this->assertArrayHasKey('name', $documents[0]); $this->assertArrayNotHasKey('cities', $documents[0]); $documents = $database->find('countries', [ Query::select(['*', 'cities.*', 'cities.mayor.*']), - Query::limit(1) + Query::limit(1), ]); - $this->assertEquals('Mayor 1', $documents[0]['cities'][0]['mayor']['name']); + $this->assertEquals('Mayor 1', $documents[0]->getDocuments('cities')[0]->getDocument('mayor')->getAttribute('name')); // Insert docs to cache: $country1 = $database->getDocument('countries', 'country1'); $mayor1 = $database->getDocument('mayors', 'mayor1'); - $this->assertEquals('City 1', $mayor1['city']['name']); - $this->assertEquals('City 1', $country1['cities'][0]['name']); + $this->assertEquals('City 1', $mayor1->getDocument('city')->getAttribute('name')); + $this->assertEquals('City 1', $country1->getDocuments('cities')[0]->getAttribute('name')); $database->updateDocument('cities', 'city1', new Document([ '$id' => 'city1', @@ -958,14 +953,14 @@ public function testNestedOneToMany_OneToOneRelationship(): void $mayor1 = $database->getDocument('mayors', 'mayor1'); $country1 = $database->getDocument('countries', 'country1'); - $this->assertEquals('City 1 updated', $mayor1['city']['name']); - $this->assertEquals('City 1 updated', $country1['cities'][0]['name']); - $this->assertEquals('city1', $country1['cities'][0]['$id']); - $this->assertEquals('city2', $country1['cities'][1]['$id']); - $this->assertEquals('mayor1', $country1['cities'][0]['mayor']['$id']); - $this->assertEquals('mayor2', $country1['cities'][1]['mayor']['$id']); - $this->assertArrayNotHasKey('city', $country1['cities'][0]['mayor']); - $this->assertArrayNotHasKey('city', $country1['cities'][1]['mayor']); + $this->assertEquals('City 1 updated', $mayor1->getDocument('city')->getAttribute('name')); + $this->assertEquals('City 1 updated', $country1->getDocuments('cities')[0]->getAttribute('name')); + $this->assertEquals('city1', $country1->getDocuments('cities')[0]->getId()); + $this->assertEquals('city2', $country1->getDocuments('cities')[1]->getId()); + $this->assertEquals('mayor1', $country1->getDocuments('cities')[0]->getDocument('mayor')->getId()); + $this->assertEquals('mayor2', $country1->getDocuments('cities')[1]->getDocument('mayor')->getId()); + $this->assertArrayNotHasKey('city', $country1->getDocuments('cities')[0]->getDocument('mayor')); + $this->assertArrayNotHasKey('city', $country1->getDocuments('cities')[1]->getDocument('mayor')); $database->createDocument('mayors', new Document([ '$id' => 'mayor3', @@ -990,10 +985,10 @@ public function testNestedOneToMany_OneToOneRelationship(): void ])); $country2 = $database->getDocument('countries', 'country2'); - $this->assertEquals('city3', $country2['cities'][0]['$id']); - $this->assertEquals('mayor3', $country2['cities'][0]['mayor']['$id']); - $this->assertArrayNotHasKey('country', $country2['cities'][0]); - $this->assertArrayNotHasKey('city', $country2['cities'][0]['mayor']); + $this->assertEquals('city3', $country2->getDocuments('cities')[0]->getId()); + $this->assertEquals('mayor3', $country2->getDocuments('cities')[0]->getDocument('mayor')->getId()); + $this->assertArrayNotHasKey('country', $country2->getDocuments('cities')[0]); + $this->assertArrayNotHasKey('city', $country2->getDocuments('cities')[0]->getDocument('mayor')); } public function testNestedOneToMany_OneToManyRelationship(): void @@ -1001,33 +996,22 @@ public function testNestedOneToMany_OneToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('dormitories'); - $database->createCollection('occupants'); - $database->createCollection('pets'); + $database->createCollection(Collection::create(id: 'dormitories')); + $database->createCollection(Collection::create(id: 'occupants')); + $database->createCollection(Collection::create(id: 'pets')); - $database->createAttribute('dormitories', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('occupants', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('pets', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('dormitories', Attribute::string(key: 'name', required: true)); + $database->createAttribute('occupants', Attribute::string(key: 'name', required: true)); + $database->createAttribute('pets', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'dormitories', - relatedCollection: 'occupants', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - twoWayKey: 'dormitory' - ); - $database->createRelationship( - collection: 'occupants', - relatedCollection: 'pets', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - twoWayKey: 'occupant' - ); + $database->createRelationship('dormitories', Relationship::oneToMany(relatedCollection: 'occupants', twoWay: true, twoWayKey: 'dormitory')); + $database->createRelationship('occupants', Relationship::oneToMany(relatedCollection: 'pets', twoWay: true, twoWayKey: 'occupant')); $database->createDocument('dormitories', new Document([ '$id' => 'dormitory1', @@ -1086,18 +1070,18 @@ public function testNestedOneToMany_OneToManyRelationship(): void ])); $dormitory1 = $database->getDocument('dormitories', 'dormitory1'); - $this->assertEquals('occupant1', $dormitory1['occupants'][0]['$id']); - $this->assertEquals('occupant2', $dormitory1['occupants'][1]['$id']); - $this->assertEquals('pet1', $dormitory1['occupants'][0]['pets'][0]['$id']); - $this->assertEquals('pet2', $dormitory1['occupants'][0]['pets'][1]['$id']); - $this->assertEquals('pet3', $dormitory1['occupants'][1]['pets'][0]['$id']); - $this->assertEquals('pet4', $dormitory1['occupants'][1]['pets'][1]['$id']); - $this->assertArrayNotHasKey('dormitory', $dormitory1['occupants'][0]); - $this->assertArrayNotHasKey('dormitory', $dormitory1['occupants'][1]); - $this->assertArrayNotHasKey('occupant', $dormitory1['occupants'][0]['pets'][0]); - $this->assertArrayNotHasKey('occupant', $dormitory1['occupants'][0]['pets'][1]); - $this->assertArrayNotHasKey('occupant', $dormitory1['occupants'][1]['pets'][0]); - $this->assertArrayNotHasKey('occupant', $dormitory1['occupants'][1]['pets'][1]); + $this->assertEquals('occupant1', $dormitory1->getDocuments('occupants')[0]->getId()); + $this->assertEquals('occupant2', $dormitory1->getDocuments('occupants')[1]->getId()); + $this->assertEquals('pet1', $dormitory1->getDocuments('occupants')[0]->getDocuments('pets')[0]->getId()); + $this->assertEquals('pet2', $dormitory1->getDocuments('occupants')[0]->getDocuments('pets')[1]->getId()); + $this->assertEquals('pet3', $dormitory1->getDocuments('occupants')[1]->getDocuments('pets')[0]->getId()); + $this->assertEquals('pet4', $dormitory1->getDocuments('occupants')[1]->getDocuments('pets')[1]->getId()); + $this->assertArrayNotHasKey('dormitory', $dormitory1->getDocuments('occupants')[0]); + $this->assertArrayNotHasKey('dormitory', $dormitory1->getDocuments('occupants')[1]); + $this->assertArrayNotHasKey('occupant', $dormitory1->getDocuments('occupants')[0]->getDocuments('pets')[0]); + $this->assertArrayNotHasKey('occupant', $dormitory1->getDocuments('occupants')[0]->getDocuments('pets')[1]); + $this->assertArrayNotHasKey('occupant', $dormitory1->getDocuments('occupants')[1]->getDocuments('pets')[0]); + $this->assertArrayNotHasKey('occupant', $dormitory1->getDocuments('occupants')[1]->getDocuments('pets')[1]); $database->createDocument('pets', new Document([ '$id' => 'pet5', @@ -1122,10 +1106,10 @@ public function testNestedOneToMany_OneToManyRelationship(): void ])); $pet5 = $database->getDocument('pets', 'pet5'); - $this->assertEquals('occupant3', $pet5['occupant']['$id']); - $this->assertEquals('dormitory2', $pet5['occupant']['dormitory']['$id']); - $this->assertArrayNotHasKey('pets', $pet5['occupant']); - $this->assertArrayNotHasKey('occupant', $pet5['occupant']['dormitory']); + $this->assertEquals('occupant3', $pet5->getDocument('occupant')->getId()); + $this->assertEquals('dormitory2', $pet5->getDocument('occupant')->getDocument('dormitory')->getId()); + $this->assertArrayNotHasKey('pets', $pet5->getDocument('occupant')); + $this->assertArrayNotHasKey('occupant', $pet5->getDocument('occupant')->getDocument('dormitory')); } public function testNestedOneToMany_ManyToOneRelationship(): void @@ -1133,32 +1117,22 @@ public function testNestedOneToMany_ManyToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('home'); - $database->createCollection('renters'); - $database->createCollection('floors'); + $database->createCollection(Collection::create(id: 'home')); + $database->createCollection(Collection::create(id: 'renters')); + $database->createCollection(Collection::create(id: 'floors')); - $database->createAttribute('home', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('renters', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('floors', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('home', Attribute::string(key: 'name', required: true)); + $database->createAttribute('renters', Attribute::string(key: 'name', required: true)); + $database->createAttribute('floors', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'home', - relatedCollection: 'renters', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true - ); - $database->createRelationship( - collection: 'renters', - relatedCollection: 'floors', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'floor' - ); + $database->createRelationship('home', Relationship::oneToMany(relatedCollection: 'renters', twoWay: true)); + $database->createRelationship('renters', Relationship::manyToOne(relatedCollection: 'floors', twoWay: true, key: 'floor')); $database->createDocument('home', new Document([ '$id' => 'home1', @@ -1185,10 +1159,10 @@ public function testNestedOneToMany_ManyToOneRelationship(): void ])); $home1 = $database->getDocument('home', 'home1'); - $this->assertEquals('renter1', $home1['renters'][0]['$id']); - $this->assertEquals('floor1', $home1['renters'][0]['floor']['$id']); - $this->assertArrayNotHasKey('home', $home1['renters'][0]); - $this->assertArrayNotHasKey('renters', $home1['renters'][0]['floor']); + $this->assertEquals('renter1', $home1->getDocuments('renters')[0]->getId()); + $this->assertEquals('floor1', $home1->getDocuments('renters')[0]->getDocument('floor')->getId()); + $this->assertArrayNotHasKey('home', $home1->getDocuments('renters')[0]); + $this->assertArrayNotHasKey('renters', $home1->getDocuments('renters')[0]->getDocument('floor')); $database->createDocument('floors', new Document([ '$id' => 'floor2', @@ -1215,10 +1189,10 @@ public function testNestedOneToMany_ManyToOneRelationship(): void ])); $floor2 = $database->getDocument('floors', 'floor2'); - $this->assertEquals('renter2', $floor2['renters'][0]['$id']); - $this->assertArrayNotHasKey('floor', $floor2['renters'][0]); - $this->assertEquals('home2', $floor2['renters'][0]['home']['$id']); - $this->assertArrayNotHasKey('renter', $floor2['renters'][0]['home']); + $this->assertEquals('renter2', $floor2->getDocuments('renters')[0]->getId()); + $this->assertArrayNotHasKey('floor', $floor2->getDocuments('renters')[0]); + $this->assertEquals('home2', $floor2->getDocuments('renters')[0]->getDocument('home')->getId()); + $this->assertArrayNotHasKey('renter', $floor2->getDocuments('renters')[0]->getDocument('home')); } public function testNestedOneToMany_ManyToManyRelationship(): void @@ -1226,32 +1200,22 @@ public function testNestedOneToMany_ManyToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('owners'); - $database->createCollection('cats'); - $database->createCollection('toys'); + $database->createCollection(Collection::create(id: 'owners')); + $database->createCollection(Collection::create(id: 'cats')); + $database->createCollection(Collection::create(id: 'toys')); - $database->createAttribute('owners', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('cats', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('toys', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('owners', Attribute::string(key: 'name', required: true)); + $database->createAttribute('cats', Attribute::string(key: 'name', required: true)); + $database->createAttribute('toys', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'owners', - relatedCollection: 'cats', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - twoWayKey: 'owner' - ); - $database->createRelationship( - collection: 'cats', - relatedCollection: 'toys', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true - ); + $database->createRelationship('owners', Relationship::oneToMany(relatedCollection: 'cats', twoWay: true, twoWayKey: 'owner')); + $database->createRelationship('cats', Relationship::manyToMany(relatedCollection: 'toys', twoWay: true)); $database->createDocument('owners', new Document([ '$id' => 'owner1', @@ -1280,10 +1244,10 @@ public function testNestedOneToMany_ManyToManyRelationship(): void ])); $owner1 = $database->getDocument('owners', 'owner1'); - $this->assertEquals('cat1', $owner1['cats'][0]['$id']); - $this->assertArrayNotHasKey('owner', $owner1['cats'][0]); - $this->assertEquals('toy1', $owner1['cats'][0]['toys'][0]['$id']); - $this->assertArrayNotHasKey('cats', $owner1['cats'][0]['toys'][0]); + $this->assertEquals('cat1', $owner1->getDocuments('cats')[0]->getId()); + $this->assertArrayNotHasKey('owner', $owner1->getDocuments('cats')[0]); + $this->assertEquals('toy1', $owner1->getDocuments('cats')[0]->getDocuments('toys')[0]->getId()); + $this->assertArrayNotHasKey('cats', $owner1->getDocuments('cats')[0]->getDocuments('toys')[0]); $database->createDocument('toys', new Document([ '$id' => 'toy2', @@ -1310,10 +1274,10 @@ public function testNestedOneToMany_ManyToManyRelationship(): void ])); $toy2 = $database->getDocument('toys', 'toy2'); - $this->assertEquals('cat2', $toy2['cats'][0]['$id']); - $this->assertArrayNotHasKey('toys', $toy2['cats'][0]); - $this->assertEquals('owner2', $toy2['cats'][0]['owner']['$id']); - $this->assertArrayNotHasKey('cats', $toy2['cats'][0]['owner']); + $this->assertEquals('cat2', $toy2->getDocuments('cats')[0]->getId()); + $this->assertArrayNotHasKey('toys', $toy2->getDocuments('cats')[0]); + $this->assertEquals('owner2', $toy2->getDocuments('cats')[0]->getDocument('owner')->getId()); + $this->assertArrayNotHasKey('cats', $toy2->getDocuments('cats')[0]->getDocument('owner')); } public function testExceedMaxDepthOneToMany(): void @@ -1321,8 +1285,9 @@ public function testExceedMaxDepthOneToMany(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -1331,29 +1296,14 @@ public function testExceedMaxDepthOneToMany(): void $level3Collection = 'level3OneToMany'; $level4Collection = 'level4OneToMany'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(Collection::create(id: $level1Collection)); + $database->createCollection(Collection::create(id: $level2Collection)); + $database->createCollection(Collection::create(id: $level3Collection)); + $database->createCollection(Collection::create(id: $level4Collection)); - $database->createRelationship( - collection: $level1Collection, - relatedCollection: $level2Collection, - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: $level2Collection, - relatedCollection: $level3Collection, - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: $level3Collection, - relatedCollection: $level4Collection, - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $database->createRelationship($level1Collection, Relationship::oneToMany(relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship($level2Collection, Relationship::oneToMany(relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship($level3Collection, Relationship::oneToMany(relatedCollection: $level4Collection, twoWay: true)); // Exceed create depth $level1 = $database->createDocument($level1Collection, new Document([ @@ -1378,26 +1328,25 @@ public function testExceedMaxDepthOneToMany(): void ], ], ])); - $this->assertEquals(1, count($level1[$level2Collection])); - $this->assertEquals('level2', $level1[$level2Collection][0]->getId()); - $this->assertEquals(1, count($level1[$level2Collection][0][$level3Collection])); - $this->assertEquals('level3', $level1[$level2Collection][0][$level3Collection][0]->getId()); - $this->assertArrayNotHasKey('level4', $level1[$level2Collection][0][$level3Collection][0]); + $this->assertEquals(1, count($level1->getDocuments($level2Collection))); + $this->assertEquals('level2', $level1->getDocuments($level2Collection)[0]->getId()); + $this->assertEquals(1, count($level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection))); + $this->assertEquals('level3', $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]->getId()); + $this->assertArrayNotHasKey('level4', $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]); // Make sure level 4 document was not created $level3 = $database->getDocument($level3Collection, 'level3'); - $this->assertEquals(0, count($level3[$level4Collection])); + $this->assertEquals(0, count($level3->getDocuments($level4Collection))); $level4 = $database->getDocument($level4Collection, 'level4'); $this->assertTrue($level4->isEmpty()); // Exceed fetch depth $level1 = $database->getDocument($level1Collection, 'level1'); - $this->assertEquals(1, count($level1[$level2Collection])); - $this->assertEquals('level2', $level1[$level2Collection][0]->getId()); - $this->assertEquals(1, count($level1[$level2Collection][0][$level3Collection])); - $this->assertEquals('level3', $level1[$level2Collection][0][$level3Collection][0]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][0][$level3Collection][0]); - + $this->assertEquals(1, count($level1->getDocuments($level2Collection))); + $this->assertEquals('level2', $level1->getDocuments($level2Collection)[0]->getId()); + $this->assertEquals(1, count($level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection))); + $this->assertEquals('level3', $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]->getId()); + $this->assertArrayNotHasKey($level4Collection, $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]); // Exceed update depth $level1 = $database->updateDocument( @@ -1418,25 +1367,27 @@ public function testExceedMaxDepthOneToMany(): void ], ])]) ); - $this->assertEquals(1, count($level1[$level2Collection])); - $this->assertEquals('level2new', $level1[$level2Collection][0]->getId()); - $this->assertEquals(1, count($level1[$level2Collection][0][$level3Collection])); - $this->assertEquals('level3new', $level1[$level2Collection][0][$level3Collection][0]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][0][$level3Collection][0]); + $this->assertEquals(1, count($level1->getDocuments($level2Collection))); + $this->assertEquals('level2new', $level1->getDocuments($level2Collection)[0]->getId()); + $this->assertEquals(1, count($level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection))); + $this->assertEquals('level3new', $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]->getId()); + $this->assertArrayNotHasKey($level4Collection, $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]); // Make sure level 4 document was not created $level3 = $database->getDocument($level3Collection, 'level3new'); - $this->assertEquals(0, count($level3[$level4Collection])); + $this->assertEquals(0, count($level3->getDocuments($level4Collection))); $level4 = $database->getDocument($level4Collection, 'level4new'); $this->assertTrue($level4->isEmpty()); } + public function testExceedMaxDepthOneToManyChild(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -1445,29 +1396,14 @@ public function testExceedMaxDepthOneToManyChild(): void $level3Collection = 'level3OneToManyChild'; $level4Collection = 'level4OneToManyChild'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(Collection::create(id: $level1Collection)); + $database->createCollection(Collection::create(id: $level2Collection)); + $database->createCollection(Collection::create(id: $level3Collection)); + $database->createCollection(Collection::create(id: $level4Collection)); - $database->createRelationship( - collection: $level1Collection, - relatedCollection: $level2Collection, - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: $level2Collection, - relatedCollection: $level3Collection, - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); - $database->createRelationship( - collection: $level3Collection, - relatedCollection: $level4Collection, - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $database->createRelationship($level1Collection, Relationship::oneToMany(relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship($level2Collection, Relationship::oneToMany(relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship($level3Collection, Relationship::oneToMany(relatedCollection: $level4Collection, twoWay: true)); $level1 = $database->createDocument($level1Collection, new Document([ '$id' => 'level1', @@ -1485,41 +1421,41 @@ public function testExceedMaxDepthOneToManyChild(): void [ '$id' => 'level4', ], - ] + ], ], ], ], ], ])); $this->assertArrayHasKey($level2Collection, $level1); - $this->assertEquals('level2', $level1[$level2Collection][0]->getId()); - $this->assertArrayHasKey($level3Collection, $level1[$level2Collection][0]); - $this->assertEquals('level3', $level1[$level2Collection][0][$level3Collection][0]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][0][$level3Collection][0]); + $this->assertEquals('level2', $level1->getDocuments($level2Collection)[0]->getId()); + $this->assertArrayHasKey($level3Collection, $level1->getDocuments($level2Collection)[0]); + $this->assertEquals('level3', $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]->getId()); + $this->assertArrayNotHasKey($level4Collection, $level1->getDocuments($level2Collection)[0]->getDocuments($level3Collection)[0]); // Confirm the 4th level document does not exist $level3 = $database->getDocument($level3Collection, 'level3'); - $this->assertEquals(0, count($level3[$level4Collection])); + $this->assertEquals(0, count($level3->getDocuments($level4Collection))); // Create level 4 document $level3->setAttribute($level4Collection, [new Document([ '$id' => 'level4', ])]); $level3 = $database->updateDocument($level3Collection, $level3->getId(), $level3); - $this->assertEquals('level4', $level3[$level4Collection][0]->getId()); + $this->assertEquals('level4', $level3->getDocuments($level4Collection)[0]->getId()); // Verify level 4 document is set $level3 = $database->getDocument($level3Collection, 'level3'); $this->assertArrayHasKey($level4Collection, $level3); - $this->assertEquals('level4', $level3[$level4Collection][0]->getId()); + $this->assertEquals('level4', $level3->getDocuments($level4Collection)[0]->getId()); // Exceed fetch depth $level4 = $database->getDocument($level4Collection, 'level4'); $this->assertArrayHasKey($level3Collection, $level4); - $this->assertEquals('level3', $level4[$level3Collection]->getId()); - $this->assertArrayHasKey($level2Collection, $level4[$level3Collection]); - $this->assertEquals('level2', $level4[$level3Collection][$level2Collection]->getId()); - $this->assertArrayNotHasKey($level1Collection, $level4[$level3Collection][$level2Collection]); + $this->assertEquals('level3', $level4->getDocument($level3Collection)->getId()); + $this->assertArrayHasKey($level2Collection, $level4->getDocument($level3Collection)); + $this->assertEquals('level2', $level4->getDocument($level3Collection)->getDocument($level2Collection)->getId()); + $this->assertArrayNotHasKey($level1Collection, $level4->getDocument($level3Collection)->getDocument($level2Collection)); } public function testOneToManyRelationshipKeyWithSymbols(): void @@ -1527,42 +1463,38 @@ public function testOneToManyRelationshipKeyWithSymbols(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('$symbols_coll.ection3'); - $database->createCollection('$symbols_coll.ection4'); + $database->createCollection(Collection::create(id: '$symbols_coll.ection3')); + $database->createCollection(Collection::create(id: '$symbols_coll.ection4')); - $database->createRelationship( - collection: '$symbols_coll.ection3', - relatedCollection: '$symbols_coll.ection4', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $database->createRelationship('$symbols_coll.ection3', Relationship::oneToMany(relatedCollection: '$symbols_coll.ection4', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection4', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc2 = $database->createDocument('$symbols_coll.ection3', new Document([ - '$id' => ID::unique(), - '$symbols_coll.ection4' => [$doc1->getId()], + '$id' => Id::unique(), + 'symbols_collection4' => [$doc1->getId()], '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc1 = $database->getDocument('$symbols_coll.ection4', $doc1->getId()); $doc2 = $database->getDocument('$symbols_coll.ection3', $doc2->getId()); - $this->assertEquals($doc2->getId(), $doc1->getAttribute('$symbols_coll.ection3')->getId()); - $this->assertEquals($doc1->getId(), $doc2->getAttribute('$symbols_coll.ection4')[0]->getId()); + $this->assertEquals($doc2->getId(), $doc1->getDocument('symbols_collection3')->getId()); + $this->assertEquals($doc1->getId(), $doc2->getDocuments('symbols_collection4')[0]->getId()); } public function testRecreateOneToManyOneWayRelationshipFromChild(): void @@ -1570,65 +1502,42 @@ public function testRecreateOneToManyOneWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - ); + $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - ); + $relationship = $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToManyTwoWayRelationshipFromParent(): void @@ -1636,67 +1545,42 @@ public function testRecreateOneToManyTwoWayRelationshipFromParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToManyTwoWayRelationshipFromChild(): void @@ -1704,67 +1588,42 @@ public function testRecreateOneToManyTwoWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToManyOneWayRelationshipFromParent(): void @@ -1772,65 +1631,42 @@ public function testRecreateOneToManyOneWayRelationshipFromParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - ); + $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - ); + $relationship = $database->createRelationship($one, Relationship::oneToMany(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testDeleteBulkDocumentsOneToManyRelationship(): void @@ -1838,25 +1674,21 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_o2m'); - $this->getDatabase()->createCollection('bulk_delete_library_o2m'); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_person_o2m')); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_library_o2m')); - $this->getDatabase()->createAttribute('bulk_delete_person_o2m', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_o2m', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_o2m', 'area', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_person_o2m', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_o2m', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_o2m', Attribute::string(key: 'area', required: true)); // Restrict - $this->getDatabase()->createRelationship( - collection: 'bulk_delete_person_o2m', - relatedCollection: 'bulk_delete_library_o2m', - type: Database::RELATION_ONE_TO_MANY, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $this->getDatabase()->createRelationship('bulk_delete_person_o2m', Relationship::oneToMany(relatedCollection: 'bulk_delete_library_o2m')); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ '$id' => 'person1', @@ -1891,7 +1723,7 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); - $libraries = $person1->getAttribute('bulk_delete_library_o2m'); + $libraries = $person1->getDocuments('bulk_delete_library_o2m'); $this->assertCount(2, $libraries); // Delete person @@ -1912,8 +1744,8 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void // NULL $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2m', - id: 'bulk_delete_library_o2m', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'bulk_delete_library_o2m', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ @@ -1949,26 +1781,25 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); - $libraries = $person1->getAttribute('bulk_delete_library_o2m'); + $libraries = $person1->getDocuments('bulk_delete_library_o2m'); $this->assertCount(2, $libraries); $this->getDatabase()->deleteDocuments('bulk_delete_library_o2m'); $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2m')); $person = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); - $libraries = $person->getAttribute('bulk_delete_library_o2m'); + $libraries = $person->getDocuments('bulk_delete_library_o2m'); $this->assertEmpty($libraries); // NULL - Cleanup $this->getDatabase()->deleteDocuments('bulk_delete_person_o2m'); $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_o2m')); - // Cascade $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2m', - id: 'bulk_delete_library_o2m', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'bulk_delete_library_o2m', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ @@ -2004,104 +1835,99 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); - $libraries = $person1->getAttribute('bulk_delete_library_o2m'); + $libraries = $person1->getDocuments('bulk_delete_library_o2m'); $this->assertCount(2, $libraries); $this->getDatabase()->deleteDocuments('bulk_delete_library_o2m'); $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2m')); $person = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); - $libraries = $person->getAttribute('bulk_delete_library_o2m'); + $libraries = $person->getDocuments('bulk_delete_library_o2m'); $this->assertEmpty($libraries); } - public function testOneToManyAndManyToOneDeleteRelationship(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('relation1'); - $database->createCollection('relation2'); + $relation1 = 'relation1_' . uniqid(); + $relation2 = 'relation2_' . uniqid(); - $database->createRelationship( - collection: 'relation1', - relatedCollection: 'relation2', - type: Database::RELATION_ONE_TO_MANY, - ); + $database->createCollection(Collection::create(id: $relation1)); + $database->createCollection(Collection::create(id: $relation2)); - $relation1 = $database->getCollection('relation1'); - $this->assertCount(1, $relation1->getAttribute('attributes')); - $this->assertCount(0, $relation1->getAttribute('indexes')); - $relation2 = $database->getCollection('relation2'); - $this->assertCount(1, $relation2->getAttribute('attributes')); - $this->assertCount(1, $relation2->getAttribute('indexes')); - - $database->deleteRelationship('relation2', 'relation1'); - - $relation1 = $database->getCollection('relation1'); - $this->assertCount(0, $relation1->getAttribute('attributes')); - $this->assertCount(0, $relation1->getAttribute('indexes')); - $relation2 = $database->getCollection('relation2'); - $this->assertCount(0, $relation2->getAttribute('attributes')); - $this->assertCount(0, $relation2->getAttribute('indexes')); - - $database->createRelationship( - collection: 'relation1', - relatedCollection: 'relation2', - type: Database::RELATION_MANY_TO_ONE, - ); + $database->createRelationship($relation1, Relationship::oneToMany(relatedCollection: $relation2)); + + $relation1Col = $database->getCollection($relation1); + $this->assertCount(1, $relation1Col->attributes()); + $this->assertCount(0, $relation1Col->indexes()); + $relation2Col = $database->getCollection($relation2); + $this->assertCount(1, $relation2Col->attributes()); + $this->assertCount(1, $relation2Col->indexes()); + + $database->deleteRelationship($relation2, $relation1); - $relation1 = $database->getCollection('relation1'); - $this->assertCount(1, $relation1->getAttribute('attributes')); - $this->assertCount(1, $relation1->getAttribute('indexes')); - $relation2 = $database->getCollection('relation2'); - $this->assertCount(1, $relation2->getAttribute('attributes')); - $this->assertCount(0, $relation2->getAttribute('indexes')); - - $database->deleteRelationship('relation1', 'relation2'); - - $relation1 = $database->getCollection('relation1'); - $this->assertCount(0, $relation1->getAttribute('attributes')); - $this->assertCount(0, $relation1->getAttribute('indexes')); - $relation2 = $database->getCollection('relation2'); - $this->assertCount(0, $relation2->getAttribute('attributes')); - $this->assertCount(0, $relation2->getAttribute('indexes')); + $relation1Col = $database->getCollection($relation1); + $this->assertCount(0, $relation1Col->attributes()); + $this->assertCount(0, $relation1Col->indexes()); + $relation2Col = $database->getCollection($relation2); + $this->assertCount(0, $relation2Col->attributes()); + $this->assertCount(0, $relation2Col->indexes()); + + $database->createRelationship($relation1, Relationship::manyToOne(relatedCollection: $relation2)); + + $relation1Col = $database->getCollection($relation1); + $this->assertCount(1, $relation1Col->attributes()); + $this->assertCount(1, $relation1Col->indexes()); + $relation2Col = $database->getCollection($relation2); + $this->assertCount(1, $relation2Col->attributes()); + $this->assertCount(0, $relation2Col->indexes()); + + $database->deleteRelationship($relation1, $relation2); + + $relation1Col = $database->getCollection($relation1); + $this->assertCount(0, $relation1Col->attributes()); + $this->assertCount(0, $relation1Col->indexes()); + $relation2Col = $database->getCollection($relation2); + $this->assertCount(0, $relation2Col->attributes()); + $this->assertCount(0, $relation2Col->indexes()); + + $database->deleteCollection($relation1); + $database->deleteCollection($relation2); } + public function testUpdateParentAndChild_OneToMany(): void { /** @var Database $database */ $database = $this->getDatabase(); if ( - !$database->getAdapter()->getSupportForRelationships() || - !$database->getAdapter()->getSupportForBatchOperations() + ! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || + ! $this->supportsBulkWrites() ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_o2m'; $childCollection = 'child_combined_o2m'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'parentNumber', Database::VAR_INTEGER, 0, false); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::integer(key: 'parentNumber')); - $database->createRelationship( - collection: $parentCollection, - relatedCollection: $childCollection, - type: Database::RELATION_ONE_TO_MANY, - id: 'parentNumber' - ); + $database->createRelationship($parentCollection, Relationship::oneToMany(relatedCollection: $childCollection, key: 'parentNumber')); $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2155,30 +1981,27 @@ public function testUpdateParentAndChild_OneToMany(): void $database->deleteCollection($parentCollection); $database->deleteCollection($childCollection); } + public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToMany(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_relationship_error_one_to_many'; $childCollection = 'child_relationship_error_one_to_many'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: $parentCollection, - relatedCollection: $childCollection, - type: Database::RELATION_ONE_TO_MANY, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $database->createRelationship($parentCollection, Relationship::oneToMany(relatedCollection: $childCollection)); $parent = $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2197,8 +2020,8 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToMan Permission::delete(Role::any()), ], 'name' => 'Child 1', - ] - ] + ], + ], ])); try { @@ -2220,30 +2043,31 @@ public function testPartialBatchUpdateWithRelationships(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - // Setup collections with relationships - $database->createCollection('products'); - $database->createCollection('categories'); + $products = 'products_' . uniqid(); + $categories = 'categories_' . uniqid(); + + $database->createCollection(Collection::create(id: $products)); + $database->createCollection(Collection::create(id: $categories)); - $database->createAttribute('products', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('products', 'price', Database::VAR_FLOAT, 0, true); - $database->createAttribute('categories', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute($products, Attribute::string(key: 'name', required: true)); + $database->createAttribute($products, Attribute::double(key: 'price', required: true)); + $database->createAttribute($categories, Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'categories', - relatedCollection: 'products', - type: Database::RELATION_ONE_TO_MANY, + $database->createRelationship($categories, Relationship::oneToMany( + relatedCollection: $products, twoWay: true, - id: 'products', + key: 'products', twoWayKey: 'category' - ); + )); // Create category with products - $database->createDocument('categories', new Document([ + $database->createDocument($categories, new Document([ '$id' => 'electronics', '$permissions' => [ Permission::read(Role::any()), @@ -2273,51 +2097,49 @@ public function testPartialBatchUpdateWithRelationships(): void ])); // Verify initial state - $product1 = $database->getDocument('products', 'product1'); + $product1 = $database->getDocument($products, 'product1'); $this->assertEquals('Laptop', $product1->getAttribute('name')); $this->assertEquals(999.99, $product1->getAttribute('price')); - $this->assertEquals('electronics', $product1->getAttribute('category')->getId()); + $this->assertEquals('electronics', $product1->getDocument('category')->getId()); - $product2 = $database->getDocument('products', 'product2'); + $product2 = $database->getDocument($products, 'product2'); $this->assertEquals('Mouse', $product2->getAttribute('name')); $this->assertEquals(25.50, $product2->getAttribute('price')); - $this->assertEquals('electronics', $product2->getAttribute('category')->getId()); + $this->assertEquals('electronics', $product2->getDocument('category')->getId()); // Perform a BATCH partial update - ONLY update price, NOT the category relationship - // This is the critical test case - batch updates with relationships $database->updateDocuments( - 'products', + $products, new Document([ - 'price' => 50.00, // Update price for all matching products - // NOTE: We deliberately do NOT include the 'category' field here - this is a partial update + 'price' => 50.00, ]), [Query::equal('$id', ['product1', 'product2'])] ); // Verify that prices were updated but category relationships were preserved - $product1After = $database->getDocument('products', 'product1'); + $product1After = $database->getDocument($products, 'product1'); $this->assertEquals('Laptop', $product1After->getAttribute('name'), 'Product name should be preserved'); $this->assertEquals(50.00, $product1After->getAttribute('price'), 'Price should be updated'); // This is the critical assertion - the category relationship should still exist after batch partial update - $categoryAfter = $product1After->getAttribute('category'); - $this->assertNotNull($categoryAfter, 'Category relationship should be preserved after batch partial update'); + $categoryAfter = $product1After->getDocument('category'); + $this->assertFalse($categoryAfter->isEmpty(), 'Category relationship should be preserved after batch partial update'); $this->assertEquals('electronics', $categoryAfter->getId(), 'Category should still be electronics'); - $product2After = $database->getDocument('products', 'product2'); + $product2After = $database->getDocument($products, 'product2'); $this->assertEquals('Mouse', $product2After->getAttribute('name'), 'Product name should be preserved'); $this->assertEquals(50.00, $product2After->getAttribute('price'), 'Price should be updated'); - $this->assertEquals('electronics', $product2After->getAttribute('category')->getId(), 'Category should still be electronics'); + $this->assertEquals('electronics', $product2After->getDocument('category')->getId(), 'Category should still be electronics'); // Verify the reverse relationship is still intact - $category = $database->getDocument('categories', 'electronics'); - $products = $category->getAttribute('products'); - $this->assertCount(2, $products, 'Category should still have 2 products'); - $this->assertEquals('product1', $products[0]->getId()); - $this->assertEquals('product2', $products[1]->getId()); - - $database->deleteCollection('products'); - $database->deleteCollection('categories'); + $category = $database->getDocument($categories, 'electronics'); + $productsArr = $category->getDocuments('products'); + $this->assertCount(2, $productsArr, 'Category should still have 2 products'); + $this->assertEquals('product1', $productsArr[0]->getId()); + $this->assertEquals('product2', $productsArr[1]->getId()); + + $database->deleteCollection($products); + $database->deleteCollection($categories); } public function testPartialUpdateOnlyRelationship(): void @@ -2325,30 +2147,31 @@ public function testPartialUpdateOnlyRelationship(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - // Setup collections - $database->createCollection('authors'); - $database->createCollection('books'); + $authors = 'authors_' . uniqid(); + $books = 'books_' . uniqid(); + + $database->createCollection(Collection::create(id: $authors)); + $database->createCollection(Collection::create(id: $books)); - $database->createAttribute('authors', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('authors', 'bio', Database::VAR_STRING, 1000, false); - $database->createAttribute('books', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute($authors, Attribute::string(key: 'name', required: true)); + $database->createAttribute($authors, Attribute::string(key: 'bio', size: 1000)); + $database->createAttribute($books, Attribute::string(key: 'title', required: true)); - $database->createRelationship( - collection: 'authors', - relatedCollection: 'books', - type: Database::RELATION_ONE_TO_MANY, + $database->createRelationship($authors, Relationship::oneToMany( + relatedCollection: $books, twoWay: true, - id: 'books', + key: 'books', twoWayKey: 'author' - ); + )); // Create author with one book - $database->createDocument('authors', new Document([ + $database->createDocument($authors, new Document([ '$id' => 'author1', '$permissions' => [ Permission::read(Role::any()), @@ -2369,7 +2192,7 @@ public function testPartialUpdateOnlyRelationship(): void ])); // Create a second book independently - $database->createDocument('books', new Document([ + $database->createDocument($books, new Document([ '$id' => 'book2', '$permissions' => [ Permission::read(Role::any()), @@ -2379,19 +2202,17 @@ public function testPartialUpdateOnlyRelationship(): void ])); // Verify initial state - $author = $database->getDocument('authors', 'author1'); + $author = $database->getDocument($authors, 'author1'); $this->assertEquals('John Doe', $author->getAttribute('name')); $this->assertEquals('A great author', $author->getAttribute('bio')); - $this->assertCount(1, $author->getAttribute('books')); - $this->assertEquals('book1', $author->getAttribute('books')[0]->getId()); + $this->assertCount(1, $author->getDocuments('books')); + $this->assertEquals('book1', $author->getDocuments('books')[0]->getId()); // Partial update that ONLY changes the relationship (adds book2 to the author) - // Do NOT update name or bio - $database->updateDocument('authors', 'author1', new Document([ + $database->updateDocument($authors, 'author1', new Document([ '$id' => 'author1', - '$collection' => 'authors', - 'books' => ['book1', 'book2'], // Update relationship - // NOTE: We deliberately do NOT include 'name' or 'bio' + '$collection' => $authors, + 'books' => ['book1', 'book2'], '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -2399,24 +2220,24 @@ public function testPartialUpdateOnlyRelationship(): void ])); // Verify that the relationship was updated but other fields preserved - $authorAfter = $database->getDocument('authors', 'author1'); + $authorAfter = $database->getDocument($authors, 'author1'); $this->assertEquals('John Doe', $authorAfter->getAttribute('name'), 'Name should be preserved'); $this->assertEquals('A great author', $authorAfter->getAttribute('bio'), 'Bio should be preserved'); - $this->assertCount(2, $authorAfter->getAttribute('books'), 'Should now have 2 books'); + $this->assertCount(2, $authorAfter->getDocuments('books'), 'Should now have 2 books'); - $bookIds = array_map(fn ($book) => $book->getId(), $authorAfter->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $authorAfter->getDocuments('books')); $this->assertContains('book1', $bookIds); $this->assertContains('book2', $bookIds); // Verify reverse relationships - $book1 = $database->getDocument('books', 'book1'); - $this->assertEquals('author1', $book1->getAttribute('author')->getId()); + $book1 = $database->getDocument($books, 'book1'); + $this->assertEquals('author1', $book1->getDocument('author')->getId()); - $book2 = $database->getDocument('books', 'book2'); - $this->assertEquals('author1', $book2->getAttribute('author')->getId()); + $book2 = $database->getDocument($books, 'book2'); + $this->assertEquals('author1', $book2->getDocument('author')->getId()); - $database->deleteCollection('authors'); - $database->deleteCollection('books'); + $database->deleteCollection($authors); + $database->deleteCollection($books); } public function testPartialUpdateBothDataAndRelationship(): void @@ -2424,31 +2245,32 @@ public function testPartialUpdateBothDataAndRelationship(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - // Setup collections - $database->createCollection('teams'); - $database->createCollection('players'); + $teams = 'teams_' . uniqid(); + $players = 'players_' . uniqid(); - $database->createAttribute('teams', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('teams', 'city', Database::VAR_STRING, 255, true); - $database->createAttribute('teams', 'founded', Database::VAR_INTEGER, 0, false); - $database->createAttribute('players', 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: $teams)); + $database->createCollection(Collection::create(id: $players)); - $database->createRelationship( - collection: 'teams', - relatedCollection: 'players', - type: Database::RELATION_ONE_TO_MANY, + $database->createAttribute($teams, Attribute::string(key: 'name', required: true)); + $database->createAttribute($teams, Attribute::string(key: 'city', required: true)); + $database->createAttribute($teams, Attribute::integer(key: 'founded')); + $database->createAttribute($players, Attribute::string(key: 'name', required: true)); + + $database->createRelationship($teams, Relationship::oneToMany( + relatedCollection: $players, twoWay: true, - id: 'players', + key: 'players', twoWayKey: 'team' - ); + )); // Create team with players - $database->createDocument('teams', new Document([ + $database->createDocument($teams, new Document([ '$id' => 'team1', '$permissions' => [ Permission::read(Role::any()), @@ -2478,7 +2300,7 @@ public function testPartialUpdateBothDataAndRelationship(): void ])); // Create an additional player - $database->createDocument('players', new Document([ + $database->createDocument($players, new Document([ '$id' => 'player3', '$permissions' => [ Permission::read(Role::any()), @@ -2488,17 +2310,17 @@ public function testPartialUpdateBothDataAndRelationship(): void ])); // Verify initial state - $team = $database->getDocument('teams', 'team1'); + $team = $database->getDocument($teams, 'team1'); $this->assertEquals('The Warriors', $team->getAttribute('name')); $this->assertEquals('San Francisco', $team->getAttribute('city')); $this->assertEquals(1946, $team->getAttribute('founded')); - $this->assertCount(2, $team->getAttribute('players')); + $this->assertCount(2, $team->getDocuments('players')); // Partial update that changes BOTH flat data (city) AND relationship (players) // Do NOT update name or founded - $database->updateDocument('teams', 'team1', new Document([ + $database->updateDocument($teams, 'team1', new Document([ '$id' => 'team1', - '$collection' => 'teams', + '$collection' => $teams, 'city' => 'Oakland', // Update flat data 'players' => ['player1', 'player3'], // Update relationship (replace player2 with player3) // NOTE: We deliberately do NOT include 'name' or 'founded' @@ -2509,29 +2331,29 @@ public function testPartialUpdateBothDataAndRelationship(): void ])); // Verify that both updates worked and other fields preserved - $teamAfter = $database->getDocument('teams', 'team1'); + $teamAfter = $database->getDocument($teams, 'team1'); $this->assertEquals('The Warriors', $teamAfter->getAttribute('name'), 'Name should be preserved'); $this->assertEquals('Oakland', $teamAfter->getAttribute('city'), 'City should be updated'); $this->assertEquals(1946, $teamAfter->getAttribute('founded'), 'Founded should be preserved'); - $this->assertCount(2, $teamAfter->getAttribute('players'), 'Should still have 2 players'); + $this->assertCount(2, $teamAfter->getDocuments('players'), 'Should still have 2 players'); - $playerIds = array_map(fn ($player) => $player->getId(), $teamAfter->getAttribute('players')); + $playerIds = \array_map(fn ($player) => $player->getId(), $teamAfter->getDocuments('players')); $this->assertContains('player1', $playerIds, 'Should still have player1'); $this->assertContains('player3', $playerIds, 'Should now have player3'); $this->assertNotContains('player2', $playerIds, 'Should no longer have player2'); // Verify reverse relationships - $player1 = $database->getDocument('players', 'player1'); - $this->assertEquals('team1', $player1->getAttribute('team')->getId()); + $player1 = $database->getDocument($players, 'player1'); + $this->assertEquals('team1', $player1->getDocument('team')->getId()); - $player2 = $database->getDocument('players', 'player2'); + $player2 = $database->getDocument($players, 'player2'); $this->assertNull($player2->getAttribute('team'), 'Player2 should no longer have a team'); - $player3 = $database->getDocument('players', 'player3'); - $this->assertEquals('team1', $player3->getAttribute('team')->getId()); + $player3 = $database->getDocument($players, 'player3'); + $this->assertEquals('team1', $player3->getDocument('team')->getId()); - $database->deleteCollection('teams'); - $database->deleteCollection('players'); + $database->deleteCollection($teams); + $database->deleteCollection($players); } public function testPartialUpdateOneToManyChildSide(): void @@ -2539,30 +2361,32 @@ public function testPartialUpdateOneToManyChildSide(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('blogs'); - $database->createCollection('posts'); + $blogs = 'blogs_' . uniqid(); + $posts = 'posts_' . uniqid(); - $database->createAttribute('blogs', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('blogs', 'description', Database::VAR_STRING, 1000, false); - $database->createAttribute('posts', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('posts', 'views', Database::VAR_INTEGER, 0, false); + $database->createCollection(Collection::create(id: $blogs)); + $database->createCollection(Collection::create(id: $posts)); - $database->createRelationship( - collection: 'blogs', - relatedCollection: 'posts', - type: Database::RELATION_ONE_TO_MANY, + $database->createAttribute($blogs, Attribute::string(key: 'title', required: true)); + $database->createAttribute($blogs, Attribute::string(key: 'description', size: 1000)); + $database->createAttribute($posts, Attribute::string(key: 'title', required: true)); + $database->createAttribute($posts, Attribute::integer(key: 'views')); + + $database->createRelationship($blogs, Relationship::oneToMany( + relatedCollection: $posts, twoWay: true, - id: 'posts', + key: 'posts', twoWayKey: 'blog' - ); + )); // Create blog with posts - $database->createDocument('blogs', new Document([ + $database->createDocument($blogs, new Document([ '$id' => 'blog1', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Tech Blog', @@ -2573,20 +2397,20 @@ public function testPartialUpdateOneToManyChildSide(): void ])); // Partial update from child (post) side - update views only, preserve blog relationship - $database->updateDocument('posts', 'post1', new Document([ + $database->updateDocument($posts, 'post1', new Document([ '$id' => 'post1', - '$collection' => 'posts', + '$collection' => $posts, 'views' => 200, '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $post = $database->getDocument('posts', 'post1'); + $post = $database->getDocument($posts, 'post1'); $this->assertEquals('Post 1', $post->getAttribute('title'), 'Title should be preserved'); $this->assertEquals(200, $post->getAttribute('views'), 'Views should be updated'); - $this->assertEquals('blog1', $post->getAttribute('blog')->getId(), 'Blog relationship should be preserved'); + $this->assertEquals('blog1', $post->getDocument('blog')->getId(), 'Blog relationship should be preserved'); - $database->deleteCollection('blogs'); - $database->deleteCollection('posts'); + $database->deleteCollection($blogs); + $database->deleteCollection($posts); } public function testPartialUpdateWithStringIdsVsDocuments(): void @@ -2594,29 +2418,31 @@ public function testPartialUpdateWithStringIdsVsDocuments(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('libraries'); - $database->createCollection('books_lib'); + $libraries = 'libraries_' . uniqid(); + $booksLib = 'books_lib_' . uniqid(); + + $database->createCollection(Collection::create(id: $libraries)); + $database->createCollection(Collection::create(id: $booksLib)); - $database->createAttribute('libraries', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('libraries', 'location', Database::VAR_STRING, 255, false); - $database->createAttribute('books_lib', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute($libraries, Attribute::string(key: 'name', required: true)); + $database->createAttribute($libraries, Attribute::string(key: 'location')); + $database->createAttribute($booksLib, Attribute::string(key: 'title', required: true)); - $database->createRelationship( - collection: 'libraries', - relatedCollection: 'books_lib', - type: Database::RELATION_ONE_TO_MANY, + $database->createRelationship($libraries, Relationship::oneToMany( + relatedCollection: $booksLib, twoWay: true, - id: 'books', + key: 'books', twoWayKey: 'library' - ); + )); // Create library with books - $database->createDocument('libraries', new Document([ + $database->createDocument($libraries, new Document([ '$id' => 'lib1', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'name' => 'Central Library', @@ -2627,241 +2453,126 @@ public function testPartialUpdateWithStringIdsVsDocuments(): void ])); // Create standalone book - $database->createDocument('books_lib', new Document([ + $database->createDocument($booksLib, new Document([ '$id' => 'book2', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Book Two', ])); // Partial update using STRING IDs for relationship - $database->updateDocument('libraries', 'lib1', new Document([ + $database->updateDocument($libraries, 'lib1', new Document([ '$id' => 'lib1', - '$collection' => 'libraries', - 'books' => ['book1', 'book2'], // Using string IDs + '$collection' => $libraries, + 'books' => ['book1', 'book2'], '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $lib = $database->getDocument('libraries', 'lib1'); + $lib = $database->getDocument($libraries, 'lib1'); $this->assertEquals('Central Library', $lib->getAttribute('name'), 'Name should be preserved'); $this->assertEquals('Downtown', $lib->getAttribute('location'), 'Location should be preserved'); - $this->assertCount(2, $lib->getAttribute('books'), 'Should have 2 books'); + $this->assertCount(2, $lib->getDocuments('books'), 'Should have 2 books'); // Create another standalone book - $database->createDocument('books_lib', new Document([ + $database->createDocument($booksLib, new Document([ '$id' => 'book3', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Book Three', ])); // Partial update using DOCUMENT OBJECTS for relationship - $database->updateDocument('libraries', 'lib1', new Document([ + $database->updateDocument($libraries, 'lib1', new Document([ '$id' => 'lib1', - '$collection' => 'libraries', - 'books' => [ // Using Document objects + '$collection' => $libraries, + 'books' => [ new Document(['$id' => 'book1']), new Document(['$id' => 'book3']), ], '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $lib = $database->getDocument('libraries', 'lib1'); + $lib = $database->getDocument($libraries, 'lib1'); $this->assertEquals('Central Library', $lib->getAttribute('name'), 'Name should be preserved'); $this->assertEquals('Downtown', $lib->getAttribute('location'), 'Location should be preserved'); - $this->assertCount(2, $lib->getAttribute('books'), 'Should have 2 books'); + $this->assertCount(2, $lib->getDocuments('books'), 'Should have 2 books'); - $bookIds = array_map(fn ($book) => $book->getId(), $lib->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $lib->getDocuments('books')); $this->assertContains('book1', $bookIds); $this->assertContains('book3', $bookIds); - $database->deleteCollection('libraries'); - $database->deleteCollection('books_lib'); + $database->deleteCollection($libraries); + $database->deleteCollection($booksLib); } public function testOneToManyRelationshipWithArrayOperators(): void { /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $database->getAdapter()->supports(Capability::Operators)) { $this->expectNotToPerformAssertions(); - return; - } - if (!$database->getAdapter()->getSupportForOperators()) { - $this->expectNotToPerformAssertions(); return; } - // Cleanup any leftover collections from previous runs - try { - $database->deleteCollection('author'); - } catch (\Throwable $e) { - } - try { - $database->deleteCollection('article'); - } catch (\Throwable $e) { - } - - $database->createCollection('author'); - $database->createCollection('article'); + $database->createCollection(Collection::create(id: 'operator_author')); + $database->createCollection(Collection::create(id: 'operator_article')); - $database->createAttribute('author', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('article', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('operator_author', Attribute::string(key: 'name', required: true)); + $database->createAttribute('operator_article', Attribute::string(key: 'title', required: true)); - $database->createRelationship( - collection: 'author', - relatedCollection: 'article', - type: Database::RELATION_ONE_TO_MANY, + $database->createRelationship('operator_author', Relationship::oneToMany( + relatedCollection: 'operator_article', twoWay: true, - id: 'articles', - twoWayKey: 'author' - ); - - // Create some articles - $article1 = $database->createDocument('article', new Document([ - '$id' => 'article1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Article 1', - ])); + key: 'articles', + twoWayKey: 'author', + )); - $article2 = $database->createDocument('article', new Document([ - '$id' => 'article2', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Article 2', - ])); + $permissions = [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ]; - $article3 = $database->createDocument('article', new Document([ - '$id' => 'article3', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Article 3', - ])); + foreach (['article1' => 'Article 1', 'article2' => 'Article 2', 'article3' => 'Article 3'] as $id => $title) { + $database->createDocument('operator_article', new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'title' => $title, + ])); + } - // Create author with one article - $database->createDocument('author', new Document([ + $database->createDocument('operator_author', new Document([ '$id' => 'author1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], + '$permissions' => $permissions, 'name' => 'Author 1', 'articles' => ['article1'], ])); - // Fetch the document to get relationships (needed for Mirror which may not return relationships on create) - $author = $database->getDocument('author', 'author1'); - $this->assertCount(1, $author->getAttribute('articles')); - $this->assertEquals('article1', $author->getAttribute('articles')[0]->getId()); - - // Test arrayAppend - add articles - $author = $database->updateDocument('author', 'author1', new Document([ - 'articles' => \Utopia\Database\Operator::arrayAppend(['article2']), - ])); - - $author = $database->getDocument('author', 'author1'); - $this->assertCount(2, $author->getAttribute('articles')); - $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); - $this->assertContains('article1', $articleIds); - $this->assertContains('article2', $articleIds); - - // Test arrayRemove - remove an article - $author = $database->updateDocument('author', 'author1', new Document([ - 'articles' => \Utopia\Database\Operator::arrayRemove('article1'), - ])); - - $author = $database->getDocument('author', 'author1'); - $this->assertCount(1, $author->getAttribute('articles')); - $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); - $this->assertNotContains('article1', $articleIds); - $this->assertContains('article2', $articleIds); - - // Cleanup - $database->deleteCollection('author'); - $database->deleteCollection('article'); - } - - public function testOneToManyChildSideRejectsArrayOperators(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - if (!$database->getAdapter()->getSupportForOperators()) { - $this->expectNotToPerformAssertions(); - return; - } - - // Cleanup any leftover collections from previous runs - try { - $database->deleteCollection('parent_o2m'); - } catch (\Throwable $e) { - } - try { - $database->deleteCollection('child_o2m'); - } catch (\Throwable $e) { - } - - $database->createCollection('parent_o2m'); - $database->createCollection('child_o2m'); + $articleIds = function () use ($database): array { + $ids = \array_map( + fn (Document $article): string => $article->getId(), + $database->getDocument('operator_author', 'author1')->getDocuments('articles'), + ); + \sort($ids); - $database->createAttribute('parent_o2m', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('child_o2m', 'title', Database::VAR_STRING, 255, true); + return $ids; + }; - $database->createRelationship( - collection: 'parent_o2m', - relatedCollection: 'child_o2m', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - id: 'children', - twoWayKey: 'parent' - ); + $this->assertSame(['article1'], $articleIds()); - // Create a parent - $database->createDocument('parent_o2m', new Document([ - '$id' => 'parent1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Parent 1', + $database->updateDocument('operator_author', 'author1', new Document([ + 'articles' => Operator::arrayAppend(['article2']), ])); + $this->assertSame(['article1', 'article2'], $articleIds()); - // Create child with parent - $database->createDocument('child_o2m', new Document([ - '$id' => 'child1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'title' => 'Child 1', - 'parent' => 'parent1', + $database->updateDocument('operator_author', 'author1', new Document([ + 'articles' => Operator::arrayRemove('article1'), ])); + $this->assertSame(['article2'], $articleIds()); + $this->assertNull($database->getDocument('operator_article', 'article1')->getAttribute('author')); + $this->assertSame('author1', $database->getDocument('operator_article', 'article2')->getDocument('author')->getId()); - // Array operators should fail on child side (single-value "parent" relationship) - try { - $database->updateDocument('child_o2m', 'child1', new Document([ - 'parent' => \Utopia\Database\Operator::arrayAppend(['parent2']), - ])); - $this->fail('Expected exception for array operator on child side of one-to-many relationship'); - } catch (\Utopia\Database\Exception\Structure $e) { - $this->assertStringContainsString('single-value relationship', $e->getMessage()); - } - - // Cleanup - $database->deleteCollection('parent_o2m'); - $database->deleteCollection('child_o2m'); + $database->deleteCollection('operator_author'); + $database->deleteCollection('operator_article'); } /** @@ -2883,20 +2594,18 @@ public function testOneToManySetNullAfterSelectDelete(): void Permission::delete(Role::any()), ]; - $database->createCollection('otm_select_parent', permissions: $collectionPermissions, documentSecurity: true); - $database->createCollection('otm_select_child', permissions: $collectionPermissions, documentSecurity: true); - $database->createAttribute('otm_select_parent', 'name', Database::VAR_STRING, 255, false); - $database->createAttribute('otm_select_child', 'name', Database::VAR_STRING, 255, false); + $database->createCollection(Collection::create(id: 'otm_select_parent', permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(Collection::create(id: 'otm_select_child', permissions: $collectionPermissions, documentSecurity: true)); + $database->createAttribute('otm_select_parent', Attribute::string(key: 'name', size: 255)); + $database->createAttribute('otm_select_child', Attribute::string(key: 'name', size: 255)); - $database->createRelationship( - collection: 'otm_select_parent', + $database->createRelationship('otm_select_parent', Relationship::oneToMany( relatedCollection: 'otm_select_child', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'child', + key: 'child', twoWayKey: 'parent', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); + onDelete: RelationshipDeleteAction::SetNull, + )); $database->createDocument('otm_select_child', new Document([ '$id' => 'child1', @@ -2922,4 +2631,136 @@ public function testOneToManySetNullAfterSelectDelete(): void $this->assertFalse($survivor->isEmpty()); $this->assertNull($survivor->getAttribute('parent')); } + + public function testOneToManySetNullClearsMoreReferencesThanTheQueryValueLimit(): void + { + $database = static::getDatabase(); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: 'otm_setnull_limit_parent', permissions: $permissions)); + $database->createCollection(Collection::create(id: 'otm_setnull_limit_child', permissions: $permissions)); + + $database->createRelationship('otm_setnull_limit_parent', Relationship::oneToMany( + relatedCollection: 'otm_setnull_limit_child', + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: RelationshipDeleteAction::SetNull, + )); + + $childIds = ['child1', 'child2', 'child3']; + foreach ($childIds as $childId) { + $database->createDocument('otm_setnull_limit_child', new Document(['$id' => $childId])); + } + + $database->createDocument('otm_setnull_limit_parent', new Document([ + '$id' => 'parent1', + 'children' => $childIds, + ])); + + $max = $database->getMaxQueryValues(); + $database->setMaxQueryValues(2); + + try { + $database->deleteDocument('otm_setnull_limit_parent', 'parent1'); + } finally { + $database->setMaxQueryValues($max); + } + + foreach ($childIds as $childId) { + $child = $database->getDocument('otm_setnull_limit_child', $childId); + $this->assertFalse($child->isEmpty()); + $this->assertNull($child->getAttribute('parent')); + } + } + + public function testDeleteDocumentsWithASelectCascadesToChildren_OneToMany(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'o2m_select_cascade_parent'; + $childCollection = 'o2m_select_cascade_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($parentCollection, Relationship::oneToMany(relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}"])); + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}", 'parent' => "parent{$suffix}"])); + } + + $deleted = $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + + $this->assertSame(1, $deleted); + $this->assertSame(['parent1'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')])), "The deleted parent's child must be deleted with it"); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } + + public function testDeleteDocumentsWithASelectHonoursRestrict_OneToMany(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'o2m_select_restrict_parent'; + $childCollection = 'o2m_select_restrict_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($parentCollection, Relationship::oneToMany(relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Restrict)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}"])); + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}", 'parent' => "parent{$suffix}"])); + } + + try { + $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + $this->fail('Deleting a parent with a related document must be restricted'); + } catch (RestrictedException $exception) { + $this->assertSame('Cannot delete document because it has at least one related document.', $exception->getMessage()); + } + + $this->assertSame(['parent1', 'parent2'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1', 'child2'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')]))); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php index 69b4c89447..ddb3ae595c 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -3,6 +3,9 @@ namespace Tests\E2E\Adapter\Scopes\Relationships; use Exception; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -10,10 +13,15 @@ use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipDeleteAction; +use Utopia\Database\RelationshipType; +use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Role; +use Utopia\Query\Schema\ColumnType; trait OneToOneTests { @@ -22,37 +30,34 @@ public function testOneToOneOneWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('person'); - $database->createCollection('library'); + $database->createCollection(Collection::create(id: 'person')); + $database->createCollection(Collection::create(id: 'library')); - $database->createAttribute('person', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('library', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('library', 'area', Database::VAR_STRING, 255, true); + $database->createAttribute('person', Attribute::string(key: 'name', required: true)); + $database->createAttribute('library', Attribute::string(key: 'name', required: true)); + $database->createAttribute('library', Attribute::string(key: 'area', required: true)); - $database->createRelationship( - collection: 'person', - relatedCollection: 'library', - type: Database::RELATION_ONE_TO_ONE - ); + $database->createRelationship('person', Relationship::oneToOne(relatedCollection: 'library')); // Check metadata for collection $collection = $database->getCollection('person'); - $attributes = $collection->getAttribute('attributes', []); - - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'library') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('library', $attribute['$id']); - $this->assertEquals('library', $attribute['key']); - $this->assertEquals('library', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_ONE_TO_ONE, $attribute['options']['relationType']); - $this->assertEquals(false, $attribute['options']['twoWay']); - $this->assertEquals('person', $attribute['options']['twoWayKey']); + + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'library') { + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('library', $attribute->key); + $this->assertSame('library', $relationship->relatedCollection); + $this->assertSame(RelationshipType::OneToOne, $relationship->type); + $this->assertSame(false, $relationship->twoWay); + $this->assertSame('person', $relationship->twoWayKey); } } @@ -125,7 +130,7 @@ public function testOneToOneOneWayRelationship(): void 'area' => 'Area 10 Updated', ], ])); - $this->assertEquals('Library 10 Updated', $person10->getAttribute('library')->getAttribute('name')); + $this->assertEquals('Library 10 Updated', $person10->getDocument('library')->getAttribute('name')); $library10 = $database->getDocument('library', $library10->getId()); $this->assertEquals('Library 10 Updated', $library10->getAttribute('name')); @@ -152,13 +157,13 @@ public function testOneToOneOneWayRelationship(): void // Get documents with relationship $person1 = $database->getDocument('person', 'person1'); - $library = $person1->getAttribute('library'); - $this->assertEquals('library1', $library['$id']); + $library = $person1->getDocument('library'); + $this->assertEquals('library1', $library->getId()); $this->assertArrayNotHasKey('person', $library); $person = $database->getDocument('person', 'person2'); - $library = $person->getAttribute('library'); - $this->assertEquals('library2', $library['$id']); + $library = $person->getDocument('library'); + $this->assertEquals('library2', $library->getId()); $this->assertArrayNotHasKey('person', $library); // Get related documents @@ -169,7 +174,7 @@ public function testOneToOneOneWayRelationship(): void $this->assertArrayNotHasKey('person', $library); $people = $database->find('person', [ - Query::select(['name']) + Query::select(['name']), ]); $this->assertArrayNotHasKey('library', $people[0]); @@ -179,24 +184,22 @@ public function testOneToOneOneWayRelationship(): void // Select related document attributes $person = $database->findOne('person', [ - Query::select(['*', 'library.name']) + Query::select(['*', 'library.name']), ]); if ($person->isEmpty()) { throw new Exception('Person not found'); } - $this->assertEquals('Library 1', $person->getAttribute('library')->getAttribute('name')); - $this->assertArrayNotHasKey('area', $person->getAttribute('library')); + $this->assertEquals('Library 1', $person->getDocument('library')->getAttribute('name')); + $this->assertArrayNotHasKey('area', $person->getDocument('library')); $person = $database->getDocument('person', 'person1', [ - Query::select(['*', 'library.name', '$id']) + Query::select(['*', 'library.name', '$id']), ]); - $this->assertEquals('Library 1', $person->getAttribute('library')->getAttribute('name')); - $this->assertArrayNotHasKey('area', $person->getAttribute('library')); - - + $this->assertEquals('Library 1', $person->getDocument('library')->getAttribute('name')); + $this->assertArrayNotHasKey('area', $person->getDocument('library')); $document = $database->getDocument('person', $person->getId(), [ Query::select(['name']), @@ -212,7 +215,7 @@ public function testOneToOneOneWayRelationship(): void $document = $database->getDocument('person', $person->getId(), [ Query::select(['library.*']), ]); - $this->assertEquals('Library 1', $document['library']['name']); + $this->assertEquals('Library 1', $document->getDocument('library')->getAttribute('name')); $this->assertArrayNotHasKey('name', $document); // Update root document attribute without altering relationship @@ -233,14 +236,14 @@ public function testOneToOneOneWayRelationship(): void $person1->setAttribute( 'library', $person1 - ->getAttribute('library') + ->getDocument('library') ->setAttribute('name', 'Library 1 Updated') ) ); - $this->assertEquals('Library 1 Updated', $person1->getAttribute('library')->getAttribute('name')); + $this->assertEquals('Library 1 Updated', $person1->getDocument('library')->getAttribute('name')); $person1 = $database->getDocument('person', 'person1'); - $this->assertEquals('Library 1 Updated', $person1->getAttribute('library')->getAttribute('name')); + $this->assertEquals('Library 1 Updated', $person1->getDocument('library')->getAttribute('name')); // Create new document with no relationship $person3 = $database->createDocument('person', new Document([ @@ -268,9 +271,9 @@ public function testOneToOneOneWayRelationship(): void ])) ); - $this->assertEquals('library3', $person3->getAttribute('library')['$id']); + $this->assertEquals('library3', $person3->getDocument('library')->getId()); $person3 = $database->getDocument('person', 'person3'); - $this->assertEquals('Library 3', $person3['library']['name']); + $this->assertEquals('Library 3', $person3->getDocument('library')->getAttribute('name')); $libraryDocument = $database->getDocument('library', 'library3'); $libraryDocument->setAttribute('name', 'Library 3 updated'); @@ -280,8 +283,8 @@ public function testOneToOneOneWayRelationship(): void $person3 = $database->getDocument('person', 'person3'); // Todo: This is failing - $this->assertEquals($libraryDocument['name'], $person3['library']['name']); - $this->assertEquals('library3', $person3->getAttribute('library')['$id']); + $this->assertEquals($libraryDocument->getAttribute('name'), $person3->getDocument('library')->getAttribute('name')); + $this->assertEquals('library3', $person3->getDocument('library')->getId()); // One to one can't relate to multiple documents, unique index throws duplicate try { @@ -323,14 +326,14 @@ public function testOneToOneOneWayRelationship(): void // Rename relationship key $database->updateRelationship( collection: 'person', - id: 'library', - newKey: 'newLibrary' + key: 'library', + update: new RelationshipUpdate(key: 'newLibrary') ); // Get document with again $person = $database->getDocument('person', 'person1'); - $library = $person->getAttribute('newLibrary'); - $this->assertEquals('library4', $library['$id']); + $library = $person->getDocument('newLibrary'); + $this->assertEquals('library4', $library->getId()); // Create person with no relationship $database->createDocument('person', new Document([ @@ -385,8 +388,8 @@ public function testOneToOneOneWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'person', - id: 'newLibrary', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newLibrary', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); // Delete parent, no effect on children for one-way @@ -409,8 +412,8 @@ public function testOneToOneOneWayRelationship(): void // Change on delete to cascade $database->updateRelationship( collection: 'person', - id: 'newLibrary', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newLibrary', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -447,50 +450,46 @@ public function testOneToOneTwoWayRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('country'); - $database->createCollection('city'); + $database->createCollection(Collection::create(id: 'country')); + $database->createCollection(Collection::create(id: 'city')); - $database->createAttribute('country', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('city', 'code', Database::VAR_STRING, 3, true); - $database->createAttribute('city', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('country', Attribute::string(key: 'name', required: true)); + $database->createAttribute('city', Attribute::string(key: 'code', size: 3, required: true)); + $database->createAttribute('city', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'country', - relatedCollection: 'city', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true - ); + $database->createRelationship('country', Relationship::oneToOne(relatedCollection: 'city', twoWay: true)); $collection = $database->getCollection('country'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'city') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('city', $attribute['$id']); - $this->assertEquals('city', $attribute['key']); - $this->assertEquals('city', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_ONE_TO_ONE, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('country', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'city') { + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('city', $attribute->key); + $this->assertSame('city', $relationship->relatedCollection); + $this->assertSame(RelationshipType::OneToOne, $relationship->type); + $this->assertSame(true, $relationship->twoWay); + $this->assertSame('country', $relationship->twoWayKey); } } $collection = $database->getCollection('city'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'country') { - $this->assertEquals('relationship', $attribute['type']); - $this->assertEquals('country', $attribute['$id']); - $this->assertEquals('country', $attribute['key']); - $this->assertEquals('country', $attribute['options']['relatedCollection']); - $this->assertEquals(Database::RELATION_ONE_TO_ONE, $attribute['options']['relationType']); - $this->assertEquals(true, $attribute['options']['twoWay']); - $this->assertEquals('city', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'country') { + $relationship = $attribute->relationship; + $this->assertNotNull($relationship); + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('country', $attribute->key); + $this->assertSame('country', $relationship->relatedCollection); + $this->assertSame(RelationshipType::OneToOne, $relationship->type); + $this->assertSame(true, $relationship->twoWay); + $this->assertSame('city', $relationship->twoWayKey); } } @@ -517,7 +516,7 @@ public function testOneToOneTwoWayRelationship(): void $database->createDocument('country', new Document($doc->getArrayCopy())); $country1 = $database->getDocument('country', 'country1'); - $this->assertEquals('London', $country1->getAttribute('city')->getAttribute('name')); + $this->assertEquals('London', $country1->getDocument('city')->getAttribute('name')); // Update a document with non existing related document. It should not get added to the list. $database->updateDocument('country', 'country1', (new Document($doc->getArrayCopy()))->setAttribute('city', 'no-city')); @@ -539,13 +538,13 @@ public function testOneToOneTwoWayRelationship(): void $this->assertTrue($city1->isEmpty()); $country1 = $database->getDocument('country', 'country1'); - $this->assertTrue($country1->getAttribute('city')->isEmpty()); + $this->assertTrue($country1->getDocument('city')->isEmpty()); $this->assertTrue($database->deleteDocument('country', 'country1')); $database->createDocument('country', new Document($doc->getArrayCopy())); $country1 = $database->getDocument('country', 'country1'); - $this->assertEquals('London', $country1->getAttribute('city')->getAttribute('name')); + $this->assertEquals('London', $country1->getDocument('city')->getAttribute('name')); // Create document with relationship with related ID $database->createDocument('city', new Document([ @@ -612,44 +611,44 @@ public function testOneToOneTwoWayRelationship(): void // Get document with relationship $city = $database->getDocument('city', 'city1'); - $country = $city->getAttribute('country'); - $this->assertEquals('country1', $country['$id']); + $country = $city->getDocument('country'); + $this->assertEquals('country1', $country->getId()); $this->assertArrayNotHasKey('city', $country); $city = $database->getDocument('city', 'city2'); - $country = $city->getAttribute('country'); - $this->assertEquals('country2', $country['$id']); + $country = $city->getDocument('country'); + $this->assertEquals('country2', $country->getId()); $this->assertArrayNotHasKey('city', $country); $city = $database->getDocument('city', 'city3'); - $country = $city->getAttribute('country'); - $this->assertEquals('country3', $country['$id']); + $country = $city->getDocument('country'); + $this->assertEquals('country3', $country->getId()); $this->assertArrayNotHasKey('city', $country); $city = $database->getDocument('city', 'city4'); - $country = $city->getAttribute('country'); - $this->assertEquals('country4', $country['$id']); + $country = $city->getDocument('country'); + $this->assertEquals('country4', $country->getId()); $this->assertArrayNotHasKey('city', $country); // Get inverse document with relationship $country = $database->getDocument('country', 'country1'); - $city = $country->getAttribute('city'); - $this->assertEquals('city1', $city['$id']); + $city = $country->getDocument('city'); + $this->assertEquals('city1', $city->getId()); $this->assertArrayNotHasKey('country', $city); $country = $database->getDocument('country', 'country2'); - $city = $country->getAttribute('city'); - $this->assertEquals('city2', $city['$id']); + $city = $country->getDocument('city'); + $this->assertEquals('city2', $city->getId()); $this->assertArrayNotHasKey('country', $city); $country = $database->getDocument('country', 'country3'); - $city = $country->getAttribute('city'); - $this->assertEquals('city3', $city['$id']); + $city = $country->getDocument('city'); + $this->assertEquals('city3', $city->getId()); $this->assertArrayNotHasKey('country', $city); $country = $database->getDocument('country', 'country4'); - $city = $country->getAttribute('city'); - $this->assertEquals('city4', $city['$id']); + $city = $country->getDocument('city'); + $this->assertEquals('city4', $city->getId()); $this->assertArrayNotHasKey('country', $city); $countries = $database->find('country'); @@ -658,22 +657,22 @@ public function testOneToOneTwoWayRelationship(): void // Select related document attributes $country = $database->findOne('country', [ - Query::select(['*', 'city.name']) + Query::select(['*', 'city.name']), ]); if ($country->isEmpty()) { throw new Exception('Country not found'); } - $this->assertEquals('London', $country->getAttribute('city')->getAttribute('name')); - $this->assertArrayNotHasKey('code', $country->getAttribute('city')); + $this->assertEquals('London', $country->getDocument('city')->getAttribute('name')); + $this->assertArrayNotHasKey('code', $country->getDocument('city')); $country = $database->getDocument('country', 'country1', [ - Query::select(['*', 'city.name']) + Query::select(['*', 'city.name']), ]); - $this->assertEquals('London', $country->getAttribute('city')->getAttribute('name')); - $this->assertArrayNotHasKey('code', $country->getAttribute('city')); + $this->assertEquals('London', $country->getDocument('city')->getAttribute('name')); + $this->assertArrayNotHasKey('code', $country->getDocument('city')); $country1 = $database->getDocument('country', 'country1'); @@ -708,14 +707,14 @@ public function testOneToOneTwoWayRelationship(): void $country1->setAttribute( 'city', $country1 - ->getAttribute('city') + ->getDocument('city') ->setAttribute('name', 'City 1 Updated') ) ); - $this->assertEquals('City 1 Updated', $country1->getAttribute('city')->getAttribute('name')); + $this->assertEquals('City 1 Updated', $country1->getDocument('city')->getAttribute('name')); $country1 = $database->getDocument('country', 'country1'); - $this->assertEquals('City 1 Updated', $country1->getAttribute('city')->getAttribute('name')); + $this->assertEquals('City 1 Updated', $country1->getDocument('city')->getAttribute('name')); // Update inverse nested document attribute $city2 = $database->updateDocument( @@ -724,14 +723,14 @@ public function testOneToOneTwoWayRelationship(): void $city2->setAttribute( 'country', $city2 - ->getAttribute('country') + ->getDocument('country') ->setAttribute('name', 'Country 2 Updated') ) ); - $this->assertEquals('Country 2 Updated', $city2->getAttribute('country')->getAttribute('name')); + $this->assertEquals('Country 2 Updated', $city2->getDocument('country')->getAttribute('name')); $city2 = $database->getDocument('city', 'city2'); - $this->assertEquals('Country 2 Updated', $city2->getAttribute('country')->getAttribute('name')); + $this->assertEquals('Country 2 Updated', $city2->getDocument('country')->getAttribute('name')); // Create new document with no relationship $country5 = $database->createDocument('country', new Document([ @@ -759,9 +758,9 @@ public function testOneToOneTwoWayRelationship(): void ])) ); - $this->assertEquals('city5', $country5->getAttribute('city')['$id']); + $this->assertEquals('city5', $country5->getDocument('city')->getId()); $country5 = $database->getDocument('country', 'country5'); - $this->assertEquals('city5', $country5->getAttribute('city')['$id']); + $this->assertEquals('city5', $country5->getDocument('city')->getId()); // Create new document with no relationship $city6 = $database->createDocument('city', new Document([ @@ -789,9 +788,9 @@ public function testOneToOneTwoWayRelationship(): void ])) ); - $this->assertEquals('country6', $city6->getAttribute('country')['$id']); + $this->assertEquals('country6', $city6->getDocument('country')->getId()); $city6 = $database->getDocument('city', 'city6'); - $this->assertEquals('country6', $city6->getAttribute('country')['$id']); + $this->assertEquals('country6', $city6->getDocument('country')->getId()); // One to one can't relate to multiple documents, unique index throws duplicate try { @@ -852,7 +851,7 @@ public function testOneToOneTwoWayRelationship(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Denmark' + 'name' => 'Denmark', ])); // Update inverse document with new related document @@ -866,19 +865,21 @@ public function testOneToOneTwoWayRelationship(): void $database->updateRelationship( 'country', 'city', - 'newCity', - 'newCountry' + new RelationshipUpdate( + key: 'newCity', + twoWayKey: 'newCountry', + ) ); // Get document with new relationship key $city = $database->getDocument('city', 'city1'); - $country = $city->getAttribute('newCountry'); - $this->assertEquals('country7', $country['$id']); + $country = $city->getDocument('newCountry'); + $this->assertEquals('country7', $country->getId()); // Get inverse document with new relationship key $country = $database->getDocument('country', 'country7'); - $city = $country->getAttribute('newCity'); - $this->assertEquals('city1', $city['$id']); + $city = $country->getDocument('newCity'); + $this->assertEquals('city1', $city->getId()); // Create a new country with no relation $database->createDocument('country', new Document([ @@ -888,7 +889,7 @@ public function testOneToOneTwoWayRelationship(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Denmark' + 'name' => 'Denmark', ])); // Can delete parent document with no relation with on delete set to restrict @@ -898,7 +899,6 @@ public function testOneToOneTwoWayRelationship(): void $country8 = $database->getDocument('country', 'country8'); $this->assertEquals(true, $country8->isEmpty()); - // Cannot delete document while still related to another with on delete set to restrict try { $database->deleteDocument('country', 'country1'); @@ -910,15 +910,15 @@ public function testOneToOneTwoWayRelationship(): void // Change on delete to set null $database->updateRelationship( collection: 'country', - id: 'newCity', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'newCity', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); $database->updateDocument('city', 'city1', new Document(['newCountry' => null, '$id' => 'city1'])); $city1 = $database->getDocument('city', 'city1'); $this->assertNull($city1->getAttribute('newCountry')); - // Check Delete TwoWay TRUE && RELATION_MUTATE_SET_NULL && related value NULL + // Check Delete TwoWay TRUE && RelationshipDeleteAction::SetNull && related value NULL $this->assertTrue($database->deleteDocument('city', 'city1')); $city1 = $database->getDocument('city', 'city1'); $this->assertTrue($city1->isEmpty()); @@ -947,8 +947,8 @@ public function testOneToOneTwoWayRelationship(): void // Change on delete to cascade $database->updateRelationship( collection: 'country', - id: 'newCity', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'newCity', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -983,8 +983,8 @@ public function testOneToOneTwoWayRelationship(): void 'code' => 'MUC', 'newCountry' => [ '$id' => 'country7', - 'name' => 'Germany' - ] + 'name' => 'Germany', + ], ])); // Delete relationship @@ -1009,50 +1009,34 @@ public function testIdenticalTwoWayKeyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('parent'); - $database->createCollection('child'); + $database->createCollection(Collection::create(id: 'parent')); + $database->createCollection(Collection::create(id: 'child')); - $database->createRelationship( - collection: 'parent', - relatedCollection: 'child', - type: Database::RELATION_ONE_TO_ONE, - id: 'child1' - ); + $database->createRelationship('parent', Relationship::oneToOne(relatedCollection: 'child', key: 'child1')); try { - $database->createRelationship( - collection: 'parent', - relatedCollection: 'child', - type: Database::RELATION_ONE_TO_MANY, - id: 'children', - ); + $database->createRelationship('parent', Relationship::oneToMany(relatedCollection: 'child', key: 'children')); $this->fail('Failed to throw Exception'); } catch (Exception $e) { $this->assertEquals('Related attribute already exists', $e->getMessage()); } - $database->createRelationship( - collection: 'parent', - relatedCollection: 'child', - type: Database::RELATION_ONE_TO_MANY, - id: 'children', - twoWayKey: 'parent_id' - ); + $database->createRelationship('parent', Relationship::oneToMany(relatedCollection: 'child', key: 'children', twoWayKey: 'parent_id')); $collection = $database->getCollection('parent'); - $attributes = $collection->getAttribute('attributes', []); - foreach ($attributes as $attribute) { - if ($attribute['key'] === 'child1') { - $this->assertEquals('parent', $attribute['options']['twoWayKey']); + foreach ($collection->attributes() as $attribute) { + if ($attribute->key === 'child1') { + $this->assertSame('parent', $attribute->relationship?->twoWayKey); } - if ($attribute['key'] === 'children') { - $this->assertEquals('parent_id', $attribute['options']['twoWayKey']); + if ($attribute->key === 'children') { + $this->assertSame('parent_id', $attribute->relationship?->twoWayKey); } } @@ -1075,17 +1059,20 @@ public function testIdenticalTwoWayKeyRelationship(): void ])); $documents = $database->find('parent', []); - $document = array_pop($documents); + $document = array_pop($documents); + $this->assertInstanceOf(Document::class, $document); $this->assertArrayHasKey('child1', $document); - $this->assertEquals('foo', $document->getAttribute('child1')->getId()); + $this->assertEquals('foo', $document->getDocument('child1')->getId()); $this->assertArrayHasKey('children', $document); - $this->assertEquals('bar', $document->getAttribute('children')[0]->getId()); + $children = $document->getDocuments('children'); + $this->assertNotEmpty($children); + $this->assertEquals('bar', $children[0]->getId()); try { $database->updateRelationship( collection: 'parent', - id: 'children', - newKey: 'child1' + key: 'children', + update: new RelationshipUpdate(key: 'child1') ); $this->fail('Failed to throw Exception'); } catch (Exception $e) { @@ -1095,8 +1082,8 @@ public function testIdenticalTwoWayKeyRelationship(): void try { $database->updateRelationship( collection: 'parent', - id: 'children', - newTwoWayKey: 'parent' + key: 'children', + update: new RelationshipUpdate(twoWayKey: 'parent') ); $this->fail('Failed to throw Exception'); } catch (Exception $e) { @@ -1109,35 +1096,32 @@ public function testNestedOneToOne_OneToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('pattern'); - $database->createCollection('shirt'); - $database->createCollection('team'); + $database->createCollection(Collection::create(id: 'pattern')); + $database->createCollection(Collection::create(id: 'shirt')); + $database->createCollection(Collection::create(id: 'team')); - $database->createAttribute('pattern', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('shirt', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('team', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('pattern', Attribute::string(key: 'name', required: true)); + $database->createAttribute('shirt', Attribute::string(key: 'name', required: true)); + $database->createAttribute('team', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'pattern', + $database->createRelationship('pattern', Relationship::oneToOne( relatedCollection: 'shirt', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'shirt', + key: 'shirt', twoWayKey: 'pattern' - ); - $database->createRelationship( - collection: 'shirt', + )); + $database->createRelationship('shirt', Relationship::oneToOne( relatedCollection: 'team', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'team', + key: 'team', twoWayKey: 'shirt' - ); + )); $database->createDocument('pattern', new Document([ '$id' => 'stripes', @@ -1162,10 +1146,12 @@ public function testNestedOneToOne_OneToOneRelationship(): void ])); $pattern = $database->getDocument('pattern', 'stripes'); - $this->assertEquals('red', $pattern['shirt']['$id']); - $this->assertArrayNotHasKey('pattern', $pattern['shirt']); - $this->assertEquals('reds', $pattern['shirt']['team']['$id']); - $this->assertArrayNotHasKey('shirt', $pattern['shirt']['team']); + $shirt = $pattern->getDocument('shirt'); + $this->assertEquals('red', $shirt->getId()); + $this->assertArrayNotHasKey('pattern', $shirt); + $team = $shirt->getDocument('team'); + $this->assertEquals('reds', $team->getId()); + $this->assertArrayNotHasKey('shirt', $team); $database->createDocument('team', new Document([ '$id' => 'blues', @@ -1190,10 +1176,12 @@ public function testNestedOneToOne_OneToOneRelationship(): void ])); $team = $database->getDocument('team', 'blues'); - $this->assertEquals('blue', $team['shirt']['$id']); - $this->assertArrayNotHasKey('team', $team['shirt']); - $this->assertEquals('plain', $team['shirt']['pattern']['$id']); - $this->assertArrayNotHasKey('shirt', $team['shirt']['pattern']); + $shirt = $team->getDocument('shirt'); + $this->assertEquals('blue', $shirt->getId()); + $this->assertArrayNotHasKey('team', $shirt); + $pattern = $shirt->getDocument('pattern'); + $this->assertEquals('plain', $pattern->getId()); + $this->assertArrayNotHasKey('shirt', $pattern); } public function testNestedOneToOne_OneToManyRelationship(): void @@ -1201,34 +1189,27 @@ public function testNestedOneToOne_OneToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('teachers'); - $database->createCollection('classrooms'); - $database->createCollection('children'); + $database->createCollection(Collection::create(id: 'teachers')); + $database->createCollection(Collection::create(id: 'classrooms')); + $database->createCollection(Collection::create(id: 'children')); - $database->createAttribute('children', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('teachers', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('classrooms', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('children', Attribute::string(key: 'name', required: true)); + $database->createAttribute('teachers', Attribute::string(key: 'name', required: true)); + $database->createAttribute('classrooms', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'teachers', + $database->createRelationship('teachers', Relationship::oneToOne( relatedCollection: 'classrooms', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'classroom', + key: 'classroom', twoWayKey: 'teacher' - ); - $database->createRelationship( - collection: 'classrooms', - relatedCollection: 'children', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - twoWayKey: 'classroom' - ); + )); + $database->createRelationship('classrooms', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, twoWayKey: 'classroom')); $database->createDocument('teachers', new Document([ '$id' => 'teacher1', @@ -1262,11 +1243,13 @@ public function testNestedOneToOne_OneToManyRelationship(): void ])); $teacher1 = $database->getDocument('teachers', 'teacher1'); - $this->assertEquals('classroom1', $teacher1['classroom']['$id']); - $this->assertArrayNotHasKey('teacher', $teacher1['classroom']); - $this->assertEquals(2, \count($teacher1['classroom']['children'])); - $this->assertEquals('Child 1', $teacher1['classroom']['children'][0]['name']); - $this->assertEquals('Child 2', $teacher1['classroom']['children'][1]['name']); + $classroom = $teacher1->getDocument('classroom'); + $this->assertEquals('classroom1', $classroom->getId()); + $this->assertArrayNotHasKey('teacher', $classroom); + $children = $classroom->getDocuments('children'); + $this->assertCount(2, $children); + $this->assertEquals('Child 1', $children[0]->getAttribute('name')); + $this->assertEquals('Child 2', $children[1]->getAttribute('name')); $database->createDocument('children', new Document([ '$id' => 'child3', @@ -1291,10 +1274,12 @@ public function testNestedOneToOne_OneToManyRelationship(): void ])); $child3 = $database->getDocument('children', 'child3'); - $this->assertEquals('classroom2', $child3['classroom']['$id']); - $this->assertArrayNotHasKey('children', $child3['classroom']); - $this->assertEquals('teacher2', $child3['classroom']['teacher']['$id']); - $this->assertArrayNotHasKey('classroom', $child3['classroom']['teacher']); + $classroom = $child3->getDocument('classroom'); + $this->assertEquals('classroom2', $classroom->getId()); + $this->assertArrayNotHasKey('children', $classroom); + $teacher = $classroom->getDocument('teacher'); + $this->assertEquals('teacher2', $teacher->getId()); + $this->assertArrayNotHasKey('classroom', $teacher); } public function testNestedOneToOne_ManyToOneRelationship(): void @@ -1302,34 +1287,27 @@ public function testNestedOneToOne_ManyToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('users'); - $database->createCollection('profiles'); - $database->createCollection('avatars'); + $database->createCollection(Collection::create(id: 'users')); + $database->createCollection(Collection::create(id: 'profiles')); + $database->createCollection(Collection::create(id: 'avatars')); - $database->createAttribute('users', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('profiles', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('avatars', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('users', Attribute::string(key: 'name', required: true)); + $database->createAttribute('profiles', Attribute::string(key: 'name', required: true)); + $database->createAttribute('avatars', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'users', + $database->createRelationship('users', Relationship::oneToOne( relatedCollection: 'profiles', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'profile', + key: 'profile', twoWayKey: 'user' - ); - $database->createRelationship( - collection: 'profiles', - relatedCollection: 'avatars', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'avatar', - ); + )); + $database->createRelationship('profiles', Relationship::manyToOne(relatedCollection: 'avatars', twoWay: true, key: 'avatar')); $database->createDocument('users', new Document([ '$id' => 'user1', @@ -1354,10 +1332,12 @@ public function testNestedOneToOne_ManyToOneRelationship(): void ])); $user1 = $database->getDocument('users', 'user1'); - $this->assertEquals('profile1', $user1['profile']['$id']); - $this->assertArrayNotHasKey('user', $user1['profile']); - $this->assertEquals('avatar1', $user1['profile']['avatar']['$id']); - $this->assertArrayNotHasKey('profile', $user1['profile']['avatar']); + $profile = $user1->getDocument('profile'); + $this->assertEquals('profile1', $profile->getId()); + $this->assertArrayNotHasKey('user', $profile); + $avatar = $profile->getDocument('avatar'); + $this->assertEquals('avatar1', $avatar->getId()); + $this->assertArrayNotHasKey('profile', $avatar); $database->createDocument('avatars', new Document([ '$id' => 'avatar2', @@ -1379,15 +1359,17 @@ public function testNestedOneToOne_ManyToOneRelationship(): void ], 'name' => 'User 2', ], - ] + ], ], ])); $avatar2 = $database->getDocument('avatars', 'avatar2'); - $this->assertEquals('profile2', $avatar2['profiles'][0]['$id']); - $this->assertArrayNotHasKey('avatars', $avatar2['profiles'][0]); - $this->assertEquals('user2', $avatar2['profiles'][0]['user']['$id']); - $this->assertArrayNotHasKey('profiles', $avatar2['profiles'][0]['user']); + $profiles = $avatar2->getDocuments('profiles'); + $this->assertNotEmpty($profiles); + $this->assertEquals('profile2', $profiles[0]->getId()); + $this->assertArrayNotHasKey('avatars', $profiles[0]); + $this->assertEquals('user2', $profiles[0]->getDocument('user')->getId()); + $this->assertArrayNotHasKey('profiles', $profiles[0]->getDocument('user')); } public function testNestedOneToOne_ManyToManyRelationship(): void @@ -1395,33 +1377,27 @@ public function testNestedOneToOne_ManyToManyRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('addresses'); - $database->createCollection('houses'); - $database->createCollection('buildings'); + $database->createCollection(Collection::create(id: 'addresses')); + $database->createCollection(Collection::create(id: 'houses')); + $database->createCollection(Collection::create(id: 'buildings')); - $database->createAttribute('addresses', 'street', Database::VAR_STRING, 255, true); - $database->createAttribute('houses', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('buildings', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('addresses', Attribute::string(key: 'street', required: true)); + $database->createAttribute('houses', Attribute::string(key: 'name', required: true)); + $database->createAttribute('buildings', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'addresses', + $database->createRelationship('addresses', Relationship::oneToOne( relatedCollection: 'houses', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'house', + key: 'house', twoWayKey: 'address' - ); - $database->createRelationship( - collection: 'houses', - relatedCollection: 'buildings', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + )); + $database->createRelationship('houses', Relationship::manyToMany(relatedCollection: 'buildings', twoWay: true)); $database->createDocument('addresses', new Document([ '$id' => 'address1', @@ -1455,12 +1431,15 @@ public function testNestedOneToOne_ManyToManyRelationship(): void ])); $address1 = $database->getDocument('addresses', 'address1'); - $this->assertEquals('house1', $address1['house']['$id']); - $this->assertArrayNotHasKey('address', $address1['house']); - $this->assertEquals('building1', $address1['house']['buildings'][0]['$id']); - $this->assertEquals('building2', $address1['house']['buildings'][1]['$id']); - $this->assertArrayNotHasKey('houses', $address1['house']['buildings'][0]); - $this->assertArrayNotHasKey('houses', $address1['house']['buildings'][1]); + $house = $address1->getDocument('house'); + $this->assertSame('house1', $house->getId()); + $this->assertArrayNotHasKey('address', $house); + $buildings = $house->getDocuments('buildings'); + $this->assertCount(2, $buildings); + $this->assertSame('building1', $buildings[0]->getId()); + $this->assertSame('building2', $buildings[1]->getId()); + $this->assertArrayNotHasKey('houses', $buildings[0]); + $this->assertArrayNotHasKey('houses', $buildings[1]); $database->createDocument('buildings', new Document([ '$id' => 'building3', @@ -1492,8 +1471,9 @@ public function testExceedMaxDepthOneToOne(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -1502,29 +1482,14 @@ public function testExceedMaxDepthOneToOne(): void $level3Collection = 'level3OneToOne'; $level4Collection = 'level4OneToOne'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(Collection::create(id: $level1Collection)); + $database->createCollection(Collection::create(id: $level2Collection)); + $database->createCollection(Collection::create(id: $level3Collection)); + $database->createCollection(Collection::create(id: $level4Collection)); - $database->createRelationship( - collection: $level1Collection, - relatedCollection: $level2Collection, - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - $database->createRelationship( - collection: $level2Collection, - relatedCollection: $level3Collection, - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - $database->createRelationship( - collection: $level3Collection, - relatedCollection: $level4Collection, - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $database->createRelationship($level1Collection, Relationship::oneToOne(relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship($level2Collection, Relationship::oneToOne(relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship($level3Collection, Relationship::oneToOne(relatedCollection: $level4Collection, twoWay: true)); // Exceed create depth $level1 = $database->createDocument($level1Collection, new Document([ @@ -1543,11 +1508,13 @@ public function testExceedMaxDepthOneToOne(): void ], ], ])); + $level2 = $level1->getDocument($level2Collection); $this->assertArrayHasKey($level2Collection, $level1); - $this->assertEquals('level2', $level1[$level2Collection]->getId()); - $this->assertArrayHasKey($level3Collection, $level1[$level2Collection]); - $this->assertEquals('level3', $level1[$level2Collection][$level3Collection]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][$level3Collection]); + $this->assertEquals('level2', $level2->getId()); + $this->assertArrayHasKey($level3Collection, $level2); + $level3Related = $level2->getDocument($level3Collection); + $this->assertEquals('level3', $level3Related->getId()); + $this->assertArrayNotHasKey($level4Collection, $level3Related); // Confirm the 4th level document does not exist $level3 = $database->getDocument($level3Collection, 'level3'); @@ -1558,15 +1525,17 @@ public function testExceedMaxDepthOneToOne(): void '$id' => 'level4', ])); $level3 = $database->updateDocument($level3Collection, $level3->getId(), $level3); - $this->assertEquals('level4', $level3[$level4Collection]->getId()); + $this->assertEquals('level4', $level3->getDocument($level4Collection)->getId()); // Exceed fetch depth $level1 = $database->getDocument($level1Collection, 'level1'); + $level2 = $level1->getDocument($level2Collection); $this->assertArrayHasKey($level2Collection, $level1); - $this->assertEquals('level2', $level1[$level2Collection]->getId()); - $this->assertArrayHasKey($level3Collection, $level1[$level2Collection]); - $this->assertEquals('level3', $level1[$level2Collection][$level3Collection]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][$level3Collection]); + $this->assertEquals('level2', $level2->getId()); + $this->assertArrayHasKey($level3Collection, $level2); + $level3Related = $level2->getDocument($level3Collection); + $this->assertEquals('level3', $level3Related->getId()); + $this->assertArrayNotHasKey($level4Collection, $level3Related); } public function testExceedMaxDepthOneToOneNull(): void @@ -1574,8 +1543,9 @@ public function testExceedMaxDepthOneToOneNull(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -1584,29 +1554,14 @@ public function testExceedMaxDepthOneToOneNull(): void $level3Collection = 'level3OneToOneNull'; $level4Collection = 'level4OneToOneNull'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(Collection::create(id: $level1Collection)); + $database->createCollection(Collection::create(id: $level2Collection)); + $database->createCollection(Collection::create(id: $level3Collection)); + $database->createCollection(Collection::create(id: $level4Collection)); - $database->createRelationship( - collection: $level1Collection, - relatedCollection: $level2Collection, - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - $database->createRelationship( - collection: $level2Collection, - relatedCollection: $level3Collection, - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - $database->createRelationship( - collection: $level3Collection, - relatedCollection: $level4Collection, - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $database->createRelationship($level1Collection, Relationship::oneToOne(relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship($level2Collection, Relationship::oneToOne(relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship($level3Collection, Relationship::oneToOne(relatedCollection: $level4Collection, twoWay: true)); $level1 = $database->createDocument($level1Collection, new Document([ '$id' => 'level1', @@ -1624,11 +1579,13 @@ public function testExceedMaxDepthOneToOneNull(): void ], ], ])); + $level2 = $level1->getDocument($level2Collection); $this->assertArrayHasKey($level2Collection, $level1); - $this->assertEquals('level2', $level1[$level2Collection]->getId()); - $this->assertArrayHasKey($level3Collection, $level1[$level2Collection]); - $this->assertEquals('level3', $level1[$level2Collection][$level3Collection]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][$level3Collection]); + $this->assertEquals('level2', $level2->getId()); + $this->assertArrayHasKey($level3Collection, $level2); + $level3Related = $level2->getDocument($level3Collection); + $this->assertEquals('level3', $level3Related->getId()); + $this->assertArrayNotHasKey($level4Collection, $level3Related); // Confirm the 4th level document does not exist $level3 = $database->getDocument($level3Collection, 'level3'); @@ -1639,17 +1596,19 @@ public function testExceedMaxDepthOneToOneNull(): void '$id' => 'level4', ])); $level3 = $database->updateDocument($level3Collection, $level3->getId(), $level3); - $this->assertEquals('level4', $level3[$level4Collection]->getId()); + $this->assertEquals('level4', $level3->getDocument($level4Collection)->getId()); $level3 = $database->getDocument($level3Collection, 'level3'); - $this->assertEquals('level4', $level3[$level4Collection]->getId()); + $this->assertEquals('level4', $level3->getDocument($level4Collection)->getId()); // Exceed fetch depth $level1 = $database->getDocument($level1Collection, 'level1'); + $level2 = $level1->getDocument($level2Collection); $this->assertArrayHasKey($level2Collection, $level1); - $this->assertEquals('level2', $level1[$level2Collection]->getId()); - $this->assertArrayHasKey($level3Collection, $level1[$level2Collection]); - $this->assertEquals('level3', $level1[$level2Collection][$level3Collection]->getId()); - $this->assertArrayNotHasKey($level4Collection, $level1[$level2Collection][$level3Collection]); + $this->assertEquals('level2', $level2->getId()); + $this->assertArrayHasKey($level3Collection, $level2); + $level3Related = $level2->getDocument($level3Collection); + $this->assertEquals('level3', $level3Related->getId()); + $this->assertArrayNotHasKey($level4Collection, $level3Related); } public function testOneToOneRelationshipKeyWithSymbols(): void @@ -1657,42 +1616,38 @@ public function testOneToOneRelationshipKeyWithSymbols(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('$symbols_coll.ection1'); - $database->createCollection('$symbols_coll.ection2'); + $database->createCollection(Collection::create(id: '$symbols_coll.ection1')); + $database->createCollection(Collection::create(id: '$symbols_coll.ection2')); - $database->createRelationship( - collection: '$symbols_coll.ection1', - relatedCollection: '$symbols_coll.ection2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $database->createRelationship('$symbols_coll.ection1', Relationship::oneToOne(relatedCollection: '$symbols_coll.ection2', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection2', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc2 = $database->createDocument('$symbols_coll.ection1', new Document([ - '$id' => ID::unique(), - '$symbols_coll.ection2' => $doc1->getId(), + '$id' => Id::unique(), + 'symbols_collection2' => $doc1->getId(), '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) - ] + Permission::update(Role::any()), + ], ])); $doc1 = $database->getDocument('$symbols_coll.ection2', $doc1->getId()); $doc2 = $database->getDocument('$symbols_coll.ection1', $doc2->getId()); - $this->assertEquals($doc2->getId(), $doc1->getAttribute('$symbols_coll.ection1')->getId()); - $this->assertEquals($doc1->getId(), $doc2->getAttribute('$symbols_coll.ection2')->getId()); + $this->assertEquals($doc2->getId(), $doc1->getDocument('symbols_collection1')->getId()); + $this->assertEquals($doc1->getId(), $doc2->getDocument('symbols_collection2')->getId()); } public function testRecreateOneToOneOneWayRelationshipFromChild(): void @@ -1700,65 +1655,42 @@ public function testRecreateOneToOneOneWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - ); + $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - ); + $relationship = $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToOneTwoWayRelationshipFromParent(): void @@ -1766,67 +1698,42 @@ public function testRecreateOneToOneTwoWayRelationshipFromParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToOneTwoWayRelationshipFromChild(): void @@ -1834,67 +1741,42 @@ public function testRecreateOneToOneTwoWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two, twoWay: true)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $relationship = $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two, twoWay: true)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToOneOneWayRelationshipFromParent(): void @@ -1902,65 +1784,42 @@ public function testRecreateOneToOneOneWayRelationshipFromParent(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('one', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + + $one = 'one_' . uniqid(); + $two = 'two_' . uniqid(); + + $database->createCollection(Collection::create(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); - $database->createCollection('two', [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 100, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], [], [ + Permission::delete(Role::any()), + ])); + $database->createCollection(Collection::create(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100), + ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ]); + Permission::delete(Role::any()), + ])); - $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - ); + $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - ); + $relationship = $database->createRelationship($one, Relationship::oneToOne(relatedCollection: $two)); - $this->assertTrue($result); + $this->assertSame($two, $relationship->relatedCollection); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testDeleteBulkDocumentsOneToOneRelationship(): void @@ -1968,25 +1827,21 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_o2o'); - $this->getDatabase()->createCollection('bulk_delete_library_o2o'); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_person_o2o')); + $this->getDatabase()->createCollection(Collection::create(id: 'bulk_delete_library_o2o')); - $this->getDatabase()->createAttribute('bulk_delete_person_o2o', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_o2o', 'name', Database::VAR_STRING, 255, true); - $this->getDatabase()->createAttribute('bulk_delete_library_o2o', 'area', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_person_o2o', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_o2o', Attribute::string(key: 'name', required: true)); + $this->getDatabase()->createAttribute('bulk_delete_library_o2o', Attribute::string(key: 'area', required: true)); // Restrict - $this->getDatabase()->createRelationship( - collection: 'bulk_delete_person_o2o', - relatedCollection: 'bulk_delete_library_o2o', - type: Database::RELATION_ONE_TO_ONE, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $this->getDatabase()->createRelationship('bulk_delete_person_o2o', Relationship::oneToOne(relatedCollection: 'bulk_delete_library_o2o')); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ '$id' => 'person1', @@ -2009,8 +1864,8 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); - $library = $person1->getAttribute('bulk_delete_library_o2o'); - $this->assertEquals('library1', $library['$id']); + $library = $person1->getDocument('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library->getId()); $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); // Delete person @@ -2040,8 +1895,8 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void // NULL $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2o', - id: 'bulk_delete_library_o2o', - onDelete: Database::RELATION_MUTATE_SET_NULL + key: 'bulk_delete_library_o2o', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ @@ -2065,8 +1920,8 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); - $library = $person1->getAttribute('bulk_delete_library_o2o'); - $this->assertEquals('library1', $library['$id']); + $library = $person1->getDocument('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library->getId()); $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); $person = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); @@ -2088,8 +1943,8 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void // Cascade $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2o', - id: 'bulk_delete_library_o2o', - onDelete: Database::RELATION_MUTATE_CASCADE + key: 'bulk_delete_library_o2o', + update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ @@ -2113,8 +1968,8 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); - $library = $person1->getAttribute('bulk_delete_library_o2o'); - $this->assertEquals('library1', $library['$id']); + $library = $person1->getDocument('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library->getId()); $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); $person = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); @@ -2153,8 +2008,8 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void ])); $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); - $library = $person1->getAttribute('bulk_delete_library_o2o'); - $this->assertEquals('library1', $library['$id']); + $library = $person1->getDocument('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library->getId()); $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); $this->getDatabase()->deleteDocuments('bulk_delete_person_o2o'); @@ -2167,157 +2022,147 @@ public function testDeleteTwoWayRelationshipFromChild(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('drivers'); - $database->createCollection('licenses'); + $database->createCollection(Collection::create(id: 'drivers')); + $database->createCollection(Collection::create(id: 'licenses')); - $database->createRelationship( - collection: 'drivers', + $database->createRelationship('drivers', Relationship::oneToOne( relatedCollection: 'licenses', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'license', + key: 'license', twoWayKey: 'driver' - ); + )); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $this->assertEquals(1, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(1, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(1, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(1, \count($licenses->getAttribute('indexes'))); + $this->assertEquals(1, \count($drivers->attributes())); + $this->assertEquals(1, \count($drivers->indexes())); + $this->assertEquals(1, \count($licenses->attributes())); + $this->assertEquals(1, \count($licenses->indexes())); $database->deleteRelationship('licenses', 'driver'); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $this->assertEquals(0, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(0, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(0, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(0, \count($licenses->getAttribute('indexes'))); + $this->assertEquals(0, \count($drivers->attributes())); + $this->assertEquals(0, \count($drivers->indexes())); + $this->assertEquals(0, \count($licenses->attributes())); + $this->assertEquals(0, \count($licenses->indexes())); - $database->createRelationship( - collection: 'drivers', + $database->createRelationship('drivers', Relationship::oneToMany( relatedCollection: 'licenses', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'licenses', + key: 'licenses', twoWayKey: 'driver' - ); + )); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $this->assertEquals(1, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(0, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(1, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(1, \count($licenses->getAttribute('indexes'))); + $this->assertEquals(1, \count($drivers->attributes())); + $this->assertEquals(0, \count($drivers->indexes())); + $this->assertEquals(1, \count($licenses->attributes())); + $this->assertEquals(1, \count($licenses->indexes())); $database->deleteRelationship('licenses', 'driver'); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $this->assertEquals(0, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(0, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(0, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(0, \count($licenses->getAttribute('indexes'))); + $this->assertEquals(0, \count($drivers->attributes())); + $this->assertEquals(0, \count($drivers->indexes())); + $this->assertEquals(0, \count($licenses->attributes())); + $this->assertEquals(0, \count($licenses->indexes())); - $database->createRelationship( - collection: 'licenses', + $database->createRelationship('licenses', Relationship::manyToOne( relatedCollection: 'drivers', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'driver', + key: 'driver', twoWayKey: 'licenses' - ); + )); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $this->assertEquals(1, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(0, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(1, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(1, \count($licenses->getAttribute('indexes'))); + $this->assertEquals(1, \count($drivers->attributes())); + $this->assertEquals(0, \count($drivers->indexes())); + $this->assertEquals(1, \count($licenses->attributes())); + $this->assertEquals(1, \count($licenses->indexes())); $database->deleteRelationship('drivers', 'licenses'); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $this->assertEquals(0, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(0, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(0, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(0, \count($licenses->getAttribute('indexes'))); + $this->assertEquals(0, \count($drivers->attributes())); + $this->assertEquals(0, \count($drivers->indexes())); + $this->assertEquals(0, \count($licenses->attributes())); + $this->assertEquals(0, \count($licenses->indexes())); - $database->createRelationship( - collection: 'licenses', + $database->createRelationship('licenses', Relationship::manyToMany( relatedCollection: 'drivers', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'drivers', + key: 'drivers', twoWayKey: 'licenses' - ); + )); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $junction = $database->getCollection('_' . $licenses->getSequence() . '_' . $drivers->getSequence()); + $junction = $database->getCollection('_'.$licenses->getSequence().'_'.$drivers->getSequence()); - $this->assertEquals(1, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(0, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(1, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(0, \count($licenses->getAttribute('indexes'))); - $this->assertEquals(2, \count($junction->getAttribute('attributes'))); - $this->assertEquals(2, \count($junction->getAttribute('indexes'))); + $this->assertEquals(1, \count($drivers->attributes())); + $this->assertEquals(0, \count($drivers->indexes())); + $this->assertEquals(1, \count($licenses->attributes())); + $this->assertEquals(0, \count($licenses->indexes())); + $this->assertEquals(2, \count($junction->attributes())); + $this->assertEquals(2, \count($junction->indexes())); $database->deleteRelationship('drivers', 'licenses'); $drivers = $database->getCollection('drivers'); $licenses = $database->getCollection('licenses'); - $junction = $database->getCollection('_licenses_drivers'); + $junction = $database->findCollection('_licenses_drivers'); - $this->assertEquals(0, \count($drivers->getAttribute('attributes'))); - $this->assertEquals(0, \count($drivers->getAttribute('indexes'))); - $this->assertEquals(0, \count($licenses->getAttribute('attributes'))); - $this->assertEquals(0, \count($licenses->getAttribute('indexes'))); + $this->assertEquals(0, \count($drivers->attributes())); + $this->assertEquals(0, \count($drivers->indexes())); + $this->assertEquals(0, \count($licenses->attributes())); + $this->assertEquals(0, \count($licenses->indexes())); - $this->assertEquals(true, $junction->isEmpty()); + $this->assertNull($junction); } + public function testUpdateParentAndChild_OneToOne(): void { /** @var Database $database */ $database = $this->getDatabase(); if ( - !$database->getAdapter()->getSupportForRelationships() || - !$database->getAdapter()->getSupportForBatchOperations() + ! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || + ! $this->supportsBulkWrites() ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_o2o'; $childCollection = 'child_combined_o2o'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'parentNumber', Database::VAR_INTEGER, 0, false); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::integer(key: 'parentNumber')); - $database->createRelationship( - collection: $parentCollection, - relatedCollection: $childCollection, - type: Database::RELATION_ONE_TO_ONE, - id: 'parentNumber' - ); + $database->createRelationship($parentCollection, Relationship::oneToOne(relatedCollection: $childCollection, key: 'parentNumber')); $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2377,25 +2222,21 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToOne /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_relationship_error_one_to_one'; $childCollection = 'child_relationship_error_one_to_one'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); - $database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: $parentCollection)); + $database->createCollection(Collection::create(id: $childCollection)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', required: true)); + $database->createAttribute($childCollection, Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: $parentCollection, - relatedCollection: $childCollection, - type: Database::RELATION_ONE_TO_ONE, - onDelete: Database::RELATION_MUTATE_RESTRICT - ); + $database->createRelationship($parentCollection, Relationship::oneToOne(relatedCollection: $childCollection)); $parent = $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2413,7 +2254,7 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToOne Permission::delete(Role::any()), ], 'name' => 'Child 1', - ] + ], ])); try { @@ -2435,27 +2276,26 @@ public function testPartialUpdateOneToOneWithRelationships(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Setup collections with relationships - $database->createCollection('cities_partial'); - $database->createCollection('mayors_partial'); + $database->createCollection(Collection::create(id: 'cities_partial')); + $database->createCollection(Collection::create(id: 'mayors_partial')); - $database->createAttribute('cities_partial', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('cities_partial', 'population', Database::VAR_INTEGER, 0, false); - $database->createAttribute('mayors_partial', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('cities_partial', Attribute::string(key: 'name', required: true)); + $database->createAttribute('cities_partial', Attribute::integer(key: 'population')); + $database->createAttribute('mayors_partial', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'cities_partial', + $database->createRelationship('cities_partial', Relationship::oneToOne( relatedCollection: 'mayors_partial', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'mayor', + key: 'mayor', twoWayKey: 'city' - ); + )); // Create a city with a mayor $database->createDocument('cities_partial', new Document([ @@ -2480,11 +2320,11 @@ public function testPartialUpdateOneToOneWithRelationships(): void $city = $database->getDocument('cities_partial', 'city1'); $this->assertEquals('Test City', $city->getAttribute('name')); $this->assertEquals(100000, $city->getAttribute('population')); - $this->assertEquals('mayor1', $city->getAttribute('mayor')->getId()); + $this->assertEquals('mayor1', $city->getDocument('mayor')->getId()); $mayor = $database->getDocument('mayors_partial', 'mayor1'); $this->assertEquals('Test Mayor', $mayor->getAttribute('name')); - $this->assertEquals('city1', $mayor->getAttribute('city')->getId()); + $this->assertEquals('city1', $mayor->getDocument('city')->getId()); // Perform a partial update - ONLY update the city name, NOT the mayor relationship $database->updateDocument('cities_partial', 'city1', new Document([ @@ -2504,14 +2344,13 @@ public function testPartialUpdateOneToOneWithRelationships(): void $this->assertEquals(100000, $cityAfterUpdate->getAttribute('population'), 'Population should be preserved'); // This is the critical test - the mayor relationship should still exist - $mayorAfterUpdate = $cityAfterUpdate->getAttribute('mayor'); - $this->assertNotNull($mayorAfterUpdate, 'Mayor relationship should be preserved after partial update'); + $mayorAfterUpdate = $cityAfterUpdate->getDocument('mayor'); $this->assertEquals('mayor1', $mayorAfterUpdate->getId(), 'Mayor ID should still be mayor1'); // Verify the bidirectional relationship is still intact $mayor = $database->getDocument('mayors_partial', 'mayor1'); - $this->assertEquals('city1', $mayor->getAttribute('city')->getId(), 'Reverse relationship should be preserved'); - $this->assertEquals('Updated City Name', $mayor->getAttribute('city')->getAttribute('name'), 'Reverse relationship should reflect updated city name'); + $this->assertEquals('city1', $mayor->getDocument('city')->getId(), 'Reverse relationship should be preserved'); + $this->assertEquals('Updated City Name', $mayor->getDocument('city')->getAttribute('name'), 'Reverse relationship should reflect updated city name'); $database->deleteCollection('cities_partial'); $database->deleteCollection('mayors_partial'); @@ -2522,26 +2361,25 @@ public function testPartialUpdateOneToOneWithoutRelationshipField(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } // Recreate the exact scenario from testNestedOneToMany_OneToOneRelationship - $database->createCollection('cities_strict'); - $database->createCollection('mayors_strict'); + $database->createCollection(Collection::create(id: 'cities_strict')); + $database->createCollection(Collection::create(id: 'mayors_strict')); - $database->createAttribute('cities_strict', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('mayors_strict', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('cities_strict', Attribute::string(key: 'name', required: true)); + $database->createAttribute('mayors_strict', Attribute::string(key: 'name', required: true)); - $database->createRelationship( - collection: 'cities_strict', + $database->createRelationship('cities_strict', Relationship::oneToOne( relatedCollection: 'mayors_strict', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'mayor', + key: 'mayor', twoWayKey: 'city' - ); + )); // Create city with mayor $database->createDocument('cities_strict', new Document([ @@ -2564,7 +2402,7 @@ public function testPartialUpdateOneToOneWithoutRelationshipField(): void // Get the current state to verify $cityBefore = $database->getDocument('cities_strict', 'city1'); $this->assertEquals('City 1', $cityBefore->getAttribute('name')); - $this->assertEquals('mayor1', $cityBefore->getAttribute('mayor')->getId()); + $this->assertEquals('mayor1', $cityBefore->getDocument('mayor')->getId()); // Now do what the comment says we "don't support" - update WITHOUT including mayor field // Creating a fresh Document object with only the fields we want to update @@ -2586,101 +2424,29 @@ public function testPartialUpdateOneToOneWithoutRelationshipField(): void $this->assertEquals('City 1 updated', $cityAfter->getAttribute('name')); // The relationship should still exist - $mayorAttr = $cityAfter->getAttribute('mayor'); - $this->assertNotNull($mayorAttr, 'Mayor should still be set after partial update without mayor field'); - $this->assertEquals('mayor1', $mayorAttr->getId()); + $this->assertEquals('mayor1', $cityAfter->getDocument('mayor')->getId()); // Also verify the reverse relationship $mayor = $database->getDocument('mayors_strict', 'mayor1'); - $this->assertEquals('city1', $mayor->getAttribute('city')->getId()); + $this->assertEquals('city1', $mayor->getDocument('city')->getId()); $database->deleteCollection('cities_strict'); $database->deleteCollection('mayors_strict'); } - public function testOneToOneRelationshipRejectsArrayOperators(): void + /** + * Deleting the child must clear the parent foreign key even when the delete ran through a select query. + */ + public function testOneToOneSetNullAfterSelectDelete(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { $this->expectNotToPerformAssertions(); - return; - } - if (!$database->getAdapter()->getSupportForOperators()) { - $this->expectNotToPerformAssertions(); return; } - // Cleanup any leftover collections from previous runs - try { - $database->deleteCollection('user_o2o'); - } catch (\Throwable $e) { - } - try { - $database->deleteCollection('profile_o2o'); - } catch (\Throwable $e) { - } - - $database->createCollection('user_o2o'); - $database->createCollection('profile_o2o'); - - $database->createAttribute('user_o2o', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('profile_o2o', 'bio', Database::VAR_STRING, 255, true); - - $database->createRelationship( - collection: 'user_o2o', - relatedCollection: 'profile_o2o', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - id: 'profile', - twoWayKey: 'user' - ); - - // Create a profile - $database->createDocument('profile_o2o', new Document([ - '$id' => 'profile1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'bio' => 'Test bio', - ])); - - // Create user with profile - $database->createDocument('user_o2o', new Document([ - '$id' => 'user1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'User 1', - 'profile' => 'profile1', - ])); - - // Array operators should fail on one-to-one relationships - try { - $database->updateDocument('user_o2o', 'user1', new Document([ - 'profile' => \Utopia\Database\Operator::arrayAppend(['profile2']), - ])); - $this->fail('Expected exception for array operator on one-to-one relationship'); - } catch (\Utopia\Database\Exception\Structure $e) { - $this->assertStringContainsString('single-value relationship', $e->getMessage()); - } - - // Cleanup - $database->deleteCollection('user_o2o'); - $database->deleteCollection('profile_o2o'); - } - - /** - * Deleting the child must clear the parent foreign key even when the delete ran through a select query. - */ - public function testOneToOneSetNullAfterSelectDelete(): void - { - $database = static::getDatabase(); - $collectionPermissions = [ Permission::create(Role::any()), Permission::read(Role::any()), @@ -2693,20 +2459,20 @@ public function testOneToOneSetNullAfterSelectDelete(): void Permission::delete(Role::any()), ]; - $database->createCollection('oto_select_parent', permissions: $collectionPermissions, documentSecurity: true); - $database->createCollection('oto_select_child', permissions: $collectionPermissions, documentSecurity: true); - $database->createAttribute('oto_select_parent', 'name', Database::VAR_STRING, 255, false); - $database->createAttribute('oto_select_child', 'name', Database::VAR_STRING, 255, false); + $database->createCollection(Collection::create(id: 'oto_select_parent', attributes: [ + Attribute::string(key: 'name', size: 255), + ], permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(Collection::create(id: 'oto_select_child', attributes: [ + Attribute::string(key: 'name', size: 255), + ], permissions: $collectionPermissions, documentSecurity: true)); - $database->createRelationship( - collection: 'oto_select_parent', + $database->createRelationship('oto_select_parent', Relationship::oneToOne( relatedCollection: 'oto_select_child', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'child', + key: 'child', twoWayKey: 'parent', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); + onDelete: RelationshipDeleteAction::SetNull, + )); $database->createDocument('oto_select_child', new Document([ '$id' => 'child1', @@ -2721,8 +2487,6 @@ public function testOneToOneSetNullAfterSelectDelete(): void 'child' => 'child1', ])); - // A select query turns relationship population off, so the deleted document - // reaches deleteSetNull() without its relationship value $database->deleteDocuments('oto_select_child', [ Query::select(['$id', 'name']), Query::equal('$id', ['child1']), @@ -2730,6 +2494,132 @@ public function testOneToOneSetNullAfterSelectDelete(): void $survivor = $database->getDocument('oto_select_parent', 'parent1'); $this->assertFalse($survivor->isEmpty()); - $this->assertNull($survivor->getAttribute('child')); + $this->assertNull($survivor->getAttribute('child'), 'A select query disables relationship population, the deleted child must still be unlinked'); + + $database->deleteCollection('oto_select_parent'); + $database->deleteCollection('oto_select_child'); + } + + public function testDeleteDocumentsWithASelectCascadesToChildren_OneToOne(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'o2o_select_cascade_parent'; + $childCollection = 'o2o_select_cascade_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($parentCollection, Relationship::oneToOne(relatedCollection: $childCollection, key: 'child', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}"])); + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}", 'child' => "child{$suffix}"])); + } + + $deleted = $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + + $this->assertSame(1, $deleted); + $this->assertSame(['parent1'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')])), "The deleted parent's child must be deleted with it"); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } + + public function testDeleteDocumentsWithASelectHonoursRestrict_OneToOne(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'o2o_select_restrict_parent'; + $childCollection = 'o2o_select_restrict_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship($parentCollection, Relationship::oneToOne(relatedCollection: $childCollection, key: 'child', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Restrict)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}"])); + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}", 'child' => "child{$suffix}"])); + } + + try { + $database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + $this->fail('Deleting a parent with a related document must be restricted'); + } catch (RestrictedException $exception) { + $this->assertSame('Cannot delete document because it has at least one related document.', $exception->getMessage()); + } + + $this->assertSame(['parent1', 'parent2'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['child1', 'child2'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')]))); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); + } + + public function testDeleteDocumentsWithASelectCascadesFromTheChildSide_OneToOne(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $this->supportsBulkWrites()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $parentCollection = 'o2o_select_child_side_parent'; + $childCollection = 'o2o_select_child_side_child'; + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createRelationship($parentCollection, Relationship::oneToOne(relatedCollection: $childCollection, twoWay: true, key: 'child', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade)); + + foreach (['1', '2'] as $suffix) { + $database->createDocument($childCollection, new Document(['$id' => "child{$suffix}"])); + $database->createDocument($parentCollection, new Document(['$id' => "parent{$suffix}", 'child' => "child{$suffix}"])); + } + + $deleted = $database->deleteDocuments($childCollection, [Query::equal('$id', ['child2']), Query::select(['$id'])]); + + $this->assertSame(1, $deleted); + $this->assertSame(['child1'], \array_map(fn (Document $document) => $document->getId(), $database->find($childCollection, [Query::orderAsc('$id')]))); + $this->assertSame(['parent1'], \array_map(fn (Document $document) => $document->getId(), $database->find($parentCollection, [Query::orderAsc('$id')])), "The deleted child's parent must be deleted with it"); + + $database->deleteCollection($parentCollection); + $database->deleteCollection($childCollection); } } diff --git a/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php b/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php new file mode 100644 index 0000000000..ff93d4233a --- /dev/null +++ b/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php @@ -0,0 +1,283 @@ +getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->supports(Capability::SchemaIntrospection)) { + $this->assertSame([], $database->getSchemaAttributes('reconcileTypes')); + $this->assertSame([], $database->getSchemaIndexes('reconcileTypes')); + + return; + } + + $attributes = [ + Attribute::string(key: 'short', size: 64), + Attribute::string(key: 'long', size: 20000), + Attribute::string(key: 'list', size: 64, array: true), + Attribute::integer(key: 'count'), + Attribute::bigInteger(key: 'total'), + Attribute::double(key: 'ratio'), + Attribute::boolean(key: 'active'), + Attribute::datetime(key: 'born'), + ]; + if ($adapter->supports(Capability::Objects)) { + $attributes[] = Attribute::object(key: 'meta'); + } + if ($adapter->supports(Capability::Vectors)) { + $attributes[] = Attribute::vector(key: 'embedding', dimensions: 3); + } + + $collection = 'reconcileTypes'; + $database->createCollection(Collection::create(id: $collection, attributes: $attributes, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + try { + $columns = $this->getSchemaColumnTypes($database, $collection); + + foreach ($attributes as $attribute) { + $this->assertSame($adapter->getColumnType($attribute), $columns[$attribute->key] ?? null, $attribute->key); + } + $this->assertArrayHasKey(Storage::UID, $columns, 'An internal column is read back as a column'); + } finally { + $database->deleteCollection($collection); + } + } + + public function testAnOrphanColumnOfTheRequestedTypeIsReused(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->supports(Capability::SchemaIntrospection)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'reconcileReusedColumn'; + $this->createReconciledCollection($database, $collection); + + try { + $attribute = Attribute::string(key: 'nick', size: 64); + $adapter->createAttribute($collection, $attribute); + + $this->assertSame('nick', $database->createAttribute($collection, $attribute)->key); + $this->assertSame(['nick'], $this->getReconciledAttributeKeys($database, $collection)); + $this->assertSame($adapter->getColumnType($attribute), $this->getSchemaColumnTypes($database, $collection)['nick'] ?? null); + + $database->createDocument($collection, new Document([Document::ID => 'one', 'nick' => 'kept'])); + $this->assertSame('kept', $database->getDocument($collection, 'one')->getAttribute('nick')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testARelationshipRenameThatFailsPartWayConvergesOnRetry(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->supports(Capability::SchemaIntrospection) || ! $adapter->hasFeature(Feature\Relationships::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $books = 'halfRenamedBooks'; + $authors = 'halfRenamedAuthors'; + foreach ([$books, $authors] as $collection) { + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 64), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: false)); + } + + try { + $database->createRelationship($books, Relationship::oneToOne(relatedCollection: $authors, twoWay: true, key: 'author', twoWayKey: 'book')); + $database->createDocument($authors, new Document([Document::ID => 'herbert', 'name' => 'Herbert'])); + $database->createDocument($books, new Document([Document::ID => 'dune', 'name' => 'Dune', 'author' => 'herbert'])); + $adapter->createAttribute($authors, Attribute::string(key: 'novel', size: 64)); + $update = new RelationshipUpdate(key: 'writer', twoWayKey: 'novel'); + + try { + $database->updateRelationship($books, 'author', $update); + $this->fail('A rename whose second column the engine refuses must fail'); + } catch (Throwable $error) { + $this->assertNotInstanceOf(\PHPUnit\Framework\AssertionFailedError::class, $error); + } + + $adapter->deleteAttribute($authors, 'novel'); + $database->updateRelationship($books, 'author', $update); + + $writer = $database->getDocument($books, 'dune')->getAttribute('writer'); + $this->assertInstanceOf(Document::class, $writer); + $this->assertSame('herbert', $writer->getId()); + $novel = $database->getDocument($authors, 'herbert')->getAttribute('novel'); + $this->assertInstanceOf(Document::class, $novel); + $this->assertSame('dune', $novel->getId()); + } finally { + $database->deleteCollection($books); + $database->deleteCollection($authors); + } + } + + public function testAnOrphanColumnOfAnotherTypeIsReplacedOrRefusedUnderSharedTables(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->supports(Capability::SchemaIntrospection)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'reconcileReplacedColumn'; + $this->createReconciledCollection($database, $collection); + + try { + $orphan = Attribute::integer(key: 'nick'); + $requested = Attribute::string(key: 'nick', size: 64); + $adapter->createAttribute($collection, $orphan); + + if ($database->hasSharedTables()) { + try { + $database->createAttribute($collection, $requested); + $this->fail('A column another tenant may use must not be replaced under shared tables'); + } catch (DuplicateException $error) { + $this->assertSame('Attribute exists in the shared table with another type', $error->getMessage()); + } + + $this->assertSame([], $this->getReconciledAttributeKeys($database, $collection)); + $this->assertSame($adapter->getColumnType($orphan), $this->getSchemaColumnTypes($database, $collection)['nick'] ?? null); + + return; + } + + $this->assertSame('nick', $database->createAttribute($collection, $requested)->key); + $this->assertSame(['nick'], $this->getReconciledAttributeKeys($database, $collection)); + $this->assertSame($adapter->getColumnType($requested), $this->getSchemaColumnTypes($database, $collection)['nick'] ?? null); + } finally { + $database->deleteCollection($collection); + } + } + + public function testAnOrphanIndexOfTheRequestedDefinitionIsReused(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->supports(Capability::SchemaIntrospection)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'reconcileReusedIndex'; + $this->createReconciledCollection($database, $collection, [Attribute::string(key: 'name', size: 64)]); + + try { + $index = Index::key(key: 'lookup', attributes: ['name']); + $adapter->createIndex($collection, $index); + + if (! $this->hasSchemaIndex($database, $collection, 'lookup')) { + $this->markTestSkipped('getSchemaIndexes() does not report this index under its key on this adapter'); + } + + $this->assertSame('lookup', $database->createIndex($collection, $index)->key); + $this->assertSame(['lookup'], \array_map( + static fn (Index $stored): string => $stored->key, + $database->getCollection($collection)->indexes(), + )); + + $lookups = \array_values(\array_filter( + $database->getSchemaIndexes($collection), + static fn (SchemaIndex $schemaIndex): bool => $schemaIndex->name === 'lookup', + )); + $this->assertCount(1, $lookups); + $this->assertSame(IndexType::Key, $lookups[0]->type); + } finally { + $database->deleteCollection($collection); + } + } + + /** + * @param list $attributes + */ + private function createReconciledCollection(Database $database, string $collection, array $attributes = []): void + { + try { + $database->deleteCollection($collection); + } catch (Throwable) { + } + + $database->createCollection(Collection::create(id: $collection, attributes: $attributes, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + } + + /** + * @return array The type of each column by name + */ + private function getSchemaColumnTypes(Database $database, string $collection): array + { + $types = []; + foreach ($database->getSchemaAttributes($collection) as $column) { + $types[$column->name] = $column->type; + } + + return $types; + } + + /** + * @return list + */ + private function getReconciledAttributeKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection($collection)->attributes(), + ); + } + + private function hasSchemaIndex(Database $database, string $collection, string $key): bool + { + foreach ($database->getSchemaIndexes($collection) as $schemaIndex) { + if ($schemaIndex->name === $key) { + return true; + } + } + + return false; + } +} diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 9f8d150bf6..c4dfdff4e2 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -3,7 +3,10 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; -use Throwable; +use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -11,27 +14,53 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Exception\Type as TypeException; +use Utopia\Database\Id; +use Utopia\Database\Index; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\IndexType; trait SchemalessTests { + private function asString(mixed $value): string + { + $this->assertIsString($value); + + return $value; + } + + private function asDocument(mixed $value): Document + { + $this->assertInstanceOf(Document::class, $value); + + return $value; + } + + private function assertSameInstant(string $expected, string $actual): void + { + $parsed = date_create($actual); + $this->assertInstanceOf(\DateTime::class, $parsed, "\"{$actual}\" must parse as a datetime"); + $this->assertSame((new \DateTime($expected))->getTimestamp(), $parsed->getTimestamp()); + } + public function testSchemalessDocumentOperation(): void { /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $colName = uniqid('schemaless'); - $database->createCollection($colName); - $database->createAttribute($colName, 'key', Database::VAR_STRING, 50, true); - $database->createAttribute($colName, 'value', Database::VAR_STRING, 50, false, 'value'); + $database->createCollection(Collection::create(id: $colName)); + $database->createAttribute($colName, Attribute::string(key: 'key', size: 50, required: true)); + $database->createAttribute($colName, Attribute::string(key: 'value', size: 50, default: 'value')); $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; @@ -115,57 +144,19 @@ public function testSchemalessDocumentOperation(): void $database->deleteCollection($colName); } - public function testSchemalessDocumentInvalidInteralAttributeValidation(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - // test to ensure internal attributes are checked during creating schemaless document - if ($database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $colName = uniqid('schemaless'); - $database->createCollection($colName); - try { - $docs = [ - new Document(['$id' => true, 'freeA' => 'doc1']), - new Document(['$id' => true, 'freeB' => 'test']), - new Document(['$id' => true]), - ]; - $database->createDocuments($colName, $docs); - } catch (\Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - - try { - $docs = [ - new Document(['$createdAt' => true, 'freeA' => 'doc1']), - new Document(['$updatedAt' => true, 'freeB' => 'test']), - new Document(['$permissions' => 12]), - ]; - $database->createDocuments($colName, $docs); - } catch (\Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - - $database->deleteCollection($colName); - - } - public function testSchemalessSelectionOnUnknownAttributes(): void { /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $colName = uniqid('schemaless'); - $database->createCollection($colName); + $database->createCollection(Collection::create(id: $colName)); $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())]; $docs = [ new Document(['$id' => 'doc1', '$permissions' => $permissions, 'freeA' => 'doc1']), @@ -180,7 +171,7 @@ public function testSchemalessSelectionOnUnknownAttributes(): void $docC = $database->getDocument($colName, 'doc1', [Query::select(['freeC'])]); $this->assertNull($docC->getAttribute('freeC')); - $docs = $database->find($colName, [Query::equal('$id', ['doc1','doc2']),Query::select(['freeC'])]); + $docs = $database->find($colName, [Query::equal('$id', ['doc1', 'doc2']), Query::select(['freeC'])]); foreach ($docs as $doc) { $this->assertNull($doc->getAttribute('freeC')); // since not selected @@ -190,13 +181,13 @@ public function testSchemalessSelectionOnUnknownAttributes(): void $docA = $database->find($colName, [ Query::equal('$id', ['doc1']), - Query::select(['freeA']) + Query::select(['freeA']), ]); $this->assertEquals('doc1', $docA[0]->getAttribute('freeA')); $docC = $database->find($colName, [ Query::equal('$id', ['doc1']), - Query::select(['freeC']) + Query::select(['freeC']), ]); $this->assertArrayNotHasKey('freeC', $docC[0]->getAttributes()); } @@ -206,27 +197,29 @@ public function testSchemalessIncrement(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_increment"); - $database->createCollection($colName); + $colName = uniqid('schemaless_increment'); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; $docs = [ new Document(['$id' => 'doc1', '$permissions' => $permissions, 'counter' => 10, 'score' => 5.5]), new Document(['$id' => 'doc2', '$permissions' => $permissions, 'counter' => 20, 'points' => 100]), new Document(['$id' => 'doc3', '$permissions' => $permissions, 'value' => 0]), + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'nullable' => null]), ]; - $this->assertEquals(3, $database->createDocuments($colName, $docs)); + $this->assertEquals(4, $database->createDocuments($colName, $docs)); $doc1 = $database->increaseDocumentAttribute($colName, 'doc1', 'counter', 5); $this->assertEquals(15, $doc1->getAttribute('counter')); @@ -242,6 +235,13 @@ public function testSchemalessIncrement(): void $this->assertEquals(1, $doc3->getAttribute('newCounter')); $this->assertEquals(0, $doc3->getAttribute('value')); + try { + $database->increaseDocumentAttribute($colName, 'doc4', 'nullable', 1); + $this->fail('An explicitly null attribute must not use the missing-attribute numeric default.'); + } catch (TypeException) { + $this->addToAssertionCount(1); + } + try { $database->increaseDocumentAttribute($colName, 'doc1', 'counter', 10, 20); $this->assertEquals(20, $database->getDocument($colName, 'doc1')->getAttribute('counter')); @@ -250,7 +250,7 @@ public function testSchemalessIncrement(): void } $allDocs = $database->find($colName); - $this->assertCount(3, $allDocs); + $this->assertCount(4, $allDocs); $database->deleteCollection($colName); } @@ -260,19 +260,20 @@ public function testSchemalessDecrement(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_decrement"); - $database->createCollection($colName); + $colName = uniqid('schemaless_decrement'); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; $docs = [ @@ -314,19 +315,20 @@ public function testSchemalessUpdateDocumentWithQuery(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_update"); - $database->createCollection($colName); + $colName = uniqid('schemaless_update'); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; $docs = [ @@ -340,7 +342,7 @@ public function testSchemalessUpdateDocumentWithQuery(): void $updatedDoc = $database->updateDocument($colName, 'doc1', new Document([ 'status' => 'updated', 'lastModified' => '2023-01-01', - 'newAttribute' => 'added' + 'newAttribute' => 'added', ])); $this->assertEquals('updated', $updatedDoc->getAttribute('status')); @@ -356,7 +358,7 @@ public function testSchemalessUpdateDocumentWithQuery(): void $updatedDoc2 = $database->updateDocument($colName, 'doc2', new Document([ 'customField1' => 'value1', 'customField2' => 42, - 'customField3' => ['array', 'of', 'values'] + 'customField3' => ['array', 'of', 'values'], ])); $this->assertEquals('value1', $updatedDoc2->getAttribute('customField1')); @@ -372,19 +374,20 @@ public function testSchemalessDeleteDocumentWithQuery(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_delete"); - $database->createCollection($colName); + $colName = uniqid('schemaless_delete'); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; $docs = [ @@ -415,24 +418,26 @@ public function testSchemalessUpdateDocumentsWithQuery(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForBatchOperations()) { + if (! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_bulk_update"); - $database->createCollection($colName); + $colName = uniqid('schemaless_bulk_update'); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; $docs = []; @@ -443,7 +448,7 @@ public function testSchemalessUpdateDocumentsWithQuery(): void 'type' => $i <= 5 ? 'typeA' : 'typeB', 'status' => 'pending', 'score' => $i * 10, - 'customField' => "value{$i}" + 'customField' => "value{$i}", ]); } $this->assertEquals(10, $database->createDocuments($colName, $docs)); @@ -451,7 +456,7 @@ public function testSchemalessUpdateDocumentsWithQuery(): void $updatedCount = $database->updateDocuments($colName, new Document([ 'status' => 'processed', 'processedAt' => '2023-01-01', - 'newBulkField' => 'bulk_value' + 'newBulkField' => 'bulk_value', ]), [Query::equal('type', ['typeA'])]); $this->assertEquals(5, $updatedCount); @@ -479,7 +484,7 @@ public function testSchemalessUpdateDocumentsWithQuery(): void } $highScoreCount = $database->updateDocuments($colName, new Document([ - 'tier' => 'premium' + 'tier' => 'premium', ]), [Query::greaterThan('score', 70)]); $this->assertEquals(3, $highScoreCount); // docs 8, 9, 10 @@ -489,7 +494,7 @@ public function testSchemalessUpdateDocumentsWithQuery(): void $allUpdateCount = $database->updateDocuments($colName, new Document([ 'globalFlag' => true, - 'lastUpdate' => '2023-12-31' + 'lastUpdate' => '2023-12-31', ])); $this->assertEquals(10, $allUpdateCount); @@ -510,24 +515,26 @@ public function testSchemalessDeleteDocumentsWithQuery(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForBatchOperations()) { + if (! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_bulk_delete"); - $database->createCollection($colName); + $colName = uniqid('schemaless_bulk_delete'); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; $docs = []; @@ -539,7 +546,7 @@ public function testSchemalessDeleteDocumentsWithQuery(): void 'priority' => $i % 3, // 0, 1, or 2 'score' => $i * 5, 'tags' => ["tag{$i}", 'common'], - 'metadata' => ['created' => "2023-01-{$i}"] + 'metadata' => ['created' => "2023-01-{$i}"], ]); } $this->assertEquals(15, $database->createDocuments($colName, $docs)); @@ -566,7 +573,7 @@ public function testSchemalessDeleteDocumentsWithQuery(): void $multiConditionDeleted = $database->deleteDocuments($colName, [ Query::equal('category', ['archive']), - Query::equal('priority', [1]) + Query::equal('priority', [1]), ]); $this->assertEquals(2, $multiConditionDeleted); // docs 7 and 10 @@ -592,24 +599,26 @@ public function testSchemalessOperationsWithCallback(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForBatchOperations()) { + if (! $this->supportsBulkWrites()) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_callbacks"); - $database->createCollection($colName); + $colName = uniqid('schemaless_callbacks'); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; $docs = []; @@ -619,7 +628,7 @@ public function testSchemalessOperationsWithCallback(): void '$permissions' => $permissions, 'group' => $i <= 4 ? 'A' : 'B', 'value' => $i * 10, - 'customData' => "data{$i}" + 'customData' => "data{$i}", ]); } $this->assertEquals(8, $database->createDocuments($colName, $docs)); @@ -644,6 +653,7 @@ public function testSchemalessOperationsWithCallback(): void $processedDocs = $database->find($colName, [Query::equal('processed', [true])]); $this->assertCount(4, $processedDocs); + /** @var list $deleteResults */ $deleteResults = []; $deleteCount = $database->deleteDocuments( $colName, @@ -652,7 +662,7 @@ public function testSchemalessOperationsWithCallback(): void $deleteResults[] = [ 'id' => $doc->getId(), 'value' => $doc->getAttribute('value'), - 'customData' => $doc->getAttribute('customData') + 'customData' => $doc->getAttribute('customData'), ]; } ); @@ -662,7 +672,7 @@ public function testSchemalessOperationsWithCallback(): void foreach ($deleteResults as $result) { $this->assertGreaterThan(50, $result['value']); - $this->assertStringStartsWith('data', $result['customData']); + $this->assertStringStartsWith('data', $this->asString($result['customData'])); } $remainingDocs = $database->find($colName); @@ -680,14 +690,15 @@ public function testSchemalessIndexCreateListDelete(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } // Schemaless adapter still supports defining attributes/indexes metadata $col = uniqid('sl_idx'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $database->createDocument($col, new Document([ '$id' => 'a', @@ -702,53 +713,22 @@ public function testSchemalessIndexCreateListDelete(): void 'rank' => 2, ])); - $this->assertTrue($database->createIndex($col, 'idx_title_unique', Database::INDEX_UNIQUE, ['title'], [128], [Database::ORDER_ASC])); - $this->assertTrue($database->createIndex($col, 'idx_rank_key', Database::INDEX_KEY, ['rank'], [0], [Database::ORDER_ASC])); + $database->createIndex($col, Index::unique(key: 'idx_title_unique', attributes: ['title'], lengths: [128], orders: [OrderDirection::Asc])); + $database->createIndex($col, Index::key(key: 'idx_rank_key', attributes: ['rank'], lengths: [0], orders: [OrderDirection::Asc])); $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(2, $indexes); - $ids = array_map(fn ($i) => $i['$id'], $indexes); + $ids = array_map(fn (Index $index): string => $index->key, $indexes); $this->assertContains('idx_rank_key', $ids); $this->assertContains('idx_title_unique', $ids); - $this->assertTrue($database->deleteIndex($col, 'idx_rank_key')); + $database->deleteIndex($col, 'idx_rank_key'); $collection = $database->getCollection($col); - $this->assertCount(1, $collection->getAttribute('indexes')); - $this->assertEquals('idx_title_unique', $collection->getAttribute('indexes')[0]['$id']); - - $this->assertTrue($database->deleteIndex($col, 'idx_title_unique')); - $database->deleteCollection($col); - } - - public function testSchemalessIndexDuplicatePrevention(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if ($database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $col = uniqid('sl_idx_dup'); - $database->createCollection($col); - - $database->createDocument($col, new Document([ - '$id' => 'a', - '$permissions' => [Permission::read(Role::any())], - 'name' => 'x' - ])); - - $this->assertTrue($database->createIndex($col, 'duplicate', Database::INDEX_KEY, ['name'], [0], [Database::ORDER_ASC])); - - try { - $database->createIndex($col, 'duplicate', Database::INDEX_KEY, ['name'], [0], [Database::ORDER_ASC]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - } + $this->assertCount(1, $collection->indexes()); + $this->assertEquals('idx_title_unique', $collection->indexes()[0]->key); + $database->deleteIndex($col, 'idx_title_unique'); $database->deleteCollection($col); } @@ -758,53 +738,36 @@ public function testSchemalessObjectIndexes(): void $database = static::getDatabase(); // Only run for schemaless adapters that support object attributes - if ($database->getAdapter()->getSupportForAttributes() || !$database->getAdapter()->getSupportForObject()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes) || ! $database->getAdapter()->supports(Capability::Objects)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_obj_idx'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); // Define object attributes in metadata - $database->createAttribute($col, 'meta', Database::VAR_OBJECT, 0, false); - $database->createAttribute($col, 'meta2', Database::VAR_OBJECT, 0, false); + $database->createAttribute($col, Attribute::object(key: 'meta')); + $database->createAttribute($col, Attribute::object(key: 'meta2')); // Create regular key index on first object attribute - $this->assertTrue( - $database->createIndex( - $col, - 'idx_meta_key', - Database::INDEX_KEY, - ['meta'], - [0], - [Database::ORDER_ASC] - ) - ); + $database->createIndex($col, Index::key(key: 'idx_meta_key', attributes: ['meta'], lengths: [0], orders: [OrderDirection::Asc])); // Create unique index on second object attribute - $this->assertTrue( - $database->createIndex( - $col, - 'idx_meta_unique', - Database::INDEX_UNIQUE, - ['meta2'], - [0], - [Database::ORDER_ASC] - ) - ); + $database->createIndex($col, Index::unique(key: 'idx_meta_unique', attributes: ['meta2'], lengths: [0], orders: [OrderDirection::Asc])); // Verify index metadata is stored on the collection $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(2, $indexes); - $ids = array_map(fn ($i) => $i['$id'], $indexes); + $ids = array_map(fn (Index $index): string => $index->key, $indexes); $this->assertContains('idx_meta_key', $ids); $this->assertContains('idx_meta_unique', $ids); // Clean up indexes and collection - $this->assertTrue($database->deleteIndex($col, 'idx_meta_key')); - $this->assertTrue($database->deleteIndex($col, 'idx_meta_unique')); + $database->deleteIndex($col, 'idx_meta_key'); + $database->deleteIndex($col, 'idx_meta_unique'); $database->deleteCollection($col); } @@ -813,21 +776,22 @@ public function testSchemalessPermissions(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_perms'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); // Create with permissive read only $doc = $database->createDocument($col, new Document([ '$id' => 'd1', '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'field' => 'value' + 'field' => 'value', ])); $this->assertFalse($doc->isEmpty()); @@ -858,7 +822,7 @@ public function testSchemalessPermissions(): void '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), - ] + ], ])); }); @@ -869,7 +833,7 @@ public function testSchemalessPermissions(): void $database->getAuthorization()->cleanRoles(); try { $database->createDocument($col, new Document([ - 'field' => 'x' + 'field' => 'x', ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -885,109 +849,104 @@ public function testSchemalessInternalAttributes(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $col = uniqid('sl_internal_full'); - $database->createCollection($col); + $collectionId = uniqid('sl_internal_full'); + $database->createCollection(Collection::create(id: $collectionId)); - $database->getAuthorization()->addRole(Role::any()->toString()); - - $doc = $database->createDocument($col, new Document([ - '$id' => 'i1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'alpha', - ])); + try { + $document = $database->createDocument($collectionId, new Document([ + '$id' => 'i1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'alpha', + ])); - $this->assertEquals('i1', $doc->getId()); - $this->assertEquals($col, $doc->getCollection()); - $this->assertNotEmpty($doc->getSequence()); - $this->assertNotEmpty($doc->getAttribute('$createdAt')); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - $perms = $doc->getPermissions(); - $this->assertGreaterThanOrEqual(1, count($perms)); - $this->assertContains(Permission::read(Role::any()), $perms); - $this->assertContains(Permission::update(Role::any()), $perms); - $this->assertContains(Permission::delete(Role::any()), $perms); - - $selected = $database->getDocument($col, 'i1', [ - Query::select(['name', '$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']) - ]); - $this->assertEquals('alpha', $selected->getAttribute('name')); - $this->assertArrayHasKey('$id', $selected); - $this->assertArrayHasKey('$sequence', $selected); - $this->assertArrayHasKey('$collection', $selected); - $this->assertArrayHasKey('$createdAt', $selected); - $this->assertArrayHasKey('$updatedAt', $selected); - $this->assertArrayHasKey('$permissions', $selected); - - $found = $database->find($col, [ - Query::equal('$id', ['i1']), - Query::select(['$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']) - ]); - $this->assertCount(1, $found); - $this->assertArrayHasKey('$id', $found[0]); - $this->assertArrayHasKey('$sequence', $found[0]); - $this->assertArrayHasKey('$collection', $found[0]); - $this->assertArrayHasKey('$createdAt', $found[0]); - $this->assertArrayHasKey('$updatedAt', $found[0]); - $this->assertArrayHasKey('$permissions', $found[0]); - - $seq = $doc->getSequence(); - $bySeq = $database->find($col, [Query::equal('$sequence', [$seq])]); - $this->assertCount(1, $bySeq); - $this->assertEquals('i1', $bySeq[0]->getId()); - - $createdAtBefore = $doc->getAttribute('$createdAt'); - $updatedAtBefore = $doc->getAttribute('$updatedAt'); - $updated = $database->updateDocument($col, 'i1', new Document(['name' => 'beta'])); - $this->assertEquals('beta', $updated->getAttribute('name')); - $this->assertEquals($createdAtBefore, $updated->getAttribute('$createdAt')); - $this->assertNotEquals($updatedAtBefore, $updated->getAttribute('$updatedAt')); - - $changed = $database->updateDocument($col, 'i1', new Document(['$id' => 'i1-new'])); - $this->assertEquals('i1-new', $changed->getId()); - $refetched = $database->getDocument($col, 'i1-new'); - $this->assertEquals('i1-new', $refetched->getId()); + $this->assertSame('i1', $document->getId()); + $this->assertSame($collectionId, $document->getCollection()); + $this->assertNotEmpty($document->getSequence()); + $this->assertNotEmpty($document->getAttribute('$createdAt')); + $this->assertNotEmpty($document->getAttribute('$updatedAt')); + $permissions = $document->getPermissions(); + $this->assertGreaterThanOrEqual(1, count($permissions)); + $this->assertContains(Permission::read(Role::any()), $permissions); + $this->assertContains(Permission::update(Role::any()), $permissions); + $this->assertContains(Permission::delete(Role::any()), $permissions); + + $internalAttributes = ['$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']; + + $selected = $database->getDocument($collectionId, 'i1', [ + Query::select(['name', ...$internalAttributes]), + ]); + $this->assertSame('alpha', $selected->getAttribute('name')); + foreach ($internalAttributes as $attribute) { + $this->assertArrayHasKey($attribute, $selected); + } - try { - $database->updateDocument($col, 'i1-new', new Document(['$permissions' => 'invalid'])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertTrue($e instanceof StructureException); - } + $found = $database->find($collectionId, [ + Query::equal('$id', ['i1']), + Query::select($internalAttributes), + ]); + $this->assertCount(1, $found); + foreach ($internalAttributes as $attribute) { + $this->assertArrayHasKey($attribute, $found[0]); + } - $database->setPreserveDates(true); - $customCreated = '2000-01-01T00:00:00.000+00:00'; - $customUpdated = '2000-01-02T00:00:00.000+00:00'; - $d2 = $database->createDocument($col, new Document([ - '$id' => 'i2', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - '$createdAt' => $customCreated, - '$updatedAt' => $customUpdated, - 'v' => 1 - ])); - $this->assertEquals($customCreated, $d2->getAttribute('$createdAt')); - $this->assertEquals($customUpdated, $d2->getAttribute('$updatedAt')); + $bySequence = $database->find($collectionId, [Query::equal('$sequence', [$document->getSequence()])]); + $this->assertCount(1, $bySequence); + $this->assertSame('i1', $bySequence[0]->getId()); + + $createdAtBefore = $document->getAttribute('$createdAt'); + $updatedAtBefore = $document->getAttribute('$updatedAt'); + $updated = $database->updateDocument($collectionId, 'i1', new Document(['name' => 'beta'])); + $this->assertSame('beta', $updated->getAttribute('name')); + $this->assertSame($createdAtBefore, $updated->getAttribute('$createdAt')); + $this->assertNotSame($updatedAtBefore, $updated->getAttribute('$updatedAt')); + + $changed = $database->updateDocument($collectionId, 'i1', new Document(['$id' => 'i1-new'])); + $this->assertSame('i1-new', $changed->getId()); + $this->assertSame('i1-new', $database->getDocument($collectionId, 'i1-new')->getId()); + $this->assertTrue($database->getDocument($collectionId, 'i1')->isEmpty(), 'The renamed document must no longer be readable by its old $id'); + + try { + $database->updateDocument($collectionId, 'i1-new', new Document(['$permissions' => 'invalid'])); + $this->fail('Failed to throw exception'); + } catch (\Throwable $exception) { + $this->assertInstanceOf(StructureException::class, $exception); + } - $newUpdated = '2000-01-03T00:00:00.000+00:00'; - $d2u = $database->updateDocument($col, 'i2', new Document([ - 'v' => 2, - '$updatedAt' => $newUpdated - ])); - $this->assertEquals($customCreated, $d2u->getAttribute('$createdAt')); - $this->assertEquals($newUpdated, $d2u->getAttribute('$updatedAt')); - $database->setPreserveDates(false); + $database->setPreserveDates(true); + $customCreated = '2000-01-01T00:00:00.000+00:00'; + $customUpdated = '2000-01-02T00:00:00.000+00:00'; + $preserved = $database->createDocument($collectionId, new Document([ + '$id' => 'i2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + '$createdAt' => $customCreated, + '$updatedAt' => $customUpdated, + 'v' => 1, + ])); + $this->assertSame($customCreated, $preserved->getAttribute('$createdAt')); + $this->assertSame($customUpdated, $preserved->getAttribute('$updatedAt')); - $database->deleteCollection($col); - $database->getAuthorization()->cleanRoles(); + $newUpdated = '2000-01-03T00:00:00.000+00:00'; + $preservedUpdate = $database->updateDocument($collectionId, 'i2', new Document([ + 'v' => 2, + '$updatedAt' => $newUpdated, + ])); + $this->assertSame($customCreated, $preservedUpdate->getAttribute('$createdAt')); + $this->assertSame($newUpdated, $preservedUpdate->getAttribute('$updatedAt')); + } finally { + $database->setPreserveDates(false); + $database->deleteCollection($collectionId); + } } public function testSchemalessDates(): void @@ -995,28 +954,29 @@ public function testSchemalessDates(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_dates'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Seed deterministic date strings $createdAt1 = '2000-01-01T10:00:00.000+00:00'; $updatedAt1 = '2000-01-02T11:11:11.000+00:00'; - $curDate1 = '2000-01-05T05:05:05.000+00:00'; + $curDate1 = '2000-01-05T05:05:05.000+00:00'; // createDocument with preserved dates - $doc1 = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt1, $updatedAt1, $curDate1) { + $doc1 = $this->asDocument($database->withPreserveDates(true, function () use ($database, $col, $permissions, $createdAt1, $updatedAt1, $curDate1) { return $database->createDocument($col, new Document([ '$id' => 'd1', '$permissions' => $permissions, @@ -1025,13 +985,13 @@ public function testSchemalessDates(): void 'curDate' => $curDate1, 'counter' => 0, ])); - }); + })); $this->assertEquals('d1', $doc1->getId()); $this->assertTrue(is_string($doc1->getAttribute('curDate'))); // MongoDB converts ISO 8601 to 'Y-m-d H:i:s.v' format, so compare by parsing $curDate1Value = $doc1->getAttribute('curDate'); - $parsedCurDate1 = new \DateTime($curDate1Value); + $parsedCurDate1 = new \DateTime($this->asString($curDate1Value)); $parsedExpectedCurDate1 = new \DateTime($curDate1); $this->assertEquals($parsedExpectedCurDate1->getTimestamp(), $parsedCurDate1->getTimestamp()); $this->assertTrue(is_string($doc1->getAttribute('$createdAt'))); @@ -1039,8 +999,8 @@ public function testSchemalessDates(): void // Internal attributes should preserve format better, but verify by parsing for MongoDB $createdAt1Value = $doc1->getAttribute('$createdAt'); $updatedAt1Value = $doc1->getAttribute('$updatedAt'); - $parsedCreatedAt1 = new \DateTime($createdAt1Value); - $parsedUpdatedAt1 = new \DateTime($updatedAt1Value); + $parsedCreatedAt1 = new \DateTime($this->asString($createdAt1Value)); + $parsedUpdatedAt1 = new \DateTime($this->asString($updatedAt1Value)); $parsedExpectedCreatedAt1 = new \DateTime($createdAt1); $parsedExpectedUpdatedAt1 = new \DateTime($updatedAt1); $this->assertEquals($parsedExpectedCreatedAt1->getTimestamp(), $parsedCreatedAt1->getTimestamp()); @@ -1049,27 +1009,27 @@ public function testSchemalessDates(): void $fetched1 = $database->getDocument($col, 'd1'); $fetchedCurDate1 = $fetched1->getAttribute('curDate'); $this->assertTrue(is_string($fetchedCurDate1)); - $parsedFetchedCurDate1 = new \DateTime($fetchedCurDate1); + $parsedFetchedCurDate1 = new \DateTime($this->asString($fetchedCurDate1)); $this->assertEquals($parsedExpectedCurDate1->getTimestamp(), $parsedFetchedCurDate1->getTimestamp()); $this->assertTrue(is_string($fetched1->getAttribute('$createdAt'))); $this->assertTrue(is_string($fetched1->getAttribute('$updatedAt'))); $fetchedCreatedAt1 = $fetched1->getAttribute('$createdAt'); $fetchedUpdatedAt1 = $fetched1->getAttribute('$updatedAt'); - $parsedFetchedCreatedAt1 = new \DateTime($fetchedCreatedAt1); - $parsedFetchedUpdatedAt1 = new \DateTime($fetchedUpdatedAt1); + $parsedFetchedCreatedAt1 = new \DateTime($this->asString($fetchedCreatedAt1)); + $parsedFetchedUpdatedAt1 = new \DateTime($this->asString($fetchedUpdatedAt1)); $this->assertEquals($parsedExpectedCreatedAt1->getTimestamp(), $parsedFetchedCreatedAt1->getTimestamp()); $this->assertEquals($parsedExpectedUpdatedAt1->getTimestamp(), $parsedFetchedUpdatedAt1->getTimestamp()); // createDocuments with preserved dates $createdAt2 = '2001-02-03T04:05:06.000+00:00'; $updatedAt2 = '2001-02-04T04:05:07.000+00:00'; - $curDate2 = '2001-02-05T06:07:08.000+00:00'; + $curDate2 = '2001-02-05T06:07:08.000+00:00'; $createdAt3 = '2002-03-04T05:06:07.000+00:00'; $updatedAt3 = '2002-03-05T05:06:08.000+00:00'; - $curDate3 = '2002-03-06T07:08:09.000+00:00'; + $curDate3 = '2002-03-06T07:08:09.000+00:00'; - $countCreated = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt2, $updatedAt2, $curDate2, $createdAt3, $updatedAt3, $curDate3) { + $countCreated = $database->withPreserveDates(true, function () use ($database, $col, $permissions, $createdAt2, $updatedAt2, $curDate2, $createdAt3, $updatedAt3, $curDate3) { return $database->createDocuments($col, [ new Document([ '$id' => 'd2', @@ -1091,13 +1051,13 @@ public function testSchemalessDates(): void $fetched2 = $database->getDocument($col, 'd2'); $fetchedCurDate2 = $fetched2->getAttribute('curDate'); - $parsedCurDate2 = new \DateTime($fetchedCurDate2); + $parsedCurDate2 = new \DateTime($this->asString($fetchedCurDate2)); $parsedExpectedCurDate2 = new \DateTime($curDate2); $this->assertEquals($parsedExpectedCurDate2->getTimestamp(), $parsedCurDate2->getTimestamp()); $fetchedCreatedAt2 = $fetched2->getAttribute('$createdAt'); $fetchedUpdatedAt2 = $fetched2->getAttribute('$updatedAt'); - $parsedCreatedAt2 = new \DateTime($fetchedCreatedAt2); - $parsedUpdatedAt2 = new \DateTime($fetchedUpdatedAt2); + $parsedCreatedAt2 = new \DateTime($this->asString($fetchedCreatedAt2)); + $parsedUpdatedAt2 = new \DateTime($this->asString($fetchedUpdatedAt2)); $parsedExpectedCreatedAt2 = new \DateTime($createdAt2); $parsedExpectedUpdatedAt2 = new \DateTime($updatedAt2); $this->assertEquals($parsedExpectedCreatedAt2->getTimestamp(), $parsedCreatedAt2->getTimestamp()); @@ -1105,31 +1065,31 @@ public function testSchemalessDates(): void $fetched3 = $database->getDocument($col, 'd3'); $fetchedCurDate3 = $fetched3->getAttribute('curDate'); - $parsedCurDate3 = new \DateTime($fetchedCurDate3); + $parsedCurDate3 = new \DateTime($this->asString($fetchedCurDate3)); $parsedExpectedCurDate3 = new \DateTime($curDate3); $this->assertEquals($parsedExpectedCurDate3->getTimestamp(), $parsedCurDate3->getTimestamp()); $fetchedCreatedAt3 = $fetched3->getAttribute('$createdAt'); $fetchedUpdatedAt3 = $fetched3->getAttribute('$updatedAt'); - $parsedCreatedAt3 = new \DateTime($fetchedCreatedAt3); - $parsedUpdatedAt3 = new \DateTime($fetchedUpdatedAt3); + $parsedCreatedAt3 = new \DateTime($this->asString($fetchedCreatedAt3)); + $parsedUpdatedAt3 = new \DateTime($this->asString($fetchedUpdatedAt3)); $parsedExpectedCreatedAt3 = new \DateTime($createdAt3); $parsedExpectedUpdatedAt3 = new \DateTime($updatedAt3); $this->assertEquals($parsedExpectedCreatedAt3->getTimestamp(), $parsedCreatedAt3->getTimestamp()); $this->assertEquals($parsedExpectedUpdatedAt3->getTimestamp(), $parsedUpdatedAt3->getTimestamp()); // updateDocument with preserved $updatedAt and custom date field - $newCurDate1 = '2000-02-01T00:00:00.000+00:00'; + $newCurDate1 = '2000-02-01T00:00:00.000+00:00'; $newUpdatedAt1 = '2000-02-02T02:02:02.000+00:00'; - $updated1 = $database->withPreserveDates(function () use ($database, $col, $newCurDate1, $newUpdatedAt1) { + $updated1 = $this->asDocument($database->withPreserveDates(true, function () use ($database, $col, $newCurDate1, $newUpdatedAt1) { return $database->updateDocument($col, 'd1', new Document([ 'curDate' => $newCurDate1, '$updatedAt' => $newUpdatedAt1, ])); - }); + })); $updatedCurDate1 = $updated1->getAttribute('curDate'); $updatedUpdatedAt1 = $updated1->getAttribute('$updatedAt'); - $parsedUpdatedCurDate1 = new \DateTime($updatedCurDate1); - $parsedUpdatedUpdatedAt1 = new \DateTime($updatedUpdatedAt1); + $parsedUpdatedCurDate1 = new \DateTime($this->asString($updatedCurDate1)); + $parsedUpdatedUpdatedAt1 = new \DateTime($this->asString($updatedUpdatedAt1)); $parsedExpectedNewCurDate1 = new \DateTime($newCurDate1); $parsedExpectedNewUpdatedAt1 = new \DateTime($newUpdatedAt1); $this->assertEquals($parsedExpectedNewCurDate1->getTimestamp(), $parsedUpdatedCurDate1->getTimestamp()); @@ -1137,15 +1097,15 @@ public function testSchemalessDates(): void $refetched1 = $database->getDocument($col, 'd1'); $refetchedCurDate1 = $refetched1->getAttribute('curDate'); $refetchedUpdatedAt1 = $refetched1->getAttribute('$updatedAt'); - $parsedRefetchedCurDate1 = new \DateTime($refetchedCurDate1); - $parsedRefetchedUpdatedAt1 = new \DateTime($refetchedUpdatedAt1); + $parsedRefetchedCurDate1 = new \DateTime($this->asString($refetchedCurDate1)); + $parsedRefetchedUpdatedAt1 = new \DateTime($this->asString($refetchedUpdatedAt1)); $this->assertEquals($parsedExpectedNewCurDate1->getTimestamp(), $parsedRefetchedCurDate1->getTimestamp()); $this->assertEquals($parsedExpectedNewUpdatedAt1->getTimestamp(), $parsedRefetchedUpdatedAt1->getTimestamp()); // updateDocuments with preserved $updatedAt over a subset - $bulkCurDate = '2001-01-01T00:00:00.000+00:00'; + $bulkCurDate = '2001-01-01T00:00:00.000+00:00'; $bulkUpdatedAt = '2001-01-02T00:00:00.000+00:00'; - $updatedCount = $database->withPreserveDates(function () use ($database, $col, $bulkCurDate, $bulkUpdatedAt) { + $updatedCount = $database->withPreserveDates(true, function () use ($database, $col, $bulkCurDate, $bulkUpdatedAt) { return $database->updateDocuments( $col, new Document([ @@ -1162,10 +1122,10 @@ public function testSchemalessDates(): void $bulkUpdatedAt2 = $afterBulk2->getAttribute('$updatedAt'); $bulkCurDate3 = $afterBulk3->getAttribute('curDate'); $bulkUpdatedAt3 = $afterBulk3->getAttribute('$updatedAt'); - $parsedBulkCurDate2 = new \DateTime($bulkCurDate2); - $parsedBulkUpdatedAt2 = new \DateTime($bulkUpdatedAt2); - $parsedBulkCurDate3 = new \DateTime($bulkCurDate3); - $parsedBulkUpdatedAt3 = new \DateTime($bulkUpdatedAt3); + $parsedBulkCurDate2 = new \DateTime($this->asString($bulkCurDate2)); + $parsedBulkUpdatedAt2 = new \DateTime($this->asString($bulkUpdatedAt2)); + $parsedBulkCurDate3 = new \DateTime($this->asString($bulkCurDate3)); + $parsedBulkUpdatedAt3 = new \DateTime($this->asString($bulkUpdatedAt3)); $parsedExpectedBulkCurDate = new \DateTime($bulkCurDate); $parsedExpectedBulkUpdatedAt = new \DateTime($bulkUpdatedAt); $this->assertEquals($parsedExpectedBulkCurDate->getTimestamp(), $parsedBulkCurDate2->getTimestamp()); @@ -1176,8 +1136,8 @@ public function testSchemalessDates(): void // upsertDocument: create new then update existing with preserved dates $createdAt4 = '2003-03-03T03:03:03.000+00:00'; $updatedAt4 = '2003-03-04T04:04:04.000+00:00'; - $curDate4 = '2003-03-05T05:05:05.000+00:00'; - $up1 = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt4, $updatedAt4, $curDate4) { + $curDate4 = '2003-03-05T05:05:05.000+00:00'; + $up1 = $this->asDocument($database->withPreserveDates(true, function () use ($database, $col, $permissions, $createdAt4, $updatedAt4, $curDate4) { return $database->upsertDocument($col, new Document([ '$id' => 'd4', '$permissions' => $permissions, @@ -1185,14 +1145,14 @@ public function testSchemalessDates(): void '$updatedAt' => $updatedAt4, 'curDate' => $curDate4, ])); - }); + })); $this->assertEquals('d4', $up1->getId()); $up1CurDate4 = $up1->getAttribute('curDate'); $up1CreatedAt4 = $up1->getAttribute('$createdAt'); $up1UpdatedAt4 = $up1->getAttribute('$updatedAt'); - $parsedUp1CurDate4 = new \DateTime($up1CurDate4); - $parsedUp1CreatedAt4 = new \DateTime($up1CreatedAt4); - $parsedUp1UpdatedAt4 = new \DateTime($up1UpdatedAt4); + $parsedUp1CurDate4 = new \DateTime($this->asString($up1CurDate4)); + $parsedUp1CreatedAt4 = new \DateTime($this->asString($up1CreatedAt4)); + $parsedUp1UpdatedAt4 = new \DateTime($this->asString($up1UpdatedAt4)); $parsedExpectedCurDate4 = new \DateTime($curDate4); $parsedExpectedCreatedAt4 = new \DateTime($createdAt4); $parsedExpectedUpdatedAt4 = new \DateTime($updatedAt4); @@ -1201,18 +1161,18 @@ public function testSchemalessDates(): void $this->assertEquals($parsedExpectedUpdatedAt4->getTimestamp(), $parsedUp1UpdatedAt4->getTimestamp()); $updatedAt4b = '2003-03-06T06:06:06.000+00:00'; - $curDate4b = '2003-03-07T07:07:07.000+00:00'; - $up2 = $database->withPreserveDates(function () use ($database, $col, $updatedAt4b, $curDate4b) { + $curDate4b = '2003-03-07T07:07:07.000+00:00'; + $up2 = $this->asDocument($database->withPreserveDates(true, function () use ($database, $col, $updatedAt4b, $curDate4b) { return $database->upsertDocument($col, new Document([ '$id' => 'd4', 'curDate' => $curDate4b, '$updatedAt' => $updatedAt4b, ])); - }); + })); $up2CurDate4b = $up2->getAttribute('curDate'); $up2UpdatedAt4b = $up2->getAttribute('$updatedAt'); - $parsedUp2CurDate4b = new \DateTime($up2CurDate4b); - $parsedUp2UpdatedAt4b = new \DateTime($up2UpdatedAt4b); + $parsedUp2CurDate4b = new \DateTime($this->asString($up2CurDate4b)); + $parsedUp2UpdatedAt4b = new \DateTime($this->asString($up2UpdatedAt4b)); $parsedExpectedCurDate4b = new \DateTime($curDate4b); $parsedExpectedUpdatedAt4b = new \DateTime($updatedAt4b); $this->assertEquals($parsedExpectedCurDate4b->getTimestamp(), $parsedUp2CurDate4b->getTimestamp()); @@ -1220,19 +1180,19 @@ public function testSchemalessDates(): void $refetched4 = $database->getDocument($col, 'd4'); $refetched4CurDate4b = $refetched4->getAttribute('curDate'); $refetched4UpdatedAt4b = $refetched4->getAttribute('$updatedAt'); - $parsedRefetched4CurDate4b = new \DateTime($refetched4CurDate4b); - $parsedRefetched4UpdatedAt4b = new \DateTime($refetched4UpdatedAt4b); + $parsedRefetched4CurDate4b = new \DateTime($this->asString($refetched4CurDate4b)); + $parsedRefetched4UpdatedAt4b = new \DateTime($this->asString($refetched4UpdatedAt4b)); $this->assertEquals($parsedExpectedCurDate4b->getTimestamp(), $parsedRefetched4CurDate4b->getTimestamp()); $this->assertEquals($parsedExpectedUpdatedAt4b->getTimestamp(), $parsedRefetched4UpdatedAt4b->getTimestamp()); // upsertDocuments: mix create and update with preserved dates $createdAt5 = '2004-04-01T01:01:01.000+00:00'; $updatedAt5 = '2004-04-02T02:02:02.000+00:00'; - $curDate5 = '2004-04-03T03:03:03.000+00:00'; + $curDate5 = '2004-04-03T03:03:03.000+00:00'; $updatedAt2b = '2001-02-08T08:08:08.000+00:00'; - $curDate2b = '2001-02-09T09:09:09.000+00:00'; + $curDate2b = '2001-02-09T09:09:09.000+00:00'; - $upCount = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt5, $updatedAt5, $curDate5, $updatedAt2b, $curDate2b) { + $upCount = $database->withPreserveDates(true, function () use ($database, $col, $permissions, $createdAt5, $updatedAt5, $curDate5, $updatedAt2b, $curDate2b) { return $database->upsertDocuments($col, [ new Document([ '$id' => 'd5', @@ -1254,9 +1214,9 @@ public function testSchemalessDates(): void $fetched5CurDate5 = $fetched5->getAttribute('curDate'); $fetched5CreatedAt5 = $fetched5->getAttribute('$createdAt'); $fetched5UpdatedAt5 = $fetched5->getAttribute('$updatedAt'); - $parsedFetched5CurDate5 = new \DateTime($fetched5CurDate5); - $parsedFetched5CreatedAt5 = new \DateTime($fetched5CreatedAt5); - $parsedFetched5UpdatedAt5 = new \DateTime($fetched5UpdatedAt5); + $parsedFetched5CurDate5 = new \DateTime($this->asString($fetched5CurDate5)); + $parsedFetched5CreatedAt5 = new \DateTime($this->asString($fetched5CreatedAt5)); + $parsedFetched5UpdatedAt5 = new \DateTime($this->asString($fetched5UpdatedAt5)); $parsedExpectedCurDate5 = new \DateTime($curDate5); $parsedExpectedCreatedAt5 = new \DateTime($createdAt5); $parsedExpectedUpdatedAt5 = new \DateTime($updatedAt5); @@ -1267,8 +1227,8 @@ public function testSchemalessDates(): void $fetched2b = $database->getDocument($col, 'd2'); $fetched2bCurDate2b = $fetched2b->getAttribute('curDate'); $fetched2bUpdatedAt2b = $fetched2b->getAttribute('$updatedAt'); - $parsedFetched2bCurDate2b = new \DateTime($fetched2bCurDate2b); - $parsedFetched2bUpdatedAt2b = new \DateTime($fetched2bUpdatedAt2b); + $parsedFetched2bCurDate2b = new \DateTime($this->asString($fetched2bCurDate2b)); + $parsedFetched2bUpdatedAt2b = new \DateTime($this->asString($fetched2bUpdatedAt2b)); $parsedExpectedCurDate2b = new \DateTime($curDate2b); $parsedExpectedUpdatedAt2b = new \DateTime($updatedAt2b); $this->assertEquals($parsedExpectedCurDate2b->getTimestamp(), $parsedFetched2bCurDate2b->getTimestamp()); @@ -1307,19 +1267,20 @@ public function testSchemalessExists(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $colName = uniqid('schemaless_exists'); - $database->createCollection($colName); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Create documents with and without the 'optionalField' attribute @@ -1424,19 +1385,20 @@ public function testSchemalessNotExists(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $colName = uniqid('schemaless_not_exists'); - $database->createCollection($colName); + $database->createCollection(Collection::create(id: $colName)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Create documents with and without the 'optionalField' attribute @@ -1534,12 +1496,13 @@ public function testElemMatch(): void { /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Create documents with array of objects $doc1 = $database->createDocument($collectionId, new Document([ @@ -1548,7 +1511,7 @@ public function testElemMatch(): void 'items' => [ ['sku' => 'ABC', 'qty' => 5, 'price' => 10.50], ['sku' => 'XYZ', 'qty' => 2, 'price' => 20.00], - ] + ], ])); $doc2 = $database->createDocument($collectionId, new Document([ @@ -1557,7 +1520,7 @@ public function testElemMatch(): void 'items' => [ ['sku' => 'ABC', 'qty' => 1, 'price' => 10.50], ['sku' => 'DEF', 'qty' => 10, 'price' => 15.00], - ] + ], ])); $doc3 = $database->createDocument($collectionId, new Document([ @@ -1565,7 +1528,7 @@ public function testElemMatch(): void '$permissions' => [Permission::read(Role::any())], 'items' => [ ['sku' => 'XYZ', 'qty' => 3, 'price' => 20.00], - ] + ], ])); // Test 1: elemMatch with equal and greaterThan - should match doc1 @@ -1573,7 +1536,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::equal('sku', ['ABC']), Query::greaterThan('qty', 1), - ]) + ]), ]); $this->assertCount(1, $results); $this->assertEquals('order1', $results[0]->getId()); @@ -1583,7 +1546,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::equal('sku', ['ABC']), Query::greaterThan('qty', 1), - ]) + ]), ]); $this->assertCount(1, $results); $this->assertEquals('order1', $results[0]->getId()); @@ -1592,7 +1555,7 @@ public function testElemMatch(): void $results = $database->find($collectionId, [ Query::elemMatch('items', [ Query::equal('sku', ['ABC']), - ]) + ]), ]); $this->assertCount(2, $results); $ids = array_map(fn ($doc) => $doc->getId(), $results); @@ -1604,7 +1567,7 @@ public function testElemMatch(): void $results = $database->find($collectionId, [ Query::elemMatch('items', [ Query::greaterThan('qty', 1), - ]) + ]), ]); $this->assertCount(3, $results); $ids = array_map(fn ($doc) => $doc->getId(), $results); @@ -1617,7 +1580,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::equal('sku', ['DEF']), Query::greaterThan('qty', 5), - ]) + ]), ]); $this->assertCount(1, $results); $this->assertEquals('order2', $results[0]->getId()); @@ -1627,7 +1590,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::equal('sku', ['ABC']), Query::lessThan('qty', 3), - ]) + ]), ]); $this->assertCount(1, $results); $this->assertEquals('order2', $results[0]->getId()); @@ -1637,7 +1600,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::equal('sku', ['ABC']), Query::greaterThanEqual('qty', 1), - ]) + ]), ]); $this->assertCount(2, $results); @@ -1645,7 +1608,7 @@ public function testElemMatch(): void $results = $database->find($collectionId, [ Query::elemMatch('items', [ Query::equal('sku', ['NONEXISTENT']), - ]) + ]), ]); $this->assertCount(0, $results); @@ -1654,7 +1617,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::equal('sku', ['XYZ']), Query::equal('price', [20.00]), - ]) + ]), ]); $this->assertCount(2, $results); $ids = array_map(fn ($doc) => $doc->getId(), $results); @@ -1666,7 +1629,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::notEqual('sku', ['ABC']), Query::greaterThan('qty', 2), - ]) + ]), ]); // order 1 has elements where sku == "ABC", qty: 5 => !=ABC fails and sku = XYZ ,qty: 2 => >2 fails $this->assertCount(2, $results); @@ -1687,12 +1650,13 @@ public function testElemMatchComplex(): void { /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $collectionId = ID::unique(); - $database->createCollection($collectionId); + $collectionId = Id::unique(); + $database->createCollection(Collection::create(id: $collectionId)); // Create documents with complex nested structures $doc1 = $database->createDocument($collectionId, new Document([ @@ -1701,7 +1665,7 @@ public function testElemMatchComplex(): void 'products' => [ ['name' => 'Widget', 'stock' => 100, 'category' => 'A', 'active' => true], ['name' => 'Gadget', 'stock' => 50, 'category' => 'B', 'active' => false], - ] + ], ])); $doc2 = $database->createDocument($collectionId, new Document([ @@ -1710,7 +1674,7 @@ public function testElemMatchComplex(): void 'products' => [ ['name' => 'Widget', 'stock' => 200, 'category' => 'A', 'active' => true], ['name' => 'Thing', 'stock' => 25, 'category' => 'C', 'active' => true], - ] + ], ])); // Test: elemMatch with multiple conditions including boolean @@ -1720,7 +1684,7 @@ public function testElemMatchComplex(): void Query::greaterThan('stock', 50), Query::equal('category', ['A']), Query::equal('active', [true]), - ]) + ]), ]); $this->assertCount(2, $results); @@ -1729,7 +1693,7 @@ public function testElemMatchComplex(): void Query::elemMatch('products', [ Query::equal('category', ['A']), Query::between('stock', 75, 150), - ]) + ]), ]); $this->assertCount(1, $results); $this->assertEquals('store1', $results[0]->getId()); @@ -1742,7 +1706,7 @@ public function testElemMatchComplex(): void Query::equal('name', ['Thing']), ]), Query::greaterThanEqual('stock', 25), - ]) + ]), ]); // Both stores have at least one matching product: // - store1: Widget (stock 100) @@ -1763,7 +1727,7 @@ public function testElemMatchComplex(): void ]), ]), Query::equal('active', [true]), - ]) + ]), ]); // Only store2 matches: // - Widget with stock 200 (>150) and active true @@ -1782,19 +1746,20 @@ public function testSchemalessNestedObjectAttributeQueries(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_nested_obj'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Documents with nested objects @@ -1900,9 +1865,9 @@ public function testSchemalessNestedObjectAttributeQueries(): void $this->assertCount(1, $usWithCoords); $this->assertEquals('u1', $usWithCoords[0]->getId()); - // contains on object attribute using nested structure: parent.key and [key => [key => 'value']] + // containsAny on object attribute using nested structure: parent.key and [key => [key => 'value']] $matchedByNestedContains = $database->find($col, [ - Query::contains('profile', [[ + Query::containsAny('profile', [[ 'location' => [ 'city' => 'London', ], @@ -1960,17 +1925,17 @@ public function testUpsertFieldRemoval(): void /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->markTestSkipped('Adapter supports attributes (schemaful mode). Field removal in upsert is tested in schemaful tests.'); } - $collectionName = ID::unique(); - $database->createCollection($collectionName, permissions: [ + $collectionName = Id::unique(); + $database->createCollection(Collection::create(id: $collectionName, permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), - ]); + ])); $permissions = [ Permission::read(Role::any()), @@ -1990,8 +1955,8 @@ public function testUpsertFieldRemoval(): void 'tags' => ['php', 'mongodb'], 'metadata' => [ 'author' => 'John Doe', - 'version' => 1 - ] + 'version' => 1, + ], ])); $this->assertEquals('Original Title', $doc1->getAttribute('title')); @@ -2051,12 +2016,12 @@ public function testUpsertFieldRemoval(): void 'details' => [ 'color' => 'red', 'size' => 'large', - 'weight' => 10 + 'weight' => 10, ], 'specs' => [ 'cpu' => 'Intel', - 'ram' => '8GB' - ] + 'ram' => '8GB', + ], ])); // Upsert removing details but keeping specs @@ -2066,7 +2031,7 @@ public function testUpsertFieldRemoval(): void 'name' => 'Updated Product', 'specs' => [ 'cpu' => 'AMD', - 'ram' => '16GB' + 'ram' => '16GB', ], // details is removed ])); @@ -2074,7 +2039,7 @@ public function testUpsertFieldRemoval(): void $retrieved3 = $database->getDocument($collectionName, 'doc3'); $this->assertEquals('Updated Product', $retrieved3->getAttribute('name')); $this->assertArrayHasKey('specs', $retrieved3->getArrayCopy()); - $this->assertEquals('AMD', $retrieved3->getAttribute('specs')['cpu']); + $this->assertEquals('AMD', $retrieved3->getArray('specs')['cpu']); $this->assertArrayNotHasKey('details', $retrieved3->getArrayCopy()); // Test 4: Remove array fields @@ -2084,7 +2049,7 @@ public function testUpsertFieldRemoval(): void 'title' => 'Article', 'tags' => ['tag1', 'tag2', 'tag3'], 'categories' => ['cat1', 'cat2'], - 'comments' => ['comment1', 'comment2'] + 'comments' => ['comment1', 'comment2'], ])); // Upsert removing tags and comments but keeping categories @@ -2245,40 +2210,31 @@ public function testSchemalessTTLIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_ttl'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_valid', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 3600 // 1 hour TTL - ) - ); + $database->createIndex($col, Index::ttl(key: 'idx_ttl_valid', attribute: 'expiresAt', ttl: 3600)); $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(1, $indexes); $ttlIndex = $indexes[0]; - $this->assertEquals('idx_ttl_valid', $ttlIndex->getId()); - $this->assertEquals(Database::INDEX_TTL, $ttlIndex->getAttribute('type')); - $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); + $this->assertEquals('idx_ttl_valid', $ttlIndex->key); + $this->assertEquals(IndexType::Ttl, $ttlIndex->type); + $this->assertEquals(3600, $ttlIndex->ttl); $now = new \DateTime(); $future1 = (clone $now)->modify('+2 hours'); @@ -2289,21 +2245,21 @@ public function testSchemalessTTLIndexes(): void '$id' => 'doc1', '$permissions' => $permissions, 'expiresAt' => $future1->format(\DateTime::ATOM), - 'data' => 'will expire in 2 hours' + 'data' => 'will expire in 2 hours', ])); $doc2 = $database->createDocument($col, new Document([ '$id' => 'doc2', '$permissions' => $permissions, 'expiresAt' => $future2->format(\DateTime::ATOM), - 'data' => 'will expire in 1 hour' + 'data' => 'will expire in 1 hour', ])); $doc3 = $database->createDocument($col, new Document([ '$id' => 'doc3', '$permissions' => $permissions, 'expiresAt' => $past->format(\DateTime::ATOM), - 'data' => 'already expired' + 'data' => 'already expired', ])); // Verify documents were created @@ -2311,50 +2267,24 @@ public function testSchemalessTTLIndexes(): void $this->assertEquals('doc2', $doc2->getId()); $this->assertEquals('doc3', $doc3->getId()); - $this->assertTrue($database->deleteIndex($col, 'idx_ttl_valid')); + $database->deleteIndex($col, 'idx_ttl_valid'); - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_min', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 1 // Minimum TTL - ) - ); + $database->createIndex($col, Index::ttl(key: 'idx_ttl_min', attribute: 'expiresAt', ttl: 1)); $col2 = uniqid('sl_ttl_collection'); - $expiresAtAttr = new Document([ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]); + $expiresAtAttr = Attribute::datetime(key: 'expiresAt'); - $ttlIndexDoc = new Document([ - '$id' => ID::custom('idx_ttl_collection'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200 // 2 hours - ]); + $ttlIndexDoc = Index::ttl(key: 'idx_ttl_collection', attribute: 'expiresAt', ttl: 7200); - $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); + $database->createCollection(Collection::create(id: $col2, attributes: [$expiresAtAttr], indexes: [$ttlIndexDoc])); $collection2 = $database->getCollection($col2); - $indexes2 = $collection2->getAttribute('indexes'); + $indexes2 = $collection2->indexes(); $this->assertCount(1, $indexes2); $ttlIndex2 = $indexes2[0]; - $this->assertEquals('idx_ttl_collection', $ttlIndex2->getId()); - $this->assertEquals(7200, $ttlIndex2->getAttribute('ttl')); + $this->assertEquals('idx_ttl_collection', $ttlIndex2->key); + $this->assertEquals(7200, $ttlIndex2->ttl); $database->deleteCollection($col); $database->deleteCollection($col2); @@ -2365,144 +2295,67 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $col = uniqid('sl_ttl_dup'); - $database->createCollection($col); + $collectionId = uniqid('sl_ttl_dup'); + $database->createCollection(Collection::create(id: $collectionId)); - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expires', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 3600 // 1 hour - ) - ); + $assertRejected = function (callable $create, string $case): void { + try { + $create(); + $this->fail("Expected exception for {$case}"); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('There can be only one TTL index in a collection', $exception->getMessage(), $case); + } + }; - try { - $database->createIndex( - $col, - 'idx_ttl_expires_duplicate', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 7200 // 2 hours - ); - $this->fail('Expected exception for creating a second TTL index in a collection'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); - } + $indexIds = fn (): array => array_map(fn (Index $index) => $index->key, $database->getCollection($collectionId)->indexes()); try { - $database->createIndex( - $col, - 'idx_ttl_deleted', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 86400 // 24 hours - ); - $this->fail('Expected exception for creating a second TTL index in a collection'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); - } - - $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(1, $indexes); - - $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); - $this->assertContains('idx_ttl_expires', $indexIds); - $this->assertNotContains('idx_ttl_deleted', $indexIds); + $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_expires', attribute: 'expiresAt', ttl: 3600)); - try { - $database->createIndex( - $col, - 'idx_ttl_deleted_duplicate', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 172800 // 48 hours + $assertRejected( + fn () => $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_expires_duplicate', attribute: 'expiresAt', ttl: 7200)), + 'a second TTL index on the same attribute', + ); + $assertRejected( + fn () => $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_deleted', attribute: 'deletedAt', ttl: 86400)), + 'a second TTL index on another attribute', ); - $this->fail('Expected exception for creating a second TTL index in a collection'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); - } - - $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); - - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_deleted', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 1800 // 30 minutes - ) - ); - - $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(1, $indexes); - $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); - $this->assertNotContains('idx_ttl_expires', $indexIds); - $this->assertContains('idx_ttl_deleted', $indexIds); + $this->assertSame(['idx_ttl_expires'], $indexIds()); - $col3 = uniqid('sl_ttl_dup_collection'); + $assertRejected( + fn () => $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_deleted_duplicate', attribute: 'deletedAt', ttl: 172800)), + 'a second TTL index after earlier rejections', + ); - $expiresAtAttr = new Document([ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]); + $database->deleteIndex($collectionId, 'idx_ttl_expires'); - $ttlIndex1 = new Document([ - '$id' => ID::custom('idx_ttl_1'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 3600 - ]); + $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_deleted', attribute: 'deletedAt', ttl: 1800)); - $ttlIndex2 = new Document([ - '$id' => ID::custom('idx_ttl_2'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200 - ]); + $this->assertSame(['idx_ttl_deleted'], $indexIds()); - try { - $database->createCollection($col3, [$expiresAtAttr], [$ttlIndex1, $ttlIndex2]); - $this->fail('Expected exception for duplicate TTL indexes in createCollection'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + $collectionWithTwoTTLIndexes = uniqid('sl_ttl_dup_collection'); + $assertRejected( + fn () => $database->createCollection(Collection::create( + id: $collectionWithTwoTTLIndexes, + attributes: [Attribute::datetime(key: 'expiresAt')], + indexes: [ + Index::ttl(key: 'idx_ttl_1', attribute: 'expiresAt', ttl: 3600), + Index::ttl(key: 'idx_ttl_2', attribute: 'expiresAt', ttl: 7200), + ], + )), + 'two TTL indexes in createCollection', + ); + $this->assertNull($database->findCollection($collectionWithTwoTTLIndexes), 'A rejected collection must not be created'); + } finally { + $database->deleteCollection($collectionId); } - - $database->deleteCollection($col); } public function testSchemalessDatetimeCreationAndFetching(): void @@ -2510,19 +2363,20 @@ public function testSchemalessDatetimeCreationAndFetching(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_datetime'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Create documents with ISO 8601 datetime strings (20-40 chars) @@ -2535,21 +2389,21 @@ public function testSchemalessDatetimeCreationAndFetching(): void '$id' => 'dt1', '$permissions' => $permissions, 'eventDate' => $datetime1, - 'name' => 'Event 1' + 'name' => 'Event 1', ])); $doc2 = $database->createDocument($col, new Document([ '$id' => 'dt2', '$permissions' => $permissions, 'eventDate' => $datetime2, - 'name' => 'Event 2' + 'name' => 'Event 2', ])); $doc3 = $database->createDocument($col, new Document([ '$id' => 'dt3', '$permissions' => $permissions, 'eventDate' => $datetime3, - 'name' => 'Event 3' + 'name' => 'Event 3', ])); // Verify creation - check that datetime is stored and returned as string @@ -2567,14 +2421,14 @@ public function testSchemalessDatetimeCreationAndFetching(): void // Verify datetime values are equivalent by parsing (MongoDB converts to UTC) $parsedInput1 = new \DateTime($datetime1); - $parsedOutput1 = new \DateTime($fetchedEventDate1); + $parsedOutput1 = new \DateTime($this->asString($fetchedEventDate1)); $this->assertEquals($parsedInput1->getTimestamp(), $parsedOutput1->getTimestamp()); $fetched2 = $database->getDocument($col, 'dt2'); $fetchedEventDate2 = $fetched2->getAttribute('eventDate'); $this->assertTrue(is_string($fetchedEventDate2)); $parsedInput2 = new \DateTime($datetime2); - $parsedOutput2 = new \DateTime($fetchedEventDate2); + $parsedOutput2 = new \DateTime($this->asString($fetchedEventDate2)); $this->assertEquals($parsedInput2->getTimestamp(), $parsedOutput2->getTimestamp()); $fetched3 = $database->getDocument($col, 'dt3'); @@ -2583,8 +2437,8 @@ public function testSchemalessDatetimeCreationAndFetching(): void // Verify it's a valid datetime string (format may vary slightly) $this->assertGreaterThanOrEqual(20, strlen($fetchedEventDate3)); $this->assertLessThanOrEqual(40, strlen($fetchedEventDate3)); - $parsedInput3 = new \DateTime($datetime3); - $parsedOutput3 = new \DateTime($fetchedEventDate3); + $parsedInput3 = new \DateTime($this->asString($datetime3)); + $parsedOutput3 = new \DateTime($this->asString($fetchedEventDate3)); // MongoDB converts to UTC, so timestamps should match $this->assertEquals($parsedInput3->getTimestamp(), $parsedOutput3->getTimestamp()); @@ -2601,7 +2455,7 @@ public function testSchemalessDatetimeCreationAndFetching(): void // Update datetime $newDatetime = '2024-12-31T23:59:59.999+00:00'; $updated = $database->updateDocument($col, 'dt1', new Document([ - 'eventDate' => $newDatetime + 'eventDate' => $newDatetime, ])); $updatedEventDate = $updated->getAttribute('eventDate'); $this->assertTrue(is_string($updatedEventDate)); @@ -2612,7 +2466,7 @@ public function testSchemalessDatetimeCreationAndFetching(): void $refetchedEventDate = $refetched->getAttribute('eventDate'); $this->assertTrue(is_string($refetchedEventDate)); $parsedNewInput = new \DateTime($newDatetime); - $parsedNewOutput = new \DateTime($refetchedEventDate); + $parsedNewOutput = new \DateTime($this->asString($refetchedEventDate)); $this->assertEquals($parsedNewInput->getTimestamp(), $parsedNewOutput->getTimestamp()); $database->deleteCollection($col); @@ -2623,38 +2477,30 @@ public function testSchemalessTTLExpiry(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForTTLIndexes()) { + if (! $database->getAdapter()->supports(Capability::IndexTtl)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_ttl_expiry'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Create TTL index with 60 seconds expiry - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expiresAt', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 10 - ) - ); + $database->createIndex($col, Index::ttl(key: 'idx_ttl_expiresAt', attribute: 'expiresAt', ttl: 10)); $now = new \DateTime(); $expiredTime = (clone $now)->modify('-10 seconds'); // Already expired @@ -2666,7 +2512,7 @@ public function testSchemalessTTLExpiry(): void '$permissions' => $permissions, 'expiresAt' => $expiredTime->format(\DateTime::ATOM), 'data' => 'This should expire', - 'type' => 'temporary' + 'type' => 'temporary', ])); $doc2 = $database->createDocument($col, new Document([ @@ -2674,21 +2520,21 @@ public function testSchemalessTTLExpiry(): void '$permissions' => $permissions, 'expiresAt' => $futureTime->format(\DateTime::ATOM), 'data' => 'This should not expire yet', - 'type' => 'temporary' + 'type' => 'temporary', ])); $doc3 = $database->createDocument($col, new Document([ '$id' => 'permanent_doc', '$permissions' => $permissions, 'data' => 'This should never expire', - 'type' => 'permanent' + 'type' => 'permanent', ])); $doc4 = $database->createDocument($col, new Document([ '$id' => 'another_permanent', '$permissions' => $permissions, 'data' => 'This should also never expire', - 'type' => 'permanent' + 'type' => 'permanent', ])); // Verify all documents were created @@ -2711,14 +2557,13 @@ public function testSchemalessTTLExpiry(): void sleep($retryDelay); // Fetch collection to trigger TTL cleanup check - $collection = $database->getCollection($col); - $this->assertNotNull($collection); + $database->getCollection($col); // Check if expired document is gone $remainingDocs = $database->find($col); $remainingIds = array_map(fn ($doc) => $doc->getId(), $remainingDocs); - if (!in_array('expired_doc', $remainingIds)) { + if (! in_array('expired_doc', $remainingIds)) { $expiredDocDeleted = true; break; } @@ -2765,38 +2610,30 @@ public function testSchemalessTTLWithCacheExpiry(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForTTLIndexes()) { + if (! $database->getAdapter()->supports(Capability::IndexTtl)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_ttl_cache_expiry'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Create TTL index with 10 seconds expiry (also used as cache TTL) - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expiresAt', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 10 - ) - ); + $database->createIndex($col, Index::ttl(key: 'idx_ttl_expiresAt', attribute: 'expiresAt', ttl: 10)); $now = new \DateTime(); $expiredTime = (clone $now)->modify('-10 seconds'); // Already expired from TTL perspective @@ -2831,8 +2668,7 @@ public function testSchemalessTTLWithCacheExpiry(): void sleep($retryDelay); // Fetch collection to trigger TTL cleanup check in MongoDB - $collection = $database->getCollection($col); - $this->assertNotNull($collection); + $database->getCollection($col); // Fetch through getDocument, which goes through the cache layer $expired = $database->getDocument($col, 'expired_doc'); @@ -2858,19 +2694,20 @@ public function testStringAndDatetime(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_str_datetime'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Create documents with mix of formatted dates (ISO 8601) and non-formatted dates (regular strings) @@ -2880,31 +2717,31 @@ public function testStringAndDatetime(): void '$id' => 'doc1', '$permissions' => $permissions, 'str' => '2024-01-15T10:30:00.000+00:00', // ISO 8601 formatted date as string - 'datetime' => '2024-01-15T10:30:00.000+00:00' // ISO 8601 formatted date + 'datetime' => '2024-01-15T10:30:00.000+00:00', // ISO 8601 formatted date ]), new Document([ '$id' => 'doc2', '$permissions' => $permissions, 'str' => 'just a regular string', // Non-formatted string - 'datetime' => '2024-02-20T14:45:30.123Z' // ISO 8601 formatted date + 'datetime' => '2024-02-20T14:45:30.123Z', // ISO 8601 formatted date ]), new Document([ '$id' => 'doc3', '$permissions' => $permissions, 'str' => '2024-03-25T08:15:45.000000+05:30', // ISO 8601 formatted date as string - 'datetime' => 'not a date string' // Non-formatted string in datetime field + 'datetime' => 'not a date string', // Non-formatted string in datetime field ]), new Document([ '$id' => 'doc4', '$permissions' => $permissions, 'str' => 'another string value', - 'datetime' => '2024-12-31T23:59:59.999+00:00' // ISO 8601 formatted date + 'datetime' => '2024-12-31T23:59:59.999+00:00', // ISO 8601 formatted date ]), new Document([ '$id' => 'doc5', '$permissions' => $permissions, 'str' => '2024-06-15T12:00:00.000Z', // ISO 8601 formatted date as string - 'datetime' => '2024-06-15T12:00:00.000Z' // ISO 8601 formatted date + 'datetime' => '2024-06-15T12:00:00.000Z', // ISO 8601 formatted date ]), ]; @@ -2924,21 +2761,17 @@ public function testStringAndDatetime(): void $this->assertGreaterThanOrEqual(20, strlen($doc1->getAttribute('str'))); $this->assertLessThanOrEqual(40, strlen($doc1->getAttribute('str'))); // datetime field should be converted to MongoDB format if it's a valid ISO date - $datetime1 = $doc1->getAttribute('datetime'); - $this->assertTrue(is_string($datetime1)); + $datetime1 = $this->asString($doc1->getAttribute('datetime')); $this->assertGreaterThanOrEqual(20, strlen($datetime1)); $this->assertLessThanOrEqual(40, strlen($datetime1)); // Verify it's a valid datetime by parsing - $parsed1 = new \DateTime($datetime1); - $this->assertInstanceOf(\DateTime::class, $parsed1); + $this->assertSameInstant('2024-01-15T10:30:00.000+00:00', $datetime1); $doc2 = $database->getDocument($col, 'doc2'); $this->assertEquals('doc2', $doc2->getId()); $this->assertEquals('just a regular string', $doc2->getAttribute('str')); - $datetime2 = $doc2->getAttribute('datetime'); - $this->assertTrue(is_string($datetime2)); - $parsed2 = new \DateTime($datetime2); - $this->assertInstanceOf(\DateTime::class, $parsed2); + $datetime2 = $this->asString($doc2->getAttribute('datetime')); + $this->assertSameInstant('2024-02-20T14:45:30.123Z', $datetime2); $doc3 = $database->getDocument($col, 'doc3'); $this->assertEquals('doc3', $doc3->getId()); @@ -2954,10 +2787,8 @@ public function testStringAndDatetime(): void $doc4 = $database->getDocument($col, 'doc4'); $this->assertEquals('doc4', $doc4->getId()); $this->assertEquals('another string value', $doc4->getAttribute('str')); - $datetime4 = $doc4->getAttribute('datetime'); - $this->assertTrue(is_string($datetime4)); - $parsed4 = new \DateTime($datetime4); - $this->assertInstanceOf(\DateTime::class, $parsed4); + $datetime4 = $this->asString($doc4->getAttribute('datetime')); + $this->assertSameInstant('2024-12-31T23:59:59.999+00:00', $datetime4); $doc5 = $database->getDocument($col, 'doc5'); $this->assertEquals('doc5', $doc5->getId()); @@ -2965,10 +2796,8 @@ public function testStringAndDatetime(): void $this->assertTrue(is_string($str5)); $this->assertGreaterThanOrEqual(20, strlen($str5)); $this->assertLessThanOrEqual(40, strlen($str5)); - $datetime5 = $doc5->getAttribute('datetime'); - $this->assertTrue(is_string($datetime5)); - $parsed5 = new \DateTime($datetime5); - $this->assertInstanceOf(\DateTime::class, $parsed5); + $datetime5 = $this->asString($doc5->getAttribute('datetime')); + $this->assertSameInstant('2024-06-15T12:00:00.000Z', $datetime5); // Verify all documents are present using simple find $allDocs = $database->find($col); @@ -2988,38 +2817,30 @@ public function testStringAndDateWithTTL(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForTTLIndexes()) { + if (! $database->getAdapter()->supports(Capability::IndexTtl)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_str_date_ttl'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Create TTL index on expiresAt field - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expiresAt', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 10 - ) - ); + $database->createIndex($col, Index::ttl(key: 'idx_ttl_expiresAt', attribute: 'expiresAt', ttl: 10)); $now = new \DateTime(); $expiredTime = (clone $now)->modify('-10 seconds'); // Already expired @@ -3032,35 +2853,35 @@ public function testStringAndDateWithTTL(): void '$permissions' => $permissions, 'expiresAt' => $expiredTime->format(\DateTime::ATOM), // Valid datetime - should expire 'data' => 'This should expire', - 'type' => 'datetime' + 'type' => 'datetime', ]), new Document([ '$id' => 'doc_datetime_future', '$permissions' => $permissions, 'expiresAt' => $futureTime->format(\DateTime::ATOM), // Valid datetime - future 'data' => 'This should not expire yet', - 'type' => 'datetime' + 'type' => 'datetime', ]), new Document([ '$id' => 'doc_string_random', '$permissions' => $permissions, 'expiresAt' => 'random_string_value_12345', // Random string - should not expire 'data' => 'This should never expire', - 'type' => 'string' + 'type' => 'string', ]), new Document([ '$id' => 'doc_string_another', '$permissions' => $permissions, 'expiresAt' => 'another_random_string_xyz', // Random string - should not expire 'data' => 'This should also never expire', - 'type' => 'string' + 'type' => 'string', ]), new Document([ '$id' => 'doc_datetime_valid', '$permissions' => $permissions, 'expiresAt' => $futureTime->format(\DateTime::ATOM), // Valid datetime - future 'data' => 'This is a valid datetime', - 'type' => 'datetime' + 'type' => 'datetime', ]), ]; @@ -3078,10 +2899,8 @@ public function testStringAndDateWithTTL(): void $docDatetimeFuture = $database->getDocument($col, 'doc_datetime_future'); $this->assertFalse($docDatetimeFuture->isEmpty()); - $expiresAt2 = $docDatetimeFuture->getAttribute('expiresAt'); - $this->assertTrue(is_string($expiresAt2)); - $parsed2 = new \DateTime($expiresAt2); - $this->assertInstanceOf(\DateTime::class, $parsed2); + $expiresAt2 = $this->asString($docDatetimeFuture->getAttribute('expiresAt')); + $this->assertSameInstant($futureTime->format(\DateTime::ATOM), $expiresAt2); // Verify documents with random strings remain as strings $docStringRandom = $database->getDocument($col, 'doc_string_random'); @@ -3107,13 +2926,12 @@ public function testStringAndDateWithTTL(): void sleep($retryDelay); // Fetch collection to trigger TTL cleanup check - $collection = $database->getCollection($col); - $this->assertNotNull($collection); + $database->getCollection($col); $remainingDocs = $database->find($col); $remainingIds = array_map(fn ($doc) => $doc->getId(), $remainingDocs); - if (!in_array('doc_datetime_expired', $remainingIds)) { + if (! in_array('doc_datetime_expired', $remainingIds)) { $expiredDocDeleted = true; break; } @@ -3158,16 +2976,17 @@ public function testSchemalessMongoDotNotationIndexes(): void $database = static::getDatabase(); // Only meaningful for schemaless adapters - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_mongo_dot_idx'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); // Define top-level object attribute (metadata only; schemaless adapter won't enforce) - $database->createAttribute($col, 'profile', Database::VAR_OBJECT, 0, false); + $database->createAttribute($col, Attribute::object(key: 'profile')); // Seed documents $database->createDocuments($col, [ @@ -3177,9 +2996,9 @@ public function testSchemalessMongoDotNotationIndexes(): void 'profile' => [ 'user' => [ 'email' => 'alice@example.com', - 'id' => 'alice' - ] - ] + 'id' => 'alice', + ], + ], ]), new Document([ '$id' => 'u2', @@ -3187,35 +3006,17 @@ public function testSchemalessMongoDotNotationIndexes(): void 'profile' => [ 'user' => [ 'email' => 'bob@example.com', - 'id' => 'bob' - ] - ] + 'id' => 'bob', + ], + ], ]), ]); // Create KEY index on nested path - $this->assertTrue( - $database->createIndex( - $col, - 'idx_profile_user_email_key', - Database::INDEX_KEY, - ['profile.user.email'], - [0], - [Database::ORDER_ASC] - ) - ); + $database->createIndex($col, Index::key(key: 'idx_profile_user_email_key', attributes: ['profile.user.email'], lengths: [0], orders: [OrderDirection::Asc])); // Create UNIQUE index on nested path and verify enforcement - $this->assertTrue( - $database->createIndex( - $col, - 'idx_profile_user_id_unique', - Database::INDEX_UNIQUE, - ['profile.user.id'], - [0], - [Database::ORDER_ASC] - ) - ); + $database->createIndex($col, Index::unique(key: 'idx_profile_user_id_unique', attributes: ['profile.user.id'], lengths: [0], orders: [OrderDirection::Asc])); try { $database->createDocument($col, new Document([ @@ -3224,9 +3025,9 @@ public function testSchemalessMongoDotNotationIndexes(): void 'profile' => [ 'user' => [ 'email' => 'eve@example.com', - 'id' => 'alice' // duplicate unique nested id - ] - ] + 'id' => 'alice', // duplicate unique nested id + ], + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -3235,7 +3036,7 @@ public function testSchemalessMongoDotNotationIndexes(): void // Validate dot-notation querying works (and is the shape that can use indexes) $results = $database->find($col, [ - Query::equal('profile.user.email', ['bob@example.com']) + Query::equal('profile.user.email', ['bob@example.com']), ]); $this->assertCount(1, $results); $this->assertEquals('u2', $results[0]->getId()); @@ -3248,19 +3049,20 @@ public function testQueryWithDatetime(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_query_datetime'); - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) + Permission::delete(Role::any()), ]; // Documents with datetime field (ISO 8601) for query tests @@ -3270,13 +3072,13 @@ public function testQueryWithDatetime(): void '$id' => 'dt1', '$permissions' => $permissions, 'name' => 'January', - 'datetime' => '2024-01-15T10:30:00.000+00:00' + 'datetime' => '2024-01-15T10:30:00.000+00:00', ]), new Document([ '$id' => 'dt2', '$permissions' => $permissions, 'name' => 'February', - 'datetime' => '2024-02-20T14:45:30.123Z' + 'datetime' => '2024-02-20T14:45:30.123Z', ]), new Document([ '$id' => 'dt3', @@ -3284,19 +3086,19 @@ public function testQueryWithDatetime(): void 'name' => 'March', // Use a valid extended ISO 8601 datetime that will be normalized // to MongoDB UTCDateTime for comparison queries. - 'datetime' => '2024-03-25T08:15:45.000+00:00' + 'datetime' => '2024-03-25T08:15:45.000+00:00', ]), new Document([ '$id' => 'dt4', '$permissions' => $permissions, 'name' => 'June', - 'datetime' => '2024-06-15T12:00:00.000Z' + 'datetime' => '2024-06-15T12:00:00.000Z', ]), new Document([ '$id' => 'dt5', '$permissions' => $permissions, 'name' => 'December', - 'datetime' => '2024-12-31T23:59:59.999+00:00' + 'datetime' => '2024-12-31T23:59:59.999+00:00', ]), ]; @@ -3305,7 +3107,7 @@ public function testQueryWithDatetime(): void // Query: equal - find document with exact datetime (Jan 15 2024) $equalResults = $database->find($col, [ - Query::equal('datetime', ['2024-01-15T10:30:00.000+00:00']) + Query::equal('datetime', ['2024-01-15T10:30:00.000+00:00']), ]); $this->assertCount(1, $equalResults); $this->assertEquals('dt1', $equalResults[0]->getId()); @@ -3313,7 +3115,7 @@ public function testQueryWithDatetime(): void // Query: greaterThan - datetimes after 2024-03-01 (dt3, dt4, dt5) $greaterResults = $database->find($col, [ - Query::greaterThan('datetime', '2024-03-01T00:00:00.000Z') + Query::greaterThan('datetime', '2024-03-01T00:00:00.000Z'), ]); $this->assertCount(3, $greaterResults); $greaterIds = array_map(fn ($d) => $d->getId(), $greaterResults); @@ -3323,7 +3125,7 @@ public function testQueryWithDatetime(): void // Query: lessThan - datetimes before 2024-03-01 (dt1, dt2) $lessResults = $database->find($col, [ - Query::lessThan('datetime', '2024-03-01T00:00:00.000Z') + Query::lessThan('datetime', '2024-03-01T00:00:00.000Z'), ]); $this->assertCount(2, $lessResults); $lessIds = array_map(fn ($d) => $d->getId(), $lessResults); @@ -3332,7 +3134,7 @@ public function testQueryWithDatetime(): void // Query: greaterThanEqual - datetimes on or after 2024-02-20 (dt2, dt3, dt4, dt5) $gteResults = $database->find($col, [ - Query::greaterThanEqual('datetime', '2024-02-20T14:45:30.123Z') + Query::greaterThanEqual('datetime', '2024-02-20T14:45:30.123Z'), ]); $this->assertCount(4, $gteResults); $gteIds = array_map(fn ($d) => $d->getId(), $gteResults); @@ -3343,7 +3145,7 @@ public function testQueryWithDatetime(): void // Query: lessThanEqual - datetimes on or before 2024-06-15 (dt1, dt2, dt3, dt4) $lteResults = $database->find($col, [ - Query::lessThanEqual('datetime', '2024-06-15T12:00:00.000Z') + Query::lessThanEqual('datetime', '2024-06-15T12:00:00.000Z'), ]); $this->assertCount(4, $lteResults); $lteIds = array_map(fn ($d) => $d->getId(), $lteResults); @@ -3354,7 +3156,7 @@ public function testQueryWithDatetime(): void // Query: between - datetimes in range [2024-02-01, 2024-07-01) (dt2, dt3, dt4) $betweenResults = $database->find($col, [ - Query::between('datetime', '2024-02-01T00:00:00.000Z', '2024-07-01T00:00:00.000Z') + Query::between('datetime', '2024-02-01T00:00:00.000Z', '2024-07-01T00:00:00.000Z'), ]); $this->assertCount(3, $betweenResults); $betweenIds = array_map(fn ($d) => $d->getId(), $betweenResults); @@ -3364,7 +3166,7 @@ public function testQueryWithDatetime(): void // Query: equal with no match $noneResults = $database->find($col, [ - Query::equal('datetime', ['2020-01-01T00:00:00.000Z']) + Query::equal('datetime', ['2020-01-01T00:00:00.000Z']), ]); $this->assertCount(0, $noneResults); @@ -3376,21 +3178,22 @@ public function testSchemalessCreatedAndUpdatedAtQuery(): void /** @var Database $database */ $database = static::getDatabase(); - if ($database->getAdapter()->getSupportForAttributes()) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } // Create a simple schemaless collection and one document. - $database->createCollection('schemaless_time', permissions: [ + $database->createCollection(Collection::create(id: 'schemaless_time', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), - ]); + ])); $database->createDocument('schemaless_time', new Document([ - '$id' => ID::unique(), + '$id' => Id::unique(), '$permissions' => [Permission::read(Role::any())], 'name' => 'Schemaless Movie', ])); @@ -3400,7 +3203,7 @@ public function testSchemalessCreatedAndUpdatedAtQuery(): void $recentPastDate = '2020-01-01T00:00:00.000Z'; $nearFutureDate = '2025-01-01T00:00:00.000Z'; - // --- createdBefore --- + // createdBefore $documents = $database->find('schemaless_time', [ Query::createdBefore($futureDate), Query::limit(1), @@ -3413,7 +3216,7 @@ public function testSchemalessCreatedAndUpdatedAtQuery(): void ]); $this->assertEquals(0, count($documents)); - // --- createdAfter --- + // createdAfter $documents = $database->find('schemaless_time', [ Query::createdAfter($pastDate), Query::limit(1), @@ -3426,7 +3229,7 @@ public function testSchemalessCreatedAndUpdatedAtQuery(): void ]); $this->assertEquals(0, count($documents)); - // --- updatedBefore --- + // updatedBefore $documents = $database->find('schemaless_time', [ Query::updatedBefore($futureDate), Query::limit(1), @@ -3439,7 +3242,7 @@ public function testSchemalessCreatedAndUpdatedAtQuery(): void ]); $this->assertEquals(0, count($documents)); - // --- updatedAfter --- + // updatedAfter $documents = $database->find('schemaless_time', [ Query::updatedAfter($pastDate), Query::limit(1), @@ -3452,7 +3255,7 @@ public function testSchemalessCreatedAndUpdatedAtQuery(): void ]); $this->assertEquals(0, count($documents)); - // --- createdBetween --- + // createdBetween $documents = $database->find('schemaless_time', [ Query::createdBetween($pastDate, $futureDate), Query::limit(25), @@ -3477,7 +3280,7 @@ public function testSchemalessCreatedAndUpdatedAtQuery(): void ]); $this->assertGreaterThanOrEqual($count, count($documents)); - // --- updatedBetween --- + // updatedBetween $documents = $database->find('schemaless_time', [ Query::updatedBetween($pastDate, $futureDate), Query::limit(25), diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 9a82de6b9c..b05c720067 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -2,16 +2,29 @@ namespace Tests\E2E\Adapter\Scopes; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Attribute; +use Utopia\Database\AttributeUpdate; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Index; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\Role; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; trait SpatialTests { @@ -19,73 +32,40 @@ public function testSpatialCollection(): void { /** @var Database $database */ $database = $this->getDatabase(); - $collectionName = "test_spatial_Col"; - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $collectionName = 'test_spatial_Col'; + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; - }; + } $attributes = [ - new Document([ - '$id' => ID::custom('attribute1'), - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('attribute2'), - 'type' => Database::VAR_POINT, - 'size' => 0, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]) + Attribute::string(key: 'attribute1', size: 256), + Attribute::point(key: 'attribute2', required: true), ]; $indexes = [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['attribute1'], - 'lengths' => [256], - 'orders' => [], - ]), - new Document([ - '$id' => ID::custom('index2'), - 'type' => Database::INDEX_SPATIAL, - 'attributes' => ['attribute2'], - 'lengths' => [], - 'orders' => [], - ]), + Index::key(key: 'index1', attributes: ['attribute1'], lengths: [256]), + Index::spatial(key: 'index2', attribute: 'attribute2'), ]; - $col = $database->createCollection($collectionName, $attributes, $indexes); + $col = $database->createCollection(Collection::create(id: $collectionName, attributes: $attributes, indexes: $indexes)); - $this->assertIsArray($col->getAttribute('attributes')); - $this->assertCount(2, $col->getAttribute('attributes')); + $this->assertCount(2, $col->attributes()); - $this->assertIsArray($col->getAttribute('indexes')); - $this->assertCount(2, $col->getAttribute('indexes')); + $this->assertCount(2, $col->indexes()); $col = $database->getCollection($collectionName); - $this->assertIsArray($col->getAttribute('attributes')); - $this->assertCount(2, $col->getAttribute('attributes')); + $this->assertCount(2, $col->attributes()); - $this->assertIsArray($col->getAttribute('indexes')); - $this->assertCount(2, $col->getAttribute('indexes')); + $this->assertCount(2, $col->indexes()); - $database->createAttribute($collectionName, 'attribute3', Database::VAR_POINT, 0, true); - $database->createIndex($collectionName, ID::custom("index3"), Database::INDEX_SPATIAL, ['attribute3']); + $database->createAttribute($collectionName, Attribute::point(key: 'attribute3', required: true)); + $database->createIndex($collectionName, Index::spatial(key: Id::custom('index3'), attribute: 'attribute3')); $col = $database->getCollection($collectionName); - $this->assertIsArray($col->getAttribute('attributes')); - $this->assertCount(3, $col->getAttribute('attributes')); + $this->assertCount(3, $col->attributes()); - $this->assertIsArray($col->getAttribute('indexes')); - $this->assertCount(3, $col->getAttribute('indexes')); + $this->assertCount(3, $col->indexes()); $database->deleteCollection($collectionName); } @@ -94,8 +74,9 @@ public function testSpatialTypeDocuments(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -103,17 +84,17 @@ public function testSpatialTypeDocuments(): void try { // Create collection first - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); // Create spatial attributes using createAttribute method - $this->assertEquals(true, $database->createAttribute($collectionName, 'pointAttr', Database::VAR_POINT, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'lineAttr', Database::VAR_LINESTRING, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'polyAttr', Database::VAR_POLYGON, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true)); + $database->createAttribute($collectionName, Attribute::point(key: 'pointAttr', required: $database->getAdapter()->supports(Capability::IndexSpatialNull) ? false : true)); + $database->createAttribute($collectionName, Attribute::lineString(key: 'lineAttr', required: $database->getAdapter()->supports(Capability::IndexSpatialNull) ? false : true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'polyAttr', required: $database->getAdapter()->supports(Capability::IndexSpatialNull) ? false : true)); // Create spatial indexes - $this->assertEquals(true, $database->createIndex($collectionName, 'point_spatial', Database::INDEX_SPATIAL, ['pointAttr'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'line_spatial', Database::INDEX_SPATIAL, ['lineAttr'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'poly_spatial', Database::INDEX_SPATIAL, ['polyAttr'])); + $database->createIndex($collectionName, Index::spatial(key: 'point_spatial', attribute: 'pointAttr')); + $database->createIndex($collectionName, Index::spatial(key: 'line_spatial', attribute: 'lineAttr')); + $database->createIndex($collectionName, Index::spatial(key: 'poly_spatial', attribute: 'polyAttr')); $point = [5.0, 5.0]; $linestring = [[1.0, 2.0], [3.0, 4.0]]; @@ -125,16 +106,14 @@ public function testSpatialTypeDocuments(): void 'pointAttr' => $point, 'lineAttr' => $linestring, 'polyAttr' => $polygon, - '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())] + '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())], ]); $createdDoc = $database->createDocument($collectionName, $doc1); - $this->assertInstanceOf(Document::class, $createdDoc); $this->assertEquals($point, $createdDoc->getAttribute('pointAttr')); $this->assertEquals($linestring, $createdDoc->getAttribute('lineAttr')); $this->assertEquals($polygon, $createdDoc->getAttribute('polyAttr')); $createdDoc = $database->getDocument($collectionName, 'doc1'); - $this->assertInstanceOf(Document::class, $createdDoc); $this->assertEquals($point, $createdDoc->getAttribute('pointAttr')); $this->assertEquals($linestring, $createdDoc->getAttribute('lineAttr')); $this->assertEquals($polygon, $createdDoc->getAttribute('polyAttr')); @@ -145,7 +124,6 @@ public function testSpatialTypeDocuments(): void $this->assertEquals([6.0, 6.0], $updatedDoc->getAttribute('pointAttr')); - // Test spatial queries with appropriate operations for each geometry type // Point attribute tests - use operations valid for points $pointQueries = [ @@ -154,30 +132,30 @@ public function testSpatialTypeDocuments(): void 'distanceEqual' => Query::distanceEqual('pointAttr', [5.0, 5.0], 1.4142135623730951), 'distanceNotEqual' => Query::distanceNotEqual('pointAttr', [1.0, 1.0], 0.0), 'intersects' => Query::intersects('pointAttr', [6.0, 6.0]), - 'notIntersects' => Query::notIntersects('pointAttr', [1.0, 1.0]) + 'notIntersects' => Query::notIntersects('pointAttr', [1.0, 1.0]), ]; foreach ($pointQueries as $queryType => $query) { - $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $result = $database->find($collectionName, [$query], PermissionType::Read); $this->assertNotEmpty($result, sprintf('Failed spatial query: %s on pointAttr', $queryType)); $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document returned for %s on pointAttr', $queryType)); } // LineString attribute tests - use operations valid for linestrings $lineQueries = [ - 'contains' => Query::contains('lineAttr', [[1.0, 2.0]]), // Point on the line (endpoint) - 'notContains' => Query::notContains('lineAttr', [[5.0, 6.0]]), // Point not on the line + 'contains' => Query::covers('lineAttr', [[1.0, 2.0]]), // Point on the line (endpoint) + 'notContains' => Query::notCovers('lineAttr', [[5.0, 6.0]]), // Point not on the line 'equals' => query::equal('lineAttr', [[[1.0, 2.0], [3.0, 4.0]]]), // Exact same linestring 'notEquals' => query::notEqual('lineAttr', [[[5.0, 6.0], [7.0, 8.0]]]), // Different linestring 'intersects' => Query::intersects('lineAttr', [1.0, 2.0]), // Point on the line should intersect - 'notIntersects' => Query::notIntersects('lineAttr', [5.0, 6.0]) // Point not on the line should not intersect + 'notIntersects' => Query::notIntersects('lineAttr', [5.0, 6.0]), // Point not on the line should not intersect ]; foreach ($lineQueries as $queryType => $query) { - if (!$database->getAdapter()->getSupportForBoundaryInclusiveContains() && in_array($queryType, ['contains','notContains'])) { + if (! $this->spatialIncludesBoundaries() && in_array($queryType, ['contains', 'notContains'])) { continue; } - $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $result = $database->find($collectionName, [$query], PermissionType::Read); $this->assertNotEmpty($result, sprintf('Failed spatial query: %s on polyAttr', $queryType)); $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document returned for %s on polyAttr', $queryType)); } @@ -187,19 +165,19 @@ public function testSpatialTypeDocuments(): void 'distanceEqual' => Query::distanceEqual('lineAttr', [[1.0, 2.0], [3.0, 4.0]], 0.0), 'distanceNotEqual' => Query::distanceNotEqual('lineAttr', [[5.0, 6.0], [7.0, 8.0]], 0.0), 'distanceLessThan' => Query::distanceLessThan('lineAttr', [[1.0, 2.0], [3.0, 4.0]], 0.1), - 'distanceGreaterThan' => Query::distanceGreaterThan('lineAttr', [[5.0, 6.0], [7.0, 8.0]], 0.1) + 'distanceGreaterThan' => Query::distanceGreaterThan('lineAttr', [[5.0, 6.0], [7.0, 8.0]], 0.1), ]; foreach ($lineDistanceQueries as $queryType => $query) { - $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $result = $database->find($collectionName, [$query], PermissionType::Read); $this->assertNotEmpty($result, sprintf('Failed distance query: %s on lineAttr', $queryType)); $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document for distance %s on lineAttr', $queryType)); } // Polygon attribute tests - use operations valid for polygons $polyQueries = [ - 'contains' => Query::contains('polyAttr', [[5.0, 5.0]]), // Point inside polygon - 'notContains' => Query::notContains('polyAttr', [[15.0, 15.0]]), // Point outside polygon + 'contains' => Query::covers('polyAttr', [[5.0, 5.0]]), // Point inside polygon + 'notContains' => Query::notCovers('polyAttr', [[15.0, 15.0]]), // Point outside polygon 'intersects' => Query::intersects('polyAttr', [0.0, 0.0]), // Point inside polygon should intersect 'notIntersects' => Query::notIntersects('polyAttr', [15.0, 15.0]), // Point outside polygon should not intersect 'equals' => query::equal('polyAttr', [[ @@ -208,19 +186,19 @@ public function testSpatialTypeDocuments(): void [0.0, 10.0], [10.0, 10.0], [10.0, 0.0], - [0.0, 0.0] - ] + [0.0, 0.0], + ], ]]), // Exact same polygon 'notEquals' => query::notEqual('polyAttr', [[[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [20.0, 20.0]]]]), // Different polygon 'overlaps' => Query::overlaps('polyAttr', [[[5.0, 5.0], [5.0, 15.0], [15.0, 15.0], [15.0, 5.0], [5.0, 5.0]]]), // Overlapping polygon - 'notOverlaps' => Query::notOverlaps('polyAttr', [[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [30.0, 20.0], [20.0, 20.0]]]) // Non-overlapping polygon + 'notOverlaps' => Query::notOverlaps('polyAttr', [[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [30.0, 20.0], [20.0, 20.0]]]), // Non-overlapping polygon ]; foreach ($polyQueries as $queryType => $query) { - if (!$database->getAdapter()->getSupportForBoundaryInclusiveContains() && in_array($queryType, ['contains','notContains'])) { + if (! $this->spatialIncludesBoundaries() && in_array($queryType, ['contains', 'notContains'])) { continue; } - $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $result = $database->find($collectionName, [$query], PermissionType::Read); $this->assertNotEmpty($result, sprintf('Failed spatial query: %s on polyAttr', $queryType)); $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document returned for %s on polyAttr', $queryType)); } @@ -230,11 +208,11 @@ public function testSpatialTypeDocuments(): void 'distanceEqual' => Query::distanceEqual('polyAttr', [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]], 0.0), 'distanceNotEqual' => Query::distanceNotEqual('polyAttr', [[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [20.0, 20.0]]], 0.0), 'distanceLessThan' => Query::distanceLessThan('polyAttr', [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]], 0.1), - 'distanceGreaterThan' => Query::distanceGreaterThan('polyAttr', [[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [20.0, 20.0]]], 0.1) + 'distanceGreaterThan' => Query::distanceGreaterThan('polyAttr', [[[20.0, 20.0], [20.0, 30.0], [30.0, 30.0], [20.0, 20.0]]], 0.1), ]; foreach ($polyDistanceQueries as $queryType => $query) { - $result = $database->find($collectionName, [$query], Database::PERMISSION_READ); + $result = $database->find($collectionName, [$query], PermissionType::Read); $this->assertNotEmpty($result, sprintf('Failed distance query: %s on polyAttr', $queryType)); $this->assertEquals('doc1', $result[0]->getId(), sprintf('Incorrect document for distance %s on polyAttr', $queryType)); } @@ -248,21 +226,22 @@ public function testSpatialRelationshipOneToOne(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('location'); - $database->createCollection('building'); + $database->createCollection(Collection::create(id: 'location')); + $database->createCollection(Collection::create(id: 'building')); - $database->createAttribute('location', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('location', 'coordinates', Database::VAR_POINT, 0, true); - $database->createAttribute('building', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('building', 'area', Database::VAR_STRING, 255, true); + $database->createAttribute('location', Attribute::string(key: 'name', required: true)); + $database->createAttribute('location', Attribute::point(key: 'coordinates', required: true)); + $database->createAttribute('building', Attribute::string(key: 'name', required: true)); + $database->createAttribute('building', Attribute::string(key: 'area', required: true)); // Create spatial indexes - $database->createIndex('location', 'coordinates_spatial', Database::INDEX_SPATIAL, ['coordinates']); + $database->createIndex('location', Index::spatial(key: 'coordinates_spatial', attribute: 'coordinates')); // Create building document first $building1 = $database->createDocument('building', new Document([ @@ -276,13 +255,7 @@ public function testSpatialRelationshipOneToOne(): void 'area' => 'Manhattan', ])); - $database->createRelationship( - collection: 'location', - relatedCollection: 'building', - type: Database::RELATION_ONE_TO_ONE, - id: 'building', - twoWay: false - ); + $database->createRelationship('location', Relationship::oneToOne(relatedCollection: 'building', key: 'building')); // Create location with spatial data and relationship $location1 = $database->createDocument('location', new Document([ @@ -297,7 +270,6 @@ public function testSpatialRelationshipOneToOne(): void 'building' => 'building1', ])); - $this->assertInstanceOf(Document::class, $location1); $this->assertEquals([40.7128, -74.0060], $location1->getAttribute('coordinates')); // Check if building attribute is populated (could be ID string or Document object) @@ -311,8 +283,8 @@ public function testSpatialRelationshipOneToOne(): void // Test spatial queries on related documents $nearbyLocations = $database->find('location', [ - Query::distanceLessThan('coordinates', [40.7128, -74.0060], 0.1) - ], Database::PERMISSION_READ); + Query::distanceLessThan('coordinates', [40.7128, -74.0060], 0.1), + ], PermissionType::Read); $this->assertNotEmpty($nearbyLocations); $this->assertEquals('location1', $nearbyLocations[0]->getId()); @@ -325,22 +297,20 @@ public function testSpatialRelationshipOneToOne(): void // Test spatial query after update $timesSquareLocations = $database->find('location', [ - Query::distanceLessThan('coordinates', [40.7589, -73.9851], 0.1) - ], Database::PERMISSION_READ); + Query::distanceLessThan('coordinates', [40.7589, -73.9851], 0.1), + ], PermissionType::Read); $this->assertNotEmpty($timesSquareLocations); $this->assertEquals('location1', $timesSquareLocations[0]->getId()); // Test relationship integrity with spatial data $building = $database->getDocument('building', 'building1'); - $this->assertInstanceOf(Document::class, $building); $this->assertEquals('building1', $building->getId()); // Test one-way relationship (building doesn't have location attribute) $this->assertArrayNotHasKey('location', $building->getArrayCopy()); // Test basic relationship integrity - $this->assertInstanceOf(Document::class, $building); $this->assertEquals('Empire State Building', $building->getAttribute('name')); // Clean up @@ -348,49 +318,83 @@ public function testSpatialRelationshipOneToOne(): void $database->deleteCollection('building'); } + public function testSpatialRequiredDropAcceptsNull(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class)) || ! $database->getAdapter()->supports(Capability::IndexSpatialNull)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionName = 'spatial_required_drop_'; + try { + $database->createCollection(Collection::create(id: $collectionName)); + $database->createAttribute($collectionName, Attribute::string(key: 'name', required: true)); + $database->createAttribute($collectionName, Attribute::point(key: 'location', required: true)); + + $updated = $database->updateAttribute($collectionName, 'location', new AttributeUpdate(required: false)); + $this->assertFalse($updated->required, 'the stored definition should no longer be required'); + + // The stored definition flipping is not enough: the column keeps + // whatever null constraint it was created with until the adapter + // alters it, and only a write proves that happened. + $document = $database->createDocument($collectionName, new Document([ + '$id' => Id::unique(), + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Test Location', + 'location' => null, + ])); + + $this->assertFalse($document->isEmpty()); + $this->assertNull($document->getAttribute('location')); + } finally { + $database->deleteCollection($collectionName); + } + } + public function testSpatialAttributes(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_attrs_'; try { - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); - $required = $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true; - $this->assertEquals(true, $database->createAttribute($collectionName, 'pointAttr', Database::VAR_POINT, 0, $required)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'lineAttr', Database::VAR_LINESTRING, 0, $required)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'polyAttr', Database::VAR_POLYGON, 0, $required)); + $required = $database->getAdapter()->supports(Capability::IndexSpatialNull) ? false : true; + $database->createAttribute($collectionName, Attribute::point(key: 'pointAttr', required: $required)); + $database->createAttribute($collectionName, Attribute::lineString(key: 'lineAttr', required: $required)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'polyAttr', required: $required)); // Create spatial indexes - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_point', Database::INDEX_SPATIAL, ['pointAttr'])); - if ($database->getAdapter()->getSupportForSpatialIndexNull()) { - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + $database->createIndex($collectionName, Index::spatial(key: 'idx_point', attribute: 'pointAttr')); + if ($database->getAdapter()->supports(Capability::IndexSpatialNull)) { + $database->createIndex($collectionName, Index::spatial(key: 'idx_line', attribute: 'lineAttr')); } else { // Attribute was created as required above; directly create index once - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + $database->createIndex($collectionName, Index::spatial(key: 'idx_line', attribute: 'lineAttr')); } - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_poly', Database::INDEX_SPATIAL, ['polyAttr'])); + $database->createIndex($collectionName, Index::spatial(key: 'idx_poly', attribute: 'polyAttr')); $collection = $database->getCollection($collectionName); - $this->assertIsArray($collection->getAttribute('attributes')); - $this->assertCount(3, $collection->getAttribute('attributes')); - $this->assertIsArray($collection->getAttribute('indexes')); - $this->assertCount(3, $collection->getAttribute('indexes')); + $this->assertCount(3, $collection->attributes()); + $this->assertCount(3, $collection->indexes()); // Create a simple document to ensure structure is valid $doc = $database->createDocument($collectionName, new Document([ - '$id' => ID::custom('sdoc'), + '$id' => Id::custom('sdoc'), 'pointAttr' => [1.0, 1.0], 'lineAttr' => [[0.0, 0.0], [1.0, 1.0]], 'polyAttr' => [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], - '$permissions' => [Permission::read(Role::any())] + '$permissions' => [Permission::read(Role::any())], ])); - $this->assertInstanceOf(Document::class, $doc); } finally { $database->deleteCollection($collectionName); } @@ -400,108 +404,102 @@ public function testSpatialOneToMany(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $parent = 'regions_'; $child = 'places_'; try { - $database->createCollection($parent); - $database->createCollection($child); + $database->createCollection(Collection::create(id: $parent)); + $database->createCollection(Collection::create(id: $child)); - $database->createAttribute($parent, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($child, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($child, 'coord', Database::VAR_POINT, 0, true); - $database->createIndex($child, 'coord_spatial', Database::INDEX_SPATIAL, ['coord']); + $database->createAttribute($parent, Attribute::string(key: 'name', required: true)); + $database->createAttribute($child, Attribute::string(key: 'name', required: true)); + $database->createAttribute($child, Attribute::point(key: 'coord', required: true)); + $database->createIndex($child, Index::spatial(key: 'coord_spatial', attribute: 'coord')); - $database->createRelationship( - collection: $parent, + $database->createRelationship($parent, Relationship::oneToMany( relatedCollection: $child, - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'places', + key: 'places', twoWayKey: 'region' - ); + )); - $r1 = $database->createDocument($parent, new Document([ + $database->createDocument($parent, new Document([ '$id' => 'r1', 'name' => 'Region 1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $this->assertInstanceOf(Document::class, $r1); - - $p1 = $database->createDocument($child, new Document([ + $database->createDocument($child, new Document([ '$id' => 'p1', 'name' => 'Place 1', 'coord' => [10.0, 10.0], 'region' => 'r1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $p2 = $database->createDocument($child, new Document([ + $database->createDocument($child, new Document([ '$id' => 'p2', 'name' => 'Place 2', 'coord' => [10.1, 10.1], 'region' => 'r1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $this->assertInstanceOf(Document::class, $p1); - $this->assertInstanceOf(Document::class, $p2); - // Spatial query on child collection $near = $database->find($child, [ - Query::distanceLessThan('coord', [10.0, 10.0], 1.0) - ], Database::PERMISSION_READ); + Query::distanceLessThan('coord', [10.0, 10.0], 1.0), + ], PermissionType::Read); $this->assertNotEmpty($near); // Test distanceGreaterThan: places far from center (should find p2 which is 0.141 units away) $far = $database->find($child, [ - Query::distanceGreaterThan('coord', [10.0, 10.0], 0.05) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('coord', [10.0, 10.0], 0.05), + ], PermissionType::Read); $this->assertNotEmpty($far); // Test distanceLessThan: places very close to center (should find p1 which is exactly at center) $close = $database->find($child, [ - Query::distanceLessThan('coord', [10.0, 10.0], 0.2) - ], Database::PERMISSION_READ); + Query::distanceLessThan('coord', [10.0, 10.0], 0.2), + ], PermissionType::Read); $this->assertNotEmpty($close); // Test distanceGreaterThan with various thresholds // Test: places more than 0.12 units from center (should find p2) $moderatelyFar = $database->find($child, [ - Query::distanceGreaterThan('coord', [10.0, 10.0], 0.12) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('coord', [10.0, 10.0], 0.12), + ], PermissionType::Read); $this->assertNotEmpty($moderatelyFar); // Test: places more than 0.05 units from center (should find p2) $slightlyFar = $database->find($child, [ - Query::distanceGreaterThan('coord', [10.0, 10.0], 0.05) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('coord', [10.0, 10.0], 0.05), + ], PermissionType::Read); $this->assertNotEmpty($slightlyFar); // Test: places more than 10 units from center (should find none) $extremelyFar = $database->find($child, [ - Query::distanceGreaterThan('coord', [10.0, 10.0], 10.0) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('coord', [10.0, 10.0], 10.0), + ], PermissionType::Read); $this->assertEmpty($extremelyFar); // Equal-distanceEqual semantics: distanceEqual (<=) and distanceNotEqual (>), threshold exactly at 0 $equalZero = $database->find($child, [ - Query::distanceEqual('coord', [10.0, 10.0], 0.0) - ], Database::PERMISSION_READ); + Query::distanceEqual('coord', [10.0, 10.0], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($equalZero); $this->assertEquals('p1', $equalZero[0]->getId()); $notEqualZero = $database->find($child, [ - Query::distanceNotEqual('coord', [10.0, 10.0], 0.0) - ], Database::PERMISSION_READ); + Query::distanceNotEqual('coord', [10.0, 10.0], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($notEqualZero); $this->assertEquals('p2', $notEqualZero[0]->getId()); $region = $database->getDocument($parent, 'r1'); $this->assertArrayHasKey('places', $region); - $this->assertEquals(2, \count($region['places'])); + $this->assertCount(2, $region->getDocuments('places')); } finally { $database->deleteCollection($child); $database->deleteCollection($parent); @@ -512,101 +510,96 @@ public function testSpatialManyToOne(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $parent = 'cities_'; $child = 'stops_'; try { - $database->createCollection($parent); - $database->createCollection($child); + $database->createCollection(Collection::create(id: $parent)); + $database->createCollection(Collection::create(id: $child)); - $database->createAttribute($parent, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($child, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($child, 'coord', Database::VAR_POINT, 0, true); - $database->createIndex($child, 'coord_spatial', Database::INDEX_SPATIAL, ['coord']); + $database->createAttribute($parent, Attribute::string(key: 'name', required: true)); + $database->createAttribute($child, Attribute::string(key: 'name', required: true)); + $database->createAttribute($child, Attribute::point(key: 'coord', required: true)); + $database->createIndex($child, Index::spatial(key: 'coord_spatial', attribute: 'coord')); - $database->createRelationship( - collection: $child, + $database->createRelationship($child, Relationship::manyToOne( relatedCollection: $parent, - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'city', + key: 'city', twoWayKey: 'stops' - ); + )); - $c1 = $database->createDocument($parent, new Document([ + $database->createDocument($parent, new Document([ '$id' => 'c1', 'name' => 'City 1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $s1 = $database->createDocument($child, new Document([ + $database->createDocument($child, new Document([ '$id' => 's1', 'name' => 'Stop 1', 'coord' => [20.0, 20.0], 'city' => 'c1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $s2 = $database->createDocument($child, new Document([ + $database->createDocument($child, new Document([ '$id' => 's2', 'name' => 'Stop 2', 'coord' => [20.2, 20.2], 'city' => 'c1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $this->assertInstanceOf(Document::class, $c1); - $this->assertInstanceOf(Document::class, $s1); - $this->assertInstanceOf(Document::class, $s2); - $near = $database->find($child, [ - Query::distanceLessThan('coord', [20.0, 20.0], 1.0) - ], Database::PERMISSION_READ); + Query::distanceLessThan('coord', [20.0, 20.0], 1.0), + ], PermissionType::Read); $this->assertNotEmpty($near); // Test distanceLessThan: stops very close to center (should find s1 which is exactly at center) $close = $database->find($child, [ - Query::distanceLessThan('coord', [20.0, 20.0], 0.1) - ], Database::PERMISSION_READ); + Query::distanceLessThan('coord', [20.0, 20.0], 0.1), + ], PermissionType::Read); $this->assertNotEmpty($close); // Test distanceGreaterThan with various thresholds // Test: stops more than 0.25 units from center (should find s2) $moderatelyFar = $database->find($child, [ - Query::distanceGreaterThan('coord', [20.0, 20.0], 0.25) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('coord', [20.0, 20.0], 0.25), + ], PermissionType::Read); $this->assertNotEmpty($moderatelyFar); // Test: stops more than 0.05 units from center (should find s2) $slightlyFar = $database->find($child, [ - Query::distanceGreaterThan('coord', [20.0, 20.0], 0.05) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('coord', [20.0, 20.0], 0.05), + ], PermissionType::Read); $this->assertNotEmpty($slightlyFar); // Test: stops more than 5 units from center (should find none) $veryFar = $database->find($child, [ - Query::distanceGreaterThan('coord', [20.0, 20.0], 5.0) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('coord', [20.0, 20.0], 5.0), + ], PermissionType::Read); $this->assertEmpty($veryFar); // Equal-distanceEqual semantics: distanceEqual (<=) and distanceNotEqual (>), threshold exactly at 0 $equalZero = $database->find($child, [ - Query::distanceEqual('coord', [20.0, 20.0], 0.0) - ], Database::PERMISSION_READ); + Query::distanceEqual('coord', [20.0, 20.0], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($equalZero); $this->assertEquals('s1', $equalZero[0]->getId()); $notEqualZero = $database->find($child, [ - Query::distanceNotEqual('coord', [20.0, 20.0], 0.0) - ], Database::PERMISSION_READ); + Query::distanceNotEqual('coord', [20.0, 20.0], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($notEqualZero); $this->assertEquals('s2', $notEqualZero[0]->getId()); $city = $database->getDocument($parent, 'c1'); $this->assertArrayHasKey('stops', $city); - $this->assertEquals(2, \count($city['stops'])); + $this->assertCount(2, $city->getDocuments('stops')); } finally { $database->deleteCollection($child); $database->deleteCollection($parent); @@ -617,34 +610,33 @@ public function testSpatialManyToMany(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $a = 'drivers_'; $b = 'routes_'; try { - $database->createCollection($a); - $database->createCollection($b); - - $database->createAttribute($a, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($a, 'home', Database::VAR_POINT, 0, true); - $database->createIndex($a, 'home_spatial', Database::INDEX_SPATIAL, ['home']); - $database->createAttribute($b, 'title', Database::VAR_STRING, 255, true); - $database->createAttribute($b, 'area', Database::VAR_POLYGON, 0, true); - $database->createIndex($b, 'area_spatial', Database::INDEX_SPATIAL, ['area']); - - $database->createRelationship( - collection: $a, + $database->createCollection(Collection::create(id: $a)); + $database->createCollection(Collection::create(id: $b)); + + $database->createAttribute($a, Attribute::string(key: 'name', required: true)); + $database->createAttribute($a, Attribute::point(key: 'home', required: true)); + $database->createIndex($a, Index::spatial(key: 'home_spatial', attribute: 'home')); + $database->createAttribute($b, Attribute::string(key: 'title', required: true)); + $database->createAttribute($b, Attribute::polygon(key: 'area', required: true)); + $database->createIndex($b, Index::spatial(key: 'area_spatial', attribute: 'area')); + + $database->createRelationship($a, Relationship::manyToMany( relatedCollection: $b, - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'routes', + key: 'routes', twoWayKey: 'drivers' - ); + )); - $d1 = $database->createDocument($a, new Document([ + $database->createDocument($a, new Document([ '$id' => 'd1', 'name' => 'Driver 1', 'home' => [30.0, 30.0], @@ -652,131 +644,230 @@ public function testSpatialManyToMany(): void [ '$id' => 'rte1', 'title' => 'Route 1', - 'area' => [[[29.5,29.5],[29.5,30.5],[30.5,30.5],[29.5,29.5]]] - ] + 'area' => [[[29.5, 29.5], [29.5, 30.5], [30.5, 30.5], [29.5, 29.5]]], + ], ], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $this->assertInstanceOf(Document::class, $d1); - // Spatial query on "drivers" using point distanceEqual $near = $database->find($a, [ - Query::distanceLessThan('home', [30.0, 30.0], 0.5) - ], Database::PERMISSION_READ); + Query::distanceLessThan('home', [30.0, 30.0], 0.5), + ], PermissionType::Read); $this->assertNotEmpty($near); // Test distanceGreaterThan: drivers far from center (using large threshold to find the driver) $far = $database->find($a, [ - Query::distanceGreaterThan('home', [30.0, 30.0], 100.0) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('home', [30.0, 30.0], 100.0), + ], PermissionType::Read); $this->assertEmpty($far); // Test distanceLessThan: drivers very close to center (should find d1 which is exactly at center) $close = $database->find($a, [ - Query::distanceLessThan('home', [30.0, 30.0], 0.1) - ], Database::PERMISSION_READ); + Query::distanceLessThan('home', [30.0, 30.0], 0.1), + ], PermissionType::Read); $this->assertNotEmpty($close); // Test distanceGreaterThan with various thresholds // Test: drivers more than 0.05 units from center (should find none since d1 is exactly at center) $slightlyFar = $database->find($a, [ - Query::distanceGreaterThan('home', [30.0, 30.0], 0.05) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('home', [30.0, 30.0], 0.05), + ], PermissionType::Read); $this->assertEmpty($slightlyFar); // Test: drivers more than 0.001 units from center (should find none since d1 is exactly at center) $verySlightlyFar = $database->find($a, [ - Query::distanceGreaterThan('home', [30.0, 30.0], 0.001) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('home', [30.0, 30.0], 0.001), + ], PermissionType::Read); $this->assertEmpty($verySlightlyFar); // Test: drivers more than 0.5 units from center (should find none since d1 is at center) $moderatelyFar = $database->find($a, [ - Query::distanceGreaterThan('home', [30.0, 30.0], 0.5) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('home', [30.0, 30.0], 0.5), + ], PermissionType::Read); $this->assertEmpty($moderatelyFar); // Equal-distanceEqual semantics: distanceEqual (<=) and distanceNotEqual (>), threshold exactly at 0 $equalZero = $database->find($a, [ - Query::distanceEqual('home', [30.0, 30.0], 0.0) - ], Database::PERMISSION_READ); + Query::distanceEqual('home', [30.0, 30.0], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($equalZero); $this->assertEquals('d1', $equalZero[0]->getId()); $notEqualZero = $database->find($a, [ - Query::distanceNotEqual('home', [30.0, 30.0], 0.0) - ], Database::PERMISSION_READ); + Query::distanceNotEqual('home', [30.0, 30.0], 0.0), + ], PermissionType::Read); $this->assertEmpty($notEqualZero); // Ensure relationship present $d1 = $database->getDocument($a, 'd1'); $this->assertArrayHasKey('routes', $d1); - $this->assertEquals(1, \count($d1['routes'])); + $this->assertCount(1, $d1->getDocuments('routes')); } finally { $database->deleteCollection($b); $database->deleteCollection($a); } } + public function testDistanceLessThanMatchesTheExactDistance(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->hasFeature(Feature\Spatial::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'distanceWithin'; + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::point(key: 'loc', required: true), + ], indexes: [ + Index::spatial(key: 'loc_spatial', attribute: 'loc'), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + try { + $centers = [[10.0, 60.0], [-120.0, -35.0], [179.95, 0.0], [0.0, 89.95]]; + $documents = []; + foreach ($centers as $position => $center) { + for ($bearing = 0; $bearing < 360; $bearing += 15) { + foreach ([0.5, 0.9, 0.99, 1.01, 1.1, 2.0] as $scale) { + $radians = \deg2rad($bearing); + $latitude = \max(-89.999, \min(89.999, $center[1] + $scale * 0.01 * \cos($radians))); + $longitude = $center[0] + $scale * 0.01 * \sin($radians) / \cos(\deg2rad($center[1])); + $longitude = $longitude > 180 ? $longitude - 360 : $longitude; + $documents[] = new Document([ + '$id' => 'p'.$position.'_'.$bearing.'_'.\str_replace('.', '_', (string) $scale), + 'loc' => [$longitude, $latitude], + ]); + } + } + } + $database->createDocuments($collection, $documents); + + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + + foreach ($centers as $center) { + foreach ([[0.01, false], [1111.95, true]] as [$distance, $meters]) { + $profiler->reset(); + $within = $this->distanceIds($database, $collection, Query::distanceLessThan('loc', $center, $distance, $meters)); + $logs = $profiler->getLogs(); + + $beyond = $this->distanceIds($database, $collection, Query::distanceGreaterThan('loc', $center, $distance, $meters)); + $equal = $this->distanceIds($database, $collection, Query::distanceEqual('loc', $center, $distance, $meters)); + + $label = 'distanceLessThan('.\json_encode($center).', '.$distance.($meters ? ' m' : '').')'; + $this->assertNotEmpty($within, $label.' must match the points well inside the range'); + $this->assertSame([], \array_values(\array_intersect($within, $beyond)), $label.' must match no point beyond the range'); + $this->assertCount(\count($documents), \array_unique([...$within, ...$beyond, ...$equal]), $label.' must match every point that is neither beyond nor at the range'); + + if ($adapter instanceof Postgres && $center[0] === 10.0) { + $this->assertUsesIndex($adapter, '"'.$database->getDatabase().'"."'.$database->getNamespace().'_'.$collection.'"', $logs, 'loc_spatial', $label); + } + } + } + } finally { + $database->setProfiling(false); + $database->deleteCollection($collection); + } + } + + /** + * @return list + */ + private function distanceIds(Database $database, string $collection, Query $query): array + { + $ids = \array_map(static fn (Document $document): string => $document->getId(), $database->find($collection, [$query, Query::limit(1000)])); + \sort($ids); + + return $ids; + } + + /** + * @param list<\Utopia\Database\Profiler\Log> $logs + */ + private function assertUsesIndex(Postgres $adapter, string $table, array $logs, string $index, string $label): void + { + $plans = []; + $adapter->rawQuery('ANALYZE '.$table); + $adapter->rawQuery('SET enable_seqscan = off'); + try { + foreach ($logs as $log) { + if (! \str_contains($log->query, 'ST_Distance')) { + continue; + } + + $rows = $adapter->rawQuery('EXPLAIN '.$log->query, $log->bindings); + $plans[] = \implode("\n", \array_map(static function (Document $row): string { + $line = $row->getAttribute('QUERY PLAN'); + self::assertIsString($line); + + return $line; + }, $rows)); + } + } finally { + $adapter->rawQuery('RESET enable_seqscan'); + } + + $this->assertCount(1, $plans, $label.' must run one statement'); + $this->assertStringContainsString($index, $plans[0], $label.' must use the spatial index: '.$plans[0]); + } + public function testSpatialIndex(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } // Basic spatial index create/delete $collectionName = 'spatial_index_'; try { - $database->createCollection($collectionName); - $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true); - $this->assertEquals(true, $database->createIndex($collectionName, 'loc_spatial', Database::INDEX_SPATIAL, ['loc'])); + $database->createCollection(Collection::create(id: $collectionName)); + $database->createAttribute($collectionName, Attribute::point(key: 'loc', required: true)); + $database->createIndex($collectionName, Index::spatial(key: 'loc_spatial', attribute: 'loc')); $collection = $database->getCollection($collectionName); - $this->assertIsArray($collection->getAttribute('indexes')); - $this->assertCount(1, $collection->getAttribute('indexes')); - $this->assertEquals('loc_spatial', $collection->getAttribute('indexes')[0]['$id']); - $this->assertEquals(Database::INDEX_SPATIAL, $collection->getAttribute('indexes')[0]['type']); + $indexes = $collection->indexes(); + $this->assertCount(1, $indexes); + $this->assertSame('loc_spatial', $indexes[0]->key); + $this->assertSame(IndexType::Spatial, $indexes[0]->type); - $this->assertEquals(true, $database->deleteIndex($collectionName, 'loc_spatial')); + $database->deleteIndex($collectionName, 'loc_spatial'); $collection = $database->getCollection($collectionName); - $this->assertCount(0, $collection->getAttribute('indexes')); + $this->assertCount(0, $collection->indexes()); } finally { $database->deleteCollection($collectionName); } // Edge cases: Spatial Index Order support (createCollection and createIndex) - $orderSupported = $database->getAdapter()->getSupportForSpatialIndexOrder(); + $orderSupported = $database->getAdapter()->supports(Capability::IndexSpatialOrder); // createCollection with orders $collOrderCreate = 'spatial_idx_order_create'; try { - $attributes = [new Document([ - '$id' => ID::custom('loc'), - 'type' => Database::VAR_POINT, - 'size' => 0, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ])]; - $indexes = [new Document([ - '$id' => ID::custom('idx_loc'), - 'type' => Database::INDEX_SPATIAL, - 'attributes' => ['loc'], - 'lengths' => [], - 'orders' => $orderSupported ? [Database::ORDER_ASC] : ['ASC'], - ])]; + $attributes = [Attribute::point(key: 'loc', required: true)]; + $indexes = [Index::spatial( + key: 'idx_loc', + attribute: 'loc', + order: OrderDirection::Asc, + )]; if ($orderSupported) { - $database->createCollection($collOrderCreate, $attributes, $indexes); + $database->createCollection(Collection::create(id: $collOrderCreate, attributes: $attributes, indexes: $indexes)); $meta = $database->getCollection($collOrderCreate); - $this->assertEquals('idx_loc', $meta->getAttribute('indexes')[0]['$id']); + $createdIndexes = $meta->indexes(); + $this->assertCount(1, $createdIndexes); + $this->assertSame('idx_loc', $createdIndexes[0]->key); } else { try { - $database->createCollection($collOrderCreate, $attributes, $indexes); + $database->createCollection(Collection::create(id: $collOrderCreate, attributes: $attributes, indexes: $indexes)); $this->fail('Expected exception when orders are provided for spatial index on unsupported adapter'); } catch (\Throwable $e) { $this->assertStringContainsString('Spatial index', $e->getMessage()); @@ -789,15 +880,15 @@ public function testSpatialIndex(): void } // createIndex with orders - $collOrderIndex = 'spatial_idx_order_index_' . uniqid(); + $collOrderIndex = 'spatial_idx_order_index_'.uniqid(); try { - $database->createCollection($collOrderIndex); - $database->createAttribute($collOrderIndex, 'loc', Database::VAR_POINT, 0, true); + $database->createCollection(Collection::create(id: $collOrderIndex)); + $database->createAttribute($collOrderIndex, Attribute::point(key: 'loc', required: true)); if ($orderSupported) { - $this->assertTrue($database->createIndex($collOrderIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'], [], [Database::ORDER_DESC])); + $database->createIndex($collOrderIndex, Index::spatial(key: 'idx_loc', attribute: 'loc', order: OrderDirection::Desc)); } else { try { - $database->createIndex($collOrderIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'], [], ['DESC']); + $database->createIndex($collOrderIndex, Index::spatial(key: 'idx_loc', attribute: 'loc', order: OrderDirection::Desc)); $this->fail('Expected exception when orders are provided for spatial index on unsupported adapter'); } catch (\Throwable $e) { $this->assertStringContainsString('Spatial index', $e->getMessage()); @@ -808,38 +899,26 @@ public function testSpatialIndex(): void } // Edge cases: Spatial Index Nullability (createCollection and createIndex) - $nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull(); + $nullSupported = $database->getAdapter()->supports(Capability::IndexSpatialNull); // createCollection with required=false - $collNullCreate = 'spatial_idx_null_create_' . uniqid(); + $collNullCreate = 'spatial_idx_null_create_'.uniqid(); try { - $attributes = [new Document([ - '$id' => ID::custom('loc'), - 'type' => Database::VAR_POINT, - 'size' => 0, - 'required' => false, // edge case - 'signed' => true, - 'array' => false, - 'filters' => [], - ])]; - $indexes = [new Document([ - '$id' => ID::custom('idx_loc'), - 'type' => Database::INDEX_SPATIAL, - 'attributes' => ['loc'], - 'lengths' => [], - 'orders' => [], - ])]; + $attributes = [Attribute::point(key: 'loc')]; + $indexes = [Index::spatial(key: 'idx_loc', attribute: 'loc')]; if ($nullSupported) { - $database->createCollection($collNullCreate, $attributes, $indexes); + $database->createCollection(Collection::create(id: $collNullCreate, attributes: $attributes, indexes: $indexes)); $meta = $database->getCollection($collNullCreate); - $this->assertEquals('idx_loc', $meta->getAttribute('indexes')[0]['$id']); + $createdIndexes = $meta->indexes(); + $this->assertCount(1, $createdIndexes); + $this->assertSame('idx_loc', $createdIndexes[0]->key); } else { try { - $database->createCollection($collNullCreate, $attributes, $indexes); + $database->createCollection(Collection::create(id: $collNullCreate, attributes: $attributes, indexes: $indexes)); $this->fail('Expected exception when spatial index is created on NULL-able geometry attribute'); } catch (\Throwable $e) { - $this->assertTrue(true); // exception expected; exact message is adapter-specific + $this->assertNotSame('', $e->getMessage()); } } } finally { @@ -849,18 +928,18 @@ public function testSpatialIndex(): void } // createIndex with required=false - $collNullIndex = 'spatial_idx_null_index_' . uniqid(); + $collNullIndex = 'spatial_idx_null_index_'.uniqid(); try { - $database->createCollection($collNullIndex); - $database->createAttribute($collNullIndex, 'loc', Database::VAR_POINT, 0, false); + $database->createCollection(Collection::create(id: $collNullIndex)); + $database->createAttribute($collNullIndex, Attribute::point(key: 'loc')); if ($nullSupported) { - $this->assertTrue($database->createIndex($collNullIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + $database->createIndex($collNullIndex, Index::spatial(key: 'idx_loc', attribute: 'loc')); } else { try { - $database->createIndex($collNullIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $database->createIndex($collNullIndex, Index::spatial(key: 'idx_loc', attribute: 'loc')); $this->fail('Expected exception when spatial index is created on NULL-able geometry attribute'); } catch (\Throwable $e) { - $this->assertTrue(true); // exception expected; exact message is adapter-specific + $this->assertNotSame('', $e->getMessage()); // exception expected; exact message is adapter-specific } } } finally { @@ -869,47 +948,46 @@ public function testSpatialIndex(): void $collUpdateNull = 'spatial_idx_req'; try { - $database->createCollection($collUpdateNull); + $database->createCollection(Collection::create(id: $collUpdateNull)); - $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, false); - if (!$nullSupported) { + $database->createAttribute($collUpdateNull, Attribute::point(key: 'loc')); + if (! $nullSupported) { try { - $database->createIndex($collUpdateNull, 'idx_loc_required', Database::INDEX_SPATIAL, ['loc']); + $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc_required', attribute: 'loc')); $this->fail('Expected exception when creating spatial index on NULL-able attribute'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } } else { - $this->assertTrue($database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attribute: 'loc')); } - $database->updateAttribute($collUpdateNull, 'loc', required: true); + $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: true)); - $this->assertTrue($database->createIndex($collUpdateNull, 'idx_loc_req', Database::INDEX_SPATIAL, ['loc'])); + $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc_req', attribute: 'loc')); } finally { $database->deleteCollection($collUpdateNull); } - $collUpdateNull = 'spatial_idx_index_null_required_true'; try { - $database->createCollection($collUpdateNull); + $database->createCollection(Collection::create(id: $collUpdateNull)); - $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, false); - if (!$nullSupported) { + $database->createAttribute($collUpdateNull, Attribute::point(key: 'loc')); + if (! $nullSupported) { try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attribute: 'loc')); $this->fail('Expected exception when creating spatial index on NULL-able attribute'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } } else { - $this->assertTrue($database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attribute: 'loc')); } - $database->updateAttribute($collUpdateNull, 'loc', required: true); + $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: true)); - $this->assertTrue($database->createIndex($collUpdateNull, 'new index', Database::INDEX_SPATIAL, ['loc'])); + $database->createIndex($collUpdateNull, Index::spatial(key: 'new index', attribute: 'loc')); } finally { $database->deleteCollection($collUpdateNull); } @@ -919,30 +997,31 @@ public function testComplexGeometricShapes(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'complex_shapes_'; try { - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); // Create spatial attributes for different geometric shapes - $this->assertEquals(true, $database->createAttribute($collectionName, 'rectangle', Database::VAR_POLYGON, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'square', Database::VAR_POLYGON, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'triangle', Database::VAR_POLYGON, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'circle_center', Database::VAR_POINT, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'complex_polygon', Database::VAR_POLYGON, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'multi_linestring', Database::VAR_LINESTRING, 0, true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'rectangle', required: true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'square', required: true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'triangle', required: true)); + $database->createAttribute($collectionName, Attribute::point(key: 'circle_center', required: true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'complex_polygon', required: true)); + $database->createAttribute($collectionName, Attribute::lineString(key: 'multi_linestring', required: true)); // Create spatial indexes - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_rectangle', Database::INDEX_SPATIAL, ['rectangle'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_square', Database::INDEX_SPATIAL, ['square'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_triangle', Database::INDEX_SPATIAL, ['triangle'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_circle_center', Database::INDEX_SPATIAL, ['circle_center'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_complex_polygon', Database::INDEX_SPATIAL, ['complex_polygon'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_multi_linestring', Database::INDEX_SPATIAL, ['multi_linestring'])); + $database->createIndex($collectionName, Index::spatial(key: 'idx_rectangle', attribute: 'rectangle')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_square', attribute: 'square')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_triangle', attribute: 'triangle')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_circle_center', attribute: 'circle_center')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_complex_polygon', attribute: 'complex_polygon')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_multi_linestring', attribute: 'multi_linestring')); // Create documents with different geometric shapes $doc1 = new Document([ @@ -953,7 +1032,7 @@ public function testComplexGeometricShapes(): void 'circle_center' => [10, 5], // center of rectangle 'complex_polygon' => [[[0, 0], [0, 20], [20, 20], [20, 15], [15, 15], [15, 5], [20, 5], [20, 0], [0, 0]]], // L-shaped polygon 'multi_linestring' => [[0, 0], [10, 10], [20, 0], [0, 20], [20, 20]], // single linestring with multiple points - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ]); $doc2 = new Document([ @@ -964,45 +1043,42 @@ public function testComplexGeometricShapes(): void 'circle_center' => [40, 4], // center of second rectangle 'complex_polygon' => [[[30, 0], [30, 20], [50, 20], [50, 10], [40, 10], [40, 0], [30, 0]]], // T-shaped polygon 'multi_linestring' => [[30, 0], [40, 10], [50, 0], [30, 20], [50, 20]], // single linestring with multiple points - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ]); - $createdDoc1 = $database->createDocument($collectionName, $doc1); - $createdDoc2 = $database->createDocument($collectionName, $doc2); - - $this->assertInstanceOf(Document::class, $createdDoc1); - $this->assertInstanceOf(Document::class, $createdDoc2); + $database->createDocument($collectionName, $doc1); + $database->createDocument($collectionName, $doc2); // Test rectangle contains point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $insideRect1 = $database->find($collectionName, [ - Query::contains('rectangle', [[5, 5]]) // Point inside first rectangle - ], Database::PERMISSION_READ); + Query::covers('rectangle', [[5, 5]]), // Point inside first rectangle + ], PermissionType::Read); $this->assertNotEmpty($insideRect1); $this->assertEquals('rect1', $insideRect1[0]->getId()); } // Test rectangle doesn't contain point outside - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $outsideRect1 = $database->find($collectionName, [ - Query::notContains('rectangle', [[25, 25]]) // Point outside first rectangle - ], Database::PERMISSION_READ); + Query::notCovers('rectangle', [[25, 25]]), // Point outside first rectangle + ], PermissionType::Read); $this->assertNotEmpty($outsideRect1); } // Test failure case: rectangle should NOT contain distant point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $distantPoint = $database->find($collectionName, [ - Query::contains('rectangle', [[100, 100]]) // Point far outside rectangle - ], Database::PERMISSION_READ); + Query::covers('rectangle', [[100, 100]]), // Point far outside rectangle + ], PermissionType::Read); $this->assertEmpty($distantPoint); } // Test failure case: rectangle should NOT contain point outside - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $outsidePoint = $database->find($collectionName, [ - Query::contains('rectangle', [[-1, -1]]) // Point clearly outside rectangle - ], Database::PERMISSION_READ); + Query::covers('rectangle', [[-1, -1]]), // Point clearly outside rectangle + ], PermissionType::Read); $this->assertEmpty($outsidePoint); } @@ -1010,334 +1086,333 @@ public function testComplexGeometricShapes(): void $overlappingRect = $database->find($collectionName, [ Query::and([ Query::intersects('rectangle', [[15, 5], [15, 15], [25, 15], [25, 5], [15, 5]]), - Query::notTouches('rectangle', [[15, 5], [15, 15], [25, 15], [25, 5], [15, 5]]) + Query::notTouches('rectangle', [[15, 5], [15, 15], [25, 15], [25, 5], [15, 5]]), ]), - ], Database::PERMISSION_READ); + ], PermissionType::Read); $this->assertNotEmpty($overlappingRect); - // Test square contains point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $insideSquare1 = $database->find($collectionName, [ - Query::contains('square', [[10, 10]]) // Point inside first square - ], Database::PERMISSION_READ); + Query::covers('square', [[10, 10]]), // Point inside first square + ], PermissionType::Read); $this->assertNotEmpty($insideSquare1); $this->assertEquals('rect1', $insideSquare1[0]->getId()); } // Test rectangle contains square (shape contains shape) - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $rectContainsSquare = $database->find($collectionName, [ - Query::contains('rectangle', [[[5, 2], [5, 8], [15, 8], [15, 2], [5, 2]]]) // Square geometry that fits within rectangle - ], Database::PERMISSION_READ); + Query::covers('rectangle', [[[5, 2], [5, 8], [15, 8], [15, 2], [5, 2]]]), // Square geometry that fits within rectangle + ], PermissionType::Read); $this->assertNotEmpty($rectContainsSquare); $this->assertEquals('rect1', $rectContainsSquare[0]->getId()); } // Test rectangle contains triangle (shape contains shape) - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $rectContainsTriangle = $database->find($collectionName, [ - Query::contains('rectangle', [[[10, 2], [18, 2], [14, 8], [10, 2]]]) // Triangle geometry that fits within rectangle - ], Database::PERMISSION_READ); + Query::covers('rectangle', [[[10, 2], [18, 2], [14, 8], [10, 2]]]), // Triangle geometry that fits within rectangle + ], PermissionType::Read); $this->assertNotEmpty($rectContainsTriangle); $this->assertEquals('rect1', $rectContainsTriangle[0]->getId()); } // Test L-shaped polygon contains smaller rectangle (shape contains shape) - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $lShapeContainsRect = $database->find($collectionName, [ - Query::contains('complex_polygon', [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]]) // Small rectangle inside L-shape - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]]), // Small rectangle inside L-shape + ], PermissionType::Read); $this->assertNotEmpty($lShapeContainsRect); $this->assertEquals('rect1', $lShapeContainsRect[0]->getId()); } // Test T-shaped polygon contains smaller square (shape contains shape) - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $tShapeContainsSquare = $database->find($collectionName, [ - Query::contains('complex_polygon', [[[35, 5], [35, 10], [40, 10], [40, 5], [35, 5]]]) // Small square inside T-shape - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[[35, 5], [35, 10], [40, 10], [40, 5], [35, 5]]]), // Small square inside T-shape + ], PermissionType::Read); $this->assertNotEmpty($tShapeContainsSquare); $this->assertEquals('rect2', $tShapeContainsSquare[0]->getId()); } // Test failure case: square should NOT contain rectangle (smaller shape cannot contain larger shape) - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $squareNotContainsRect = $database->find($collectionName, [ - Query::notContains('square', [[[0, 0], [0, 20], [20, 20], [20, 0], [0, 0]]]) // Larger rectangle - ], Database::PERMISSION_READ); + Query::notCovers('square', [[[0, 0], [0, 20], [20, 20], [20, 0], [0, 0]]]), // Larger rectangle + ], PermissionType::Read); $this->assertNotEmpty($squareNotContainsRect); } // Test failure case: triangle should NOT contain rectangle - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $triangleNotContainsRect = $database->find($collectionName, [ - Query::notContains('triangle', [[[20, 0], [20, 25], [30, 25], [30, 0], [20, 0]]]) // Rectangle that extends beyond triangle - ], Database::PERMISSION_READ); + Query::notCovers('triangle', [[[20, 0], [20, 25], [30, 25], [30, 0], [20, 0]]]), // Rectangle that extends beyond triangle + ], PermissionType::Read); $this->assertNotEmpty($triangleNotContainsRect); } // Test failure case: L-shape should NOT contain T-shape (different complex polygons) - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $lShapeNotContainsTShape = $database->find($collectionName, [ - Query::notContains('complex_polygon', [[[30, 0], [30, 20], [50, 20], [50, 0], [30, 0]]]) // T-shape geometry - ], Database::PERMISSION_READ); + Query::notCovers('complex_polygon', [[[30, 0], [30, 20], [50, 20], [50, 0], [30, 0]]]), // T-shape geometry + ], PermissionType::Read); $this->assertNotEmpty($lShapeNotContainsTShape); } // Test square doesn't contain point outside - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $outsideSquare1 = $database->find($collectionName, [ - Query::notContains('square', [[20, 20]]) // Point outside first square - ], Database::PERMISSION_READ); + Query::notCovers('square', [[20, 20]]), // Point outside first square + ], PermissionType::Read); $this->assertNotEmpty($outsideSquare1); } // Test failure case: square should NOT contain distant point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $distantPointSquare = $database->find($collectionName, [ - Query::contains('square', [[100, 100]]) // Point far outside square - ], Database::PERMISSION_READ); + Query::covers('square', [[100, 100]]), // Point far outside square + ], PermissionType::Read); $this->assertEmpty($distantPointSquare); } // Test failure case: square should NOT contain point on boundary - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $boundaryPointSquare = $database->find($collectionName, [ - Query::contains('square', [[5, 5]]) // Point on square boundary (should be empty if boundary not inclusive) - ], Database::PERMISSION_READ); + Query::covers('square', [[5, 5]]), // Point on square boundary (should be empty if boundary not inclusive) + ], PermissionType::Read); // Note: This may or may not be empty depending on boundary inclusivity } // Test square equals same geometry using contains when supported, otherwise intersects - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $exactSquare = $database->find($collectionName, [ - Query::contains('square', [[[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]]) - ], Database::PERMISSION_READ); + Query::covers('square', [[[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]]), + ], PermissionType::Read); } else { $exactSquare = $database->find($collectionName, [ - Query::intersects('square', [[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]) - ], Database::PERMISSION_READ); + Query::intersects('square', [[5, 5], [5, 15], [15, 15], [15, 5], [5, 5]]), + ], PermissionType::Read); } $this->assertNotEmpty($exactSquare); $this->assertEquals('rect1', $exactSquare[0]->getId()); // Test square doesn't equal different square $differentSquare = $database->find($collectionName, [ - query::notEqual('square', [[[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]]]) // Different square - ], Database::PERMISSION_READ); + query::notEqual('square', [[[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]]]), // Different square + ], PermissionType::Read); $this->assertNotEmpty($differentSquare); // Test triangle contains point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $insideTriangle1 = $database->find($collectionName, [ - Query::contains('triangle', [[25, 10]]) // Point inside first triangle - ], Database::PERMISSION_READ); + Query::covers('triangle', [[25, 10]]), // Point inside first triangle + ], PermissionType::Read); $this->assertNotEmpty($insideTriangle1); $this->assertEquals('rect1', $insideTriangle1[0]->getId()); } // Test triangle doesn't contain point outside - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $outsideTriangle1 = $database->find($collectionName, [ - Query::notContains('triangle', [[25, 25]]) // Point outside first triangle - ], Database::PERMISSION_READ); + Query::notCovers('triangle', [[25, 25]]), // Point outside first triangle + ], PermissionType::Read); $this->assertNotEmpty($outsideTriangle1); } // Test failure case: triangle should NOT contain distant point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $distantPointTriangle = $database->find($collectionName, [ - Query::contains('triangle', [[100, 100]]) // Point far outside triangle - ], Database::PERMISSION_READ); + Query::covers('triangle', [[100, 100]]), // Point far outside triangle + ], PermissionType::Read); $this->assertEmpty($distantPointTriangle); } // Test failure case: triangle should NOT contain point outside its area - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $outsideTriangleArea = $database->find($collectionName, [ - Query::contains('triangle', [[35, 25]]) // Point outside triangle area - ], Database::PERMISSION_READ); + Query::covers('triangle', [[35, 25]]), // Point outside triangle area + ], PermissionType::Read); $this->assertEmpty($outsideTriangleArea); } // Test triangle intersects with point $intersectingTriangle = $database->find($collectionName, [ - Query::intersects('triangle', [25, 10]) // Point inside triangle should intersect - ], Database::PERMISSION_READ); + Query::intersects('triangle', [25, 10]), // Point inside triangle should intersect + ], PermissionType::Read); $this->assertNotEmpty($intersectingTriangle); // Test triangle doesn't intersect with distant point $nonIntersectingTriangle = $database->find($collectionName, [ - Query::notIntersects('triangle', [10, 10]) // Distant point should not intersect - ], Database::PERMISSION_READ); + Query::notIntersects('triangle', [10, 10]), // Distant point should not intersect + ], PermissionType::Read); $this->assertNotEmpty($nonIntersectingTriangle); // Test L-shaped polygon contains point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $insideLShape = $database->find($collectionName, [ - Query::contains('complex_polygon', [[10, 10]]) // Point inside L-shape - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[10, 10]]), // Point inside L-shape + ], PermissionType::Read); $this->assertNotEmpty($insideLShape); $this->assertEquals('rect1', $insideLShape[0]->getId()); } // Test L-shaped polygon doesn't contain point in "hole" - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $inHole = $database->find($collectionName, [ - Query::notContains('complex_polygon', [[17, 10]]) // Point in the "hole" of L-shape - ], Database::PERMISSION_READ); + Query::notCovers('complex_polygon', [[17, 10]]), // Point in the "hole" of L-shape + ], PermissionType::Read); $this->assertNotEmpty($inHole); } // Test failure case: L-shaped polygon should NOT contain distant point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $distantPointLShape = $database->find($collectionName, [ - Query::contains('complex_polygon', [[100, 100]]) // Point far outside L-shape - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[100, 100]]), // Point far outside L-shape + ], PermissionType::Read); $this->assertEmpty($distantPointLShape); } // Test failure case: L-shaped polygon should NOT contain point in the hole - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $holePoint = $database->find($collectionName, [ - Query::contains('complex_polygon', [[17, 10]]) // Point in the "hole" of L-shape - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[17, 10]]), // Point in the "hole" of L-shape + ], PermissionType::Read); $this->assertEmpty($holePoint); } // Test T-shaped polygon contains point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $insideTShape = $database->find($collectionName, [ - Query::contains('complex_polygon', [[40, 5]]) // Point inside T-shape - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[40, 5]]), // Point inside T-shape + ], PermissionType::Read); $this->assertNotEmpty($insideTShape); $this->assertEquals('rect2', $insideTShape[0]->getId()); } // Test failure case: T-shaped polygon should NOT contain distant point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $distantPointTShape = $database->find($collectionName, [ - Query::contains('complex_polygon', [[100, 100]]) // Point far outside T-shape - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[100, 100]]), // Point far outside T-shape + ], PermissionType::Read); $this->assertEmpty($distantPointTShape); } // Test failure case: T-shaped polygon should NOT contain point outside its area - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $outsideTShapeArea = $database->find($collectionName, [ - Query::contains('complex_polygon', [[25, 25]]) // Point outside T-shape area - ], Database::PERMISSION_READ); + Query::covers('complex_polygon', [[25, 25]]), // Point outside T-shape area + ], PermissionType::Read); $this->assertEmpty($outsideTShapeArea); } // Test complex polygon intersects with line $intersectingLine = $database->find($collectionName, [ - Query::intersects('complex_polygon', [[0, 10], [20, 10]]) // Horizontal line through L-shape - ], Database::PERMISSION_READ); + Query::intersects('complex_polygon', [[0, 10], [20, 10]]), // Horizontal line through L-shape + ], PermissionType::Read); $this->assertNotEmpty($intersectingLine); // Test linestring contains point - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $onLine1 = $database->find($collectionName, [ - Query::contains('multi_linestring', [[5, 5]]) // Point on first line segment - ], Database::PERMISSION_READ); + Query::covers('multi_linestring', [[5, 5]]), // Point on first line segment + ], PermissionType::Read); $this->assertNotEmpty($onLine1); } // Test linestring doesn't contain point off line - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $offLine1 = $database->find($collectionName, [ - Query::notContains('multi_linestring', [[5, 15]]) // Point not on any line - ], Database::PERMISSION_READ); + Query::notCovers('multi_linestring', [[5, 15]]), // Point not on any line + ], PermissionType::Read); $this->assertNotEmpty($offLine1); } // Test linestring intersects with point $intersectingPoint = $database->find($collectionName, [ - Query::intersects('multi_linestring', [10, 10]) // Point on diagonal line - ], Database::PERMISSION_READ); + Query::intersects('multi_linestring', [10, 10]), // Point on diagonal line + ], PermissionType::Read); $this->assertNotEmpty($intersectingPoint); // Test linestring intersects with a horizontal line coincident at y=20 $touchingLine = $database->find($collectionName, [ - Query::intersects('multi_linestring', [[0, 20], [20, 20]]) - ], Database::PERMISSION_READ); + Query::intersects('multi_linestring', [[0, 20], [20, 20]]), + ], PermissionType::Read); $this->assertNotEmpty($touchingLine); // Test distanceEqual queries between shapes $nearCenter = $database->find($collectionName, [ - Query::distanceLessThan('circle_center', [10, 5], 5.0) // Points within 5 units of first center - ], Database::PERMISSION_READ); + Query::distanceLessThan('circle_center', [10, 5], 5.0), // Points within 5 units of first center + ], PermissionType::Read); $this->assertNotEmpty($nearCenter); $this->assertEquals('rect1', $nearCenter[0]->getId()); // Test distanceEqual queries to find nearby shapes $nearbyShapes = $database->find($collectionName, [ - Query::distanceLessThan('circle_center', [40, 4], 15.0) // Points within 15 units of second center - ], Database::PERMISSION_READ); + Query::distanceLessThan('circle_center', [40, 4], 15.0), // Points within 15 units of second center + ], PermissionType::Read); $this->assertNotEmpty($nearbyShapes); $this->assertEquals('rect2', $nearbyShapes[0]->getId()); // Test distanceGreaterThan queries $farShapes = $database->find($collectionName, [ - Query::distanceGreaterThan('circle_center', [10, 5], 10.0) // Points more than 10 units from first center - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('circle_center', [10, 5], 10.0), // Points more than 10 units from first center + ], PermissionType::Read); $this->assertNotEmpty($farShapes); $this->assertEquals('rect2', $farShapes[0]->getId()); // Test distanceLessThan queries $closeShapes = $database->find($collectionName, [ - Query::distanceLessThan('circle_center', [10, 5], 3.0) // Points less than 3 units from first center - ], Database::PERMISSION_READ); + Query::distanceLessThan('circle_center', [10, 5], 3.0), // Points less than 3 units from first center + ], PermissionType::Read); $this->assertNotEmpty($closeShapes); $this->assertEquals('rect1', $closeShapes[0]->getId()); // Test distanceGreaterThan queries with various thresholds // Test: points more than 20 units from first center (should find rect2) $veryFarShapes = $database->find($collectionName, [ - Query::distanceGreaterThan('circle_center', [10, 5], 20.0) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('circle_center', [10, 5], 20.0), + ], PermissionType::Read); $this->assertNotEmpty($veryFarShapes); $this->assertEquals('rect2', $veryFarShapes[0]->getId()); // Test: points more than 5 units from second center (should find rect1) $farFromSecondCenter = $database->find($collectionName, [ - Query::distanceGreaterThan('circle_center', [40, 4], 5.0) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('circle_center', [40, 4], 5.0), + ], PermissionType::Read); $this->assertNotEmpty($farFromSecondCenter); $this->assertEquals('rect1', $farFromSecondCenter[0]->getId()); // Test: points more than 30 units from origin (should find only rect2) $farFromOrigin = $database->find($collectionName, [ - Query::distanceGreaterThan('circle_center', [0, 0], 30.0) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('circle_center', [0, 0], 30.0), + ], PermissionType::Read); $this->assertCount(1, $farFromOrigin); // Equal-distanceEqual semantics for circle_center // rect1 is exactly at [10,5], so distanceEqual 0 $equalZero = $database->find($collectionName, [ - Query::distanceEqual('circle_center', [10, 5], 0.0) - ], Database::PERMISSION_READ); + Query::distanceEqual('circle_center', [10, 5], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($equalZero); $this->assertEquals('rect1', $equalZero[0]->getId()); $notEqualZero = $database->find($collectionName, [ - Query::distanceNotEqual('circle_center', [10, 5], 0.0) - ], Database::PERMISSION_READ); + Query::distanceNotEqual('circle_center', [10, 5], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($notEqualZero); $this->assertEquals('rect2', $notEqualZero[0]->getId()); // Additional distance queries for complex shapes (polygon and linestring) $rectDistanceEqual = $database->find($collectionName, [ - Query::distanceEqual('rectangle', [[[0, 0], [0, 10], [20, 10], [20, 0], [0, 0]]], 0.0) - ], Database::PERMISSION_READ); + Query::distanceEqual('rectangle', [[[0, 0], [0, 10], [20, 10], [20, 0], [0, 0]]], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($rectDistanceEqual); $this->assertEquals('rect1', $rectDistanceEqual[0]->getId()); $lineDistanceEqual = $database->find($collectionName, [ - Query::distanceEqual('multi_linestring', [[0, 0], [10, 10], [20, 0], [0, 20], [20, 20]], 0.0) - ], Database::PERMISSION_READ); + Query::distanceEqual('multi_linestring', [[0, 0], [10, 10], [20, 0], [0, 20], [20, 20]], 0.0), + ], PermissionType::Read); $this->assertNotEmpty($lineDistanceEqual); $this->assertEquals('rect1', $lineDistanceEqual[0]->getId()); @@ -1350,25 +1425,26 @@ public function testSpatialQueryCombinations(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_combinations_'; try { - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); // Create spatial attributes - $this->assertEquals(true, $database->createAttribute($collectionName, 'location', Database::VAR_POINT, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'area', Database::VAR_POLYGON, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'route', Database::VAR_LINESTRING, 0, true)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 255, true)); + $database->createAttribute($collectionName, Attribute::point(key: 'location', required: true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'area', required: true)); + $database->createAttribute($collectionName, Attribute::lineString(key: 'route', required: true)); + $database->createAttribute($collectionName, Attribute::string(key: 'name', required: true)); // Create spatial indexes - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_location', Database::INDEX_SPATIAL, ['location'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_area', Database::INDEX_SPATIAL, ['area'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_route', Database::INDEX_SPATIAL, ['route'])); + $database->createIndex($collectionName, Index::spatial(key: 'idx_location', attribute: 'location')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_area', attribute: 'area')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_route', attribute: 'route')); // Create test documents $doc1 = new Document([ @@ -1377,7 +1453,7 @@ public function testSpatialQueryCombinations(): void 'location' => [40.7829, -73.9654], 'area' => [[[40.7649, -73.9814], [40.7649, -73.9494], [40.8009, -73.9494], [40.8009, -73.9814], [40.7649, -73.9814]]], 'route' => [[40.7649, -73.9814], [40.8009, -73.9494]], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ]); $doc2 = new Document([ @@ -1386,7 +1462,7 @@ public function testSpatialQueryCombinations(): void 'location' => [40.6602, -73.9690], 'area' => [[[40.6502, -73.9790], [40.6502, -73.9590], [40.6702, -73.9590], [40.6702, -73.9790], [40.6502, -73.9790]]], 'route' => [[40.6502, -73.9790], [40.6702, -73.9590]], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ]); $doc3 = new Document([ @@ -1395,7 +1471,7 @@ public function testSpatialQueryCombinations(): void 'location' => [40.6033, -74.0170], 'area' => [[[40.5933, -74.0270], [40.5933, -74.0070], [40.6133, -74.0070], [40.6133, -74.0270], [40.5933, -74.0270]]], 'route' => [[40.5933, -74.0270], [40.6133, -74.0070]], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ]); $database->createDocument($collectionName, $doc1); @@ -1404,13 +1480,13 @@ public function testSpatialQueryCombinations(): void // Test complex spatial queries with logical combinations // Test AND combination: parks within area AND near specific location - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $nearbyAndInArea = $database->find($collectionName, [ Query::and([ Query::distanceLessThan('location', [40.7829, -73.9654], 0.01), // Near Central Park - Query::contains('area', [[40.7829, -73.9654]]) // Location is within area - ]) - ], Database::PERMISSION_READ); + Query::covers('area', [[40.7829, -73.9654]]), // Location is within area + ]), + ], PermissionType::Read); $this->assertNotEmpty($nearbyAndInArea); $this->assertEquals('park1', $nearbyAndInArea[0]->getId()); } @@ -1419,47 +1495,47 @@ public function testSpatialQueryCombinations(): void $nearEitherLocation = $database->find($collectionName, [ Query::or([ Query::distanceLessThan('location', [40.7829, -73.9654], 0.01), // Near Central Park - Query::distanceLessThan('location', [40.6602, -73.9690], 0.01) // Near Prospect Park - ]) - ], Database::PERMISSION_READ); + Query::distanceLessThan('location', [40.6602, -73.9690], 0.01), // Near Prospect Park + ]), + ], PermissionType::Read); $this->assertCount(2, $nearEitherLocation); // Test distanceGreaterThan: parks far from Central Park $farFromCentral = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [40.7829, -73.9654], 0.1) // More than 0.1 degrees from Central Park - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('location', [40.7829, -73.9654], 0.1), // More than 0.1 degrees from Central Park + ], PermissionType::Read); $this->assertNotEmpty($farFromCentral); // Test distanceLessThan: parks very close to Central Park $veryCloseToCentral = $database->find($collectionName, [ - Query::distanceLessThan('location', [40.7829, -73.9654], 0.001) // Less than 0.001 degrees from Central Park - ], Database::PERMISSION_READ); + Query::distanceLessThan('location', [40.7829, -73.9654], 0.001), // Less than 0.001 degrees from Central Park + ], PermissionType::Read); $this->assertNotEmpty($veryCloseToCentral); // Test distanceGreaterThan with various thresholds // Test: parks more than 0.3 degrees from Central Park (should find none since all parks are closer) $veryFarFromCentral = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [40.7829, -73.9654], 0.3) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('location', [40.7829, -73.9654], 0.3), + ], PermissionType::Read); $this->assertCount(0, $veryFarFromCentral); // Test: parks more than 0.3 degrees from Prospect Park (should find other parks) $farFromProspect = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [40.6602, -73.9690], 0.1) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('location', [40.6602, -73.9690], 0.1), + ], PermissionType::Read); $this->assertNotEmpty($farFromProspect); // Test: parks more than 0.3 degrees from Times Square (should find none since all parks are closer) $farFromTimesSquare = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [40.7589, -73.9851], 0.3) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('location', [40.7589, -73.9851], 0.3), + ], PermissionType::Read); $this->assertCount(0, $farFromTimesSquare); // Test ordering by distanceEqual from a specific point $orderedByDistance = $database->find($collectionName, [ Query::distanceLessThan('location', [40.7829, -73.9654], 0.01), // Within ~1km - Query::limit(10) - ], Database::PERMISSION_READ); + Query::limit(10), + ], PermissionType::Read); $this->assertNotEmpty($orderedByDistance); // First result should be closest to the reference point @@ -1468,8 +1544,8 @@ public function testSpatialQueryCombinations(): void // Test spatial queries with limits $limitedResults = $database->find($collectionName, [ Query::distanceLessThan('location', [40.7829, -73.9654], 1.0), // Within 1 degree - Query::limit(2) - ], Database::PERMISSION_READ); + Query::limit(2), + ], PermissionType::Read); $this->assertCount(2, $limitedResults); } finally { @@ -1481,18 +1557,18 @@ public function testSpatialIsNullIsNotNull(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); return; } $collectionName = 'spatial_null_checks'; try { - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 255, true)); + $database->createAttribute($collectionName, Attribute::string(key: 'name', required: true)); // Optional spatial attribute: some documents legitimately have no location set. - $this->assertEquals(true, $database->createAttribute($collectionName, 'location', Database::VAR_POINT, 0, false)); + $database->createAttribute($collectionName, Attribute::point(key: 'location')); $database->createDocument($collectionName, new Document([ '$id' => 'withLocation', @@ -1510,13 +1586,13 @@ public function testSpatialIsNullIsNotNull(): void $withLocation = $database->find($collectionName, [ Query::isNotNull('location'), - ], Database::PERMISSION_READ); + ], PermissionType::Read); $this->assertCount(1, $withLocation); $this->assertEquals('withLocation', $withLocation[0]->getId()); $withoutLocation = $database->find($collectionName, [ Query::isNull('location'), - ], Database::PERMISSION_READ); + ], PermissionType::Read); $this->assertCount(1, $withoutLocation); $this->assertEquals('withoutLocation', $withoutLocation[0]->getId()); } finally { @@ -1528,8 +1604,9 @@ public function testSpatialBulkOperation(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -1537,43 +1614,16 @@ public function testSpatialBulkOperation(): void // Create collection with spatial attributes $attributes = [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => true, - 'signed' => true, - 'array' => false, - ]), - new Document([ - '$id' => ID::custom('location'), - 'type' => Database::VAR_POINT, - 'size' => 0, - 'required' => true, - 'signed' => true, - 'array' => false, - ]), - new Document([ - '$id' => ID::custom('area'), - 'type' => Database::VAR_POLYGON, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - ]) + Attribute::string(key: 'name', size: 256, required: true), + Attribute::point(key: 'location', required: true), + Attribute::polygon(key: 'area'), ]; $indexes = [ - new Document([ - '$id' => ID::custom('spatial_idx'), - 'type' => Database::INDEX_SPATIAL, - 'attributes' => ['location'], - 'lengths' => [], - 'orders' => [], - ]) + Index::spatial(key: 'spatial_idx', attribute: 'location'), ]; - $database->createCollection($collectionName, $attributes, $indexes); + $database->createCollection(Collection::create(id: $collectionName, attributes: $attributes, indexes: $indexes)); // Test 1: createDocuments with spatial data $spatialDocuments = []; @@ -1585,15 +1635,15 @@ public function testSpatialBulkOperation(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => 'Location ' . $i, + 'name' => 'Location '.$i, 'location' => [10.0 + $i, 20.0 + $i], // POINT 'area' => [ [10.0 + $i, 20.0 + $i], [11.0 + $i, 20.0 + $i], [11.0 + $i, 21.0 + $i], [10.0 + $i, 21.0 + $i], - [10.0 + $i, 20.0 + $i] - ] // POLYGON + [10.0 + $i, 20.0 + $i], + ], // POLYGON ]); } @@ -1610,10 +1660,12 @@ public function testSpatialBulkOperation(): void $this->assertNotEmpty($document->getId()); $this->assertNotEmpty($document->getAttribute('name')); $this->assertNotEmpty($document->getSequence()); - $this->assertIsArray($document->getAttribute('location')); - $this->assertIsArray($document->getAttribute('area')); - $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates - $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points + $location = $document->getArray('location'); + $area = $document->getArray('area'); + $this->assertCount(2, $location); + $ring = $area[0] ?? null; + $this->assertIsArray($ring); + $this->assertGreaterThan(1, count($ring)); } $results = $database->find($collectionName); @@ -1621,10 +1673,12 @@ public function testSpatialBulkOperation(): void $this->assertNotEmpty($document->getId()); $this->assertNotEmpty($document->getAttribute('name')); $this->assertNotEmpty($document->getSequence()); - $this->assertIsArray($document->getAttribute('location')); - $this->assertIsArray($document->getAttribute('area')); - $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates - $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points + $location = $document->getArray('location'); + $area = $document->getArray('area'); + $this->assertCount(2, $location); + $ring = $area[0] ?? null; + $this->assertIsArray($ring); + $this->assertGreaterThan(1, count($ring)); } foreach ($results as $doc) { @@ -1633,26 +1687,36 @@ public function testSpatialBulkOperation(): void $this->assertNotEmpty($document->getAttribute('name')); $this->assertEquals($document->getAttribute('name'), $doc->getAttribute('name')); $this->assertNotEmpty($document->getSequence()); - $this->assertIsArray($document->getAttribute('location')); - $this->assertIsArray($document->getAttribute('area')); - $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates - $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points + $location = $document->getArray('location'); + $area = $document->getArray('area'); + $this->assertCount(2, $location); + $ring = $area[0] ?? null; + $this->assertIsArray($ring); + $this->assertGreaterThan(1, count($ring)); } - $results = $database->find($collectionName, [Query::select(["name"])]); + $results = $database->find($collectionName, [Query::select(['name'])]); foreach ($results as $document) { $this->assertNotEmpty($document->getAttribute('name')); } - $results = $database->find($collectionName, [Query::select(["location"])]); + $results = $database->find($collectionName, [Query::select(['location'])]); foreach ($results as $document) { - $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates + $location = $document->getAttribute('location'); + $this->assertIsArray($location); + $this->assertCount(2, $location); // POINT has 2 coordinates } - $results = $database->find($collectionName, [Query::select(["area","location"])]); + $results = $database->find($collectionName, [Query::select(['area', 'location'])]); foreach ($results as $document) { - $this->assertCount(2, $document->getAttribute('location')); // POINT has 2 coordinates - $this->assertGreaterThan(1, count($document->getAttribute('area')[0])); // POLYGON has multiple points + $location = $document->getAttribute('location'); + $this->assertIsArray($location); + $this->assertCount(2, $location); // POINT has 2 coordinates + $area = $document->getAttribute('area'); + $this->assertIsArray($area); + $ring = $area[0] ?? null; + $this->assertIsArray($ring); + $this->assertGreaterThan(1, count($ring)); // POLYGON has multiple points } // Test 2: updateDocuments with spatial data @@ -1665,10 +1729,10 @@ public function testSpatialBulkOperation(): void [16.0, 25.0], [16.0, 26.0], [15.0, 26.0], - [15.0, 25.0] - ] // New POLYGON + [15.0, 25.0], + ], // New POLYGON ]), [ - Query::greaterThanEqual('$sequence', $results[0]->getSequence()) + Query::greaterThanEqual('$sequence', $results[0]->getSequence() ?? ''), ], onNext: function ($doc) use (&$updateResults) { $updateResults[] = $doc; }); @@ -1678,9 +1742,9 @@ public function testSpatialBulkOperation(): void $database->updateDocuments($collectionName, new Document([ 'name' => 'Updated Location', 'location' => [15.0, 25.0], - 'area' => [15.0, 25.0] // invalid polygon + 'area' => [15.0, 25.0], // invalid polygon ])); - $this->fail("fail to throw structure exception for the invalid spatial type"); + $this->fail('fail to throw structure exception for the invalid spatial type'); } catch (\Throwable $th) { $this->assertInstanceOf(StructureException::class, $th); @@ -1697,7 +1761,7 @@ public function testSpatialBulkOperation(): void [16.0, 25.0], [16.0, 26.0], [15.0, 26.0], - [15.0, 25.0] + [15.0, 25.0], ]], $document->getAttribute('area')); } @@ -1718,8 +1782,8 @@ public function testSpatialBulkOperation(): void [31.0, 40.0], [31.0, 41.0], [30.0, 41.0], - [30.0, 40.0] - ] + [30.0, 40.0], + ], ]), new Document([ '$id' => 'upsert2', @@ -1736,9 +1800,9 @@ public function testSpatialBulkOperation(): void [36.0, 45.0], [36.0, 46.0], [35.0, 46.0], - [35.0, 45.0] - ] - ]) + [35.0, 45.0], + ], + ]), ]; $upsertResults = []; @@ -1753,71 +1817,71 @@ public function testSpatialBulkOperation(): void foreach ($upsertResults as $document) { $this->assertNotEmpty($document->getId()); $this->assertNotEmpty($document->getSequence()); - $this->assertIsArray($document->getAttribute('location')); - $this->assertIsArray($document->getAttribute('area')); + $this->assertNotEmpty($document->getArray('location')); + $this->assertNotEmpty($document->getArray('area')); } // Test 4: Query spatial data after bulk operations $allDocuments = $database->find($collectionName, [ - Query::orderAsc('$sequence') + Query::orderAsc('$sequence'), ]); $this->assertGreaterThan(5, count($allDocuments)); // Should have original 5 + upserted 2 // Test 5: Spatial queries on bulk created data $nearbyDocuments = $database->find($collectionName, [ - Query::distanceLessThan('location', [15.0, 25.0], 1.0) // Find documents within 1 unit + Query::distanceLessThan('location', [15.0, 25.0], 1.0), // Find documents within 1 unit ]); $this->assertGreaterThan(0, count($nearbyDocuments)); // Test 6: distanceGreaterThan queries on bulk created data $farDocuments = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [15.0, 25.0], 5.0) // Find documents more than 5 units away + Query::distanceGreaterThan('location', [15.0, 25.0], 5.0), // Find documents more than 5 units away ]); $this->assertGreaterThan(0, count($farDocuments)); // Test 7: distanceLessThan queries on bulk created data $closeDocuments = $database->find($collectionName, [ - Query::distanceLessThan('location', [15.0, 25.0], 0.5) // Find documents less than 0.5 units away + Query::distanceLessThan('location', [15.0, 25.0], 0.5), // Find documents less than 0.5 units away ]); $this->assertGreaterThan(0, count($closeDocuments)); // Test 8: Additional distanceGreaterThan queries on bulk created data $veryFarDocuments = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [15.0, 25.0], 10.0) // Find documents more than 10 units away + Query::distanceGreaterThan('location', [15.0, 25.0], 10.0), // Find documents more than 10 units away ]); $this->assertGreaterThan(0, count($veryFarDocuments)); // Test 9: distanceGreaterThan with very small threshold (should find most documents) $slightlyFarDocuments = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [15.0, 25.0], 0.1) // Find documents more than 0.1 units away + Query::distanceGreaterThan('location', [15.0, 25.0], 0.1), // Find documents more than 0.1 units away ]); $this->assertGreaterThan(0, count($slightlyFarDocuments)); // Test 10: distanceGreaterThan with very large threshold (should find none) $extremelyFarDocuments = $database->find($collectionName, [ - Query::distanceGreaterThan('location', [15.0, 25.0], 100.0) // Find documents more than 100 units away + Query::distanceGreaterThan('location', [15.0, 25.0], 100.0), // Find documents more than 100 units away ]); $this->assertEquals(0, count($extremelyFarDocuments)); // Test 11: Update specific spatial documents $specificUpdateCount = $database->updateDocuments($collectionName, new Document([ - 'name' => 'Specifically Updated' + 'name' => 'Specifically Updated', ]), [ - Query::equal('$id', ['upsert1']) + Query::equal('$id', ['upsert1']), ]); $this->assertEquals(1, $specificUpdateCount); // Verify the specific update $specificDoc = $database->find($collectionName, [ - Query::equal('$id', ['upsert1']) + Query::equal('$id', ['upsert1']), ]); $this->assertCount(1, $specificDoc); @@ -1831,56 +1895,53 @@ public function testSptialAggregation(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_agg_'; try { // Create collection with spatial and numeric attributes - $database->createCollection($collectionName); - $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 255, true); - $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true); - $database->createAttribute($collectionName, 'area', Database::VAR_POLYGON, 0, true); - $database->createAttribute($collectionName, 'score', Database::VAR_INTEGER, 0, true); + $database->createCollection(Collection::create(id: $collectionName)); + $database->createAttribute($collectionName, Attribute::string(key: 'name', required: true)); + $database->createAttribute($collectionName, Attribute::point(key: 'loc', required: true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'area', required: true)); + $database->createAttribute($collectionName, Attribute::integer(key: 'score', required: true)); // Spatial indexes - $database->createIndex($collectionName, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); - $database->createIndex($collectionName, 'idx_area', Database::INDEX_SPATIAL, ['area']); + $database->createIndex($collectionName, Index::spatial(key: 'idx_loc', attribute: 'loc')); + $database->createIndex($collectionName, Index::spatial(key: 'idx_area', attribute: 'area')); // Seed documents - $a = $database->createDocument($collectionName, new Document([ + $database->createDocument($collectionName, new Document([ '$id' => 'a', 'name' => 'A', 'loc' => [10.0, 10.0], 'area' => [[[9.0, 9.0], [9.0, 11.0], [11.0, 11.0], [11.0, 9.0], [9.0, 9.0]]], 'score' => 10, - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $b = $database->createDocument($collectionName, new Document([ + $database->createDocument($collectionName, new Document([ '$id' => 'b', 'name' => 'B', 'loc' => [10.05, 10.05], 'area' => [[[9.5, 9.5], [9.5, 10.6], [10.6, 10.6], [10.6, 9.5], [9.5, 9.5]]], 'score' => 20, - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $c = $database->createDocument($collectionName, new Document([ + $database->createDocument($collectionName, new Document([ '$id' => 'c', 'name' => 'C', 'loc' => [50.0, 50.0], 'area' => [[[49.0, 49.0], [49.0, 51.0], [51.0, 51.0], [51.0, 49.0], [49.0, 49.0]]], 'score' => 30, - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $this->assertInstanceOf(Document::class, $a); - $this->assertInstanceOf(Document::class, $b); - $this->assertInstanceOf(Document::class, $c); - // COUNT with spatial distanceEqual filter $queries = [ - Query::distanceLessThan('loc', [10.0, 10.0], 0.1) + Query::distanceLessThan('loc', [10.0, 10.0], 0.1), ]; $this->assertEquals(2, $database->count($collectionName, $queries)); $this->assertCount(2, $database->find($collectionName, $queries)); @@ -1891,21 +1952,21 @@ public function testSptialAggregation(): void // COUNT and SUM with distanceGreaterThan (should only include far point "c") $queriesFar = [ - Query::distanceGreaterThan('loc', [10.0, 10.0], 10.0) + Query::distanceGreaterThan('loc', [10.0, 10.0], 10.0), ]; $this->assertEquals(1, $database->count($collectionName, $queriesFar)); $this->assertEquals(30, $database->sum($collectionName, 'score', $queriesFar)); // COUNT and SUM with polygon contains filter (adapter-dependent boundary inclusivity) - if ($database->getAdapter()->getSupportForBoundaryInclusiveContains()) { + if ($this->spatialIncludesBoundaries()) { $queriesContain = [ - Query::contains('area', [[10.0, 10.0]]) + Query::covers('area', [[10.0, 10.0]]), ]; $this->assertEquals(2, $database->count($collectionName, $queriesContain)); $this->assertEquals(30, $database->sum($collectionName, 'score', $queriesContain)); $queriesNotContain = [ - Query::notContains('area', [[10.0, 10.0]]) + Query::notCovers('area', [[10.0, 10.0]]), ]; $this->assertEquals(1, $database->count($collectionName, $queriesNotContain)); $this->assertEquals(30, $database->sum($collectionName, 'score', $queriesNotContain)); @@ -1919,82 +1980,85 @@ public function testUpdateSpatialAttributes(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_update_attrs_'; try { - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); - // 0) Disallow creation of spatial attributes with size or array - try { - $database->createAttribute($collectionName, 'geom_bad_size', Database::VAR_POINT, 10, true); - $this->fail('Expected DatabaseException when creating spatial attribute with non-zero size'); - } catch (\Throwable $e) { - $this->assertInstanceOf(Exception::class, $e); - } + // 0) A spatial attribute never stores a size or an array flag + $created = $database->createAttribute($collectionName, Attribute::fromArray(['key' => 'geom_bad_size', 'type' => ColumnType::Point, 'size' => 10, 'required' => true])); + $this->assertNull($created->size); + $this->assertNull($database->getCollection($collectionName)->attributes()[0]->size); + $database->deleteAttribute($collectionName, 'geom_bad_size'); - try { - $database->createAttribute($collectionName, 'geom_bad_array', Database::VAR_POINT, 0, true, array: true); - $this->fail('Expected DatabaseException when creating spatial attribute with array=true'); - } catch (\Throwable $e) { - $this->assertInstanceOf(Exception::class, $e); - } + $created = $database->createAttribute($collectionName, Attribute::fromArray(['key' => 'geom_bad_array', 'type' => ColumnType::Point, 'required' => true, 'array' => true])); + $this->assertFalse($created->array); + $this->assertFalse($database->getCollection($collectionName)->attributes()[0]->array); + $database->deleteAttribute($collectionName, 'geom_bad_array'); // Create a single spatial attribute (required=true) - $this->assertEquals(true, $database->createAttribute($collectionName, 'geom', Database::VAR_POINT, 0, true)); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_geom', Database::INDEX_SPATIAL, ['geom'])); + $database->createAttribute($collectionName, Attribute::point(key: 'geom', required: true)); + $database->createIndex($collectionName, Index::spatial(key: 'idx_geom', attribute: 'geom')); // 1) Disallow size and array updates on spatial attributes: expect DatabaseException try { - $database->updateAttribute($collectionName, 'geom', size: 10); + $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(size: 10)); $this->fail('Expected DatabaseException when updating size on spatial attribute'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } try { - $database->updateAttribute($collectionName, 'geom', array: true); + $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(array: true)); $this->fail('Expected DatabaseException when updating array on spatial attribute'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } // 2) required=true -> create index -> update required=false - $nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull(); + $nullSupported = $database->getAdapter()->supports(Capability::IndexSpatialNull); if ($nullSupported) { // Should succeed on adapters that allow nullable spatial indexes - $database->updateAttribute($collectionName, 'geom', required: false); + $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(required: false)); $meta = $database->getCollection($collectionName); - $this->assertEquals(false, $meta->getAttribute('attributes')[0]['required']); + $attributes = $meta->attributes(); + $this->assertCount(1, $attributes); + $this->assertSame('geom', $attributes[0]->key); + $this->assertFalse($attributes[0]->required); } else { // Should error (index constraint) when making required=false while spatial index exists $threw = false; try { - $database->updateAttribute($collectionName, 'geom', required: false); + $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(required: false)); } catch (\Throwable $e) { $threw = true; } $this->assertTrue($threw, 'Expected error when setting required=false with existing spatial index and adapter not supporting nullable indexes'); // Ensure attribute remains required $meta = $database->getCollection($collectionName); - $this->assertEquals(true, $meta->getAttribute('attributes')[0]['required']); + $attributes = $meta->attributes(); + $this->assertCount(1, $attributes); + $this->assertSame('geom', $attributes[0]->key); + $this->assertTrue($attributes[0]->required); } // 3) Spatial index order support: providing orders should fail if not supported - $orderSupported = $database->getAdapter()->getSupportForSpatialIndexOrder(); + $orderSupported = $database->getAdapter()->supports(Capability::IndexSpatialOrder); if ($orderSupported) { - $this->assertTrue($database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], [Database::ORDER_DESC])); + $database->createIndex($collectionName, Index::spatial(key: 'idx_geom_desc', attribute: 'geom', order: OrderDirection::Desc)); // cleanup - $this->assertTrue($database->deleteIndex($collectionName, 'idx_geom_desc')); + $database->deleteIndex($collectionName, 'idx_geom_desc'); } else { try { - $database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], ['DESC']); + $database->createIndex($collectionName, Index::spatial(key: 'idx_geom_desc', attribute: 'geom', order: OrderDirection::Desc)); $this->fail('Expected error when providing orders for spatial index on adapter without order support'); } catch (\Throwable $e) { - $this->assertTrue(true); + $this->assertNotSame('', $e->getMessage()); } } } finally { @@ -2006,277 +2070,214 @@ public function testSpatialAttributeDefaults(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_defaults_'; + $database->createCollection(Collection::create(id: $collectionName)); + try { - $database->createCollection($collectionName); + $database->createAttribute($collectionName, Attribute::point(key: 'pt', default: [1.0, 2.0])); + $database->createAttribute($collectionName, Attribute::lineString(key: 'ln', default: [[0.0, 0.0], [1.0, 1.0]])); + $database->createAttribute($collectionName, Attribute::polygon(key: 'pg', default: [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]])); + + $database->createAttribute($collectionName, Attribute::string(key: 'title', size: 255, default: 'Untitled')); + $database->createAttribute($collectionName, Attribute::integer(key: 'count', default: 0)); + $database->createAttribute($collectionName, Attribute::double(key: 'rating')); + $database->createAttribute($collectionName, Attribute::boolean(key: 'active', default: true)); + + $defaults = [ + 'pt' => [1.0, 2.0], + 'ln' => [[0.0, 0.0], [1.0, 1.0]], + 'pg' => [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], + 'title' => 'Untitled', + 'count' => 0, + 'rating' => null, + 'active' => true, + ]; - // Create spatial attributes with defaults and no indexes to avoid nullability/index constraints - $this->assertEquals(true, $database->createAttribute($collectionName, 'pt', Database::VAR_POINT, 0, false, [1.0, 2.0])); - $this->assertEquals(true, $database->createAttribute($collectionName, 'ln', Database::VAR_LINESTRING, 0, false, [[0.0, 0.0], [1.0, 1.0]])); - $this->assertEquals(true, $database->createAttribute($collectionName, 'pg', Database::VAR_POLYGON, 0, false, [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]])); + $document = $database->createDocument($collectionName, new Document([ + '$id' => Id::custom('d1'), + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + $this->assertSpatialDefaults($defaults, $document, 'created'); - // Create non-spatial attributes (mix of defaults and no defaults) - $this->assertEquals(true, $database->createAttribute($collectionName, 'title', Database::VAR_STRING, 255, false, 'Untitled')); - $this->assertEquals(true, $database->createAttribute($collectionName, 'count', Database::VAR_INTEGER, 0, false, 0)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'rating', Database::VAR_FLOAT, 0, false)); // no default - $this->assertEquals(true, $database->createAttribute($collectionName, 'active', Database::VAR_BOOLEAN, 0, false, true)); + $database->purgeCachedDocument($collectionName, 'd1'); + $this->assertSpatialDefaults($defaults, $database->getDocument($collectionName, 'd1'), 'stored'); - // Create document without providing spatial values, expect defaults applied - $doc = $database->createDocument($collectionName, new Document([ - '$id' => ID::custom('d1'), - '$permissions' => [Permission::read(Role::any())] - ])); - $this->assertInstanceOf(Document::class, $doc); - $this->assertEquals([1.0, 2.0], $doc->getAttribute('pt')); - $this->assertEquals([[0.0, 0.0], [1.0, 1.0]], $doc->getAttribute('ln')); - $this->assertEquals([[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], $doc->getAttribute('pg')); - // Non-spatial defaults - $this->assertEquals('Untitled', $doc->getAttribute('title')); - $this->assertEquals(0, $doc->getAttribute('count')); - $this->assertNull($doc->getAttribute('rating')); - $this->assertTrue($doc->getAttribute('active')); - - // Create document overriding defaults - $doc2 = $database->createDocument($collectionName, new Document([ - '$id' => ID::custom('d2'), - '$permissions' => [Permission::read(Role::any())], + $updated = $database->updateDocument($collectionName, 'd1', new Document(['count' => 1])); + $this->assertSame(1, $updated->getAttribute('count')); + $database->purgeCachedDocument($collectionName, 'd1'); + $this->assertSpatialDefaults(['count' => 1] + $defaults, $database->getDocument($collectionName, 'd1'), 'updated'); + + $overrides = [ 'pt' => [9.0, 9.0], 'ln' => [[2.0, 2.0], [3.0, 3.0]], 'pg' => [[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]], 'title' => 'Custom', 'count' => 5, 'rating' => 4.5, - 'active' => false + 'active' => false, + ]; + $overridden = $database->createDocument($collectionName, new Document([ + '$id' => Id::custom('d2'), + '$permissions' => [Permission::read(Role::any())], + ...$overrides, ])); - $this->assertInstanceOf(Document::class, $doc2); - $this->assertEquals([9.0, 9.0], $doc2->getAttribute('pt')); - $this->assertEquals([[2.0, 2.0], [3.0, 3.0]], $doc2->getAttribute('ln')); - $this->assertEquals([[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]], $doc2->getAttribute('pg')); - $this->assertEquals('Custom', $doc2->getAttribute('title')); - $this->assertEquals(5, $doc2->getAttribute('count')); - $this->assertEquals(4.5, $doc2->getAttribute('rating')); - $this->assertFalse($doc2->getAttribute('active')); - - // Update defaults and ensure they are applied for new documents - $database->updateAttributeDefault($collectionName, 'pt', [5.0, 6.0]); - $database->updateAttributeDefault($collectionName, 'ln', [[10.0, 10.0], [20.0, 20.0]]); - $database->updateAttributeDefault($collectionName, 'pg', [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]]); - $database->updateAttributeDefault($collectionName, 'title', 'Updated'); - $database->updateAttributeDefault($collectionName, 'count', 10); - $database->updateAttributeDefault($collectionName, 'active', false); - - $doc3 = $database->createDocument($collectionName, new Document([ - '$id' => ID::custom('d3'), - '$permissions' => [Permission::read(Role::any())] + $this->assertSpatialDefaults($overrides, $overridden, 'overridden'); + + $database->updateAttribute($collectionName, 'pt', new AttributeUpdate(default: [5.0, 6.0])); + $database->updateAttribute($collectionName, 'ln', new AttributeUpdate(default: [[10.0, 10.0], [20.0, 20.0]])); + $database->updateAttribute($collectionName, 'pg', new AttributeUpdate(default: [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]])); + $database->updateAttribute($collectionName, 'title', new AttributeUpdate(default: 'Updated')); + $database->updateAttribute($collectionName, 'count', new AttributeUpdate(default: 10)); + $database->updateAttribute($collectionName, 'active', new AttributeUpdate(default: false)); + + $newDefaults = [ + 'pt' => [5.0, 6.0], + 'ln' => [[10.0, 10.0], [20.0, 20.0]], + 'pg' => [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]], + 'title' => 'Updated', + 'count' => 10, + 'rating' => null, + 'active' => false, + ]; + $document = $database->createDocument($collectionName, new Document([ + '$id' => Id::custom('d3'), + '$permissions' => [Permission::read(Role::any())], ])); - $this->assertInstanceOf(Document::class, $doc3); - $this->assertEquals([5.0, 6.0], $doc3->getAttribute('pt')); - $this->assertEquals([[10.0, 10.0], [20.0, 20.0]], $doc3->getAttribute('ln')); - $this->assertEquals([[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]], $doc3->getAttribute('pg')); - $this->assertEquals('Updated', $doc3->getAttribute('title')); - $this->assertEquals(10, $doc3->getAttribute('count')); - $this->assertNull($doc3->getAttribute('rating')); - $this->assertFalse($doc3->getAttribute('active')); - - // Invalid defaults should raise errors - try { - $database->updateAttributeDefault($collectionName, 'pt', [[1.0, 2.0]]); // wrong dimensionality - $this->fail('Expected exception for invalid point default shape'); - } catch (\Throwable $e) { - $this->assertTrue(true); - } - try { - $database->updateAttributeDefault($collectionName, 'ln', [1.0, 2.0]); // wrong dimensionality - $this->fail('Expected exception for invalid linestring default shape'); - } catch (\Throwable $e) { - $this->assertTrue(true); - } - try { - $database->updateAttributeDefault($collectionName, 'pg', [[1.0, 2.0]]); // wrong dimensionality - $this->fail('Expected exception for invalid polygon default shape'); - } catch (\Throwable $e) { - $this->assertTrue(true); - } + $this->assertSpatialDefaults($newDefaults, $document, 'created after the defaults changed'); + + $database->purgeCachedDocument($collectionName, 'd3'); + $this->assertSpatialDefaults($newDefaults, $database->getDocument($collectionName, 'd3'), 'stored after the defaults changed'); } finally { $database->deleteCollection($collectionName); } } + /** + * @param array $expected + */ + private function assertSpatialDefaults(array $expected, Document $document, string $stage): void + { + foreach ($expected as $key => $value) { + $this->assertSame($value, $document->getAttribute($key), "Attribute \"{$key}\" of the {$stage} document"); + } + } + public function testInvalidSpatialTypes(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'test_invalid_spatial_types'; - $attributes = [ - new Document([ - '$id' => ID::custom('pointAttr'), - 'type' => Database::VAR_POINT, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('lineAttr'), - 'type' => Database::VAR_LINESTRING, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('polyAttr'), - 'type' => Database::VAR_POLYGON, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]) - ]; - - $database->createCollection($collectionName, $attributes); - - // Invalid Point (must be [x, y]) - try { - $database->createDocument($collectionName, new Document([ - 'pointAttr' => [10.0], // only 1 coordinate - ])); - $this->fail("Expected StructureException for invalid point"); - } catch (\Throwable $th) { - $this->assertInstanceOf(StructureException::class, $th); - } - - // Invalid LineString (must be [[x,y],[x,y],...], at least 2 points) - try { - $database->createDocument($collectionName, new Document([ - 'lineAttr' => [[10.0, 20.0]], // only one point - ])); - $this->fail("Expected StructureException for invalid line"); - } catch (\Throwable $th) { - $this->assertInstanceOf(StructureException::class, $th); - } + $database->createCollection(Collection::create(id: $collectionName, attributes: [ + Attribute::point(key: 'pointAttr'), + Attribute::lineString(key: 'lineAttr'), + Attribute::polygon(key: 'polyAttr'), + ])); - try { - $database->createDocument($collectionName, new Document([ - 'lineAttr' => [10.0, 20.0], // not an array of arrays - ])); - $this->fail("Expected StructureException for invalid line structure"); - } catch (\Throwable $th) { - $this->assertInstanceOf(StructureException::class, $th); - } + $invalidDocuments = [ + 'point with one coordinate' => ['pointAttr' => [10.0]], + 'line with one point' => ['lineAttr' => [[10.0, 20.0]]], + 'line that is not an array of points' => ['lineAttr' => [10.0, 20.0]], + 'polygon that is not an array of rings' => ['polyAttr' => [10.0, 20.0]], + 'polygon ring with three points' => ['polyAttr' => [[0, 0], [1, 1], [0, 1]]], + 'polygon ring with a non-numeric coordinate' => ['polyAttr' => [[0, 0], ['a', 1], [1, 1], [0, 0]]], + 'polygon ring that is not closed' => ['polyAttr' => [[0, 0], [1, 0], [1, 1], [0, 1]]], + 'empty polygon' => ['polyAttr' => []], + 'polygon ring with three-dimensional points' => ['polyAttr' => [[0, 0, 5], [1, 0, 5], [1, 1, 5], [0, 0, 5]]], + 'polygon with a three-dimensional inner ring' => ['polyAttr' => [ + [[0, 0], [2, 0], [2, 2], [0, 0]], + [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1]], + ]], + ]; try { - $database->createDocument($collectionName, new Document([ - 'polyAttr' => [10.0, 20.0] // not an array of arrays - ])); - $this->fail("Expected StructureException for invalid polygon structure"); - } catch (\Throwable $th) { - $this->assertInstanceOf(StructureException::class, $th); - } - - $invalidPolygons = [ - [[0,0],[1,1],[0,1]], - [[0,0],['a',1],[1,1],[0,0]], - [[0,0],[1,0],[1,1],[0,1]], - [], - [[0,0,5],[1,0,5],[1,1,5],[0,0,5]], - [ - [[0,0],[2,0],[2,2],[0,0]], // valid - [[0,0,1],[1,0,1],[1,1,1],[0,0,1]] // invalid 3D - ] - ]; - foreach ($invalidPolygons as $invalidPolygon) { - try { - $database->createDocument($collectionName, new Document([ - 'polyAttr' => $invalidPolygon - ])); - $this->fail("Expected StructureException for invalid polygon structure"); - } catch (\Throwable $th) { - $this->assertInstanceOf(StructureException::class, $th); + foreach ($invalidDocuments as $case => $attributes) { + try { + $database->createDocument($collectionName, new Document($attributes)); + $this->fail("Expected StructureException for {$case}"); + } catch (\Throwable $exception) { + $this->assertInstanceOf(StructureException::class, $exception, $case); + } } + } finally { + $database->deleteCollection($collectionName); } - // Cleanup - $database->deleteCollection($collectionName); } public function testSpatialDistanceInMeter(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'spatial_distance_meters_'; try { - $database->createCollection($collectionName); - $this->assertEquals(true, $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true)); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + $database->createCollection(Collection::create(id: $collectionName)); + $database->createAttribute($collectionName, Attribute::point(key: 'loc', required: true)); + $database->createIndex($collectionName, Index::spatial(key: 'idx_loc', attribute: 'loc')); // Two points roughly ~1000 meters apart by latitude delta (~0.009 deg ≈ 1km) - $p0 = $database->createDocument($collectionName, new Document([ + $database->createDocument($collectionName, new Document([ '$id' => 'p0', 'loc' => [0.0000, 0.0000], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $p1 = $database->createDocument($collectionName, new Document([ + $database->createDocument($collectionName, new Document([ '$id' => 'p1', 'loc' => [0.0090, 0.0000], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $this->assertInstanceOf(Document::class, $p0); - $this->assertInstanceOf(Document::class, $p1); - // distanceLessThan with meters=true: within 1500m should include both $within1_5km = $database->find($collectionName, [ - Query::distanceLessThan('loc', [0.0000, 0.0000], 1500, true) - ], Database::PERMISSION_READ); + Query::distanceLessThan('loc', [0.0000, 0.0000], 1500, true), + ], PermissionType::Read); $this->assertNotEmpty($within1_5km); $this->assertCount(2, $within1_5km); // Within 500m should include only p0 (exact point) $within500m = $database->find($collectionName, [ - Query::distanceLessThan('loc', [0.0000, 0.0000], 500, true) - ], Database::PERMISSION_READ); + Query::distanceLessThan('loc', [0.0000, 0.0000], 500, true), + ], PermissionType::Read); $this->assertNotEmpty($within500m); $this->assertCount(1, $within500m); $this->assertEquals('p0', $within500m[0]->getId()); // distanceGreaterThan 500m should include only p1 $greater500m = $database->find($collectionName, [ - Query::distanceGreaterThan('loc', [0.0000, 0.0000], 500, true) - ], Database::PERMISSION_READ); + Query::distanceGreaterThan('loc', [0.0000, 0.0000], 500, true), + ], PermissionType::Read); $this->assertNotEmpty($greater500m); $this->assertCount(1, $greater500m); $this->assertEquals('p1', $greater500m[0]->getId()); // distanceEqual with 0m should return exact match p0 $equalZero = $database->find($collectionName, [ - Query::distanceEqual('loc', [0.0000, 0.0000], 0, true) - ], Database::PERMISSION_READ); + Query::distanceEqual('loc', [0.0000, 0.0000], 0, true), + ], PermissionType::Read); $this->assertNotEmpty($equalZero); $this->assertEquals('p0', $equalZero[0]->getId()); // distanceNotEqual with 0m should return p1 $notEqualZero = $database->find($collectionName, [ - Query::distanceNotEqual('loc', [0.0000, 0.0000], 0, true) - ], Database::PERMISSION_READ); + Query::distanceNotEqual('loc', [0.0000, 0.0000], 0, true), + ], PermissionType::Read); $this->assertNotEmpty($notEqualZero); $this->assertEquals('p1', $notEqualZero[0]->getId()); } finally { @@ -2288,46 +2289,48 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } - if (!$database->getAdapter()->getSupportForDistanceBetweenMultiDimensionGeometryInMeters()) { + if (! $this->supportsMultiDimensionDistance()) { $this->expectNotToPerformAssertions(); + return; } $multiCollection = 'spatial_distance_meters_multi_'; try { - $database->createCollection($multiCollection); + $database->createCollection(Collection::create(id: $multiCollection)); // Create spatial attributes - $this->assertEquals(true, $database->createAttribute($multiCollection, 'loc', Database::VAR_POINT, 0, true)); - $this->assertEquals(true, $database->createAttribute($multiCollection, 'line', Database::VAR_LINESTRING, 0, true)); - $this->assertEquals(true, $database->createAttribute($multiCollection, 'poly', Database::VAR_POLYGON, 0, true)); + $database->createAttribute($multiCollection, Attribute::point(key: 'loc', required: true)); + $database->createAttribute($multiCollection, Attribute::lineString(key: 'line', required: true)); + $database->createAttribute($multiCollection, Attribute::polygon(key: 'poly', required: true)); // Create indexes - $this->assertEquals(true, $database->createIndex($multiCollection, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); - $this->assertEquals(true, $database->createIndex($multiCollection, 'idx_line', Database::INDEX_SPATIAL, ['line'])); - $this->assertEquals(true, $database->createIndex($multiCollection, 'idx_poly', Database::INDEX_SPATIAL, ['poly'])); + $database->createIndex($multiCollection, Index::spatial(key: 'idx_loc', attribute: 'loc')); + $database->createIndex($multiCollection, Index::spatial(key: 'idx_line', attribute: 'line')); + $database->createIndex($multiCollection, Index::spatial(key: 'idx_poly', attribute: 'poly')); // Geometry sets: near origin and far east - $docNear = $database->createDocument($multiCollection, new Document([ + $database->createDocument($multiCollection, new Document([ '$id' => 'near', 'loc' => [0.0000, 0.0000], 'line' => [[0.0000, 0.0000], [0.0010, 0.0000]], // ~111m 'poly' => [[ [-0.0010, -0.0010], [-0.0010, 0.0010], - [ 0.0010, 0.0010], - [ 0.0010, -0.0010], - [-0.0010, -0.0010] // closed + [0.0010, 0.0010], + [0.0010, -0.0010], + [-0.0010, -0.0010], // closed ]], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $docFar = $database->createDocument($multiCollection, new Document([ + $database->createDocument($multiCollection, new Document([ '$id' => 'far', 'loc' => [0.2000, 0.0000], // ~22 km east 'line' => [[0.2000, 0.0000], [0.2020, 0.0000]], @@ -2336,14 +2339,11 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void [0.1980, 0.0020], [0.2020, 0.0020], [0.2020, -0.0020], - [0.1980, -0.0020] // closed + [0.1980, -0.0020], // closed ]], - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], ])); - $this->assertInstanceOf(Document::class, $docNear); - $this->assertInstanceOf(Document::class, $docFar); - // polygon vs polygon (~1 km from near, ~22 km from far) $polyPolyWithin3km = $database->find($multiCollection, [ Query::distanceLessThan('poly', [[ @@ -2351,9 +2351,9 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void [0.0080, 0.0010], [0.0110, 0.0010], [0.0110, -0.0010], - [0.0080, -0.0010] // closed - ]], 3000, true) - ], Database::PERMISSION_READ); + [0.0080, -0.0010], // closed + ]], 3000, true), + ], PermissionType::Read); $this->assertCount(1, $polyPolyWithin3km); $this->assertEquals('near', $polyPolyWithin3km[0]->getId()); @@ -2363,9 +2363,9 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void [0.0080, 0.0010], [0.0110, 0.0010], [0.0110, -0.0010], - [0.0080, -0.0010] // closed - ]], 3000, true) - ], Database::PERMISSION_READ); + [0.0080, -0.0010], // closed + ]], 3000, true), + ], PermissionType::Read); $this->assertCount(1, $polyPolyGreater3km); $this->assertEquals('far', $polyPolyGreater3km[0]->getId()); @@ -2374,10 +2374,10 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void Query::distanceLessThan('loc', [[ [-0.0010, -0.0010], [-0.0010, 0.0020], - [ 0.0020, 0.0020], - [-0.0010, -0.0010] - ]], 500, true) - ], Database::PERMISSION_READ); + [0.0020, 0.0020], + [-0.0010, -0.0010], + ]], 500, true), + ], PermissionType::Read); $this->assertCount(1, $ptPolyWithin500); $this->assertEquals('near', $ptPolyWithin500[0]->getId()); @@ -2385,17 +2385,17 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void Query::distanceGreaterThan('loc', [[ [-0.0010, -0.0010], [-0.0010, 0.0020], - [ 0.0020, 0.0020], - [-0.0010, -0.0010] - ]], 500, true) - ], Database::PERMISSION_READ); + [0.0020, 0.0020], + [-0.0010, -0.0010], + ]], 500, true), + ], PermissionType::Read); $this->assertCount(1, $ptPolyGreater500); $this->assertEquals('far', $ptPolyGreater500[0]->getId()); // Zero-distance checks $lineEqualZero = $database->find($multiCollection, [ - Query::distanceEqual('line', [[0.0000, 0.0000], [0.0010, 0.0000]], 0, true) - ], Database::PERMISSION_READ); + Query::distanceEqual('line', [[0.0000, 0.0000], [0.0010, 0.0000]], 0, true), + ], PermissionType::Read); $this->assertNotEmpty($lineEqualZero); $this->assertEquals('near', $lineEqualZero[0]->getId()); @@ -2403,11 +2403,11 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void Query::distanceEqual('poly', [[ [-0.0010, -0.0010], [-0.0010, 0.0010], - [ 0.0010, 0.0010], - [ 0.0010, -0.0010], - [-0.0010, -0.0010] - ]], 0, true) - ], Database::PERMISSION_READ); + [0.0010, 0.0010], + [0.0010, -0.0010], + [-0.0010, -0.0010], + ]], 0, true), + ], PermissionType::Read); $this->assertNotEmpty($polyEqualZero); $this->assertEquals('near', $polyEqualZero[0]->getId()); @@ -2416,107 +2416,51 @@ public function testSpatialDistanceInMeterForMultiDimensionGeometry(): void } } - public function testSpatialDistanceInMeterError(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - if ($database->getAdapter()->getSupportForDistanceBetweenMultiDimensionGeometryInMeters()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collection = 'spatial_distance_error_test'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'loc', Database::VAR_POINT, 0, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'line', Database::VAR_LINESTRING, 0, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'poly', Database::VAR_POLYGON, 0, true)); - - $doc = $database->createDocument($collection, new Document([ - '$id' => 'doc1', - 'loc' => [0.0, 0.0], - 'line' => [[0.0, 0.0], [0.001, 0.0]], - 'poly' => [[[ -0.001, -0.001 ], [ -0.001, 0.001 ], [ 0.001, 0.001 ], [ -0.001, -0.001 ]]], - '$permissions' => [] - ])); - $this->assertInstanceOf(Document::class, $doc); - - // Invalid geometry pairs - $cases = [ - ['attr' => 'line', 'geom' => [0.002, 0.0], 'expected' => ['linestring', 'point']], - ['attr' => 'poly', 'geom' => [0.002, 0.0], 'expected' => ['polygon', 'point']], - ['attr' => 'loc', 'geom' => [[0.0, 0.0], [0.001, 0.001]], 'expected' => ['point', 'linestring']], - ['attr' => 'poly', 'geom' => [[0.0, 0.0], [0.001, 0.001]], 'expected' => ['polygon', 'linestring']], - ['attr' => 'loc', 'geom' => [[[0.0, 0.0], [0.001, 0.0], [0.001, 0.001], [0.0, 0.0]]], 'expected' => ['point', 'polygon']], - ['attr' => 'line', 'geom' => [[[0.0, 0.0], [0.001, 0.0], [0.001, 0.001], [0.0, 0.0]]], 'expected' => ['linestring', 'polygon']], - ['attr' => 'poly', 'geom' => [[[0.002, -0.001], [0.002, 0.001], [0.004, 0.001], [0.002, -0.001]]], 'expected' => ['polygon', 'polygon']], - ['attr' => 'line', 'geom' => [[0.002, 0.0], [0.003, 0.0]], 'expected' => ['linestring', 'linestring']], - ]; - - foreach ($cases as $case) { - try { - $database->find($collection, [ - Query::distanceLessThan($case['attr'], $case['geom'], 1000, true) - ]); - $this->fail('Expected Exception not thrown for ' . implode(' vs ', $case['expected'])); - } catch (\Exception $e) { - $this->assertInstanceOf(QueryException::class, $e); - - // Validate exception message contains correct type names - $msg = strtolower($e->getMessage()); - $this->assertStringContainsString($case['expected'][0], $msg, 'Attr type missing in exception'); - $this->assertStringContainsString($case['expected'][1], $msg, 'Geom type missing in exception'); - } - } - } public function testSpatialEncodeDecode(): void { $collection = new Document([ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('users'), + '$collection' => Id::custom(Database::METADATA), + '$id' => Id::custom('users'), 'name' => 'Users', 'attributes' => [ [ - '$id' => ID::custom('point'), - 'type' => Database::VAR_POINT, + '$id' => Id::custom('point'), + 'type' => ColumnType::Point->value, 'required' => false, - 'filters' => [Database::VAR_POINT], + 'filters' => [ColumnType::Point->value], ], [ - '$id' => ID::custom('line'), - 'type' => Database::VAR_LINESTRING, + '$id' => Id::custom('line'), + 'type' => ColumnType::Linestring->value, 'format' => '', 'required' => false, - 'filters' => [Database::VAR_LINESTRING], + 'filters' => [ColumnType::Linestring->value], ], [ - '$id' => ID::custom('poly'), - 'type' => Database::VAR_POLYGON, + '$id' => Id::custom('poly'), + 'type' => ColumnType::Polygon->value, 'format' => '', 'required' => false, - 'filters' => [Database::VAR_POLYGON], - ] - ] + 'filters' => [ColumnType::Polygon->value], + ], + ], ]); /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } - $point = "POINT(1 2)"; - $line = "LINESTRING(1 2, 1 2)"; - $poly = "POLYGON((0 0, 0 10, 10 10, 0 0))"; + $point = 'POINT(1 2)'; + $line = 'LINESTRING(1 2, 1 2)'; + $poly = 'POLYGON((0 0, 0 10, 10 10, 0 0))'; - $pointArr = [1,2]; - $lineArr = [[1,2],[1,2]]; + $pointArr = [1, 2]; + $lineArr = [[1, 2], [1, 2]]; $polyArr = [[[0.0, 0.0], [0.0, 10.0], [10.0, 10.0], [0.0, 0.0]]]; - $doc = new Document(['point' => $pointArr ,'line' => $lineArr, 'poly' => $polyArr]); + $doc = new Document(['point' => $pointArr, 'line' => $lineArr, 'poly' => $polyArr]); $result = $database->encode($collection, $doc); @@ -2524,19 +2468,18 @@ public function testSpatialEncodeDecode(): void $this->assertEquals($result->getAttribute('line'), $line); $this->assertEquals($result->getAttribute('poly'), $poly); - $result = $database->decode($collection, $doc); $this->assertEquals($result->getAttribute('point'), $pointArr); $this->assertEquals($result->getAttribute('line'), $lineArr); $this->assertEquals($result->getAttribute('poly'), $polyArr); - $stringDoc = new Document(['point' => $point,'line' => $line, 'poly' => $poly]); + $stringDoc = new Document(['point' => $point, 'line' => $line, 'poly' => $poly]); $result = $database->decode($collection, $stringDoc); $this->assertEquals($result->getAttribute('point'), $pointArr); $this->assertEquals($result->getAttribute('line'), $lineArr); $this->assertEquals($result->getAttribute('poly'), $polyArr); - $nullDoc = new Document(['point' => null,'line' => null, 'poly' => null]); + $nullDoc = new Document(['point' => null, 'line' => null, 'poly' => null]); $result = $database->decode($collection, $nullDoc); $this->assertEquals($result->getAttribute('point'), null); $this->assertEquals($result->getAttribute('line'), null); @@ -2547,49 +2490,37 @@ public function testSpatialIndexSingleAttributeOnly(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } - $collectionName = 'spatial_idx_single_attr_' . uniqid(); - try { - $database->createCollection($collectionName); - - // Create a spatial attribute - $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true); - $database->createAttribute($collectionName, 'loc2', Database::VAR_POINT, 0, true); - $database->createAttribute($collectionName, 'title', Database::VAR_STRING, 255, true); + $collectionName = 'spatial_idx_single_attr'; + $database->createCollection(Collection::create(id: $collectionName)); - // Case 1: Valid spatial index on a single spatial attribute - $this->assertTrue( - $database->createIndex($collectionName, 'idx_loc', Database::INDEX_SPATIAL, ['loc']) - ); - - // Case 2: Fail when trying to create spatial index with multiple attributes - try { - $database->createIndex($collectionName, 'idx_multi', Database::INDEX_SPATIAL, ['loc', 'loc2']); - $this->fail('Expected exception when creating spatial index on multiple attributes'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); - } + try { + $database->createAttribute($collectionName, Attribute::point(key: 'loc', required: true)); + $database->createAttribute($collectionName, Attribute::point(key: 'loc2', required: true)); + $database->createAttribute($collectionName, Attribute::string(key: 'title', size: 255, required: true)); - // Case 3: Fail when trying to create non-spatial index on a spatial attribute - try { - $database->createIndex($collectionName, 'idx_wrong_type', Database::INDEX_KEY, ['loc']); - $this->fail('Expected exception when creating non-spatial index on spatial attribute'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); - } + $database->createIndex($collectionName, Index::spatial(key: 'idx_loc', attribute: 'loc')); - // Case 4: Fail when trying to mix spatial + non-spatial attributes in a spatial index - try { - $database->createIndex($collectionName, 'idx_mix', Database::INDEX_SPATIAL, ['loc', 'title']); - $this->fail('Expected exception when creating spatial index with mixed attribute types'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); + $invalidIndexes = [ + 'spatial index on multiple attributes' => Index::fromArray(['key' => 'idx_multi', 'type' => IndexType::Spatial, 'attributes' => ['loc', 'loc2']]), + 'non-spatial index on a spatial attribute' => Index::key(key: 'idx_wrong_type', attributes: ['loc']), + 'spatial index mixing spatial and non-spatial attributes' => Index::fromArray(['key' => 'idx_mix', 'type' => IndexType::Spatial, 'attributes' => ['loc', 'title']]), + ]; + foreach ($invalidIndexes as $case => $index) { + try { + $database->createIndex($collectionName, $index); + $this->fail("Expected exception when creating a {$case}"); + } catch (\Throwable $exception) { + $this->assertInstanceOf(IndexException::class, $exception, $case); + } } + $this->assertSame(['idx_loc'], array_map(fn (Index $index) => $index->key, $database->getCollection($collectionName)->indexes())); } finally { $database->deleteCollection($collectionName); } @@ -2599,30 +2530,32 @@ public function testSpatialIndexRequiredToggling(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } - if ($database->getAdapter()->getSupportForSpatialIndexNull()) { + if ($database->getAdapter()->supports(Capability::IndexSpatialNull)) { $this->expectNotToPerformAssertions(); + return; } try { $collUpdateNull = 'spatial_idx_toggle'; - $database->createCollection($collUpdateNull); + $database->createCollection(Collection::create(id: $collUpdateNull)); - $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, false); + $database->createAttribute($collUpdateNull, Attribute::point(key: 'loc')); try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attribute: 'loc')); $this->fail('Expected exception when creating spatial index on NULL-able attribute'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } - $database->updateAttribute($collUpdateNull, 'loc', required: true); - $this->assertTrue($database->createIndex($collUpdateNull, 'new index', Database::INDEX_SPATIAL, ['loc'])); - $this->assertTrue($database->deleteIndex($collUpdateNull, 'new index')); - $database->updateAttribute($collUpdateNull, 'loc', required: false); + $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: true)); + $database->createIndex($collUpdateNull, Index::spatial(key: 'new index', attribute: 'loc')); + $database->deleteIndex($collUpdateNull, 'new index'); + $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: false)); $database->createDocument($collUpdateNull, new Document(['loc' => null])); } finally { @@ -2634,61 +2567,39 @@ public function testSpatialIndexOnNonSpatial(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } - try { - $collUpdateNull = 'spatial_idx_toggle'; - $database->createCollection($collUpdateNull); - - $database->createAttribute($collUpdateNull, 'loc', Database::VAR_POINT, 0, true); - $database->createAttribute($collUpdateNull, 'name', Database::VAR_STRING, 4, true); - try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['name']); - $this->fail('Expected exception when creating spatial index on NULL-able attribute'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); - } + $collectionName = 'spatial_idx_non_spatial'; + $database->createCollection(Collection::create(id: $collectionName)); - try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_KEY, ['loc']); - $this->fail('Expected exception when creating non spatial index on spatial attribute'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); - } - - try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_KEY, ['loc,name']); - $this->fail('Expected exception when creating index'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); - } - - try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_KEY, ['name,loc']); - $this->fail('Expected exception when creating index'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); - } - - try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['name,loc']); - $this->fail('Expected exception when creating index'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); - } - - try { - $database->createIndex($collUpdateNull, 'idx_loc', Database::INDEX_SPATIAL, ['loc,name']); - $this->fail('Expected exception when creating index'); - } catch (\Throwable $e) { - $this->assertInstanceOf(IndexException::class, $e); + try { + $database->createAttribute($collectionName, Attribute::point(key: 'loc', required: true)); + $database->createAttribute($collectionName, Attribute::string(key: 'name', size: 4, required: true)); + + $invalidIndexes = [ + 'spatial index on a string attribute' => Index::spatial(key: 'idx_loc', attribute: 'name'), + 'key index on a spatial attribute' => Index::key(key: 'idx_loc', attributes: ['loc']), + 'key index on "loc,name"' => Index::key(key: 'idx_loc', attributes: ['loc,name']), + 'key index on "name,loc"' => Index::key(key: 'idx_loc', attributes: ['name,loc']), + 'spatial index on "name,loc"' => Index::spatial(key: 'idx_loc', attribute: 'name,loc'), + 'spatial index on "loc,name"' => Index::spatial(key: 'idx_loc', attribute: 'loc,name'), + ]; + foreach ($invalidIndexes as $case => $index) { + try { + $database->createIndex($collectionName, $index); + $this->fail("Expected exception when creating a {$case}"); + } catch (\Throwable $exception) { + $this->assertInstanceOf(IndexException::class, $exception, $case); + } } + $this->assertSame([], $database->getCollection($collectionName)->indexes()); } finally { - $database->deleteCollection($collUpdateNull); + $database->deleteCollection($collectionName); } } @@ -2696,152 +2607,66 @@ public function testSpatialDocOrder(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'test_spatial_order_axis'; // Create collection first - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); // Create spatial attributes using createAttribute method - $this->assertEquals(true, $database->createAttribute($collectionName, 'pointAttr', Database::VAR_POINT, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true)); + $database->createAttribute($collectionName, Attribute::point(key: 'pointAttr', required: $database->getAdapter()->supports(Capability::IndexSpatialNull) ? false : true)); // Create test document $doc1 = new Document( [ '$id' => 'doc1', 'pointAttr' => [5.0, 5.5], - '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())] + '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())], ] ); $database->createDocument($collectionName, $doc1); $result = $database->getDocument($collectionName, 'doc1'); - $this->assertEquals($result->getAttribute('pointAttr')[0], 5.0); - $this->assertEquals($result->getAttribute('pointAttr')[1], 5.5); + $point = $result->getAttribute('pointAttr'); + $this->assertIsArray($point); + $this->assertEquals($point[0], 5.0); + $this->assertEquals($point[1], 5.5); $database->deleteCollection($collectionName); } - public function testInvalidCoordinateDocuments(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collectionName = 'test_invalid_coord_'; - try { - $database->createCollection($collectionName); - - $database->createAttribute($collectionName, 'pointAttr', Database::VAR_POINT, 0, true); - $database->createAttribute($collectionName, 'lineAttr', Database::VAR_LINESTRING, 0, true); - $database->createAttribute($collectionName, 'polyAttr', Database::VAR_POLYGON, 0, true); - - $invalidDocs = [ - // Invalid POINT (longitude > 180) - [ - '$id' => 'invalidDoc1', - 'pointAttr' => [200.0, 20.0], - 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], - 'polyAttr' => [ - [ - [0.0, 0.0], - [0.0, 10.0], - [10.0, 10.0], - [10.0, 0.0], - [0.0, 0.0] - ] - ] - ], - // Invalid POINT (latitude < -90) - [ - '$id' => 'invalidDoc2', - 'pointAttr' => [50.0, -100.0], - 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], - 'polyAttr' => [ - [ - [0.0, 0.0], - [0.0, 10.0], - [10.0, 10.0], - [10.0, 0.0], - [0.0, 0.0] - ] - ] - ], - // Invalid LINESTRING (point outside valid range) - [ - '$id' => 'invalidDoc3', - 'pointAttr' => [50.0, 20.0], - 'lineAttr' => [[1.0, 2.0], [300.0, 4.0]], // invalid longitude in line - 'polyAttr' => [ - [ - [0.0, 0.0], - [0.0, 10.0], - [10.0, 10.0], - [10.0, 0.0], - [0.0, 0.0] - ] - ] - ], - // Invalid POLYGON (point outside valid range) - [ - '$id' => 'invalidDoc4', - 'pointAttr' => [50.0, 20.0], - 'lineAttr' => [[1.0, 2.0], [3.0, 4.0]], - 'polyAttr' => [ - [ - [0.0, 0.0], - [0.0, 10.0], - [190.0, 10.0], // invalid longitude - [10.0, 0.0], - [0.0, 0.0] - ] - ] - ], - ]; - foreach ($invalidDocs as $docData) { - $this->expectException(StructureException::class); - $docData['$permissions'] = [Permission::update(Role::any()), Permission::read(Role::any())]; - $doc = new Document($docData); - $database->createDocument($collectionName, $doc); - } - - - } finally { - $database->deleteCollection($collectionName); - } - } - public function testCreateSpatialColumnWithExistingData(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } - if ($database->getAdapter()->getSupportForSpatialIndexNull()) { + if ($database->getAdapter()->supports(Capability::IndexSpatialNull)) { $this->expectNotToPerformAssertions(); + return; } - if ($database->getAdapter()->getSupportForOptionalSpatialAttributeWithExistingRows()) { + if ($this->supportsOptionalSpatial()) { $this->expectNotToPerformAssertions(); + return; } $col = 'spatial_col_existing_data'; try { - $database->createCollection($col); + $database->createCollection(Collection::create(id: $col)); - $database->createAttribute($col, 'name', Database::VAR_STRING, 40, false); - $database->createDocument($col, new Document(['name' => 'test-doc','$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())]])); + $database->createAttribute($col, Attribute::string(key: 'name', size: 40)); + $database->createDocument($col, new Document(['name' => 'test-doc', '$permissions' => [Permission::update(Role::any()), Permission::read(Role::any())]])); try { - $database->createAttribute($col, 'loc', Database::VAR_POINT, 0, true); + $database->createAttribute($col, Attribute::point(key: 'loc', required: true)); } catch (\Throwable $e) { $this->assertInstanceOf(StructureException::class, $e); } @@ -2859,25 +2684,26 @@ public function testSpatialArrayWKTConversionInUpdateDocument(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + if (! ($database->getAdapter()->hasFeature(Feature\Spatial::class))) { $this->expectNotToPerformAssertions(); + return; } $collectionName = 'test_spatial_wkt_conversion'; try { - $database->createCollection($collectionName); + $database->createCollection(Collection::create(id: $collectionName)); // Use required=true for spatial attributes to support spatial indexes (MariaDB requires this) - $database->createAttribute($collectionName, 'location', Database::VAR_POINT, 0, true); - $database->createAttribute($collectionName, 'route', Database::VAR_LINESTRING, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true); - $database->createAttribute($collectionName, 'area', Database::VAR_POLYGON, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true); - $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 100, false); + $database->createAttribute($collectionName, Attribute::point(key: 'location', required: true)); + $database->createAttribute($collectionName, Attribute::lineString(key: 'route', required: $database->getAdapter()->supports(Capability::IndexSpatialNull) ? false : true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'area', required: $database->getAdapter()->supports(Capability::IndexSpatialNull) ? false : true)); + $database->createAttribute($collectionName, Attribute::string(key: 'name', size: 100)); // Create indexes for spatial queries - $database->createIndex($collectionName, 'location_idx', Database::INDEX_SPATIAL, ['location']); - $database->createIndex($collectionName, 'route_idx', Database::INDEX_SPATIAL, ['route']); - $database->createIndex($collectionName, 'area_idx', Database::INDEX_SPATIAL, ['area']); + $database->createIndex($collectionName, Index::spatial(key: 'location_idx', attribute: 'location')); + $database->createIndex($collectionName, Index::spatial(key: 'route_idx', attribute: 'route')); + $database->createIndex($collectionName, Index::spatial(key: 'area_idx', attribute: 'area')); // Create initial document with spatial arrays $initialPoint = [10.0, 20.0]; @@ -2890,7 +2716,7 @@ public function testSpatialArrayWKTConversionInUpdateDocument(): void 'location' => $initialPoint, 'route' => $initialLine, 'area' => $initialPolygon, - 'name' => 'Original' + 'name' => 'Original', ])); // Verify initial values @@ -2907,7 +2733,7 @@ public function testSpatialArrayWKTConversionInUpdateDocument(): void 'location' => $newPoint, 'route' => $newLine, 'area' => $newPolygon, - 'name' => 'Updated' + 'name' => 'Updated', ])); // Verify updated spatial values are correctly stored and retrieved @@ -2924,7 +2750,7 @@ public function testSpatialArrayWKTConversionInUpdateDocument(): void // Test spatial queries work with updated data $results = $database->find($collectionName, [ - Query::equal('location', [$newPoint]) + Query::equal('location', [$newPoint]), ]); $this->assertCount(1, $results, 'Should find document by exact point match'); $this->assertEquals('spatial_doc', $results[0]->getId()); @@ -2932,7 +2758,7 @@ public function testSpatialArrayWKTConversionInUpdateDocument(): void // Test mixed update (spatial + non-spatial attributes) $updated2 = $database->updateDocument($collectionName, 'spatial_doc', new Document([ 'location' => [50.0, 60.0], - 'name' => 'Mixed Update' + 'name' => 'Mixed Update', ])); $this->assertEquals([50.0, 60.0], $updated2->getAttribute('location')); $this->assertEquals('Mixed Update', $updated2->getAttribute('name')); @@ -2944,4 +2770,303 @@ public function testSpatialArrayWKTConversionInUpdateDocument(): void $database->deleteCollection($collectionName); } } + + public function testSpatialDistanceInMeterError(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Spatial::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + if ($this->supportsMultiDimensionDistance()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'spatial_distance_error_test'; + $database->createCollection(Collection::create(id: $collection)); + $database->createAttribute($collection, Attribute::point(key: 'loc', required: true)); + $database->createAttribute($collection, Attribute::lineString(key: 'line', required: true)); + $database->createAttribute($collection, Attribute::polygon(key: 'poly', required: true)); + + $document = $database->createDocument($collection, new Document([ + '$id' => 'doc1', + 'loc' => [0.0, 0.0], + 'line' => [[0.0, 0.0], [0.001, 0.0]], + 'poly' => [[[-0.001, -0.001], [-0.001, 0.001], [0.001, 0.001], [-0.001, -0.001]]], + '$permissions' => [], + ])); + $this->assertSame('doc1', $document->getId()); + + $cases = [ + ['attribute' => 'line', 'geometry' => [0.002, 0.0], 'expected' => ['linestring', 'point']], + ['attribute' => 'poly', 'geometry' => [0.002, 0.0], 'expected' => ['polygon', 'point']], + ['attribute' => 'loc', 'geometry' => [[0.0, 0.0], [0.001, 0.001]], 'expected' => ['point', 'linestring']], + ['attribute' => 'poly', 'geometry' => [[0.0, 0.0], [0.001, 0.001]], 'expected' => ['polygon', 'linestring']], + ['attribute' => 'loc', 'geometry' => [[[0.0, 0.0], [0.001, 0.0], [0.001, 0.001], [0.0, 0.0]]], 'expected' => ['point', 'polygon']], + ['attribute' => 'line', 'geometry' => [[[0.0, 0.0], [0.001, 0.0], [0.001, 0.001], [0.0, 0.0]]], 'expected' => ['linestring', 'polygon']], + ['attribute' => 'poly', 'geometry' => [[[0.002, -0.001], [0.002, 0.001], [0.004, 0.001], [0.002, -0.001]]], 'expected' => ['polygon', 'polygon']], + ['attribute' => 'line', 'geometry' => [[0.002, 0.0], [0.003, 0.0]], 'expected' => ['linestring', 'linestring']], + ]; + + foreach ($cases as $case) { + try { + $database->find($collection, [ + Query::distanceLessThan($case['attribute'], $case['geometry'], 1000, true), + ]); + $this->fail('Expected Exception not thrown for '.implode(' vs ', $case['expected'])); + } catch (\Exception $e) { + $this->assertInstanceOf(QueryException::class, $e); + + $message = strtolower($e->getMessage()); + $this->assertStringContainsString($case['expected'][0], $message, 'Attribute type missing in exception'); + $this->assertStringContainsString($case['expected'][1], $message, 'Geometry type missing in exception'); + } + } + + $database->deleteCollection($collection); + } + + public function testBatchSpatialAttributesMatchSingleCreation(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->hasFeature(Feature\Spatial::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'spatial_batch_definitions'; + $database->createCollection(Collection::create(id: $collection)); + + try { + $database->createAttribute($collection, Attribute::point(key: 'singlePoint', required: true)); + $database->createAttribute($collection, Attribute::lineString(key: 'singleRoute')); + $database->createAttribute($collection, Attribute::polygon(key: 'singleArea', required: true)); + + $database->createAttributes($collection, [ + Attribute::point(key: 'batchPoint', required: true), + Attribute::lineString(key: 'batchRoute'), + Attribute::polygon(key: 'batchArea', required: true), + ]); + + $shapes = [ + 'Point' => [1.5, 2.5], + 'Route' => [[0.0, 0.0], [1.0, 1.0]], + 'Area' => [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], + ]; + + $definitions = $this->getSpatialColumnDefinitions($database, $collection); + if ($definitions !== null) { + foreach (\array_keys($shapes) as $shape) { + $this->assertArrayHasKey('single'.$shape, $definitions); + $this->assertSame( + $definitions['single'.$shape], + $definitions['batch'.$shape] ?? null, + 'A '.$shape.' column created in a batch must have the definition createAttribute() gives it', + ); + } + } + + $values = [ + '$id' => 'shapes', + '$permissions' => [Permission::read(Role::any())], + ]; + foreach ($shapes as $shape => $value) { + $values['single'.$shape] = $value; + $values['batch'.$shape] = $value; + } + $database->createDocument($collection, new Document($values)); + + $document = $database->getDocument($collection, 'shapes'); + foreach ($shapes as $shape => $value) { + $this->assertSame($value, $document->getAttribute('batch'.$shape)); + $this->assertSame($document->getAttribute('single'.$shape), $document->getAttribute('batch'.$shape)); + } + } finally { + $database->deleteCollection($collection); + } + } + + public function testRequiredSpatialAttributesOnPopulatedCollection(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->hasFeature(Feature\Spatial::class) || ! $adapter->supports(Capability::IndexSpatialNull)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'spatial_required_populated'; + $database->createCollection(Collection::create(id: $collection)); + + try { + $database->createAttribute($collection, Attribute::string(key: 'name', size: 64)); + $database->createDocument($collection, new Document([ + '$id' => 'existing', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'created before the spatial attributes', + ])); + + $database->createAttribute($collection, Attribute::point(key: 'location', required: true)); + $database->createAttributes($collection, [ + Attribute::lineString(key: 'route', required: true), + Attribute::polygon(key: 'area', required: true), + ]); + + $definitions = $this->getSpatialColumnDefinitions($database, $collection); + if ($definitions !== null) { + foreach (['location', 'route', 'area'] as $key) { + $this->assertArrayHasKey($key, $definitions); + $this->assertStringNotContainsString('NOT NULL', $definitions[$key], 'A spatial column must stay nullable where the adapter indexes nullable spatial columns'); + } + } + + $existing = $database->getDocument($collection, 'existing'); + $this->assertSame('created before the spatial attributes', $existing->getAttribute('name')); + $this->assertNull($existing->getAttribute('location')); + $this->assertNull($existing->getAttribute('route')); + $this->assertNull($existing->getAttribute('area')); + + try { + $database->createDocument($collection, new Document([ + '$id' => 'incomplete', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'missing its required shapes', + ])); + $this->fail('A document without its required spatial attributes must be rejected'); + } catch (StructureException $e) { + $this->assertStringContainsString('Missing required attribute "location"', $e->getMessage()); + } + } finally { + $database->deleteCollection($collection); + } + } + + /** + * Physical definition of every column of a collection's table, keyed by column name. + * + * @return array|null null when the adapter's schema cannot be read here + */ + private function getSpatialColumnDefinitions(Database $database, string $collection): ?array + { + $adapter = $database->getAdapter(); + $table = $database->getNamespace().'_'.$collection; + + if ($adapter instanceof MariaDB) { + $rows = $adapter->rawQuery('SHOW CREATE TABLE `'.$database->getDatabase().'`.`'.$table.'`'); + $statement = $rows[0]->getAttribute('Create Table'); + $this->assertIsString($statement); + + $definitions = []; + foreach (\explode("\n", $statement) as $line) { + if (\preg_match('/^\s*`([^`]+)` (.+?),?$/', $line, $matches) === 1) { + $definitions[$matches[1]] = $matches[2]; + } + } + + return $definitions; + } + + if ($adapter instanceof Postgres) { + $rows = $adapter->rawQuery( + 'SELECT a.attname AS "column", format_type(a.atttypid, a.atttypmod) || CASE WHEN a.attnotnull THEN \' NOT NULL\' ELSE \' NULL\' END AS "definition" + FROM pg_attribute a + WHERE a.attrelid = to_regclass(?) AND a.attnum > 0 AND NOT a.attisdropped', + ['"'.$database->getDatabase().'"."'.$table.'"'], + ); + + $definitions = []; + foreach ($rows as $row) { + $column = $row->getAttribute('column'); + $definition = $row->getAttribute('definition'); + $this->assertIsString($column); + $this->assertIsString($definition); + $definitions[$column] = $definition; + } + + return $definitions; + } + + return null; + } + + public function testCreateCollectionWithRequiredSpatialColumns(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->hasFeature(Feature\Spatial::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $shapes = [ + 'location' => [1.5, 2.5], + 'route' => [[0.0, 0.0], [1.0, 1.0]], + 'area' => [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], + ]; + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + + $declared = 'spatial_required_declared'; + $database->createCollection(Collection::create(id: $declared, attributes: [ + Attribute::point(key: 'location', required: true), + Attribute::lineString(key: 'route', required: true), + Attribute::polygon(key: 'area', required: true), + ], permissions: $permissions)); + + $updated = 'spatial_required_updated'; + $database->createCollection(Collection::create(id: $updated, attributes: [ + Attribute::point(key: 'location'), + Attribute::lineString(key: 'route'), + Attribute::polygon(key: 'area'), + ], permissions: $permissions)); + + try { + foreach (\array_keys($shapes) as $key) { + $database->updateAttribute($updated, $key, new AttributeUpdate(required: true)); + } + + foreach ([$declared, $updated] as $collection) { + foreach (['route', 'area'] as $missing) { + $values = $shapes; + unset($values[$missing]); + + try { + $database->createDocument($collection, new Document(['$permissions' => [Permission::read(Role::any())], ...$values])); + $this->fail('A document without the required '.$missing.' of '.$collection.' must be rejected'); + } catch (StructureException $e) { + $this->assertStringContainsString('Missing required attribute "'.$missing.'"', $e->getMessage()); + } + } + + $database->createDocument($collection, new Document(['$id' => 'complete', '$permissions' => [Permission::read(Role::any())], ...$shapes])); + $this->assertSame($shapes['area'], $database->getDocument($collection, 'complete')->getAttribute('area')); + + if ($adapter->supports(Capability::IndexSpatialNull) || ! $adapter->supports(Capability::SchemaIntrospection)) { + continue; + } + + $nullable = []; + foreach ($database->getSchemaAttributes($collection) as $column) { + $nullable[$column->name] = $column->nullable; + } + foreach (\array_keys($shapes) as $key) { + $this->assertFalse($nullable[$key] ?? true, 'The required '.$key.' column of '.$collection.' must be NOT NULL'); + } + } + } finally { + $database->deleteCollection($declared); + $database->deleteCollection($updated); + } + } } diff --git a/tests/e2e/Adapter/Scopes/VectorTests.php b/tests/e2e/Adapter/Scopes/VectorTests.php index 3d229c3e18..eeffdd3a09 100644 --- a/tests/e2e/Adapter/Scopes/VectorTests.php +++ b/tests/e2e/Adapter/Scopes/VectorTests.php @@ -2,13 +2,21 @@ namespace Tests\E2E\Adapter\Scopes; +use Utopia\Database\Attribute; +use Utopia\Database\AttributeUpdate; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Structure as StructureException; +use Utopia\Database\Index; +use Utopia\Database\Permission; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; +use Utopia\Database\Relationship; +use Utopia\Database\Role; +use Utopia\Query\Schema\ColumnType; trait VectorTests { @@ -17,125 +25,85 @@ public function testVectorAttributes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } // Test that vector attributes can only be created on PostgreSQL - $database->createCollection('vectorCollection'); + $database->createCollection(Collection::create(id: 'vectorCollection')); // Create a vector attribute with 3 dimensions - $database->createAttribute('vectorCollection', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createAttribute('vectorCollection', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create a vector attribute with 128 dimensions - $database->createAttribute('vectorCollection', 'large_embedding', Database::VAR_VECTOR, 128, false, null); + $database->createAttribute('vectorCollection', Attribute::vector(key: 'large_embedding', dimensions: 128)); // Verify the attributes were created $collection = $database->getCollection('vectorCollection'); - $attributes = $collection->getAttribute('attributes'); + $attributes = $collection->attributes(); $embeddingAttr = null; $largeEmbeddingAttr = null; - foreach ($attributes as $attr) { - if ($attr['key'] === 'embedding') { - $embeddingAttr = $attr; - } elseif ($attr['key'] === 'large_embedding') { - $largeEmbeddingAttr = $attr; + foreach ($attributes as $attribute) { + if ($attribute->key === 'embedding') { + $embeddingAttr = $attribute; + } elseif ($attribute->key === 'large_embedding') { + $largeEmbeddingAttr = $attribute; } } - $this->assertNotNull($embeddingAttr); - $this->assertNotNull($largeEmbeddingAttr); - $this->assertEquals(Database::VAR_VECTOR, $embeddingAttr['type']); - $this->assertEquals(3, $embeddingAttr['size']); - $this->assertEquals(Database::VAR_VECTOR, $largeEmbeddingAttr['type']); - $this->assertEquals(128, $largeEmbeddingAttr['size']); + $this->assertInstanceOf(Attribute::class, $embeddingAttr); + $this->assertInstanceOf(Attribute::class, $largeEmbeddingAttr); + $this->assertEquals(ColumnType::Vector, $embeddingAttr->type); + $this->assertEquals(3, $embeddingAttr->size); + $this->assertEquals(ColumnType::Vector, $largeEmbeddingAttr->type); + $this->assertEquals(128, $largeEmbeddingAttr->size); // Cleanup $database->deleteCollection('vectorCollection'); } - public function testVectorInvalidDimensions(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorErrorCollection'); - - // Test invalid dimensions - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Vector dimensions must be a positive integer'); - $database->createAttribute('vectorErrorCollection', 'bad_embedding', Database::VAR_VECTOR, 0, true); - - // Cleanup - $database->deleteCollection('vectorErrorCollection'); - } - - public function testVectorTooManyDimensions(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorLimitCollection'); - - // Test too many dimensions (pgvector limit is 16000) - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Vector dimensions cannot exceed 16000'); - $database->createAttribute('vectorLimitCollection', 'huge_embedding', Database::VAR_VECTOR, 16001, true); - - // Cleanup - $database->deleteCollection('vectorLimitCollection'); - } - public function testVectorDocuments(): void { /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorDocuments'); - $database->createAttribute('vectorDocuments', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorDocuments', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorDocuments')); + $database->createAttribute('vectorDocuments', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorDocuments', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create documents with vector data $doc1 = $database->createDocument('vectorDocuments', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Document 1', - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $doc2 = $database->createDocument('vectorDocuments', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Document 2', - 'embedding' => [0.0, 1.0, 0.0] + 'embedding' => [0.0, 1.0, 0.0], ])); $doc3 = $database->createDocument('vectorDocuments', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Document 3', - 'embedding' => [0.0, 0.0, 1.0] + 'embedding' => [0.0, 0.0, 1.0], ])); $this->assertNotEmpty($doc1->getId()); @@ -155,38 +123,39 @@ public function testVectorQueries(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorQueries'); - $database->createAttribute('vectorQueries', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorQueries', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorQueries')); + $database->createAttribute('vectorQueries', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorQueries', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create test documents with read permissions $doc1 = $database->createDocument('vectorQueries', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Test 1', - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $doc2 = $database->createDocument('vectorQueries', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Test 2', - 'embedding' => [0.0, 1.0, 0.0] + 'embedding' => [0.0, 1.0, 0.0], ])); $doc3 = $database->createDocument('vectorQueries', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Test 3', - 'embedding' => [0.5, 0.5, 0.0] + 'embedding' => [0.5, 0.5, 0.0], ])); // Verify documents were created @@ -196,12 +165,12 @@ public function testVectorQueries(): void // Test without vector queries first $allDocs = $database->find('vectorQueries'); - $this->assertCount(3, $allDocs, "Should have 3 documents in collection"); + $this->assertCount(3, $allDocs, 'Should have 3 documents in collection'); // Test vector dot product query $results = $database->find('vectorQueries', [ Query::vectorDot('embedding', [1.0, 0.0, 0.0]), - Query::orderAsc('$id') + Query::orderAsc('$id'), ]); $this->assertCount(3, $results); @@ -209,7 +178,7 @@ public function testVectorQueries(): void // Test vector cosine distance query $results = $database->find('vectorQueries', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::orderAsc('$id') + Query::orderAsc('$id'), ]); $this->assertCount(3, $results); @@ -217,7 +186,7 @@ public function testVectorQueries(): void // Test vector euclidean distance query $results = $database->find('vectorQueries', [ Query::vectorEuclidean('embedding', [1.0, 0.0, 0.0]), - Query::orderAsc('$id') + Query::orderAsc('$id'), ]); $this->assertCount(3, $results); @@ -225,7 +194,7 @@ public function testVectorQueries(): void // Test vector queries with limit - should return only top results $results = $database->find('vectorQueries', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(2) + Query::limit(2), ]); $this->assertCount(2, $results); @@ -235,7 +204,7 @@ public function testVectorQueries(): void // Test vector query with limit of 1 $results = $database->find('vectorQueries', [ Query::vectorDot('embedding', [0.0, 1.0, 0.0]), - Query::limit(1) + Query::limit(1), ]); $this->assertCount(1, $results); @@ -244,7 +213,7 @@ public function testVectorQueries(): void // Test vector query combined with other filters $results = $database->find('vectorQueries', [ Query::vectorCosine('embedding', [0.5, 0.5, 0.0]), - Query::notEqual('name', 'Test 1') + Query::notEqual('name', 'Test 1'), ]); $this->assertCount(2, $results); @@ -256,7 +225,7 @@ public function testVectorQueries(): void // Test vector query with specific name filter $results = $database->find('vectorQueries', [ Query::vectorEuclidean('embedding', [0.7, 0.7, 0.0]), - Query::equal('name', ['Test 3']) + Query::equal('name', ['Test 3']), ]); $this->assertCount(1, $results); @@ -266,7 +235,7 @@ public function testVectorQueries(): void $results = $database->find('vectorQueries', [ Query::vectorDot('embedding', [0.5, 0.5, 0.0]), Query::limit(2), - Query::offset(1) + Query::offset(1), ]); $this->assertCount(2, $results); @@ -276,7 +245,7 @@ public function testVectorQueries(): void $results = $database->find('vectorQueries', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::equal('name', ['Test 2']), - Query::equal('name', ['Test 3']) // Impossible condition + Query::equal('name', ['Test 3']), // Impossible condition ]); $this->assertCount(0, $results); @@ -286,7 +255,7 @@ public function testVectorQueries(): void $results = $database->find('vectorQueries', [ Query::vectorDot('embedding', [0.4, 0.6, 0.0]), Query::orderDesc('name'), - Query::limit(2) + Query::limit(2), ]); $this->assertCount(2, $results); @@ -302,234 +271,55 @@ public function testVectorQueries(): void $database->deleteCollection('vectorQueries'); } - public function testVectorDistance(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorDistance'); - $database->createAttribute('vectorDistance', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorDistance', 'embedding', Database::VAR_VECTOR, 3, true); - - $vectors = [ - 'identical' => [1.0, 0.0, 0.0], - 'scaled' => [2.0, 0.0, 0.0], - 'orthogonal' => [0.0, 1.0, 0.0], - 'opposite' => [-1.0, 0.0, 0.0], - ]; - - foreach ($vectors as $name => $embedding) { - $database->createDocument('vectorDistance', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'name' => $name, - 'embedding' => $embedding, - ])); - } - - $target = [1.0, 0.0, 0.0]; - - $results = $database->find('vectorDistance', [ - Query::vectorCosine('embedding', $target) - ]); - - $this->assertCount(4, $results); - - $cosine = []; - foreach ($results as $result) { - $distance = $result->getAttribute(Database::VECTOR_DISTANCE); - - $this->assertIsFloat($distance, "Cosine distance for '{$result->getAttribute('name')}' must be a float"); - - $cosine[$result->getAttribute('name')] = $distance; - } - - // Cosine distance is 1 - cosine similarity, so magnitude is irrelevant - $this->assertEqualsWithDelta(0.0, $cosine['identical'], 0.000001, 'Identical vector must have zero cosine distance'); - $this->assertEqualsWithDelta(0.0, $cosine['scaled'], 0.000001, 'Cosine distance must ignore magnitude'); - $this->assertEqualsWithDelta(1.0, $cosine['orthogonal'], 0.000001, 'Orthogonal vector must have cosine distance of 1'); - $this->assertEqualsWithDelta(2.0, $cosine['opposite'], 0.000001, 'Opposite vector must have cosine distance of 2'); - - // The returned distance must agree with the order the rows came back in, - // otherwise a caller ranking by the number would disagree with the database - $distances = \array_map( - fn (Document $result) => $result->getAttribute(Database::VECTOR_DISTANCE), - $results - ); - - $sorted = $distances; - \sort($sorted); - $this->assertSame($sorted, $distances, 'Results must be returned in ascending distance order'); - - // Cosine similarity, which is what a caller displays as a relevance score - $this->assertEqualsWithDelta(1.0, 1 - $cosine['identical'], 0.000001); - $this->assertEqualsWithDelta(0.0, 1 - $cosine['orthogonal'], 0.000001); - $this->assertEqualsWithDelta(-1.0, 1 - $cosine['opposite'], 0.000001); - - $results = $database->find('vectorDistance', [ - Query::vectorEuclidean('embedding', $target) - ]); - - $euclidean = []; - foreach ($results as $result) { - $euclidean[$result->getAttribute('name')] = $result->getAttribute(Database::VECTOR_DISTANCE); - } - - $this->assertEqualsWithDelta(0.0, $euclidean['identical'], 0.000001, 'Identical vector must have zero euclidean distance'); - $this->assertEqualsWithDelta(1.0, $euclidean['scaled'], 0.000001, 'Euclidean distance must account for magnitude'); - $this->assertEqualsWithDelta(\sqrt(2), $euclidean['orthogonal'], 0.000001); - $this->assertEqualsWithDelta(2.0, $euclidean['opposite'], 0.000001); - - // The dot product operator returns the negative inner product so that - // ascending order still means most similar first - $results = $database->find('vectorDistance', [ - Query::vectorDot('embedding', $target) - ]); - - $dot = []; - foreach ($results as $result) { - $dot[$result->getAttribute('name')] = $result->getAttribute(Database::VECTOR_DISTANCE); - } - - $this->assertEqualsWithDelta(-1.0, $dot['identical'], 0.000001); - $this->assertEqualsWithDelta(-2.0, $dot['scaled'], 0.000001); - $this->assertEqualsWithDelta(0.0, $dot['orthogonal'], 0.000001); - $this->assertEqualsWithDelta(1.0, $dot['opposite'], 0.000001); - - // A distance is only meaningful relative to a query vector, so a plain - // find must not carry one - $results = $database->find('vectorDistance'); - - $this->assertCount(4, $results); - foreach ($results as $result) { - $this->assertNull( - $result->getAttribute(Database::VECTOR_DISTANCE), - 'A find without a vector query must not return a distance' - ); - } - - // Selecting a subset of attributes builds a different projection - $results = $database->find('vectorDistance', [ - Query::select(['name']), - Query::vectorCosine('embedding', $target), - Query::limit(1), - ]); - - $this->assertCount(1, $results); - $this->assertSame('identical', $results[0]->getAttribute('name')); - $this->assertEqualsWithDelta(0.0, $results[0]->getAttribute(Database::VECTOR_DISTANCE), 0.000001); - - // Cosine distance to a zero vector divides by a zero magnitude, so the engine answers - // NaN. That has no honest float representation and must not read back as 0.0, which - // would claim the pair is identical - $database->createDocument('vectorDistance', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'name' => 'zero', - 'embedding' => [0.0, 0.0, 0.0], - ])); - - $results = $database->find('vectorDistance', [ - Query::vectorCosine('embedding', $target) - ]); - - $this->assertCount(5, $results); - - $zero = null; - foreach ($results as $result) { - if ($result->getAttribute('name') === 'zero') { - $zero = $result; - } - } - - $this->assertNotNull($zero, 'The zero vector must still be returned'); - $this->assertNull( - $zero->getAttribute(Database::VECTOR_DISTANCE), - 'An undefined distance must be null, not a number' - ); - - $database->deleteCollection('vectorDistance'); - } - - public function testVectorQueryValidation(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorValidation'); - $database->createAttribute('vectorValidation', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorValidation', 'name', Database::VAR_STRING, 255, true); - - // Test that vector queries fail on non-vector attributes - $this->expectException(DatabaseException::class); - $database->find('vectorValidation', [ - Query::vectorDot('name', [1.0, 0.0, 0.0]) - ]); - - // Cleanup - $database->deleteCollection('vectorValidation'); - } - public function testVectorIndexes(): void { /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorIndexes'); - $database->createAttribute('vectorIndexes', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorIndexes')); + $database->createAttribute('vectorIndexes', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create different types of vector indexes // Euclidean distance index (L2 distance) - $database->createIndex('vectorIndexes', 'embedding_euclidean', Database::INDEX_HNSW_EUCLIDEAN, ['embedding']); + $database->createIndex('vectorIndexes', Index::hnswEuclidean(key: 'embedding_euclidean', attribute: 'embedding')); // Cosine distance index - $database->createIndex('vectorIndexes', 'embedding_cosine', Database::INDEX_HNSW_COSINE, ['embedding']); + $database->createIndex('vectorIndexes', Index::hnswCosine(key: 'embedding_cosine', attribute: 'embedding')); // Inner product (dot product) index - $database->createIndex('vectorIndexes', 'embedding_dot', Database::INDEX_HNSW_DOT, ['embedding']); + $database->createIndex('vectorIndexes', Index::hnswDot(key: 'embedding_dot', attribute: 'embedding')); // Verify indexes were created $collection = $database->getCollection('vectorIndexes'); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(3, $indexes); // Test that queries work with indexes $database->createDocument('vectorIndexes', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $database->createDocument('vectorIndexes', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [0.0, 1.0, 0.0] + 'embedding' => [0.0, 1.0, 0.0], ])); // Query should use the appropriate index based on the operator $results = $database->find('vectorIndexes', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(1) + Query::limit(1), ]); $this->assertCount(1, $results); @@ -543,70 +333,28 @@ public function testVectorDimensionMismatch(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorDimMismatch'); - $database->createAttribute('vectorDimMismatch', 'embedding', Database::VAR_VECTOR, 3, true); - - // Test creating document with wrong dimension count - $this->expectException(DatabaseException::class); - $this->expectExceptionMessageMatches('/must be an array of 3 numeric values/'); - - $database->createDocument('vectorDimMismatch', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [1.0, 0.0] // Only 2 dimensions, expects 3 - ])); - // Cleanup - $database->deleteCollection('vectorDimMismatch'); - } - - public function testVectorWithInvalidDataTypes(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); return; } - $database->createCollection('vectorInvalidTypes'); - $database->createAttribute('vectorInvalidTypes', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorDimMismatch')); + $database->createAttribute('vectorDimMismatch', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); - // Test with string values in vector try { - $database->createDocument('vectorInvalidTypes', new Document([ + $database->createDocument('vectorDimMismatch', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => ['one', 'two', 'three'] + 'embedding' => [1.0, 0.0], ])); - $this->fail('Should have thrown exception for non-numeric vector values'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric values', strtolower($e->getMessage())); + $this->fail('A two-element embedding must be rejected by a three-dimension vector attribute'); + } catch (DatabaseException $exception) { + $this->assertMatchesRegularExpression('/must be an array of 3 numeric values/', $exception->getMessage()); + } finally { + $database->deleteCollection('vectorDimMismatch'); } - - // Test with mixed types - try { - $database->createDocument('vectorInvalidTypes', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [1.0, 'two', 3.0] - ])); - $this->fail('Should have thrown exception for mixed type vector values'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric values', strtolower($e->getMessage())); - } - - // Cleanup - $database->deleteCollection('vectorInvalidTypes'); } public function testVectorWithNullAndEmpty(): void @@ -614,20 +362,21 @@ public function testVectorWithNullAndEmpty(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorNullEmpty'); - $database->createAttribute('vectorNullEmpty', 'embedding', Database::VAR_VECTOR, 3, false); // Not required + $database->createCollection(Collection::create(id: 'vectorNullEmpty')); + $database->createAttribute('vectorNullEmpty', Attribute::vector(key: 'embedding', dimensions: 3)); // Not required // Test with null vector (should work for non-required attribute) $doc1 = $database->createDocument('vectorNullEmpty', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => null + 'embedding' => null, ])); $this->assertNull($doc1->getAttribute('embedding')); @@ -636,9 +385,9 @@ public function testVectorWithNullAndEmpty(): void try { $database->createDocument('vectorNullEmpty', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [] + 'embedding' => [], ])); $this->fail('Should have thrown exception for empty vector'); } catch (DatabaseException $e) { @@ -654,14 +403,15 @@ public function testLargeVectors(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } // Test with maximum allowed dimensions (16000 for pgvector) - $database->createCollection('vectorLarge'); - $database->createAttribute('vectorLarge', 'embedding', Database::VAR_VECTOR, 1536, true); // Common embedding size + $database->createCollection(Collection::create(id: 'vectorLarge')); + $database->createAttribute('vectorLarge', Attribute::vector(key: 'embedding', dimensions: 1536, required: true)); // Common embedding size // Create a large vector $largeVector = array_fill(0, 1536, 0.1); @@ -669,20 +419,22 @@ public function testLargeVectors(): void $doc = $database->createDocument('vectorLarge', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => $largeVector + 'embedding' => $largeVector, ])); - $this->assertCount(1536, $doc->getAttribute('embedding')); - $this->assertEquals(1.0, $doc->getAttribute('embedding')[0]); + $embedding = $doc->getAttribute('embedding'); + $this->assertIsArray($embedding); + $this->assertCount(1536, $embedding); + $this->assertEquals(1.0, $embedding[0]); // Test vector search on large vectors $searchVector = array_fill(0, 1536, 0.0); $searchVector[0] = 1.0; $results = $database->find('vectorLarge', [ - Query::vectorCosine('embedding', $searchVector) + Query::vectorCosine('embedding', $searchVector), ]); $this->assertCount(1, $results); @@ -696,35 +448,36 @@ public function testVectorUpdates(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorUpdates'); - $database->createAttribute('vectorUpdates', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorUpdates')); + $database->createAttribute('vectorUpdates', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create initial document $doc = $database->createDocument('vectorUpdates', new Document([ '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) + Permission::update(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $this->assertEquals([1.0, 0.0, 0.0], $doc->getAttribute('embedding')); // Update the vector $updated = $database->updateDocument('vectorUpdates', $doc->getId(), new Document([ - 'embedding' => [0.0, 1.0, 0.0] + 'embedding' => [0.0, 1.0, 0.0], ])); $this->assertEquals([0.0, 1.0, 0.0], $updated->getAttribute('embedding')); // Test partial update (should replace entire vector) $updated2 = $database->updateDocument('vectorUpdates', $doc->getId(), new Document([ - 'embedding' => [0.5, 0.5, 0.5] + 'embedding' => [0.5, 0.5, 0.5], ])); $this->assertEquals([0.5, 0.5, 0.5], $updated2->getAttribute('embedding')); @@ -738,38 +491,39 @@ public function testMultipleVectorAttributes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('multiVector'); - $database->createAttribute('multiVector', 'embedding1', Database::VAR_VECTOR, 3, true); - $database->createAttribute('multiVector', 'embedding2', Database::VAR_VECTOR, 5, true); - $database->createAttribute('multiVector', 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'multiVector')); + $database->createAttribute('multiVector', Attribute::vector(key: 'embedding1', dimensions: 3, required: true)); + $database->createAttribute('multiVector', Attribute::vector(key: 'embedding2', dimensions: 5, required: true)); + $database->createAttribute('multiVector', Attribute::string(key: 'name', required: true)); // Create documents with multiple vector attributes $doc1 = $database->createDocument('multiVector', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Doc 1', 'embedding1' => [1.0, 0.0, 0.0], - 'embedding2' => [1.0, 0.0, 0.0, 0.0, 0.0] + 'embedding2' => [1.0, 0.0, 0.0, 0.0, 0.0], ])); $doc2 = $database->createDocument('multiVector', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Doc 2', 'embedding1' => [0.0, 1.0, 0.0], - 'embedding2' => [0.0, 1.0, 0.0, 0.0, 0.0] + 'embedding2' => [0.0, 1.0, 0.0, 0.0, 0.0], ])); // Query by first vector $results = $database->find('multiVector', [ - Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]), ]); $this->assertCount(2, $results); @@ -777,7 +531,7 @@ public function testMultipleVectorAttributes(): void // Query by second vector $results = $database->find('multiVector', [ - Query::vectorCosine('embedding2', [0.0, 1.0, 0.0, 0.0, 0.0]) + Query::vectorCosine('embedding2', [0.0, 1.0, 0.0, 0.0, 0.0]), ]); $this->assertCount(2, $results); @@ -792,29 +546,40 @@ public function testVectorQueriesWithPagination(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorPagination'); - $database->createAttribute('vectorPagination', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorPagination', 'index', Database::VAR_INTEGER, 0, true); + $database->createCollection(Collection::create(id: 'vectorPagination')); + $database->createAttribute('vectorPagination', Attribute::vector(key: 'embedding', dimensions: 3)); + $database->createAttribute('vectorPagination', Attribute::integer(key: 'index', required: true)); - // Create 10 documents - for ($i = 0; $i < 10; $i++) { + // Insert documents in an order deliberately unrelated to vector rank. + // Cursor pagination must continue from distance + sequence, not from + // sequence alone. + $ranks = [6, 1, 8, 3, 0, 9, 4, 2, 7, 5]; + foreach ($ranks as $rank) { $database->createDocument('vectorPagination', new Document([ + '$id' => "rank-{$rank}", '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'index' => $i, + 'index' => $rank, 'embedding' => [ - cos($i * M_PI / 10), - sin($i * M_PI / 10), - 0.0 - ] + cos($rank * M_PI / 10), + sin($rank * M_PI / 10), + 0.0, + ], ])); } + $database->createDocument('vectorPagination', new Document([ + '$id' => 'rank-null', + '$permissions' => [Permission::read(Role::any())], + 'index' => -1, + 'embedding' => null, + ])); // Test pagination with vector queries $searchVector = [1.0, 0.0, 0.0]; @@ -823,7 +588,7 @@ public function testVectorQueriesWithPagination(): void $page1 = $database->find('vectorPagination', [ Query::vectorCosine('embedding', $searchVector), Query::limit(3), - Query::offset(0) + Query::offset(0), ]); $this->assertCount(3, $page1); @@ -832,7 +597,7 @@ public function testVectorQueriesWithPagination(): void $page2 = $database->find('vectorPagination', [ Query::vectorCosine('embedding', $searchVector), Query::limit(3), - Query::offset(3) + Query::offset(3), ]); $this->assertCount(3, $page2); @@ -841,11 +606,12 @@ public function testVectorQueriesWithPagination(): void $page1Ids = array_map(fn ($doc) => $doc->getId(), $page1); $page2Ids = array_map(fn ($doc) => $doc->getId(), $page2); $this->assertEmpty(array_intersect($page1Ids, $page2Ids)); + $this->assertNotContains('rank-null', [...$page1Ids, ...$page2Ids]); // Test with cursor pagination $firstBatch = $database->find('vectorPagination', [ Query::vectorCosine('embedding', $searchVector), - Query::limit(5) + Query::limit(5), ]); $this->assertCount(5, $firstBatch); @@ -854,11 +620,18 @@ public function testVectorQueriesWithPagination(): void $nextBatch = $database->find('vectorPagination', [ Query::vectorCosine('embedding', $searchVector), Query::cursorAfter($lastDoc), - Query::limit(5) + Query::limit(5), ]); $this->assertCount(5, $nextBatch); $this->assertNotEquals($lastDoc->getId(), $nextBatch[0]->getId()); + $cursorIds = array_map(fn ($document) => $document->getId(), [...$firstBatch, ...$nextBatch]); + $this->assertCount(10, array_unique($cursorIds)); + $this->assertNotContains('rank-null', $cursorIds); + $this->assertSame( + array_map(fn (int $rank): string => "rank-{$rank}", range(0, 9)), + $cursorIds, + ); // Cleanup $database->deleteCollection('vectorPagination'); @@ -869,18 +642,19 @@ public function testCombinedVectorAndTextSearch(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorTextSearch'); - $database->createAttribute('vectorTextSearch', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorTextSearch', 'category', Database::VAR_STRING, 50, true); - $database->createAttribute('vectorTextSearch', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorTextSearch')); + $database->createAttribute('vectorTextSearch', Attribute::string(key: 'title', required: true)); + $database->createAttribute('vectorTextSearch', Attribute::string(key: 'category', size: 50, required: true)); + $database->createAttribute('vectorTextSearch', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create fulltext index for title - $database->createIndex('vectorTextSearch', 'title_fulltext', Database::INDEX_FULLTEXT, ['title']); + $database->createIndex('vectorTextSearch', Index::fulltext(key: 'title_fulltext', attributes: ['title'])); // Create test documents $docs = [ @@ -894,9 +668,9 @@ public function testCombinedVectorAndTextSearch(): void foreach ($docs as $doc) { $database->createDocument('vectorTextSearch', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - ...$doc + ...$doc, ])); } @@ -904,7 +678,7 @@ public function testCombinedVectorAndTextSearch(): void $results = $database->find('vectorTextSearch', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::equal('category', ['AI']), - Query::limit(2) + Query::limit(2), ]); $this->assertCount(2, $results); @@ -915,19 +689,21 @@ public function testCombinedVectorAndTextSearch(): void $results = $database->find('vectorTextSearch', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::search('title', 'Learning'), - Query::limit(5) + Query::limit(5), ]); $this->assertCount(2, $results); foreach ($results as $result) { - $this->assertStringContainsString('Learning', $result->getAttribute('title')); + $title = $result->getAttribute('title'); + $this->assertIsString($title); + $this->assertStringContainsString('Learning', $title); } // Complex query with multiple filters $results = $database->find('vectorTextSearch', [ Query::vectorEuclidean('embedding', [0.5, 0.5, 0.0]), Query::notEqual('category', ['Web']), - Query::limit(3) + Query::limit(3), ]); $this->assertCount(3, $results); @@ -944,57 +720,58 @@ public function testVectorSpecialFloatValues(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorSpecialFloats'); - $database->createAttribute('vectorSpecialFloats', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorSpecialFloats')); + $database->createAttribute('vectorSpecialFloats', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Test with very small values (near zero) $doc1 = $database->createDocument('vectorSpecialFloats', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1e-10, 1e-10, 1e-10] + 'embedding' => [1e-10, 1e-10, 1e-10], ])); - $this->assertNotNull($doc1->getId()); + $this->assertNotSame('', $doc1->getId()); // Test with very large values $doc2 = $database->createDocument('vectorSpecialFloats', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1e10, 1e10, 1e10] + 'embedding' => [1e10, 1e10, 1e10], ])); - $this->assertNotNull($doc2->getId()); + $this->assertNotSame('', $doc2->getId()); // Test with negative values $doc3 = $database->createDocument('vectorSpecialFloats', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [-1.0, -0.5, -0.1] + 'embedding' => [-1.0, -0.5, -0.1], ])); - $this->assertNotNull($doc3->getId()); + $this->assertNotSame('', $doc3->getId()); // Test with mixed sign values $doc4 = $database->createDocument('vectorSpecialFloats', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [-1.0, 0.0, 1.0] + 'embedding' => [-1.0, 0.0, 1.0], ])); - $this->assertNotNull($doc4->getId()); + $this->assertNotSame('', $doc4->getId()); // Query with negative vector $results = $database->find('vectorSpecialFloats', [ - Query::vectorCosine('embedding', [-1.0, -1.0, -1.0]) + Query::vectorCosine('embedding', [-1.0, -1.0, -1.0]), ]); $this->assertGreaterThan(0, count($results)); @@ -1008,14 +785,15 @@ public function testVectorIndexPerformance(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorPerf'); - $database->createAttribute('vectorPerf', 'embedding', Database::VAR_VECTOR, 128, true); - $database->createAttribute('vectorPerf', 'name', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'vectorPerf')); + $database->createAttribute('vectorPerf', Attribute::vector(key: 'embedding', dimensions: 128, required: true)); + $database->createAttribute('vectorPerf', Attribute::string(key: 'name', required: true)); // Create documents $numDocs = 100; @@ -1027,10 +805,10 @@ public function testVectorIndexPerformance(): void $database->createDocument('vectorPerf', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => "Doc $i", - 'embedding' => $vector + 'embedding' => $vector, ])); } @@ -1040,20 +818,20 @@ public function testVectorIndexPerformance(): void $startTime = microtime(true); $results1 = $database->find('vectorPerf', [ Query::vectorCosine('embedding', $searchVector), - Query::limit(10) + Query::limit(10), ]); $timeWithoutIndex = microtime(true) - $startTime; $this->assertCount(10, $results1); // Create HNSW index - $database->createIndex('vectorPerf', 'embedding_hnsw', Database::INDEX_HNSW_COSINE, ['embedding']); + $database->createIndex('vectorPerf', Index::hnswCosine(key: 'embedding_hnsw', attribute: 'embedding')); // Query with index (should be faster for larger datasets) $startTime = microtime(true); $results2 = $database->find('vectorPerf', [ Query::vectorCosine('embedding', $searchVector), - Query::limit(10) + Query::limit(10), ]); $timeWithIndex = microtime(true) - $startTime; @@ -1069,83 +847,38 @@ public function testVectorIndexPerformance(): void $database->deleteCollection('vectorPerf'); } - public function testVectorQueryValidationExtended(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorValidation2'); - $database->createAttribute('vectorValidation2', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorValidation2', 'text', Database::VAR_STRING, 255, true); - - $database->createDocument('vectorValidation2', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'text' => 'Test', - 'embedding' => [1.0, 0.0, 0.0] - ])); - - // Test vector query with wrong dimension count - try { - $database->find('vectorValidation2', [ - Query::vectorCosine('embedding', [1.0, 0.0]) // Wrong dimension - ]); - $this->fail('Should have thrown exception for dimension mismatch'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('elements', strtolower($e->getMessage())); - } - - // Test vector query on non-vector attribute - try { - $database->find('vectorValidation2', [ - Query::vectorCosine('text', [1.0, 0.0, 0.0]) - ]); - $this->fail('Should have thrown exception for non-vector attribute'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('vector', strtolower($e->getMessage())); - } - - // Cleanup - $database->deleteCollection('vectorValidation2'); - } - public function testVectorNormalization(): void { /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorNorm'); - $database->createAttribute('vectorNorm', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorNorm')); + $database->createAttribute('vectorNorm', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create documents with normalized and non-normalized vectors $doc1 = $database->createDocument('vectorNorm', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] // Already normalized + 'embedding' => [1.0, 0.0, 0.0], // Already normalized ])); $doc2 = $database->createDocument('vectorNorm', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [3.0, 4.0, 0.0] // Not normalized (magnitude = 5) + 'embedding' => [3.0, 4.0, 0.0], // Not normalized (magnitude = 5) ])); // Cosine similarity should work regardless of normalization $results = $database->find('vectorNorm', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), ]); $this->assertCount(2, $results); @@ -1163,21 +896,22 @@ public function testVectorWithInfinityValues(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorInfinity'); - $database->createAttribute('vectorInfinity', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorInfinity')); + $database->createAttribute('vectorInfinity', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Test with INF value - should fail try { $database->createDocument('vectorInfinity', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [INF, 0.0, 0.0] + 'embedding' => [INF, 0.0, 0.0], ])); $this->fail('Should have thrown exception for INF value'); } catch (DatabaseException $e) { @@ -1188,9 +922,9 @@ public function testVectorWithInfinityValues(): void try { $database->createDocument('vectorInfinity', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [-INF, 0.0, 0.0] + 'embedding' => [-INF, 0.0, 0.0], ])); $this->fail('Should have thrown exception for -INF value'); } catch (DatabaseException $e) { @@ -1206,21 +940,22 @@ public function testVectorWithNaNValues(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorNaN'); - $database->createAttribute('vectorNaN', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorNaN')); + $database->createAttribute('vectorNaN', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Test with NaN value - should fail try { $database->createDocument('vectorNaN', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [NAN, 0.0, 0.0] + 'embedding' => [NAN, 0.0, 0.0], ])); $this->fail('Should have thrown exception for NaN value'); } catch (DatabaseException $e) { @@ -1231,170 +966,42 @@ public function testVectorWithNaNValues(): void $database->deleteCollection('vectorNaN'); } - public function testVectorWithAssociativeArray(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorAssoc'); - $database->createAttribute('vectorAssoc', 'embedding', Database::VAR_VECTOR, 3, true); - - // Test with associative array - should fail - try { - $database->createDocument('vectorAssoc', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => ['x' => 1.0, 'y' => 0.0, 'z' => 0.0] - ])); - $this->fail('Should have thrown exception for associative array'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric', strtolower($e->getMessage())); - } - - // Cleanup - $database->deleteCollection('vectorAssoc'); - } - - public function testVectorWithSparseArray(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorSparse'); - $database->createAttribute('vectorSparse', 'embedding', Database::VAR_VECTOR, 3, true); - - // Test with sparse array (missing indexes) - should fail - try { - $vector = []; - $vector[0] = 1.0; - $vector[2] = 1.0; // Skip index 1 - $database->createDocument('vectorSparse', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => $vector - ])); - $this->fail('Should have thrown exception for sparse array'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric', strtolower($e->getMessage())); - } - - // Cleanup - $database->deleteCollection('vectorSparse'); - } - - public function testVectorWithNestedArrays(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorNested'); - $database->createAttribute('vectorNested', 'embedding', Database::VAR_VECTOR, 3, true); - - // Test with nested array - should fail - try { - $database->createDocument('vectorNested', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [[1.0], [0.0], [0.0]] - ])); - $this->fail('Should have thrown exception for nested array'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric', strtolower($e->getMessage())); - } - - // Cleanup - $database->deleteCollection('vectorNested'); - } - - public function testVectorWithBooleansInArray(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorBooleans'); - $database->createAttribute('vectorBooleans', 'embedding', Database::VAR_VECTOR, 3, true); - - // Test with boolean values - should fail - try { - $database->createDocument('vectorBooleans', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [true, false, true] - ])); - $this->fail('Should have thrown exception for boolean values'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric', strtolower($e->getMessage())); - } - - // Cleanup - $database->deleteCollection('vectorBooleans'); - } - public function testVectorWithStringNumbers(): void { /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorStringNums'); - $database->createAttribute('vectorStringNums', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorStringNums')); + $database->createAttribute('vectorStringNums', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); - // Test with numeric strings - should fail (strict validation) - try { - $database->createDocument('vectorStringNums', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => ['1.0', '2.0', '3.0'] - ])); - $this->fail('Should have thrown exception for string numbers'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric', strtolower($e->getMessage())); - } + $embeddings = [ + 'numeric strings' => ['1.0', '2.0', '3.0'], + 'numeric strings with spaces' => [' 1.0 ', '2.0', '3.0'], + ]; - // Test with strings containing spaces try { - $database->createDocument('vectorStringNums', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [' 1.0 ', '2.0', '3.0'] - ])); - $this->fail('Should have thrown exception for string numbers with spaces'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + foreach ($embeddings as $case => $embedding) { + try { + $database->createDocument('vectorStringNums', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'embedding' => $embedding, + ])); + $this->fail("Should have thrown exception for {$case}"); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('numeric', strtolower($exception->getMessage()), $case); + } + } + } finally { + $database->deleteCollection('vectorStringNums'); } - - // Cleanup - $database->deleteCollection('vectorStringNums'); } public function testVectorWithRelationships(): void @@ -1402,58 +1009,64 @@ public function testVectorWithRelationships(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } // Create parent collection with vectors - $database->createCollection('vectorParent'); - $database->createAttribute('vectorParent', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorParent', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorParent')); + $database->createAttribute('vectorParent', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorParent', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create child collection - $database->createCollection('vectorChild'); - $database->createAttribute('vectorChild', 'title', Database::VAR_STRING, 255, true); - $database->createRelationship('vectorChild', 'vectorParent', Database::RELATION_MANY_TO_ONE, true, 'parent', 'children'); + $database->createCollection(Collection::create(id: 'vectorChild')); + $database->createAttribute('vectorChild', Attribute::string(key: 'title', required: true)); + $database->createRelationship('vectorChild', Relationship::manyToOne( + relatedCollection: 'vectorParent', + twoWay: true, + key: 'parent', + twoWayKey: 'children', + )); // Create parent documents with vectors $parent1 = $database->createDocument('vectorParent', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Parent 1', - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $parent2 = $database->createDocument('vectorParent', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Parent 2', - 'embedding' => [0.0, 1.0, 0.0] + 'embedding' => [0.0, 1.0, 0.0], ])); // Create child documents $child1 = $database->createDocument('vectorChild', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'title' => 'Child 1', - 'parent' => $parent1->getId() + 'parent' => $parent1->getId(), ])); $child2 = $database->createDocument('vectorChild', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'title' => 'Child 2', - 'parent' => $parent2->getId() + 'parent' => $parent2->getId(), ])); // Query parents by vector similarity $results = $database->find('vectorParent', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), ]); $this->assertCount(2, $results); @@ -1462,13 +1075,16 @@ public function testVectorWithRelationships(): void // Verify relationships are intact $parent1Fetched = $database->getDocument('vectorParent', $parent1->getId()); $children = $parent1Fetched->getAttribute('children'); + $this->assertIsArray($children); $this->assertCount(1, $children); - $this->assertEquals('Child 1', $children[0]->getAttribute('title')); + $child = $children[0] ?? null; + $this->assertInstanceOf(Document::class, $child); + $this->assertEquals('Child 1', $child->getAttribute('title')); // Query with vector and relationship filter combined $results = $database->find('vectorParent', [ Query::vectorCosine('embedding', [0.5, 0.5, 0.0]), - Query::equal('name', ['Parent 1']) + Query::equal('name', ['Parent 1']), ]); $this->assertCount(1, $results); @@ -1483,52 +1099,58 @@ public function testVectorWithTwoWayRelationships(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } // Create two collections with two-way relationship and vectors - $database->createCollection('vectorAuthors'); - $database->createAttribute('vectorAuthors', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorAuthors', 'embedding', Database::VAR_VECTOR, 3, true); - - $database->createCollection('vectorBooks'); - $database->createAttribute('vectorBooks', 'title', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorBooks', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createRelationship('vectorBooks', 'vectorAuthors', Database::RELATION_MANY_TO_ONE, true, 'author', 'books'); + $database->createCollection(Collection::create(id: 'vectorAuthors')); + $database->createAttribute('vectorAuthors', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorAuthors', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); + + $database->createCollection(Collection::create(id: 'vectorBooks')); + $database->createAttribute('vectorBooks', Attribute::string(key: 'title', required: true)); + $database->createAttribute('vectorBooks', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); + $database->createRelationship('vectorBooks', Relationship::manyToOne( + relatedCollection: 'vectorAuthors', + twoWay: true, + key: 'author', + twoWayKey: 'books', + )); // Create documents $author = $database->createDocument('vectorAuthors', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Author 1', - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $book1 = $database->createDocument('vectorBooks', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'title' => 'Book 1', 'embedding' => [0.9, 0.1, 0.0], - 'author' => $author->getId() + 'author' => $author->getId(), ])); $book2 = $database->createDocument('vectorBooks', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'title' => 'Book 2', 'embedding' => [0.8, 0.2, 0.0], - 'author' => $author->getId() + 'author' => $author->getId(), ])); // Query books by vector similarity $results = $database->find('vectorBooks', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(1) + Query::limit(1), ]); $this->assertCount(1, $results); @@ -1536,8 +1158,7 @@ public function testVectorWithTwoWayRelationships(): void // Query authors and verify relationship $authorFetched = $database->getDocument('vectorAuthors', $author->getId()); - $books = $authorFetched->getAttribute('books'); - $this->assertCount(2, $books); + $this->assertCount(2, $authorFetched->getDocuments('books')); // Cleanup $database->deleteCollection('vectorBooks'); @@ -1549,20 +1170,21 @@ public function testVectorAllZeros(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorZeros'); - $database->createAttribute('vectorZeros', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorZeros')); + $database->createAttribute('vectorZeros', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create document with all-zeros vector $doc = $database->createDocument('vectorZeros', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [0.0, 0.0, 0.0] + 'embedding' => [0.0, 0.0, 0.0], ])); $this->assertEquals([0.0, 0.0, 0.0], $doc->getAttribute('embedding')); @@ -1570,14 +1192,14 @@ public function testVectorAllZeros(): void // Create another document with non-zero vector $doc2 = $database->createDocument('vectorZeros', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); // Query with zero vector - cosine similarity should handle gracefully $results = $database->find('vectorZeros', [ - Query::vectorCosine('embedding', [0.0, 0.0, 0.0]) + Query::vectorCosine('embedding', [0.0, 0.0, 0.0]), ]); // Should return documents, though similarity may be undefined @@ -1585,7 +1207,7 @@ public function testVectorAllZeros(): void // Query with non-zero vector against zero vectors $results = $database->find('vectorZeros', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), ]); $this->assertCount(2, $results); @@ -1599,39 +1221,33 @@ public function testVectorCosineSimilarityDivisionByZero(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorCosineZero'); - $database->createAttribute('vectorCosineZero', 'embedding', Database::VAR_VECTOR, 3, true); - - // Create multiple documents with zero vectors - $database->createDocument('vectorCosineZero', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [0.0, 0.0, 0.0] - ])); - - $database->createDocument('vectorCosineZero', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [0.0, 0.0, 0.0] - ])); + $database->createCollection(Collection::create(id: 'vectorCosineZero')); + $database->createAttribute('vectorCosineZero', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); - // Query with zero vector - should not cause division by zero error - $results = $database->find('vectorCosineZero', [ - Query::vectorCosine('embedding', [0.0, 0.0, 0.0]) - ]); + try { + for ($index = 0; $index < 2; $index++) { + $database->createDocument('vectorCosineZero', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'embedding' => [0.0, 0.0, 0.0], + ])); + } - // Should handle gracefully and return results - $this->assertCount(2, $results); + $results = $database->find('vectorCosineZero', [ + Query::vectorCosine('embedding', [0.0, 0.0, 0.0]), + ]); - // Cleanup - $database->deleteCollection('vectorCosineZero'); + $this->assertCount(2, $results, 'A cosine search whose vectors all have zero magnitude must still return every document'); + } finally { + $database->deleteCollection('vectorCosineZero'); + } } public function testDeleteVectorAttribute(): void @@ -1639,35 +1255,35 @@ public function testDeleteVectorAttribute(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorDeleteAttr'); - $database->createAttribute('vectorDeleteAttr', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorDeleteAttr', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorDeleteAttr')); + $database->createAttribute('vectorDeleteAttr', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorDeleteAttr', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create document with vector $doc = $database->createDocument('vectorDeleteAttr', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Test', - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $this->assertNotNull($doc->getAttribute('embedding')); // Delete the vector attribute - $result = $database->deleteAttribute('vectorDeleteAttr', 'embedding'); - $this->assertTrue($result); + $database->deleteAttribute('vectorDeleteAttr', 'embedding'); // Verify attribute is gone $collection = $database->getCollection('vectorDeleteAttr'); - $attributes = $collection->getAttribute('attributes'); - foreach ($attributes as $attr) { - $this->assertNotEquals('embedding', $attr['key']); + $attributes = $collection->attributes(); + foreach ($attributes as $attribute) { + $this->assertNotEquals('embedding', $attribute->key); } // Fetch document - should not have embedding anymore @@ -1683,33 +1299,33 @@ public function testDeleteAttributeWithVectorIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorDeleteIndexedAttr'); - $database->createAttribute('vectorDeleteIndexedAttr', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorDeleteIndexedAttr')); + $database->createAttribute('vectorDeleteIndexedAttr', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create multiple indexes on the vector attribute - $database->createIndex('vectorDeleteIndexedAttr', 'idx1', Database::INDEX_HNSW_COSINE, ['embedding']); - $database->createIndex('vectorDeleteIndexedAttr', 'idx2', Database::INDEX_HNSW_EUCLIDEAN, ['embedding']); + $database->createIndex('vectorDeleteIndexedAttr', Index::hnswCosine(key: 'idx1', attribute: 'embedding')); + $database->createIndex('vectorDeleteIndexedAttr', Index::hnswEuclidean(key: 'idx2', attribute: 'embedding')); // Create document $database->createDocument('vectorDeleteIndexedAttr', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); // Delete the attribute - should also delete indexes - $result = $database->deleteAttribute('vectorDeleteIndexedAttr', 'embedding'); - $this->assertTrue($result); + $database->deleteAttribute('vectorDeleteIndexedAttr', 'embedding'); // Verify indexes are gone $collection = $database->getCollection('vectorDeleteIndexedAttr'); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(0, $indexes); // Cleanup @@ -1721,74 +1337,77 @@ public function testVectorSearchWithRestrictedPermissions(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - // Create documents with different permissions inside Authorization::skip - $database->getAuthorization()->skip(function () use ($database) { - $database->createCollection('vectorPermissions', [], [], [], true); - $database->createAttribute('vectorPermissions', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorPermissions', 'embedding', Database::VAR_VECTOR, 3, true); + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection(Collection::create(id: 'vectorPermissions', permissions: [], documentSecurity: true)); + $database->createAttribute('vectorPermissions', Attribute::string(key: 'name', size: 255, required: true)); + $database->createAttribute('vectorPermissions', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); $database->createDocument('vectorPermissions', new Document([ '$permissions' => [ - Permission::read(Role::user('user1')) + Permission::read(Role::user('user1')), ], 'name' => 'Doc 1', - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $database->createDocument('vectorPermissions', new Document([ '$permissions' => [ - Permission::read(Role::user('user2')) + Permission::read(Role::user('user2')), ], 'name' => 'Doc 2', - 'embedding' => [0.9, 0.1, 0.0] + 'embedding' => [0.9, 0.1, 0.0], ])); $database->createDocument('vectorPermissions', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Doc 3', - 'embedding' => [0.8, 0.2, 0.0] + 'embedding' => [0.8, 0.2, 0.0], ])); }); - // Query as user1 - should only see doc1 and doc3 - $database->getAuthorization()->addRole(Role::user('user1')->toString()); - $database->getAuthorization()->addRole(Role::any()->toString()); - $results = $database->find('vectorPermissions', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) - ]); - - $this->assertCount(2, $results); - $names = array_map(fn ($d) => $d->getAttribute('name'), $results); - $this->assertContains('Doc 1', $names); - $this->assertContains('Doc 3', $names); - $this->assertNotContains('Doc 2', $names); - - // Query as user2 - should only see doc2 and doc3 - $database->getAuthorization()->cleanRoles(); - $database->getAuthorization()->addRole(Role::user('user2')->toString()); - $database->getAuthorization()->addRole(Role::any()->toString()); - $results = $database->find('vectorPermissions', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) - ]); - - $this->assertCount(2, $results); - $names = array_map(fn ($d) => $d->getAttribute('name'), $results); - $this->assertContains('Doc 2', $names); - $this->assertContains('Doc 3', $names); - $this->assertNotContains('Doc 1', $names); + try { + $authorization->cleanRoles(); + $authorization->addRole(Role::user('user1')->toString()); + $authorization->addRole(Role::any()->toString()); + $results = $database->find('vectorPermissions', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + ]); - $database->getAuthorization()->cleanRoles(); - $database->getAuthorization()->addRole(Role::any()->toString()); + $this->assertCount(2, $results); + $names = array_map(fn (Document $document) => $document->getAttribute('name'), $results); + $this->assertContains('Doc 1', $names); + $this->assertContains('Doc 3', $names); + $this->assertNotContains('Doc 2', $names); + $this->assertSame(['Doc 1', 'Doc 3'], $names, 'Readable documents must keep their similarity order'); + + $authorization->cleanRoles(); + $authorization->addRole(Role::user('user2')->toString()); + $authorization->addRole(Role::any()->toString()); + $results = $database->find('vectorPermissions', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + ]); - // Cleanup - $database->deleteCollection('vectorPermissions'); + $this->assertCount(2, $results); + $names = array_map(fn (Document $document) => $document->getAttribute('name'), $results); + $this->assertContains('Doc 2', $names); + $this->assertContains('Doc 3', $names); + $this->assertNotContains('Doc 1', $names); + $this->assertSame(['Doc 2', 'Doc 3'], $names, 'Readable documents must keep their similarity order'); + } finally { + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $database->deleteCollection('vectorPermissions'); + } } public function testVectorPermissionFilteringAfterScoring(): void @@ -1796,45 +1415,48 @@ public function testVectorPermissionFilteringAfterScoring(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorPermScoring'); - $database->createAttribute('vectorPermScoring', 'score', Database::VAR_INTEGER, 0, true); - $database->createAttribute('vectorPermScoring', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorPermScoring')); + $database->createAttribute('vectorPermScoring', Attribute::integer(key: 'score', required: true)); + $database->createAttribute('vectorPermScoring', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); - // Create 5 documents, top 3 by similarity have restricted access - for ($i = 0; $i < 5; $i++) { - $perms = $i < 3 - ? [Permission::read(Role::user('restricted'))] - : [Permission::read(Role::any())]; - - $database->createDocument('vectorPermScoring', new Document([ - '$permissions' => $perms, - 'score' => $i, - 'embedding' => [1.0 - ($i * 0.1), $i * 0.1, 0.0] - ])); - } + $authorization = $database->getAuthorization(); - // Query with limit 3 as any user - should skip restricted docs and return accessible ones - $database->getAuthorization()->addRole(Role::any()->toString()); - $results = $database->find('vectorPermScoring', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(3) - ]); - - // Should only get the 2 accessible documents - $this->assertCount(2, $results); - foreach ($results as $doc) { - $this->assertGreaterThanOrEqual(3, $doc->getAttribute('score')); - } + try { + for ($index = 0; $index < 5; $index++) { + $permissions = $index < 3 + ? [Permission::read(Role::user('restricted'))] + : [Permission::read(Role::any())]; + + $database->createDocument('vectorPermScoring', new Document([ + '$permissions' => $permissions, + 'score' => $index, + 'embedding' => [1.0 - ($index * 0.1), $index * 0.1, 0.0], + ])); + } - $database->getAuthorization()->cleanRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $results = $database->find('vectorPermScoring', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::limit(3), + ]); - // Cleanup - $database->deleteCollection('vectorPermScoring'); + $this->assertCount(2, $results, 'The limit must apply to readable documents, not to the three closest restricted ones'); + foreach ($results as $document) { + $this->assertGreaterThanOrEqual(3, $document->getAttribute('score')); + } + $this->assertSame([3, 4], array_map(fn (Document $document) => $document->getAttribute('score'), $results)); + } finally { + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $database->deleteCollection('vectorPermScoring'); + } } public function testVectorCursorBeforePagination(): void @@ -1842,30 +1464,31 @@ public function testVectorCursorBeforePagination(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorCursorBefore'); - $database->createAttribute('vectorCursorBefore', 'index', Database::VAR_INTEGER, 0, true); - $database->createAttribute('vectorCursorBefore', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorCursorBefore')); + $database->createAttribute('vectorCursorBefore', Attribute::integer(key: 'index', required: true)); + $database->createAttribute('vectorCursorBefore', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create 10 documents for ($i = 0; $i < 10; $i++) { $database->createDocument('vectorCursorBefore', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'index' => $i, - 'embedding' => [1.0 - ($i * 0.05), $i * 0.05, 0.0] + 'embedding' => [1.0 - ($i * 0.05), $i * 0.05, 0.0], ])); } // Get first 5 results $firstBatch = $database->find('vectorCursorBefore', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(5) + Query::limit(5), ]); $this->assertCount(5, $firstBatch); @@ -1875,7 +1498,7 @@ public function testVectorCursorBeforePagination(): void $beforeBatch = $database->find('vectorCursorBefore', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::cursorBefore($fourthDoc), - Query::limit(3) + Query::limit(3), ]); // Should get the 3 documents before the 4th one @@ -1892,30 +1515,31 @@ public function testVectorBackwardPagination(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorBackward'); - $database->createAttribute('vectorBackward', 'value', Database::VAR_INTEGER, 0, true); - $database->createAttribute('vectorBackward', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorBackward')); + $database->createAttribute('vectorBackward', Attribute::integer(key: 'value', required: true)); + $database->createAttribute('vectorBackward', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create documents for ($i = 0; $i < 20; $i++) { $database->createDocument('vectorBackward', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'value' => $i, - 'embedding' => [cos($i * 0.1), sin($i * 0.1), 0.0] + 'embedding' => [cos($i * 0.1), sin($i * 0.1), 0.0], ])); } // Get last batch $allResults = $database->find('vectorBackward', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(20) + Query::limit(20), ]); // Navigate backwards from the end @@ -1923,7 +1547,7 @@ public function testVectorBackwardPagination(): void $backwardBatch = $database->find('vectorBackward', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::cursorBefore($lastDoc), - Query::limit(5) + Query::limit(5), ]); $this->assertCount(5, $backwardBatch); @@ -1933,7 +1557,7 @@ public function testVectorBackwardPagination(): void $moreBackward = $database->find('vectorBackward', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::cursorBefore($firstOfBackward), - Query::limit(5) + Query::limit(5), ]); // Should get at least some results (may be less than 5 due to cursor position) @@ -1949,31 +1573,31 @@ public function testVectorDimensionUpdate(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorDimUpdate'); - $database->createAttribute('vectorDimUpdate', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorDimUpdate')); + $database->createAttribute('vectorDimUpdate', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create document $doc = $database->createDocument('vectorDimUpdate', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); - $this->assertCount(3, $doc->getAttribute('embedding')); + $this->assertCount(3, $doc->getArray('embedding')); // Try to update attribute dimensions - should fail (immutable) try { - $database->updateAttribute('vectorDimUpdate', 'embedding', Database::VAR_VECTOR, 5, true); + $database->updateAttribute('vectorDimUpdate', 'embedding', new AttributeUpdate(type: ColumnType::Vector, size: 5, required: true)); $this->fail('Should not allow changing vector dimensions'); } catch (\Throwable $e) { - // Expected - dimension changes not allowed (either validation or database error) - $this->assertTrue(true); + $this->assertNotSame('', $e->getMessage()); } // Cleanup @@ -1985,41 +1609,42 @@ public function testVectorRequiredWithNullValue(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorRequiredNull'); - $database->createAttribute('vectorRequiredNull', 'embedding', Database::VAR_VECTOR, 3, true); // Required + $database->createCollection(Collection::create(id: 'vectorRequiredNull')); + $database->createAttribute('vectorRequiredNull', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); - // Try to create document with null required vector - should fail try { - $database->createDocument('vectorRequiredNull', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => null - ])); - $this->fail('Should have thrown exception for null required vector'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('required', strtolower($e->getMessage())); - } + try { + $database->createDocument('vectorRequiredNull', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'embedding' => null, + ])); + $this->fail('Should have thrown exception for null required vector'); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('required', strtolower($exception->getMessage())); + } - // Try to create document without vector attribute - should fail - try { - $database->createDocument('vectorRequiredNull', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ] - ])); - $this->fail('Should have thrown exception for missing required vector'); - } catch (DatabaseException $e) { - $this->assertTrue(true); + try { + $database->createDocument('vectorRequiredNull', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + ])); + $this->fail('Should have thrown exception for missing required vector'); + } catch (DatabaseException $exception) { + $this->assertInstanceOf(StructureException::class, $exception); + $this->assertStringContainsString('Missing required attribute "embedding"', $exception->getMessage()); + } + } finally { + $database->deleteCollection('vectorRequiredNull'); } - - // Cleanup - $database->deleteCollection('vectorRequiredNull'); } public function testVectorConcurrentUpdates(): void @@ -2027,34 +1652,35 @@ public function testVectorConcurrentUpdates(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorConcurrent'); - $database->createAttribute('vectorConcurrent', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorConcurrent', 'version', Database::VAR_INTEGER, 0, true); + $database->createCollection(Collection::create(id: 'vectorConcurrent')); + $database->createAttribute('vectorConcurrent', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); + $database->createAttribute('vectorConcurrent', Attribute::integer(key: 'version', required: true)); // Create initial document $doc = $database->createDocument('vectorConcurrent', new Document([ '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) + Permission::update(Role::any()), ], 'embedding' => [1.0, 0.0, 0.0], - 'version' => 1 + 'version' => 1, ])); // Simulate concurrent updates $update1 = $database->updateDocument('vectorConcurrent', $doc->getId(), new Document([ 'embedding' => [0.0, 1.0, 0.0], - 'version' => 2 + 'version' => 2, ])); $update2 = $database->updateDocument('vectorConcurrent', $doc->getId(), new Document([ 'embedding' => [0.0, 0.0, 1.0], - 'version' => 3 + 'version' => 3, ])); // Last update should win @@ -2071,42 +1697,42 @@ public function testDeleteVectorIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorDeleteIdx'); - $database->createAttribute('vectorDeleteIdx', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorDeleteIdx')); + $database->createAttribute('vectorDeleteIdx', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create index - $database->createIndex('vectorDeleteIdx', 'idx_cosine', Database::INDEX_HNSW_COSINE, ['embedding']); + $database->createIndex('vectorDeleteIdx', Index::hnswCosine(key: 'idx_cosine', attribute: 'embedding')); // Verify index exists $collection = $database->getCollection('vectorDeleteIdx'); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(1, $indexes); // Create documents $database->createDocument('vectorDeleteIdx', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); // Delete index - $result = $database->deleteIndex('vectorDeleteIdx', 'idx_cosine'); - $this->assertTrue($result); + $database->deleteIndex('vectorDeleteIdx', 'idx_cosine'); // Verify index is gone $collection = $database->getCollection('vectorDeleteIdx'); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(0, $indexes); // Queries should still work (without index optimization) $results = $database->find('vectorDeleteIdx', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), ]); $this->assertCount(1, $results); @@ -2120,42 +1746,43 @@ public function testMultipleVectorIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorMultiIdx'); - $database->createAttribute('vectorMultiIdx', 'embedding1', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorMultiIdx', 'embedding2', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorMultiIdx')); + $database->createAttribute('vectorMultiIdx', Attribute::vector(key: 'embedding1', dimensions: 3, required: true)); + $database->createAttribute('vectorMultiIdx', Attribute::vector(key: 'embedding2', dimensions: 3, required: true)); // Create multiple indexes on different vector attributes - $database->createIndex('vectorMultiIdx', 'idx1_cosine', Database::INDEX_HNSW_COSINE, ['embedding1']); - $database->createIndex('vectorMultiIdx', 'idx2_euclidean', Database::INDEX_HNSW_EUCLIDEAN, ['embedding2']); + $database->createIndex('vectorMultiIdx', Index::hnswCosine(key: 'idx1_cosine', attribute: 'embedding1')); + $database->createIndex('vectorMultiIdx', Index::hnswEuclidean(key: 'idx2_euclidean', attribute: 'embedding2')); // Verify both indexes exist $collection = $database->getCollection('vectorMultiIdx'); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes(); $this->assertCount(2, $indexes); // Create document $database->createDocument('vectorMultiIdx', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'embedding1' => [1.0, 0.0, 0.0], - 'embedding2' => [0.0, 1.0, 0.0] + 'embedding2' => [0.0, 1.0, 0.0], ])); // Query using first index $results = $database->find('vectorMultiIdx', [ - Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]), ]); $this->assertCount(1, $results); // Query using second index $results = $database->find('vectorMultiIdx', [ - Query::vectorEuclidean('embedding2', [0.0, 1.0, 0.0]) + Query::vectorEuclidean('embedding2', [0.0, 1.0, 0.0]), ]); $this->assertCount(1, $results); @@ -2168,34 +1795,38 @@ public function testVectorIndexCreationFailure(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorIdxFail'); - $database->createAttribute('vectorIdxFail', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorIdxFail', 'text', Database::VAR_STRING, 255, true); + $database->createCollection(Collection::create(id: 'vectorIdxFail')); + $database->createAttribute('vectorIdxFail', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); + $database->createAttribute('vectorIdxFail', Attribute::string(key: 'text', size: 255, required: true)); - // Try to create vector index on non-vector attribute - should fail try { - $database->createIndex('vectorIdxFail', 'bad_idx', Database::INDEX_HNSW_COSINE, ['text']); - $this->fail('Should not allow vector index on non-vector attribute'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('vector', strtolower($e->getMessage())); - } + try { + $database->createIndex('vectorIdxFail', Index::hnswCosine(key: 'bad_idx', attribute: 'text')); + $this->fail('Should not allow vector index on non-vector attribute'); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('vector', strtolower($exception->getMessage())); + } - // Try to create duplicate index - $database->createIndex('vectorIdxFail', 'idx1', Database::INDEX_HNSW_COSINE, ['embedding']); - try { - $database->createIndex('vectorIdxFail', 'idx1', Database::INDEX_HNSW_COSINE, ['embedding']); - $this->fail('Should not allow duplicate index'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('index', strtolower($e->getMessage())); - } + $database->createIndex('vectorIdxFail', Index::hnswCosine(key: 'idx1', attribute: 'embedding')); - // Cleanup - $database->deleteCollection('vectorIdxFail'); + try { + $database->createIndex('vectorIdxFail', Index::hnswCosine(key: 'idx1', attribute: 'embedding')); + $this->fail('Should not allow duplicate index'); + } catch (DatabaseException $exception) { + $this->assertInstanceOf(DuplicateException::class, $exception); + $this->assertStringContainsString('index', strtolower($exception->getMessage())); + } + + $this->assertSame(['idx1'], array_map(fn (Index $index) => $index->key, $database->getCollection('vectorIdxFail')->indexes())); + } finally { + $database->deleteCollection('vectorIdxFail'); + } } public function testVectorQueryWithoutIndex(): void @@ -2203,32 +1834,33 @@ public function testVectorQueryWithoutIndex(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorNoIndex'); - $database->createAttribute('vectorNoIndex', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorNoIndex')); + $database->createAttribute('vectorNoIndex', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create documents without any index $database->createDocument('vectorNoIndex', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $database->createDocument('vectorNoIndex', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [0.0, 1.0, 0.0] + 'embedding' => [0.0, 1.0, 0.0], ])); // Queries should still work (sequential scan) $results = $database->find('vectorNoIndex', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), ]); $this->assertCount(2, $results); @@ -2242,17 +1874,18 @@ public function testVectorQueryEmpty(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorEmptyQuery'); - $database->createAttribute('vectorEmptyQuery', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorEmptyQuery')); + $database->createAttribute('vectorEmptyQuery', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // No documents in collection $results = $database->find('vectorEmptyQuery', [ - Query::vectorCosine('embedding', [1.0, 0.0, 0.0]) + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), ]); $this->assertCount(0, $results); @@ -2266,27 +1899,28 @@ public function testSingleDimensionVector(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorSingleDim'); - $database->createAttribute('vectorSingleDim', 'embedding', Database::VAR_VECTOR, 1, true); + $database->createCollection(Collection::create(id: 'vectorSingleDim')); + $database->createAttribute('vectorSingleDim', Attribute::vector(key: 'embedding', dimensions: 1, required: true)); // Create documents with single-dimension vectors $doc1 = $database->createDocument('vectorSingleDim', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1.0] + 'embedding' => [1.0], ])); $doc2 = $database->createDocument('vectorSingleDim', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [0.5] + 'embedding' => [0.5], ])); $this->assertEquals([1.0], $doc1->getAttribute('embedding')); @@ -2294,7 +1928,7 @@ public function testSingleDimensionVector(): void // Query with single dimension $results = $database->find('vectorSingleDim', [ - Query::vectorCosine('embedding', [1.0]) + Query::vectorCosine('embedding', [1.0]), ]); $this->assertCount(2, $results); @@ -2308,32 +1942,33 @@ public function testVectorLongResultSet(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorLongResults'); - $database->createAttribute('vectorLongResults', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorLongResults')); + $database->createAttribute('vectorLongResults', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create 100 documents for ($i = 0; $i < 100; $i++) { $database->createDocument('vectorLongResults', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'embedding' => [ sin($i * 0.1), cos($i * 0.1), - sin($i * 0.05) - ] + sin($i * 0.05), + ], ])); } // Query all results $results = $database->find('vectorLongResults', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(100) + Query::limit(100), ]); $this->assertCount(100, $results); @@ -2347,42 +1982,43 @@ public function testMultipleVectorQueriesOnSameCollection(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorMultiQuery'); - $database->createAttribute('vectorMultiQuery', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorMultiQuery')); + $database->createAttribute('vectorMultiQuery', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create documents for ($i = 0; $i < 10; $i++) { $database->createDocument('vectorMultiQuery', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'embedding' => [ cos($i * M_PI / 10), sin($i * M_PI / 10), - 0.0 - ] + 0.0, + ], ])); } // Execute multiple different vector queries $results1 = $database->find('vectorMultiQuery', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::limit(5) + Query::limit(5), ]); $results2 = $database->find('vectorMultiQuery', [ Query::vectorEuclidean('embedding', [0.0, 1.0, 0.0]), - Query::limit(5) + Query::limit(5), ]); $results3 = $database->find('vectorMultiQuery', [ Query::vectorDot('embedding', [0.5, 0.5, 0.0]), - Query::limit(5) + Query::limit(5), ]); // All should return results @@ -2400,75 +2036,33 @@ public function testMultipleVectorQueriesOnSameCollection(): void $database->deleteCollection('vectorMultiQuery'); } - public function testVectorNonNumericValidationE2E(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForVectors()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('vectorNonNumeric'); - $database->createAttribute('vectorNonNumeric', 'embedding', Database::VAR_VECTOR, 3, true); - - // Test null value in array - try { - $database->createDocument('vectorNonNumeric', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [1.0, null, 0.0] - ])); - $this->fail('Should reject null in vector array'); - } catch (DatabaseException $e) { - $this->assertStringContainsString('numeric', strtolower($e->getMessage())); - } - - // Test object in array - try { - $database->createDocument('vectorNonNumeric', new Document([ - '$permissions' => [ - Permission::read(Role::any()) - ], - 'embedding' => [1.0, (object)['x' => 1], 0.0] - ])); - $this->fail('Should reject object in vector array'); - } catch (\Throwable $e) { - $this->assertTrue(true); - } - - // Cleanup - $database->deleteCollection('vectorNonNumeric'); - } - public function testVectorLargeValues(): void { /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorLargeVals'); - $database->createAttribute('vectorLargeVals', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorLargeVals')); + $database->createAttribute('vectorLargeVals', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Test with very large float values (but not INF) $doc = $database->createDocument('vectorLargeVals', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => [1e38, -1e38, 1e37] + 'embedding' => [1e38, -1e38, 1e37], ])); - $this->assertNotNull($doc->getId()); + $this->assertNotSame('', $doc->getId()); // Query should work $results = $database->find('vectorLargeVals', [ - Query::vectorCosine('embedding', [1e38, -1e38, 1e37]) + Query::vectorCosine('embedding', [1e38, -1e38, 1e37]), ]); $this->assertCount(1, $results); @@ -2482,25 +2076,26 @@ public function testVectorPrecisionLoss(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorPrecision'); - $database->createAttribute('vectorPrecision', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorPrecision')); + $database->createAttribute('vectorPrecision', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create vector with high precision values $highPrecision = [0.123456789012345, 0.987654321098765, 0.555555555555555]; $doc = $database->createDocument('vectorPrecision', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => $highPrecision + 'embedding' => $highPrecision, ])); // Retrieve and check precision (may have some loss) - $retrieved = $doc->getAttribute('embedding'); + $retrieved = $doc->getArray('embedding'); $this->assertCount(3, $retrieved); // Values should be close to original (allowing for float precision) @@ -2517,14 +2112,15 @@ public function testVector16000DimensionsBoundary(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } // Test exactly 16000 dimensions (pgvector limit) - $database->createCollection('vector16000'); - $database->createAttribute('vector16000', 'embedding', Database::VAR_VECTOR, 16000, true); + $database->createCollection(Collection::create(id: 'vector16000')); + $database->createAttribute('vector16000', Attribute::vector(key: 'embedding', dimensions: 16000, required: true)); // Create a vector with exactly 16000 dimensions $largeVector = array_fill(0, 16000, 0.1); @@ -2532,12 +2128,14 @@ public function testVector16000DimensionsBoundary(): void $doc = $database->createDocument('vector16000', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => $largeVector + 'embedding' => $largeVector, ])); - $this->assertCount(16000, $doc->getAttribute('embedding')); + $embedding = $doc->getAttribute('embedding'); + $this->assertIsArray($embedding); + $this->assertCount(16000, $embedding); // Query should work $searchVector = array_fill(0, 16000, 0.0); @@ -2545,7 +2143,7 @@ public function testVector16000DimensionsBoundary(): void $results = $database->find('vector16000', [ Query::vectorCosine('embedding', $searchVector), - Query::limit(1) + Query::limit(1), ]); $this->assertCount(1, $results); @@ -2559,13 +2157,14 @@ public function testVectorLargeDatasetIndexBuild(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorLargeDataset'); - $database->createAttribute('vectorLargeDataset', 'embedding', Database::VAR_VECTOR, 128, true); + $database->createCollection(Collection::create(id: 'vectorLargeDataset')); + $database->createAttribute('vectorLargeDataset', Attribute::vector(key: 'embedding', dimensions: 128, required: true)); // Create 200 documents for ($i = 0; $i < 200; $i++) { @@ -2576,20 +2175,20 @@ public function testVectorLargeDatasetIndexBuild(): void $database->createDocument('vectorLargeDataset', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], - 'embedding' => $vector + 'embedding' => $vector, ])); } // Create index on large dataset - $database->createIndex('vectorLargeDataset', 'idx_hnsw', Database::INDEX_HNSW_COSINE, ['embedding']); + $database->createIndex('vectorLargeDataset', Index::hnswCosine(key: 'idx_hnsw', attribute: 'embedding')); // Verify queries work $searchVector = array_fill(0, 128, 0.5); $results = $database->find('vectorLargeDataset', [ Query::vectorCosine('embedding', $searchVector), - Query::limit(10) + Query::limit(10), ]); $this->assertCount(10, $results); @@ -2603,44 +2202,45 @@ public function testVectorFilterDisabled(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorFilterDisabled'); - $database->createAttribute('vectorFilterDisabled', 'status', Database::VAR_STRING, 50, true); - $database->createAttribute('vectorFilterDisabled', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorFilterDisabled')); + $database->createAttribute('vectorFilterDisabled', Attribute::string(key: 'status', size: 50, required: true)); + $database->createAttribute('vectorFilterDisabled', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create documents $database->createDocument('vectorFilterDisabled', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'status' => 'active', - 'embedding' => [1.0, 0.0, 0.0] + 'embedding' => [1.0, 0.0, 0.0], ])); $database->createDocument('vectorFilterDisabled', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'status' => 'disabled', - 'embedding' => [0.9, 0.1, 0.0] + 'embedding' => [0.9, 0.1, 0.0], ])); $database->createDocument('vectorFilterDisabled', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'status' => 'active', - 'embedding' => [0.8, 0.2, 0.0] + 'embedding' => [0.8, 0.2, 0.0], ])); // Query with filter excluding disabled $results = $database->find('vectorFilterDisabled', [ Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), - Query::notEqual('status', ['disabled']) + Query::notEqual('status', ['disabled']), ]); $this->assertCount(2, $results); @@ -2657,25 +2257,26 @@ public function testVectorFilterOverride(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorFilterOverride'); - $database->createAttribute('vectorFilterOverride', 'category', Database::VAR_STRING, 50, true); - $database->createAttribute('vectorFilterOverride', 'priority', Database::VAR_INTEGER, 0, true); - $database->createAttribute('vectorFilterOverride', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorFilterOverride')); + $database->createAttribute('vectorFilterOverride', Attribute::string(key: 'category', size: 50, required: true)); + $database->createAttribute('vectorFilterOverride', Attribute::integer(key: 'priority', required: true)); + $database->createAttribute('vectorFilterOverride', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); // Create documents for ($i = 0; $i < 5; $i++) { $database->createDocument('vectorFilterOverride', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'category' => $i < 3 ? 'A' : 'B', 'priority' => $i, - 'embedding' => [1.0 - ($i * 0.1), $i * 0.1, 0.0] + 'embedding' => [1.0 - ($i * 0.1), $i * 0.1, 0.0], ])); } @@ -2684,7 +2285,7 @@ public function testVectorFilterOverride(): void Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), Query::equal('category', ['A']), Query::greaterThan('priority', 0), - Query::limit(2) + Query::limit(2), ]); // Should get category A documents with priority > 0 @@ -2703,31 +2304,32 @@ public function testMultipleFiltersOnVectorAttribute(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorMultiFilters'); - $database->createAttribute('vectorMultiFilters', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorMultiFilters', 'embedding1', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorMultiFilters', 'embedding2', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorMultiFilters')); + $database->createAttribute('vectorMultiFilters', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorMultiFilters', Attribute::vector(key: 'embedding1', dimensions: 3, required: true)); + $database->createAttribute('vectorMultiFilters', Attribute::vector(key: 'embedding2', dimensions: 3, required: true)); // Create documents $database->createDocument('vectorMultiFilters', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Doc 1', 'embedding1' => [1.0, 0.0, 0.0], - 'embedding2' => [0.0, 1.0, 0.0] + 'embedding2' => [0.0, 1.0, 0.0], ])); // Try to use multiple vector queries - should reject try { $database->find('vectorMultiFilters', [ Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]), - Query::vectorCosine('embedding2', [0.0, 1.0, 0.0]) + Query::vectorCosine('embedding2', [0.0, 1.0, 0.0]), ]); $this->fail('Should not allow multiple vector queries'); } catch (DatabaseException $e) { @@ -2743,24 +2345,25 @@ public function testVectorQueryInNestedQuery(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorNested'); - $database->createAttribute('vectorNested', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('vectorNested', 'embedding1', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorNested', 'embedding2', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorNested')); + $database->createAttribute('vectorNested', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorNested', Attribute::vector(key: 'embedding1', dimensions: 3, required: true)); + $database->createAttribute('vectorNested', Attribute::vector(key: 'embedding2', dimensions: 3, required: true)); // Create document $database->createDocument('vectorNested', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'name' => 'Doc 1', 'embedding1' => [1.0, 0.0, 0.0], - 'embedding2' => [0.0, 1.0, 0.0] + 'embedding2' => [0.0, 1.0, 0.0], ])); // Try to use vector query in nested OR clause with another vector query - should reject @@ -2769,8 +2372,8 @@ public function testVectorQueryInNestedQuery(): void Query::vectorCosine('embedding1', [1.0, 0.0, 0.0]), Query::or([ Query::vectorCosine('embedding2', [0.0, 1.0, 0.0]), - Query::equal('name', ['Doc 1']) - ]) + Query::equal('name', ['Doc 1']), + ]), ]); $this->fail('Should not allow multiple vector queries across nested queries'); } catch (DatabaseException $e) { @@ -2786,17 +2389,18 @@ public function testVectorQueryCount(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorCount'); - $database->createAttribute('vectorCount', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorCount')); + $database->createAttribute('vectorCount', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); $database->createDocument('vectorCount', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'embedding' => [1.0, 0.0, 0.0], ])); @@ -2815,38 +2419,39 @@ public function testVectorQuerySum(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorSum'); - $database->createAttribute('vectorSum', 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute('vectorSum', 'value', Database::VAR_INTEGER, 0, true); + $database->createCollection(Collection::create(id: 'vectorSum')); + $database->createAttribute('vectorSum', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); + $database->createAttribute('vectorSum', Attribute::integer(key: 'value', required: true)); // Create documents with different values $database->createDocument('vectorSum', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'embedding' => [1.0, 0.0, 0.0], - 'value' => 10 + 'value' => 10, ])); $database->createDocument('vectorSum', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'embedding' => [0.0, 1.0, 0.0], - 'value' => 20 + 'value' => 20, ])); $database->createDocument('vectorSum', new Document([ '$permissions' => [ - Permission::read(Role::any()) + Permission::read(Role::any()), ], 'embedding' => [0.5, 0.5, 0.0], - 'value' => 30 + 'value' => 30, ])); // Test sum with vector query - should sum all matching documents @@ -2872,19 +2477,20 @@ public function testVectorUpsert(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForVectors()) { + if (! $database->getAdapter()->supports(Capability::Vectors)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('vectorUpsert'); - $database->createAttribute('vectorUpsert', 'embedding', Database::VAR_VECTOR, 3, true); + $database->createCollection(Collection::create(id: 'vectorUpsert')); + $database->createAttribute('vectorUpsert', Attribute::vector(key: 'embedding', dimensions: 3, required: true)); $insertedDoc = $database->upsertDocument('vectorUpsert', new Document([ '$id' => 'vectorUpsert', '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) + Permission::update(Role::any()), ], 'embedding' => [1.0, 0.0, 0.0], ])); @@ -2898,7 +2504,7 @@ public function testVectorUpsert(): void '$id' => 'vectorUpsert', '$permissions' => [ Permission::read(Role::any()), - Permission::update(Role::any()) + Permission::update(Role::any()), ], 'embedding' => [2.0, 0.0, 0.0], ])); @@ -2910,4 +2516,190 @@ public function testVectorUpsert(): void $database->deleteCollection('vectorUpsert'); } + + public function testVectorDistance(): void + { + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(Collection::create(id: 'vectorDistance', attributes: [ + Attribute::string(key: 'name', size: 255, required: true), + Attribute::vector(key: 'embedding', dimensions: 3, required: true), + ])); + + $vectors = [ + 'identical' => [1.0, 0.0, 0.0], + 'scaled' => [2.0, 0.0, 0.0], + 'orthogonal' => [0.0, 1.0, 0.0], + 'opposite' => [-1.0, 0.0, 0.0], + ]; + + foreach ($vectors as $name => $embedding) { + $database->createDocument('vectorDistance', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => $name, + 'embedding' => $embedding, + ])); + } + + $target = [1.0, 0.0, 0.0]; + + $results = $database->find('vectorDistance', [ + Query::vectorCosine('embedding', $target), + ]); + + $this->assertCount(4, $results); + + $cosine = []; + foreach ($results as $result) { + $name = $result->getAttribute('name'); + $this->assertIsString($name); + + $distance = $result->getAttribute(Document::DISTANCE); + $this->assertIsFloat($distance, "Cosine distance for '{$name}' must be a float"); + + $cosine[$name] = $distance; + } + + $this->assertEqualsWithDelta(0.0, $cosine['identical'], 0.000001, 'Identical vector must have zero cosine distance'); + $this->assertEqualsWithDelta(0.0, $cosine['scaled'], 0.000001, 'Cosine distance must ignore magnitude'); + $this->assertEqualsWithDelta(1.0, $cosine['orthogonal'], 0.000001, 'Orthogonal vector must have cosine distance of 1'); + $this->assertEqualsWithDelta(2.0, $cosine['opposite'], 0.000001, 'Opposite vector must have cosine distance of 2'); + + $distances = \array_map( + fn (Document $result) => $result->getAttribute(Document::DISTANCE), + $results + ); + + $sorted = $distances; + \sort($sorted); + $this->assertSame($sorted, $distances, 'Results must be returned in ascending distance order'); + + $this->assertEqualsWithDelta(1.0, 1 - $cosine['identical'], 0.000001); + $this->assertEqualsWithDelta(0.0, 1 - $cosine['orthogonal'], 0.000001); + $this->assertEqualsWithDelta(-1.0, 1 - $cosine['opposite'], 0.000001); + + $results = $database->find('vectorDistance', [ + Query::vectorEuclidean('embedding', $target), + ]); + + $euclidean = []; + foreach ($results as $result) { + $name = $result->getAttribute('name'); + $this->assertIsString($name); + $euclidean[$name] = $result->getAttribute(Document::DISTANCE); + } + + $this->assertEqualsWithDelta(0.0, $euclidean['identical'], 0.000001, 'Identical vector must have zero euclidean distance'); + $this->assertEqualsWithDelta(1.0, $euclidean['scaled'], 0.000001, 'Euclidean distance must account for magnitude'); + $this->assertEqualsWithDelta(\sqrt(2), $euclidean['orthogonal'], 0.000001); + $this->assertEqualsWithDelta(2.0, $euclidean['opposite'], 0.000001); + + $results = $database->find('vectorDistance', [ + Query::vectorDot('embedding', $target), + ]); + + $dot = []; + foreach ($results as $result) { + $name = $result->getAttribute('name'); + $this->assertIsString($name); + $dot[$name] = $result->getAttribute(Document::DISTANCE); + } + + $this->assertEqualsWithDelta(-1.0, $dot['identical'], 0.000001, 'The dot operator returns the negative inner product so ascending still means most similar first'); + $this->assertEqualsWithDelta(-2.0, $dot['scaled'], 0.000001); + $this->assertEqualsWithDelta(0.0, $dot['orthogonal'], 0.000001); + $this->assertEqualsWithDelta(1.0, $dot['opposite'], 0.000001); + + $results = $database->find('vectorDistance'); + + $this->assertCount(4, $results); + foreach ($results as $result) { + $this->assertNull( + $result->getAttribute(Document::DISTANCE), + 'A find without a vector query must not return a distance' + ); + } + + $results = $database->find('vectorDistance', [ + Query::select(['name']), + Query::vectorCosine('embedding', $target), + Query::limit(1), + ]); + + $this->assertCount(1, $results); + $this->assertSame('identical', $results[0]->getAttribute('name')); + $this->assertEqualsWithDelta(0.0, $results[0]->getAttribute(Document::DISTANCE), 0.000001); + + $database->createDocument('vectorDistance', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'zero', + 'embedding' => [0.0, 0.0, 0.0], + ])); + + $results = $database->find('vectorDistance', [ + Query::vectorCosine('embedding', $target), + ]); + + $this->assertCount(5, $results); + + $zero = null; + foreach ($results as $result) { + if ($result->getAttribute('name') === 'zero') { + $zero = $result; + } + } + + $this->assertNotNull($zero, 'The zero vector must still be returned'); + $this->assertNull( + $zero->getAttribute(Document::DISTANCE), + 'Cosine distance to a zero vector is undefined and must read back as null, not 0.0' + ); + + $database->deleteCollection('vectorDistance'); + } + + public function testVectorWithAssociativeArray(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(Collection::create( + id: 'vectorAssociative', + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + try { + $database->createAttribute('vectorAssociative', Attribute::vector(key: 'embedding', dimensions: 3)); + + try { + $database->createDocument('vectorAssociative', new Document([ + 'embedding' => ['x' => 1.0, 'y' => 0.0, 'z' => 0.0], + ])); + $this->fail('An associative array must not be stored as a vector'); + } catch (StructureException $exception) { + $this->assertSame( + 'Invalid document structure: Attribute "embedding" has invalid type. Value must be an array of 3 numeric values', + $exception->getMessage(), + ); + } + } finally { + $database->deleteCollection('vectorAssociative'); + } + } } diff --git a/tests/e2e/Adapter/SharedTables/MariaDBTest.php b/tests/e2e/Adapter/SharedTables/MariaDBTest.php index f6574ab0de..25278ca711 100644 --- a/tests/e2e/Adapter/SharedTables/MariaDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MariaDBTest.php @@ -13,26 +13,25 @@ class MariaDBTest extends Base { protected static ?Database $database = null; + protected static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; // Remove once all methods are implemented /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "mariadb"; + return 'mariadb'; } - /** - * @return Database - */ + #[\Override] public function getDatabase(bool $fresh = false): Database { - if (!is_null(self::$database) && !$fresh) { + if (! is_null(self::$database) && ! $fresh) { return self::$database; } @@ -41,21 +40,21 @@ public function getDatabase(bool $fresh = false): Database $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); + $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(7); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); $database = new Database(new MariaDB($pdo), $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') + ->setDatabase($this->testDatabase) ->setSharedTables(true) ->setTenant(999) - ->setNamespace(static::$namespace = '') - ->enableLocks(true) - ; + ->setNamespace(static::$namespace = 'st_'.static::getTestToken()) + ->setLocks(true); if ($database->exists()) { $database->delete(); @@ -64,24 +63,29 @@ public function getDatabase(bool $fresh = false): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "DROP INDEX `{$index}` ON {$sqlTable}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/SharedTables/MongoDBTest.php b/tests/e2e/Adapter/SharedTables/MongoDBTest.php index 61904861c1..5cc98d1935 100644 --- a/tests/e2e/Adapter/SharedTables/MongoDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MongoDBTest.php @@ -5,43 +5,57 @@ use Exception; use Redis; use Tests\E2E\Adapter\Base; +use Tests\E2E\Adapter\Scopes\MongoReadFilterTests; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Permission; +use Utopia\Database\Query; +use Utopia\Database\Role; use Utopia\Mongo\Client; class MongoDBTest extends Base { + use MongoReadFilterTests; + public static ?Database $database = null; + + #[\Override] protected static string $namespace; + /** + * @var array> + */ + private array $emittedSequences = []; + /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "mongodb"; + return 'mongodb'; } /** - * @return Database * @throws Exception */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(11); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); - $schema = 'utopiaTests'; // same as $this->testDatabase + $schema = $this->testDatabase; $client = new Client( $schema, 'mongo', @@ -52,12 +66,13 @@ public function getDatabase(): Database ); $database = new Database(new Mongo($client), $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) ->setDatabase($schema) ->setSharedTables(true) ->setTenant(999) - ->setNamespace(static::$namespace = 'my_shared_tables'); + ->setNamespace(static::$namespace = 'st_'.static::getTestToken()); if ($database->exists()) { $database->delete(); @@ -71,40 +86,121 @@ public function getDatabase(): Database /** * @throws Exception */ + #[\Override] public function testCreateExistsDelete(): void { - // Mongo creates databases on the fly, so exists would always pass. So we override this test to remove the exists check. - $this->assertNotNull($this->getDatabase()->create()); - $this->assertEquals(true, $this->getDatabase()->delete($this->testDatabase)); - $this->assertEquals(true, $this->getDatabase()->create()); - $this->assertEquals($this->getDatabase(), $this->getDatabase()->setDatabase($this->testDatabase)); + $database = $this->getDatabase(); + + $this->assertTrue($database->create()); + $this->assertTrue($database->exists($this->testDatabase)); + $this->assertFalse($database->exists($this->testDatabase.'Absent')); + $this->assertTrue($database->delete($this->testDatabase)); + $this->assertFalse($database->exists($this->testDatabase)); + $this->assertTrue($database->create()); + $this->assertTrue($database->exists($this->testDatabase)); + $this->assertSame($database, $database->setDatabase($this->testDatabase)); } - public function testRenameAttribute(): void + public function testSkipDuplicatesKeepsEachTenantsSequence(): void { - $this->assertTrue(true); - } - - public function testRenameAttributeExisting(): void - { - $this->assertTrue(true); - } - - public function testUpdateAttributeStructure(): void - { - $this->assertTrue(true); + $database = $this->getDatabase(); + $tenant = $database->getTenant(); + $tenantPerDocument = $database->isTenantPerDocument(); + $collection = 'tenantSequences'; + + $documents = fn (string $id): array => [ + new Document(['$id' => $id, '$tenant' => 1, 'name' => 'tenant one']), + new Document(['$id' => $id, '$tenant' => 2, 'name' => 'tenant two']), + ]; + + try { + $database->setTenant(null)->setTenantPerDocument(true); + + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + $database->createDocuments($collection, $documents('existing')); + + foreach (['existing', 'inserted'] as $id) { + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->ignoreDuplicates(fn () => $database->createDocuments( + $collection, + $documents($id), + onNext: function (Document $document): void { + $this->emittedSequences[$document->getId()][(int) $document->getTenant()] = $document->getSequence(); + }, + )); + + foreach ([1 => 'tenant one', 2 => 'tenant two'] as $documentTenant => $name) { + $stored = $database + ->setTenantPerDocument(false) + ->setTenant($documentTenant) + ->getDocument($collection, $id); + + $this->assertSame($name, $stored->getAttribute('name')); + $this->assertNotEmpty($stored->getSequence()); + $this->assertSame( + $id === 'existing' ? null : $stored->getSequence(), + $this->emittedSequences[$id][$documentTenant] ?? null, + "Tenant {$documentTenant}'s {$id} document must carry its own \$sequence, and a skipped one is not emitted", + ); + } + } + } finally { + $database->setTenant($tenant)->setTenantPerDocument($tenantPerDocument); + } } - public function testKeywords(): void + public function testPooledDefinitionsAreListedUnderTheirReadPermissions(): void { - $this->assertTrue(true); + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + $tenant = $database->getTenant(); + $roles = $authorization->getRoles(); + + try { + $database->setTenant(null); + $database->createCollection(Collection::create(id: 'pooledDefinition', permissions: [Permission::read(Role::any())])); + $database->createCollection(Collection::create(id: 'pooledAdminDefinition', permissions: [Permission::read(Role::user('admin'))])); + + $database->setTenant(1); + $database->createCollection(Collection::create(id: 'ownedDefinition', permissions: [Permission::read(Role::any())])); + + $database->setTenant(990); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $queries = [Query::equal('$id', ['pooledDefinition', 'pooledAdminDefinition', 'ownedDefinition'])]; + + $this->assertSame( + ['pooledDefinition'], + \array_map(fn (Document $definition) => $definition->getId(), $database->find(Database::METADATA, $queries)), + ); + $this->assertSame(1, $database->count(Database::METADATA, $queries)); + } finally { + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + $database->setTenant($tenant); + } } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { return true; diff --git a/tests/e2e/Adapter/SharedTables/MySQLTest.php b/tests/e2e/Adapter/SharedTables/MySQLTest.php index 697c42c7ea..d910b5928c 100644 --- a/tests/e2e/Adapter/SharedTables/MySQLTest.php +++ b/tests/e2e/Adapter/SharedTables/MySQLTest.php @@ -4,6 +4,7 @@ use Redis; use Tests\E2E\Adapter\Base; +use Tests\E2E\Adapter\Scopes\MySQLJoinPlanTests; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MySQL; @@ -12,27 +13,28 @@ class MySQLTest extends Base { + use MySQLJoinPlanTests; + public static ?Database $database = null; + protected static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; // Remove once all methods are implemented /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "mysql"; + return 'mysql'; } - /** - * @return Database - */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -41,23 +43,23 @@ public function getDatabase(): Database $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); + $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); + $redis->select(8); - $cache = new Cache(new RedisAdapter($redis)); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); $database = new Database(new MySQL($pdo), $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') + ->setDatabase($this->testDatabase) ->setSharedTables(true) ->setTenant(999) - ->setNamespace(static::$namespace = '') - ->enableLocks(true) - ; + ->setNamespace(static::$namespace = 'st_'.static::getTestToken()) + ->setLocks(true); if ($database->exists()) { $database->delete(); @@ -66,24 +68,29 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getDatabase().'`.`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "DROP INDEX `{$index}` ON {$sqlTable}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/SharedTables/PostgresTest.php b/tests/e2e/Adapter/SharedTables/PostgresTest.php index cb9633c019..f58924ebcf 100644 --- a/tests/e2e/Adapter/SharedTables/PostgresTest.php +++ b/tests/e2e/Adapter/SharedTables/PostgresTest.php @@ -13,25 +13,27 @@ class PostgresTest extends Base { public static ?Database $database = null; + public static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "postgres"; + return 'postgres'; } /** * @reture Adapter */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -40,19 +42,20 @@ public function getDatabase(): Database $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};", $dbUser, $dbPass, Postgres::getPDOAttributes()); + $pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};", $dbUser, $dbPass, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + $redis->select(9); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); $database = new Database(new Postgres($pdo), $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') + ->setDatabase($this->testDatabase) ->setSharedTables(true) ->setTenant(999) - ->setNamespace(static::$namespace = ''); + ->setNamespace(static::$namespace = 'st_'.static::getTestToken()); if ($database->exists()) { $database->delete(); @@ -61,25 +64,30 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = '"' . $this->getDatabase()->getDatabase() . '"."' . $this->getDatabase()->getNamespace() . '_' . $collection . '"'; + $sqlTable = '"'.$this->getDatabase()->getDatabase().'"."'.$this->getDatabase()->getNamespace().'_'.$collection.'"'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN \"{$column}\""; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $key = "\"".$this->getDatabase()->getNamespace()."_".$this->getDatabase()->getTenant()."_{$collection}_{$index}\""; + $key = '"'.$this->getDatabase()->getNamespace().'_'.$this->getDatabase()->getTenant()."_{$collection}_{$index}\""; - $sql = "DROP INDEX \"".$this->getDatabase()->getDatabase()."\".{$key}"; + $sql = 'DROP INDEX "'.$this->getDatabase()->getDatabase()."\".{$key}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/SharedTables/RedisTest.php b/tests/e2e/Adapter/SharedTables/RedisTest.php index 1c2364fc61..6b40db2d68 100644 --- a/tests/e2e/Adapter/SharedTables/RedisTest.php +++ b/tests/e2e/Adapter/SharedTables/RedisTest.php @@ -13,6 +13,7 @@ class RedisTest extends BaseRedisTest * the bootstrap keys (dbs, cols, metadata) under the per-run namespace * and leak them when teardown only scrubs the empty-namespace pattern. */ + #[\Override] protected function configureDatabase(Database $database): void { $database->setSharedTables(true); @@ -20,6 +21,7 @@ protected function configureDatabase(Database $database): void $database->setNamespace(''); } + #[\Override] public function setUp(): void { parent::setUp(); diff --git a/tests/e2e/Adapter/SharedTables/SQLiteTest.php b/tests/e2e/Adapter/SharedTables/SQLiteTest.php index 1701bc8dcc..de5d81ddd1 100644 --- a/tests/e2e/Adapter/SharedTables/SQLiteTest.php +++ b/tests/e2e/Adapter/SharedTables/SQLiteTest.php @@ -7,61 +7,69 @@ use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Index; use Utopia\Database\PDO; +use Utopia\Database\Permission; +use Utopia\Database\Role; class SQLiteTest extends Base { public static ?Database $database = null; + public static ?PDO $pdo = null; + + #[\Override] protected static string $namespace; // Remove once all methods are implemented /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "sqlite"; + return 'sqlite'; } - /** - * @return Database - */ + #[\Override] public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } - $db = __DIR__."/database.sql"; + $db = __DIR__.'/database_'.static::getTestToken().'.sql'; if (file_exists($db)) { unlink($db); } $dsn = $db; - //$dsn = 'memory'; // Overwrite for fast tests - $pdo = new PDO("sqlite:" . $dsn, null, null, SQLite::getPDOAttributes()); + // $dsn = 'memory'; // Overwrite for fast tests + $pdo = new PDO('sqlite:'.$dsn, null, null, self::PDO_ATTRIBUTES); $redis = new Redis(); $redis->connect('redis'); - $redis->flushAll(); + $redis->select(10); - $cache = new Cache(new RedisAdapter($redis)); + $cache = new Cache((new RedisAdapter($redis))->setMaxRetries(3)); - $adapter = new SQLite($pdo); - $adapter->setEmulateMySQL(true); + $adapter = new class ($pdo) extends SQLite { + #[\Override] + protected bool $emulateMySQL = true; + }; $database = new Database($adapter, $cache); + assert(self::$authorization !== null); $database ->setAuthorization(self::$authorization) - ->setDatabase('utopiaTests') + ->setDatabase($this->testDatabase) ->setSharedTables(true) ->setTenant(999) - ->setNamespace(static::$namespace = ''); + ->setNamespace(static::$namespace = 'st_'.static::getTestToken().'_'.uniqid()); if ($database->exists()) { $database->delete(); @@ -70,24 +78,78 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + public function testIndexNamesUseTheFilteredTenant(): void + { + $database = $this->getDatabase(); + $collection = 'tenantIndexNames'; + + $database->withTenant('acme.1', function () use ($database, $collection): void { + $database->createCollection(Collection::create(id: $collection, attributes: [ + Attribute::string(key: 'email', size: 64, required: true), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + $index = Index::unique(key: 'email', attributes: ['email']); + $database->createIndex($collection, $index); + + $this->assertTrue($database->getAdapter()->createIndex($collection, $index), 'Creating an existing index must be a no-op'); + $this->assertSame([$database->getNamespace().'_acme1_'.$collection.'_email'], $this->emailIndexes($database, $collection)); + + $database->deleteIndex($collection, 'email'); + $this->assertSame([], $this->emailIndexes($database, $collection), 'The index deleteIndex() reported as dropped must be gone'); + + $database->createDocument($collection, new Document(['email' => 'user@example.com'])); + $database->createDocument($collection, new Document(['email' => 'user@example.com'])); + + $this->assertSame(2, $database->count($collection), 'A deleted unique index must stop rejecting duplicates'); + }); + } + + /** + * @return list + */ + private function emailIndexes(Database $database, string $collection): array + { + $adapter = $database->getAdapter(); + $this->assertInstanceOf(SQLite::class, $adapter); + + $names = []; + foreach ($adapter->rawQuery("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?", [$database->getNamespace().'_'.$collection]) as $index) { + $name = $index->getAttribute('name'); + $this->assertIsString($name); + if (\str_ends_with($name, '_email')) { + $names[] = $name; + } + } + + return $names; + } + + #[\Override] protected function deleteColumn(string $collection, string $column): bool { - $sqlTable = "`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; + $sqlTable = '`'.$this->getDatabase()->getNamespace().'_'.$collection.'`'; $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; } + #[\Override] protected function deleteIndex(string $collection, string $index): bool { - $index = "`".$this->getDatabase()->getNamespace()."_".$this->getDatabase()->getTenant()."_{$collection}_{$index}`"; + $index = '`'.$this->getDatabase()->getNamespace().'_'.$this->getDatabase()->getTenant()."_{$collection}_{$index}`"; $sql = "DROP INDEX {$index}"; + assert(self::$pdo !== null); self::$pdo->exec($sql); return true; diff --git a/tests/e2e/Adapter/Support/EventRecorder.php b/tests/e2e/Adapter/Support/EventRecorder.php new file mode 100644 index 0000000000..1048a2c3a2 --- /dev/null +++ b/tests/e2e/Adapter/Support/EventRecorder.php @@ -0,0 +1,88 @@ + */ + private array $events = []; + + private bool $recording = true; + + public function __construct( + private readonly string $name, + ) { + } + + #[\Override] + public function getName(): string + { + return $this->name; + } + + #[\Override] + public function handle(Domain $event): void + { + if ($this->recording) { + $this->events[] = $event; + } + } + + /** + * Stop recording and return the events recorded so far. + * + * @return list + */ + public function stop(): array + { + $this->recording = false; + + return \array_map(static fn (Domain $event): Event => $event->event, $this->events); + } + + /** + * The typed events recorded for $event so far, in the order they fired. + * + * @return list + */ + public function received(Event $event): array + { + return \array_values(\array_filter( + $this->events, + static fn (Domain $recorded): bool => $recorded->event === $event, + )); + } + + /** + * The documents the events recorded for $event so far carry, in the order they fired. + * + * @return list + * + * @throws UnexpectedValueException When an event recorded for $event carries no document + */ + public function getDocuments(Event $event): array + { + $documents = []; + foreach ($this->received($event) as $recorded) { + $document = \property_exists($recorded, 'document') ? $recorded->document : null; + if (! $document instanceof Document) { + throw new UnexpectedValueException($event->value.' recorded a '.$recorded::class.', which carries no document'); + } + $documents[] = $document; + } + + return $documents; + } +} diff --git a/tests/e2e/Adapter/Support/Post.php b/tests/e2e/Adapter/Support/Post.php new file mode 100644 index 0000000000..582f6cd15a --- /dev/null +++ b/tests/e2e/Adapter/Support/Post.php @@ -0,0 +1,24 @@ +getAttribute('title', ''); + + return $title; + } + + public function getContent(): string + { + /** @var string $content */ + $content = $this->getAttribute('content', ''); + + return $content; + } +} diff --git a/tests/e2e/Adapter/Support/User.php b/tests/e2e/Adapter/Support/User.php new file mode 100644 index 0000000000..01d41c8ccb --- /dev/null +++ b/tests/e2e/Adapter/Support/User.php @@ -0,0 +1,29 @@ +getAttribute('email', ''); + + return $email; + } + + public function getName(): string + { + /** @var string $name */ + $name = $this->getAttribute('name', ''); + + return $name; + } + + public function isActive(): bool + { + return $this->getAttribute('status') === 'active'; + } +} diff --git a/tests/unit/Adapter/AdapterDatabaseRenameTest.php b/tests/unit/Adapter/AdapterDatabaseRenameTest.php new file mode 100644 index 0000000000..c0814bc61b --- /dev/null +++ b/tests/unit/Adapter/AdapterDatabaseRenameTest.php @@ -0,0 +1,153 @@ +> + */ + private array $databases = []; + + /** + * A table another connection creates in the old database while the rename runs. + */ + private ?string $created = null; + + /** + * @return iterable + */ + public static function adapters(): iterable + { + yield 'MariaDB' => [new MariaDB(new stdClass())]; + yield 'MySQL' => [new MySQL(new stdClass())]; + yield 'Postgres' => [new Postgres(new stdClass())]; + yield 'Memory' => [new Memory()]; + } + + #[DataProvider('adapters')] + public function testSharedTablesRefuseTheRename(Adapter $adapter): void + { + $adapter->setSharedTables(true); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot rename a database while shared tables are enabled'); + + $adapter->update('library', 'archive'); + } + + public function testMariaDBMovesEveryTableAndDropsTheOldDatabase(): void + { + $this->databases = ['library' => ['ns_authors', 'ns_books']]; + + $this->assertTrue($this->mariadb()->update('library', 'archive')); + + $this->assertSame(['archive' => ['ns_authors', 'ns_books']], $this->databases); + } + + public function testMariaDBKeepsAnOldDatabaseThatGainedATableDuringTheRename(): void + { + $this->databases = ['library' => ['ns_authors', 'ns_books']]; + $this->created = 'ns_loans'; + + try { + $this->mariadb()->update('library', 'archive'); + $this->fail('An old database that is not empty must not be dropped'); + } catch (DatabaseException $error) { + $this->assertSame('Database library was renamed to archive but holds tables created during the rename, so it was not dropped', $error->getMessage()); + } + + $this->assertSame(['library' => ['ns_loans'], 'archive' => ['ns_authors', 'ns_books']], $this->databases); + } + + private function mariadb(): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback($this->statement(...)); + + return new MariaDB($pdo); + } + + private function statement(string $query): PDOStatement + { + $bound = new stdClass(); + $bound->values = []; + $statement = $this->createStub(PDOStatement::class); + $statement->method('bindValue')->willReturnCallback(static function (int|string $parameter, mixed $value) use ($bound): bool { + $bound->values[] = $value; + + return true; + }); + $statement->method('execute')->willReturnCallback(function () use ($query): bool { + $this->apply($query); + + return true; + }); + $statement->method('fetchAll')->willReturnCallback(fn (): array => $this->rows($query, $bound->values)); + $statement->method('closeCursor')->willReturn(true); + + return $statement; + } + + private function apply(string $query): void + { + \preg_match_all('/`([^`]+)`/', $query, $identifiers); + $names = $identifiers[1]; + + match (true) { + \str_contains($query, 'CREATE DATABASE') => $this->databases[$names[0]] = [], + \str_contains($query, 'DROP DATABASE') => $this->drop($names[0]), + \str_contains($query, 'RENAME TABLE') => $this->move($names), + default => null, + }; + } + + /** + * @param list $names database, table, database, table for each move + */ + private function move(array $names): void + { + foreach (\array_chunk($names, 4) as [$from, $table, $to]) { + $this->databases[$from] = \array_values(\array_diff($this->databases[$from], [$table])); + $this->databases[$to][] = $table; + } + + if ($this->created !== null) { + $this->databases[$names[0]][] = $this->created; + } + } + + private function drop(string $database): void + { + unset($this->databases[$database]); + } + + /** + * @param list $values + * @return list> + */ + private function rows(string $query, array $values): array + { + $database = \is_string($values[0] ?? null) ? $values[0] : ''; + + if (\str_contains($query, 'SCHEMATA')) { + return isset($this->databases[$database]) ? [['SCHEMA_NAME' => $database]] : []; + } + + return \array_map(static fn (string $table): array => ['TABLE_NAME' => $table], $this->databases[$database] ?? []); + } +} diff --git a/tests/unit/Adapter/AddTableColumnTest.php b/tests/unit/Adapter/AddTableColumnTest.php new file mode 100644 index 0000000000..8a1be6800a --- /dev/null +++ b/tests/unit/Adapter/AddTableColumnTest.php @@ -0,0 +1,24 @@ +setDatabase('database'); + $adapter->setNamespace('namespace'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Vector columns are only supported on PostgreSQL'); + + $adapter->createAttribute('movies', Attribute::vector(key: 'embedding', dimensions: 3)); + } +} diff --git a/tests/unit/Adapter/AggregateReference.php b/tests/unit/Adapter/AggregateReference.php new file mode 100644 index 0000000000..37d4731f9f --- /dev/null +++ b/tests/unit/Adapter/AggregateReference.php @@ -0,0 +1,24 @@ + $queries + */ + public function builtAggregate(string $operation, Document $collection, array $queries, ?int $max): Statement; + + /** + * @param list $bindings + * @return list + */ + public function boundValues(array $bindings): array; +} diff --git a/tests/unit/Adapter/AttributeSupportToggleTest.php b/tests/unit/Adapter/AttributeSupportToggleTest.php new file mode 100644 index 0000000000..081dfd1f66 --- /dev/null +++ b/tests/unit/Adapter/AttributeSupportToggleTest.php @@ -0,0 +1,41 @@ + + */ + public static function schemaAdapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'redis' => [static fn (): Adapter => new RedisAdapter(self::createStub(Redis::class))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('schemaAdapters')] + public function testSchemaAdaptersReportThatAttributesStayDefined(Closure $adapter): void + { + $adapter = $adapter(); + + $this->assertFalse($adapter->hasFeature(Feature\Schemaless::class), 'An adapter that always enforces its schema has no schemaless mode'); + $this->assertTrue($adapter->supports(Capability::DefinedAttributes)); + } +} diff --git a/tests/unit/Adapter/BaseAdapterStateTest.php b/tests/unit/Adapter/BaseAdapterStateTest.php new file mode 100644 index 0000000000..08d46f6b30 --- /dev/null +++ b/tests/unit/Adapter/BaseAdapterStateTest.php @@ -0,0 +1,169 @@ +assertSame([], $adapter->getMetadata()); + $this->assertSame($adapter, $adapter->setMetadata('a', 1)); + $this->assertSame($adapter, $adapter->setMetadata('b', ['nested' => true])); + $this->assertSame($adapter, $adapter->setMetadata('a', 2)); + $this->assertSame(['a' => 2, 'b' => ['nested' => true]], $adapter->getMetadata()); + + $adapter->resetMetadata(); + $this->assertSame([], $adapter->getMetadata()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAKeyThePatternEngineCannotFilterIsAnError(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $jit = \ini_get('pcre.jit'); + $limit = \ini_get('pcre.backtrack_limit'); + \ini_set('pcre.jit', '0'); + \ini_set('pcre.backtrack_limit', '0'); + + try { + $adapter->filter('unfilterable_'.\uniqid().' !@#'); + $this->fail('a key the pattern engine fails on must not pass as filtered'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to filter key', $error->getMessage()); + } finally { + \ini_set('pcre.jit', (string) $jit); + \ini_set('pcre.backtrack_limit', (string) $limit); + } + } + + public function testTenantHookIsFoundAmongTheOtherWriteHooks(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $adapter->addWriteHook(new class () extends Interceptor { + }); + + $this->assertNull($adapter->getTenantHook()); + + $tenancy = new Tenancy(); + $adapter->addWriteHook($tenancy); + + $this->assertSame($tenancy, $adapter->getTenantHook()); + + $adapter->removeWriteHook(Tenancy::class); + + $this->assertNull($adapter->getTenantHook()); + $this->assertCount(1, $adapter->getWriteHooks()); + } + + public function testRemovingAWriteHookInstanceKeepsAnotherOfItsClass(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $kept = new Tenancy(); + $removed = new Tenancy('owner'); + $adapter->addWriteHook($kept); + $adapter->addWriteHook($removed); + + $adapter->removeWriteHook($removed); + + $this->assertSame([$kept], $adapter->getWriteHooks()); + } + + public function testTenantHookFollowsSharedTablesOnAWrite(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection($this->notes('own')); + $database->createDocument('own', new Document(['$id' => 'first', 'body' => 'one'])); + + $this->assertNull($adapter->getTenantHook()); + + $database->setSharedTables(true)->setTenant(7); + $database->setNamespace(self::NAMESPACE . '_shared'); + $database->create(); + $database->createCollection($this->notes('shared')); + $database->createDocument('shared', new Document(['$id' => 'second', 'body' => 'two'])); + + $this->assertNotNull($adapter->getTenantHook()); + $this->assertSame(7, $database->getDocument('shared', 'second')->getTenant()); + + $database->setSharedTables(false)->setTenant(null); + $database->setNamespace(self::NAMESPACE); + $database->createDocument('own', new Document(['$id' => 'third', 'body' => 'three'])); + + $this->assertNull($adapter->getTenantHook()); + } + + public function testClearingTheGlobalTimeoutForgetsEveryEvent(): void + { + $adapter = new TimeoutRecordingAdapter(); + $adapter->setTimeout(100); + $adapter->setTimeout(50, Event::DocumentFind); + + $this->assertSame(100, $adapter->getTimeout()); + $this->assertSame(50, $adapter->getTimeout(Event::DocumentFind)); + + $adapter->clearTimeout(); + + $this->assertSame(0, $adapter->getTimeout()); + $this->assertSame(0, $adapter->getTimeout(Event::DocumentFind)); + $this->assertSame(0, $adapter->getTimeout(Event::DocumentCreate)); + } + + public function testFilterStaysCorrectPastTheCacheLimit(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $count = self::FILTERED_KEY_CACHE_LIMIT + 4; + + for ($index = 0; $index < $count; $index++) { + $this->assertSame('key_' . $index . '-x', $adapter->filter('key_' . $index . '-x !@#')); + } + + for ($index = 0; $index < $count; $index++) { + $this->assertSame('key_' . $index . '-x', $adapter->filter('key_' . $index . '-x !@#')); + } + } + + private function notes(string $id): Collection + { + return Collection::create( + id: $id, + attributes: [Attribute::string('body', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ); + } +} diff --git a/tests/unit/Adapter/BuilderStatementTest.php b/tests/unit/Adapter/BuilderStatementTest.php new file mode 100644 index 0000000000..972072a7f4 --- /dev/null +++ b/tests/unit/Adapter/BuilderStatementTest.php @@ -0,0 +1,382 @@ +from() and every statement the adapter builds must compile to the same SQL + * and bindings. + */ +final class BuilderStatementTest extends TestCase +{ + private const string TENANT_CONDITION = '/((?:[`"][^`"]+[`"]\.)*[`"][^`"]+[`"])\._tenant IN \(\?\)/'; + + /** @var list}> */ + private array $statements = []; + + /** + * @return iterable, bool}> + */ + public static function adapters(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class, 'Postgres' => Postgres::class, 'SQLite' => SQLite::class] as $name => $class) { + yield "{$name}/plain" => [$class, false]; + yield "{$name}/shared" => [$class, true]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('adapters')] + public function testReadingACollectionCompilesAsBuilderOverTheCollectionDid(string $class, bool $shared): void + { + $this->assertSame($this->expected($class, $shared)['reads'], $this->reads($class, $shared)); + } + + /** + * @param class-string $class + */ + #[DataProvider('adapters')] + public function testTheStatementsTheAdapterBuildsItselfAreUnchanged(string $class, bool $shared): void + { + $this->assertSame($this->expected($class, $shared)['internal'], $this->internal($class, $shared)); + } + + /** + * @param class-string $class + */ + #[DataProvider('adapters')] + public function testEachTableOfASharedTablesReadIsKeptToTheTenantOnce(string $class, bool $shared): void + { + if (! $shared) { + $this->assertStringNotContainsString(Storage::TENANT, \implode(' ', \array_column($this->reads($class, false), 0))); + + return; + } + + $reads = $this->reads($class, true); + $internal = $this->internal($class, true); + $adapter = $this->adapter($class, true); + $authors = $this->quoted($adapter, $this->raw($adapter, 'authors')); + $aliased = $adapter->builder()->from('authors', 'author')->select(['name'])->build()->query; + + $this->assertSame([$authors], $this->tenantConditions($reads['select'][0])); + $this->assertSame([$authors], $this->tenantConditions($reads['aggregate'][0])); + $this->assertSame([$authors], $this->tenantConditions($reads['update'][0])); + $this->assertSame([$authors], $this->tenantConditions($reads['delete'][0])); + $this->assertSame([$authors], $this->tenantConditions($reads['reused'][0])); + $this->assertSame([$this->quoted($adapter, $this->raw($adapter, Storage::permissionsTable('authors')))], $this->tenantConditions($reads['permissions'][0])); + $this->assertSame([$this->quoted($adapter, $this->raw($adapter, Database::METADATA))], $this->tenantConditions($reads['metadata'][0])); + $this->assertEqualsCanonicalizing([$authors, $this->quoted($adapter, 'book'), $this->quoted($adapter, 'review')], $this->tenantConditions($reads['join'][0])); + $this->assertSame([$this->quoted($adapter, 'author')], $this->tenantConditions($aliased)); + $this->assertSame([$this->quoted($adapter, 'table_main')], $this->tenantConditions($internal['find']['statements'][0][0])); + $this->assertSame([], $this->tenantConditions($reads['insert'][0])); + } + + /** + * @param class-string $class + */ + #[DataProvider('adapters')] + public function testARightJoinPairsWithTheAliasFromNamesTheCollectionUnder(string $class, bool $shared): void + { + $adapter = $this->adapter($class, $shared); + + $statement = $adapter->builder() + ->from('authors', 'author') + ->rightJoin('reviews', 'author._uid', 'review.authorId', '=', 'review') + ->select(['author.name', 'review.stars']) + ->build(); + + $this->assertStringContainsString( + 'FROM '.$this->quoted($adapter, $this->raw($adapter, 'authors')).' AS '.$this->quoted($adapter, 'author'), + $statement->query, + ); + if (! $shared) { + $this->assertSame([], $statement->bindings); + + return; + } + $this->assertContains($this->quoted($adapter, 'author'), $this->tenantConditions($statement->query)); + $this->assertNotContains($this->quoted($adapter, $this->raw($adapter, 'authors')), $this->tenantConditions($statement->query)); + } + + /** + * @param class-string $class + */ + #[DataProvider('adapters')] + public function testABuilderThatNamesNoCollectionIsKeptToNoTenant(string $class, bool $shared): void + { + $adapter = $this->adapter($class, $shared); + $table = $this->raw($adapter, 'authors'); + + $this->assertSame('SELECT 1', $adapter->builder()->fromNone()->selectRaw('1')->build()->query); + $this->assertSame( + 'SELECT '.$this->quoted($adapter, 'name').' FROM '.$this->quoted($adapter, $table), + $adapter->builder()->fromTable($table)->select(['name'])->build()->query, + ); + $this->assertSame( + 'INSERT INTO '.$this->quoted($adapter, $table).' ('.$this->quoted($adapter, 'name').') VALUES (?)', + $adapter->builder()->into($table)->set(['name' => 'new'])->insert()->query, + ); + } + + /** + * @param class-string $class + * @return array + */ + private function expected(string $class, bool $shared): array + { + $fixture = \json_decode((string) \file_get_contents(__DIR__.'/Data/Builder/statements.json'), true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($fixture); + $key = (new \ReflectionClass($class))->getShortName().($shared ? '/shared' : '/plain'); + $this->assertArrayHasKey($key, $fixture); + $this->assertIsArray($fixture[$key]); + + return $fixture[$key]; + } + + /** + * @return list + */ + private function tenantConditions(string $query): array + { + \preg_match_all(self::TENANT_CONDITION, $query, $matches); + + return $matches[1]; + } + + private function quoted(SQL $adapter, string $identifier): string + { + $quote = (new ReflectionMethod($adapter, 'getIdentifierQuote'))->invoke($adapter); + $this->assertIsString($quote); + + return \implode('.', \array_map(static fn (string $part): string => $quote.$part.$quote, \explode('.', $identifier))); + } + + private function raw(SQL $adapter, string $collection): string + { + $table = (new ReflectionMethod($adapter, 'getTableRaw'))->invoke($adapter, $collection); + $this->assertIsString($table); + + return $table; + } + + /** + * @param class-string $class + * @return array + */ + private function reads(string $class, bool $shared): array + { + $adapter = $this->adapter($class, $shared); + $authors = $this->raw($adapter, 'authors'); + $result = []; + $from = static fn (string $collection): SQLBuilder => $adapter->builder()->from($collection); + + $result['select'] = $this->compile(static fn () => $from('authors') + ->select(['name', '$id']) + ->filter([Query::equal('name', ['x']), Query::greaterThan('$sequence', 3)]) + ->sortDesc('$createdAt') + ->limit(5) + ->offset(2) + ->build()); + $result['join'] = $this->compile(static fn () => $from('authors') + ->join('books', $authors.'._uid', 'book.authorId', '=', 'book') + ->leftJoin('reviews', $authors.'._uid', 'review.authorId', '=', 'review') + ->select([$authors.'.name', 'book.pages', 'review.stars']) + ->filter([Query::greaterThan('book.pages', 10)]) + ->sortAsc($authors.'.name') + ->limit(3) + ->build()); + $result['metadata'] = $this->compile(static fn () => $from(Database::METADATA) + ->select(['name']) + ->filter([Query::equal('$id', ['authors'])]) + ->build()); + $result['permissions'] = $this->compile(static fn () => $from(Storage::permissionsTable('authors')) + ->select([Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION]) + ->filter([Query::equal(Storage::PERMISSIONS_DOCUMENT, ['a1'])]) + ->build()); + $result['metadataPermissions'] = $this->compile(static fn () => $from(Storage::permissionsTable(Database::METADATA)) + ->select([Storage::PERMISSIONS_PERMISSION]) + ->build()); + $result['rightJoin'] = $this->compile(static fn () => $from('authors') + ->rightJoin('reviews', $authors.'._uid', 'review.authorId', '=', 'review') + ->select([$authors.'.name', 'review.stars']) + ->build()); + if ($from('authors') instanceof FullOuterJoins) { + $result['fullOuterJoin'] = $this->compile(static fn () => $from('authors') + ->fullOuterJoin('reviews', $authors.'._uid', 'review.authorId', '=', 'review') + ->select([$authors.'.name', 'review.stars']) + ->build()); + } + $result['aggregate'] = $this->compile(static fn () => $from('authors') + ->count('*', 'total') + ->groupBy(['name']) + ->build()); + $result['update'] = $this->compile(static fn () => $from('authors') + ->set(['name' => 'renamed']) + ->filter([Query::equal('$id', ['a1'])]) + ->update()); + $result['delete'] = $this->compile(static fn () => $from('authors') + ->filter([Query::equal('$id', ['a1'])]) + ->delete()); + $result['insert'] = $this->compile(static fn () => $adapter->builder() + ->into($authors) + ->set(['_uid' => 'a9', 'name' => 'new']) + ->insert()); + $result['reused'] = $this->compile(static function () use ($from) { + $builder = $from('authors')->select(['name']); + $builder->build(); + + return $builder->build(); + }); + + return $result; + } + + /** + * @param Closure(): Statement $build + * @return array{string, mixed} + */ + private function compile(Closure $build): array + { + try { + $statement = $build(); + + return [$statement->query, $statement->bindings]; + } catch (Throwable $error) { + return [$error::class, $error->getMessage()]; + } + } + + /** + * @param class-string $class + * @return array}>}> + */ + private function internal(string $class, bool $shared): array + { + $result = []; + $collection = new Document(['$id' => 'authors', 'attributes' => [], 'indexes' => []]); + $capture = function (string $name, Closure $run) use (&$result, $class, $shared): void { + $this->statements = []; + $adapter = $this->adapter($class, $shared); + try { + $run($adapter); + $outcome = 'ok'; + } catch (Throwable $error) { + $outcome = $error::class.': '.$error->getMessage(); + } + $result[$name] = ['outcome' => $outcome, 'statements' => $this->statements]; + }; + $document = static fn (string $id, array $permissions): Document => new Document([ + '$id' => $id, + '$permissions' => $permissions, + '$createdAt' => '2026-09-30 00:00:00.000', + '$updatedAt' => '2026-09-30 00:00:00.000', + '$tenant' => $shared ? 7 : null, + 'name' => 'one', + ]); + $fullOuterJoin = Query::fullOuterJoin('books', 'book', [Query::on('$id', 'authorId')]); + + $capture('ping', static fn (SQL $adapter) => $adapter->ping()); + $capture('id', static fn (SQL $adapter) => $adapter->id()); + $capture('exists', static fn (SQL $adapter) => $adapter->exists('database')); + $capture('collectionExists', static fn (SQL $adapter) => $adapter->collectionExists('database', 'authors')); + $capture('size', static fn (SQL $adapter) => $adapter->getSizeOfCollection('authors')); + $capture('sizeOnDisk', static fn (SQL $adapter) => $adapter->getSizeOfCollectionOnDisk('authors')); + $capture('create', static fn (SQL $adapter) => $adapter->createDocument($collection, $document('a1', ['read("any")', 'update("user:1")']))); + $capture('update', static fn (SQL $adapter) => $adapter->updateDocument($collection, 'a1', $document('a1', ['read("user:2")']), false)); + $capture('rename', static fn (SQL $adapter) => $adapter->updateDocument($collection, 'a1', $document('a2', ['read("user:2")']), false)); + $capture('delete', static fn (SQL $adapter) => $adapter->deleteDocument($collection, 'a1')); + $capture('upsert', static fn (SQL $adapter) => $adapter->upsertDocuments($collection, [new Change(new Document(['$id' => 'a1', '$permissions' => ['read("any")'], '$tenant' => $shared ? 7 : null]), $document('a1', ['read("user:3")']))])); + $capture('find', static fn (SQL $adapter) => $adapter->find($collection, [Query::equal('name', ['one'])], limit: 10)); + $capture('findAuthorized', static function (SQL $adapter) use ($collection) { + $authorization = new Authorization(); + $authorization->addRole('any'); + $adapter->setAuthorization($authorization); + + return $adapter->find($collection, [Query::join('books', 'book', [Query::on('$id', 'authorId')])], limit: 10); + }); + $capture('fullOuterJoin', static fn (SQL $adapter) => $adapter->find($collection, [$fullOuterJoin], limit: 10)); + $capture('fullOuterJoinRandom', static fn (SQL $adapter) => $adapter->find($collection, [$fullOuterJoin], limit: 10, orderAttributes: [''], orderTypes: [OrderDirection::Random])); + $capture('fullOuterJoinCount', static fn (SQL $adapter) => $adapter->count($collection, [$fullOuterJoin], 5)); + $capture('count', static fn (SQL $adapter) => $adapter->count($collection, [Query::equal('name', ['one'])], 5)); + $capture('sum', static fn (SQL $adapter) => $adapter->sum($collection, 'pages', [Query::equal('name', ['one'])], 5)); + + return $result; + } + + /** + * @param class-string $class + */ + private function adapter(string $class, bool $shared): SQL + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $index = \count($this->statements); + $this->statements[] = [$query, []]; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('fetch')->willReturn(false); + $statement->method('fetchColumn')->willReturn(\str_contains($query, 'dbstat') ? '0' : false); + $statement->method('rowCount')->willReturn(1); + $statement->method('closeCursor')->willReturn(true); + $statement->method('bindValue')->willReturnCallback(function (int|string $position, mixed $value) use ($index): bool { + $this->statements[$index][1][] = [$position, $value]; + + return true; + }); + $statement->method('bindParam')->willReturnCallback(function (int|string $position, mixed &$value) use ($index): bool { + $this->statements[$index][1][] = [$position, $value]; + + return true; + }); + + return $statement; + }); + $pdo->method('lastInsertId')->willReturn('12'); + $pdo->method('inTransaction')->willReturn(false); + $pdo->method('beginTransaction')->willReturn(true); + $pdo->method('commit')->willReturn(true); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + if ($shared) { + $adapter->setSharedTables(true); + $adapter->setTenant(7); + } + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $adapter->addWriteHook(new Permissions()); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/BuilderTest.php b/tests/unit/Adapter/BuilderTest.php new file mode 100644 index 0000000000..036b4a5613 --- /dev/null +++ b/tests/unit/Adapter/BuilderTest.php @@ -0,0 +1,146 @@ + + */ + public static function dialects(): iterable + { + yield 'MariaDB' => [new MariaDB(new stdClass()), MySQLSchema::class]; + yield 'Postgres' => [new Postgres(new stdClass()), PostgresSchema::class]; + yield 'SQLite' => [new SQLite(new PDO('sqlite::memory:')), MySQLSchema::class]; + } + + /** + * @param class-string $schema + */ + #[DataProvider('dialects')] + public function testEachSqlAdapterHandsOutABuilderAndASchemaInItsDialect(SQL $adapter, string $schema): void + { + $adapter->setDatabase('builder'); + $adapter->setNamespace('dialect'); + + $this->assertTrue($adapter->hasFeature(Feature\QueryBuilder::class)); + $this->assertInstanceOf($schema, $adapter->schema()); + } + + public function testTheDatabaseRunsABuilderOverACollection(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'first', 'title' => 'hello'])); + + $rows = $database->getAuthorization()->skip( + fn (): array|int => $database->from(self::COLLECTION)->select(['$id', 'title'])->execute(), + ); + + $this->assertIsArray($rows); + $this->assertCount(1, $rows); + $this->assertInstanceOf(Document::class, $rows[0]); + $this->assertSame('hello', $rows[0]->getAttribute('title')); + } + + public function testTheDatabaseHandsOutASchemaBuilder(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + + $this->assertInstanceOf(MySQLSchema::class, $database->schema()); + } + + public function testAPoolHandsOutTheBuilderOfItsConnection(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $database = $this->database($adapter); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'first', 'title' => 'pooled'])); + $pool = $this->pool($adapter); + $pool->setDatabase($database->getDatabase()); + $pool->setNamespace($database->getNamespace()); + + $statement = $pool->builder()->from(self::COLLECTION)->select(['title'])->build(); + $rows = $pool->rawQuery($statement->query, $statement->bindings); + + $this->assertTrue($pool->hasFeature(Feature\QueryBuilder::class)); + $this->assertInstanceOf(MySQLSchema::class, $pool->schema()); + $this->assertSame(['pooled'], \array_map(static fn (Document $row): mixed => $row->getAttribute('title'), $rows)); + } + + public function testAnAdapterWithoutABuilderIsRefused(): void + { + $database = $this->database(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query builder is not supported by this adapter'); + + $database->getAuthorization()->skip(fn (): mixed => $database->from(self::COLLECTION)); + } + + public function testAPoolOverAnAdapterWithoutABuilderRefusesIt(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support query builder'); + + $this->pool(new Memory())->schema(); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('builder') + ->setNamespace('builder_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + return $database; + } + + private function pool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(static fn (callable $callback): mixed => $callback($adapter)); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } +} diff --git a/tests/unit/Adapter/BuildsAggregates.php b/tests/unit/Adapter/BuildsAggregates.php new file mode 100644 index 0000000000..de00a43a3e --- /dev/null +++ b/tests/unit/Adapter/BuildsAggregates.php @@ -0,0 +1,53 @@ + $queries + */ + public function builtAggregate(string $operation, Document $collection, array $queries, ?int $max): Statement + { + $name = $this->filter($collection->getId()); + $builder = $this->newBuilder($name, Query::DEFAULT_ALIAS); + $builder->filter($queries); + + $perDocument = $collection->getAttribute('documentSecurity', false) || $collection->getId() === Database::METADATA; + if ($this->authorization->getStatus() && $perDocument) { + $builder->addHook($this->newPermissionHook($name, $this->authorization->getRoles())); + } + + if ($max === null) { + $operation === 'count' ? $builder->count('1', 'sum') : $builder->sum('price', 'sum'); + + return $builder->build(); + } + + $operation === 'count' ? $builder->selectRaw('1') : $builder->select(['price']); + $builder->limit($max); + $outer = $this->builder(); + $outer->fromSub($builder, 'table_count'); + $operation === 'count' ? $outer->count('1', 'sum') : $outer->sum('price', 'sum'); + + return $outer->build(); + } + + /** + * @param list $bindings + * @return list + */ + public function boundValues(array $bindings): array + { + return \array_map(fn (mixed $value): mixed => \is_float($value) ? $this->getFloatPrecision($value) : $value, $bindings); + } +} diff --git a/tests/unit/Adapter/CapabilityIdiomTest.php b/tests/unit/Adapter/CapabilityIdiomTest.php new file mode 100644 index 0000000000..40453937d5 --- /dev/null +++ b/tests/unit/Adapter/CapabilityIdiomTest.php @@ -0,0 +1,80 @@ + $lines) { + foreach ($lines as $number => $line) { + if (\preg_match_all('/(\S+)\s+instanceof\s+\\\\?(?:Utopia\\\\Database\\\\Adapter\\\\)?Feature\\\\/', $line, $matches) === 0) { + continue; + } + + foreach ($matches[1] as $subject) { + if (\ltrim($subject, '(!') !== '$this') { + $offences[] = $path.':'.($number + 1); + } + } + } + } + + $this->assertSame([], $offences, 'Ask hasFeature() instead of instanceof Feature\\'); + } + + public function testNoSourceProbesAnAdapterWithMethodExists(): void + { + $offences = []; + + foreach (self::sources() as $path => $lines) { + foreach ($lines as $number => $line) { + if (\preg_match('/method_exists\(\s*\$(?:this->)?adapter\b/', $line) === 1) { + $offences[] = $path.':'.($number + 1); + } + } + } + + $this->assertSame([], $offences, 'Ask hasFeature() instead of method_exists()'); + } + + public function testTheScanFindsTheSources(): void + { + $this->assertArrayHasKey('Adapter/Pool.php', self::sources()); + $this->assertArrayHasKey('Database.php', self::sources()); + } + + /** + * @return array> + */ + private static function sources(): array + { + $root = \dirname((string) (new ReflectionClass(Adapter::class))->getFileName()); + $sources = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $lines = \file($file->getPathname(), FILE_IGNORE_NEW_LINES); + $sources[\substr($file->getPathname(), \strlen($root) + 1)] = $lines === false ? [] : $lines; + } + + return $sources; + } +} diff --git a/tests/unit/Adapter/CastCountingSQLite.php b/tests/unit/Adapter/CastCountingSQLite.php new file mode 100644 index 0000000000..e03dca8bf2 --- /dev/null +++ b/tests/unit/Adapter/CastCountingSQLite.php @@ -0,0 +1,50 @@ +> The ids of each batch of collection documents cast after a read + */ + public array $batches = []; + + public function __construct() + { + parent::__construct(new PDO('sqlite::memory:')); + } + + #[\Override] + public function castBefore(Document $collection, Document $document): Document + { + return $document; + } + + #[\Override] + public function castAfter(Document $collection, array $documents): array + { + if ($collection->getId() !== Database::METADATA) { + $this->batches[] = \array_values(\array_map(fn (Document $document): string => $document->getId(), $documents)); + } + + return $documents; + } + + #[\Override] + public function castDatetime(string $value): mixed + { + return DateTime::setTimezone($value); + } + + public function reset(): void + { + $this->batches = []; + } +} diff --git a/tests/unit/Adapter/CastingAdapterStub.php b/tests/unit/Adapter/CastingAdapterStub.php new file mode 100644 index 0000000000..410958a0b5 --- /dev/null +++ b/tests/unit/Adapter/CastingAdapterStub.php @@ -0,0 +1,9 @@ +database()->find(self::COLLECTION, [Query::limit(self::PAGE)]); + + $this->assertCount(self::PAGE, $documents); + foreach ($documents as $position => $document) { + $this->assertSame('post'.$position, $document->getId()); + $this->assertSame((string) ($position + 1), $document->getSequence()); + $this->assertSame($position, $document->getAttribute('score')); + $this->assertSame((float) $position, $document->getAttribute('price')); + $this->assertSame($position % 2 === 1, $document->getAttribute('active')); + $this->assertSame(['a', (string) $position], $document->getAttribute('tags')); + $this->assertSame((string) $position, $document->getAttribute('name')); + $this->assertSame('2025-10-14T01:31:18.290+00:00', $document->getAttribute('when')); + } + } + + public function testAPoolOverMongoCastsAPageItIsHandedUnderItsKeys(): void + { + $pool = $this->pool(); + $collection = $this->collectionRecord(); + $page = ['first' => $this->stored(3), 9 => $this->stored(4)]; + + $this->assertTrue($pool->hasFeature(Feature\Casting::class)); + + $cast = $pool->castAfter(new Document([Document::ID => self::COLLECTION, 'attributes' => $collection['attributes']]), $page); + + $this->assertSame(['first', 9], \array_keys($cast)); + $this->assertSame(3, $cast['first']->getAttribute('score')); + $this->assertSame(4.0, $cast[9]->getAttribute('price')); + $this->assertSame(['a', '4'], $cast[9]->getAttribute('tags')); + $this->assertSame([], $pool->castAfter(new Document([Document::ID => self::COLLECTION]), [])); + } + + public function testAnAdapterWithoutCastingLeavesItToTheLibrary(): void + { + $pool = new Pool($this->connections(new Memory())); + $pool->setAuthorization(new Authorization()); + + $this->assertFalse($pool->hasFeature(Feature\Casting::class)); + } + + private function stored(int $position): Document + { + return new Document([ + Document::ID => 'post'.$position, + Document::SEQUENCE => $position + 1, + 'score' => (string) $position, + 'price' => $position, + 'active' => $position % 2, + 'tags' => \json_encode(['a', (string) $position]), + 'name' => $position, + 'when' => ['$date' => ['$numberLong' => self::STORED_WHEN]], + ]); + } + + private function database(): Database + { + $database = new Database($this->pool(), new Cache(new None())); + $database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE); + $database->getAuthorization()->disable(); + + return $database; + } + + private function pool(): Pool + { + $authorization = new Authorization(); + $authorization->disable(); + + $adapter = new Mongo($this->client()); + $adapter->setAuthorization($authorization); + + $pool = new Pool($this->connections($adapter)); + $pool->setAuthorization($authorization); + + return $pool; + } + + /** + * @return UtopiaPool + */ + private function connections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(static fn (callable $callback): mixed => $callback($adapter)); + + return $connections; + } + + /** + * @return array + */ + private function collectionRecord(): array + { + return [ + Storage::UID => self::COLLECTION, + Storage::SEQUENCE => '1', + 'name' => self::COLLECTION, + 'documentSecurity' => false, + Storage::PERMISSIONS => [], + 'attributes' => \json_encode(\array_map( + static fn (Attribute $attribute): array => $attribute->toDocument()->getArrayCopy(), + [ + Attribute::integer(key: 'score'), + Attribute::double(key: 'price'), + Attribute::boolean(key: 'active'), + Attribute::string(key: 'tags', size: 16, array: true), + Attribute::string(key: 'name', size: 16), + Attribute::datetime(key: 'when'), + ], + )), + 'indexes' => '[]', + ]; + } + + private function client(): Client + { + $page = []; + for ($position = 0; $position < self::PAGE; $position++) { + $page[] = (object) [ + Storage::UID => 'post'.$position, + Storage::SEQUENCE => $position + 1, + 'score' => (string) $position, + 'price' => $position, + 'active' => $position % 2, + 'tags' => \json_encode(['a', (string) $position]), + 'name' => $position, + 'when' => (object) ['$date' => (object) ['$numberLong' => self::STORED_WHEN]], + ]; + } + + return new class ((object) $this->collectionRecord(), $page) extends Client { + /** + * @param list $page + */ + public function __construct(private readonly stdClass $collection, private readonly array $page) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function getHost(): string + { + return 'mongo'; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + $documents = \str_ends_with($collection, Database::METADATA) ? [$this->collection] : $this->page; + + return (object) ['cursor' => (object) ['firstBatch' => $documents, 'id' => 0]]; + } + }; + } +} diff --git a/tests/unit/Adapter/CastingMemory.php b/tests/unit/Adapter/CastingMemory.php new file mode 100644 index 0000000000..3da5bc5c45 --- /dev/null +++ b/tests/unit/Adapter/CastingMemory.php @@ -0,0 +1,29 @@ + */ + private array $statements = []; + + /** + * @return array}> + */ + public static function mariaDBAdapters(): array + { + return [ + 'MariaDB' => [MariaDB::class], + 'MySQL' => [MySQL::class], + ]; + } + + public function testPostgresRenameAdoptsAColumnAnotherTenantAlreadyRenamed(): void + { + $adapter = $this->createPostgres(['_id', 'years']); + + $this->assertTrue($adapter->renameAttribute('users', 'age', 'years')); + + $this->assertCount(1, $this->statements); + $this->assertStringStartsWith(self::POSTGRES_CATALOG, $this->statements[0]); + } + + public function testPostgresRenameRunsWhileTheOldColumnExists(): void + { + $adapter = $this->createPostgres(['_id', 'age']); + + $this->assertTrue($adapter->renameAttribute('users', 'age', 'years')); + + $this->assertCount(2, $this->statements); + $this->assertStringStartsWith(self::POSTGRES_CATALOG, $this->statements[0]); + $this->assertSame(self::POSTGRES_RENAME, $this->statements[1]); + } + + public function testPostgresRenameRefusesATargetThatExistsBesideTheOldColumn(): void + { + $adapter = $this->createPostgres(['_id', 'age', 'years'], $this->postgresError('42701', 'column "years" of relation "namespace_users" already exists')); + + try { + $adapter->renameAttribute('users', 'age', 'years'); + $this->fail('A rename onto a column that exists beside the old one must be refused'); + } catch (DuplicateException $e) { + $this->assertSame('Attribute already exists', $e->getMessage()); + } + + $this->assertSame(self::POSTGRES_RENAME, $this->statements[1]); + } + + public function testPostgresRenameOfAMissingColumnIsNotFound(): void + { + $adapter = $this->createPostgres(['_id'], $this->postgresError('42703', 'column "age" does not exist')); + + try { + $adapter->renameAttribute('users', 'age', 'years'); + $this->fail('A rename of a column that is gone without a renamed one must be reported'); + } catch (NotFoundException $e) { + $this->assertSame('Attribute not found', $e->getMessage()); + } + + $this->assertSame(self::POSTGRES_RENAME, $this->statements[1]); + } + + public function testPostgresUpdateAttributeAdoptsAColumnAnotherTenantAlreadyRenamed(): void + { + $adapter = $this->createPostgres(['_id', 'years']); + + $this->assertTrue($adapter->updateAttribute('users', 'age', Attribute::integer(key: 'years', required: true))); + + $this->assertCount(2, $this->statements); + $this->assertStringStartsWith(self::POSTGRES_CATALOG, $this->statements[0]); + $this->assertStringStartsWith(self::POSTGRES_RETYPE, $this->statements[1]); + } + + public function testPostgresUpdateAttributeRenamesWhileTheOldColumnExists(): void + { + $adapter = $this->createPostgres(['_id', 'age']); + + $this->assertTrue($adapter->updateAttribute('users', 'age', Attribute::integer(key: 'years', required: true))); + + $this->assertCount(3, $this->statements); + $this->assertStringStartsWith(self::POSTGRES_CATALOG, $this->statements[0]); + $this->assertSame(self::POSTGRES_RENAME, $this->statements[1]); + $this->assertStringStartsWith(self::POSTGRES_RETYPE, $this->statements[2]); + } + + public function testPostgresUpdateAttributeRefusesATargetThatExistsBesideTheOldColumn(): void + { + $adapter = $this->createPostgres(['_id', 'age', 'years'], $this->postgresError('42701', 'column "years" of relation "namespace_users" already exists')); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Attribute already exists'); + + $adapter->updateAttribute('users', 'age', Attribute::integer(key: 'years', required: true)); + } + + public function testPostgresReadsNoCatalogWithoutARename(): void + { + $adapter = $this->createPostgres(['_id', 'age']); + + $this->assertTrue($adapter->updateAttribute('users', 'age', Attribute::integer(key: 'age', required: true))); + + $this->assertSame([], \array_filter($this->statements, fn (string $statement): bool => \str_starts_with($statement, self::POSTGRES_CATALOG))); + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('mariaDBAdapters')] + public function testMariaDBRenameAdoptsAColumnAnotherTenantAlreadyRenamed(string $adapterClass): void + { + $adapter = $this->createMariaDB($adapterClass, ['_id', 'years']); + + $this->assertTrue($adapter->renameAttribute('users', 'age', 'years')); + + $this->assertCount(1, $this->statements); + $this->assertStringContainsString(self::MARIADB_CATALOG, $this->statements[0]); + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('mariaDBAdapters')] + public function testMariaDBRenameRunsWhileTheOldColumnExists(string $adapterClass): void + { + $adapter = $this->createMariaDB($adapterClass, ['_id', 'age']); + + $this->assertTrue($adapter->renameAttribute('users', 'age', 'years')); + + $this->assertCount(2, $this->statements); + $this->assertSame('ALTER TABLE `database`.`namespace_users` RENAME COLUMN `age` TO `years`', $this->statements[1]); + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('mariaDBAdapters')] + public function testMariaDBUpdateAttributeAdoptsAColumnAnotherTenantAlreadyRenamed(string $adapterClass): void + { + $adapter = $this->createMariaDB($adapterClass, ['_id', 'years']); + + $this->assertTrue($adapter->updateAttribute('users', 'age', Attribute::integer(key: 'years', required: true))); + + $this->assertCount(2, $this->statements); + $this->assertStringStartsWith('ALTER TABLE `database`.`namespace_users` MODIFY `years` INT', $this->statements[1]); + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('mariaDBAdapters')] + public function testMariaDBUpdateAttributeChangesTheColumnWhileTheOldOneExists(string $adapterClass): void + { + $adapter = $this->createMariaDB($adapterClass, ['_id', 'age']); + + $this->assertTrue($adapter->updateAttribute('users', 'age', Attribute::integer(key: 'years', required: true))); + + $this->assertCount(2, $this->statements); + $this->assertStringStartsWith('ALTER TABLE `database`.`namespace_users` CHANGE COLUMN `age` `years` INT', $this->statements[1]); + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('mariaDBAdapters')] + public function testMariaDBReadsNoCatalogWithoutARename(string $adapterClass): void + { + $adapter = $this->createMariaDB($adapterClass, ['_id', 'age']); + + $this->assertTrue($adapter->updateAttribute('users', 'age', Attribute::integer(key: 'age', required: true))); + + $this->assertCount(1, $this->statements); + $this->assertStringStartsWith('ALTER TABLE `database`.`namespace_users` MODIFY `age` INT', $this->statements[0]); + } + + /** + * @param list $columns + */ + private function createPostgres(array $columns, ?PDOException $ddlError = null): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($columns, $ddlError): PDOStatement { + $this->statements[] = $query; + $catalog = \str_starts_with($query, self::POSTGRES_CATALOG); + + $statement = $this->createStub(PDOStatement::class); + if ($ddlError !== null && ! $catalog) { + $statement->method('execute')->willThrowException($ddlError); + } else { + $statement->method('execute')->willReturn(true); + } + $statement->method('fetchAll')->willReturn($catalog ? $columns : []); + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables(true); + $adapter->setTenant(2); + + return $adapter; + } + + /** + * @param class-string $adapterClass + * @param list $columns + */ + private function createMariaDB(string $adapterClass, array $columns): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($columns): PDOStatement { + $this->statements[] = \trim($query); + + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn(\str_contains($query, self::MARIADB_CATALOG) + ? \array_map(static fn (string $column): array => ['name' => $column, 'type' => 'int', 'length' => null, 'nullable' => 'YES'], $columns) + : []); + + return $statement; + }); + + $adapter = new $adapterClass($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables(true); + $adapter->setTenant(2); + + return $adapter; + } + + private function postgresError(string $state, string $message): PDOException + { + $error = new class ("SQLSTATE[{$state}]: 7 ERROR: {$message}", $state) extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $error->errorInfo = [$state, 7, $message]; + + return $error; + } +} diff --git a/tests/unit/Adapter/ConnectionFeatureTest.php b/tests/unit/Adapter/ConnectionFeatureTest.php new file mode 100644 index 0000000000..030bfaa411 --- /dev/null +++ b/tests/unit/Adapter/ConnectionFeatureTest.php @@ -0,0 +1,153 @@ +database(new Memory()); + + $this->assertTrue($database->ping()); + $database->reconnect(); + $this->assertNull($database->getConnectionId()); + $this->assertNull($database->getHostname()); + } + + public function testADatabaseForwardsToTheAdaptersConnection(): void + { + $database = $this->database(new HostnameSQLite('db-1')); + + $this->assertTrue($database->ping()); + $this->assertSame('db-1', $database->getHostname()); + $this->assertNotSame('', $database->getConnectionId()); + } + + public function testAReconnectedConnectionKeepsAnswering(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $id = $database->getConnectionId(); + + $database->reconnect(); + + $this->assertTrue($database->ping()); + $this->assertSame($id, $database->getConnectionId()); + } + + public function testAPoolAnswersForTheConnectionOfWhatItPools(): void + { + $connected = $this->pool(new HostnameSQLite('db-1')); + $unconnected = $this->pool(new Memory()); + + $this->assertTrue($connected->hasFeature(Feature\Connection::class)); + $this->assertFalse($unconnected->hasFeature(Feature\Connection::class)); + $this->assertSame('db-1', $this->database($connected)->getHostname()); + $this->assertNull($this->database($unconnected)->getHostname()); + } + + public function testADatabaseOverAPoolWithoutAConnectionPingsWithoutCallingIt(): void + { + $database = $this->database($this->pool(new Memory())); + + $this->assertTrue($database->ping()); + $database->reconnect(); + $this->assertNull($database->getConnectionId()); + } + + public function testAPoolRefusesAConnectionCallItsAdapterCannotServe(): void + { + $pool = $this->pool(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support connections'); + + $pool->ping(); + } + + public function testTheConnectionQuestionIsAskedOncePerPool(): void + { + $connections = $this->connections(new HostnameSQLite('db-1')); + $this->assertTrue($this->handle($connections)->hasFeature(Feature\Connection::class)); + + $this->checkouts = 0; + + $this->assertTrue($this->handle($connections)->hasFeature(Feature\Connection::class)); + $this->assertSame(0, $this->checkouts); + } + + public function testCacheKeysNameTheHostOfAConnectedAdapter(): void + { + $first = $this->database(new HostnameSQLite('db-1')); + $second = $this->database(new HostnameSQLite('db-2')); + + $this->assertNotSame($first->getCacheKeys('posts', 'post'), $second->getCacheKeys('posts', 'post')); + $this->assertNotSame($first->getQueryCacheKey('posts'), $second->getQueryCacheKey('posts')); + } + + public function testCacheKeysOfAdaptersWithoutAConnectionDoNotDependOnAHost(): void + { + $first = $this->database(new Memory()); + $second = $this->database(new Memory()); + $second->getAdapter()->setHostname('db-2'); + + $this->assertSame($first->getCacheKeys('posts', 'post'), $second->getCacheKeys('posts', 'post')); + $this->assertSame($first->getQueryCacheKey('posts'), $second->getQueryCacheKey('posts')); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setAuthorization(new Authorization()); + + return $database; + } + + private function pool(Adapter $connection): Pool + { + return $this->handle($this->connections($connection)); + } + + /** + * @param UtopiaPool $connections + */ + private function handle(UtopiaPool $connections): Pool + { + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } + + /** + * @return UtopiaPool&Stub + */ + private function connections(Adapter $connection): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(function (callable $callback) use ($connection): mixed { + $this->checkouts++; + + return $callback($connection); + }); + + return $connections; + } +} diff --git a/tests/unit/Adapter/Data/Builder/statements.json b/tests/unit/Adapter/Data/Builder/statements.json new file mode 100644 index 0000000000..a9b2416d61 --- /dev/null +++ b/tests/unit/Adapter/Data/Builder/statements.json @@ -0,0 +1,10 @@ +{ + "MariaDB\/plain": {"reads":{"select":["SELECT `name`, `_uid` FROM `database`.`namespace_authors` WHERE `name` IN (?) AND `_id` > ? ORDER BY `_createdAt` DESC LIMIT ? OFFSET ?",["x",3,5,2]],"join":["SELECT `database`.`namespace_authors`.`name`, `book`.`pages`, `review`.`stars` FROM `database`.`namespace_authors` JOIN `database`.`namespace_books` AS `book` ON `database`.`namespace_authors`.`_uid` = `book`.`authorId` LEFT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId` WHERE `book`.`pages` > ? ORDER BY `database`.`namespace_authors`.`name` ASC LIMIT ?",[10,3]],"metadata":["SELECT `name` FROM `database`.`namespace__metadata` WHERE `_uid` IN (?)",["authors"]],"permissions":["SELECT `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",["a1"]],"metadataPermissions":["SELECT `_permission` FROM `database`.`namespace__metadata_perms`",[]],"rightJoin":["SELECT `database`.`namespace_authors`.`name`, `review`.`stars` FROM `database`.`namespace_authors` RIGHT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId`",[]],"aggregate":["SELECT COUNT(*) AS `total` FROM `database`.`namespace_authors` GROUP BY `name`",[]],"update":["UPDATE `database`.`namespace_authors` SET `name` = ? WHERE `_uid` IN (?)",["renamed","a1"]],"delete":["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?)",["a1"]],"insert":["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`) VALUES (?, ?)",["a9","new"]],"reused":["SELECT `name` FROM `database`.`namespace_authors`",[]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[["SELECT CONNECTION_ID()",[]]]},"exists":{"outcome":"ok","statements":[["SELECT SCHEMA_NAME FROM `INFORMATION_SCHEMA`.`SCHEMATA` WHERE `SCHEMA_NAME` IN (?)",[[1,"database"]]]]},"collectionExists":{"outcome":"ok","statements":[["SELECT TABLE_NAME FROM `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA` IN (?) AND `TABLE_NAME` IN (?)",[[1,"database"],[2,"namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["SELECT SUM(size) FROM (\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n UNION ALL\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n ) AS sizes",[[1,"namespace_authors"],[2,"database"],[3,"namespace_authors_perms"],[4,"database"]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM `INFORMATION_SCHEMA`.`INNODB_SYS_TABLESPACES` WHERE `NAME` IN (?)",[[1,"database/namespace_authors"],[1,"database/namespace_authors"]]],["SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM `INFORMATION_SCHEMA`.`INNODB_SYS_TABLESPACES` WHERE `NAME` IN (?)",[[1,"database/namespace_authors_perms"],[1,"database/namespace_authors_perms"]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`, `_createdAt`, `_updatedAt`, `_permissions`) VALUES (?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?), (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,"a1"],[5,"update"],[6,"user:1"]]]]},"update":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"]]],["SELECT `_document`, `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",[[1,"a1"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"]]]]},"rename":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `_uid` = ?, `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",[[1,"a1"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?)",[[1,"a1"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",[[1,"a1"]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_createdAt`, `_permissions`, `_uid`, `_updatedAt`, `name`) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `_permissions` = VALUES(`_permissions`), `_updatedAt` = VALUES(`_updatedAt`), `name` = VALUES(`name`)",[[1,"2026-09-30 00:00:00.000"],[2,"[\"read(\\\"user:3\\\")\"]"],[3,"a1"],[4,"2026-09-30 00:00:00.000"],[5,"one"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE ((`_document` IN (?) AND `_type` IN (?) AND `_permission` IN (?)))",[[1,"a1"],[2,"read"],[3,"any"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?",[[1,"one"],[2,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`.`_uid` IN (SELECT _document FROM `database`.`namespace_books_perms` WHERE _permission IN (?) AND _type = ?) LIMIT ?",[[1,"any"],[2,"read"],[3,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["(SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId`) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL) LIMIT ?",[[1,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT * FROM ((SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId`) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL)) AS `foj_rows` ORDER BY RAND() LIMIT ?",[[1,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM ((SELECT 1 FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId`) UNION ALL (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL) LIMIT ?) AS `table_count`",[[1,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(`pages`) AS `sum` FROM (SELECT `pages` FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,5]]]]}}}, + "MariaDB\/shared": {"reads":{"select":["SELECT `name`, `_uid` FROM `database`.`namespace_authors` WHERE `name` IN (?) AND `_id` > ? AND `database`.`namespace_authors`._tenant IN (?) ORDER BY `_createdAt` DESC LIMIT ? OFFSET ?",["x",3,7,5,2]],"join":["SELECT `database`.`namespace_authors`.`name`, `book`.`pages`, `review`.`stars` FROM `database`.`namespace_authors` JOIN `database`.`namespace_books` AS `book` ON `database`.`namespace_authors`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) LEFT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId` AND `review`._tenant IN (?) WHERE `book`.`pages` > ? AND `database`.`namespace_authors`._tenant IN (?) ORDER BY `database`.`namespace_authors`.`name` ASC LIMIT ?",[7,7,10,7,3]],"metadata":["SELECT `name` FROM `database`.`namespace__metadata` WHERE `_uid` IN (?) AND (`database`.`namespace__metadata`._tenant IN (?) OR `database`.`namespace__metadata`._tenant IS NULL)",["authors",7]],"permissions":["SELECT `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND `database`.`namespace_authors_perms`._tenant IN (?)",["a1",7]],"metadataPermissions":["SELECT `_permission` FROM `database`.`namespace__metadata_perms` WHERE (`database`.`namespace__metadata_perms`._tenant IN (?) OR `database`.`namespace__metadata_perms`._tenant IS NULL)",[7]],"rightJoin":["SELECT `database`.`namespace_authors`.`name`, `review`.`stars` FROM `database`.`namespace_authors` RIGHT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId` AND (`database`.`namespace_authors`._tenant IN (?) OR `database`.`namespace_authors`.`_uid` IS NULL) AND `review`._tenant IN (?) WHERE (`database`.`namespace_authors`._tenant IN (?) OR `database`.`namespace_authors`.`_uid` IS NULL) AND (`review`._tenant IN (?) OR `review`.`_uid` IS NULL)",[7,7,7,7]],"aggregate":["SELECT COUNT(*) AS `total` FROM `database`.`namespace_authors` WHERE `database`.`namespace_authors`._tenant IN (?) GROUP BY `name`",[7]],"update":["UPDATE `database`.`namespace_authors` SET `name` = ? WHERE `_uid` IN (?) AND `database`.`namespace_authors`._tenant IN (?)",["renamed","a1",7]],"delete":["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?) AND `database`.`namespace_authors`._tenant IN (?)",["a1",7]],"insert":["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`) VALUES (?, ?)",["a9","new"]],"reused":["SELECT `name` FROM `database`.`namespace_authors` WHERE `database`.`namespace_authors`._tenant IN (?)",[7]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[["SELECT CONNECTION_ID()",[]]]},"exists":{"outcome":"ok","statements":[["SELECT SCHEMA_NAME FROM `INFORMATION_SCHEMA`.`SCHEMATA` WHERE `SCHEMA_NAME` IN (?)",[[1,"database"]]]]},"collectionExists":{"outcome":"ok","statements":[["SELECT TABLE_NAME FROM `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA` IN (?) AND `TABLE_NAME` IN (?)",[[1,"database"],[2,"namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["SELECT SUM(size) FROM (\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n UNION ALL\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n ) AS sizes",[[1,"namespace_authors"],[2,"database"],[3,"namespace_authors_perms"],[4,"database"]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM `INFORMATION_SCHEMA`.`INNODB_SYS_TABLESPACES` WHERE `NAME` IN (?)",[[1,"database/namespace_authors"],[1,"database/namespace_authors"]]],["SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) FROM `INFORMATION_SCHEMA`.`INNODB_SYS_TABLESPACES` WHERE `NAME` IN (?)",[[1,"database/namespace_authors_perms"],[1,"database/namespace_authors_perms"]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`, `_createdAt`, `_updatedAt`, `_permissions`, `_tenant`) VALUES (?, ?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"],[6,7]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?), (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,7],[5,"a1"],[6,"update"],[7,"user:1"],[8,7]]]]},"update":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL AND _tenant IN (?)",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"],[5,7]]],["SELECT `_document`, `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"],[4,7]]]]},"rename":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `_uid` = ?, `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL AND _tenant IN (?)",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"],[6,7]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"],[4,7]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_createdAt`, `_permissions`, `_tenant`, `_uid`, `_updatedAt`, `name`) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `_permissions` = IF(_tenant = VALUES(_tenant), VALUES(`_permissions`), `_permissions`), `_updatedAt` = IF(_tenant = VALUES(_tenant), VALUES(`_updatedAt`), `_updatedAt`), `name` = IF(_tenant = VALUES(_tenant), VALUES(`name`), `name`)",[[1,"2026-09-30 00:00:00.000"],[2,"[\"read(\\\"user:3\\\")\"]"],[3,7],[4,"a1"],[5,"2026-09-30 00:00:00.000"],[6,"one"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE ((`_document` IN (?) AND `_tenant` IN (?) AND `_type` IN (?) AND `_permission` IN (?)))",[[1,"a1"],[2,7],[3,"read"],[4,"any"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"],[4,7]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?",[[1,"one"],[2,7],[3,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) AND `book`.`_uid` IN (SELECT _document FROM `database`.`namespace_books_perms` WHERE _permission IN (?) AND _type = ? AND _tenant IN (?)) WHERE `table_main`._tenant IN (?) LIMIT ?",[[1,7],[2,"any"],[3,"read"],[4,7],[5,7],[6,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["(SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?)) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL)) LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT * FROM ((SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?)) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL))) AS `foj_rows` ORDER BY RAND() LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM ((SELECT 1 FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?)) UNION ALL (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL)) LIMIT ?) AS `table_count`",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,7],[3,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(`pages`) AS `sum` FROM (SELECT `pages` FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,7],[3,5]]]]}}}, + "MySQL\/plain": {"reads":{"select":["SELECT `name`, `_uid` FROM `database`.`namespace_authors` WHERE `name` IN (?) AND `_id` > ? ORDER BY `_createdAt` DESC LIMIT ? OFFSET ?",["x",3,5,2]],"join":["SELECT `database`.`namespace_authors`.`name`, `book`.`pages`, `review`.`stars` FROM `database`.`namespace_authors` JOIN `database`.`namespace_books` AS `book` ON `database`.`namespace_authors`.`_uid` = `book`.`authorId` LEFT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId` WHERE `book`.`pages` > ? ORDER BY `database`.`namespace_authors`.`name` ASC LIMIT ?",[10,3]],"metadata":["SELECT `name` FROM `database`.`namespace__metadata` WHERE `_uid` IN (?)",["authors"]],"permissions":["SELECT `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",["a1"]],"metadataPermissions":["SELECT `_permission` FROM `database`.`namespace__metadata_perms`",[]],"rightJoin":["SELECT `database`.`namespace_authors`.`name`, `review`.`stars` FROM `database`.`namespace_authors` RIGHT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId`",[]],"aggregate":["SELECT COUNT(*) AS `total` FROM `database`.`namespace_authors` GROUP BY `name`",[]],"update":["UPDATE `database`.`namespace_authors` SET `name` = ? WHERE `_uid` IN (?)",["renamed","a1"]],"delete":["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?)",["a1"]],"insert":["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`) VALUES (?, ?)",["a9","new"]],"reused":["SELECT `name` FROM `database`.`namespace_authors`",[]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[["SELECT CONNECTION_ID()",[]]]},"exists":{"outcome":"ok","statements":[["SELECT SCHEMA_NAME FROM `INFORMATION_SCHEMA`.`SCHEMATA` WHERE `SCHEMA_NAME` IN (?)",[[1,"database"]]]]},"collectionExists":{"outcome":"ok","statements":[["SELECT TABLE_NAME FROM `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA` IN (?) AND `TABLE_NAME` IN (?)",[[1,"database"],[2,"namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["SELECT SUM(size) FROM (\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n UNION ALL\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n ) AS sizes",[[1,"namespace_authors"],[2,"database"],[3,"namespace_authors_perms"],[4,"database"]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["\n SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) \n FROM INFORMATION_SCHEMA.INNODB_TABLESPACES\n WHERE NAME = :name\n ",[[":name","database/namespace_authors"]]],["\n SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) \n FROM INFORMATION_SCHEMA.INNODB_TABLESPACES\n WHERE NAME = :permissions\n ",[[":permissions","database/namespace_authors_perms"]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`, `_createdAt`, `_updatedAt`, `_permissions`) VALUES (?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?), (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,"a1"],[5,"update"],[6,"user:1"]]]]},"update":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"]]],["SELECT `_document`, `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",[[1,"a1"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"]]]]},"rename":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `_uid` = ?, `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",[[1,"a1"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?)",[[1,"a1"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?)",[[1,"a1"]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_createdAt`, `_permissions`, `_uid`, `_updatedAt`, `name`) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `_permissions` = VALUES(`_permissions`), `_updatedAt` = VALUES(`_updatedAt`), `name` = VALUES(`name`)",[[1,"2026-09-30 00:00:00.000"],[2,"[\"read(\\\"user:3\\\")\"]"],[3,"a1"],[4,"2026-09-30 00:00:00.000"],[5,"one"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE ((`_document` IN (?) AND `_type` IN (?) AND `_permission` IN (?)))",[[1,"a1"],[2,"read"],[3,"any"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?",[[1,"one"],[2,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`.`_uid` IN (SELECT _document FROM `database`.`namespace_books_perms` WHERE _permission IN (?) AND _type = ?) LIMIT ?",[[1,"any"],[2,"read"],[3,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["(SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId`) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL) LIMIT ?",[[1,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT * FROM ((SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId`) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL)) AS `foj_rows` ORDER BY RAND() LIMIT ?",[[1,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM ((SELECT 1 FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId`) UNION ALL (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL) LIMIT ?) AS `table_count`",[[1,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(`pages`) AS `sum` FROM (SELECT `pages` FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,5]]]]}}}, + "MySQL\/shared": {"reads":{"select":["SELECT `name`, `_uid` FROM `database`.`namespace_authors` WHERE `name` IN (?) AND `_id` > ? AND `database`.`namespace_authors`._tenant IN (?) ORDER BY `_createdAt` DESC LIMIT ? OFFSET ?",["x",3,7,5,2]],"join":["SELECT `database`.`namespace_authors`.`name`, `book`.`pages`, `review`.`stars` FROM `database`.`namespace_authors` JOIN `database`.`namespace_books` AS `book` ON `database`.`namespace_authors`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) LEFT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId` AND `review`._tenant IN (?) WHERE `book`.`pages` > ? AND `database`.`namespace_authors`._tenant IN (?) ORDER BY `database`.`namespace_authors`.`name` ASC LIMIT ?",[7,7,10,7,3]],"metadata":["SELECT `name` FROM `database`.`namespace__metadata` WHERE `_uid` IN (?) AND (`database`.`namespace__metadata`._tenant IN (?) OR `database`.`namespace__metadata`._tenant IS NULL)",["authors",7]],"permissions":["SELECT `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND `database`.`namespace_authors_perms`._tenant IN (?)",["a1",7]],"metadataPermissions":["SELECT `_permission` FROM `database`.`namespace__metadata_perms` WHERE (`database`.`namespace__metadata_perms`._tenant IN (?) OR `database`.`namespace__metadata_perms`._tenant IS NULL)",[7]],"rightJoin":["SELECT `database`.`namespace_authors`.`name`, `review`.`stars` FROM `database`.`namespace_authors` RIGHT JOIN `database`.`namespace_reviews` AS `review` ON `database`.`namespace_authors`.`_uid` = `review`.`authorId` AND (`database`.`namespace_authors`._tenant IN (?) OR `database`.`namespace_authors`.`_uid` IS NULL) AND `review`._tenant IN (?) WHERE (`database`.`namespace_authors`._tenant IN (?) OR `database`.`namespace_authors`.`_uid` IS NULL) AND (`review`._tenant IN (?) OR `review`.`_uid` IS NULL)",[7,7,7,7]],"aggregate":["SELECT COUNT(*) AS `total` FROM `database`.`namespace_authors` WHERE `database`.`namespace_authors`._tenant IN (?) GROUP BY `name`",[7]],"update":["UPDATE `database`.`namespace_authors` SET `name` = ? WHERE `_uid` IN (?) AND `database`.`namespace_authors`._tenant IN (?)",["renamed","a1",7]],"delete":["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?) AND `database`.`namespace_authors`._tenant IN (?)",["a1",7]],"insert":["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`) VALUES (?, ?)",["a9","new"]],"reused":["SELECT `name` FROM `database`.`namespace_authors` WHERE `database`.`namespace_authors`._tenant IN (?)",[7]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[["SELECT CONNECTION_ID()",[]]]},"exists":{"outcome":"ok","statements":[["SELECT SCHEMA_NAME FROM `INFORMATION_SCHEMA`.`SCHEMATA` WHERE `SCHEMA_NAME` IN (?)",[[1,"database"]]]]},"collectionExists":{"outcome":"ok","statements":[["SELECT TABLE_NAME FROM `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA` IN (?) AND `TABLE_NAME` IN (?)",[[1,"database"],[2,"namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["SELECT SUM(size) FROM (\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n UNION ALL\n SELECT data_length + index_length AS size\n FROM INFORMATION_SCHEMA.TABLES\n WHERE table_name = ? AND\n table_schema = ?\n ) AS sizes",[[1,"namespace_authors"],[2,"database"],[3,"namespace_authors_perms"],[4,"database"]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["\n SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) \n FROM INFORMATION_SCHEMA.INNODB_TABLESPACES\n WHERE NAME = :name\n ",[[":name","database/namespace_authors"]]],["\n SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) \n FROM INFORMATION_SCHEMA.INNODB_TABLESPACES\n WHERE NAME = :permissions\n ",[[":permissions","database/namespace_authors_perms"]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_uid`, `name`, `_createdAt`, `_updatedAt`, `_permissions`, `_tenant`) VALUES (?, ?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"],[6,7]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?), (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,7],[5,"a1"],[6,"update"],[7,"user:1"],[8,7]]]]},"update":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL AND _tenant IN (?)",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"],[5,7]]],["SELECT `_document`, `_type`, `_permission` FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"],[4,7]]]]},"rename":{"outcome":"ok","statements":[["UPDATE `database`.`namespace_authors` SET `_uid` = ?, `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_id` IS NULL AND _tenant IN (?)",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"],[6,7]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"],[4,7]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM `database`.`namespace_authors` WHERE `_uid` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE `_document` IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO `database`.`namespace_authors` (`_createdAt`, `_permissions`, `_tenant`, `_uid`, `_updatedAt`, `name`) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `_permissions` = IF(_tenant = VALUES(_tenant), VALUES(`_permissions`), `_permissions`), `_updatedAt` = IF(_tenant = VALUES(_tenant), VALUES(`_updatedAt`), `_updatedAt`), `name` = IF(_tenant = VALUES(_tenant), VALUES(`name`), `name`)",[[1,"2026-09-30 00:00:00.000"],[2,"[\"read(\\\"user:3\\\")\"]"],[3,7],[4,"a1"],[5,"2026-09-30 00:00:00.000"],[6,"one"]]],["DELETE FROM `database`.`namespace_authors_perms` WHERE ((`_document` IN (?) AND `_tenant` IN (?) AND `_type` IN (?) AND `_permission` IN (?)))",[[1,"a1"],[2,7],[3,"read"],[4,"any"]]],["INSERT INTO `database`.`namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"],[4,7]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?",[[1,"one"],[2,7],[3,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) AND `book`.`_uid` IN (SELECT _document FROM `database`.`namespace_books_perms` WHERE _permission IN (?) AND _type = ? AND _tenant IN (?)) WHERE `table_main`._tenant IN (?) LIMIT ?",[[1,7],[2,"any"],[3,"read"],[4,7],[5,7],[6,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["(SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?)) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL)) LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT * FROM ((SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?)) UNION ALL (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL))) AS `foj_rows` ORDER BY RAND() LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM ((SELECT 1 FROM `database`.`namespace_authors` AS `table_main` LEFT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?)) UNION ALL (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` RIGHT JOIN `database`.`namespace_books` AS `book` ON `table_main`.`_uid` = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL)) LIMIT ?) AS `table_count`",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,7],[3,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(`pages`) AS `sum` FROM (SELECT `pages` FROM `database`.`namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,7],[3,5]]]]}}}, + "Postgres\/plain": {"reads":{"select":["SELECT \"name\", \"_uid\" FROM \"database\".\"namespace_authors\" WHERE \"name\" IN (?) AND \"_id\" > ? ORDER BY \"_createdAt\" DESC LIMIT ? OFFSET ?",["x",3,5,2]],"join":["SELECT \"database\".\"namespace_authors\".\"name\", \"book\".\"pages\", \"review\".\"stars\" FROM \"database\".\"namespace_authors\" JOIN \"database\".\"namespace_books\" AS \"book\" ON \"database\".\"namespace_authors\".\"_uid\" = \"book\".\"authorId\" LEFT JOIN \"database\".\"namespace_reviews\" AS \"review\" ON \"database\".\"namespace_authors\".\"_uid\" = \"review\".\"authorId\" WHERE \"book\".\"pages\" > ? ORDER BY \"database\".\"namespace_authors\".\"name\" ASC LIMIT ?",[10,3]],"metadata":["SELECT \"name\" FROM \"database\".\"namespace__metadata\" WHERE \"_uid\" IN (?)",["authors"]],"permissions":["SELECT \"_type\", \"_permission\" FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?)",["a1"]],"metadataPermissions":["SELECT \"_permission\" FROM \"database\".\"namespace__metadata_perms\"",[]],"rightJoin":["SELECT \"database\".\"namespace_authors\".\"name\", \"review\".\"stars\" FROM \"database\".\"namespace_authors\" RIGHT JOIN \"database\".\"namespace_reviews\" AS \"review\" ON \"database\".\"namespace_authors\".\"_uid\" = \"review\".\"authorId\"",[]],"fullOuterJoin":["SELECT \"database\".\"namespace_authors\".\"name\", \"review\".\"stars\" FROM \"database\".\"namespace_authors\" FULL OUTER JOIN \"database\".\"namespace_reviews\" AS \"review\" ON \"database\".\"namespace_authors\".\"_uid\" = \"review\".\"authorId\"",[]],"aggregate":["SELECT COUNT(*) AS \"total\" FROM \"database\".\"namespace_authors\" GROUP BY \"name\"",[]],"update":["UPDATE \"database\".\"namespace_authors\" SET \"name\" = ? WHERE \"_uid\" IN (?)",["renamed","a1"]],"delete":["DELETE FROM \"database\".\"namespace_authors\" WHERE \"_uid\" IN (?)",["a1"]],"insert":["INSERT INTO \"database\".\"namespace_authors\" (\"_uid\", \"name\") VALUES (?, ?)",["a9","new"]],"reused":["SELECT \"name\" FROM \"database\".\"namespace_authors\"",[]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[["SELECT pg_backend_pid()",[]]]},"exists":{"outcome":"ok","statements":[["SELECT \"schema_name\" FROM information_schema.schemata WHERE \"schema_name\" = ?",[[1,"database"]]]]},"collectionExists":{"outcome":"ok","statements":[["SELECT \"table_name\" FROM information_schema.tables WHERE \"table_schema\" = ? AND \"table_name\" = ?",[[1,"database"],[2,"namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["SELECT pg_relation_size(?)",[[1,"\"database\".\"namespace_authors\""],[1,"\"database\".\"namespace_authors\""]]],["SELECT pg_relation_size(?)",[[1,"\"database\".\"namespace_authors_perms\""],[1,"\"database\".\"namespace_authors_perms\""]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["SELECT pg_total_relation_size(?)",[[1,"\"database\".\"namespace_authors\""],[1,"\"database\".\"namespace_authors\""]]],["SELECT pg_total_relation_size(?)",[[1,"\"database\".\"namespace_authors_perms\""],[1,"\"database\".\"namespace_authors_perms\""]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO \"database\".\"namespace_authors\" (\"_uid\", \"name\", \"_createdAt\", \"_updatedAt\", \"_permissions\") VALUES (?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\") VALUES (?, ?, ?), (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,"a1"],[5,"update"],[6,"user:1"]]]]},"update":{"outcome":"ok","statements":[["UPDATE \"database\".\"namespace_authors\" SET \"name\" = ?, \"_createdAt\" = ?, \"_updatedAt\" = ?, \"_permissions\" = ? WHERE \"_id\" IS NULL",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"]]],["SELECT \"_document\", \"_type\", \"_permission\" FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?)",[[1,"a1"]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\") VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"]]]]},"rename":{"outcome":"ok","statements":[["UPDATE \"database\".\"namespace_authors\" SET \"_uid\" = ?, \"name\" = ?, \"_createdAt\" = ?, \"_updatedAt\" = ?, \"_permissions\" = ? WHERE \"_id\" IS NULL",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"]]],["DELETE FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?)",[[1,"a1"]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\") VALUES (?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM \"database\".\"namespace_authors\" WHERE \"_uid\" IN (?)",[[1,"a1"]]],["DELETE FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?)",[[1,"a1"]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO \"database\".\"namespace_authors\" AS \"target\" (\"_createdAt\", \"_permissions\", \"_uid\", \"_updatedAt\", \"name\") VALUES (?, ?, ?, ?, ?) ON CONFLICT (\"_uid\") DO UPDATE SET \"_permissions\" = EXCLUDED.\"_permissions\", \"_updatedAt\" = EXCLUDED.\"_updatedAt\", \"name\" = EXCLUDED.\"name\"",[[1,"2026-09-30 00:00:00.000"],[2,"[\"read(\\\"user:3\\\")\"]"],[3,"a1"],[4,"2026-09-30 00:00:00.000"],[5,"one"]]],["DELETE FROM \"database\".\"namespace_authors_perms\" WHERE ((\"_document\" IN (?) AND \"_type\" IN (?) AND \"_permission\" IN (?)))",[[1,"a1"],[2,"read"],[3,"any"]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\") VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM \"database\".\"namespace_authors\" AS \"table_main\" WHERE \"name\" IN (?) LIMIT ?",[[1,"one"],[2,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT \"table_main\".*, \"book\".\"_uid\" AS \"book._uid\" FROM \"database\".\"namespace_authors\" AS \"table_main\" JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" AND (\"book\".\"_permissions\" @> ?::jsonb) LIMIT ?",[[1,"[\"read(\\\"any\\\")\"]"],[2,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["SELECT \"table_main\".*, \"book\".\"_uid\" AS \"book._uid\" FROM \"database\".\"namespace_authors\" AS \"table_main\" FULL OUTER JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" LIMIT ?",[[1,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT \"table_main\".*, \"book\".\"_uid\" AS \"book._uid\" FROM \"database\".\"namespace_authors\" AS \"table_main\" FULL OUTER JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" ORDER BY RANDOM() LIMIT ?",[[1,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS \"sum\" FROM (SELECT 1 FROM \"database\".\"namespace_authors\" AS \"table_main\" FULL OUTER JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" LIMIT ?) AS \"table_count\"",[[1,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS \"sum\" FROM (SELECT 1 FROM \"database\".\"namespace_authors\" AS \"table_main\" WHERE \"name\" IN (?) LIMIT ?) AS \"table_count\"",[[1,"one"],[2,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(\"pages\") AS \"sum\" FROM (SELECT \"pages\" FROM \"database\".\"namespace_authors\" AS \"table_main\" WHERE \"name\" IN (?) LIMIT ?) AS \"table_count\"",[[1,"one"],[2,5]]]]}}}, + "Postgres\/shared": {"reads":{"select":["SELECT \"name\", \"_uid\" FROM \"database\".\"namespace_authors\" WHERE \"name\" IN (?) AND \"_id\" > ? AND \"database\".\"namespace_authors\"._tenant IN (?) ORDER BY \"_createdAt\" DESC LIMIT ? OFFSET ?",["x",3,7,5,2]],"join":["SELECT \"database\".\"namespace_authors\".\"name\", \"book\".\"pages\", \"review\".\"stars\" FROM \"database\".\"namespace_authors\" JOIN \"database\".\"namespace_books\" AS \"book\" ON \"database\".\"namespace_authors\".\"_uid\" = \"book\".\"authorId\" AND \"book\"._tenant IN (?) LEFT JOIN \"database\".\"namespace_reviews\" AS \"review\" ON \"database\".\"namespace_authors\".\"_uid\" = \"review\".\"authorId\" AND \"review\"._tenant IN (?) WHERE \"book\".\"pages\" > ? AND \"database\".\"namespace_authors\"._tenant IN (?) ORDER BY \"database\".\"namespace_authors\".\"name\" ASC LIMIT ?",[7,7,10,7,3]],"metadata":["SELECT \"name\" FROM \"database\".\"namespace__metadata\" WHERE \"_uid\" IN (?) AND (\"database\".\"namespace__metadata\"._tenant IN (?) OR \"database\".\"namespace__metadata\"._tenant IS NULL)",["authors",7]],"permissions":["SELECT \"_type\", \"_permission\" FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?) AND \"database\".\"namespace_authors_perms\"._tenant IN (?)",["a1",7]],"metadataPermissions":["SELECT \"_permission\" FROM \"database\".\"namespace__metadata_perms\" WHERE (\"database\".\"namespace__metadata_perms\"._tenant IN (?) OR \"database\".\"namespace__metadata_perms\"._tenant IS NULL)",[7]],"rightJoin":["SELECT \"database\".\"namespace_authors\".\"name\", \"review\".\"stars\" FROM \"database\".\"namespace_authors\" RIGHT JOIN \"database\".\"namespace_reviews\" AS \"review\" ON \"database\".\"namespace_authors\".\"_uid\" = \"review\".\"authorId\" AND (\"database\".\"namespace_authors\"._tenant IN (?) OR \"database\".\"namespace_authors\".\"_uid\" IS NULL) AND \"review\"._tenant IN (?) WHERE (\"database\".\"namespace_authors\"._tenant IN (?) OR \"database\".\"namespace_authors\".\"_uid\" IS NULL) AND (\"review\"._tenant IN (?) OR \"review\".\"_uid\" IS NULL)",[7,7,7,7]],"fullOuterJoin":["SELECT \"database\".\"namespace_authors\".\"name\", \"review\".\"stars\" FROM \"database\".\"namespace_authors\" FULL OUTER JOIN \"database\".\"namespace_reviews\" AS \"review\" ON \"database\".\"namespace_authors\".\"_uid\" = \"review\".\"authorId\" AND (\"database\".\"namespace_authors\"._tenant IN (?) OR \"database\".\"namespace_authors\".\"_uid\" IS NULL) AND \"review\"._tenant IN (?) WHERE (\"database\".\"namespace_authors\"._tenant IN (?) OR \"database\".\"namespace_authors\".\"_uid\" IS NULL) AND (\"review\"._tenant IN (?) OR \"review\".\"_uid\" IS NULL)",[7,7,7,7]],"aggregate":["SELECT COUNT(*) AS \"total\" FROM \"database\".\"namespace_authors\" WHERE \"database\".\"namespace_authors\"._tenant IN (?) GROUP BY \"name\"",[7]],"update":["UPDATE \"database\".\"namespace_authors\" SET \"name\" = ? WHERE \"_uid\" IN (?) AND \"database\".\"namespace_authors\"._tenant IN (?)",["renamed","a1",7]],"delete":["DELETE FROM \"database\".\"namespace_authors\" WHERE \"_uid\" IN (?) AND \"database\".\"namespace_authors\"._tenant IN (?)",["a1",7]],"insert":["INSERT INTO \"database\".\"namespace_authors\" (\"_uid\", \"name\") VALUES (?, ?)",["a9","new"]],"reused":["SELECT \"name\" FROM \"database\".\"namespace_authors\" WHERE \"database\".\"namespace_authors\"._tenant IN (?)",[7]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[["SELECT pg_backend_pid()",[]]]},"exists":{"outcome":"ok","statements":[["SELECT \"schema_name\" FROM information_schema.schemata WHERE \"schema_name\" = ?",[[1,"database"]]]]},"collectionExists":{"outcome":"ok","statements":[["SELECT \"table_name\" FROM information_schema.tables WHERE \"table_schema\" = ? AND \"table_name\" = ?",[[1,"database"],[2,"namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["SELECT pg_relation_size(?)",[[1,"\"database\".\"namespace_authors\""],[1,"\"database\".\"namespace_authors\""]]],["SELECT pg_relation_size(?)",[[1,"\"database\".\"namespace_authors_perms\""],[1,"\"database\".\"namespace_authors_perms\""]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["SELECT pg_total_relation_size(?)",[[1,"\"database\".\"namespace_authors\""],[1,"\"database\".\"namespace_authors\""]]],["SELECT pg_total_relation_size(?)",[[1,"\"database\".\"namespace_authors_perms\""],[1,"\"database\".\"namespace_authors_perms\""]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO \"database\".\"namespace_authors\" (\"_uid\", \"name\", \"_createdAt\", \"_updatedAt\", \"_permissions\", \"_tenant\") VALUES (?, ?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"],[6,7]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\", \"_tenant\") VALUES (?, ?, ?, ?), (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,7],[5,"a1"],[6,"update"],[7,"user:1"],[8,7]]]]},"update":{"outcome":"ok","statements":[["UPDATE \"database\".\"namespace_authors\" SET \"name\" = ?, \"_createdAt\" = ?, \"_updatedAt\" = ?, \"_permissions\" = ? WHERE \"_id\" IS NULL AND _tenant IN (?)",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"],[5,7]]],["SELECT \"_document\", \"_type\", \"_permission\" FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\", \"_tenant\") VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"],[4,7]]]]},"rename":{"outcome":"ok","statements":[["UPDATE \"database\".\"namespace_authors\" SET \"_uid\" = ?, \"name\" = ?, \"_createdAt\" = ?, \"_updatedAt\" = ?, \"_permissions\" = ? WHERE \"_id\" IS NULL AND _tenant IN (?)",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"],[6,7]]],["DELETE FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\", \"_tenant\") VALUES (?, ?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"],[4,7]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM \"database\".\"namespace_authors\" WHERE \"_uid\" IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]],["DELETE FROM \"database\".\"namespace_authors_perms\" WHERE \"_document\" IN (?) AND _tenant IN (?)",[[1,"a1"],[2,7]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO \"database\".\"namespace_authors\" AS \"target\" (\"_createdAt\", \"_permissions\", \"_tenant\", \"_uid\", \"_updatedAt\", \"name\") VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (\"_uid\", \"_tenant\") DO UPDATE SET \"_permissions\" = CASE WHEN target._tenant = EXCLUDED._tenant THEN EXCLUDED.\"_permissions\" ELSE target.\"_permissions\" END, \"_updatedAt\" = CASE WHEN target._tenant = EXCLUDED._tenant THEN EXCLUDED.\"_updatedAt\" ELSE target.\"_updatedAt\" END, \"name\" = CASE WHEN target._tenant = EXCLUDED._tenant THEN EXCLUDED.\"name\" ELSE target.\"name\" END",[[1,"2026-09-30 00:00:00.000"],[2,"[\"read(\\\"user:3\\\")\"]"],[3,7],[4,"a1"],[5,"2026-09-30 00:00:00.000"],[6,"one"]]],["DELETE FROM \"database\".\"namespace_authors_perms\" WHERE ((\"_document\" IN (?) AND \"_tenant\" IN (?) AND \"_type\" IN (?) AND \"_permission\" IN (?)))",[[1,"a1"],[2,7],[3,"read"],[4,"any"]]],["INSERT INTO \"database\".\"namespace_authors_perms\" (\"_document\", \"_type\", \"_permission\", \"_tenant\") VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"],[4,7]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM \"database\".\"namespace_authors\" AS \"table_main\" WHERE \"name\" IN (?) AND \"table_main\"._tenant IN (?) LIMIT ?",[[1,"one"],[2,7],[3,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT \"table_main\".*, \"book\".\"_uid\" AS \"book._uid\" FROM \"database\".\"namespace_authors\" AS \"table_main\" JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" AND \"book\"._tenant IN (?) AND (\"book\".\"_permissions\" @> ?::jsonb) WHERE \"table_main\"._tenant IN (?) LIMIT ?",[[1,7],[2,"[\"read(\\\"any\\\")\"]"],[3,7],[4,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["SELECT \"table_main\".*, \"book\".\"_uid\" AS \"book._uid\" FROM \"database\".\"namespace_authors\" AS \"table_main\" FULL OUTER JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" AND (\"table_main\"._tenant IN (?) OR \"table_main\".\"_uid\" IS NULL) AND \"book\"._tenant IN (?) WHERE (\"table_main\"._tenant IN (?) OR \"table_main\".\"_uid\" IS NULL) AND (\"book\"._tenant IN (?) OR \"book\".\"_uid\" IS NULL) LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT \"table_main\".*, \"book\".\"_uid\" AS \"book._uid\" FROM \"database\".\"namespace_authors\" AS \"table_main\" FULL OUTER JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" AND (\"table_main\"._tenant IN (?) OR \"table_main\".\"_uid\" IS NULL) AND \"book\"._tenant IN (?) WHERE (\"table_main\"._tenant IN (?) OR \"table_main\".\"_uid\" IS NULL) AND (\"book\"._tenant IN (?) OR \"book\".\"_uid\" IS NULL) ORDER BY RANDOM() LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS \"sum\" FROM (SELECT 1 FROM \"database\".\"namespace_authors\" AS \"table_main\" FULL OUTER JOIN \"database\".\"namespace_books\" AS \"book\" ON \"table_main\".\"_uid\" = \"book\".\"authorId\" AND (\"table_main\"._tenant IN (?) OR \"table_main\".\"_uid\" IS NULL) AND \"book\"._tenant IN (?) WHERE (\"table_main\"._tenant IN (?) OR \"table_main\".\"_uid\" IS NULL) AND (\"book\"._tenant IN (?) OR \"book\".\"_uid\" IS NULL) LIMIT ?) AS \"table_count\"",[[1,7],[2,7],[3,7],[4,7],[5,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS \"sum\" FROM (SELECT 1 FROM \"database\".\"namespace_authors\" AS \"table_main\" WHERE \"name\" IN (?) AND \"table_main\"._tenant IN (?) LIMIT ?) AS \"table_count\"",[[1,"one"],[2,7],[3,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(\"pages\") AS \"sum\" FROM (SELECT \"pages\" FROM \"database\".\"namespace_authors\" AS \"table_main\" WHERE \"name\" IN (?) AND \"table_main\"._tenant IN (?) LIMIT ?) AS \"table_count\"",[[1,"one"],[2,7],[3,5]]]]}}}, + "SQLite\/plain": {"reads":{"select":["SELECT `name`, `_uid` FROM `namespace_authors` WHERE `name` IN (?) AND `_id` > ? ORDER BY `_createdAt` DESC LIMIT ? OFFSET ?",["x",3,5,2]],"join":["SELECT `namespace_authors`.`name`, `book`.`pages`, `review`.`stars` FROM `namespace_authors` JOIN `namespace_books` AS `book` ON `namespace_authors`.`_uid` COLLATE NOCASE = `book`.`authorId` LEFT JOIN `namespace_reviews` AS `review` ON `namespace_authors`.`_uid` COLLATE NOCASE = `review`.`authorId` WHERE `book`.`pages` > ? ORDER BY `namespace_authors`.`name` ASC LIMIT ?",[10,3]],"metadata":["SELECT `name` FROM `namespace__metadata` WHERE `_uid` COLLATE NOCASE IN (?)",["authors"]],"permissions":["SELECT `_type`, `_permission` FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?)",["a1"]],"metadataPermissions":["SELECT `_permission` FROM `namespace__metadata_perms`",[]],"rightJoin":["SELECT `namespace_authors`.`name`, `review`.`stars` FROM `namespace_authors` RIGHT JOIN `namespace_reviews` AS `review` ON `namespace_authors`.`_uid` COLLATE NOCASE = `review`.`authorId`",[]],"aggregate":["SELECT COUNT(*) AS `total` FROM `namespace_authors` GROUP BY `name`",[]],"update":["UPDATE `namespace_authors` SET `name` = ? WHERE `_uid` COLLATE NOCASE IN (?)",["renamed","a1"]],"delete":["DELETE FROM `namespace_authors` WHERE `_uid` COLLATE NOCASE IN (?)",["a1"]],"insert":["INSERT INTO `namespace_authors` (`_uid`, `name`) VALUES (?, ?)",["a9","new"]],"reused":["SELECT `name` FROM `namespace_authors`",[]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[]},"exists":{"outcome":"ok","statements":[]},"collectionExists":{"outcome":"ok","statements":[["\n\t\t\tSELECT name FROM sqlite_master\n\t\t\tWHERE type='table' AND name = :table\n\t\t",[[":table","namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["\n SELECT COALESCE(SUM(\"pgsize\" - \"unused\"), 0)\n FROM \"dbstat\"\n WHERE name = :name OR name = :perms OR name LIKE :fts_pattern ESCAPE '\\';\n ",[[":name","namespace_authors"],[":perms","namespace_authors_perms"],[":fts_pattern","namespace\\_authors\\_%\\_fts%"]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["\n SELECT COALESCE(SUM(\"pgsize\" - \"unused\"), 0)\n FROM \"dbstat\"\n WHERE name = :name OR name = :perms OR name LIKE :fts_pattern ESCAPE '\\';\n ",[[":name","namespace_authors"],[":perms","namespace_authors_perms"],[":fts_pattern","namespace\\_authors\\_%\\_fts%"]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO `namespace_authors` (`_uid`, `name`, `_createdAt`, `_updatedAt`, `_permissions`) VALUES (?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?), (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,"a1"],[5,"update"],[6,"user:1"]]]]},"update":{"outcome":"ok","statements":[["UPDATE `namespace_authors` SET `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_uid` COLLATE NOCASE IN (?)",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"],[5,"a1"]]],["SELECT `_document`, `_type`, `_permission` FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?)",[[1,"a1"]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"]]]]},"rename":{"outcome":"ok","statements":[["UPDATE `namespace_authors` SET `_uid` = ?, `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_uid` COLLATE NOCASE IN (?)",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"],[6,"a1"]]],["DELETE FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?)",[[1,"a1"]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM `namespace_authors` WHERE `_uid` COLLATE NOCASE IN (?)",[[1,"a1"]]],["DELETE FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?)",[[1,"a1"]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO `namespace_authors` (`_createdAt`, `_permissions`, `_uid`, `_updatedAt`, `name`)\n VALUES (:key_0, :key_1, :key_2, :key_3, :key_4)\n ON CONFLICT (_uid) DO UPDATE\n SET `_permissions` = excluded.`_permissions`, `_updatedAt` = excluded.`_updatedAt`, `name` = excluded.`name`",[["key_0","2026-09-30 00:00:00.000"],["key_1","[\"read(\\\"user:3\\\")\"]"],["key_2","a1"],["key_3","2026-09-30 00:00:00.000"],["key_4","one"]]],["DELETE FROM `namespace_authors_perms` WHERE ((`_document` COLLATE NOCASE IN (?) AND `_type` IN (?) AND `_permission` IN (?)))",[[1,"a1"],[2,"read"],[3,"any"]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`) VALUES (?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM `namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?",[[1,"one"],[2,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND `book`.`_uid` COLLATE NOCASE IN (SELECT _document FROM `namespace_books_perms` WHERE _permission IN (?) AND _type = ?) LIMIT ?",[[1,"any"],[2,"read"],[3,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` LEFT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` UNION ALL SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` RIGHT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL LIMIT ?",[[1,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT * FROM (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` LEFT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` UNION ALL SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` RIGHT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL) AS `foj_rows` ORDER BY RANDOM() LIMIT ?",[[1,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `namespace_authors` AS `table_main` LEFT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` UNION ALL SELECT 1 FROM `namespace_authors` AS `table_main` RIGHT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL LIMIT ?) AS `table_count`",[[1,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(`pages`) AS `sum` FROM (SELECT `pages` FROM `namespace_authors` AS `table_main` WHERE `name` IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,5]]]]}}}, + "SQLite\/shared": {"reads":{"select":["SELECT `name`, `_uid` FROM `namespace_authors` WHERE `name` IN (?) AND `_id` > ? AND `namespace_authors`._tenant IN (?) ORDER BY `_createdAt` DESC LIMIT ? OFFSET ?",["x",3,7,5,2]],"join":["SELECT `namespace_authors`.`name`, `book`.`pages`, `review`.`stars` FROM `namespace_authors` JOIN `namespace_books` AS `book` ON `namespace_authors`.`_uid` COLLATE NOCASE = `book`.`authorId` AND `book`._tenant IN (?) LEFT JOIN `namespace_reviews` AS `review` ON `namespace_authors`.`_uid` COLLATE NOCASE = `review`.`authorId` AND `review`._tenant IN (?) WHERE `book`.`pages` > ? AND `namespace_authors`._tenant IN (?) ORDER BY `namespace_authors`.`name` ASC LIMIT ?",[7,7,10,7,3]],"metadata":["SELECT `name` FROM `namespace__metadata` WHERE `_uid` COLLATE NOCASE IN (?) AND (`namespace__metadata`._tenant IN (?) OR `namespace__metadata`._tenant IS NULL)",["authors",7]],"permissions":["SELECT `_type`, `_permission` FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?) AND `namespace_authors_perms`._tenant IN (?)",["a1",7]],"metadataPermissions":["SELECT `_permission` FROM `namespace__metadata_perms` WHERE (`namespace__metadata_perms`._tenant IN (?) OR `namespace__metadata_perms`._tenant IS NULL)",[7]],"rightJoin":["SELECT `namespace_authors`.`name`, `review`.`stars` FROM `namespace_authors` RIGHT JOIN `namespace_reviews` AS `review` ON `namespace_authors`.`_uid` COLLATE NOCASE = `review`.`authorId` AND (`namespace_authors`._tenant IN (?) OR `namespace_authors`.`_uid` IS NULL) AND `review`._tenant IN (?) WHERE (`namespace_authors`._tenant IN (?) OR `namespace_authors`.`_uid` IS NULL) AND (`review`._tenant IN (?) OR `review`.`_uid` IS NULL)",[7,7,7,7]],"aggregate":["SELECT COUNT(*) AS `total` FROM `namespace_authors` WHERE `namespace_authors`._tenant IN (?) GROUP BY `name`",[7]],"update":["UPDATE `namespace_authors` SET `name` = ? WHERE `_uid` COLLATE NOCASE IN (?) AND `namespace_authors`._tenant IN (?)",["renamed","a1",7]],"delete":["DELETE FROM `namespace_authors` WHERE `_uid` COLLATE NOCASE IN (?) AND `namespace_authors`._tenant IN (?)",["a1",7]],"insert":["INSERT INTO `namespace_authors` (`_uid`, `name`) VALUES (?, ?)",["a9","new"]],"reused":["SELECT `name` FROM `namespace_authors` WHERE `namespace_authors`._tenant IN (?)",[7]]},"internal":{"ping":{"outcome":"ok","statements":[["SELECT 1",[]]]},"id":{"outcome":"ok","statements":[]},"exists":{"outcome":"ok","statements":[]},"collectionExists":{"outcome":"ok","statements":[["\n\t\t\tSELECT name FROM sqlite_master\n\t\t\tWHERE type='table' AND name = :table\n\t\t",[[":table","namespace_authors"]]]]},"size":{"outcome":"ok","statements":[["\n SELECT COALESCE(SUM(\"pgsize\" - \"unused\"), 0)\n FROM \"dbstat\"\n WHERE name = :name OR name = :perms OR name LIKE :fts_pattern ESCAPE '\\';\n ",[[":name","namespace_authors"],[":perms","namespace_authors_perms"],[":fts_pattern","namespace\\_7\\_authors\\_%\\_fts%"]]]]},"sizeOnDisk":{"outcome":"ok","statements":[["\n SELECT COALESCE(SUM(\"pgsize\" - \"unused\"), 0)\n FROM \"dbstat\"\n WHERE name = :name OR name = :perms OR name LIKE :fts_pattern ESCAPE '\\';\n ",[[":name","namespace_authors"],[":perms","namespace_authors_perms"],[":fts_pattern","namespace\\_7\\_authors\\_%\\_fts%"]]]]},"create":{"outcome":"ok","statements":[["INSERT INTO `namespace_authors` (`_uid`, `name`, `_createdAt`, `_updatedAt`, `_permissions`, `_tenant`) VALUES (?, ?, ?, ?, ?, ?)",[[1,"a1"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"any\\\")\",\"update(\\\"user:1\\\")\"]"],[6,7]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?), (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"any"],[4,7],[5,"a1"],[6,"update"],[7,"user:1"],[8,7]]]]},"update":{"outcome":"ok","statements":[["UPDATE `namespace_authors` SET `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_uid` COLLATE NOCASE IN (?) AND `namespace_authors`._tenant IN (?)",[[1,"one"],[2,"2026-09-30 00:00:00.000"],[3,"2026-09-30 00:00:00.000"],[4,"[\"read(\\\"user:2\\\")\"]"],[5,"a1"],[6,7]]],["SELECT `_document`, `_type`, `_permission` FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?) AND `namespace_authors_perms`._tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:2"],[4,7]]]]},"rename":{"outcome":"ok","statements":[["UPDATE `namespace_authors` SET `_uid` = ?, `name` = ?, `_createdAt` = ?, `_updatedAt` = ?, `_permissions` = ? WHERE `_uid` COLLATE NOCASE IN (?) AND `namespace_authors`._tenant IN (?)",[[1,"a2"],[2,"one"],[3,"2026-09-30 00:00:00.000"],[4,"2026-09-30 00:00:00.000"],[5,"[\"read(\\\"user:2\\\")\"]"],[6,"a1"],[7,7]]],["DELETE FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?) AND `namespace_authors_perms`._tenant IN (?)",[[1,"a1"],[2,7]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a2"],[2,"read"],[3,"user:2"],[4,7]]]]},"delete":{"outcome":"ok","statements":[["DELETE FROM `namespace_authors` WHERE `_uid` COLLATE NOCASE IN (?) AND `namespace_authors`._tenant IN (?)",[[1,"a1"],[2,7]]],["DELETE FROM `namespace_authors_perms` WHERE `_document` COLLATE NOCASE IN (?) AND `namespace_authors_perms`._tenant IN (?)",[[1,"a1"],[2,7]]]]},"upsert":{"outcome":"ok","statements":[["INSERT INTO `namespace_authors` (`_createdAt`, `_permissions`, `_tenant`, `_uid`, `_updatedAt`, `name`)\n VALUES (:key_0, :key_1, :key_2, :key_3, :key_4, :key_5)\n ON CONFLICT (_tenant, _uid) DO UPDATE\n SET `_permissions` = CASE WHEN _tenant = excluded._tenant THEN excluded.`_permissions` ELSE `_permissions` END, `_updatedAt` = CASE WHEN _tenant = excluded._tenant THEN excluded.`_updatedAt` ELSE `_updatedAt` END, `name` = CASE WHEN _tenant = excluded._tenant THEN excluded.`name` ELSE `name` END",[["key_0","2026-09-30 00:00:00.000"],["key_1","[\"read(\\\"user:3\\\")\"]"],["key_2",7],["key_3","a1"],["key_4","2026-09-30 00:00:00.000"],["key_5","one"]]],["DELETE FROM `namespace_authors_perms` WHERE ((`_document` COLLATE NOCASE IN (?) AND `_tenant` IN (?) AND `_type` IN (?) AND `_permission` IN (?)))",[[1,"a1"],[2,7],[3,"read"],[4,"any"]]],["INSERT INTO `namespace_authors_perms` (`_document`, `_type`, `_permission`, `_tenant`) VALUES (?, ?, ?, ?)",[[1,"a1"],[2,"read"],[3,"user:3"],[4,7]]]]},"find":{"outcome":"ok","statements":[["SELECT * FROM `namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?",[[1,"one"],[2,7],[3,10]]]]},"findAuthorized":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND `book`._tenant IN (?) AND `book`.`_uid` COLLATE NOCASE IN (SELECT _document FROM `namespace_books_perms` WHERE _permission IN (?) AND _type = ? AND `namespace_books_perms`._tenant IN (?)) WHERE `table_main`._tenant IN (?) LIMIT ?",[[1,7],[2,"any"],[3,"read"],[4,7],[5,7],[6,10]]]]},"fullOuterJoin":{"outcome":"ok","statements":[["SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` LEFT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?) UNION ALL SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` RIGHT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL) LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,10]]]]},"fullOuterJoinRandom":{"outcome":"ok","statements":[["SELECT * FROM (SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` LEFT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?) UNION ALL SELECT `table_main`.*, `book`.`_uid` AS `book._uid` FROM `namespace_authors` AS `table_main` RIGHT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL)) AS `foj_rows` ORDER BY RANDOM() LIMIT ?",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,10]]]]},"fullOuterJoinCount":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `namespace_authors` AS `table_main` LEFT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND `book`._tenant IN (?) WHERE `table_main`._tenant IN (?) UNION ALL SELECT 1 FROM `namespace_authors` AS `table_main` RIGHT JOIN `namespace_books` AS `book` ON `table_main`.`_uid` COLLATE NOCASE = `book`.`authorId` AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `book`._tenant IN (?) WHERE `table_main`.`_uid` IS NULL AND `book`.`_uid` IS NOT NULL AND (`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND (`book`._tenant IN (?) OR `book`.`_uid` IS NULL) LIMIT ?) AS `table_count`",[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7],[7,5]]]]},"count":{"outcome":"ok","statements":[["SELECT COUNT(1) AS `sum` FROM (SELECT 1 FROM `namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,7],[3,5]]]]},"sum":{"outcome":"ok","statements":[["SELECT SUM(`pages`) AS `sum` FROM (SELECT `pages` FROM `namespace_authors` AS `table_main` WHERE `name` IN (?) AND `table_main`._tenant IN (?) LIMIT ?) AS `table_count`",[[1,"one"],[2,7],[3,5]]]]}}} +} diff --git a/tests/unit/Adapter/Data/HasFeature/narrowing.php b/tests/unit/Adapter/Data/HasFeature/narrowing.php new file mode 100644 index 0000000000..ece7975d29 --- /dev/null +++ b/tests/unit/Adapter/Data/HasFeature/narrowing.php @@ -0,0 +1,23 @@ +hasFeature(Spatial::class)) { + assertType('Utopia\Database\Adapter', $adapter); + } +} + +function pool(Pool $pool): void +{ + if ($pool->hasFeature(Spatial::class)) { + assertType('Utopia\Database\Adapter\Pool', $pool); + } +} diff --git a/tests/unit/Adapter/DatabaseUpdateTest.php b/tests/unit/Adapter/DatabaseUpdateTest.php new file mode 100644 index 0000000000..81c22a7376 --- /dev/null +++ b/tests/unit/Adapter/DatabaseUpdateTest.php @@ -0,0 +1,323 @@ +authorization = new Authorization(); + } + + public function testRenameKeepsCollectionsDocumentsIndexesRelationshipsAndPermissions(): void + { + $database = $this->database(new Memory()); + $this->populate($database, self::SOURCE); + + $this->assertTrue($database->update(self::SOURCE, self::TARGET)); + + $this->assertSame(self::TARGET, $database->getDatabase(), 'The renamed current database stays current'); + $this->assertFalse($database->exists(self::SOURCE)); + $this->assertTrue($database->exists(self::TARGET)); + $this->assertSame( + [self::AUTHORS, self::BOOKS], + $this->sorted(\array_map( + static fn (Collection $collection): string => $collection->getId(), + $this->authorization->skip(fn (): array => $database->listCollections()), + )), + ); + $this->assertContains('by_title', \array_map(static fn (Index $index): string => $index->key, $database->getCollection(self::BOOKS)->indexes())); + + $author = $this->authorization->skip(fn (): Document => $database->getDocument(self::AUTHORS, 'tolkien')); + $this->assertSame('Tolkien', $author->getAttribute('name')); + $this->assertSame( + ['hobbit', 'silmarillion'], + $this->relatedBookIds($author), + ); + + $this->authorization->addRole(Role::user('reader')->toString()); + $visible = $database->find(self::BOOKS, [Query::orderAsc('title')]); + $this->assertSame(['hobbit'], \array_map(static fn (Document $book): string => $book->getId(), $visible), 'Only the book readable by the reader is found'); + + $this->expectException(DuplicateException::class); + $this->authorization->skip(fn (): Document => $database->createDocument(self::BOOKS, new Document([ + '$id' => 'duplicate', + 'title' => 'The Hobbit', + '$permissions' => [], + ]))); + } + + public function testRenamingToAnExistingDatabaseThrowsDuplicateAndMovesNothing(): void + { + $database = $this->database(new Memory()); + $this->populate($database, self::SOURCE); + $database->setDatabase(self::TARGET)->create(); + + try { + $database->update(self::SOURCE, self::TARGET); + $this->fail('Renaming onto an existing database must throw'); + } catch (DuplicateException) { + } + + $database->setDatabase(self::SOURCE); + $this->assertSame('Tolkien', $this->authorization->skip(fn (): Document => $database->getDocument(self::AUTHORS, 'tolkien'))->getAttribute('name')); + $database->setDatabase(self::TARGET); + $this->assertNull($database->findCollection(self::AUTHORS)); + } + + public function testRenamingAMissingDatabaseThrowsNotFound(): void + { + $database = $this->database(new Memory()); + + $this->expectException(NotFoundException::class); + $database->update('missing', self::TARGET); + } + + public function testSharedTablesRefuseTheRename(): void + { + $database = $this->database(new Memory()); + $this->populate($database, self::SOURCE); + $database->setSharedTables(true)->setTenant(1); + + try { + $database->update(self::SOURCE, self::TARGET); + $this->fail('A rename under shared tables must be refused'); + } catch (DatabaseException $error) { + $this->assertNotInstanceOf(DuplicateException::class, $error); + $this->assertNotInstanceOf(NotFoundException::class, $error); + } + + $database->setSharedTables(false)->setTenant(null); + $this->assertTrue($database->exists(self::SOURCE)); + $this->assertFalse($database->exists(self::TARGET)); + } + + public function testMetadataCachedUnderTheOldNameIsNotServedAfterTheRename(): void + { + $database = $this->database(new Memory()); + $this->populate($database, self::SOURCE); + $this->assertNotNull($database->findCollection(self::AUTHORS)); + $this->authorization->skip(fn (): Document => $database->getDocument(self::AUTHORS, 'tolkien')); + + $database->update(self::SOURCE, self::TARGET); + $database->setDatabase(self::SOURCE)->create(); + + $this->assertNull($database->findCollection(self::AUTHORS), 'A recreated database must not see the renamed one through the cache'); + } + + public function testMetadataCachedUnderTheNewNameIsNotServedAfterTheRename(): void + { + $database = $this->database(new Memory()); + $this->populate($database, self::SOURCE); + $database->setDatabase(self::TARGET); + $this->assertNull($database->findCollection(self::AUTHORS)); + $database->setDatabase(self::SOURCE); + + $database->update(self::SOURCE, self::TARGET); + + $this->assertNotNull($database->findCollection(self::AUTHORS), 'A miss cached under the new name must not hide the renamed collection'); + $this->assertSame('Tolkien', $this->authorization->skip(fn (): Document => $database->getDocument(self::AUTHORS, 'tolkien'))->getAttribute('name')); + } + + public function testRenamingAnotherDatabaseKeepsTheCurrentOne(): void + { + $database = $this->database(new Memory()); + $this->populate($database, self::SOURCE); + $database->setDatabase('current')->create(); + + $database->update(self::SOURCE, self::TARGET); + + $this->assertSame('current', $database->getDatabase()); + $database->setDatabase(self::TARGET); + $this->assertSame('Tolkien', $this->authorization->skip(fn (): Document => $database->getDocument(self::AUTHORS, 'tolkien'))->getAttribute('name')); + } + + public function testRollingBackRestoresTheRenamedDatabase(): void + { + $adapter = new Memory(); + $database = $this->database($adapter); + $this->populate($database, self::SOURCE); + + $adapter->startTransaction(); + $adapter->update(self::SOURCE, self::TARGET); + $adapter->rollbackTransaction(); + + $this->assertTrue($adapter->exists(self::SOURCE)); + $this->assertFalse($adapter->exists(self::TARGET)); + $this->assertSame('Tolkien', $this->authorization->skip(fn (): Document => $database->getDocument(self::AUTHORS, 'tolkien'))->getAttribute('name')); + } + + public function testSQLiteRenameKeepsEveryCollectionReachable(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $this->populate($database, self::SOURCE); + + $this->assertTrue($database->update(self::SOURCE, self::TARGET)); + + $this->assertSame(self::TARGET, $database->getDatabase()); + $author = $this->authorization->skip(fn (): Document => $database->getDocument(self::AUTHORS, 'tolkien')); + $this->assertSame('Tolkien', $author->getAttribute('name')); + $this->assertSame(['hobbit', 'silmarillion'], $this->relatedBookIds($author)); + } + + public function testMirrorRenamesTheSourceAndTheDestination(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $destination = new Database(new Memory(), new Cache(new None())); + $mirror = new Mirror($source, $destination); + $mirror->setAuthorization($this->authorization)->setNamespace(self::NAMESPACE)->setDatabase(self::SOURCE)->create(); + $this->authorization->skip(fn (): Collection => $mirror->createCollection(Collection::create(id: self::AUTHORS, attributes: [Attribute::string(key: 'name', size: 64)]))); + + $this->assertTrue($mirror->update(self::SOURCE, self::TARGET)); + + foreach (['source' => $source, 'destination' => $destination] as $side => $replica) { + $this->assertSame(self::TARGET, $replica->getDatabase(), "The {$side} follows the rename"); + $this->assertFalse($replica->exists(self::SOURCE), "The {$side} no longer holds the old name"); + $this->assertNotNull($replica->findCollection(self::AUTHORS), "The {$side} keeps its collections"); + } + } + + public function testReadWritePoolRenamesOnThePrimary(): void + { + $primary = new Memory(); + $replica = new Memory(); + $primary->create(self::SOURCE); + $replica->create(self::SOURCE); + $pool = new ReadWritePool($this->connections($primary), $this->connections($replica)); + $pool->setAuthorization($this->authorization); + + $this->assertTrue($pool->update(self::SOURCE, self::TARGET)); + + $this->assertTrue($primary->exists(self::TARGET)); + $this->assertFalse($primary->exists(self::SOURCE)); + $this->assertTrue($replica->exists(self::SOURCE), 'A replica follows the primary through replication, never through the pool'); + } + + /** + * @return UtopiaPool + */ + private function connections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(static fn (callable $callback): mixed => $callback($adapter)); + + return $connections; + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new HashAwareMemoryCache())); + $database->setAuthorization($this->authorization)->setNamespace(self::NAMESPACE); + $database->addHook(new Relationships()); + + return $database; + } + + private function populate(Database $database, string $name): void + { + $database->setDatabase($name)->create(); + + $this->authorization->skip(function () use ($database): void { + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: self::BOOKS, + attributes: [Attribute::string(key: 'title', size: 64)], + indexes: [Index::unique(key: 'by_title', attributes: ['title'])], + permissions: [], + documentSecurity: true, + )); + $database->createRelationship(self::AUTHORS, Relationship::oneToMany( + relatedCollection: self::BOOKS, + key: 'books', + twoWay: true, + twoWayKey: 'author', + )); + + $database->createDocument(self::AUTHORS, new Document(['$id' => 'tolkien', 'name' => 'Tolkien'])); + $database->createDocument(self::BOOKS, new Document([ + '$id' => 'hobbit', + 'title' => 'The Hobbit', + 'author' => 'tolkien', + '$permissions' => [Permission::read(Role::user('reader'))], + ])); + $database->createDocument(self::BOOKS, new Document([ + '$id' => 'silmarillion', + 'title' => 'The Silmarillion', + 'author' => 'tolkien', + '$permissions' => [], + ])); + }); + } + + /** + * @return list + */ + private function relatedBookIds(Document $author): array + { + $books = $author->getAttribute('books', []); + $ids = []; + foreach (\is_array($books) ? $books : [] as $book) { + $ids[] = $book instanceof Document ? $book->getId() : (\is_string($book) ? $book : ''); + } + \sort($ids); + + return $ids; + } + + /** + * @param list $values + * @return list + */ + private function sorted(array $values): array + { + \sort($values); + + return $values; + } +} diff --git a/tests/unit/Adapter/ElsewherePinnedPool.php b/tests/unit/Adapter/ElsewherePinnedPool.php new file mode 100644 index 0000000000..f4443b3d9e --- /dev/null +++ b/tests/unit/Adapter/ElsewherePinnedPool.php @@ -0,0 +1,22 @@ +elsewhere = $adapter; + } + + #[\Override] + protected function pin(): ?Adapter + { + return $this->elsewhere; + } +} diff --git a/tests/unit/Adapter/FeatureAdapterStub.php b/tests/unit/Adapter/FeatureAdapterStub.php new file mode 100644 index 0000000000..d49bb1dc0e --- /dev/null +++ b/tests/unit/Adapter/FeatureAdapterStub.php @@ -0,0 +1,15 @@ +hasMethod($name)) { + $concrete[] = "{$name}() is not declared"; + + continue; + } + + $method = $adapter->getMethod($name); + if (! $method->isAbstract() || ! $method->isPublic()) { + $concrete[] = "{$name}() is not public and abstract"; + } + } + + $this->assertSame([], $concrete, 'The mandatory contract is declared once, abstract on Adapter'); + } + + public function testSqlOnlyMembersAreNotPartOfTheContract(): void + { + $adapter = new ReflectionClass(Adapter::class); + + foreach (self::SQL_ONLY as $name) { + $this->assertFalse($adapter->hasMethod($name), "{$name}() belongs to Adapter\\SQL"); + } + + foreach (['getKeywords', 'getInternalIndexesKeys'] as $name) { + $this->assertFalse($adapter->hasMethod($name), "{$name}() is read through limits()"); + } + + $this->assertSame([], (new Memory())->limits()->keywords); + $this->assertSame([], (new Memory())->limits()->internalIndexKeys); + } + + public function testTheAdapterImplementsNoFeatureInterface(): void + { + $this->assertSame([], \class_implements(Adapter::class)); + + foreach (self::REMOVED_FEATURES as $name) { + $this->assertFalse(\interface_exists(self::FEATURE_NAMESPACE.$name), "Feature\\{$name} must be gone"); + } + } + + public function testFeatureHoldsOnlyOptionalInterfaces(): void + { + $present = self::features(); + $this->assertSame([], \array_values(\array_diff($present, self::OPTIONAL_FEATURES)), 'Unexpected Feature interfaces'); + $this->assertSame([], \array_values(\array_diff(self::OPTIONAL_FEATURES, $present)), 'Missing optional Feature interfaces'); + } + + public function testNoOptionalFeatureRedeclaresAMandatoryMember(): void + { + $mandatory = \array_flip(self::MANDATORY); + $overlaps = []; + + foreach (self::features() as $name) { + /** @var class-string $feature */ + $feature = self::FEATURE_NAMESPACE.$name; + foreach ((new ReflectionClass($feature))->getMethods() as $method) { + if (isset($mandatory[$method->getName()])) { + $overlaps[] = "Feature\\{$name}::{$method->getName()}()"; + } + } + } + + $this->assertSame([], $overlaps, 'A Feature holds optional behaviour only'); + } + + public function testPoolDeclaresEveryMandatoryMemberUnderTheSameParameterNames(): void + { + $pool = new ReflectionClass(Pool::class); + $mismatched = []; + + foreach ((new ReflectionClass(Adapter::class))->getMethods(ReflectionMethod::IS_ABSTRACT) as $method) { + if (! $method->isPublic()) { + continue; + } + + $name = $method->getName(); + $delegate = $pool->getMethod($name); + if ($delegate->getDeclaringClass()->getName() !== Pool::class) { + $mismatched[] = "{$name}() is not declared by Pool"; + + continue; + } + + $expected = self::parameterNames($method); + $actual = self::parameterNames($delegate); + if ($expected !== $actual) { + $mismatched[] = "{$name}(".\implode(', ', $expected).') is Pool::'.$name.'('.\implode(', ', $actual).')'; + } + } + + $this->assertSame([], $mismatched, 'Named arguments that work on an adapter must work on Pool'); + } + + public function testExistsAsksForTheDatabaseAndCollectionExistsForTheCollection(): void + { + foreach (['adapter' => new Memory(), 'pool' => $this->pool(new Memory())] as $name => $adapter) { + $adapter->setNamespace('contract'); + $adapter->setDatabase('contract'); + + $this->assertFalse($adapter->exists('contract'), $name); + $this->assertFalse($adapter->collectionExists('contract', 'books'), $name); + + $adapter->create('contract'); + + $this->assertTrue($adapter->exists('contract'), $name); + $this->assertFalse($adapter->collectionExists('contract', 'books'), $name); + $this->assertFalse($adapter->exists('elsewhere'), $name); + + $adapter->createCollection('books', [Attribute::string(key: 'title', size: 64)]); + + $this->assertTrue($adapter->collectionExists('contract', 'books'), $name); + $this->assertFalse($adapter->collectionExists('elsewhere', 'books'), $name); + $this->assertFalse($adapter->collectionExists('contract', 'authors'), $name); + } + } + + public function testDocumentWritesTakeTheCollectionDocument(): void + { + $adapter = new Memory(); + $adapter->setAuthorization(new Authorization()); + $adapter->setNamespace('contract'); + $adapter->setDatabase('contract'); + $adapter->create('contract'); + $adapter->createCollection('books', [Attribute::integer(key: 'reads', required: false)]); + $books = Collection::create(id: 'books'); + + $adapter->createDocument($books, new Document(['$id' => 'first', 'reads' => 1, '$permissions' => []])); + $adapter->createDocument($books, new Document(['$id' => 'second', 'reads' => 1, '$permissions' => []])); + + [$first] = $adapter->getSequences($books, [new Document(['$id' => 'first'])]); + $this->assertNotNull($first->getSequence()); + + $this->assertTrue($adapter->increaseDocumentAttribute($books, 'first', 'reads', 4, '2026-01-01 00:00:00.000')); + $this->assertSame(5, $adapter->getDocument($books, 'first')->getAttribute('reads')); + + $this->assertTrue($adapter->deleteDocument($books, 'first')); + $this->assertTrue($adapter->getDocument($books, 'first')->isEmpty()); + + $second = $adapter->getDocument($books, 'second'); + $this->assertSame(1, $adapter->deleteDocuments($books, [(string) $second->getSequence()], [])); + $this->assertTrue($adapter->getDocument($books, 'second')->isEmpty()); + } + + public function testAPoolOverAnEngineWithoutIntrospectionListsNoSchema(): void + { + $pool = $this->pool(new Memory()); + + $this->assertSame([], $pool->getSchemaAttributes('books')); + $this->assertSame([], $pool->getSchemaIndexes('books')); + } + + public function testMemoryPoolHasFeatureDelegatesToInnerAdapter(): void + { + $pool = $this->pool(new Memory()); + + $this->assertSame(false, $pool->hasFeature(Feature\Spatial::class)); + $this->assertSame(false, $pool->hasFeature(Feature\Upserts::class)); + $this->assertSame(true, $pool->hasFeature(Feature\Relationships::class)); + } + + /** + * Pool holds timeouts as its own state and replays them onto each connection it borrows, so it answers for + * the feature without a connection; the refusal of an engine without timeouts comes when one is applied. + */ + public function testPoolAnswersForTimeoutsWithoutAConnection(): void + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willThrowException(new RuntimeException('no connection')); + + $pool = new Pool($connections); + + $this->assertTrue($pool->hasFeature(Feature\Timeouts::class)); + } + + /** + * Pool carries every optional Feature method so it can forward whichever adapter the pool hands out, but it + * implements none of the interfaces it forwards: a caller type-checking the facade would be told the pooled + * engine supports something it does not. Support is answered by hasFeature(), and a call the inner adapter + * cannot serve is refused at the facade. + */ + public function testPoolRefusesAFeatureTheInnerAdapterLacks(): void + { + $forwarded = \array_diff(self::features(), ['Timeouts']); + $implements = $this->interfaces(Pool::class); + + foreach ($forwarded as $name) { + $this->assertArrayNotHasKey(self::FEATURE_NAMESPACE.$name, $implements, $name); + } + + $pool = $this->pool(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support upserts'); + $pool->upsertDocuments(new Document(['$id' => 'any']), []); + } + + public function testRedisOffersUpsertsConnectionAndRelationships(): void + { + $redis = $this->adapters()['Redis']; + + $this->assertTrue($redis->hasFeature(Feature\Upserts::class)); + $this->assertTrue($redis->hasFeature(Feature\Connection::class)); + $this->assertTrue($redis->hasFeature(Feature\Relationships::class)); + $this->assertFalse($redis->hasFeature(Feature\Spatial::class)); + $this->assertFalse($redis->hasFeature(Feature\RawQuery::class)); + } + + public function testSQLiteOffersTheSqlFeaturesAndConnectionButNotSpatialOrTimeouts(): void + { + $sqlite = $this->adapters()['SQLite']; + + $this->assertTrue($sqlite->hasFeature(Feature\Upserts::class)); + $this->assertTrue($sqlite->hasFeature(Feature\Relationships::class)); + $this->assertTrue($sqlite->hasFeature(Feature\RawQuery::class)); + $this->assertTrue($sqlite->hasFeature(Feature\QueryBuilder::class)); + $this->assertTrue($sqlite->hasFeature(Feature\Connection::class)); + $this->assertTrue($sqlite->supports(Capability::SchemaIntrospection)); + $this->assertFalse($sqlite->hasFeature(Feature\Spatial::class)); + $this->assertFalse($sqlite->hasFeature(Feature\Timeouts::class)); + } + + public function testMariaDBAndPostgresOfferSpatialTimeoutsAndConnectionAndIntrospectTheirSchema(): void + { + foreach (['MariaDB', 'MySQL', 'Postgres'] as $name) { + $adapter = $this->adapters()[$name]; + + $this->assertTrue($adapter->hasFeature(Feature\Spatial::class), $name); + $this->assertTrue($adapter->hasFeature(Feature\Timeouts::class), $name); + $this->assertTrue($adapter->hasFeature(Feature\Connection::class), $name); + $this->assertTrue($adapter->supports(Capability::SchemaIntrospection), $name); + } + } + + public function testEveryCapabilityIsDeclaredByAnAdapter(): void + { + $declared = []; + foreach ($this->adapters() as $adapter) { + foreach (Capability::cases() as $capability) { + if ($adapter->supports($capability)) { + $declared[$capability->name] = true; + } + } + } + + $undeclared = \array_values(\array_filter( + \array_map(static fn (Capability $capability): string => $capability->name, Capability::cases()), + static fn (string $name): bool => ! isset($declared[$name]), + )); + + $this->assertSame([], $undeclared, 'Capabilities no adapter declares'); + } + + public function testSupportsAgreesWithHasFeatureWhereBothExist(): void + { + $disagreements = []; + foreach ($this->adapters() as $name => $adapter) { + $hasFeature = $adapter->hasFeature(...); + foreach (Capability::cases() as $capability) { + $feature = self::FEATURE_NAMESPACE.$capability->name; + if (\interface_exists($feature) && $adapter->supports($capability) !== $hasFeature($feature)) { + $disagreements[] = "{$name}: Capability::{$capability->name}"; + } + } + } + + $this->assertSame([], $disagreements, 'supports() and hasFeature() disagree'); + } + + /** + * @return list + */ + private static function features(): array + { + $directory = \dirname((string) (new ReflectionClass(Adapter::class))->getFileName()).'/Adapter/Feature'; + $features = \array_map(static fn (string $file): string => \basename($file, '.php'), \glob($directory.'/*.php') ?: []); + \sort($features); + + return $features; + } + + /** + * @return list + */ + private static function parameterNames(ReflectionMethod $method): array + { + return \array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $method->getParameters()); + } + + /** + * @return array + */ + private function adapters(): array + { + $pdo = self::createStub(PDO::class); + + return [ + 'MariaDB' => new MariaDB($pdo), + 'MySQL' => new MySQL($pdo), + 'Postgres' => new Postgres($pdo), + 'SQLite' => new SQLite(new PDO('sqlite::memory:')), + 'Memory' => new Memory(), + 'MongoDB' => new class () extends Mongo { + public function __construct() + { + } + }, + 'Redis' => new class () extends Redis { + public function __construct() + { + } + }, + ]; + } + + /** + * @param class-string $class + * @return array + */ + private function interfaces(string $class): array + { + $implements = \class_implements($class); + + return $implements === false ? [] : $implements; + } + + private function pool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } +} diff --git a/tests/unit/Adapter/FilteredAggregateStatementTest.php b/tests/unit/Adapter/FilteredAggregateStatementTest.php new file mode 100644 index 0000000000..72a98e23a7 --- /dev/null +++ b/tests/unit/Adapter/FilteredAggregateStatementTest.php @@ -0,0 +1,242 @@ + */ + private array $statements = []; + + /** @var list */ + private array $bindings = []; + + /** + * @return array> + */ + private static function filters(): array + { + return [ + 'equal' => static fn (): array => [Query::equal('category', ['c3'])], + 'equal several' => static fn (): array => [Query::equal('category', ['c1', 'c2', 'c3'])], + 'not equal' => static fn (): array => [Query::notEqual('category', 'c3')], + 'comparisons' => static fn (): array => [Query::greaterThan('score', 10), Query::lessThanEqual('price', 9.5)], + 'between' => static fn (): array => [Query::between('score', 1, 99)], + 'not between' => static fn (): array => [Query::notBetween('score', 1, 99)], + 'null checks' => static fn (): array => [Query::isNull('category'), Query::isNotNull('score')], + 'starts with an escaped value' => static fn (): array => [Query::startsWith('category', 'c_3%')], + 'ends with' => static fn (): array => [Query::notEndsWith('category', '3')], + 'contains in a string' => static fn (): array => [new Query(Method::Contains, 'category', ['c'])], + 'contains in an array' => static function (): array { + $query = new Query(Method::Contains, 'tags', ['t1', 't2']); + $query->setOnArray(true); + + return [$query]; + }, + 'contains any in an array' => static function (): array { + $query = Query::containsAny('tags', ['t1']); + $query->setOnArray(true); + + return [$query]; + }, + 'not contains in an array' => static function (): array { + $query = Query::notContains('tags', ['t1']); + $query->setOnArray(true); + + return [$query]; + }, + 'regex' => static fn (): array => [Query::regex('category', '^c[0-9]$')], + 'internal attributes' => static fn (): array => [Query::equal('$id', ['d1']), Query::greaterThan('$sequence', 5), Query::lessThan('$createdAt', '2030-01-01 00:00:00.000')], + 'or' => static fn (): array => [Query::or([Query::equal('category', ['c1']), Query::greaterThan('score', 5)])], + 'and inside or' => static fn (): array => [Query::or([Query::and([Query::equal('category', ['c1']), Query::isNotNull('score')]), Query::equal('category', ['c2'])])], + 'object path' => static function (): array { + $query = Query::equal('meta.level', ['x']); + $query->setAttributeType(ColumnType::Object->value); + + return [$query]; + }, + ]; + } + + /** + * @return iterable + */ + public static function aggregates(): iterable + { + $adapters = [ + 'mariadb' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends MariaDB implements AggregateReference { + use BuildsAggregates; + }, + 'mysql' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends MySQL implements AggregateReference { + use BuildsAggregates; + }, + 'postgres' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends Postgres implements AggregateReference { + use BuildsAggregates; + }, + 'sqlite' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends SQLite implements AggregateReference { + use BuildsAggregates; + }, + ]; + $tables = [ + 'plain' => ['books', null], + 'shared' => ['books', 3], + 'shared metadata' => [Database::METADATA, 3], + ]; + + foreach ($adapters as $engine => $make) { + foreach (\array_keys(self::filters()) as $filter) { + foreach (['count', 'sum'] as $operation) { + foreach ($tables as $mode => [$collection, $tenant]) { + foreach (['unauthorized', 'document security'] as $authorization) { + foreach ([null, 25] as $max) { + $name = "{$engine} {$operation} {$filter} {$mode} {$authorization} max ".($max ?? 'none'); + yield $name => [$make, $filter, $operation, $collection, $mode !== 'plain', $tenant, $authorization, $max]; + } + } + } + } + } + } + } + + /** + * @param Closure(PDO): (SQL&AggregateReference) $make + */ + #[DataProvider('aggregates')] + public function testAnAggregateWhoseQueriesNarrowItsRowsRunsTheStatementTheBuilderMakes( + Closure $make, + string $filter, + string $operation, + string $collection, + bool $shared, + ?int $tenant, + string $authorization, + ?int $max, + ): void { + $adapter = $this->adapter($make, $shared, $tenant, $authorization); + $document = Collection::create(id: $collection, documentSecurity: $authorization === 'document security'); + $queries = self::filters()[$filter](); + + $expected = $adapter->builtAggregate($operation, $document, self::filters()[$filter](), $max); + $result = $operation === 'count' + ? $adapter->count($document, $queries, $max) + : $adapter->sum($document, 'price', $queries, $max); + + $this->assertSame([$expected->query], $this->statements); + $this->assertSame($adapter->boundValues($expected->bindings), $this->bindings); + $this->assertSame(5, $result); + $this->assertEquals(self::filters()[$filter](), $queries, 'The queries handed in are left as they were'); + } + + public function testAnAggregateWhoseQueriesNarrowItsRowsBuildsNoStatement(): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (): PDOStatement { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([['sum' => '5']]); + + return $statement; + }); + $adapter = new class ($pdo) extends MariaDB { + public int $built = 0; + + #[\Override] + protected function dialectBuilder(): SQLBuilder&Scoping + { + return parent::dialectBuilder()->beforeBuild(function (): void { + $this->built++; + }); + } + }; + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setAuthorization(new Authorization()); + $collection = Collection::create(id: 'books', documentSecurity: true); + + $adapter->count($collection, [Query::equal('category', ['c3'])]); + $adapter->count($collection, [Query::greaterThan('score', 1)], 25); + $adapter->sum($collection, 'price', [Query::isNotNull('category')]); + $this->assertSame(0, $adapter->built); + + $adapter->count($collection, [Query::equal('category', ['c3']), Query::orderAsc('score'), Query::limit(5)]); + $this->assertSame(2, $adapter->built, 'Rows bounded by their own limit are still counted from a built statement'); + } + + public function testAFilterTheBuilderCannotCompileIsRefusedAsAQueryError(): void + { + $adapter = $this->adapter(static fn (PDO $pdo): SQL => new MariaDB($pdo), false, null, 'unauthorized'); + + $this->expectException(QueryException::class); + $adapter->count(Collection::create(id: 'books'), [new Query(Method::ElemMatch, 'tags', [Query::equal('name', ['x'])])]); + } + + /** + * @template T of SQL + * + * @param Closure(PDO): T $make + * @return T + */ + private function adapter(Closure $make, bool $shared, ?int $tenant, string $authorization): SQL + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bindings[] = $value; + + return true; + }); + $statement->method('fetch')->willReturn(['sum' => '5']); + $statement->method('fetchAll')->willReturn([['sum' => '5']]); + $statement->method('closeCursor')->willReturn(true); + + return $statement; + }); + + $adapter = $make($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables($shared); + $adapter->setTenant($tenant); + + $roles = new Authorization(); + foreach (self::ROLES as $role) { + $roles->addRole($role); + } + if ($authorization === 'unauthorized') { + $roles->disable(); + } + $adapter->setAuthorization($roles); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/FullOuterJoinRandomOrderTest.php b/tests/unit/Adapter/FullOuterJoinRandomOrderTest.php new file mode 100644 index 0000000000..33d708d798 --- /dev/null +++ b/tests/unit/Adapter/FullOuterJoinRandomOrderTest.php @@ -0,0 +1,120 @@ + + */ + public static function joins(): iterable + { + yield 'emulated full outer join' => [false]; + yield 'native full outer join' => [true]; + } + + #[DataProvider('joins')] + public function testAFullOuterJoinInRandomOrderReturnsEveryRow(bool $native): void + { + $database = new Database($native ? new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:')) : new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $database->setDatabase('random_order')->setNamespace('random_order')->setAuthorization(new Authorization()); + $database->create(); + foreach (['customers' => [Attribute::string('name', size: 16)], 'notes' => [Attribute::string('customerId', size: 16), Attribute::string('body', size: 16)]] as $id => $attributes) { + $database->createCollection(Collection::create(id: $id, attributes: $attributes, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + } + foreach (['c1', 'c2', 'c3'] as $customer) { + $database->createDocument('customers', new Document(['$id' => $customer, 'name' => $customer])); + } + foreach (['n1' => 'c1', 'n2' => 'c1', 'n3' => 'c2', 'n4' => 'cx'] as $note => $customer) { + $database->createDocument('notes', new Document(['$id' => $note, 'customerId' => $customer, 'body' => $note])); + } + $join = Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')]); + $select = Query::select(['name', 'note.body']); + + $this->assertSame( + $this->rows($database->find('customers', [$join, $select])), + $this->rows($database->find('customers', [$join, $select, Query::orderRandom(), Query::limit(100)])), + ); + } + + /** + * @return iterable}> + */ + public static function emulatingEngines(): iterable + { + yield 'MariaDB' => [MariaDB::class]; + yield 'MySQL' => [MySQL::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('emulatingEngines')] + public function testARandomOrderOverAnEmulatedFullOuterJoinOrdersTheUnionAsATable(string $class): void + { + $statements = []; + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$statements): PDOStatement { + $statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + $statement->method('bindValue')->willReturn(true); + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document(['$id' => 'customers']), + [Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')])], + limit: 10, + orderAttributes: [''], + orderTypes: [OrderDirection::Random], + ); + + $this->assertCount(1, $statements); + $this->assertStringStartsWith('SELECT * FROM ((SELECT ', $statements[0]); + $this->assertStringContainsString(' UNION ALL (SELECT ', $statements[0]); + $this->assertStringEndsWith(') AS `foj_rows` ORDER BY RAND() LIMIT ?', $statements[0]); + } + + /** + * @param array $documents + * @return list + */ + private function rows(array $documents): array + { + $rows = \array_map(static fn (Document $document): string => \json_encode([$document->getAttribute('name'), $document->getAttribute('note.body')], JSON_THROW_ON_ERROR), $documents); + \sort($rows); + + return $rows; + } +} diff --git a/tests/unit/Adapter/HasFeatureTypeTest.php b/tests/unit/Adapter/HasFeatureTypeTest.php new file mode 100644 index 0000000000..f5bc6a8664 --- /dev/null +++ b/tests/unit/Adapter/HasFeatureTypeTest.php @@ -0,0 +1,23 @@ + + */ + public static function types(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/Data/HasFeature/narrowing.php'); + } + + #[DataProvider('types')] + public function testHasFeatureDoesNotClaimTheAdapterImplementsTheFeature(string $assertType, string $file, mixed ...$arguments): void + { + $this->assertFileAsserts($assertType, $file, ...$arguments); + } +} diff --git a/tests/unit/Adapter/HostnameSQLite.php b/tests/unit/Adapter/HostnameSQLite.php new file mode 100644 index 0000000000..57d3e62476 --- /dev/null +++ b/tests/unit/Adapter/HostnameSQLite.php @@ -0,0 +1,15 @@ +setHostname($hostname); + } +} diff --git a/tests/unit/Adapter/HostnameTest.php b/tests/unit/Adapter/HostnameTest.php new file mode 100644 index 0000000000..154febd9dd --- /dev/null +++ b/tests/unit/Adapter/HostnameTest.php @@ -0,0 +1,27 @@ +assertSame($adapter, $adapter->setHostname('db-1')); + $this->assertSame('db-1', $adapter->hostname()); + + $adapter->setHostname('db-2'); + $this->assertSame('db-2', $adapter->hostname()); + } + + public function testAnAdapterWithoutAHostnameReturnsAnEmptyOne(): void + { + $this->assertSame('', (new SQLite(new PDO('sqlite::memory:')))->hostname()); + } +} diff --git a/tests/unit/Adapter/IdentifierQuotingTest.php b/tests/unit/Adapter/IdentifierQuotingTest.php new file mode 100644 index 0000000000..f82a51831b --- /dev/null +++ b/tests/unit/Adapter/IdentifierQuotingTest.php @@ -0,0 +1,74 @@ + + */ + public static function names(): array + { + return [ + 'plain' => ['total'], + 'backtick' => ['a`b'], + 'trailing backtick' => ['total`'], + 'double quote' => ['a"b'], + 'trailing double quote' => ['total"'], + 'both quote chars' => ['a`"b'], + 'statement terminator' => ['a`; SELECT 2 AS `b'], + ]; + } + + #[DataProvider('names')] + public function testABacktickQuotedNameIsReadAsExactlyThatName(string $name): void + { + $pdo = new PDO('sqlite::memory:'); + $adapter = new class ($pdo) extends SQLite { + public function identifier(string $name): string + { + return $this->quote($name); + } + }; + + $this->assertSame([$name], $this->columnNames($pdo, $adapter->identifier($name))); + } + + #[DataProvider('names')] + public function testADoubleQuotedNameIsReadAsExactlyThatName(string $name): void + { + $pdo = new PDO('sqlite::memory:'); + $adapter = new class ($pdo) extends Postgres { + public function identifier(string $name): string + { + return $this->quote($name); + } + }; + + $this->assertSame([$name], $this->columnNames($pdo, $adapter->identifier($name))); + } + + /** + * SQLite reads both backtick and double quoted identifiers, each escaping its quote char by + * doubling it, so it answers which column name a quoted identifier denotes for either dialect. + * + * @return list + */ + private function columnNames(PDO $pdo, string $identifier): array + { + $statement = $pdo->query('SELECT 1 AS '.$identifier); + $this->assertNotFalse($statement); + + /** @var array|false $row */ + $row = $statement->fetch(PDO::FETCH_ASSOC); + $this->assertIsArray($row); + + return \array_keys($row); + } +} diff --git a/tests/unit/Adapter/IgnoreDuplicatesStatementTest.php b/tests/unit/Adapter/IgnoreDuplicatesStatementTest.php new file mode 100644 index 0000000000..5083815376 --- /dev/null +++ b/tests/unit/Adapter/IgnoreDuplicatesStatementTest.php @@ -0,0 +1,175 @@ + $statements */ + $statements = new ArrayObject(); + $adapter = new MariaDB($this->pdo($statements, [[[[self::FRESH]], 1]])); + + $this->assertSame([self::FRESH], $this->createDocuments($adapter, [self::STORED, self::FRESH])); + $this->assertCount(1, $statements); + $this->assertStringStartsWith('INSERT IGNORE INTO', self::sent($statements, 0)); + $this->assertStringEndsWith(' RETURNING `_uid`', self::sent($statements, 0)); + } + + public function testARepeatedIdIsSentOnlyOnce(): void + { + /** @var ArrayObject $statements */ + $statements = new ArrayObject(); + $adapter = new MariaDB($this->pdo($statements, [[[[self::FRESH]], 1]])); + + $this->assertSame([self::FRESH], $this->createDocuments($adapter, [self::FRESH, self::FRESH])); + $this->assertStringNotContainsString('), (', self::sent($statements, 0), 'Only the first copy of an id is inserted'); + } + + public function testMySQLInsertsOnlyTheIdsItFoundUnstoredWithoutLocking(): void + { + /** @var ArrayObject $statements */ + $statements = new ArrayObject(); + $adapter = new MySQL($this->pdo($statements, [[[[self::STORED]], 0], [[], 1]])); + + $this->assertSame([self::FRESH], $this->createDocuments($adapter, [self::STORED, self::FRESH])); + $this->assertCount(2, $statements); + $this->assertStringStartsWith('SELECT `_uid` FROM', self::sent($statements, 0)); + $this->assertStringNotContainsString('FOR UPDATE', self::sent($statements, 0)); + $this->assertStringStartsWith('INSERT IGNORE INTO', self::sent($statements, 1)); + $this->assertStringNotContainsString('), (', self::sent($statements, 1), 'The stored id is left out of the insert'); + $this->assertStringNotContainsString('RETURNING', self::sent($statements, 1)); + } + + public function testMySQLDoesNotReportADocumentTheInsertSkipped(): void + { + /** @var ArrayObject $statements */ + $statements = new ArrayObject(); + $adapter = new MySQL($this->pdo($statements, [[[], 0], [[], 0], [[], 0]])); + + $this->assertSame([], $this->createDocuments($adapter, [self::FRESH])); + $this->assertCount(3, $statements); + $this->assertStringStartsWith('SELECT `_uid`, `_permissions` FROM', self::sent($statements, 2)); + } + + public function testMySQLReportsARowReadBackOnlyWhenItCarriesTheDocumentsPermissions(): void + { + $granted = \json_encode([Permission::read(Role::any())], JSON_THROW_ON_ERROR); + $foreign = \json_encode([Permission::read(Role::user('alice'))], JSON_THROW_ON_ERROR); + + $adapter = new MySQL($this->pdo(new ArrayObject(), [[[], 0], [[], 0], [[[self::FRESH, $foreign]], 0]])); + $this->assertSame([], $this->createDocuments($adapter, [self::FRESH]), 'A row another writer stored with other permissions is not ours'); + + $adapter = new MySQL($this->pdo(new ArrayObject(), [[[], 0], [[], 0], [[[self::FRESH, $granted]], 0]])); + $this->assertSame([self::FRESH], $this->createDocuments($adapter, [self::FRESH])); + } + + public function testPostgresSkipsOnlyAStoredIdSoAnotherUniqueCollisionFails(): void + { + /** @var ArrayObject $statements */ + $statements = new ArrayObject(); + $adapter = new Postgres($this->pdo($statements, [[[[self::FRESH]], 1]])); + + $this->assertSame([self::FRESH], $this->createDocuments($adapter, [self::STORED, self::FRESH])); + $this->assertCount(1, $statements); + $this->assertStringStartsWith('INSERT INTO', self::sent($statements, 0)); + $this->assertStringEndsWith(' ON CONFLICT ("_uid") DO NOTHING RETURNING "_uid"', self::sent($statements, 0)); + } + + public function testPostgresNamesTheTenantInTheConflictTargetUnderSharedTables(): void + { + /** @var ArrayObject $statements */ + $statements = new ArrayObject(); + $adapter = new Postgres($this->pdo($statements, [[[[self::FRESH, 7]], 1]])); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + $this->assertSame([self::FRESH], $this->createDocuments($adapter, [self::STORED, self::FRESH], tenant: 7)); + $this->assertStringEndsWith(' ON CONFLICT ("_uid", "_tenant") DO NOTHING RETURNING "_uid", "_tenant"', self::sent($statements, 0)); + } + + public function testADocumentWithoutATenantIsMatchedUnderTheAdaptersTenant(): void + { + $adapter = new Postgres($this->pdo(new ArrayObject(), [[[[self::FRESH, 7]], 1]])); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + $this->assertSame([self::FRESH], $this->createDocuments($adapter, [self::FRESH])); + } + + /** + * @param list $ids + * @return list + */ + private function createDocuments(SQL $adapter, array $ids, ?int $tenant = null): array + { + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + $documents = []; + foreach ($ids as $id) { + $document = new Document(['$id' => $id, '$permissions' => [Permission::read(Role::any())], 'title' => $id]); + if ($tenant !== null) { + $document->setAttribute('$tenant', $tenant); + } + $documents[] = $document; + } + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => 'notes', 'attributes' => []]), $documents)); + + return \array_values(\array_map(static fn (Document $document): string => $document->getId(), $created)); + } + + /** + * @param ArrayObject $statements + */ + private static function sent(ArrayObject $statements, int $index): string + { + $sent = $statements->getArrayCopy(); + self::assertArrayHasKey($index, $sent); + + return $sent[$index]; + } + + /** + * Each prepared statement is recorded and answers with the next rows and written-row count. + * + * @param ArrayObject $statements + * @param list>, int}> $results + */ + private function pdo(ArrayObject $statements, array $results): PDO + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statements, &$results): PDOStatement { + [$rows, $written] = \array_shift($results) ?? [[], 0]; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn($rows); + $statement->method('rowCount')->willReturn($written); + $statements->append(\trim($query)); + + return $statement; + }); + + return $pdo; + } +} diff --git a/tests/unit/Adapter/IndexColumnOrderTest.php b/tests/unit/Adapter/IndexColumnOrderTest.php new file mode 100644 index 0000000000..d96e423c55 --- /dev/null +++ b/tests/unit/Adapter/IndexColumnOrderTest.php @@ -0,0 +1,134 @@ + */ + private array $statements = []; + + /** + * @return array, 1: bool, 2: string}> + */ + public static function collectionIndexes(): array + { + return [ + 'MariaDB' => [MariaDB::class, false, 'INDEX `tagsfirst` (`tags`(255), `status`, `name`(16) DESC)'], + 'MariaDB with shared tables' => [MariaDB::class, true, 'INDEX `tagsfirst` (`_tenant`, `tags`(255), `status`, `name`(16) DESC)'], + 'MySQL' => [MySQL::class, false, 'INDEX `tagsfirst` ((CAST(`tags` AS char(255) ARRAY)), `status`, `name`(16) DESC)'], + 'MySQL with shared tables' => [MySQL::class, true, 'INDEX `tagsfirst` (`_tenant`, (CAST(`tags` AS char(255) ARRAY)), `status`, `name`(16) DESC)'], + ]; + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('collectionIndexes')] + public function testCollectionIndexKeepsAttributeOrder(string $adapterClass, bool $sharedTables, string $index): void + { + $adapter = $this->createAdapter($adapterClass, $sharedTables); + + $adapter->createCollection( + 'places', + [ + Attribute::string(key: 'tags', size: 64, array: true), + Attribute::string(key: 'status', size: 32), + Attribute::string(key: 'name', size: 128), + ], + [Index::key(key: 'tagsfirst', attributes: ['tags', 'status', 'name'], lengths: [255, null, 16], orders: [null, null, OrderDirection::Desc])], + ); + + $this->assertStringContainsString($index, $this->statements[0]); + } + + /** + * @return array + */ + public static function objectPathIndexes(): array + { + return [ + 'dedicated tables' => [false, 'CREATE INDEX "namespace__places_countryfirst" ON "database"."namespace_places" ((("data"->>\'country\')::text) DESC, "status")'], + 'shared tables' => [true, 'CREATE INDEX "namespace_7_places_countryfirst" ON "database"."namespace_places" ("_tenant", (("data"->>\'country\')::text) DESC, "status")'], + ]; + } + + #[DataProvider('objectPathIndexes')] + public function testPostgresObjectPathKeepsItsPosition(bool $sharedTables, string $statement): void + { + $adapter = $this->createAdapter(Postgres::class, $sharedTables); + + $adapter->createIndex( + 'places', + Index::key(key: 'countryfirst', attributes: ['data.country', 'status'], orders: [OrderDirection::Desc, null]), + ['data.country' => ColumnType::Object->value, 'status' => ColumnType::String->value], + ); + + $this->assertSame([$statement], $this->statements); + } + + public function testRepeatedColumnKeepsItsOwnLengthAndOrder(): void + { + $mariadb = $this->createAdapter(MariaDB::class, false); + $mariadb->createCollection( + 'places', + [Attribute::string(key: 'name', size: 128)], + [Index::key(key: 'twice', attributes: ['name', 'name'], lengths: [8, 16], orders: [OrderDirection::Asc, OrderDirection::Desc])], + ); + $this->assertStringContainsString('INDEX `twice` (`name`(8) ASC, `name`(16) DESC)', $this->statements[0]); + + $this->statements = []; + $postgres = $this->createAdapter(Postgres::class, false); + $postgres->createIndex('places', Index::key(key: 'twice', attributes: ['name', 'name'], orders: [OrderDirection::Asc, OrderDirection::Desc])); + $this->assertSame(['CREATE INDEX "namespace__places_twice" ON "database"."namespace_places" ("name" ASC, "name" DESC)'], $this->statements); + } + + public function testPostgresOperatorClassFollowsEveryColumn(): void + { + $adapter = $this->createAdapter(Postgres::class, false); + + $adapter->createIndex('places', Index::trigram(key: 'names', attributes: ['name', 'status'])); + $adapter->createIndex('places', Index::hnswCosine(key: 'embeddings', attribute: 'embedding')); + + $this->assertSame([ + 'CREATE INDEX "namespace__places_names" ON "database"."namespace_places" USING GIN ("name" gin_trgm_ops, "status" gin_trgm_ops)', + 'CREATE INDEX "namespace__places_embeddings" ON "database"."namespace_places" USING HNSW ("embedding" vector_cosine_ops)', + ], $this->statements); + } + + /** + * @param class-string $adapterClass + */ + private function createAdapter(string $adapterClass, bool $sharedTables): SQL + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + + $adapter = new $adapterClass($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables($sharedTables); + $adapter->setTenant($sharedTables ? 7 : null); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/LimitsTest.php b/tests/unit/Adapter/LimitsTest.php new file mode 100644 index 0000000000..c7f409db28 --- /dev/null +++ b/tests/unit/Adapter/LimitsTest.php @@ -0,0 +1,201 @@ + + */ + public static function sqlAdaptersWithTheirEarliestDate(): array + { + return [ + 'MariaDB' => [new MariaDB(new stdClass()), '1000-01-01 00:00:00'], + 'MySQL' => [new MySQL(new stdClass()), '1000-01-01 00:00:00'], + 'Postgres' => [new Postgres(new stdClass()), '-4713-01-01 00:00:00'], + 'SQLite' => [new SQLite(new PDO('sqlite::memory:')), '1000-01-01 00:00:00'], + ]; + } + + /** + * @return array + */ + public static function sqlAdapters(): array + { + return \array_map(static fn (array $case): array => [$case[0]], self::sqlAdaptersWithTheirEarliestDate()); + } + + #[DataProvider('sqlAdaptersWithTheirEarliestDate')] + public function testSQLAdaptersShareInnoDBLimits(Adapter $adapter, string $minDateTime): void + { + $limits = $adapter->limits(); + + $this->assertSame(4294967295, $limits->string); + $this->assertSame(16381, $limits->varchar); + $this->assertSame(4294967295, $limits->integer); + $this->assertSame(Database::MAX_BIG_INT, $limits->bigInteger); + $this->assertSame(1017, $limits->attributes); + $this->assertSame(64, $limits->indexes); + $this->assertSame(\count(Database::internalAttributesFor(true)), $limits->defaultAttributes); + $this->assertSame(\count(Database::INTERNAL_INDEXES), $limits->defaultIndexes); + $this->assertSame(768, $limits->indexLength); + $this->assertSame(36, $limits->uidLength); + $this->assertSame(65535, $limits->documentSize); + $this->assertSame($minDateTime, $limits->minDateTime->format('Y-m-d H:i:s')); + $this->assertSame('9999-12-31 23:59:59', $limits->maxDateTime->format('Y-m-d H:i:s')); + $this->assertSame(ColumnType::Integer, $limits->idType); + $this->assertSame(self::SQL_INTERNAL_INDEX_KEYS, $limits->internalIndexKeys); + } + + #[DataProvider('sqlAdapters')] + public function testASharedTableSpendsAByteOfEachIndexKeyOnTheTenant(Adapter $adapter): void + { + $adapter->setSharedTables(true); + $this->assertSame(767, $adapter->limits()->indexLength); + + $adapter->setSharedTables(false); + $this->assertSame(768, $adapter->limits()->indexLength); + } + + public function testSettingSharedTablesToTheirCurrentValueKeepsTheLimits(): void + { + $adapter = new MariaDB(new stdClass()); + $limits = $adapter->limits(); + + $adapter->setSharedTables(false); + + $this->assertSame($limits, $adapter->limits(), 'An unchanged mode must not rebuild the limits'); + + $adapter->setSharedTables(true); + + $this->assertSame($limits->indexLength - 1, $adapter->limits()->indexLength); + } + + public function testChangingADatetimeLimitTheDatabaseReturnsLeavesTheLimit(): void + { + $database = new Database(new MariaDB(new stdClass()), new Cache(new None())); + $min = $database->getMinDateTime()->format('Y-m-d H:i:s'); + $max = $database->getMaxDateTime()->format('Y-m-d H:i:s'); + + $database->getMinDateTime()->modify('+1 year'); + $database->getMaxDateTime()->modify('-1 year'); + + $this->assertSame($min, $database->getMinDateTime()->format('Y-m-d H:i:s')); + $this->assertSame($max, $database->getMaxDateTime()->format('Y-m-d H:i:s')); + $this->assertSame($min, $database->profile()->limits->minDateTime->format('Y-m-d H:i:s')); + } + + public function testSQLiteReservesItsOwnKeywords(): void + { + $mariadb = (new MariaDB(new stdClass()))->limits()->keywords; + $sqlite = (new SQLite(new PDO('sqlite::memory:')))->limits()->keywords; + + $this->assertContains('SELECT', $mariadb); + $this->assertNotContains('ABORT', $mariadb); + $this->assertContains('ABORT', $sqlite); + } + + public function testMongoHasNoAttributeOrDocumentSizeCap(): void + { + $limits = $this->mongo()->limits(); + + $this->assertSame(2147483647, $limits->string); + $this->assertSame(2147483647, $limits->varchar); + $this->assertSame(0, $limits->attributes); + $this->assertSame(0, $limits->documentSize); + $this->assertSame(64, $limits->indexes); + $this->assertSame(1024, $limits->indexLength); + $this->assertSame(255, $limits->uidLength); + $this->assertSame('-9999-01-01 00:00:00', $limits->minDateTime->format('Y-m-d H:i:s')); + $this->assertSame(ColumnType::Uuid7, $limits->idType); + $this->assertSame([], $limits->keywords); + $this->assertSame([], $limits->internalIndexKeys); + } + + public function testMemoryKeepsAPositiveIndexLengthAndNoDocumentSizeCap(): void + { + $limits = (new Memory())->limits(); + + $this->assertSame(1017, $limits->attributes); + $this->assertSame(1024, $limits->indexLength); + $this->assertSame(255, $limits->uidLength); + $this->assertSame(0, $limits->documentSize); + $this->assertSame('0001-01-01 00:00:00', $limits->minDateTime->format('Y-m-d H:i:s')); + $this->assertSame(ColumnType::Integer, $limits->idType); + $this->assertSame([], $limits->internalIndexKeys); + } + + public function testAPoolAnswersWithTheLimitsOfTheAdapterItBorrows(): void + { + $connection = new MariaDB(new stdClass()); + $pool = new Pool(new UtopiaPool(new Stack(), 'limits', 1, static fn (): MariaDB => $connection, timeout: 0.0)); + $pool->setAuthorization(new Authorization()); + + $this->assertSame(768, $pool->limits()->indexLength); + $this->assertSame(ColumnType::Integer, $pool->limits()->idType); + + $pool->setSharedTables(true); + + $this->assertSame(767, $pool->limits()->indexLength, 'The pool asks again once shared tables change the limits'); + } + + public function testTheDatabaseForwardsCountOnlyTheAttributesAndIndexesACollectionDeclares(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $limits = $database->getAdapter()->limits(); + + $this->assertSame($limits->attributes - $limits->defaultAttributes, $database->getLimitForAttributes()); + $this->assertSame($limits->indexes - $limits->defaultIndexes, $database->getLimitForIndexes()); + $this->assertSame(1024, $database->getMaxIndexLength()); + $this->assertSame(16381, $database->getMaxVarcharLength()); + $this->assertSame(255, $database->getMaxUidLength()); + $this->assertSame('0001-01-01', $database->getMinDateTime()->format('Y-m-d')); + $this->assertSame('9999-12-31', $database->getMaxDateTime()->format('Y-m-d')); + $this->assertSame(ColumnType::Integer, $database->getIdAttributeType()); + } + + public function testTheDatabaseReportsNoAttributeLimitWhenTheAdapterHasNone(): void + { + $database = new Database($this->mongo(), new Cache(new None())); + + $this->assertSame(0, $database->getLimitForAttributes()); + $this->assertSame(ColumnType::Uuid7, $database->getIdAttributeType()); + } + + private function mongo(): Mongo + { + return new class () extends Mongo { + public function __construct() + { + } + }; + } +} diff --git a/tests/unit/Adapter/LockedDocumentReadTest.php b/tests/unit/Adapter/LockedDocumentReadTest.php new file mode 100644 index 0000000000..0fc7a2898a --- /dev/null +++ b/tests/unit/Adapter/LockedDocumentReadTest.php @@ -0,0 +1,120 @@ + */ + private array $statements = []; + + /** @var list> */ + private array $bindings = []; + + /** + * @return iterable + */ + public static function adapters(): iterable + { + yield 'mariadb' => [static fn (PDO $pdo): SQL => new MariaDB($pdo), 'SELECT * FROM `database`.`namespace_books` AS `table_main` WHERE `_uid` = :_uid']; + yield 'postgres' => [static fn (PDO $pdo): SQL => new Postgres($pdo), 'SELECT * FROM "database"."namespace_books" AS "table_main" WHERE "_uid" = :_uid']; + } + + /** + * @param Closure(PDO): SQL $make + */ + #[DataProvider('adapters')] + public function testALockedReadSelectsTheDocumentForUpdate(Closure $make, string $select): void + { + $document = $this->adapter($make)->getDocument(Collection::create(id: 'books'), 'dune', forUpdate: true); + + $this->assertSame([$select.' FOR UPDATE'], $this->statements); + $this->assertSame([[':_uid', 'dune']], $this->bindings); + $this->assertSame('dune', $document->getId()); + $this->assertSame('7', $document->getSequence()); + $this->assertSame('Dune', $document->getAttribute('title')); + } + + /** + * @param Closure(PDO): SQL $make + */ + #[DataProvider('adapters')] + public function testAnUnlockedReadSelectsTheDocumentWithoutALock(Closure $make, string $select): void + { + $document = $this->adapter($make)->getDocument(Collection::create(id: 'books'), 'dune'); + + $this->assertSame([$select], $this->statements); + $this->assertSame('Dune', $document->getAttribute('title')); + } + + /** + * @return iterable + */ + public static function sharedReads(): iterable + { + $mariadb = static fn (PDO $pdo): SQL => new MariaDB($pdo); + $postgres = static fn (PDO $pdo): SQL => new Postgres($pdo); + + yield 'mariadb' => [$mariadb, 'books', 3, 'SELECT * FROM `database`.`namespace_books` AS `table_main` WHERE `_uid` = :_uid AND `table_main`._tenant IN (:_tenant)']; + yield 'mariadb metadata' => [$mariadb, '_metadata', 3, 'SELECT * FROM `database`.`namespace__metadata` AS `table_main` WHERE `_uid` = :_uid AND (`table_main`._tenant IN (:_tenant) OR `table_main`._tenant IS NULL)']; + yield 'mariadb without a tenant' => [$mariadb, 'books', null, 'SELECT * FROM `database`.`namespace_books` AS `table_main` WHERE `_uid` = :_uid AND `table_main`._tenant IN (:_tenant)']; + yield 'postgres' => [$postgres, 'books', 3, 'SELECT * FROM "database"."namespace_books" AS "table_main" WHERE "_uid" = :_uid AND "table_main"._tenant IN (:_tenant)']; + yield 'postgres metadata' => [$postgres, '_metadata', 3, 'SELECT * FROM "database"."namespace__metadata" AS "table_main" WHERE "_uid" = :_uid AND ("table_main"._tenant IN (:_tenant) OR "table_main"._tenant IS NULL)']; + } + + /** + * @param Closure(PDO): SQL $make + */ + #[DataProvider('sharedReads')] + public function testASharedTableReadKeepsTheTenantFilter(Closure $make, string $collection, ?int $tenant, string $select): void + { + $adapter = $this->adapter($make); + $adapter->setSharedTables(true); + $adapter->setTenant($tenant); + + $adapter->getDocument(Collection::create(id: $collection), 'dune', forUpdate: true); + $adapter->getDocument(Collection::create(id: $collection), 'dune'); + + $this->assertSame([$select.' FOR UPDATE', $select], $this->statements); + $this->assertSame([[':_uid', 'dune'], [':_tenant', $tenant], [':_uid', 'dune'], [':_tenant', $tenant]], $this->bindings); + } + + /** + * @param Closure(PDO): SQL $make + */ + private function adapter(Closure $make): SQL + { + $row = ['_id' => '7', '_uid' => 'dune', '_createdAt' => null, '_updatedAt' => null, '_permissions' => '[]', 'title' => 'Dune']; + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($row): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bindings[] = [$parameter, $value]; + + return true; + }); + $statement->method('fetch')->willReturn($row); + $statement->method('fetchAll')->willReturn([$row]); + + return $statement; + }); + + $adapter = $make($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MariaDBCreateCollectionCleanupTest.php b/tests/unit/Adapter/MariaDBCreateCollectionCleanupTest.php new file mode 100644 index 0000000000..02a80890e2 --- /dev/null +++ b/tests/unit/Adapter/MariaDBCreateCollectionCleanupTest.php @@ -0,0 +1,163 @@ + */ + private array $statements = []; + + /** @var array */ + private array $failures = []; + + /** + * @return iterable}> + */ + public static function engines(): iterable + { + yield 'MariaDB' => [MariaDB::class]; + yield 'MySQL' => [MySQL::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testAPermissionsTableThatFailsDropsTheCollectionTable(string $class): void + { + $this->failures[self::PERMISSIONS_TABLE] = $this->engineError('70100', 1969, 'Query execution was interrupted (max_statement_time exceeded)'); + + try { + $this->adapter($class)->createCollection('books', [Attribute::string('title', size: 64)]); + $this->fail('A permissions table that fails must fail the collection'); + } catch (TimeoutException $error) { + $this->assertSame('Query timed out', $error->getMessage()); + } + + $this->assertCount(3, $this->statements); + $this->assertSame( + 'DROP TABLE IF EXISTS `database`.`namespace_books`; DROP TABLE IF EXISTS `database`.`namespace_books_perms`', + $this->statements[2], + ); + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testACleanupThatFailsKeepsTheOriginalErrorAndLogsTheCleanupFailure(string $class): void + { + $this->failures[self::PERMISSIONS_TABLE] = $this->engineError('70100', 1969, 'Query execution was interrupted (max_statement_time exceeded)'); + $this->failures['DROP TABLE IF EXISTS'] = $this->engineError('HY000', 2006, 'MySQL server has gone away'); + + $error = null; + $log = StderrCapture::during(function () use ($class, &$error): void { + try { + $this->adapter($class)->createCollection('books', [Attribute::string('title', size: 64)]); + } catch (\Throwable $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(TimeoutException::class, $error, 'the permissions table failure reaches the caller, not the failed drop'); + $this->assertSame('Query timed out', $error->getMessage()); + $this->assertStringStartsWith('DROP TABLE IF EXISTS', $this->statements[2]); + $this->assertStringContainsString("Failed to rollback collection 'books': SQLSTATE[HY000]: MySQL server has gone away", $log, 'the failed cleanup is logged'); + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testAPermissionsTableThatAlreadyExistsKeepsBothTables(string $class): void + { + $this->failures[self::PERMISSIONS_TABLE] = $this->engineError('42S01', 1050, "Table 'namespace_books_perms' already exists"); + + try { + $this->adapter($class)->createCollection('books', [Attribute::string('title', size: 64)]); + $this->fail('An existing permissions table must reach the caller'); + } catch (DuplicateException $error) { + $this->assertSame('Collection already exists', $error->getMessage()); + } + + $this->assertCount(2, $this->statements); + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testACollectionTableThatFailsDropsNothing(string $class): void + { + $this->failures['CREATE TABLE `database`.`namespace_books` '] = $this->engineError('70100', 1969, 'Query execution was interrupted (max_statement_time exceeded)'); + + try { + $this->adapter($class)->createCollection('books', [Attribute::string('title', size: 64)]); + $this->fail('A collection table that fails must fail the collection'); + } catch (TimeoutException $error) { + $this->assertSame('Query timed out', $error->getMessage()); + } + + $this->assertCount(1, $this->statements); + } + + private function engineError(string $state, int $code, string $message): PDOException + { + $error = new class ('SQLSTATE[' . $state . ']: ' . $message, $state) extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $error->errorInfo = [$state, $code, $message]; + + return $error; + } + + /** + * @param class-string $class + */ + private function adapter(string $class): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $failure = null; + foreach ($this->failures as $prefix => $error) { + if (\str_starts_with($query, $prefix)) { + $failure = $error; + } + } + + $statement = $this->createStub(PDOStatement::class); + if ($failure === null) { + $statement->method('execute')->willReturn(true); + } else { + $statement->method('execute')->willThrowException($failure); + } + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MariaDBCreateDocumentTest.php b/tests/unit/Adapter/MariaDBCreateDocumentTest.php new file mode 100644 index 0000000000..90e35c5141 --- /dev/null +++ b/tests/unit/Adapter/MariaDBCreateDocumentTest.php @@ -0,0 +1,150 @@ + */ + private array $statements = []; + + private int $hookCalls = 0; + + public function testAnEmptyInsertIdIsAnError(): void + { + $adapter = $this->adapter(insertId: ''); + + try { + $adapter->createDocument($this->collection(), $this->document()); + $this->fail('A document without an insert id must not be returned as created'); + } catch (DatabaseException $error) { + $this->assertSame('Error creating document empty "$sequence"', $error->getMessage()); + } + + $this->assertCount(1, $this->statements); + $this->assertStringStartsWith('INSERT INTO', $this->statements[0]); + } + + public function testAWriteHookFailureOtherThanAnOrphanedPermissionIsMappedAndNotRetried(): void + { + $adapter = $this->adapter(insertId: '12'); + $hook = $this->failingHook([$this->engineError('22001', 1406, 'Data too long for column \'_permission\' at row 1')]); + $adapter->addWriteHook($hook); + + try { + $adapter->createDocument($this->collection(), $this->document()); + $this->fail('A write hook failure must reach the caller'); + } catch (TruncateException $error) { + $this->assertSame('Resize would result in data truncation', $error->getMessage()); + $this->assertInstanceOf(PDOException::class, $error->getPrevious()); + } + + $this->assertSame(1, $this->hookCalls); + $this->assertCount(1, $this->statements); + } + + public function testAnOrphanedPermissionIsClearedAndTheWriteHookRetried(): void + { + $adapter = $this->adapter(insertId: '12'); + $hook = $this->failingHook([$this->engineError('23000', 1062, 'Duplicate entry \'first-read-any\' for key \'_index1\'')]); + $adapter->addWriteHook($hook); + + $created = $adapter->createDocument($this->collection(), $this->document()); + + $this->assertSame('12', $created->getSequence()); + $this->assertSame(2, $this->hookCalls); + $this->assertCount(2, $this->statements); + $this->assertStringStartsWith('DELETE FROM', $this->statements[1]); + $this->assertStringContainsString('_perms', $this->statements[1]); + } + + private function collection(): Document + { + return new Document(['$id' => 'notes', 'attributes' => []]); + } + + private function document(): Document + { + return new Document([ + '$id' => 'first', + '$permissions' => ['read("any")'], + '$createdAt' => '2026-09-30 00:00:00.000', + '$updatedAt' => '2026-09-30 00:00:00.000', + 'body' => 'one', + ]); + } + + private function engineError(string $state, int $code, string $message): PDOException + { + $error = new class ('SQLSTATE[' . $state . ']: ' . $message, $state) extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $error->errorInfo = [$state, $code, $message]; + + return $error; + } + + /** + * @param list $failures + */ + private function failingHook(array $failures): Interceptor + { + $record = function (): void { + $this->hookCalls++; + }; + + return new class ($failures, $record) extends Interceptor { + /** + * @param list $failures + */ + public function __construct(private array $failures, private readonly Closure $record) + { + } + + #[\Override] + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + ($this->record)(); + $failure = \array_shift($this->failures); + if ($failure !== null) { + throw $failure; + } + } + }; + } + + private function adapter(string $insertId): MariaDB + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + $pdo->method('lastInsertId')->willReturn($insertId); + + $adapter = new MariaDB($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MariaDBDeleteCollectionTest.php b/tests/unit/Adapter/MariaDBDeleteCollectionTest.php new file mode 100644 index 0000000000..28e6fc3cbc --- /dev/null +++ b/tests/unit/Adapter/MariaDBDeleteCollectionTest.php @@ -0,0 +1,101 @@ + */ + private array $statements = []; + + /** + * @return array}> + */ + public static function adapters(): array + { + return [ + 'MariaDB' => [MariaDB::class], + 'MySQL' => [MySQL::class], + ]; + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('adapters')] + public function testDropsBothTablesInOneStatement(string $adapterClass): void + { + $adapter = $this->createAdapter($adapterClass, mainTableExists: true); + + $this->assertTrue($adapter->deleteCollection('places')); + $this->assertSame([self::MAIN.'; '.self::PERMISSIONS], $this->statements); + } + + /** + * @param class-string $adapterClass + */ + #[DataProvider('adapters')] + public function testDropsThePermissionsTableWhenTheMainTableIsGone(string $adapterClass): void + { + $adapter = $this->createAdapter($adapterClass, mainTableExists: false); + + try { + $adapter->deleteCollection('places'); + $this->fail('A collection whose table is gone must be reported as not found'); + } catch (NotFoundException $e) { + $this->assertSame('Collection not found', $e->getMessage()); + } + + $this->assertSame([self::MAIN.'; '.self::PERMISSIONS, self::PERMISSIONS], $this->statements); + } + + /** + * @param class-string $adapterClass + */ + private function createAdapter(string $adapterClass, bool $mainTableExists): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($mainTableExists): PDOStatement { + $this->statements[] = $query; + + $statement = $this->createStub(PDOStatement::class); + if (! $mainTableExists && \str_starts_with($query, self::MAIN.';')) { + $statement->method('execute')->willThrowException($this->unknownTable()); + } else { + $statement->method('execute')->willReturn(true); + } + + return $statement; + }); + + $adapter = new $adapterClass($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } + + private function unknownTable(): PDOException + { + return new class () extends PDOException { + public function __construct() + { + parent::__construct("SQLSTATE[42S02]: Base table or view not found: 1051 Unknown table 'database.namespace_places'"); + $this->code = '42S02'; + $this->errorInfo = ['42S02', 1051, "Unknown table 'database.namespace_places'"]; + } + }; + } +} diff --git a/tests/unit/Adapter/MariaDBGeometryWriteTest.php b/tests/unit/Adapter/MariaDBGeometryWriteTest.php new file mode 100644 index 0000000000..7500717790 --- /dev/null +++ b/tests/unit/Adapter/MariaDBGeometryWriteTest.php @@ -0,0 +1,128 @@ + */ + private array $statements = []; + + /** @var list */ + private array $bound = []; + + /** + * @return iterable, string}> + */ + public static function geometries(): iterable + { + yield 'point' => ['location', [1.5, -2], 'POINT(1.5 -2)']; + yield 'linestring' => ['route', [[0, 0], [1, 2], [3, 4]], 'LINESTRING(0 0, 1 2, 3 4)']; + yield 'polygon' => ['area', [[[0, 0], [1, 0], [1, 1], [0, 0]]], 'POLYGON((0 0, 1 0, 1 1, 0 0))']; + yield 'polygon with a hole' => [ + 'area', + [[[0, 0], [4, 0], [4, 4], [0, 0]], [[1, 1], [2, 1], [2, 2], [1, 1]]], + 'POLYGON((0 0, 4 0, 4 4, 0 0), (1 1, 2 1, 2 2, 1 1))', + ]; + } + + /** + * @param array $geometry + */ + #[DataProvider('geometries')] + public function testAGeometryArrayIsWrittenAsWellKnownText(string $attribute, array $geometry, string $text): void + { + $this->adapter()->createDocument($this->collection(), $this->document($attribute, $geometry)); + + $this->assertCount(1, $this->statements); + $this->assertStringContainsString('ST_GeomFromText(', $this->statements[0]); + $this->assertContains($text, $this->bound); + } + + /** + * @return iterable, string}> + */ + public static function malformedGeometries(): iterable + { + yield 'empty' => ['area', [], 'Unrecognized geometry array format']; + yield 'keyed' => ['area', ['x' => 1, 'y' => 2], 'Unrecognized geometry array format']; + yield 'single coordinate' => ['area', [['a']], 'Unrecognized geometry array format']; + yield 'word in a line' => ['route', [[0, 0], ['x', 1]], 'Invalid point format in geometry array']; + yield 'three coordinates in a line' => ['route', [[0, 0], [1, 2, 3]], 'Invalid point format in geometry array']; + yield 'word in a ring' => ['area', [[[0, 0], ['x', 1]]], 'Invalid point format in polygon ring']; + yield 'ring that is not a list' => ['area', [[[0, 0], [1, 1]], 5], 'Invalid ring format in polygon geometry']; + } + + /** + * @param array $geometry + */ + #[DataProvider('malformedGeometries')] + public function testAMalformedGeometryArrayIsRefusedBeforeAStatementIsSent(string $attribute, array $geometry, string $message): void + { + try { + $this->adapter()->createDocument($this->collection(), $this->document($attribute, $geometry)); + $this->fail('A malformed geometry must be refused'); + } catch (DatabaseException $error) { + $this->assertSame($message, $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + private function collection(): Document + { + return new Document([ + '$id' => 'places', + 'attributes' => [ + new Document(['$id' => 'location', 'key' => 'location', 'type' => 'point']), + new Document(['$id' => 'route', 'key' => 'route', 'type' => 'linestring']), + new Document(['$id' => 'area', 'key' => 'area', 'type' => 'polygon']), + ], + ]); + } + + /** + * @param array $geometry + */ + private function document(string $attribute, array $geometry): Document + { + return new Document([ + '$id' => 'place', + '$permissions' => [], + '$createdAt' => '2026-09-30 00:00:00.000', + '$updatedAt' => '2026-09-30 00:00:00.000', + $attribute => $geometry, + ]); + } + + private function adapter(): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bound[] = $value; + + return true; + }); + + return $statement; + }); + $pdo->method('lastInsertId')->willReturn('1'); + + $adapter = new MariaDB($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MariaDBPowerOperatorTest.php b/tests/unit/Adapter/MariaDBPowerOperatorTest.php new file mode 100644 index 0000000000..1b69169e39 --- /dev/null +++ b/tests/unit/Adapter/MariaDBPowerOperatorTest.php @@ -0,0 +1,106 @@ + */ + private array $statements = []; + + /** + * @return iterable, mixed}> + */ + public static function nonNumericExponents(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class] as $engine => $class) { + yield $engine . ' word' => [$class, 'two']; + yield $engine . ' boolean' => [$class, true]; + yield $engine . ' list' => [$class, [2]]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('nonNumericExponents')] + public function testPowerWithANonNumericExponentIsRefusedBeforeAStatementIsSent(string $class, mixed $exponent): void + { + $adapter = $this->adapter($class); + + try { + $adapter->updateDocuments( + new Document(['$id' => 'scores', 'attributes' => []]), + new Document(['value' => new Operator(OperatorType::Power, 'value', [$exponent])]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + ); + $this->fail('A power exponent that is not a number must be refused'); + } catch (OperatorException $error) { + $this->assertSame('Power exponent must be numeric', $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + /** + * @return iterable, int|float}> + */ + public static function numericExponents(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class] as $engine => $class) { + yield $engine . ' integer' => [$class, 2]; + yield $engine . ' float' => [$class, 0.5]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('numericExponents')] + public function testPowerWithANumericExponentIsSentAsPower(string $class, int|float $exponent): void + { + $adapter = $this->adapter($class); + + $adapter->updateDocuments( + new Document(['$id' => 'scores', 'attributes' => []]), + new Document(['value' => Operator::power($exponent)]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + ); + + $this->assertCount(1, $this->statements); + $this->assertStringContainsString('POWER(COALESCE(`value`, 0)', $this->statements[0]); + } + + /** + * @param class-string $class + */ + private function adapter(string $class): MariaDB + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('rowCount')->willReturn(1); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MariaDBSchemaTest.php b/tests/unit/Adapter/MariaDBSchemaTest.php new file mode 100644 index 0000000000..a1dc321eb0 --- /dev/null +++ b/tests/unit/Adapter/MariaDBSchemaTest.php @@ -0,0 +1,241 @@ + */ + private array $statements = []; + + /** + * @return iterable}> + */ + public static function engines(): iterable + { + yield 'MariaDB' => [MariaDB::class]; + yield 'MySQL' => [MySQL::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testCreateCollectionAddsColumnsOnlyForRelationshipSidesThatStoreAKey(string $class): void + { + $adapter = $this->adapter($class); + + $adapter->createCollection('books', [ + Attribute::string('title', size: 64), + $this->relationship('tags', RelationshipType::ManyToMany, twoWay: true, side: RelationshipSide::Parent), + $this->relationship('cover', RelationshipType::OneToOne, twoWay: false, side: RelationshipSide::Child), + $this->relationship('chapters', RelationshipType::OneToMany, twoWay: true, side: RelationshipSide::Parent), + $this->relationship('shelf', RelationshipType::ManyToOne, twoWay: true, side: RelationshipSide::Child), + $this->relationship('isbn', RelationshipType::OneToOne, twoWay: false, side: RelationshipSide::Parent), + $this->relationship('summary', RelationshipType::OneToOne, twoWay: true, side: RelationshipSide::Child), + $this->relationship('series', RelationshipType::OneToMany, twoWay: true, side: RelationshipSide::Child), + $this->relationship('publisher', RelationshipType::ManyToOne, twoWay: true, side: RelationshipSide::Parent), + ]); + + $create = $this->statements[0] ?? ''; + $this->assertStringStartsWith('CREATE TABLE', $create); + + foreach (['title', 'isbn', 'summary', 'series', 'publisher'] as $stored) { + $this->assertStringContainsString('`' . $stored . '` ', $create, $stored . ' stores a column'); + } + + foreach (['tags', 'cover', 'chapters', 'shelf'] as $skipped) { + $this->assertStringNotContainsString('`' . $skipped . '`', $create, $skipped . ' stores nothing on this side'); + } + } + + public function testCreateCollectionRefusesASpatialIndexWithOrdersWhereTheEngineCannotOrderIt(): void + { + $adapter = $this->adapter(MySQL::class); + + try { + $adapter->createCollection('places', [Attribute::point('location', required: true)], [ + Index::spatial('location_index', 'location', order: OrderDirection::Desc), + ]); + $this->fail('A spatial index with orders must be refused where the engine cannot order it'); + } catch (DatabaseException $error) { + $this->assertSame('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.', $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + public function testCreateCollectionKeepsASpatialIndexOrderWhereTheEngineSupportsIt(): void + { + $adapter = $this->adapter(MariaDB::class); + + $adapter->createCollection('places', [Attribute::point('location', required: true)], [ + Index::spatial('location_index', 'location', order: OrderDirection::Desc), + ]); + + $this->assertStringContainsString('SPATIAL INDEX `location_index` (`location` DESC)', $this->statements[0] ?? ''); + } + + /** + * @return iterable, IndexType}> + */ + public static function unknownIndexTypes(): iterable + { + foreach (self::engines() as $engine => [$class]) { + foreach ([IndexType::Ttl, IndexType::Object, IndexType::Trigram, IndexType::HnswCosine] as $type) { + yield $engine . ' ' . $type->value => [$class, $type]; + } + } + } + + /** + * @param class-string $class + */ + #[DataProvider('unknownIndexTypes')] + public function testCreateIndexRefusesATypeTheEngineDoesNotCreate(string $class, IndexType $type): void + { + $adapter = $this->adapterWithCollection($class); + + try { + $adapter->createIndex('events', Index::fromArray(['key' => 'happened_index', 'type' => $type, 'attributes' => ['happened'], 'ttl' => 3600])); + $this->fail('An index type the engine does not create must be refused'); + } catch (DatabaseException $error) { + $this->assertSame( + 'Unknown index type: ' . $type->value . '. Must be one of key, unique, fulltext, spatial', + $error->getMessage(), + ); + } + + $this->assertSame([], $this->statements); + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testCreateIndexCreatesAKeyIndex(string $class): void + { + $adapter = $this->adapterWithCollection($class); + + $this->assertTrue($adapter->createIndex('events', Index::key('happened_index', ['happened']))); + $this->assertCount(1, $this->statements); + $this->assertStringContainsString('`happened_index`', $this->statements[0]); + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testCreateIndexOnACollectionWithoutADefinitionIsNotFound(string $class): void + { + $adapter = $this->adapterWithCollection($class); + + try { + $adapter->createIndex('missing', Index::key('happened_index', ['happened'])); + $this->fail('An index on a collection without a definition must not be created'); + } catch (NotFoundException $error) { + $this->assertSame('Collection not found', $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + private function relationship(string $key, RelationshipType $type, bool $twoWay, RelationshipSide $side): Attribute + { + return Attribute::fromArray(['key' => $key, 'type' => ColumnType::Relationship, 'options' => [ + 'relatedCollection' => 'related_' . $key, + 'relationType' => $type->value, + 'twoWay' => $twoWay, + 'twoWayKey' => 'back_' . $key, + 'side' => $side->value, + ]]); + } + + /** + * @param class-string $class + */ + private function adapter(string $class): MariaDB + { + $adapter = new $class($this->connection()); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } + + /** + * @param class-string $class + */ + private function adapterWithCollection(string $class): MariaDB + { + $collection = new Document([ + '$id' => 'events', + 'attributes' => \json_encode([['$id' => 'happened', 'type' => 'datetime', 'array' => false]]), + ]); + + if ($class === MySQL::class) { + $adapter = new class ($this->connection(), $collection) extends MySQL { + public function __construct(object $pdo, private readonly Document $collection) + { + parent::__construct($pdo); + } + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + return $collection->getId() === Database::METADATA && $id === $this->collection->getId() ? $this->collection : new Document(); + } + }; + } else { + $adapter = new class ($this->connection(), $collection) extends MariaDB { + public function __construct(object $pdo, private readonly Document $collection) + { + parent::__construct($pdo); + } + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + return $collection->getId() === Database::METADATA && $id === $this->collection->getId() ? $this->collection : new Document(); + } + }; + } + + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } + + private function connection(): PDO + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + + return $pdo; + } +} diff --git a/tests/unit/Adapter/MariaDBStatementEventTest.php b/tests/unit/Adapter/MariaDBStatementEventTest.php new file mode 100644 index 0000000000..0366bc9581 --- /dev/null +++ b/tests/unit/Adapter/MariaDBStatementEventTest.php @@ -0,0 +1,63 @@ + */ + private array $sessionStatements = []; + + public function testAFirstStatementWithoutAnEventRunsUnderTheBaselineTimeout(): void + { + $adapter = $this->adapter(); + $adapter->setTimeout(50, Event::CollectionRead); + + $adapter->rawQuery('SELECT 1'); + + $this->assertSame([], $this->sessionStatements); + } + + public function testAStatementWithAnEventRunsUnderThatEventsTimeout(): void + { + $adapter = $this->adapter(); + $adapter->setTimeout(50, Event::CollectionRead); + + $adapter->rawQuery('SELECT 1'); + $adapter->getSizeOfCollection('notes'); + + $this->assertSame([ + 'SET max_statement_time = 0.050000', + 'SET max_statement_time = 0.000000', + ], \array_values(\array_unique($this->sessionStatements))); + } + + private function adapter(): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('fetchColumn')->willReturn('0'); + + return $statement; + }); + $pdo->method('exec')->willReturnCallback(function (string $statement): int { + $this->sessionStatements[] = $statement; + + return 0; + }); + + $adapter = new MariaDB($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MariaDBTimeoutRejectionTest.php b/tests/unit/Adapter/MariaDBTimeoutRejectionTest.php new file mode 100644 index 0000000000..e3e4f1f32c --- /dev/null +++ b/tests/unit/Adapter/MariaDBTimeoutRejectionTest.php @@ -0,0 +1,81 @@ +, int, Event}> + */ + public static function nonPositiveTimeouts(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class] as $engine => $class) { + yield $engine . ' zero' => [$class, 0, Event::All]; + yield $engine . ' negative' => [$class, -1, Event::All]; + yield $engine . ' zero for one event' => [$class, 0, Event::DocumentFind]; + yield $engine . ' negative for one event' => [$class, -250, Event::DocumentFind]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('nonPositiveTimeouts')] + public function testNonPositiveTimeoutIsRejected(string $class, int $milliseconds, Event $event): void + { + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->never())->method('exec'); + + $adapter = new $class($pdo); + + try { + $adapter->setTimeout($milliseconds, $event); + $this->fail('A timeout that is not positive must be rejected'); + } catch (DatabaseException $error) { + $this->assertSame('Timeout must be greater than 0', $error->getMessage()); + } + + $this->assertSame(0, $adapter->getTimeout()); + $this->assertSame(0, $adapter->getTimeout($event)); + } + + /** + * @param class-string $class + */ + #[DataProvider('nonPositiveTimeouts')] + public function testRejectedTimeoutKeepsTheOneSetBefore(string $class, int $milliseconds, Event $event): void + { + $statements = []; + $pdo = $this->createStub(\PDO::class); + $pdo->method('exec')->willReturnCallback(function (string $statement) use (&$statements): int { + $statements[] = $statement; + + return 0; + }); + + $adapter = new $class($pdo); + $adapter->setTimeout(1000); + $adapter->setTimeout(400, Event::DocumentFind); + $applied = $statements; + + try { + $adapter->setTimeout($milliseconds, $event); + $this->fail('A timeout that is not positive must be rejected'); + } catch (DatabaseException $error) { + $this->assertSame('Timeout must be greater than 0', $error->getMessage()); + } + + $this->assertSame($applied, $statements); + $this->assertSame(1000, $adapter->getTimeout()); + $this->assertSame(400, $adapter->getTimeout(Event::DocumentFind)); + } +} diff --git a/tests/unit/Adapter/MariaDBUpdateDocumentsBindingTest.php b/tests/unit/Adapter/MariaDBUpdateDocumentsBindingTest.php new file mode 100644 index 0000000000..c2ca299580 --- /dev/null +++ b/tests/unit/Adapter/MariaDBUpdateDocumentsBindingTest.php @@ -0,0 +1,48 @@ + */ + private array $bound = []; + + public function testABulkUpdateBindsBooleansAsIntegersAndArraysAsJson(): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('rowCount')->willReturn(1); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bound[] = $value; + + return true; + }); + + return $statement; + }); + + $adapter = new MariaDB($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + $this->assertSame(1, $adapter->updateDocuments( + new Document(['$id' => 'items', 'attributes' => []]), + new Document(['active' => true, 'archived' => false, 'tags' => ['a', 'b']]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + )); + + $this->assertContains(1, $this->bound); + $this->assertContains(0, $this->bound); + $this->assertContains('["a","b"]', $this->bound); + $this->assertNotContains(true, $this->bound); + $this->assertNotContains(false, $this->bound); + } +} diff --git a/tests/unit/Adapter/MariaDBUpsertNewDocumentTest.php b/tests/unit/Adapter/MariaDBUpsertNewDocumentTest.php new file mode 100644 index 0000000000..705ce2d405 --- /dev/null +++ b/tests/unit/Adapter/MariaDBUpsertNewDocumentTest.php @@ -0,0 +1,68 @@ + */ + private array $bound = []; + + /** + * @return iterable + */ + public static function unsignedOperators(): iterable + { + yield 'increment beyond the native integer' => [Operator::increment(3), '18446744073709551613']; + yield 'increment past the limit keeps the default' => [Operator::increment(10, '18446744073709551615'), self::UNSIGNED_DEFAULT]; + yield 'decrement back into the native integer' => [Operator::decrement('18446744073709551600'), 10]; + } + + #[DataProvider('unsignedOperators')] + public function testAnUnsignedOperatorOnANewDocumentIsComputedExactly(Operator $operator, int|string $expected): void + { + $collection = new Document([ + '$id' => 'counters', + 'attributes' => [new Document(['$id' => 'counter', 'type' => 'bigint', 'signed' => false, 'default' => self::UNSIGNED_DEFAULT])], + ]); + + $this->adapter()->upsertDocuments($collection, [ + new Change(new Document(), new Document(['$id' => 'created', '$permissions' => [], 'counter' => $operator])), + ]); + + $this->assertContains($expected, $this->bound); + } + + private function adapter(): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('closeCursor')->willReturn(true); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bound[] = $value; + + return true; + }); + + return $statement; + }); + + $adapter = new MariaDB($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MariaDBVarcharColumnTest.php b/tests/unit/Adapter/MariaDBVarcharColumnTest.php new file mode 100644 index 0000000000..204ab03972 --- /dev/null +++ b/tests/unit/Adapter/MariaDBVarcharColumnTest.php @@ -0,0 +1,93 @@ + */ + private array $statements = []; + + /** + * @return iterable, int}> + */ + public static function validSizes(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class] as $engine => $class) { + yield $engine . ' smallest' => [$class, 1]; + yield $engine . ' typical' => [$class, 64]; + yield $engine . ' largest' => [$class, 16381]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('validSizes')] + public function testAVarcharAttributeIsAVarcharColumnOfItsSize(string $class, int $size): void + { + $adapter = $this->adapter($class); + + $this->assertSame('VARCHAR(' . $size . ')', $adapter->getColumnType(Attribute::varchar('code', size: $size))); + + $adapter->createCollection('codes', [Attribute::varchar('code', size: $size)]); + $this->assertStringContainsString('`code` VARCHAR(' . $size . ')', $this->statements[0] ?? ''); + } + + /** + * @return iterable, int, string}> + */ + public static function invalidSizes(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class] as $engine => $class) { + yield $engine . ' zero' => [$class, 0, 'VARCHAR size 0 is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + yield $engine . ' negative' => [$class, -5, 'VARCHAR size -5 is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + yield $engine . ' above the maximum' => [$class, 16382, 'VARCHAR size 16382 exceeds maximum varchar length 16381. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('invalidSizes')] + public function testAVarcharCollectionColumnOutsideItsSizesIsRefused(string $class, int $size, string $message): void + { + try { + $this->adapter($class)->createCollection('codes', [Attribute::varchar('code', size: $size)]); + $this->fail('A varchar column outside its sizes must be refused'); + } catch (DatabaseException $error) { + $this->assertSame($message, $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + /** + * @param class-string $class + */ + private function adapter(string $class): MariaDB + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/MemoryAdapterTest.php b/tests/unit/Adapter/MemoryAdapterTest.php new file mode 100644 index 0000000000..bb56503780 --- /dev/null +++ b/tests/unit/Adapter/MemoryAdapterTest.php @@ -0,0 +1,320 @@ +authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + } + + public function testDeleteWithAnotherCasingRemovesTheGrants(): void + { + $database = $this->memory(); + $this->createNotes($database); + $database->createDocument(self::COLLECTION, $this->note([self::ALICE])); + + $this->assertTrue($database->deleteDocument(self::COLLECTION, \strtoupper(self::DOCUMENT))); + $database->createDocument(self::COLLECTION, $this->note([self::BOB])); + + $this->assertSame([], $this->readableBy($database, self::ALICE), 'A delete under another casing must remove the deleted document\'s grants'); + $this->assertSame([self::DOCUMENT], $this->readableBy($database, self::BOB)); + } + + public function testRevokeUnderOneTenantKeepsAnotherTenantsGrants(): void + { + $database = $this->sharedNotes(); + + $database->withTenant(self::TENANT, fn (): Document => $database->updateDocument(self::COLLECTION, self::DOCUMENT, $this->readers([]))); + + $this->assertSame([], $this->readableUnder($database, self::TENANT, self::ALICE)); + $this->assertSame([self::DOCUMENT], $this->readableUnder($database, self::OTHER_TENANT, self::ALICE), 'A revoke under one tenant must keep another tenant\'s grants'); + $this->assertSame([self::DOCUMENT], $this->readableUnder($database, self::OTHER_TENANT, self::BOB)); + + $database->withTenant(self::OTHER_TENANT, fn (): Document => $database->updateDocument(self::COLLECTION, self::DOCUMENT, $this->readers([self::ALICE]))); + + $this->assertSame([self::DOCUMENT], $this->readableUnder($database, self::OTHER_TENANT, self::ALICE)); + $this->assertSame([], $this->readableUnder($database, self::OTHER_TENANT, self::BOB), 'The second tenant\'s own revoke must remove its grant'); + $this->assertSame([], $this->readableUnder($database, self::TENANT, self::BOB)); + } + + public function testDeleteUnderOneTenantKeepsAnotherTenantsGrants(): void + { + $database = $this->sharedNotes(); + + $database->withTenant(self::TENANT, fn (): bool => $database->deleteDocument(self::COLLECTION, self::DOCUMENT)); + + $this->assertSame([self::DOCUMENT], $this->readableUnder($database, self::OTHER_TENANT, self::ALICE), 'A delete under one tenant must keep another tenant\'s grants'); + + $database->withTenant(self::OTHER_TENANT, fn (): bool => $database->deleteDocument(self::COLLECTION, self::DOCUMENT)); + $database->withTenant(self::OTHER_TENANT, fn (): Document => $database->createDocument(self::COLLECTION, $this->note([self::BOB]))); + + $this->assertSame([], $this->readableUnder($database, self::OTHER_TENANT, self::ALICE), 'The second tenant\'s own delete must remove its grants'); + $this->assertSame([self::DOCUMENT], $this->readableUnder($database, self::OTHER_TENANT, self::BOB)); + $this->assertSame([], $this->readableUnder($database, self::TENANT, self::BOB)); + } + + public function testRenamingKeepsItsUniqueValue(): void + { + $database = $this->memory(); + $database->createCollection(Collection::create( + id: 'users', + attributes: [Attribute::string(key: 'email', size: 128)], + permissions: $this->everyone(), + documentSecurity: false, + )); + $database->createIndex('users', Index::unique(key: 'emailUnique', attributes: ['email'], lengths: [128])); + $database->createDocument('users', new Document(['$id' => 'old', 'email' => 'a@example.test'])); + $database->createDocument('users', new Document(['$id' => 'other', 'email' => 'b@example.test'])); + + $renamed = $database->updateDocument('users', 'old', new Document(['$id' => 'new', 'email' => 'a@example.test'])); + + $this->assertSame('new', $renamed->getId()); + $this->assertTrue($database->getDocument('users', 'old')->isEmpty()); + $this->assertSame('a@example.test', $database->getDocument('users', 'new')->getAttribute('email')); + + try { + $database->createDocument('users', new Document(['$id' => 'copy', 'email' => 'a@example.test'])); + $this->fail('The renamed document must still hold its unique value'); + } catch (UniqueException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + } + + $database->createDocument('users', new Document(['$id' => 'reuse', 'email' => 'c@example.test'])); + $database->updateDocument('users', 'reuse', new Document(['$id' => 'reused', 'email' => 'c@example.test'])); + $this->assertSame(['a@example.test', 'b@example.test', 'c@example.test'], $this->emails($database)); + } + + public function testSharedTablesListTenantlessCollections(): void + { + $listings = []; + foreach (['memory' => new Memory(), 'sqlite' => new SQLite(new PDO('sqlite::memory:'))] as $name => $adapter) { + $database = $this->database($adapter) + ->setSharedTables(true) + ->setTenant(null); + $database->create(); + $database->addHook(new Permissions()); + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: 'shared', attributes: [Attribute::string(key: 'name', size: 8)], permissions: $permissions)); + $database->setTenant(self::TENANT); + $database->createCollection(Collection::create(id: 'owned', attributes: [Attribute::string(key: 'name', size: 8)], permissions: $permissions)); + + $identifiers = \array_map(static fn (Document $collection): string => $collection->getId(), $database->listCollections()); + \sort($identifiers); + $listings[$name] = $identifiers; + } + + $this->assertSame(['owned', 'shared'], $listings['sqlite']); + $this->assertSame($listings['sqlite'], $listings['memory'], 'Memory must list the collections created without a tenant, as SQL does'); + } + + public function testNoChangeUpdateChecksOnlyTheReadPermission(): void + { + $database = $this->memory(); + $database->createCollection(Collection::create(id: 'documents')); + $database->createAttribute('documents', Attribute::string(key: 'string', size: 128, required: true)); + $database->createAttribute('documents', Attribute::integer(key: 'integer_signed', required: true)); + $database->createAttribute('documents', Attribute::integer(key: 'integer_unsigned', required: true, signed: false)); + $database->createAttribute('documents', Attribute::integer(key: 'bigint_signed', width: IntegerWidth::Bits64, required: true)); + $database->createAttribute('documents', Attribute::integer(key: 'bigint_unsigned', width: IntegerWidth::Bits64, required: true, signed: false)); + $database->createAttribute('documents', Attribute::double(key: 'float_signed', required: true)); + $database->createAttribute('documents', Attribute::double(key: 'float_unsigned', required: true, signed: false)); + $database->createAttribute('documents', Attribute::boolean(key: 'boolean', required: true)); + $database->createAttribute('documents', Attribute::string(key: 'colors', size: 32, required: true, array: true)); + + $readable = $database->createDocument('documents', $this->typedValues('readable', [Permission::read(Role::any())])); + $updated = $database->updateDocument('documents', $readable->getId(), $readable); + + $this->assertSame($readable->getUpdatedAt(), $updated->getUpdatedAt(), 'A no-change update must pass on read permission alone and leave the document untouched'); + + $hidden = $database->createDocument('documents', $this->typedValues('hidden', [])); + try { + $database->updateDocument('documents', $hidden->getId(), $hidden); + $this->fail('A no-change update without read permission must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame('No permissions provided for action \'read\'', $exception->getMessage()); + } + } + + private function database(Adapter $adapter): Database + { + return (new Database($adapter, new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('memory_adapter') + ->setNamespace('memory_adapter_'.\uniqid()); + } + + private function memory(): Database + { + $database = $this->database(new Memory()); + $database->create(); + + return $database; + } + + private function sharedNotes(): Database + { + $database = $this->database(new Memory()) + ->setSharedTables(true) + ->setTenant(null); + $database->create(); + $this->createNotes($database); + + foreach ([self::TENANT, self::OTHER_TENANT] as $tenant) { + $database->withTenant($tenant, fn (): Document => $database->createDocument(self::COLLECTION, $this->note([self::ALICE, self::BOB]))); + } + + return $database; + } + + private function createNotes(Database $database): void + { + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: true, + )); + } + + /** + * @return list + */ + private function everyone(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @param list $permissions + */ + private function typedValues(string $id, array $permissions): Document + { + return new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'string' => 'text📝', + 'integer_signed' => -Database::MAX_INT, + 'integer_unsigned' => Database::MAX_INT, + 'bigint_signed' => -Database::MAX_BIG_INT, + 'bigint_unsigned' => Database::MAX_BIG_INT, + 'float_signed' => -123456789.12346, + 'float_unsigned' => 123456789.12346, + 'boolean' => true, + 'colors' => ['pink', 'green', 'blue'], + ]); + } + + /** + * @param list $readers + */ + private function note(array $readers): Document + { + return $this->readers($readers) + ->setAttribute('$id', self::DOCUMENT) + ->setAttribute('title', 'first'); + } + + /** + * @param list $readers + */ + private function readers(array $readers): Document + { + return new Document([ + '$permissions' => \array_map( + static fn (string $reader): string => Permission::read(Role::user($reader)), + $readers, + ), + ]); + } + + /** + * @return list + */ + private function emails(Database $database): array + { + $emails = \array_map( + static fn (Document $document): string => \is_string($email = $document->getAttribute('email')) ? $email : '', + $database->find('users'), + ); + \sort($emails); + + return $emails; + } + + /** + * @return list + */ + private function readableUnder(Database $database, int $tenant, string $reader): array + { + return $database->withTenant($tenant, fn (): array => $this->readableBy($database, $reader)); + } + + /** + * @return list + */ + private function readableBy(Database $database, string $reader): array + { + $roles = $this->authorization->getRoles(); + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::user($reader)->toString()); + + try { + return \array_values(\array_map( + static fn (Document $document): string => $document->getId(), + $database->find(self::COLLECTION), + )); + } finally { + $this->authorization->cleanRoles(); + foreach ($roles as $role) { + $this->authorization->addRole($role); + } + } + } +} diff --git a/tests/unit/Adapter/MemorySchemaPathsTest.php b/tests/unit/Adapter/MemorySchemaPathsTest.php new file mode 100644 index 0000000000..00266c62ec --- /dev/null +++ b/tests/unit/Adapter/MemorySchemaPathsTest.php @@ -0,0 +1,180 @@ +adapter(); + + $adapter->startTransaction(); + $this->assertTrue($adapter->createCollection('rolled')); + $this->assertTrue($adapter->collectionExists(self::DATABASE, 'rolled')); + $adapter->rollbackTransaction(); + + $this->assertFalse($adapter->collectionExists(self::DATABASE, 'rolled'), 'A rolled-back collection must leave its database'); + $this->assertTrue($adapter->createCollection('rolled')); + } + + public function testDeleteAttributeKeepsTheOrdersOfTheRemainingIndexAttributes(): void + { + [$adapter, $indexOf] = $this->inspectableAdapter(); + $this->createPairs($adapter); + $adapter->createIndex(self::COLLECTION, Index::key(key: 'by_all', attributes: ['a', 'b', 'c'], orders: [OrderDirection::Asc, OrderDirection::Desc, OrderDirection::Asc])); + + $this->assertTrue($adapter->deleteAttribute(self::COLLECTION, 'a')); + + $index = $indexOf(self::COLLECTION, 'by_all'); + $this->assertSame(['b', 'c'], $index['attributes'] ?? null); + $this->assertSame([OrderDirection::Desc->value, OrderDirection::Asc->value], \array_map( + static fn (mixed $order): mixed => $order instanceof OrderDirection ? $order->value : $order, + \is_array($index['orders'] ?? null) ? $index['orders'] : [], + )); + } + + public function testRollbackOfDeleteAttributeRestoresValuesAndIndexes(): void + { + [$adapter, $indexOf] = $this->inspectableAdapter(); + $this->createPairs($adapter); + $adapter->createIndex(self::COLLECTION, Index::unique(key: 'unique_pair', attributes: ['a', 'b'])); + $adapter->createDocument($this->collection(), $this->pair('first', 'x', 'y')); + + $adapter->startTransaction(); + $adapter->deleteAttribute(self::COLLECTION, 'a'); + $this->assertNull($adapter->getDocument($this->collection(), 'first')->getAttribute('a')); + $adapter->rollbackTransaction(); + + $this->assertSame('x', $adapter->getDocument($this->collection(), 'first')->getAttribute('a')); + $this->assertSame(['a', 'b'], $indexOf(self::COLLECTION, 'unique_pair')['attributes'] ?? null); + + $this->expectException(DuplicateException::class); + $adapter->createDocument($this->collection(), $this->pair('second', 'x', 'y')); + } + + public function testRollbackOfRenameAttributeRestoresTheOldName(): void + { + [$adapter, $indexOf] = $this->inspectableAdapter(); + $this->createPairs($adapter); + $adapter->createIndex(self::COLLECTION, Index::key(key: 'by_a', attributes: ['a'])); + $adapter->createDocument($this->collection(), $this->pair('first', 'x', 'y')); + + $adapter->startTransaction(); + $this->assertTrue($adapter->renameAttribute(self::COLLECTION, 'a', 'renamed')); + $this->assertSame(['renamed'], $indexOf(self::COLLECTION, 'by_a')['attributes'] ?? null); + $adapter->rollbackTransaction(); + + $stored = $adapter->getDocument($this->collection(), 'first'); + $this->assertSame('x', $stored->getAttribute('a')); + $this->assertNull($stored->getAttribute('renamed')); + $this->assertSame(['a'], $indexOf(self::COLLECTION, 'by_a')['attributes'] ?? null); + $this->assertSame(['first'], $this->idsOf($adapter->find($this->collection(), [Query::equal('a', ['x'])]))); + } + + public function testUnvalidatedQueriesFollowSqlNullAndMethodRules(): void + { + $adapter = $this->adapter(); + $this->createPairs($adapter); + $adapter->createDocument($this->collection(), $this->pair('first', 'x', 'y')); + $adapter->createDocument($this->collection(), $this->pair('second', 'z', 'y')); + + $this->assertSame([], $adapter->find($this->collection(), [new Query(Method::NotEqual, 'a', [null, 'x'])]), 'A null candidate makes NOT IN unknown for every row'); + $this->assertSame([], $adapter->find($this->collection(), [new Query(Method::Regex, 'a', [5])]), 'A non-string pattern matches nothing'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query method not implemented in the Memory adapter: exists'); + $adapter->find($this->collection(), [Query::exists(['a'])]); + } + + public function testUnorderedReadReturnsRowsInSequenceOrder(): void + { + $adapter = $this->adapter(); + $this->createPairs($adapter); + foreach (['first', 'second', 'third'] as $id) { + $adapter->createDocument($this->collection(), $this->pair($id, $id, 'y')); + } + $adapter->updateDocument($this->collection(), 'first', new Document(['$id' => 'renamed']), true); + + $this->assertSame(['renamed', 'second', 'third'], $this->idsOf($adapter->find($this->collection()))); + $this->assertSame(['second'], $this->idsOf($adapter->find($this->collection(), limit: 1, offset: 1))); + } + + private function adapter(): Memory + { + return $this->prepare(new Memory()); + } + + /** + * @return array{Memory, Closure(string, string): array} + */ + private function inspectableAdapter(): array + { + $adapter = new class () extends Memory { + /** + * @return array + */ + public function indexOf(string $collection, string $index): array + { + return $this->data[$this->key($collection)]['indexes'][$index] ?? []; + } + }; + + return [$this->prepare($adapter), $adapter->indexOf(...)]; + } + + private function prepare(Memory $adapter): Memory + { + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $adapter->setDatabase(self::DATABASE); + $adapter->setNamespace('schema_paths_'.\uniqid()); + $adapter->create(self::DATABASE); + + return $adapter; + } + + private function createPairs(Memory $adapter): void + { + $adapter->createCollection(self::COLLECTION); + foreach (['a', 'b', 'c'] as $attribute) { + $adapter->createAttribute(self::COLLECTION, Attribute::string(key: $attribute, size: 32)); + } + } + + private function pair(string $id, string $a, string $b): Document + { + return new Document(['$id' => $id, '$permissions' => [], 'a' => $a, 'b' => $b]); + } + + private function collection(): Document + { + return new Document(['$id' => self::COLLECTION]); + } + + /** + * @param array $documents + * @return list + */ + private function idsOf(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): string => $document->getId(), $documents)); + } +} diff --git a/tests/unit/Adapter/MemoryWritePathsTest.php b/tests/unit/Adapter/MemoryWritePathsTest.php new file mode 100644 index 0000000000..503c7f4eee --- /dev/null +++ b/tests/unit/Adapter/MemoryWritePathsTest.php @@ -0,0 +1,334 @@ +sharedAdapter(); + foreach ([1, 2] as $tenant) { + $adapter->setTenant($tenant); + $this->storeAddress($adapter, 'home', 'x'); + } + + $adapter->setTenant(1); + $this->assertTrue($adapter->createIndex(self::COLLECTION, Index::unique(key: 'unique_addr', attributes: ['addr'])), 'Two tenants holding the same value must not block a unique index'); + + $this->assertDuplicate(fn () => $this->storeAddress($adapter, 'second', 'x'), 'A same-tenant duplicate must be rejected once the index exists'); + + $adapter->setTenant(2); + $this->assertSame('x', $adapter->getDocument($this->collection(), 'home')->getAttribute('addr')); + } + + public function testSharedTablesUniqueIndexRejectsDuplicatesWithinOneTenant(): void + { + $adapter = $this->sharedAdapter(); + $adapter->setTenant(1); + $this->storeAddress($adapter, 'first', 'x'); + $this->storeAddress($adapter, 'second', 'x'); + + $this->expectException(UniqueException::class); + $this->expectExceptionMessage(UniqueException::MESSAGE); + $adapter->createIndex(self::COLLECTION, Index::unique(key: 'unique_addr', attributes: ['addr'])); + } + + public function testRolledBackUniqueIndexLeavesNoHashTable(): void + { + $adapter = new class () extends Memory { + /** + * @return array + */ + public function uniqueHashesOf(string $collection): array + { + return $this->uniqueIndexHashes[$this->key($collection)] ?? []; + } + }; + $adapter->setNamespace('unique_rollback_'.\uniqid()); + $this->createAddresses($adapter); + $this->storeAddress($adapter, 'home', 'x'); + + $adapter->startTransaction(); + $adapter->createIndex(self::COLLECTION, Index::unique(key: 'unique_addr', attributes: ['addr'])); + $this->assertArrayHasKey('unique_addr', $adapter->uniqueHashesOf(self::COLLECTION)); + $adapter->rollbackTransaction(); + + $this->assertSame([], $adapter->uniqueHashesOf(self::COLLECTION)); + } + + public function testSharedTablesUniqueBindingsFollowTheirTenantOnUpdateAndDelete(): void + { + $adapter = $this->sharedAdapter(); + $adapter->setTenant(1); + $adapter->createIndex(self::COLLECTION, Index::unique(key: 'unique_addr', attributes: ['addr'])); + foreach ([1, 2] as $tenant) { + $adapter->setTenant($tenant); + $this->storeAddress($adapter, 'home', 'x'); + } + + $adapter->setTenant(2); + $adapter->updateDocument($this->collection(), 'home', new Document(['$id' => 'home', 'addr' => 'x', 'label' => 'kept']), true); + $this->assertTrue($adapter->deleteDocument(new Document(['$id' => self::COLLECTION]), 'home')); + + $adapter->setTenant(1); + $this->assertDuplicate(fn () => $this->storeAddress($adapter, 'second', 'x'), 'The first tenant\'s binding must survive the second tenant\'s update and delete'); + + $adapter->setTenant(2); + $this->storeAddress($adapter, 'again', 'x'); + $this->assertSame('x', $adapter->getDocument($this->collection(), 'again')->getAttribute('addr')); + } + + public function testRollbackRestoresARenamedDocument(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'a', 'addr' => 'x', 'label' => 'original'])); + + $rethrown = false; + try { + $database->withTransaction(function () use ($database): void { + $database->updateDocument(self::COLLECTION, 'a', new Document(['$id' => 'b', 'label' => 'renamed'])); + throw new \RuntimeException('roll back'); + }); + } catch (\RuntimeException $exception) { + $rethrown = $exception->getMessage() === 'roll back'; + } + $this->assertTrue($rethrown, 'The transaction must rethrow'); + + $this->assertSame('original', $database->getDocument(self::COLLECTION, 'a')->getAttribute('label')); + $this->assertTrue($database->getDocument(self::COLLECTION, 'b')->isEmpty()); + $this->assertSame(['a'], \array_map(static fn (Document $document): string => $document->getId(), $database->find(self::COLLECTION))); + } + + public function testRollbackUndoesAnIncrement(): void + { + $adapter = $this->adapter(); + $this->storeAddress($adapter, 'home', 'x', 1); + $before = $adapter->getDocument($this->collection(), 'home'); + + $adapter->startTransaction(); + $this->assertTrue($adapter->increaseDocumentAttribute(new Document(['$id' => self::COLLECTION]), 'home', 'visits', 5, self::UPDATED_AT)); + $this->assertSame(6, $adapter->getDocument($this->collection(), 'home')->getAttribute('visits')); + $adapter->rollbackTransaction(); + + $after = $adapter->getDocument($this->collection(), 'home'); + $this->assertSame(1, $after->getAttribute('visits')); + $this->assertSame($before->getUpdatedAt(), $after->getUpdatedAt()); + } + + public function testRollbackOfAnIncrementRemovesTheValueAndTimestampItAdded(): void + { + $adapter = $this->adapter(); + $adapter->createDocument($this->collection(), new Document(['$id' => 'home', '$permissions' => [], 'addr' => 'x'])); + $before = $adapter->getDocument($this->collection(), 'home'); + $this->assertNull($before->getAttribute('visits')); + $this->assertNull($before->getUpdatedAt()); + + $adapter->startTransaction(); + $this->assertTrue($adapter->increaseDocumentAttribute(new Document(['$id' => self::COLLECTION]), 'home', 'visits', 5, self::UPDATED_AT)); + $this->assertSame(5, $adapter->getDocument($this->collection(), 'home')->getAttribute('visits')); + $adapter->rollbackTransaction(); + + $after = $adapter->getDocument($this->collection(), 'home'); + $this->assertNull($after->getAttribute('visits')); + $this->assertNull($after->getUpdatedAt()); + } + + public function testADivisionOrModuloByZeroThatSkippedValidationKeepsTheValue(): void + { + $adapter = $this->adapter(); + $this->storeAddress($adapter, 'home', 'x', 10); + + foreach (['divide', 'modulo'] as $method) { + $operator = Operator::parse('{"method":"'.$method.'","attribute":"visits","values":[0]}'); + $adapter->updateDocument($this->collection(), 'home', new Document(['$id' => 'home', 'visits' => $operator]), true); + + $this->assertSame(10, $adapter->getDocument($this->collection(), 'home')->getAttribute('visits'), $method); + } + } + + public function testACursorWithoutAnOrderPagesBySequence(): void + { + $authorization = new Authorization(); + $authorization->disable(); + $adapter = $this->adapter(); + $adapter->setAuthorization($authorization); + foreach (['first', 'second', 'third'] as $id) { + $this->storeAddress($adapter, $id, 'x'); + } + $cursor = ['$sequence' => $adapter->getDocument($this->collection(), 'second')->getSequence()]; + $ids = static function (array $documents): array { + /** @var array $documents */ + return \array_map(static fn (Document $document): string => $document->getId(), $documents); + }; + + $this->assertSame(['third'], $ids($adapter->find($this->collection(), cursor: $cursor))); + $this->assertSame(['first'], $ids($adapter->find($this->collection(), cursor: $cursor, cursorDirection: CursorDirection::Before))); + } + + public function testIncrementIsANoOpWhenTheStoredValueAlreadyViolatesTheBound(): void + { + $adapter = $this->adapter(); + $this->storeAddress($adapter, 'whole', 'x', 10); + $this->storeAddress($adapter, 'fraction', 'y', 10.5); + + foreach (['whole' => 10, 'fraction' => 10.5] as $id => $stored) { + $this->assertTrue($adapter->increaseDocumentAttribute(new Document(['$id' => self::COLLECTION]), $id, 'visits', 1, self::UPDATED_AT, max: 5)); + $this->assertTrue($adapter->increaseDocumentAttribute(new Document(['$id' => self::COLLECTION]), $id, 'visits', -1, self::UPDATED_AT, min: 20)); + $this->assertSame($stored, $adapter->getDocument($this->collection(), $id)->getAttribute('visits'), $id); + } + } + + public function testBatchMixingDocumentsWithAndWithoutASequenceIsRejected(): void + { + foreach ([['10', null], [null, '10']] as [$first, $second]) { + $adapter = $this->adapter(); + $documents = [ + $this->address('first', 'x', sequence: $first), + $this->address('second', 'y', sequence: $second), + ]; + + try { + $adapter->createDocuments($this->collection(), $documents); + $this->fail('A batch mixing set and unset sequences must be rejected'); + } catch (DatabaseException $exception) { + $this->assertSame('All documents must have an sequence if one is set', $exception->getMessage()); + } + + $this->assertSame([], $adapter->find($this->collection())); + } + } + + public function testNullChecksAndUnsupportedMethodsOnAWholeObjectAttribute(): void + { + $database = $this->database(); + $database->createAttribute(self::COLLECTION, Attribute::object(key: 'meta')); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'filled', 'addr' => 'x', 'meta' => ['colour' => 'red']])); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'empty', 'addr' => 'y', 'meta' => null])); + + $idsOf = static function (array $documents): array { + /** @var array $documents */ + return \array_map(static fn (Document $document): string => $document->getId(), $documents); + }; + + $this->assertSame(['empty'], $idsOf($database->find(self::COLLECTION, [Query::isNull('meta')]))); + $this->assertSame(['filled'], $idsOf($database->find(self::COLLECTION, [Query::isNotNull('meta')]))); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query method lessThan not supported for object attributes'); + $database->skipValidation(fn (): array => $database->find(self::COLLECTION, [Query::lessThan('meta', 'x')])); + } + + private function assertDuplicate(\Closure $write, string $message): void + { + try { + $write(); + } catch (DuplicateException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + + return; + } + + $this->fail($message); + } + + private function adapter(): Memory + { + $adapter = new Memory(); + $adapter->setNamespace('write_paths_'.\uniqid()); + $this->createAddresses($adapter); + + return $adapter; + } + + private function sharedAdapter(): Memory + { + $adapter = new Memory(); + $adapter->setNamespace('write_paths_shared_'.\uniqid()); + $adapter->setSharedTables(true); + $adapter->setTenant(1); + $this->createAddresses($adapter); + + return $adapter; + } + + private function createAddresses(Memory $adapter): void + { + $adapter->createCollection(self::COLLECTION); + $adapter->createAttribute(self::COLLECTION, Attribute::string(key: 'addr', size: 128, required: true)); + $adapter->createAttribute(self::COLLECTION, Attribute::string(key: 'label', size: 32)); + $adapter->createAttribute(self::COLLECTION, Attribute::double(key: 'visits')); + } + + private function storeAddress(Memory $adapter, string $id, string $addr, int|float|null $visits = null): void + { + $adapter->createDocument($this->collection(), $this->address($id, $addr, $visits)); + } + + private function address(string $id, string $addr, int|float|null $visits = null, ?string $sequence = null): Document + { + $document = new Document([ + '$id' => $id, + '$permissions' => [], + '$updatedAt' => '2025-01-01 00:00:00.000', + 'addr' => $addr, + 'visits' => $visits, + ]); + if ($sequence !== null) { + $document->setAttribute('$sequence', $sequence); + } + + return $document; + } + + private function collection(): Document + { + return new Document(['$id' => self::COLLECTION]); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('write_paths') + ->setNamespace('write_paths_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'addr', size: 128, required: true), + Attribute::string(key: 'label', size: 32), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + + return $database; + } +} diff --git a/tests/unit/Adapter/MySQLVisibilityTest.php b/tests/unit/Adapter/MySQLVisibilityTest.php new file mode 100644 index 0000000000..9ea7458d3e --- /dev/null +++ b/tests/unit/Adapter/MySQLVisibilityTest.php @@ -0,0 +1,29 @@ + + */ + public static function methods(): iterable + { + yield 'the capability list is public' => ['capabilities', true]; + yield 'the spatial column type is not public' => ['getSpatialSqlType', false]; + } + + #[DataProvider('methods')] + public function testOnlyThePublicSurfaceIsCallable(string $method, bool $public): void + { + $mysql = new MySQL(new stdClass()); + + $this->assertTrue(\method_exists($mysql, $method), "MySQL no longer declares {$method}, so its visibility is not under test"); + $this->assertSame($public, \is_callable([$mysql, $method])); + } +} diff --git a/tests/unit/Adapter/OperatorLimitExactnessTest.php b/tests/unit/Adapter/OperatorLimitExactnessTest.php new file mode 100644 index 0000000000..f06a11550e --- /dev/null +++ b/tests/unit/Adapter/OperatorLimitExactnessTest.php @@ -0,0 +1,117 @@ + */ + private array $bound = []; + + /** + * @return iterable + */ + public static function limits(): iterable + { + yield 'increment past a whole float maximum' => ['high', Operator::increment(20, 9.0e18), 8999999999999999990]; + yield 'increment up to a whole float maximum' => ['high', Operator::increment(10, 9.0e18), 9000000000000000000]; + yield 'decrement past a whole float minimum' => ['low', Operator::decrement(20, -9.0e18), -8999999999999999990]; + yield 'decrement down to a whole float minimum' => ['low', Operator::decrement(10, -9.0e18), -9000000000000000000]; + yield 'multiply past a whole float maximum' => ['high', Operator::multiply(2, 9.0e18), 8999999999999999990]; + yield 'fractional maximum of a float' => ['ratio', Operator::increment(1, 2.25), 1.5]; + yield 'fractional maximum of a float not reached' => ['ratio', Operator::increment(0.5, 2.25), 2.0]; + } + + #[DataProvider('limits')] + public function testAWholeNumberFloatLimitIsComparedExactly(string $attribute, Operator $operator, int|float $expected): void + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $database->setDatabase('limits')->setNamespace('limits')->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create( + id: 'counters', + attributes: [ + Attribute::bigInteger('high', default: 8999999999999999990), + Attribute::bigInteger('low', default: -8999999999999999990), + Attribute::double('ratio', default: 1.5), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $database->createDocument('counters', new Document(['$id' => 'existing'])); + + $database->updateDocument('counters', 'existing', new Document([$attribute => clone $operator])); + $database->upsertDocument('counters', new Document(['$id' => 'created', $attribute => clone $operator])); + + $this->assertSame($expected, $database->getDocument('counters', 'existing')->getAttribute($attribute), 'existing document'); + $this->assertSame($expected, $database->getDocument('counters', 'created')->getAttribute($attribute), 'new document'); + } + + /** + * @return iterable, Operator}> + */ + public static function statements(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class, 'Postgres' => Postgres::class] as $engine => $class) { + yield $engine . ' increment' => [$class, Operator::increment(1, 9.0e18)]; + yield $engine . ' multiply' => [$class, Operator::multiply(2, 9.0e18)]; + yield $engine . ' power' => [$class, Operator::power(2, 9.0e18)]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('statements')] + public function testAWholeNumberFloatLimitIsBoundAsAnInteger(string $class, Operator $operator): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('rowCount')->willReturn(1); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bound[] = $value; + + return true; + }); + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + $adapter->updateDocuments( + new Document(['$id' => 'counters', 'attributes' => []]), + new Document(['high' => $operator]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + ); + + $this->assertContains(9000000000000000000, $this->bound); + foreach ($this->bound as $value) { + $this->assertIsNotFloat($value); + $this->assertNotSame('9000000000000000000.00000000000000', $value); + } + } +} diff --git a/tests/unit/Adapter/PDOConfigureTest.php b/tests/unit/Adapter/PDOConfigureTest.php new file mode 100644 index 0000000000..5db8ef21d5 --- /dev/null +++ b/tests/unit/Adapter/PDOConfigureTest.php @@ -0,0 +1,54 @@ + \PDO::ERRMODE_SILENT]); + + try { + $pdo->configure('broken', 'NOT SQL'); + $this->fail('A session statement the engine refuses must not be accepted'); + } catch (PDOException $error) { + $this->assertSame('Failed to configure session: NOT SQL', $error->getMessage()); + } + + $pdo->reconnect(); + + $this->assertSame([['value' => 1]], $this->rows($pdo, 'SELECT 1 AS value')); + } + + public function testConfigureKeepsTheEarlierStatementWhenALaterOneIsRefused(): void + { + $pdo = new PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_SILENT]); + $pdo->configure('marker', 'CREATE TEMP TABLE marker AS SELECT 7 AS value'); + + try { + $pdo->configure('marker', 'NOT SQL'); + $this->fail('A session statement the engine refuses must not replace the earlier one'); + } catch (PDOException $error) { + $this->assertSame('Failed to configure session: NOT SQL', $error->getMessage()); + } + + $pdo->reconnect(); + + $this->assertSame([['value' => 7]], $this->rows($pdo, 'SELECT value FROM temp.marker')); + } + + /** + * @return array + */ + private function rows(PDO $pdo, string $query): array + { + $statement = $pdo->query($query); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + return $statement->fetchAll(\PDO::FETCH_ASSOC); + } +} diff --git a/tests/unit/Adapter/PDOStatementFetchModeTest.php b/tests/unit/Adapter/PDOStatementFetchModeTest.php new file mode 100644 index 0000000000..7abe755f0f --- /dev/null +++ b/tests/unit/Adapter/PDOStatementFetchModeTest.php @@ -0,0 +1,52 @@ +lostStatement(), self::QUERY); + $statement->setFetchMode(\PDO::FETCH_NUM); + + $this->assertTrue($statement->execute()); + $this->assertSame([7, 8], $statement->fetch()); + } + + public function testFetchModeArgumentsAreKeptAcrossAReconnect(): void + { + $statement = new PDOStatement(new PDO('sqlite::memory:', null, null), $this->lostStatement(), self::QUERY); + $statement->setFetchMode(\PDO::FETCH_COLUMN, 1); + + $this->assertTrue($statement->execute()); + $this->assertSame([8], $statement->fetchAll()); + } + + public function testWithoutAFetchModeTheReconnectedStatementUsesTheDefault(): void + { + $statement = new PDOStatement(new PDO('sqlite::memory:', null, null), $this->lostStatement(), self::QUERY); + + $this->assertTrue($statement->execute()); + $this->assertSame(['value' => 7, 0 => 7, 'other' => 8, 1 => 8], $statement->fetch()); + } + + private function lostStatement(): \PDOStatement + { + $lost = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $lost->method('setFetchMode')->willReturn(true); + $lost->expects($this->once()) + ->method('execute') + ->willThrowException(new PDOException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away')); + + return $lost; + } +} diff --git a/tests/unit/Adapter/PingRecordingMemory.php b/tests/unit/Adapter/PingRecordingMemory.php new file mode 100644 index 0000000000..28baefc76e --- /dev/null +++ b/tests/unit/Adapter/PingRecordingMemory.php @@ -0,0 +1,57 @@ + + */ + public array $pings = []; + + private ?Closure $pause = null; + + public function pauseNextPing(Closure $pause): void + { + $this->pause = $pause; + } + + #[\Override] + public function ping(): bool + { + $before = $this->getTenant(); + $pause = $this->pause; + $this->pause = null; + if ($pause !== null) { + $pause(); + } + + /** @var int $coroutine */ + $coroutine = Coroutine::getCid(); + $this->pings[] = ['coroutine' => $coroutine, 'before' => $before, 'after' => $this->getTenant()]; + + return true; + } + + #[\Override] + public function reconnect(): void + { + } + + #[\Override] + public function id(): string + { + return 'ping-recording'; + } + + #[\Override] + public function hostname(): string + { + return ''; + } +} diff --git a/tests/unit/Adapter/PoolAlterLockTest.php b/tests/unit/Adapter/PoolAlterLockTest.php new file mode 100644 index 0000000000..97ce14608f --- /dev/null +++ b/tests/unit/Adapter/PoolAlterLockTest.php @@ -0,0 +1,80 @@ + */ + private array $statements = []; + + public function testSetLocksReachesTheBorrowedAdapter(): void + { + [$database, $pool] = $this->database(); + + $database->setLocks(true); + $pool->createAttribute('posts', Attribute::string(key: 'title', size: 64)); + + $this->assertCount(1, $this->statements); + $this->assertStringStartsWith('ALTER TABLE', $this->statements[0]); + $this->assertStringEndsWith(',LOCK=SHARED', $this->statements[0]); + } + + public function testDisablingLocksReachesTheBorrowedAdapter(): void + { + [$database, $pool] = $this->database(); + + $database->setLocks(true); + $database->setLocks(false); + $pool->createAttribute('posts', Attribute::string(key: 'title', size: 64)); + + $this->assertCount(1, $this->statements); + $this->assertStringNotContainsString('LOCK=SHARED', $this->statements[0]); + } + + /** + * @return array{Database, Pool} + */ + private function database(): array + { + $statement = self::createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = self::createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $sql) use ($statement): PDOStatement { + $this->statements[] = $sql; + + return $statement; + }); + + $connection = new MariaDB($pdo); + + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($connection), + ); + + $pool = new Pool($connections); + $database = new Database($pool, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('locks') + ->setNamespace('locks'); + + return [$database, $pool]; + } +} diff --git a/tests/unit/Adapter/PoolCapabilityTest.php b/tests/unit/Adapter/PoolCapabilityTest.php new file mode 100644 index 0000000000..27bec7ccae --- /dev/null +++ b/tests/unit/Adapter/PoolCapabilityTest.php @@ -0,0 +1,338 @@ +connections(new Memory()); + $database = $this->database($connections); + $database->setValidation(false); + $database->getDocument('posts', 'first'); + + $this->checkouts = 0; + $document = $database->getDocument('posts', 'first'); + + $this->assertSame('first', $document->getAttribute('title')); + $this->assertSame(0, $this->checkouts); + } + + public function testACachedReadSucceedsWhileTheBackingIsDown(): void + { + $connections = $this->connections(new Memory()); + $database = $this->database($connections); + $database->setValidation(false); + $database->getDocument('posts', 'first'); + + $this->down = true; + + $this->assertSame('first', $database->getDocument('posts', 'first')->getAttribute('title')); + } + + public function testAWarmValidatedReadOverSchemaEnforcingConnectionsChecksOutNoConnection(): void + { + /** @var ArrayObject $asked */ + $asked = new ArrayObject(); + $memory = $this->askedMemory($asked); + $database = $this->database($this->connections($memory)); + $selection = [Query::select(['title'])]; + $database->getDocument('posts', 'first', $selection); + + $this->checkouts = 0; + $asked->exchangeArray([]); + $document = $database->getDocument('posts', 'first', $selection); + + $this->assertSame('first', $document->getAttribute('title')); + $this->assertSame([], $asked->getArrayCopy(), 'A connection without a schemaless mode always answers alike, so its answer is kept'); + $this->assertSame(0, $this->checkouts); + } + + public function testDefinedAttributesOfSchemaEnforcingConnectionsIsAskedOnce(): void + { + $pool = $this->pool($this->connections(new Memory())); + $this->assertTrue($pool->supports(Capability::DefinedAttributes)); + + $this->checkouts = 0; + $this->down = true; + + $this->assertTrue($pool->supports(Capability::DefinedAttributes)); + $this->assertSame(0, $this->checkouts); + } + + public function testAWarmValidatedReadWithoutQueriesChecksOutNoConnection(): void + { + /** @var ArrayObject $asked */ + $asked = new ArrayObject(); + $memory = $this->askedMemory($asked); + $database = $this->database($this->connections($memory)); + $database->getDocument('posts', 'first'); + + $this->checkouts = 0; + $asked->exchangeArray([]); + $database->getDocument('posts', 'first'); + + $this->assertSame([], $asked->getArrayCopy()); + $this->assertSame(0, $this->checkouts); + } + + /** + * @param ArrayObject $asked + */ + private function askedMemory(ArrayObject $asked): Memory + { + return new class ($asked) extends Memory { + /** + * @param ArrayObject $asked + */ + public function __construct(private readonly ArrayObject $asked) + { + parent::__construct(); + } + + #[\Override] + public function supports(Capability $feature): bool + { + $this->asked->append($feature->name); + + return parent::supports($feature); + } + }; + } + + public function testACapabilityQuestionOnAColdPoolChecksOutOnce(): void + { + $pool = $this->pool($this->connections(new Memory())); + + $this->assertTrue($pool->supports(Capability::Operators)); + $this->assertFalse($pool->supports(Capability::AlterLock)); + $this->assertSame((new Memory())->capabilities(), $pool->capabilities()); + $this->assertSame(1, $this->checkouts); + } + + public function testEveryHandleOverOnePoolSharesTheAnswers(): void + { + $connections = $this->connections(new Memory()); + $this->assertTrue($this->pool($connections)->supports(Capability::Operators)); + + $this->checkouts = 0; + $this->down = true; + + $handle = $this->pool($connections); + $this->assertTrue($handle->supports(Capability::Operators)); + $this->assertTrue($handle->supports(Capability::IndexFulltext)); + $this->assertSame(0, $this->checkouts); + } + + public function testFeaturesAreAskedOncePerFeature(): void + { + $pool = $this->pool($this->connections(new Memory())); + + $this->assertTrue($pool->hasFeature(Feature\Relationships::class)); + $this->assertFalse($pool->hasFeature(Feature\Spatial::class)); + $this->assertSame(2, $this->checkouts); + + $this->down = true; + + $this->assertTrue($pool->hasFeature(Feature\Relationships::class)); + $this->assertFalse($pool->hasFeature(Feature\Spatial::class)); + $this->assertSame(2, $this->checkouts); + } + + public function testDefinedAttributesAlwaysAsksTheConnection(): void + { + $mongo = new class () extends Mongo { + public function __construct() + { + } + }; + $pool = $this->pool($this->connections($mongo)); + $this->assertTrue($pool->hasFeature(Feature\Schemaless::class)); + $this->checkouts = 0; + + $mongo->setSchemaless(true); + $this->assertFalse($pool->supports(Capability::DefinedAttributes)); + + $mongo->setSchemaless(false); + $this->assertTrue($pool->supports(Capability::DefinedAttributes)); + + $this->assertSame(2, $this->checkouts); + $this->assertTrue($mongo->supports(Capability::DefinedAttributes), "A handle that never set the schema mode must leave the connection's own"); + } + + public function testDefinedAttributesIsAskedOncePerModeTheHandleSet(): void + { + $mongo = new class () extends Mongo { + public function __construct() + { + } + }; + $connections = $this->connections($mongo); + $pool = $this->pool($connections); + + $pool->setSchemaless(true); + $this->checkouts = 0; + $this->assertFalse($pool->supports(Capability::DefinedAttributes)); + $this->assertFalse($pool->supports(Capability::DefinedAttributes)); + $this->assertSame(1, $this->checkouts, 'A schema mode the handle set is asked of a connection once'); + + $mongo->setSchemaless(false); + $this->assertFalse($pool->supports(Capability::DefinedAttributes), 'Every connection the handle borrows is put in its mode first'); + + $pool->setSchemaless(false); + $this->checkouts = 0; + $this->assertTrue($pool->supports(Capability::DefinedAttributes)); + $this->assertTrue($pool->supports(Capability::DefinedAttributes)); + $this->assertSame(1, $this->checkouts, 'Each mode is answered by a connection in that mode'); + + $other = $this->pool($connections); + $other->setSchemaless(true); + $this->checkouts = 0; + $this->assertFalse($other->supports(Capability::DefinedAttributes)); + $this->assertTrue($pool->supports(Capability::DefinedAttributes)); + $this->assertSame(0, $this->checkouts, 'Handles over one pool share the answer of each mode'); + } + + public function testTheProfileFollowsTheConnectionsSchemaModeWhileThePoolLeavesItUnset(): void + { + $mongo = new class () extends Mongo { + public function __construct() + { + } + }; + $database = new Database($this->pool($this->connections($mongo)), new Cache(new MemoryCache())); + $profile = $database->profile(); + + $mongo->setSchemaless(true); + $this->assertFalse($profile->supports(Capability::DefinedAttributes)); + $this->assertFalse($database->profile()->supports(Capability::DefinedAttributes)); + + $mongo->setSchemaless(false); + $this->assertTrue($profile->supports(Capability::DefinedAttributes)); + $this->assertTrue($database->profile()->supports(Capability::DefinedAttributes)); + } + + public function testSettingTheSchemaModeOnAPoolOfSchemaEnforcingAdaptersLeavesThemEnforcingIt(): void + { + $connections = $this->connections(new Memory()); + $pool = $this->pool($connections); + $this->assertFalse($pool->hasFeature(Feature\Schemaless::class)); + $this->down = true; + + $this->assertSame($pool, $pool->setSchemaless(true), 'Setting the mode must not need a connection'); + $this->assertSame($pool, $pool->setSchemaless(false)); + + $this->down = false; + $pool->setSchemaless(true); + $this->assertTrue($pool->supports(Capability::DefinedAttributes)); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support schemaless'); + $pool->isSchemaless(); + } + + public function testAFailedFirstCheckoutAnswersNothingAndTheNextOneFillsTheAnswers(): void + { + $pool = $this->pool($this->connections(new Memory())); + $this->down = true; + + $caught = null; + try { + $pool->supports(Capability::Operators); + } catch (RuntimeException $exception) { + $caught = $exception; + } + $this->assertSame('backing unreachable', $caught?->getMessage(), 'A capability question with no answer yet must fail while the backing is down'); + + $this->down = false; + $this->assertTrue($pool->supports(Capability::Operators)); + + $this->down = true; + $this->assertTrue($pool->supports(Capability::Operators)); + } + + /** + * @return UtopiaPool + */ + private function connections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + function (callable $callback) use ($adapter): mixed { + if ($this->down) { + throw new RuntimeException('backing unreachable'); + } + + $this->checkouts++; + + return $callback($adapter); + }, + ); + + return $connections; + } + + /** + * @param UtopiaPool $connections + */ + private function pool(UtopiaPool $connections): Pool + { + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } + + /** + * @param UtopiaPool $connections + */ + private function database(UtopiaPool $connections): Database + { + $database = new Database($this->pool($connections), new Cache(new MemoryCache())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('pool_capabilities') + ->setNamespace('pool_capabilities_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: 'posts', + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument('posts', new Document([ + Document::ID => 'first', + 'title' => 'first', + Document::PERMISSIONS => [Permission::read(Role::any())], + ])); + + return $database; + } +} diff --git a/tests/unit/Adapter/PoolCastingTest.php b/tests/unit/Adapter/PoolCastingTest.php new file mode 100644 index 0000000000..e2fb4362e9 --- /dev/null +++ b/tests/unit/Adapter/PoolCastingTest.php @@ -0,0 +1,119 @@ +database($adapter); + $database->createDocuments('posts', $this->posts(['a', 'b', 'c', 'd', 'e'])); + $database->find('posts', [Query::limit(5)]); + + $adapter->reset(); + $this->checkouts = 0; + $one = $database->find('posts', [Query::limit(1)]); + $checkoutsForOne = $this->checkouts; + + $this->checkouts = 0; + $five = $database->find('posts', [Query::limit(5)]); + + $this->assertCount(1, $one); + $this->assertCount(5, $five); + $this->assertSame($checkoutsForOne, $this->checkouts, 'Casting a page must not check out a connection per document'); + $this->assertSame([['a'], ['a', 'b', 'c', 'd', 'e']], $adapter->batches); + } + + public function testAnEmptyPageAsksNoCasting(): void + { + $adapter = new CastCountingSQLite(); + $database = $this->database($adapter); + $database->find('posts', [Query::equal('title', ['none'])]); + + $adapter->reset(); + $this->assertSame([], $database->find('posts', [Query::equal('title', ['none'])])); + $this->assertSame([], $adapter->batches); + } + + public function testBulkWritesCastEachBatchInOneCall(): void + { + $adapter = new CastCountingSQLite(); + $database = $this->database($adapter); + + $this->assertSame(3, $database->createDocuments('posts', $this->posts(['a', 'b', 'c']))); + $this->assertSame([['a', 'b', 'c']], $adapter->batches); + + $adapter->reset(); + $this->assertSame(3, $database->updateDocuments('posts', new Document(['title' => 'renamed']))); + $this->assertSame([['a', 'b', 'c'], ['a', 'b', 'c']], $adapter->batches, 'The read of the batch, then the updated batch'); + + $adapter->reset(); + $this->assertSame(2, $database->upsertDocuments('posts', $this->posts(['d', 'e']))); + $this->assertSame([['d', 'e']], $adapter->batches); + + $this->assertSame(['renamed', 'renamed', 'renamed', 'd', 'e'], \array_map( + fn (Document $document): mixed => $document->getAttribute('title'), + $database->find('posts', [Query::orderAsc('$id')]), + )); + } + + /** + * @param list $ids + * @return list + */ + private function posts(array $ids): array + { + return \array_map(fn (string $id): Document => new Document([ + Document::ID => $id, + 'title' => $id, + Document::PERMISSIONS => [Permission::read(Role::any()), Permission::update(Role::any())], + ]), $ids); + } + + private function database(Adapter $adapter): Database + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(function (callable $callback) use ($adapter): mixed { + $this->checkouts++; + + return $callback($adapter); + }); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + $database = new Database($pool, new Cache(new MemoryCache())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('pool_casting') + ->setNamespace('pool_casting_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: 'posts', + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Adapter/PoolCoroutineTransactionTest.php b/tests/unit/Adapter/PoolCoroutineTransactionTest.php new file mode 100644 index 0000000000..050b7bf7eb --- /dev/null +++ b/tests/unit/Adapter/PoolCoroutineTransactionTest.php @@ -0,0 +1,219 @@ +markTestSkipped('ext-swoole is required for coroutines sharing a pool'); + } + } + + public function testASiblingRunsOutsideAnotherCoroutinesTransaction(): void + { + /** @var list $connections */ + $connections = []; + $pool = $this->pool($connections, 2); + $seen = []; + + $this->inCoroutine(function () use ($pool, &$connections, &$seen): void { + $entered = new Channel(1); + $released = new Channel(1); + + Coroutine::create(function () use ($pool, &$seen, $entered, $released): void { + $pool->withTransaction(function () use ($pool, &$seen, $entered, $released): void { + $seen['owner'] = $pool->inTransaction(); + $pool->ping(); + $entered->push(true); + $released->pop(); + }); + }); + + $entered->pop(); + $sibling = null; + $siblingDone = new Channel(1); + Coroutine::create(function () use ($pool, &$seen, &$sibling, $siblingDone): void { + $sibling = Coroutine::getCid(); + $seen['sibling'] = $pool->inTransaction(); + $pool->ping(); + $seen['siblingNested'] = $pool->withTransaction(fn (): bool => $pool->inTransaction()); + $siblingDone->push(true); + }); + $siblingDone->pop(); + $released->push(true); + + $pinned = $connections[0]; + $seen['siblingOnPinned'] = \in_array($sibling, \array_column($pinned->pings, 'coroutine'), true); + }); + + $this->assertSame( + ['owner' => true, 'sibling' => false, 'siblingNested' => true, 'siblingOnPinned' => false], + $seen, + ); + $this->assertFalse($pool->inTransaction()); + } + + public function testACoroutineStartedInsideATransactionRunsInIt(): void + { + /** @var list $connections */ + $connections = []; + $pool = $this->pool($connections, 2); + $seen = []; + + $this->inCoroutine(function () use ($pool, &$seen): void { + $pool->withTransaction(function () use ($pool, &$seen): void { + $done = new Channel(1); + Coroutine::create(function () use ($pool, &$seen, $done): void { + $seen['child'] = $pool->inTransaction(); + $done->push(true); + }); + $done->pop(); + }); + + $seen['after'] = $pool->inTransaction(); + }); + + $this->assertSame(['child' => true, 'after' => false], $seen); + } + + public function testCoroutinesOnThePinnedConnectionKeepTheirOwnTenant(): void + { + /** @var list $connections */ + $connections = []; + $pool = $this->pool($connections, 1); + + $this->inCoroutine(function () use ($pool, &$connections): void { + $pool->withTenant(self::TENANT, function () use ($pool, &$connections): void { + $pool->withTransaction(function () use ($pool, &$connections): void { + $paused = new Channel(1); + $resumed = new Channel(1); + $done = new Channel(1); + + $connections[0]->pauseNextPing(static function () use ($paused, $resumed): void { + $paused->push(true); + $resumed->pop(); + }); + + Coroutine::create(function () use ($pool, $done): void { + $pool->withTenant(self::CHILD_TENANT, fn (): bool => $pool->ping()); + $done->push(true); + }); + + $paused->pop(); + $pool->ping(); + $resumed->push(true); + $done->pop(); + }); + }); + }); + + $this->assertCount(1, $connections); + $this->assertSame( + [ + [self::TENANT, self::TENANT], + [self::CHILD_TENANT, self::CHILD_TENANT], + ], + \array_map(static fn (array $ping): array => [$ping['before'], $ping['after']], $connections[0]->pings), + ); + } + + public function testChildCoroutinesOnThePinnedConnectionKeepTheirTenantsAcrossCalls(): void + { + /** @var list $connections */ + $connections = []; + $pool = $this->pool($connections, 1); + + $this->inCoroutine(function () use ($pool, &$connections): void { + $pool->withTenant(self::TENANT, function () use ($pool, &$connections): void { + $pool->withTransaction(function () use ($pool, &$connections): void { + $paused = new Channel(1); + $resumed = new Channel(1); + $scopedDone = new Channel(1); + $inheritingDone = new Channel(1); + + $connections[0]->pauseNextPing(static function () use ($paused, $resumed): void { + $paused->push(true); + $resumed->pop(); + }); + + Coroutine::create(function () use ($pool, $scopedDone): void { + $pool->withTenant(self::CHILD_TENANT, function () use ($pool): void { + $pool->ping(); + $pool->ping(); + }); + $scopedDone->push(true); + }); + + $paused->pop(); + Coroutine::create(function () use ($pool, $inheritingDone): void { + $pool->ping(); + $inheritingDone->push(true); + }); + $inheritingDone->pop(); + $pool->ping(); + $resumed->push(true); + $scopedDone->pop(); + $pool->ping(); + }); + }); + }); + + $this->assertCount(1, $connections); + $this->assertSame( + [ + [self::TENANT, self::TENANT], + [self::TENANT, self::TENANT], + [self::CHILD_TENANT, self::CHILD_TENANT], + [self::CHILD_TENANT, self::CHILD_TENANT], + [self::TENANT, self::TENANT], + ], + \array_map(static fn (array $ping): array => [$ping['before'], $ping['after']], $connections[0]->pings), + ); + } + + /** + * @param list $connections + */ + private function pool(array &$connections, int $size): Pool + { + $pool = new Pool(new UtopiaPool(new Stack(), 'memory', $size, function () use (&$connections): PingRecordingMemory { + $connection = new PingRecordingMemory(); + $connections[] = $connection; + + return $connection; + }, timeout: 0.0)); + $pool->setAuthorization(new Authorization()); + + return $pool; + } + + private function inCoroutine(Closure $test): void + { + $hookFlags = Runtime::getHookFlags(); + + try { + run($test); + } finally { + Runtime::setHookFlags($hookFlags); + } + } +} diff --git a/tests/unit/Adapter/PoolDelegationTest.php b/tests/unit/Adapter/PoolDelegationTest.php new file mode 100644 index 0000000000..a2a6d0bcbc --- /dev/null +++ b/tests/unit/Adapter/PoolDelegationTest.php @@ -0,0 +1,138 @@ + + */ + public static function featureCalls(): array + { + return [ + 'raw queries' => [static fn (Pool $pool): mixed => $pool->rawQuery('SELECT 1'), 'Adapter does not support raw queries'], + 'query builder' => [static fn (Pool $pool): mixed => $pool->builder(), 'Adapter does not support query builder'], + 'schema builder' => [static fn (Pool $pool): mixed => $pool->schema(), 'Adapter does not support query builder'], + 'spatial decoding' => [static fn (Pool $pool): mixed => $pool->decode('', ColumnType::Point), 'Adapter does not support spatial'], + 'casting before a write' => [static fn (Pool $pool): mixed => $pool->castBefore(new Document(), new Document()), 'Adapter does not support casting'], + 'casting after a read' => [static fn (Pool $pool): mixed => $pool->castAfter(new Document(), [new Document()]), 'Adapter does not support casting'], + 'datetime casting' => [static fn (Pool $pool): mixed => $pool->castDatetime('2026-01-01'), 'Adapter does not support casting'], + 'upsert' => [static fn (Pool $pool): mixed => $pool->upsertDocument(new Document(), new Change(new Document(), new Document())), 'Adapter does not support upserts'], + 'connection' => [static fn (Pool $pool): mixed => $pool->id(), 'Adapter does not support connections'], + 'relationships' => [static fn (Pool $pool): mixed => $pool->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', key: 'author')), 'Adapter does not support relationships'], + ]; + } + + /** + * @param Closure(Pool): mixed $call + */ + #[DataProvider('featureCalls')] + public function testEachMissingFeatureNamesItself(Closure $call, string $message): void + { + /** @var Adapter&Stub $adapter */ + $adapter = self::createStub(Adapter::class); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage($message); + $call($this->pool($adapter)); + } + + public function testMetadataEntriesReachTheBorrowedAdapter(): void + { + $adapter = new Memory(); + $adapter->setMetadata('stale', 'entry'); + $pool = $this->pool($adapter); + $pool->setMetadata('request', 'r-1'); + + $this->assertSame([], $pool->list()); + $this->assertSame(['request' => 'r-1'], $adapter->getMetadata()); + } + + public function testTheIntrospectedIndexTypeIsTheBorrowedAdapters(): void + { + $pool = $this->pool(new Postgres(new stdClass())); + + $this->assertSame(IndexType::Key, $pool->getSchemaIndexType(IndexType::Fulltext)); + } + + public function testDirectTransactionCallsReachTheBorrowedAdapter(): void + { + $adapter = new Memory(); + $pool = $this->pool($adapter); + + $this->assertTrue($pool->startTransaction()); + $this->assertTrue($adapter->inTransaction()); + $this->assertTrue($pool->commitTransaction()); + $this->assertFalse($adapter->inTransaction()); + $this->assertFalse($pool->commitTransaction(), 'A commit without a transaction reports false, as the adapter does'); + + $this->assertTrue($pool->startTransaction()); + $this->assertTrue($pool->rollbackTransaction()); + $this->assertFalse($adapter->inTransaction()); + $this->assertFalse($pool->rollbackTransaction()); + } + + public function testQueryBuilderReadsTheRowsOfTheBorrowedAdapter(): void + { + $database = new Database($this->pool(new SQLite(new PDO('sqlite::memory:'))), new Cache(new NoCache())); + $database + ->setDatabase('library') + ->setNamespace('library') + ->setAuthorization(new Authorization()); + + $rows = $database->getAuthorization()->skip(static function () use ($database): array { + $database->create(); + $database->createCollection(Collection::create( + id: 'books', + attributes: [Attribute::string('title', size: 64)], + documentSecurity: false, + )); + $database->createDocument('books', new Document(['$id' => 'dune', 'title' => 'Dune'])); + $database->createDocument('books', new Document(['$id' => 'emma', 'title' => 'Emma'])); + + return $database->query($database->from('books')->select(['title'])->filter([Query::equal('$id', ['emma'])])); + }); + + $this->assertSame(['Emma'], \array_map(static fn (Document $row): mixed => $row->getAttribute('title'), $rows)); + } + + private function pool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } +} diff --git a/tests/unit/Adapter/PoolParityTest.php b/tests/unit/Adapter/PoolParityTest.php new file mode 100644 index 0000000000..fa6dfa0940 --- /dev/null +++ b/tests/unit/Adapter/PoolParityTest.php @@ -0,0 +1,110 @@ + $method) { + $name = $method->getName(); + + if (! $pool->hasMethod($name)) { + $missing[] = "{$feature}::{$name}() is missing"; + + continue; + } + + $delegate = $pool->getMethod($name); + + if (! $delegate->isPublic() || $delegate->isAbstract()) { + $missing[] = "{$feature}::{$name}() is not a public concrete method"; + } elseif ($delegate->getDeclaringClass()->getName() !== Pool::class) { + $missing[] = "{$feature}::{$name}() is inherited from {$delegate->getDeclaringClass()->getName()} instead of delegated"; + } + } + + $this->assertSame([], $missing, 'Pool must delegate every method of every adapter feature'); + } + + public function testPoolKeepsEveryFeatureParameterName(): void + { + $pool = new ReflectionClass(Pool::class); + $mismatched = []; + + foreach (self::featureMethods() as $feature => $method) { + $name = $method->getName(); + + if (! $pool->hasMethod($name)) { + continue; + } + + $expected = self::parameterNames($method); + $actual = self::parameterNames($pool->getMethod($name)); + + if ($expected !== $actual) { + $mismatched[] = "{$feature}::{$name}(" . \implode(', ', $expected) . ') is Pool::' . $name . '(' . \implode(', ', $actual) . ')'; + } + } + + $this->assertSame([], $mismatched, 'Named arguments that work on an adapter must work on Pool'); + } + + public function testEveryFeatureFileDeclaresAnInterface(): void + { + $features = self::features(); + + $this->assertNotSame([], $features, 'No adapter feature interfaces were found'); + + foreach ($features as $feature) { + $this->assertTrue(\interface_exists($feature), "{$feature} is not an interface"); + } + } + + /** + * @return list + */ + private static function features(): array + { + $directory = \dirname((string) (new ReflectionClass(Adapter::class))->getFileName()) . '/Adapter/Feature'; + $features = []; + + foreach (\glob($directory . '/*.php') ?: [] as $file) { + /** @var class-string $feature */ + $feature = 'Utopia\\Database\\Adapter\\Feature\\' . \basename($file, '.php'); + $features[] = $feature; + } + + return $features; + } + + /** + * @return iterable + */ + private static function featureMethods(): iterable + { + foreach (self::features() as $feature) { + foreach ((new ReflectionClass($feature))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + yield $feature => $method; + } + } + } + + /** + * @return list + */ + private static function parameterNames(ReflectionMethod $method): array + { + return \array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $method->getParameters()); + } +} diff --git a/tests/unit/Adapter/PoolSyncTest.php b/tests/unit/Adapter/PoolSyncTest.php new file mode 100644 index 0000000000..5eed527e85 --- /dev/null +++ b/tests/unit/Adapter/PoolSyncTest.php @@ -0,0 +1,310 @@ +pool(); + + $pool->ping(); + $this->assertNotSame($expected, $observe($connection->last()), 'The change must differ from the starting state'); + + $change($pool); + $pool->ping(); + + $this->assertSame($expected, $observe($connection->last())); + } + + /** + * @param Closure(Pool): mixed $change + * @param Closure(SyncSnapshot): mixed $observe + */ + #[DataProvider('changes')] + public function testAPinnedCallSeesTheChange(Closure $change, Closure $observe, mixed $expected): void + { + [$pool, $connection] = $this->pool(); + + $pool->withTransaction(function () use ($pool, $change): void { + $pool->ping(); + $change($pool); + $pool->ping(); + }); + + $this->assertCount(2, $connection->snapshots); + $this->assertNotSame($expected, $observe($connection->snapshots[0]), 'The change must differ from the starting state'); + $this->assertSame($expected, $observe($connection->snapshots[1])); + } + + /** + * @param Closure(Pool): mixed $change + * @param Closure(SyncSnapshot): mixed $observe + */ + #[DataProvider('changes')] + public function testACallAfterTheTransactionSeesAChangeMadeInIt(Closure $change, Closure $observe, mixed $expected): void + { + [$pool, $connection] = $this->pool(); + + $pool->withTransaction(function () use ($pool, $change): void { + $pool->ping(); + $change($pool); + }); + $pool->ping(); + + $this->assertSame($expected, $observe($connection->last())); + } + + /** + * @param Closure(Pool): mixed $change + * @param Closure(SyncSnapshot): mixed $observe + */ + #[DataProvider('changes')] + public function testTheNextTransactionSeesAChangeMadeBeforeIt(Closure $change, Closure $observe, mixed $expected): void + { + [$pool, $connection] = $this->pool(); + + $pool->withTransaction(fn (): bool => $pool->ping()); + $change($pool); + $pool->withTransaction(fn (): bool => $pool->ping()); + + $this->assertSame($expected, $observe($connection->last())); + } + + /** + * @param Closure(SyncRecordingMemory): void $mutation + */ + #[DataProvider('mutations')] + public function testAConnectionsOwnChangeDoesNotReachTheNextBorrowedCall(Closure $mutation): void + { + [$pool, $connection] = $this->pool(); + + $connection->mutateOnNextPing($mutation); + $pool->ping(); + $pool->ping(); + + $this->assertCount(2, $connection->snapshots); + $this->assertSame(\get_object_vars($connection->snapshots[0]), \get_object_vars($connection->snapshots[1])); + } + + /** + * @param Closure(SyncRecordingMemory): void $mutation + */ + #[DataProvider('mutations')] + public function testAConnectionsOwnChangeDoesNotReachTheNextPinnedCall(Closure $mutation): void + { + [$pool, $connection] = $this->pool(); + + $pool->withTransaction(function () use ($pool, $connection, $mutation): void { + $connection->mutateOnNextPing($mutation); + $pool->ping(); + $pool->ping(); + }); + + $this->assertCount(2, $connection->snapshots); + $this->assertSame(\get_object_vars($connection->snapshots[0]), \get_object_vars($connection->snapshots[1])); + } + + public function testABorrowedCallLeavesNoProfilerOnTheConnection(): void + { + [$pool, $connection] = $this->pool(); + + $pool->ping(); + + $this->assertSame(self::profiler(), $connection->last()->profiler); + $this->assertNull($connection->getProfiler()); + } + + /** + * @return iterable + */ + public static function changes(): iterable + { + $metadata = static fn (SyncSnapshot $snapshot): mixed => $snapshot->metadata; + $transforms = static fn (SyncSnapshot $snapshot): mixed => $snapshot->transforms; + $timeouts = static fn (SyncSnapshot $snapshot): mixed => $snapshot->timeouts; + + yield 'metadata added' => [ + static fn (Pool $pool): mixed => $pool->setMetadata('trace', 'b'), + $metadata, + ['request' => 'a', 'trace' => 'b'], + ]; + yield 'metadata replaced' => [ + static fn (Pool $pool): mixed => $pool->setMetadata('request', 'c'), + $metadata, + ['request' => 'c'], + ]; + yield 'metadata reset' => [ + static fn (Pool $pool) => $pool->resetMetadata(), + $metadata, + [], + ]; + yield 'transform added' => [ + static fn (Pool $pool): mixed => $pool->addTransform('second', self::second()), + $transforms, + ['first' => self::first(), 'second' => self::second()], + ]; + yield 'transform replaced' => [ + static fn (Pool $pool): mixed => $pool->addTransform('first', self::second()), + $transforms, + ['first' => self::second()], + ]; + yield 'transform removed' => [ + static fn (Pool $pool): mixed => $pool->removeTransform('first'), + $transforms, + [], + ]; + yield 'schemaless' => [ + static fn (Pool $pool): mixed => $pool->setSchemaless(true), + static fn (SyncSnapshot $snapshot): mixed => $snapshot->schemaless, + true, + ]; + yield 'profiler replaced' => [ + static fn (Pool $pool): mixed => $pool->setProfiler(self::otherProfiler()), + static fn (SyncSnapshot $snapshot): mixed => $snapshot->profiler, + self::otherProfiler(), + ]; + yield 'profiler removed' => [ + static fn (Pool $pool): mixed => $pool->setProfiler(null), + static fn (SyncSnapshot $snapshot): mixed => $snapshot->profiler, + null, + ]; + yield 'tenant' => [ + static fn (Pool $pool): mixed => $pool->setTenant(self::OTHER_TENANT), + static fn (SyncSnapshot $snapshot): mixed => $snapshot->tenant, + self::OTHER_TENANT, + ]; + yield 'namespace' => [ + static fn (Pool $pool): mixed => $pool->setNamespace(self::OTHER_NAMESPACE), + static fn (SyncSnapshot $snapshot): mixed => $snapshot->namespace, + self::OTHER_NAMESPACE, + ]; + yield 'timeout replaced' => [ + static fn (Pool $pool) => $pool->setTimeout(self::OTHER_TIMEOUT), + $timeouts, + [Event::All->value => self::OTHER_TIMEOUT], + ]; + yield 'timeout per event' => [ + static fn (Pool $pool) => $pool->setTimeout(self::EVENT_TIMEOUT, Event::DocumentFind), + $timeouts, + [Event::DocumentFind->value => self::EVENT_TIMEOUT, Event::All->value => self::TIMEOUT], + ]; + yield 'timeout cleared' => [ + static fn (Pool $pool) => $pool->clearTimeout(), + $timeouts, + [], + ]; + } + + /** + * @return iterable + */ + public static function mutations(): iterable + { + yield 'state added' => [static function (SyncRecordingMemory $connection): void { + $connection->setMetadata('leak', true); + $connection->addTransform('leak', self::second()); + $connection->setSchemaless(true); + $connection->setProfiler(self::otherProfiler()); + $connection->setTimeout(self::OTHER_TIMEOUT); + }]; + yield 'state removed' => [static function (SyncRecordingMemory $connection): void { + $connection->resetMetadata(); + $connection->resetTransforms(); + $connection->setProfiler(null); + $connection->clearTimeout(); + }]; + yield 'state replaced' => [static function (SyncRecordingMemory $connection): void { + $connection->setMetadata('request', 'leak'); + $connection->addTransform('first', self::second()); + }]; + } + + /** + * @return array{Pool, SyncRecordingMemory} + */ + private function pool(): array + { + $connection = new SyncRecordingMemory(); + $pool = new Pool(new UtopiaPool(new Stack(), 'memory', 1, static fn (): SyncRecordingMemory => $connection, timeout: 0.0)); + $pool->setAuthorization(new Authorization()); + $pool->setNamespace(self::NAMESPACE); + $pool->setTenant(self::TENANT); + $pool->setMetadata('request', 'a'); + $pool->addTransform('first', self::first()); + $pool->setSchemaless(false); + $pool->setProfiler(self::profiler()); + $pool->setTimeout(self::TIMEOUT); + + return [$pool, $connection]; + } + + private static function first(): Transform + { + return self::$first ??= self::transform(); + } + + private static function second(): Transform + { + return self::$second ??= self::transform(); + } + + private static function profiler(): Profiler + { + return self::$profiler ??= new Profiler(); + } + + private static function otherProfiler(): Profiler + { + return self::$otherProfiler ??= new Profiler(); + } + + private static function transform(): Transform + { + return new class () implements Transform { + #[\Override] + public function transform(Event $event, string $query): string + { + return $query; + } + }; + } +} diff --git a/tests/unit/Adapter/PoolTest.php b/tests/unit/Adapter/PoolTest.php new file mode 100644 index 0000000000..5bdf03b860 --- /dev/null +++ b/tests/unit/Adapter/PoolTest.php @@ -0,0 +1,332 @@ +addWriteHook(new Tenancy(column: 'old_tenant_column')); + $pool = $this->createPool($adapter); + + $hook = new Tenancy(column: 'new_tenant_column'); + $pool->addWriteHook($hook); + + $this->assertSame([], $pool->list()); + $this->assertSame([$hook], $adapter->getWriteHooks()); + $this->assertSame(['new_tenant_column' => 1], $adapter->getTenantHook()?->decorateRow([], new RowMetadata(1))); + } + + public function testTransactionReplacesStatefulWriteHookOnReusedAdapter(): void + { + $adapter = new Memory(); + $adapter->addWriteHook(new Permissions()); + $adapter->addWriteHook(new Tenancy(column: 'old_tenant_column')); + $pool = $this->createPool($adapter); + + $hook = new Tenancy(column: 'new_tenant_column'); + $pool->addWriteHook($hook); + + $pool->withTransaction(static fn (): string => 'committed'); + $this->assertSame([$hook], $adapter->getWriteHooks()); + $this->assertSame($hook, $adapter->getTenantHook()); + } + + public function testDelegateRemovesWriteHookRemovedFromPool(): void + { + $adapter = new Memory(); + $pool = $this->createPool($adapter); + + $hook = new Permissions(); + $pool->addWriteHook($hook); + $this->assertSame([], $pool->list()); + $this->assertSame([$hook], $adapter->getWriteHooks()); + + $pool->removeWriteHook(Permissions::class); + $this->assertSame([], $pool->list()); + $this->assertSame([], $adapter->getWriteHooks()); + } + + public function testTransactionRemovesWriteHookRemovedFromPool(): void + { + $adapter = new Memory(); + $pool = $this->createPool($adapter); + + $hook = new Permissions(); + $pool->addWriteHook($hook); + $pool->withTransaction(static fn (): string => 'committed'); + $this->assertSame([$hook], $adapter->getWriteHooks()); + + $pool->removeWriteHook(Permissions::class); + $pool->withTransaction(static fn (): string => 'committed'); + $this->assertSame([], $adapter->getWriteHooks()); + } + + public function testTransactionPropagatesWriteHooksToPinnedAdapter(): void + { + /** @var Adapter&MockObject $adapter */ + $adapter = $this->createMock(Adapter::class); + $adapter->method('getWriteHooks')->willReturn([]); + $adapter->method('withTransaction')->willReturnCallback( + static fn (callable $callback): mixed => $callback(), + ); + + $hook = new Permissions(); + $adapter->expects($this->once()) + ->method('addWriteHook') + ->with($this->identicalTo($hook)) + ->willReturnSelf(); + + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + $pool->addWriteHook($hook); + + $pool->withTransaction(static fn (): string => 'committed'); + } + + public function testPinnedAdapterResyncsTenantAndDatabaseBeforeDelegatedCall(): void + { + $adapter = new Memory(); + $adapter->setDatabase('old_db'); + $adapter->setNamespace('old_ns'); + $adapter->setTenant(1); + + $pool = $this->createPool($adapter); + $pool->setDatabase('old_db'); + $pool->setNamespace('old_ns'); + $pool->setTenant(1); + + $pool->withTransaction(function () use ($pool, $adapter): void { + $pool->setDatabase('new_db'); + $pool->setNamespace('new_ns'); + $pool->setTenant(2); + + $this->assertSame([], $pool->list()); + $this->assertSame('new_db', $adapter->getDatabase()); + $this->assertSame('new_ns', $adapter->getNamespace()); + $this->assertSame(2, $adapter->getTenant()); + }); + } + + public function testMemoryPoolDelegatesWithoutRequiringTimeouts(): void + { + $pool = $this->createPool(new Memory()); + + $this->assertSame([], $pool->list()); + } + + public function testDefinedAttributesSupportPropagatesAcrossBorrowedAdapters(): void + { + $first = new class () extends Mongo { + public function __construct() + { + } + }; + $second = new class () extends Mongo { + public function __construct() + { + } + }; + $adapters = [$first, $second]; + + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static function (callable $callback) use (&$adapters): mixed { + return $callback(\array_shift($adapters)); + }, + ); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + $pool->setSchemaless(true); + + $this->assertFalse($pool->supports(Capability::DefinedAttributes)); + $this->assertTrue($pool->isSchemaless()); + $this->assertFalse($first->supports(Capability::DefinedAttributes)); + $this->assertFalse($second->supports(Capability::DefinedAttributes)); + } + + /** + * A timeout is adapter state, so setting one must not check a connection out + * — a handle built against an unreachable backing would otherwise fail before + * the caller had issued a single query. The adapter's capabilities are first + * known when the timeout is applied, which is where the refusal belongs. + */ + public function testMemoryPoolSetTimeoutRefusesWhenTheTimeoutWouldBeApplied(): void + { + $pool = $this->createPool(new Memory()); + + $pool->setTimeout(1000); + $this->assertSame(1000, $pool->getTimeout()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support timeouts'); + + $pool->getDriver(); + } + + public function testMissingFeatureThrows(): void + { + $pool = $this->createPool(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support upserts'); + + $pool->upsertDocuments(new Document(), [], 'id'); + } + + /** + * Configuring a handle must not open a connection, or an unreachable + * backing fails a caller that has issued no query yet. Pool::setTimeout() + * holds the value without checking out, but Database::setTimeout() guarded + * itself with hasFeature(), and on a pool that guard is a delegated call -- + * which dials. + */ + public function testSettingATimeoutOnAPooledHandleDoesNotOpenAConnection(): void + { + $dials = 0; + + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static function (callable $callback) use (&$dials): mixed { + $dials++; + + return $callback(new Memory()); + }, + ); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + (new Database($pool, new Cache(new NoCache())))->setTimeout(300000); + + $this->assertSame(0, $dials, 'Building a handle must not check a connection out'); + $this->assertSame(300000, $pool->getTimeout(), 'The handle must still hold the timeout it was given'); + } + + /** + * syncPinnedTimeouts() reaches whatever the handle has pinned. Upstream that + * is one adapter on the object, but a handle that pins per coroutine keeps + * its pins elsewhere, and reading the property directly reached none of + * them -- a timeout raised inside a transaction then arrived at the next + * checkout, long after the body it was meant to bound had run. + */ + public function testTimeoutRaisedWhilePinnedReachesTheSubclassPin(): void + { + $pinned = new TimeoutRecordingAdapter(); + + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback(new TimeoutRecordingAdapter()), + ); + + $pool = new ElsewherePinnedPool($connections); + $pool->setAuthorization(new Authorization()); + $pool->pinElsewhere($pinned); + + $pool->setTimeout(300000); + + $this->assertSame(300000, $pinned->getTimeout(), 'The connection the open transaction is running on must get the new bound'); + } + + public function testBorrowedAdapterDoesNotKeepTheProfilerAfterTheCall(): void + { + $adapter = new ProfilerProbeAdapter(); + $pool = $this->createPool($adapter); + $profiler = new Profiler(); + $pool->setProfiler($profiler); + + $this->assertTrue($pool->ping()); + + $this->assertSame($profiler, $adapter->profiled, 'The borrowed connection must profile the call it served'); + $this->assertNull($adapter->getProfiler(), 'The connection went back to the pool still holding the handle\'s profiler'); + } + + public function testPinnedAdapterDoesNotKeepTheProfilerAfterTheTransaction(): void + { + $adapter = new ProfilerProbeAdapter(); + $pool = $this->createPool($adapter); + $profiler = new Profiler(); + $pool->setProfiler($profiler); + + $this->assertTrue($pool->withTransaction(static fn (): bool => $pool->ping())); + + $this->assertSame($profiler, $adapter->profiled, 'The pinned connection must profile the calls of the transaction'); + $this->assertNull($adapter->getProfiler(), 'The connection went back to the pool still holding the handle\'s profiler'); + } + + public function testDisablingProfilingDetachesPooledConnections(): void + { + $connection = new SQLite(new PDO('sqlite::memory:')); + $database = new Database($this->createPool($connection), new Cache(new NoCache())); + $database + ->setDatabase('profiling') + ->setNamespace('profiling') + ->setAuthorization(new Authorization()); + $database->setProfiling(true); + $database->create(); + $database->createCollection(Collection::create(id: 'posts')); + + $profiler = $database->getProfiler(); + $this->assertInstanceOf(Profiler::class, $profiler); + $captured = $profiler->getQueryCount(); + $this->assertGreaterThan(0, $captured, 'The pooled SQLite connection must profile its statements'); + + $database->setProfiling(false); + + $this->assertNull($database->getAdapter()->getProfiler(), 'The pool must not keep the profiler once profiling is off'); + $this->assertNull($connection->getProfiler(), 'The pooled connection must not keep the profiler once profiling is off'); + + $database->getDocument('posts', 'missing'); + + $this->assertSame($captured, $profiler->getQueryCount(), 'Statements run after profiling was disabled must not be captured'); + $this->assertNotSame([], $profiler->getLogs(), 'The statements captured before disabling must stay readable'); + } + + private function createPool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } +} diff --git a/tests/unit/Adapter/PostgresCollectionIndexTest.php b/tests/unit/Adapter/PostgresCollectionIndexTest.php new file mode 100644 index 0000000000..bdfb54babb --- /dev/null +++ b/tests/unit/Adapter/PostgresCollectionIndexTest.php @@ -0,0 +1,64 @@ + */ + private array $statements = []; + + /** + * @return array + */ + public static function objectPathIndexes(): array + { + return [ + 'dedicated tables' => [false, 'CREATE INDEX "namespace__places_countryfirst" ON "database"."namespace_places" ((("data"->>\'country\')::text) DESC, "status")'], + 'shared tables' => [true, 'CREATE INDEX "namespace_7_places_countryfirst" ON "database"."namespace_places" ("_tenant", (("data"->>\'country\')::text) DESC, "status")'], + ]; + } + + #[DataProvider('objectPathIndexes')] + public function testCollectionIndexOnAnObjectPathIndexesTheJsonPath(bool $sharedTables, string $statement): void + { + $adapter = $this->createAdapter($sharedTables); + + $adapter->createCollection( + 'places', + [Attribute::object(key: 'data'), Attribute::string(key: 'status', size: 32)], + [Index::key(key: 'countryfirst', attributes: ['data.country', 'status'], orders: [OrderDirection::Desc, null])], + ); + + $this->assertSame($statement, $this->statements[\count($this->statements) - 1] ?? null); + } + + private function createAdapter(bool $sharedTables): Postgres + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables($sharedTables); + $adapter->setTenant($sharedTables ? 7 : null); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/PostgresCollectionSizeTest.php b/tests/unit/Adapter/PostgresCollectionSizeTest.php new file mode 100644 index 0000000000..1f018c019a --- /dev/null +++ b/tests/unit/Adapter/PostgresCollectionSizeTest.php @@ -0,0 +1,87 @@ + */ + private array $statements = []; + + /** + * @return iterable + */ + public static function sizes(): iterable + { + yield 'on disk' => ['getSizeOfCollectionOnDisk', 'pg_total_relation_size']; + yield 'raw data' => ['getSizeOfCollection', 'pg_relation_size']; + } + + #[DataProvider('sizes')] + public function testTheSizeAddsTheCollectionAndItsPermissionsTable(string $method, string $function): void + { + $adapter = $this->adapter(['8192', '4096']); + + $this->assertSame(12288, $this->readSize($adapter, $method)); + $this->assertCount(2, $this->statements); + foreach ($this->statements as $statement) { + $this->assertStringContainsString($function . '(', $statement); + } + } + + #[DataProvider('sizes')] + public function testAFailedSizeReadIsADatabaseError(string $method, string $function): void + { + $adapter = $this->adapter(['8192', '4096'], new PDOException('SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "database.namespace_books" does not exist')); + + try { + $this->readSize($adapter, $method); + $this->fail('A size read that fails must reach the caller'); + } catch (DatabaseException $error) { + $this->assertSame( + 'Failed to get collection size: SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "database.namespace_books" does not exist', + $error->getMessage(), + ); + } + } + + private function readSize(Postgres $adapter, string $method): int + { + return $method === 'getSizeOfCollectionOnDisk' + ? $adapter->getSizeOfCollectionOnDisk('books') + : $adapter->getSizeOfCollection('books'); + } + + /** + * @param list $sizes + */ + private function adapter(array $sizes, ?PDOException $failure = null): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sizes, $failure): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('fetchColumn')->willReturn(\array_shift($sizes)); + if ($failure === null) { + $statement->method('execute')->willReturn(true); + } else { + $statement->method('execute')->willThrowException($failure); + } + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/PostgresColumnRewriteTest.php b/tests/unit/Adapter/PostgresColumnRewriteTest.php new file mode 100644 index 0000000000..fe816fbb81 --- /dev/null +++ b/tests/unit/Adapter/PostgresColumnRewriteTest.php @@ -0,0 +1,56 @@ + */ + private array $statements = []; + + public function testDatetimeRewriteCastsTheColumnItself(): void + { + $this->createAdapter()->updateAttribute('events', 'at', Attribute::datetime(key: 'at')); + + $this->assertContains( + 'ALTER TABLE "database"."namespace_events" ALTER COLUMN "at" TYPE TIMESTAMP(3) USING "at"::TIMESTAMP(3)', + $this->statements, + ); + } + + public function testRenamedDatetimeRewriteCastsTheRenamedColumn(): void + { + $this->createAdapter()->updateAttribute('events', 'at', Attribute::datetime(key: 'happenedAt')); + + $this->assertStringStartsWith('SELECT a.attname FROM pg_attribute a', $this->statements[0]); + $this->assertSame('ALTER TABLE "database"."namespace_events" RENAME COLUMN "at" TO "happenedAt"', $this->statements[1]); + $this->assertSame( + 'ALTER TABLE "database"."namespace_events" ALTER COLUMN "happenedAt" TYPE TIMESTAMP(3) USING "happenedAt"::TIMESTAMP(3)', + $this->statements[2], + ); + } + + private function createAdapter(): Postgres + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/PostgresCreateCollectionCleanupTest.php b/tests/unit/Adapter/PostgresCreateCollectionCleanupTest.php new file mode 100644 index 0000000000..8a4aee71bb --- /dev/null +++ b/tests/unit/Adapter/PostgresCreateCollectionCleanupTest.php @@ -0,0 +1,165 @@ + */ + private array $statements = []; + + /** @var array */ + private array $failures = []; + + public function testADeclaredIndexThatFailsDropsBothTablesAndReachesTheCaller(): void + { + $this->failures[self::DECLARED_INDEX] = $this->engineError('42703', 'column "title" does not exist'); + + try { + $this->adapter()->createCollection('books', [Attribute::string('title', size: 64)], [ + Index::key('title_index', ['title']), + ]); + $this->fail('A declared index that fails must fail the collection'); + } catch (NotFoundException $error) { + $this->assertSame('Attribute not found', $error->getMessage()); + } + + $this->assertCount(4, $this->statements); + $this->assertStringStartsWith(self::DECLARED_INDEX, $this->statements[2]); + $this->assertSame( + 'DROP TABLE IF EXISTS "database"."namespace_books"; DROP TABLE IF EXISTS "database"."namespace_books_perms"', + $this->statements[3], + ); + } + + public function testACleanupThatFailsKeepsTheOriginalErrorAndLogsTheCleanupFailure(): void + { + $this->failures[self::DECLARED_INDEX] = $this->engineError('42703', 'column "title" does not exist'); + $this->failures['DROP TABLE IF EXISTS'] = $this->engineError('25P02', 'current transaction is aborted, commands ignored until end of transaction block'); + + $error = null; + $log = StderrCapture::during(function () use (&$error): void { + try { + $this->adapter()->createCollection('books', [Attribute::string('title', size: 64)], [ + Index::key('title_index', ['title']), + ]); + } catch (\Throwable $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(NotFoundException::class, $error, 'the index failure reaches the caller, not the failed drop'); + $this->assertSame('Attribute not found', $error->getMessage()); + $this->assertStringStartsWith('DROP TABLE IF EXISTS', $this->statements[3]); + $this->assertStringContainsString("Failed to rollback collection 'books': SQLSTATE[25P02]", $log, 'the failed cleanup is logged'); + } + + public function testADeclaredIndexThatAlreadyExistsKeepsBothTables(): void + { + $this->failures[self::DECLARED_INDEX] = $this->engineError('42P07', 'relation "namespace__books_title_index" already exists'); + + try { + $this->adapter()->createCollection('books', [Attribute::string('title', size: 64)], [ + Index::key('title_index', ['title']), + ]); + $this->fail('A declared index that already exists must reach the caller'); + } catch (DuplicateException $error) { + $this->assertInstanceOf(PDOException::class, $error->getPrevious()); + } + + $this->assertCount(3, $this->statements); + foreach ($this->statements as $statement) { + $this->assertStringNotContainsString('DROP TABLE', $statement); + } + } + + public function testASpatialIndexWithOrdersDropsBothTablesAndReachesTheCaller(): void + { + try { + $this->adapter()->createCollection('places', [Attribute::point('location', required: true)], [ + Index::spatial('location_index', 'location', order: OrderDirection::Desc), + ]); + $this->fail('A spatial index with orders must fail the collection'); + } catch (DatabaseException $error) { + $this->assertSame('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.', $error->getMessage()); + } + + $this->assertCount(3, $this->statements); + $this->assertSame( + 'DROP TABLE IF EXISTS "database"."namespace_places"; DROP TABLE IF EXISTS "database"."namespace_places_perms"', + $this->statements[2], + ); + } + + public function testATableThatFailsToBeCreatedDropsNothing(): void + { + $this->failures['CREATE TABLE "database"."namespace_books"'] = $this->engineError('57014', 'canceling statement due to statement timeout'); + + try { + $this->adapter()->createCollection('books', [Attribute::string('title', size: 64)]); + $this->fail('A table that fails to be created must fail the collection'); + } catch (TimeoutException $error) { + $this->assertSame('Query timed out', $error->getMessage()); + } + + $this->assertCount(1, $this->statements); + } + + private function engineError(string $state, string $message): PDOException + { + $error = new class ('SQLSTATE[' . $state . ']: ' . $message, $state) extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $error->errorInfo = [$state, 7, $message]; + + return $error; + } + + private function adapter(): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $failure = null; + foreach ($this->failures as $prefix => $error) { + if (\str_starts_with($query, $prefix)) { + $failure = $error; + } + } + + $statement = $this->createStub(PDOStatement::class); + if ($failure === null) { + $statement->method('execute')->willReturn(true); + } else { + $statement->method('execute')->willThrowException($failure); + } + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/PostgresRenameIndexTest.php b/tests/unit/Adapter/PostgresRenameIndexTest.php new file mode 100644 index 0000000000..3412da91b4 --- /dev/null +++ b/tests/unit/Adapter/PostgresRenameIndexTest.php @@ -0,0 +1,157 @@ + */ + private array $indexes = []; + + /** @var list The tenants of the collection's definitions, in the order they were stored */ + private array $tenants = []; + + /** @var list */ + private array $statements = []; + + public function testAnIndexTheSchemaHasIsRenamed(): void + { + $this->indexes = ['namespace__users_byAge']; + + $this->assertTrue($this->adapter(shared: false)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame(['namespace__users_byYears'], $this->indexes); + } + + public function testAnIndexTheSchemaDoesNotHaveIsNotRenamed(): void + { + $this->assertFalse($this->adapter(shared: false)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame([], $this->indexes); + } + + public function testAnIndexTheSchemaAlreadyRenamedIsReportedRenamed(): void + { + $this->indexes = ['namespace__users_byYears']; + + $this->assertTrue($this->adapter(shared: false)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame(['namespace__users_byYears'], $this->indexes); + } + + public function testSharedTablesRenameTheTenantsOwnIndex(): void + { + $this->indexes = ['namespace_1_users_byAge', 'namespace_2_users_byAge']; + $this->tenants = ['1', '2']; + + $this->assertTrue($this->adapter(shared: true)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame(['namespace_1_users_byAge', 'namespace_2_users_byYears'], $this->indexes); + $this->assertSame(self::RENAME, $this->statements[0]); + } + + public function testSharedTablesLeaveAnIndexAnotherTenantCreatedToItsOwner(): void + { + $this->indexes = ['namespace_1_users_byAge']; + $this->tenants = ['1', '2']; + + $this->assertTrue($this->adapter(shared: true)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame(['namespace_1_users_byAge'], $this->indexes); + $this->assertSame(self::RENAME, $this->statements[0]); + } + + public function testSharedTablesCompleteARenameAnotherTenantAlreadyMade(): void + { + $this->indexes = ['namespace_1_users_byYears']; + $this->tenants = ['1', '2']; + + $this->assertTrue($this->adapter(shared: true)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame(['namespace_1_users_byYears'], $this->indexes); + } + + public function testSharedTablesReportARenameNoTenantsIndexBacks(): void + { + $this->indexes = ['namespace_1_users_byName']; + $this->tenants = ['1', '2']; + + $this->assertFalse($this->adapter(shared: true)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame(['namespace_1_users_byName'], $this->indexes); + } + + public function testSharedTablesReportARenameOnlyANonCreatingTenantsIndexBacks(): void + { + $this->indexes = ['namespace_1_users_byAge']; + $this->tenants = ['2', '1']; + + $this->assertFalse($this->adapter(shared: true)->renameIndex('users', 'byAge', 'byYears')); + $this->assertSame(['namespace_1_users_byAge'], $this->indexes); + } + + public function testSharedTablesCompleteARenameFromTheIndexATenantlessCreatorMade(): void + { + $this->indexes = ['namespace__users_byAge']; + $this->tenants = [null, '2']; + + $this->assertTrue($this->adapter(shared: true)->renameIndex('users', 'byAge', 'byYears')); + } + + public function testSharedTablesReportARenameOfACollectionWithNoDefinitionOnlyFromTheTenantsIndex(): void + { + $this->indexes = ['namespace__users_byAge']; + + $this->assertFalse($this->adapter(shared: true)->renameIndex('users', 'byAge', 'byYears')); + } + + private function adapter(bool $shared): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $bound = []; + + $statement = $this->createStub(PDOStatement::class); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value) use (&$bound): bool { + $bound[] = $value; + + return true; + }); + $statement->method('execute')->willReturnCallback(function () use ($query): bool { + if (\preg_match('/^ALTER INDEX IF EXISTS "database"\."([^"]+)" RENAME TO "([^"]+)"$/', $query, $names) === 1) { + $this->indexes = \array_map(static fn (string $index): string => $index === $names[1] ? $names[2] : $index, $this->indexes); + } + + return true; + }); + $statement->method('fetchAll')->willReturnCallback(function () use ($query, &$bound): array { + if (\str_contains($query, 'pg_class')) { + $schema = \array_shift($bound); + $names = []; + foreach ($bound as $value) { + if (\is_string($value)) { + $names[] = $value; + } + } + + return $schema === 'database' ? \array_values(\array_intersect($names, $this->indexes)) : []; + } + + return []; + }); + $statement->method('fetch')->willReturnCallback(fn (): array|false => \str_contains($query, '_metadata') && $this->tenants !== [] ? [$this->tenants[0]] : false); + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + if ($shared) { + $adapter->setSharedTables(true); + $adapter->setTenant(2); + } + + return $adapter; + } +} diff --git a/tests/unit/Adapter/PostgresSchemaTest.php b/tests/unit/Adapter/PostgresSchemaTest.php new file mode 100644 index 0000000000..0398b7020c --- /dev/null +++ b/tests/unit/Adapter/PostgresSchemaTest.php @@ -0,0 +1,142 @@ + */ + private array $statements = []; + + public function testCreateCollectionAddsColumnsOnlyForRelationshipSidesThatStoreAKey(): void + { + $this->adapter()->createCollection('books', [ + Attribute::string('title', size: 64), + $this->relationship('tags', RelationshipType::ManyToMany, twoWay: true, side: RelationshipSide::Parent), + $this->relationship('cover', RelationshipType::OneToOne, twoWay: false, side: RelationshipSide::Child), + $this->relationship('chapters', RelationshipType::OneToMany, twoWay: true, side: RelationshipSide::Parent), + $this->relationship('shelf', RelationshipType::ManyToOne, twoWay: true, side: RelationshipSide::Child), + $this->relationship('isbn', RelationshipType::OneToOne, twoWay: false, side: RelationshipSide::Parent), + $this->relationship('summary', RelationshipType::OneToOne, twoWay: true, side: RelationshipSide::Child), + $this->relationship('series', RelationshipType::OneToMany, twoWay: true, side: RelationshipSide::Child), + $this->relationship('publisher', RelationshipType::ManyToOne, twoWay: true, side: RelationshipSide::Parent), + ]); + + $create = $this->statements[0] ?? ''; + $this->assertStringStartsWith('CREATE TABLE "database"."namespace_books"', $create); + + foreach (['title', 'isbn', 'summary', 'series', 'publisher'] as $stored) { + $this->assertStringContainsString('"' . $stored . '" ', $create, $stored . ' stores a column'); + } + + foreach (['tags', 'cover', 'chapters', 'shelf'] as $skipped) { + $this->assertStringNotContainsString('"' . $skipped . '"', $create, $skipped . ' stores nothing on this side'); + } + } + + /** + * @return iterable + */ + public static function unknownIndexTypes(): iterable + { + yield 'ttl' => [IndexType::Ttl]; + } + + #[DataProvider('unknownIndexTypes')] + public function testCreateIndexRefusesATypeTheEngineDoesNotCreate(IndexType $type): void + { + try { + $this->adapter()->createIndex('events', Index::fromArray(['key' => 'happened_index', 'type' => $type, 'attributes' => ['happened'], 'ttl' => 3600])); + $this->fail('An index type the engine does not create must be refused'); + } catch (DatabaseException $error) { + $this->assertSame( + 'Unknown index type: ' . $type->value . '. Must be one of key, unique, fulltext, spatial, object, hnsw_euclidean, hnsw_cosine, hnsw_dot', + $error->getMessage(), + ); + } + + $this->assertSame([], $this->statements); + } + + /** + * @return iterable + */ + public static function invalidPathSegments(): iterable + { + yield 'space' => ['meta.bad key', 'bad key']; + yield 'quote' => ["meta.it's", "it's"]; + yield 'empty' => ['meta..leaf', '']; + yield 'nested' => ['meta.inner.bad;drop', 'bad;drop']; + } + + #[DataProvider('invalidPathSegments')] + public function testANestedObjectIndexPathWithAnInvalidSegmentIsRefused(string $path, string $segment): void + { + try { + $this->adapter()->createIndex('books', Index::object('meta_index', $path), [$path => ColumnType::Object->value]); + $this->fail('A nested index path with an invalid segment must be refused'); + } catch (DatabaseException $error) { + $this->assertSame('Invalid JSON key ' . $segment, $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + public function testANestedObjectIndexPathIsIndexedAsText(): void + { + $this->adapter()->createIndex('books', Index::object('meta_index', 'meta.inner.leaf-key'), ['meta.inner.leaf-key' => ColumnType::Object->value]); + + $this->assertSame( + ['CREATE INDEX "namespace__books_meta_index" ON "database"."namespace_books" USING GIN ((("meta"->\'inner\'->>\'leaf-key\')::text))'], + $this->statements, + ); + } + + public function testUpdatingAnArrayAttributeKeepsItsColumnJsonb(): void + { + $this->adapter()->updateAttribute('books', 'tags', Attribute::string('tags', size: 64, array: true)); + + $this->assertSame('ALTER TABLE "database"."namespace_books" ALTER COLUMN "tags" TYPE JSONB', $this->statements[0] ?? ''); + } + + private function relationship(string $key, RelationshipType $type, bool $twoWay, RelationshipSide $side): Attribute + { + return Attribute::fromArray(['key' => $key, 'type' => ColumnType::Relationship, 'options' => [ + 'relatedCollection' => 'related_' . $key, + 'relationType' => $type->value, + 'twoWay' => $twoWay, + 'twoWayKey' => 'back_' . $key, + 'side' => $side->value, + ]]); + } + + private function adapter(): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/PostgresStatementTest.php b/tests/unit/Adapter/PostgresStatementTest.php new file mode 100644 index 0000000000..f51318a619 --- /dev/null +++ b/tests/unit/Adapter/PostgresStatementTest.php @@ -0,0 +1,187 @@ + */ + private array $statements = []; + + /** @var list */ + private array $sessionStatements = []; + + /** + * @return iterable + */ + public static function nonPositiveTimeouts(): iterable + { + yield 'zero' => [0, Event::All]; + yield 'negative' => [-1, Event::All]; + yield 'zero for one event' => [0, Event::DocumentFind]; + } + + #[DataProvider('nonPositiveTimeouts')] + public function testNonPositiveTimeoutIsRejected(int $milliseconds, Event $event): void + { + $adapter = $this->adapter(); + $adapter->setTimeout(400, Event::DocumentFind); + + try { + $adapter->setTimeout($milliseconds, $event); + $this->fail('A timeout that is not positive must be rejected'); + } catch (DatabaseException $error) { + $this->assertSame('Timeout must be greater than 0', $error->getMessage()); + } + + $this->assertSame(0, $adapter->getTimeout()); + $this->assertSame(400, $adapter->getTimeout(Event::DocumentFind)); + $this->assertSame([], $this->sessionStatements); + } + + public function testAFailedTimeoutResetAfterASuccessfulStatementReachesTheCaller(): void + { + $reset = new PDOException('reset failed'); + $adapter = $this->adapter(resetFailure: $reset); + $adapter->setTimeout(250); + + $error = null; + try { + $adapter->rawQuery('SELECT 1'); + } catch (Throwable $caught) { + $error = $caught; + } + $this->assertSame($reset, $error, 'A timeout left on the session must not be hidden'); + + $this->assertSame(["SET statement_timeout = '250ms'", 'RESET statement_timeout'], $this->sessionStatements); + } + + public function testAFailedStatementKeepsItsOwnErrorWhenTheTimeoutResetAlsoFails(): void + { + $adapter = $this->adapter(resetFailure: new PDOException('reset failed'), statementFailure: $this->engineError('22003', 'integer out of range')); + $adapter->setTimeout(250); + + try { + $adapter->rawQuery('SELECT 1'); + $this->fail('The failed statement must reach the caller'); + } catch (LimitException $error) { + $this->assertSame('Numeric value out of range', $error->getMessage()); + } + + $this->assertSame(["SET statement_timeout = '250ms'", 'RESET statement_timeout'], $this->sessionStatements); + } + + /** + * @return iterable + */ + public static function limitErrors(): iterable + { + yield 'numeric out of range' => ['22003', 'integer out of range', 'Numeric value out of range']; + yield 'datetime overflow' => ['22008', 'timestamp out of range', 'Datetime field overflow']; + } + + #[DataProvider('limitErrors')] + public function testAnOutOfRangeWriteIsALimitError(string $state, string $message, string $expected): void + { + $adapter = $this->adapter(statementFailure: $this->engineError($state, $message)); + + try { + $adapter->updateDocuments( + new Document(['$id' => 'scores', 'attributes' => []]), + new Document(['value' => Operator::increment(1)]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + ); + $this->fail('An out-of-range write must be refused'); + } catch (LimitException $error) { + $this->assertSame($expected, $error->getMessage()); + $this->assertInstanceOf(PDOException::class, $error->getPrevious()); + } + } + + /** + * @return iterable + */ + public static function nonNumericExponents(): iterable + { + yield 'word' => ['two']; + yield 'boolean' => [true]; + yield 'list' => [[2]]; + } + + #[DataProvider('nonNumericExponents')] + public function testPowerWithANonNumericExponentIsRefusedBeforeAStatementIsSent(mixed $exponent): void + { + try { + $this->adapter()->updateDocuments( + new Document(['$id' => 'scores', 'attributes' => []]), + new Document(['value' => new Operator(OperatorType::Power, 'value', [$exponent])]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + ); + $this->fail('A power exponent that is not a number must be refused'); + } catch (OperatorException $error) { + $this->assertSame('Power exponent must be numeric', $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + private function engineError(string $state, string $message): PDOException + { + $error = new class ('SQLSTATE[' . $state . ']: ' . $message, $state) extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $error->errorInfo = [$state, 7, $message]; + + return $error; + } + + private function adapter(?PDOException $resetFailure = null, ?PDOException $statementFailure = null): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statementFailure): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('fetchAll')->willReturn([]); + $statement->method('rowCount')->willReturn(1); + if ($statementFailure === null) { + $statement->method('execute')->willReturn(true); + } else { + $statement->method('execute')->willThrowException($statementFailure); + } + + return $statement; + }); + $pdo->method('exec')->willReturnCallback(function (string $statement) use ($resetFailure): int { + $this->sessionStatements[] = $statement; + if ($resetFailure !== null && $statement === 'RESET statement_timeout') { + throw $resetFailure; + } + + return 0; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/PostgresVectorCursorTest.php b/tests/unit/Adapter/PostgresVectorCursorTest.php new file mode 100644 index 0000000000..8f8a86e0da --- /dev/null +++ b/tests/unit/Adapter/PostgresVectorCursorTest.php @@ -0,0 +1,125 @@ + */ + private array $statements = []; + + /** @var list */ + private array $bound = []; + + public function testAVectorCursorWithoutItsDistanceIsRefused(): void + { + $this->assertRefused('Vector cursor is missing its distance', ['rank'], ['rank' => 3]); + } + + public function testAVectorCursorWithoutAnOrderAttributeIsRefused(): void + { + $this->assertRefused('Vector cursor requires a unique order attribute', [], ['$distance' => 0.25]); + } + + public function testAVectorCursorMissingAnOrderValueIsRefused(): void + { + $this->assertRefused("Vector cursor is missing order attribute 'rank'", ['rank', '$sequence'], ['$sequence' => '5', '$distance' => 0.25]); + } + + public function testAVectorCursorMissingALaterOrderValueIsRefused(): void + { + $this->assertRefused("Vector cursor is missing order attribute '\$sequence'", ['rank', '$sequence'], ['rank' => 3, '$distance' => 0.25]); + } + + /** + * @return iterable + */ + public static function pages(): iterable + { + yield 'descending after' => [OrderDirection::Desc, CursorDirection::After, '>', '<']; + yield 'descending before' => [OrderDirection::Desc, CursorDirection::Before, '<', '>']; + yield 'ascending after' => [OrderDirection::Asc, CursorDirection::After, '>', '>']; + yield 'ascending before' => [OrderDirection::Asc, CursorDirection::Before, '<', '<']; + } + + #[DataProvider('pages')] + public function testAVectorCursorComparesTheDistanceThenTheOrderInThePageDirection(OrderDirection $order, CursorDirection $direction, string $distanceOperator, string $rankOperator): void + { + $this->find(['rank'], [$order], ['rank' => 3, '$distance' => 0.25], $direction); + + $this->assertCount(1, $this->statements); + $where = $this->statements[0]; + $this->assertStringContainsString(') ' . $distanceOperator . ' ?', $where); + $this->assertStringContainsString('"table_main"."rank" ' . $rankOperator . ' ?', $where); + $this->assertContains('0.25', $this->bound); + $this->assertContains(3, $this->bound); + } + + /** + * @param list $orderAttributes + * @param array $cursor + */ + private function assertRefused(string $message, array $orderAttributes, array $cursor): void + { + try { + $this->find($orderAttributes, \array_fill(0, \count($orderAttributes), OrderDirection::Asc), $cursor, CursorDirection::After); + $this->fail('The vector cursor must be refused'); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + /** + * @param list $orderAttributes + * @param list $orderTypes + * @param array $cursor + */ + private function find(array $orderAttributes, array $orderTypes, array $cursor, CursorDirection $direction): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bound[] = $value; + + return true; + }); + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document(['$id' => 'items']), + [Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + limit: 2, + orderAttributes: $orderAttributes, + orderTypes: $orderTypes, + cursor: $cursor, + cursorDirection: $direction, + ); + } +} diff --git a/tests/unit/Adapter/PowerNumericTextTest.php b/tests/unit/Adapter/PowerNumericTextTest.php new file mode 100644 index 0000000000..4906790372 --- /dev/null +++ b/tests/unit/Adapter/PowerNumericTextTest.php @@ -0,0 +1,182 @@ + */ + private array $statements = []; + + /** @var list */ + private array $bound = []; + + /** + * @return iterable}> + */ + public static function exponents(): iterable + { + yield 'integer text' => ['2', [2]]; + yield 'decimal text' => ['0.5', [0.5]]; + yield 'negative text' => ['-1', [-1]]; + yield 'exponent text' => ['1e1', [10.0]]; + yield 'non-numeric text' => ['two', ['two']]; + } + + /** + * @param list $values + */ + #[DataProvider('exponents')] + public function testANumericTextExponentIsReadAsANumber(string $exponent, array $values): void + { + $this->assertSame($values, Operator::power($exponent)->getValues()); + $this->assertSame($values[0], Operator::power($exponent)->getValue()); + $this->assertSame($values, Operator::parse((string) \json_encode(['method' => 'power', 'attribute' => 'count', 'values' => [$exponent]]))->getValues()); + } + + public function testTheLimitOfAPowerIsLeftAsGiven(): void + { + $this->assertSame([2, '18446744073709551615'], Operator::power('2', '18446744073709551615')->getValues()); + $this->assertSame(['method' => 'power', 'attribute' => '', 'values' => ['2']], Operator::power('2')->toArray()); + } + + /** + * @return iterable + */ + public static function databases(): iterable + { + yield 'SQLite' => ['SQLite']; + yield 'Memory' => ['Memory']; + } + + #[DataProvider('databases')] + public function testANumericTextExponentIsAppliedAndNonNumericTextRefused(string $engine): void + { + $database = $this->database($engine === 'SQLite' ? new SQLite(new PDO('sqlite::memory:')) : new Memory()); + + $updated = $database->updateDocument('items', 'first', new Document([ + 'count' => Operator::power('2'), + 'ratio' => Operator::power('2'), + ])); + $this->assertSame(9, $updated->getAttribute('count')); + $this->assertSame(2.25, $updated->getAttribute('ratio')); + + $updated = $database->updateDocument('items', 'first', new Document(['count' => Operator::power('2', '50')])); + $this->assertSame(9, $updated->getAttribute('count')); + + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $database->upsertDocument('items', new Document(['$id' => 'created', 'count' => Operator::power('2')])); + $this->assertSame(16, $database->getDocument('items', 'created')->getAttribute('count')); + } + + try { + $database->updateDocument('items', 'first', new Document(['count' => Operator::power('two')])); + $this->fail('A non-numeric exponent must be refused'); + } catch (StructureException $error) { + $this->assertStringContainsString('value must be numeric', $error->getMessage()); + } + + $this->assertSame(9, $database->getDocument('items', 'first')->getAttribute('count')); + } + + public function testRedisAppliesANumericTextExponent(): void + { + $adapter = new class ($this->createStub(Redis::class)) extends RedisAdapter { + public function applied(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }; + + $this->assertSame(9, $adapter->applied(3, Operator::power('2'))); + $this->assertSame(3, $adapter->applied(3, Operator::power('2', '5'))); + } + + /** + * @return iterable}> + */ + public static function engines(): iterable + { + yield 'MariaDB' => [MariaDB::class]; + yield 'MySQL' => [MySQL::class]; + yield 'Postgres' => [Postgres::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testANumericTextExponentIsSentAsANumber(string $class): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('rowCount')->willReturn(1); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bound[] = $value; + + return true; + }); + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + $adapter->updateDocuments( + new Document(['$id' => 'items', 'attributes' => []]), + new Document(['count' => Operator::power('2')]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + ); + + $this->assertCount(1, $this->statements); + $this->assertStringContainsString('POWER(', $this->statements[0]); + $this->assertContains(2, $this->bound); + $this->assertNotContains('2', $this->bound); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database->setDatabase('power_text')->setNamespace('power_text')->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create( + id: 'items', + attributes: [Attribute::integer('count', default: 4), Attribute::float('ratio')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $database->createDocument('items', new Document(['$id' => 'first', 'count' => 3, 'ratio' => 1.5])); + + return $database; + } +} diff --git a/tests/unit/Adapter/ProfileTest.php b/tests/unit/Adapter/ProfileTest.php new file mode 100644 index 0000000000..390061283b --- /dev/null +++ b/tests/unit/Adapter/ProfileTest.php @@ -0,0 +1,171 @@ +limits(), + [Capability::Joins, Capability::IndexKey], + [Feature\Spatial::class], + true, + false, + ); + + $this->assertTrue($profile->supports(Capability::Joins)); + $this->assertTrue($profile->supports(Capability::IndexKey)); + $this->assertFalse($profile->supports(Capability::Aggregations)); + $this->assertTrue($profile->hasFeature(Feature\Spatial::class)); + $this->assertFalse($profile->hasFeature(Feature\Upserts::class)); + $this->assertTrue($profile->sharedTables); + $this->assertFalse($profile->migrating); + } + + public function testTheDatabaseProfileIsWhatItsAdapterSupports(): void + { + $adapter = new MariaDB(new stdClass()); + $profile = $this->database($adapter)->profile(); + + foreach (Capability::cases() as $capability) { + $this->assertSame($adapter->supports($capability), $profile->supports($capability), $capability->name); + } + $this->assertTrue($profile->hasFeature(Feature\Spatial::class)); + $this->assertTrue($profile->hasFeature(Feature\Upserts::class)); + $this->assertFalse($profile->hasFeature(Feature\Schemaless::class)); + $this->assertSame($adapter->limits(), $profile->limits); + $this->assertFalse($profile->sharedTables); + $this->assertFalse($profile->migrating); + } + + public function testSharedTablesShrinkTheIndexKeyOnlyAfterTheyAreTurnedOn(): void + { + $database = $this->database(new MariaDB(new stdClass())); + $attributes = [Attribute::string(key: 'title', size: 768)]; + $index = Index::key(key: 'title_key', attributes: ['title']); + + $this->assertFalse($database->profile()->sharedTables); + $this->assertTrue(new IndexDefinition($attributes, [], $database->profile())->isValid($index)); + + $database->setSharedTables(true); + + $this->assertTrue($database->profile()->sharedTables); + $this->assertSame(767, $database->profile()->limits->indexLength); + $validator = new IndexDefinition($attributes, [], $database->profile()); + $this->assertFalse($validator->isValid($index)); + $this->assertSame('Index length is longer than the maximum: 767', $validator->getDescription()); + + $database->setSharedTables(false); + + $this->assertTrue(new IndexDefinition($attributes, [], $database->profile())->isValid($index)); + } + + public function testTheTenantIsSelectableOnlyAfterSharedTablesAreTurnedOn(): void + { + $database = $this->database(new Memory()); + $select = [Query::select(['$tenant'])]; + + $this->assertFalse(new Documents([], [], $database->profile())->isValid($select)); + + $database->setSharedTables(true); + + $this->assertTrue(new Documents([], [], $database->profile())->isValid($select)); + } + + public function testASchemaColumnClashesOnlyUntilASharedTableMigrationStarts(): void + { + $database = $this->database(new MariaDB(new stdClass()))->setSharedTables(true); + $schema = [new Column(name: 'orphan', type: 'VARCHAR(16)', length: 16, nullable: true)]; + $attribute = Attribute::string(key: 'orphan', size: 16); + + $this->assertFalse($database->profile()->migrating); + try { + (new AttributeDefinition([], $database->profile(), $schema))->isValid($attribute); + $this->fail('A column the schema already holds must clash outside a migration'); + } catch (DuplicateException $exception) { + $this->assertSame('Attribute already exists in schema', $exception->getMessage()); + } + + $database->setMigrating(true); + + $this->assertTrue($database->profile()->migrating); + $this->assertTrue((new AttributeDefinition([], $database->profile(), $schema))->isValid($attribute)); + + $database->setMigrating(false); + + $this->assertFalse($database->profile()->migrating); + $this->expectException(DuplicateException::class); + (new AttributeDefinition([], $database->profile(), $schema))->isValid($attribute); + } + + public function testUndeclaredAttributesAreQueryableOnlyAfterTheSchemalessModeIsOn(): void + { + $adapter = $this->mongo(); + $database = $this->database($adapter); + $filter = [Query::equal('undeclared', ['value'])]; + + $this->assertTrue($database->profile()->supports(Capability::DefinedAttributes)); + $this->assertFalse(new Documents([], [], $database->profile())->isValid($filter)); + + $this->assertSame($database, $database->setSchemaless(true)); + + $this->assertTrue($adapter->isSchemaless()); + $this->assertFalse($database->profile()->supports(Capability::DefinedAttributes)); + $this->assertTrue(new Documents([], [], $database->profile())->isValid($filter)); + + $database->setSchemaless(false); + + $this->assertFalse($adapter->isSchemaless()); + $this->assertTrue($database->profile()->supports(Capability::DefinedAttributes)); + $this->assertFalse(new Documents([], [], $database->profile())->isValid($filter)); + } + + public function testAnAdapterWithoutASchemalessModeOnlyKeepsItsSchema(): void + { + $database = $this->database(new Memory()); + + $this->assertSame($database, $database->setSchemaless(false)); + $this->assertTrue($database->profile()->supports(Capability::DefinedAttributes)); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support schemaless'); + $database->setSchemaless(true); + } + + private function database(Adapter $adapter): Database + { + return new Database($adapter, new Cache(new None())); + } + + private function mongo(): Mongo + { + return new class () extends Mongo { + public function __construct() + { + } + }; + } +} diff --git a/tests/unit/Adapter/ProfilerProbeAdapter.php b/tests/unit/Adapter/ProfilerProbeAdapter.php new file mode 100644 index 0000000000..d948c1d4d8 --- /dev/null +++ b/tests/unit/Adapter/ProfilerProbeAdapter.php @@ -0,0 +1,37 @@ +profiled = $this->getProfiler(); + + return true; + } + + #[\Override] + public function reconnect(): void + { + } + + #[\Override] + public function id(): string + { + return 'probe'; + } + + #[\Override] + public function hostname(): string + { + return ''; + } +} diff --git a/tests/unit/Adapter/QueryCommentsTest.php b/tests/unit/Adapter/QueryCommentsTest.php new file mode 100644 index 0000000000..d0ea2da32d --- /dev/null +++ b/tests/unit/Adapter/QueryCommentsTest.php @@ -0,0 +1,379 @@ + + */ + private array $statements = []; + + private SQLite $adapter; + + private Database $database; + + #[\Override] + protected function setUp(): void + { + $this->adapter = new SQLite($this->recordingConnection()); + $this->database = $this->open($this->adapter); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'movies', + attributes: [ + Attribute::string('title', size: 128), + Attribute::integer('year'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + )); + $this->database->createDocument('movies', new Document([ + '$id' => 'dune', + 'title' => 'Dune', + 'year' => 1965, + ])); + + $this->statements = []; + } + + public function testTransformSeesTheCommentsAheadOfTheStatementInInsertionOrder(): void + { + $transform = new class () implements Transform { + /** + * @var array + */ + public array $queries = []; + + #[\Override] + public function transform(Event $event, string $query): string + { + $this->queries[$event->value] = $query; + + return $query; + } + }; + $this->database->addHook($transform); + + $this->database + ->setMetadata('host', 'worker-1') + ->setMetadata('project', 'console') + ->setMetadata('user', 'user-1') + ->setMetadata('host', 'worker-2'); + + $this->assertSame('Dune', $this->database->getDocument('movies', 'dune')->getAttribute('title')); + $this->assertStringStartsWith( + "/* host: worker-2 */\n/* project: console */\n/* user: user-1 */\n", + $transform->queries[Event::DocumentRead->value] ?? '', + ); + } + + public function testResetMetadataRemovesTheComments(): void + { + $this->database->setMetadata('user', 'user-1'); + $this->assertCount(1, $this->database->find('movies')); + $this->assertEveryStatementStartsWith("/* user: user-1 */\n"); + + $this->database->resetMetadata(); + $this->statements = []; + + $this->assertCount(1, $this->database->find('movies')); + $this->assertNoStatementCarriesComments(); + } + + public function testStatementsPreparedWithoutAnEventCarryTheComments(): void + { + $this->database->setMetadata('user', 'user-1'); + + $this->assertPingCarries('/* user: user-1 */'); + + $this->statements = []; + $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->rawQuery('SELECT ? AS answer', [42])); + + $this->assertSame(42, $rows[0]->getAttribute('answer')); + $this->assertEveryStatementStartsWith("/* user: user-1 */\n"); + } + + /** + * @return array, string}> + */ + public static function connectionIdFunctions(): array + { + return [ + 'MariaDB' => [MariaDB::class, 'CONNECTION_ID'], + 'MySQL' => [MySQL::class, 'CONNECTION_ID'], + 'Postgres' => [Postgres::class, 'pg_backend_pid'], + ]; + } + + /** + * @param class-string $class + */ + #[DataProvider('connectionIdFunctions')] + public function testConnectionIdQueryCarriesTheComments(string $class, string $function): void + { + $connection = $this->recordingConnection(); + $connection->createFunction($function, static fn (): int => 7, 0); + $adapter = new $class($connection); + $adapter->setMetadata('user', 'user-1'); + + $this->assertSame('7', $adapter->id()); + $this->assertEveryStatementStartsWith("/* user: user-1 */\n"); + } + + public function testEveryStatementCarriesTheComments(): void + { + $this->database->setMetadata('user', 'user-1'); + + $this->database->createAttribute('movies', Attribute::string('director', size: 64)); + $this->database->createIndex('movies', Index::key('year_index', ['year'])); + $this->database->createDocument('movies', new Document([ + '$id' => 'arrival', + 'title' => 'Arrival', + 'year' => 2016, + 'director' => 'Villeneuve', + ])); + $this->database->withTransaction(fn (): Document => $this->database->updateDocument( + 'movies', + 'dune', + new Document(['director' => 'Villeneuve']), + )); + $this->database->increaseDocumentAttribute('movies', 'arrival', 'year', 1); + + $this->assertCount(2, $this->database->find('movies', [Query::equal('director', ['Villeneuve'])])); + $this->assertSame(2, $this->database->count('movies')); + $this->assertSame(1965 + 2017, $this->database->sum('movies', 'year')); + $this->assertTrue($this->database->deleteDocument('movies', 'arrival')); + $this->database->deleteCollection('movies'); + + $this->assertEveryStatementStartsWith("/* user: user-1 */\n"); + } + + /** + * @return array + */ + public static function delimiterMetadata(): array + { + return [ + 'value closing the comment' => ['user', 'user-1 */ tail', '/* user: user-1 * / tail */'], + 'key closing the comment' => ['user */ tail', 'user-1', '/* user * / tail: user-1 */'], + 'value opening a nested comment' => ['user', '/* user-1', '/* user: / * user-1 */'], + 'key opening a nested comment' => ['/* user', 'user-1', '/* / * user: user-1 */'], + 'overlapping delimiters' => ['user', '/*/*//**/', '/* user: / * / * // ** / */'], + 'line breaks' => ['user', "user-1\r\n*/ tail", '/* user: user-1 * / tail */'], + 'NUL byte' => ['user', "user-1\0*/", '/* user: user-1 * / */'], + 'unicode line separator' => ['user', "user\u{2028}1", '/* user: user 1 */'], + 'invalid UTF-8' => ['user', "user-\xB1", '/* user: user-? */'], + 'placeholders and quotes' => ['user', "O'Brien :year ? \"1\"", "/* user: O'Brien :year ? \"1\" */"], + ]; + } + + #[DataProvider('delimiterMetadata')] + public function testMetadataStaysInsideItsComment(string $key, string $value, string $comment): void + { + $this->database->setMetadata($key, $value); + + $this->database->createDocument('movies', new Document([ + '$id' => 'arrival', + 'title' => 'Arrival', + 'year' => 2016, + ])); + $this->database->updateDocument('movies', 'dune', new Document(['year' => 2021])); + + $this->assertSame('Arrival', $this->database->getDocument('movies', 'arrival')->getAttribute('title')); + $this->assertCount(1, $this->database->find('movies', [Query::equal('year', [2021])])); + $this->assertSame(2, $this->database->count('movies')); + $this->assertEveryStatementStartsWith($comment."\n"); + + $this->database->resetMetadata(); + + $this->assertSame(2021, $this->database->getDocument('movies', 'dune')->getAttribute('year')); + $this->assertCount(2, $this->database->find('movies')); + } + + public function testMetadataValuesAreRenderedAsText(): void + { + $stringable = new class () implements Stringable { + #[\Override] + public function __toString(): string + { + return 'region */ one'; + } + }; + + $this->database + ->setMetadata('integer', 42) + ->setMetadata('float', 1.5) + ->setMetadata('true', true) + ->setMetadata('false', false) + ->setMetadata('null', null) + ->setMetadata('list', ['a', 'b*/']) + ->setMetadata('map', ['path' => 'a/b', 'id' => 7]) + ->setMetadata('stringable', $stringable) + ->setMetadata('object', (object) ['user' => "user\n1"]); + + $this->assertPingCarries( + "/* integer: 42 */\n" + ."/* float: 1.5 */\n" + ."/* true: 1 */\n" + ."/* false: */\n" + ."/* null: null */\n" + ."/* list: [\"a\",\"b* /\"] */\n" + ."/* map: {\"path\":\"a/b\",\"id\":7} */\n" + ."/* stringable: region * / one */\n" + ."/* object: {\"user\":\"user\\n1\"} */", + ); + } + + public function testEachStatementCarriesTheMetadataAsItIsThen(): void + { + $stringable = new class () implements Stringable { + public string $text = 'first'; + + #[\Override] + public function __toString(): string + { + return $this->text; + } + }; + + $this->database->setMetadata('user', 'user-1'); + $this->assertPingCarries('/* user: user-1 */'); + $this->assertPingCarries('/* user: user-1 */'); + $this->database->setMetadata('user', 'user-2'); + $this->assertPingCarries('/* user: user-2 */'); + $this->database->setMetadata('user', 2); + $this->assertPingCarries('/* user: 2 */'); + $this->database->resetMetadata(); + $this->assertPingCarries(null); + $this->database->setMetadata('region', $stringable); + $this->assertPingCarries('/* region: first */'); + $stringable->text = 'second'; + $this->assertPingCarries('/* region: second */'); + } + + public function testPooledConnectionCarriesOnlyTheCurrentHandlesMetadata(): void + { + $first = $this->open($this->pool()); + $second = $this->open($this->pool()); + + $first->setMetadata('user', 'user-1'); + + $this->assertPingCarries('/* user: user-1 */', $first); + $this->assertPingCarries(null, $second); + $this->assertPingCarries('/* user: user-1 */', $first); + } + + /** + * @param non-empty-string|null $comments + */ + private function assertPingCarries(?string $comments, ?Database $database = null): void + { + $this->statements = []; + $this->assertTrue(($database ?? $this->database)->ping()); + + if ($comments === null) { + $this->assertNoStatementCarriesComments(); + + return; + } + + $this->assertEveryStatementStartsWith($comments."\n"); + } + + /** + * @param non-empty-string $prefix + */ + private function assertEveryStatementStartsWith(string $prefix): void + { + $this->assertNotEmpty($this->statements); + foreach ($this->statements as $statement) { + $this->assertStringStartsWith($prefix, $statement); + $this->assertStringNotContainsString('/*', \substr($statement, \strlen($prefix)), 'Only the current metadata may be written as comments'); + } + } + + private function assertNoStatementCarriesComments(): void + { + $this->assertNotEmpty($this->statements); + foreach ($this->statements as $statement) { + $this->assertStringNotContainsString('/*', $statement, 'A statement without metadata must carry no comments'); + } + } + + private function open(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + + return $database; + } + + private function pool(): Pool + { + return new Pool(new UtopiaPool(new Stack(), 'sqlite', 1, fn (): SQLite => $this->adapter, timeout: 0.0)); + } + + private function recordingConnection(): SQLitePDO + { + $record = function (string $statement): void { + $this->statements[] = $statement; + }; + + return new class ($record) extends SQLitePDO { + public function __construct(private readonly Closure $record) + { + parent::__construct('sqlite::memory:'); + } + + /** + * @param array $options + */ + #[Override] + public function prepare(string $query, array $options = []): PDOStatement|false + { + ($this->record)($query); + + return parent::prepare($query, $options); + } + }; + } +} diff --git a/tests/unit/Adapter/ReadWritePoolMetadataTest.php b/tests/unit/Adapter/ReadWritePoolMetadataTest.php new file mode 100644 index 0000000000..fa81f7200e --- /dev/null +++ b/tests/unit/Adapter/ReadWritePoolMetadataTest.php @@ -0,0 +1,377 @@ +}> + */ + public static function metadataCalls(): iterable + { + $calls = [ + 'supports' => [Capability::IndexKey], + 'capabilities' => [], + 'getAttributeWidth' => [new Document()], + 'getCountOfAttributes' => [new Document()], + 'getCountOfIndexes' => [new Document()], + 'builder' => [], + 'schema' => [], + 'getColumnType' => [Attribute::string(key: 'title', size: 255)], + 'encode' => [[1, 2], ColumnType::Point], + 'decode' => ['POINT(1 2)', ColumnType::Point], + 'castBefore' => [new Document(), new Document()], + 'castAfter' => [new Document(), [new Document()]], + 'castDatetime' => ['2026-09-23 00:00:00'], + ]; + + foreach ($calls as $method => $args) { + yield $method => [$method, $args]; + } + } + + /** + * @return iterable}> + */ + public static function callsThatNeedThePrimary(): iterable + { + $calls = [ + 'getDriver' => [], + 'getSequences' => [new Document(['$id' => 'posts']), []], + 'analyzeCollection' => ['posts'], + 'startTransaction' => [], + 'commitTransaction' => [], + 'rollbackTransaction' => [], + ]; + + foreach ($calls as $method => $args) { + yield $method => [$method, $args]; + } + } + + /** + * @param non-empty-string $method + * @param array $args + */ + #[DataProvider('metadataCalls')] + public function testMetadataCallIsAnsweredWhereReadsGoWithoutOpeningTheStickyWindow(string $method, array $args): void + { + $primary = $this->createMock(CastingAdapterStub::class); + $replica = $this->createMock(CastingAdapterStub::class); + $pool = $this->createPool($primary, $replica); + + $answer = $replica->expects($this->once())->method($method); + if ($method === 'builder') { + $answer->willReturn(new SQLiteBuilder()); + } + $primary->expects($this->never())->method($method); + $replica->expects($this->once())->method('ping')->willReturn(true); + $primary->expects($this->never())->method('ping'); + + $pool->delegate($method, $args); + + $this->assertTrue($pool->ping(), "{$method}() sent the next read to the primary"); + } + + /** + * The pool asks each connection it borrows whether it takes timeouts, so the delegated question is told apart + * from that probe by the feature it names. + */ + public function testFeatureQuestionIsAnsweredWhereReadsGoWithoutOpeningTheStickyWindow(): void + { + $primary = $this->createMock(CastingAdapterStub::class); + $replica = $this->createMock(CastingAdapterStub::class); + $pool = $this->createPool($primary, $replica); + + $asked = []; + $replica->method('hasFeature')->willReturnCallback(static function (string $feature) use (&$asked): bool { + $asked[] = $feature; + + return false; + }); + $primary->expects($this->never())->method('hasFeature'); + $replica->expects($this->once())->method('ping')->willReturn(true); + $primary->expects($this->never())->method('ping'); + + $pool->delegate('hasFeature', [Feature\Spatial::class]); + + $this->assertTrue($pool->ping(), 'hasFeature() sent the next read to the primary'); + $this->assertSame([Feature\Spatial::class], \array_values(\array_filter( + $asked, + static fn (string $feature): bool => $feature !== Feature\Timeouts::class, + ))); + } + + /** + * @param non-empty-string $method + * @param array $args + */ + #[DataProvider('callsThatNeedThePrimary')] + public function testCallThatNeedsThePrimaryRunsThereAndOpensTheStickyWindow(string $method, array $args): void + { + $primary = $this->createMock(CastingAdapterStub::class); + $replica = $this->createMock(CastingAdapterStub::class); + $pool = $this->createPool($primary, $replica); + + $primary->expects($this->once())->method($method); + $replica->expects($this->never())->method($method); + $primary->expects($this->once())->method('ping')->willReturn(true); + $replica->expects($this->never())->method('ping'); + + $pool->delegate($method, $args); + + $this->assertTrue($pool->ping(), "{$method}() did not keep the next read on the primary"); + } + + public function testHostnameComesFromTheWritePoolWithoutOpeningTheStickyWindow(): void + { + $primary = $this->createMock(CastingAdapterStub::class); + $replica = $this->createMock(CastingAdapterStub::class); + + $primary->expects($this->exactly(3))->method('hostname')->willReturn('primary'); + $replica->expects($this->never())->method('hostname'); + $primary->method('createDocument')->willReturn(new Document()); + $primary->method('withTransaction')->willReturnCallback( + static fn (callable $callback): mixed => $callback(), + ); + $primary->method('withTenant')->willReturnCallback( + static fn (int|string|null $tenant, callable $callback): mixed => $callback(), + ); + $replica->expects($this->once())->method('ping')->willReturn(true); + $primary->expects($this->never())->method('ping'); + + $outside = $this->createPool($primary, $replica); + $this->assertSame('primary', $outside->hostname()); + $this->assertTrue($outside->ping(), 'Naming the host sent the next read to the primary'); + + $inside = $this->createPool($primary, $replica); + $inside->createDocument(new Document(), new Document()); + $this->assertSame('primary', $inside->hostname(), 'Inside the sticky window the hostname must still name the write pool'); + + $pinned = $this->createPool($primary, $replica); + $this->assertSame('primary', $pinned->withTransaction(static fn (): string => $pinned->hostname())); + } + + public function testHostnameIsLookedUpOncePerHandle(): void + { + $primary = $this->createMock(CastingAdapterStub::class); + $replica = $this->createMock(CastingAdapterStub::class); + $pool = $this->createPool($primary, $replica); + + $primary->expects($this->once())->method('hostname')->willReturn('primary'); + $replica->expects($this->never())->method('hostname'); + + $this->assertSame('primary', $pool->hostname()); + $this->assertSame('primary', $pool->hostname()); + } + + public function testReadsReachTheReplicaAfterTheStickyWindowWhileCacheKeysNameTheHost(): void + { + $database = $this->createReplicatedDatabase(new HostnameSQLite('primary'), new HostnameSQLite('replica')); + $database->setQueryCache(new ResultCache(new Cache(new MemoryCache()))); + + $this->assertSame( + 'primary', + $database->getHostname(), + 'The cache keys must name the host, as they do on MariaDB, MySQL, PostgreSQL and MongoDB', + ); + + $database->createDocument('posts', new Document(['$id' => 'draft', 'server' => 'primary'])); + + $this->assertFalse( + $database->getDocument('posts', 'draft')->isEmpty(), + 'A read inside the sticky window was served by a replica that has not received the write', + ); + + \usleep((self::STICKY_MILLISECONDS + 50) * 1000); + + $this->assertSame( + 'replica', + $database->getDocument('posts', 'post')->getAttribute('server'), + 'getDocument() computed its cache key through getHostname(), which kept the read on the primary', + ); + $this->assertSame( + ['replica', 'replica'], + $this->servers($database->find('posts')), + 'find() computed its cache key through getHostname(), which kept the read on the primary', + ); + } + + public function testLimitsAreAnsweredWhereReadsGoWithoutOpeningTheStickyWindow(): void + { + $primary = $this->createMock(CastingAdapterStub::class); + $replica = $this->createMock(CastingAdapterStub::class); + $pool = $this->createPool($primary, $replica); + $limits = (new Memory())->limits(); + + $replica->expects($this->once())->method('limits')->willReturn($limits); + $primary->expects($this->never())->method('limits'); + $replica->expects($this->once())->method('ping')->willReturn(true); + $primary->expects($this->never())->method('ping'); + + $this->assertSame($limits, $pool->limits()); + $this->assertTrue($pool->ping(), 'limits() sent the next read to the primary'); + } + + public function testConfiguringTheSchemalessModeKeepsReadsOnTheReplica(): void + { + $database = $this->createReplicatedDatabase($this->schemalessMemory(), $this->schemalessMemory()); + + $database->setSchemaless(false); + + $this->assertSame( + 'replica', + $database->getDocument('posts', 'post')->getAttribute('server'), + 'setSchemaless() sent the next read to the primary', + ); + } + + private function schemalessMemory(): Memory + { + return new class () extends Memory implements Feature\Schemaless { + private bool $schemaless = false; + + #[\Override] + public function setSchemaless(bool $schemaless): static + { + $this->schemaless = $schemaless; + + return $this; + } + + #[\Override] + public function isSchemaless(): bool + { + return $this->schemaless; + } + }; + } + + public function testCastingKeepsReadsOnTheReplica(): void + { + $database = $this->createReplicatedDatabase(new CastingMemory(), new CastingMemory()); + + $post = $database->getDocument('posts', 'post'); + + $this->assertSame('replica', $post->getAttribute('server'), 'castAfter() sent the read to the primary'); + $this->assertSame( + ['replica', 'replica'], + $this->servers($database->find('posts', [Query::greaterThan('$createdAt', '2000-01-01T00:00:00.000+00:00')])), + 'castDatetime() sent the read to the primary', + ); + $this->assertSame( + ['replica'], + $this->servers($database->find('posts', [Query::cursorAfter($post)])), + 'castBefore() sent the read to the primary', + ); + } + + private function createReplicatedDatabase(Adapter $primary, Adapter $replica): Database + { + $this->seed($primary, 'primary'); + $this->seed($replica, 'replica'); + + $pool = new ReadWritePool($this->createConnections($primary), $this->createConnections($replica)); + $pool->setStickyDuration(self::STICKY_MILLISECONDS); + + $database = new Database($pool, new Cache(new MemoryCache())); + $database + ->setDatabase(self::DATABASE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + + return $database; + } + + private function seed(Adapter $adapter, string $server): void + { + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase(self::DATABASE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create( + id: 'posts', + attributes: [Attribute::string(key: 'server', size: 32)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + foreach (['post', 'page'] as $id) { + $database->createDocument('posts', new Document(['$id' => $id, 'server' => $server])); + } + } + + /** + * @param CastingAdapterStub&MockObject $primary + * @param CastingAdapterStub&MockObject $replica + */ + private function createPool(CastingAdapterStub $primary, CastingAdapterStub $replica): ReadWritePool + { + $pool = new ReadWritePool($this->createConnections($primary), $this->createConnections($replica)); + $pool->setAuthorization(new Authorization()); + + return $pool; + } + + /** + * @return UtopiaPool + */ + private function createConnections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + return $connections; + } + + /** + * @param array $documents + * @return list + */ + private function servers(array $documents): array + { + return \array_map( + static fn (Document $document): mixed => $document->getAttribute('server'), + \array_values($documents), + ); + } +} diff --git a/tests/unit/Adapter/ReadWritePoolRoutingTest.php b/tests/unit/Adapter/ReadWritePoolRoutingTest.php new file mode 100644 index 0000000000..cd1ca72d31 --- /dev/null +++ b/tests/unit/Adapter/ReadWritePoolRoutingTest.php @@ -0,0 +1,97 @@ +routes(); + $known = $this->adapterMethods(); + $stale = []; + + foreach ($routes as $list => $methods) { + $this->assertNotSame([], $methods, "{$list} is empty"); + + foreach ($methods as $method) { + if (! isset($known[$method])) { + $stale[] = "{$list}: {$method}"; + } + } + } + + $this->assertSame([], $stale, 'ReadWritePool routes method names that no adapter or feature declares'); + } + + public function testNoMethodIsRoutedTwice(): void + { + $counts = \array_count_values(\array_merge(...\array_values($this->routes()))); + $duplicates = \array_keys(\array_filter($counts, static fn (int $count): bool => $count > 1)); + + $this->assertSame([], $duplicates, 'A method in more than one routing list has no single destination'); + } + + /** + * @return array> + */ + private function routes(): array + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + + $pool = new class ($connections, $connections) extends ReadWritePool { + /** + * @return array> + */ + public function routes(): array + { + return [ + 'READ_METHODS' => self::READ_METHODS, + 'METADATA_METHODS' => self::METADATA_METHODS, + 'WRITE_POOL_METADATA_METHODS' => self::WRITE_POOL_METADATA_METHODS, + ]; + } + }; + + return $pool->routes(); + } + + /** + * @return array + */ + private function adapterMethods(): array + { + $known = []; + + foreach ((new ReflectionClass(Adapter::class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $known[$method->getName()] = true; + } + + foreach ((new ReflectionClass(Pool::class))->getMethods(\ReflectionMethod::IS_PROTECTED) as $method) { + if ($method->getDeclaringClass()->getName() === Pool::class && (new ReflectionClass(Adapter::class))->hasMethod($method->getName())) { + $known[$method->getName()] = true; + } + } + + $directory = \dirname((string) (new ReflectionClass(Adapter::class))->getFileName()) . '/Adapter/Feature'; + + foreach (\glob($directory . '/*.php') ?: [] as $file) { + /** @var class-string $feature */ + $feature = 'Utopia\\Database\\Adapter\\Feature\\' . \basename($file, '.php'); + + foreach ((new ReflectionClass($feature))->getMethods() as $method) { + $known[$method->getName()] = true; + } + } + + return $known; + } +} diff --git a/tests/unit/Adapter/ReadWritePoolTest.php b/tests/unit/Adapter/ReadWritePoolTest.php new file mode 100644 index 0000000000..34d72ec088 --- /dev/null +++ b/tests/unit/Adapter/ReadWritePoolTest.php @@ -0,0 +1,737 @@ +&Stub */ + private UtopiaPool $writePool; + + /** @var UtopiaPool&Stub */ + private UtopiaPool $readPool; + + private ReadWritePool $pool; + + /** @var FeatureAdapterStub&Stub */ + private Adapter $writeAdapter; + + /** @var FeatureAdapterStub&Stub */ + private Adapter $readAdapter; + + #[\Override] + protected function setUp(): void + { + $this->writeAdapter = self::createStub(FeatureAdapterStub::class); + $this->readAdapter = self::createStub(FeatureAdapterStub::class); + + $this->writePool = self::createStub(UtopiaPool::class); + $this->readPool = self::createStub(UtopiaPool::class); + + $this->writePool->method('use')->willReturnCallback(function (callable $callback) { + return $callback($this->writeAdapter); + }); + + $this->readPool->method('use')->willReturnCallback(function (callable $callback) { + return $callback($this->readAdapter); + }); + + $this->pool = new ReadWritePool($this->writePool, $this->readPool); + $this->pool->setAuthorization(new Authorization()); + } + + private function mockWriteAdapter(): FeatureAdapterStub&MockObject + { + $adapter = $this->createMock(FeatureAdapterStub::class); + $this->writeAdapter = $adapter; + + return $adapter; + } + + private function mockReadAdapter(): FeatureAdapterStub&MockObject + { + $adapter = $this->createMock(FeatureAdapterStub::class); + $this->readAdapter = $adapter; + + return $adapter; + } + + public function testReadMethodsRouteToReadPool(): void + { + $readAdapter = $this->mockReadAdapter(); + $readMethods = [ + 'find', + 'getDocument', + 'count', + 'sum', + 'exists', + 'collectionExists', + 'list', + 'getSchemaAttributes', + 'getSchemaIndexes', + 'builder', + 'schema', + 'getColumnType', + 'encode', + 'decode', + 'getSizeOfCollection', + 'getSizeOfCollectionOnDisk', + 'ping', + 'id', + 'getAttributeWidth', + 'getCountOfAttributes', + 'getCountOfIndexes', + 'limits', + 'supports', + 'capabilities', + 'hasFeature', + ]; + + foreach ($readMethods as $method) { + $readAdapter->expects($this->atLeastOnce()) + ->method($method) + ->willReturn($this->getDefaultReturnForMethod($method)); + } + + foreach ($readMethods as $method) { + $args = $this->getDefaultArgsForMethod($method); + $this->pool->delegate($method, $args); + } + } + + public function testWriteMethodRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('createDocument') + ->willReturn(new Document()); + + $this->pool->delegate('createDocument', [new Document(), new Document()]); + } + + public function testDeleteDocumentRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('deleteDocument') + ->willReturn(true); + + $this->pool->delegate('deleteDocument', [new Document(), 'id']); + } + + public function testUpdateDocumentRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('updateDocument') + ->willReturn(new Document()); + + $this->pool->delegate('updateDocument', [new Document(), 'id', new Document(), false]); + } + + public function testCreateCollectionRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('createCollection') + ->willReturn(true); + + $this->pool->delegate('createCollection', ['testCollection', [], []]); + } + + public function testStickyModeRoutesReadsToWritePoolAfterWrite(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $this->pool->setSticky(true); + $this->pool->setStickyDuration(5000); + + $writeAdapter->expects($this->once()) + ->method('createDocument') + ->willReturn(new Document()); + + $this->pool->delegate('createDocument', [new Document(), new Document()]); + + $writeAdapter->expects($this->once()) + ->method('find') + ->willReturn([]); + + $result = $this->pool->delegate('find', [new Document(), [], 25, 0, [], [], [], \Utopia\Query\CursorDirection::After, \Utopia\Database\PermissionType::Read]); + $this->assertSame([], $result); + } + + public function testStickyDurationExpiry(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $this->pool->setSticky(true); + $this->pool->setStickyDuration(1); + + $writeAdapter->expects($this->once()) + ->method('createDocument') + ->willReturn(new Document()); + + $this->pool->delegate('createDocument', [new Document(), new Document()]); + + usleep(2000); + + $readAdapter->expects($this->once()) + ->method('ping') + ->willReturn(true); + + $result = $this->pool->delegate('ping', []); + $this->assertTrue($result); + } + + public function testReadReplicaReceivesWriteHooks(): void + { + // The permission side-table hook is a write hook, but Mongo decides whether to + // apply its read-side permission filter by asking the adapter whether that hook + // is present. A replica that never receives it answers no and reads unfiltered. + $hook = new Permissions(); + $this->pool->addWriteHook($hook); + + $received = []; + $this->readAdapter->method('getWriteHooks')->willReturnCallback(fn (): array => $received); + $this->readAdapter->method('addWriteHook')->willReturnCallback( + function (Write $hook) use (&$received): Adapter { + $received[] = $hook; + + return $this->readAdapter; + } + ); + + $this->pool->setSticky(false); + $this->pool->find(new Document(['$id' => 'posts']), []); + + $this->assertSame([$hook], $received, 'read replica did not receive the pool\'s write hooks'); + } + + public function testStickyDisabledRoutesReadNormally(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $this->pool->setSticky(false); + + $writeAdapter->expects($this->once()) + ->method('createDocument') + ->willReturn(new Document()); + + $this->pool->delegate('createDocument', [new Document(), new Document()]); + + $readAdapter->expects($this->once()) + ->method('ping') + ->willReturn(true); + + $result = $this->pool->delegate('ping', []); + $this->assertTrue($result); + } + + public function testSetStickyDurationIsChainable(): void + { + $result = $this->pool->setStickyDuration(3000); + $this->assertSame($this->pool, $result); + } + + public function testSetStickyIsChainable(): void + { + $result = $this->pool->setSticky(true); + $this->assertSame($this->pool, $result); + } + + public function testReadAfterMultipleWritesStaysSticky(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $this->pool->setSticky(true); + $this->pool->setStickyDuration(5000); + + $this->writeAdapter->method('createDocument') + ->willReturn(new Document()); + $this->writeAdapter->method('deleteDocument') + ->willReturn(true); + + $this->pool->delegate('createDocument', [new Document(), new Document()]); + $this->pool->delegate('deleteDocument', [new Document(), 'id']); + + $writeAdapter->expects($this->once()) + ->method('ping') + ->willReturn(true); + + $result = $this->pool->delegate('ping', []); + $this->assertTrue($result); + } + + public function testReadBeforeAnyWriteGoesToReadPool(): void + { + $readAdapter = $this->mockReadAdapter(); + $this->pool->setSticky(true); + $this->pool->setStickyDuration(5000); + + $readAdapter->expects($this->once()) + ->method('ping') + ->willReturn(true); + + $result = $this->pool->delegate('ping', []); + $this->assertTrue($result); + } + + public function testPinnedAdapterResyncsTenantAndDatabaseBeforeDelegatedCall(): void + { + $writeAdapter = new Memory(); + $writeAdapter->setDatabase('old_db'); + $writeAdapter->setNamespace('old_ns'); + $writeAdapter->setTenant(1); + + $readAdapter = new Memory(); + + $writePool = self::createStub(UtopiaPool::class); + $writePool->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($writeAdapter), + ); + + $readPool = self::createStub(UtopiaPool::class); + $readPool->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($readAdapter), + ); + + $pool = new ReadWritePool($writePool, $readPool); + $pool->setAuthorization(new Authorization()); + $pool->setDatabase('old_db'); + $pool->setNamespace('old_ns'); + $pool->setTenant(1); + + $pool->withTransaction(function () use ($pool, $writeAdapter): void { + $pool->setDatabase('new_db'); + $pool->setNamespace('new_ns'); + $pool->setTenant(2); + + $pool->list(); + $this->assertSame('new_db', $writeAdapter->getDatabase()); + $this->assertSame('new_ns', $writeAdapter->getNamespace()); + $this->assertSame(2, $writeAdapter->getTenant()); + }); + } + + public function testReadAdapterClearsStaleTimeout(): void + { + /** @var Adapter&Feature\Timeouts&MockObject $readAdapter */ + $readAdapter = $this->createMock(FeatureAdapterStub::class); + $readAdapter->method('hasFeature')->willReturnCallback( + static fn (string $feature): bool => $feature === Feature\Timeouts::class, + ); + $readAdapter->expects($this->once()) + ->method('clearTimeout'); + $readAdapter->expects($this->once()) + ->method('ping') + ->willReturn(true); + + $readPool = self::createStub(UtopiaPool::class); + $readPool->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($readAdapter), + ); + + $pool = new ReadWritePool($this->writePool, $readPool); + $pool->setAuthorization(new Authorization()); + + $this->assertTrue($pool->delegate('ping', [])); + } + + public function testNonReadNonStandardMethodGoesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('createAttribute') + ->willReturn(true); + + $attr = Attribute::string(key: 'test', size: 128); + $this->pool->delegate('createAttribute', ['collection', $attr]); + } + + public function testCreateIndexRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('createIndex') + ->willReturn(true); + + $index = Index::key(key: 'idx', attributes: ['col']); + $this->pool->delegate('createIndex', ['collection', $index, [], []]); + } + + public function testDeleteCollectionRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('deleteCollection') + ->willReturn(true); + + $this->pool->delegate('deleteCollection', ['collection']); + } + + public function testRawMutationRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('rawMutation') + ->willReturn(1); + + $this->pool->delegate('rawMutation', ['UPDATE t SET a = 1', []]); + } + + public function testReadAfterTransactionalWriteRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $this->writeAdapter->method('withTransaction')->willReturnCallback( + static fn (callable $callback): mixed => $callback(), + ); + $this->writeAdapter->method('withTenant')->willReturnCallback( + static fn (int|string|null $tenant, callable $callback): mixed => $callback(), + ); + $this->writeAdapter->method('createDocument')->willReturn(new Document()); + $writeAdapter->expects($this->once())->method('find')->willReturn([]); + $readAdapter->expects($this->never())->method('find'); + + $this->pool->withTransaction(fn (): Document => $this->pool->createDocument(new Document(), new Document())); + + $this->pool->find(new Document()); + } + + public function testStickinessRunsFromTheCommitRatherThanTheWrite(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $this->pool->setStickyDuration(200); + + $this->writeAdapter->method('withTransaction')->willReturnCallback( + static fn (callable $callback): mixed => $callback(), + ); + $this->writeAdapter->method('withTenant')->willReturnCallback( + static fn (int|string|null $tenant, callable $callback): mixed => $callback(), + ); + $this->writeAdapter->method('createDocument')->willReturn(new Document()); + $writeAdapter->expects($this->once())->method('find')->willReturn([]); + $readAdapter->expects($this->never())->method('find'); + + $this->pool->withTransaction(function (): void { + $this->pool->createDocument(new Document(), new Document()); + \usleep(250_000); + }); + + $this->pool->find(new Document()); + } + + public function testStickinessRunsFromTheEndOfAWrite(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $this->pool->setStickyDuration(200); + + $this->writeAdapter->method('createDocument')->willReturnCallback(static function (): Document { + \usleep(250_000); + + return new Document(); + }); + $writeAdapter->expects($this->once())->method('find')->willReturn([]); + $readAdapter->expects($this->never())->method('find'); + + $this->pool->createDocument(new Document(), new Document()); + + $this->pool->find(new Document()); + } + + public function testGetDocumentForUpdateRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $writeAdapter->expects($this->exactly(3))->method('getDocument')->willReturn(new Document()); + $readAdapter->expects($this->never())->method('getDocument'); + + $this->pool->setSticky(false); + $this->pool->getDocument(new Document(), 'id', [], true); + $this->pool->getDocument(new Document(), 'id', forUpdate: true); + $this->pool->delegate('getDocument', ['collection' => new Document(), 'id' => 'id', 'forUpdate' => true]); + } + + public function testGetDocumentWithoutLockRoutesToReadPool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $readAdapter->expects($this->once())->method('getDocument')->willReturn(new Document()); + $writeAdapter->expects($this->never())->method('getDocument'); + + $this->pool->getDocument(new Document(), 'id', [], false); + } + + public function testRawQueryRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $readAdapter = $this->mockReadAdapter(); + $writeAdapter->expects($this->once())->method('rawQuery')->willReturn([]); + $readAdapter->expects($this->never())->method('rawQuery'); + + $this->pool->setSticky(false); + $this->pool->rawQuery('UPDATE posts SET title = ?', ['draft']); + } + + public function testDocumentWrittenThroughDatabaseIsReadBackFromThePrimary(): void + { + $primary = new Memory(); + $replica = new Memory(); + $this->createSchema($primary); + $this->createSchema($replica); + + $database = $this->createReplicatedDatabase($primary, $replica); + $database->createDocument('posts', new Document(['$id' => 'post'])); + + $this->assertFalse( + $database->getDocument('posts', 'post')->isEmpty(), + 'A read straight after a committed write was served by a replica that has not received the row', + ); + } + + public function testLockingReadThroughDatabaseIsServedByThePrimary(): void + { + $primary = new Memory(); + $replica = new Memory(); + $this->createSchema($primary)->createDocument('posts', new Document(['$id' => 'post'])); + $this->createSchema($replica); + + $database = $this->createReplicatedDatabase($primary, $replica); + + $this->assertFalse( + $database->getDocument('posts', 'post', forUpdate: true)->isEmpty(), + 'A locking read was served by a replica, where the lock protects nothing', + ); + } + + public function testReplicaDoesNotKeepTheProfilerAfterARead(): void + { + $replica = new ProfilerProbeAdapter(); + $pool = new ReadWritePool($this->createConnections(new Memory()), $this->createConnections($replica)); + $pool->setAuthorization(new Authorization()); + $profiler = new Profiler(); + $pool->setProfiler($profiler); + + $this->assertTrue($pool->ping()); + + $this->assertSame($profiler, $replica->profiled, 'The replica must profile the read it served'); + $this->assertNull($replica->getProfiler(), 'The replica kept the profiler of the handle that borrowed it'); + } + + private function createSchema(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase('replication') + ->setNamespace('replication') + ->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create( + id: 'posts', + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + return $database; + } + + private function createReplicatedDatabase(Adapter $primary, Adapter $replica): Database + { + $pool = new ReadWritePool($this->createConnections($primary), $this->createConnections($replica)); + + $database = new Database($pool, new Cache(new NoCache())); + $database + ->setDatabase('replication') + ->setNamespace('replication') + ->setAuthorization(new Authorization()); + + return $database; + } + + /** + * @return UtopiaPool + */ + private function createConnections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + return $connections; + } + + /** + * @return mixed + */ + private function getDefaultReturnForMethod(string $method): mixed + { + return match ($method) { + 'find', 'list' => [], + 'getDocument' => new Document(), + 'count', 'sum', 'getSizeOfCollection', 'getSizeOfCollectionOnDisk', + 'getAttributeWidth', 'getCountOfAttributes', 'getCountOfIndexes' => 0, + 'exists', 'collectionExists', 'ping', 'supports', 'hasFeature' => true, + 'id' => 'string', + 'limits' => (new Memory())->limits(), + 'getSchemaAttributes', 'getSchemaIndexes', 'capabilities', 'decode' => [], + 'encode' => 'POINT(1 2)', + 'builder' => new \Utopia\Database\Builder\SQLite(), + 'schema' => $this->createStub(\Utopia\Query\Schema::class), + 'getColumnType' => 'VARCHAR(255)', + default => null, + }; + } + + /** + * @return array + */ + private function getDefaultArgsForMethod(string $method): array + { + return match ($method) { + 'find' => [new Document(), [], 25, 0, [], [], [], \Utopia\Query\CursorDirection::After, \Utopia\Database\PermissionType::Read], + 'getDocument' => [new Document(), 'id', [], false], + 'count' => [new Document(), [], null], + 'sum' => [new Document(), 'attr', [], null], + 'exists' => ['db'], + 'collectionExists' => ['db', 'collection'], + 'list' => [], + 'getSizeOfCollection', 'getSizeOfCollectionOnDisk' => ['collection'], + 'ping' => [], + 'id' => [], + 'getAttributeWidth' => [new Document()], + 'getCountOfAttributes' => [new Document()], + 'getCountOfIndexes' => [new Document()], + 'supports' => [\Utopia\Database\Capability::IndexKey], + 'hasFeature' => [Feature\Spatial::class], + 'getSchemaAttributes', 'getSchemaIndexes' => ['collection'], + 'builder' => [], + 'getColumnType' => [\Utopia\Database\Attribute::string(key: 'title', size: 255)], + 'encode' => [[1, 2], \Utopia\Query\Schema\ColumnType::Point], + 'decode' => ['wkb', \Utopia\Query\Schema\ColumnType::Point], + default => [], + }; + } + + public function testAReplicaReadFillsNoCache(): void + { + $primary = new Memory(); + $replica = new Memory(); + $this->createArticles($primary, 'v2'); + $replication = $this->createArticles($replica, 'v1'); + + $pool = new ReadWritePool($this->createConnections($primary), $this->createConnections($replica)); + $database = new Database($pool, new Cache(new MemoryCache())); + $database + ->setDatabase('replication') + ->setNamespace('replication') + ->setAuthorization(new Authorization()); + $database->setQueryCache(new Query(new Cache(new MemoryCache()))); + $titles = static fn (): array => \array_map( + static fn (Document $article): mixed => $article->getAttribute('title'), + $database->find('articles'), + ); + + $this->assertSame('v1', $database->getDocument('articles', 'article')->getAttribute('title')); + $this->assertSame(['v1'], $titles()); + $this->assertTrue($database->getDocument('articles', 'created')->isEmpty()); + + $replication->updateDocument('articles', 'article', new Document(['title' => 'v2'])); + $replication->createDocument('articles', new Document(['$id' => 'created', 'title' => 'v2'])); + + $this->assertSame('v2', $database->getDocument('articles', 'article')->getAttribute('title'), 'A document a lagging replica served must not be cached for later reads'); + $this->assertSame(['v2', 'v2'], $titles(), 'A query result a lagging replica served must not be cached for later reads'); + $this->assertFalse($database->getDocument('articles', 'created')->isEmpty(), 'A miss a lagging replica served must not be cached for later reads'); + } + + public function testEachCoroutineReportsWhereItsOwnReadWasServed(): void + { + $this->readAdapter->method('getDocument')->willReturn(new Document()); + $this->writeAdapter->method('getDocument')->willReturn(new Document()); + $pool = $this->pool; + $observed = null; + $hookFlags = Runtime::getHookFlags(); + + try { + run(static function () use ($pool, &$observed): void { + $read = new Channel(1); + $locked = new Channel(1); + + Coroutine::create(static function () use ($pool, $read, $locked, &$observed): void { + $pool->getDocument(new Document(), 'id', [], false); + $read->push(true); + $locked->pop(); + $observed = $pool->servedByReplica(); + }); + + Coroutine::create(static function () use ($pool, $read, $locked): void { + $read->pop(); + $pool->getDocument(new Document(), 'id', [], true); + $locked->push(true); + }); + }); + } finally { + Runtime::setHookFlags($hookFlags); + } + + $this->assertTrue($observed, "A locking read on another coroutine must not change where this coroutine's read was served"); + } + + private function createArticles(Adapter $adapter, string $title): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase('replication') + ->setNamespace('replication') + ->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create( + id: 'articles', + attributes: [Attribute::string(key: 'title')], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + $database->createDocument('articles', new Document([ + '$id' => 'article', + 'title' => $title, + ])); + + return $database; + } +} diff --git a/tests/unit/Adapter/ReadWritePoolWriteDecisionTest.php b/tests/unit/Adapter/ReadWritePoolWriteDecisionTest.php new file mode 100644 index 0000000000..3858a6f547 --- /dev/null +++ b/tests/unit/Adapter/ReadWritePoolWriteDecisionTest.php @@ -0,0 +1,126 @@ +primary = new SQLite(new PDO('sqlite::memory:')); + $replica = new SQLite(new PDO('sqlite::memory:')); + + foreach ([$this->primary, $replica] as $adapter) { + $server = $this->createDatabase($adapter); + $server->create(); + $server->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'expiry', size: 32)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + $server->createDocument(self::COLLECTION, new Document(['$id' => 'session', 'expiry' => '2025'])); + } + + $pool = new ReadWritePool($this->createConnections($this->primary), $this->createConnections($replica)); + $pool->setSticky(false); + $this->database = $this->createDatabase($pool); + } + + public function testDeleteDocumentsSelectsItsBatchOnThePrimary(): void + { + $this->createDatabase($this->primary)->updateDocument(self::COLLECTION, 'session', new Document(['expiry' => '2099'])); + + $deleted = $this->database->deleteDocuments(self::COLLECTION, [Query::lessThan('expiry', '2026')]); + + $this->assertSame(0, $deleted, 'The batch was selected on a replica that has not seen the renewal'); + $this->assertSame('2099', $this->readFromThePrimary('session')->getAttribute('expiry')); + } + + public function testUpdateDocumentsSelectsItsBatchOnThePrimary(): void + { + $this->createDatabase($this->primary)->updateDocument(self::COLLECTION, 'session', new Document(['expiry' => '2099'])); + + $updated = $this->database->updateDocuments( + self::COLLECTION, + new Document(['expiry' => '2030']), + [Query::lessThan('expiry', '2026')], + ); + + $this->assertSame(0, $updated, 'The batch was selected on a replica that has not seen the renewal'); + $this->assertSame('2099', $this->readFromThePrimary('session')->getAttribute('expiry')); + } + + public function testUpsertComparesAgainstTheDocumentOnThePrimary(): void + { + $this->createDatabase($this->primary)->updateDocument(self::COLLECTION, 'session', new Document(['expiry' => '2099'])); + + $upserted = $this->database->upsertDocuments(self::COLLECTION, [new Document(['$id' => 'session', 'expiry' => '2025'])]); + + $this->assertSame(1, $upserted, 'The existing document was read from a replica that has not seen the renewal'); + $this->assertSame('2025', $this->readFromThePrimary('session')->getAttribute('expiry')); + } + + private function readFromThePrimary(string $id): Document + { + return $this->createDatabase($this->primary)->getDocument(self::COLLECTION, $id); + } + + private function createDatabase(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase(self::DATABASE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + + return $database; + } + + /** + * @return UtopiaPool + */ + private function createConnections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + return $connections; + } +} diff --git a/tests/unit/Adapter/RedisAdapterPathsTest.php b/tests/unit/Adapter/RedisAdapterPathsTest.php new file mode 100644 index 0000000000..78452fe7a7 --- /dev/null +++ b/tests/unit/Adapter/RedisAdapterPathsTest.php @@ -0,0 +1,712 @@ + */ + private array $strings = []; + + /** @var array> */ + private array $sets = []; + + /** @var array> */ + private array $hashes = []; + + private bool $pipelining = false; + + /** @var list */ + private array $queued = []; + + /** @var list */ + private array $hashWrites = []; + + #[\Override] + protected function setUp(): void + { + $this->authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + $this->client = $this->fakeClient(); + } + + public function testOneToManyKeyRenamedFromTheChildSide(): void + { + $database = $this->petsDatabase(RelationshipType::OneToMany, key: 'pets', twoWayKey: 'owner'); + $database->createDocument('owners', new Document(['$id' => 'alice', 'name' => 'Alice', 'pets' => [ + new Document(['$id' => 'rex', 'name' => 'Rex']), + ]])); + + $database->updateRelationship('pets', 'owner', new RelationshipUpdate(key: 'keeper')); + + $pet = $database->getDocument('pets', 'rex'); + $this->assertNull($pet->getAttribute('owner')); + $this->assertSame('alice', $this->idOf($pet->getAttribute('keeper'))); + $this->assertSame(['rex'], $this->idsOf($database->getDocument('owners', 'alice')->getAttribute('pets'))); + } + + public function testManyToOneTwoWayKeyRenamedFromTheChildSide(): void + { + $database = $this->petsDatabase(RelationshipType::ManyToOne, key: 'owner', twoWayKey: 'pets', from: 'pets', to: 'owners'); + $database->createDocument('owners', new Document(['$id' => 'alice', 'name' => 'Alice'])); + $database->createDocument('pets', new Document(['$id' => 'rex', 'name' => 'Rex', 'owner' => 'alice'])); + + $database->updateRelationship('owners', 'pets', new RelationshipUpdate(twoWayKey: 'master')); + + $pet = $database->getDocument('pets', 'rex'); + $this->assertNull($pet->getAttribute('owner')); + $this->assertSame('alice', $this->idOf($pet->getAttribute('master'))); + $this->assertSame(['rex'], $this->idsOf($database->getDocument('owners', 'alice')->getAttribute('pets'))); + } + + public function testUniqueIndexOverExistingDuplicatesIsRefusedByTheAdapter(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + foreach (['first', 'second'] as $id) { + $adapter->createDocument($this->notes(), new Document(['$id' => $id, '$permissions' => [], 'title' => 'same'])); + } + + try { + $adapter->createIndex(self::NOTES, Index::unique(key: 'unique_title', attributes: ['title'])); + $this->fail('A unique index over duplicate values must be refused'); + } catch (UniqueException $exception) { + $this->assertSame(UniqueException::MESSAGE, $exception->getMessage()); + } + + $this->assertTrue($adapter->createIndex(self::NOTES, Index::key(key: 'by_title', attributes: ['title']))); + try { + $adapter->createIndex(self::NOTES, Index::key(key: 'by_title', attributes: ['title'])); + $this->fail('An index id that is already recorded must be refused'); + } catch (DuplicateException $exception) { + $this->assertSame('Index already exists', $exception->getMessage()); + } + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + $adapter->createIndex('missing', Index::key(key: 'by_title', attributes: ['title'])); + } + + public function testNoRolesSeeNoDocumentsInADocumentSecurityCollection(): void + { + $database = $this->database(); + $database->create(); + $database->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + $this->assertSame([], $database->find(self::NOTES), 'An empty collection has no ids to filter'); + + $database->createDocument(self::NOTES, new Document(['$id' => 'public', 'title' => 'a', '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument(self::NOTES, new Document(['$id' => 'private', 'title' => 'b', '$permissions' => [Permission::read(Role::user('bob'))]])); + + $this->assertSame(['public'], $this->idsOf($database->find(self::NOTES))); + $this->assertSame(['private', 'public'], $this->sorted($this->authorization->skip(fn (): array => $this->idsOf($database->find(self::NOTES))))); + + $this->authorization->cleanRoles(); + try { + $this->assertSame([], $database->find(self::NOTES), 'A caller with no roles must see no documents'); + $this->assertSame(0, $database->count(self::NOTES)); + } finally { + $this->authorization->addRole(Role::any()->toString()); + } + } + + public function testRollbackRestoresARenamedDocument(): void + { + $database = $this->database(); + $database->create(); + $database->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $database->createDocument(self::NOTES, new Document(['$id' => 'a', 'title' => 'original'])); + + $rethrown = false; + try { + $database->withTransaction(function () use ($database): void { + $database->updateDocument(self::NOTES, 'a', new Document(['$id' => 'b', 'title' => 'renamed'])); + throw new \RuntimeException('roll back'); + }); + } catch (\RuntimeException $exception) { + $rethrown = $exception->getMessage() === 'roll back'; + } + $this->assertTrue($rethrown, 'The transaction must rethrow'); + + $this->assertSame('original', $database->getDocument(self::NOTES, 'a')->getAttribute('title')); + $this->assertTrue($database->getDocument(self::NOTES, 'b')->isEmpty()); + $this->assertSame([], $database->find(self::NOTES, [Query::equal('$id', ['b'])])); + $this->assertSame(['a'], $this->idsOf($database->find(self::NOTES))); + } + + public function testRollbackRefusesAnUnknownJournalEntry(): void + { + $adapter = new class ($this->client) extends RedisAdapter { + public function journalUnknownEntry(): void + { + $this->journal('unknown', []); + } + }; + + $adapter->startTransaction(); + $adapter->journalUnknownEntry(); + + $this->expectException(TransactionException::class); + $this->expectExceptionMessage('Unknown journal op: unknown'); + $adapter->rollbackTransaction(); + } + + public function testRenamingAManyToManyKeyOfATenantlessDefinition(): void + { + $database = $this->database()->setSharedTables(true)->setTenant(null); + $database->create(); + foreach (['books', 'authors'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + $database->createRelationship('books', Relationship::manyToMany( + relatedCollection: 'authors', + twoWay: true, + key: 'authors', + twoWayKey: 'books', + )); + $junction = '_'.$database->getCollection('books')->getSequence().'_'.$database->getCollection('authors')->getSequence(); + + $adapter = $database->getAdapter(); + $this->assertInstanceOf(RedisAdapter::class, $adapter); + $adapter->setTenant(self::TENANT); + $adapter->createDocument(new Document(['$id' => $junction]), new Document([ + '$id' => 'link', + '$permissions' => [], + '$tenant' => self::TENANT, + 'authors' => 'ann', + 'books' => 'dune', + ])); + + $this->assertTrue($adapter->updateRelationship( + 'books', + Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'authors', twoWayKey: 'books'), + RelationshipSide::Parent, + new RelationshipUpdate(key: 'writers'), + )); + + $link = $adapter->getDocument(new Document(['$id' => $junction]), 'link'); + $this->assertSame('ann', $link->getAttribute('writers'), 'The junction of a tenantless definition must be found from a tenant'); + $this->assertNull($link->getAttribute('authors')); + } + + public function testNullChecksAndUnsupportedMethodsOnAWholeObjectAttribute(): void + { + $database = $this->database(); + $database->create(); + $database->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::object(key: 'meta')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument(self::NOTES, new Document(['$id' => 'filled', 'meta' => ['colour' => 'red']])); + $database->createDocument(self::NOTES, new Document(['$id' => 'empty', 'meta' => null])); + + $this->assertSame(['empty'], $this->idsOf($database->find(self::NOTES, [Query::isNull('meta')]))); + $this->assertSame(['filled'], $this->idsOf($database->find(self::NOTES, [Query::isNotNull('meta')]))); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Query method lessThan not supported for object attributes'); + $database->skipValidation(fn (): array => $database->find(self::NOTES, [Query::lessThan('meta', 'x')])); + } + + public function testSchemaChangesOnACollectionWithoutStorage(): void + { + $adapter = $this->adapter(); + + $this->assertTrue($adapter->deleteIndex('missing', 'by_title')); + $this->assertTrue($adapter->createRelationship('missing', Relationship::oneToOne(relatedCollection: 'gone', twoWay: true, key: 'partner', twoWayKey: 'partnerOf'))); + $this->assertSame(0, $adapter->getSizeOfCollection('missing')); + $this->assertSame([], $this->hashWrites, 'A collection without storage must not be written to'); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + $adapter->renameIndex('missing', 'by_title', 'by_name'); + } + + public function testRenamingAnIndexTheCollectionDoesNotRecordWritesNothingAndReportsNothingRenamed(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + $adapter->createIndex(self::NOTES, Index::key(key: 'by_title', attributes: ['title'])); + $this->hashWrites = []; + + $this->assertFalse($adapter->renameIndex(self::NOTES, 'absent', 'other')); + $this->assertSame([], $this->hashWrites); + + $this->assertTrue($adapter->renameIndex(self::NOTES, 'by_title', 'by_name')); + $this->assertCount(1, $this->recordedHashWrites()); + } + + public function testRenamingAnIndexTheCollectionAlreadyRenamedReportsItRenamed(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + $adapter->createIndex(self::NOTES, Index::key(key: 'by_name', attributes: ['title'])); + $this->hashWrites = []; + + $this->assertTrue($adapter->renameIndex(self::NOTES, 'by_title', 'by_name')); + $this->assertSame([], $this->hashWrites); + } + + public function testGetSequencesBackFillsOnlyTheDocumentsThatLackOne(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + $stored = $adapter->createDocument($this->notes(), new Document(['$id' => 'stored', '$permissions' => [], 'title' => 'a'])); + + $this->assertSame([], $adapter->getSequences(new Document(['$id' => self::NOTES]), [])); + + $documents = $adapter->getSequences(new Document(['$id' => self::NOTES]), [ + new Document(['$id' => 'stored']), + new Document(['$id' => 'missing']), + new Document(['$id' => 'given', '$sequence' => '99']), + ]); + + $this->assertSame($stored->getSequence(), $documents[0]->getSequence()); + $this->assertEmpty($documents[1]->getSequence()); + $this->assertSame('99', $documents[2]->getSequence()); + + $complete = $adapter->getSequences(new Document(['$id' => self::NOTES]), [new Document(['$id' => 'given', '$sequence' => '99'])]); + $this->assertSame('99', $complete[0]->getSequence()); + } + + public function testGetSequencesReportsAFailingPipeline(): void + { + $client = self::createStub(Redis::class); + $client->method('multi')->willReturnSelf(); + $client->method('get')->willReturnSelf(); + $client->method('exec')->willThrowException(new \RedisException('connection lost')); + + $this->expectException(TransactionException::class); + $this->expectExceptionMessage('Failed to load sequences: connection lost'); + (new RedisAdapter($client))->getSequences(new Document(['$id' => self::NOTES]), [new Document(['$id' => 'first'])]); + } + + public function testIncrementGuardsOfTheAdapter(): void + { + $adapter = $this->adapter(); + $adapter->createCollection(self::NOTES, [Attribute::double(key: 'count')]); + $adapter->createDocument($this->notes(), new Document(['$id' => 'whole', '$permissions' => [], 'count' => 10])); + $adapter->createDocument($this->notes(), new Document(['$id' => 'fraction', '$permissions' => [], 'count' => 10.5])); + + foreach (['whole' => 10, 'fraction' => 10.5] as $id => $stored) { + $this->assertTrue($adapter->increaseDocumentAttribute(new Document(['$id' => self::NOTES]), $id, 'count', 1, '2026-01-01 00:00:00.000', max: 5)); + $this->assertTrue($adapter->increaseDocumentAttribute(new Document(['$id' => self::NOTES]), $id, 'count', -1, '2026-01-01 00:00:00.000', min: 20)); + $this->assertSame($stored, $adapter->getDocument($this->notes(), $id)->getAttribute('count'), $id); + } + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Document not found'); + $adapter->increaseDocumentAttribute(new Document(['$id' => self::NOTES]), 'vanished', 'count', 1, '2026-01-01 00:00:00.000'); + } + + public function testRenamingAnAttributeOnAnEmptyCollectionOrToItsOwnName(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + + $this->assertTrue($adapter->renameAttribute(self::NOTES, 'title', 'heading')); + $adapter->createDocument($this->notes(), new Document(['$id' => 'first', '$permissions' => [], 'heading' => 'kept'])); + + $this->assertTrue($adapter->renameAttribute(self::NOTES, 'heading', 'heading')); + $this->assertSame('kept', $adapter->getDocument($this->notes(), 'first')->getAttribute('heading')); + } + + public function testUnvalidatedNullCandidateAndUnsupportedMethod(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + foreach (['first' => 'x', 'second' => 'y'] as $id => $title) { + $adapter->createDocument($this->notes(), new Document(['$id' => $id, '$permissions' => [], 'title' => $title])); + } + + $this->assertSame([], $adapter->find($this->notes(), [new Query(Method::NotEqual, 'title', [null, 'x'])]), 'A null candidate makes NOT IN unknown for every row'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Query method not supported by Redis adapter: exists'); + $adapter->find($this->notes(), [Query::exists(['title'])]); + } + + public function testUniqueIndexComparesArrayValuesByContent(): void + { + $adapter = $this->adapter(); + $adapter->createCollection(self::NOTES, [Attribute::string(key: 'tags', size: 16, array: true)]); + $adapter->createIndex(self::NOTES, Index::unique(key: 'unique_tags', attributes: ['tags'])); + $adapter->createDocument($this->notes(), new Document(['$id' => 'first', '$permissions' => [], 'tags' => ['a', 'b']])); + $adapter->createDocument($this->notes(), new Document(['$id' => 'other', '$permissions' => [], 'tags' => ['b', 'a']])); + + $this->expectException(DuplicateException::class); + $adapter->createDocument($this->notes(), new Document(['$id' => 'second', '$permissions' => [], 'tags' => ['a', 'b']])); + } + + public function testFractionalOperatorLimitIsRefusedBeforeTheWrite(): void + { + $database = $this->database(); + $database->create(); + $database->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::integer(key: 'count'), Attribute::bigInteger(key: 'big')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $database->createDocument(self::NOTES, new Document(['$id' => 'counter', 'count' => 100, 'big' => PHP_INT_MAX - 5])); + + try { + $database->updateDocument(self::NOTES, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame("Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4", $exception->getMessage()); + } + $this->assertSame(100, $database->getDocument(self::NOTES, 'counter')->getAttribute('count')); + + $database->updateDocument(self::NOTES, 'counter', new Document(['big' => Operator::increment(10, 9.0e18)])); + $this->assertSame(PHP_INT_MAX - 5, $database->getDocument(self::NOTES, 'counter')->getAttribute('big')); + } + + public function testACursorWithoutAnOrderPagesBySequence(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + foreach (['first', 'second', 'third'] as $id) { + $adapter->createDocument($this->notes(), new Document(['$id' => $id, '$permissions' => [], 'title' => $id])); + } + $cursor = ['$sequence' => $adapter->getDocument($this->notes(), 'second')->getSequence()]; + + $this->assertSame(['third'], $this->idsOf($adapter->find($this->notes(), cursor: $cursor))); + $this->assertSame(['first'], $this->idsOf($adapter->find($this->notes(), cursor: $cursor, cursorDirection: CursorDirection::Before))); + } + + public function testRenamingAManyToManyKeyBetweenCollectionsWithoutDefinitionsRenamesNothing(): void + { + $adapter = $this->adapter(); + $this->createNotes($adapter); + $adapter->createCollection('tags', [Attribute::string(key: 'name', size: 64)]); + $before = [$this->strings, $this->sets, $this->hashes]; + + $this->assertTrue($adapter->updateRelationship( + self::NOTES, + Relationship::manyToMany(relatedCollection: 'tags', twoWay: true, key: 'tags', twoWayKey: 'notes'), + RelationshipSide::Parent, + new RelationshipUpdate(key: 'labels', twoWayKey: 'entries'), + )); + + $this->assertSame($before, [$this->strings, $this->sets, $this->hashes], 'without stored definitions there is no junction to rename'); + } + + private function petsDatabase(RelationshipType $type, string $key, string $twoWayKey, string $from = 'owners', string $to = 'pets'): Database + { + $database = $this->database(); + $database->create(); + foreach (['owners', 'pets'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + } + $database->createRelationship($from, Relationship::fromArray([ + 'relatedCollection' => $to, + 'relationType' => $type, + 'twoWay' => true, + 'key' => $key, + 'twoWayKey' => $twoWayKey, + ])); + + return $database; + } + + private function database(): Database + { + $database = (new Database(new RedisAdapter($this->client), new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase(self::DATABASE) + ->setNamespace(self::NAMESPACE); + $database->addHook(new Relationships()); + + return $database; + } + + private function adapter(): RedisAdapter + { + $authorization = new Authorization(); + $authorization->disable(); + $adapter = new RedisAdapter($this->client); + $adapter->setAuthorization($authorization); + $adapter->setDatabase(self::DATABASE); + $adapter->setNamespace(self::NAMESPACE); + $adapter->create(self::DATABASE); + + return $adapter; + } + + private function createNotes(RedisAdapter $adapter): void + { + $adapter->createCollection(self::NOTES, [Attribute::string(key: 'title', size: 64)]); + } + + private function notes(): Document + { + return new Document(['$id' => self::NOTES]); + } + + private function idOf(mixed $value): ?string + { + return match (true) { + $value instanceof Document => $value->getId(), + \is_string($value) => $value, + default => null, + }; + } + + /** + * @return list + */ + private function idsOf(mixed $documents): array + { + return \array_values(\array_filter(\array_map($this->idOf(...), \is_array($documents) ? $documents : []), \is_string(...))); + } + + /** + * @param list $ids + * @return list + */ + private function sorted(array $ids): array + { + \sort($ids); + + return $ids; + } + + private function fakeClient(): Redis + { + $client = self::createStub(Redis::class); + $client->method('ping')->willReturn(true); + $client->method('multi')->willReturnCallback(function () use ($client): Redis { + $this->pipelining = true; + $this->queued = []; + + return $client; + }); + $client->method('exec')->willReturnCallback(function (): mixed { + $replies = $this->queued; + $this->pipelining = false; + $this->queued = []; + + return $replies; + }); + $client->method('discard')->willReturnCallback(function (): bool { + $this->pipelining = false; + $this->queued = []; + + return true; + }); + $client->method('get')->willReturnCallback(fn (string $key): mixed => $this->reply($client, $this->strings[$key] ?? false)); + $client->method('mGet')->willReturnCallback(fn (mixed $keys): mixed => $this->reply($client, \array_map( + fn (mixed $key): string|false => $this->strings[$this->text($key)] ?? false, + \is_array($keys) ? \array_values($keys) : [], + ))); + $client->method('set')->willReturnCallback(function (string $key, mixed $value) use ($client): mixed { + $this->forget($key); + $this->strings[$key] = $this->text($value); + + return $this->reply($client, true); + }); + $client->method('incr')->willReturnCallback(function (string $key, int $by = 1) use ($client): mixed { + $value = (int) ($this->strings[$key] ?? 0) + $by; + $this->strings[$key] = (string) $value; + + return $this->reply($client, $value); + }); + $client->method('exists')->willReturnCallback(fn (mixed ...$keys): mixed => $this->reply( + $client, + \count(\array_filter($keys, fn (mixed $key): bool => $this->has($this->text($key)))), + )); + $client->method('del')->willReturnCallback(function (mixed $key, mixed ...$otherKeys) use ($client): mixed { + $removed = 0; + foreach ([...(\is_array($key) ? \array_values($key) : [$key]), ...$otherKeys] as $candidate) { + $candidate = $this->text($candidate); + $removed += (int) $this->has($candidate); + $this->forget($candidate); + } + + return $this->reply($client, $removed); + }); + $client->method('sAdd')->willReturnCallback(function (string $key, mixed ...$members) use ($client): mixed { + $added = 0; + foreach ($members as $member) { + $member = $this->text($member); + $added += (int) ! isset($this->sets[$key][$member]); + $this->sets[$key][$member] = true; + } + + return $this->reply($client, $added); + }); + $client->method('sRem')->willReturnCallback(function (string $key, mixed ...$members) use ($client): mixed { + $removed = 0; + foreach ($members as $member) { + $member = $this->text($member); + $removed += (int) isset($this->sets[$key][$member]); + unset($this->sets[$key][$member]); + } + if (($this->sets[$key] ?? null) === []) { + unset($this->sets[$key]); + } + + return $this->reply($client, $removed); + }); + $client->method('sMembers')->willReturnCallback(fn (string $key): mixed => $this->reply($client, $this->members($key))); + $client->method('sIsMember')->willReturnCallback(fn (string $key, mixed $member): mixed => $this->reply($client, isset($this->sets[$key][$this->text($member)]))); + $client->method('sCard')->willReturnCallback(fn (string $key): mixed => $this->reply($client, \count($this->sets[$key] ?? []))); + $client->method('sUnion')->willReturnCallback(fn (string ...$keys): mixed => $this->reply( + $client, + \array_values(\array_unique(\array_merge(...\array_map($this->members(...), $keys)))), + )); + $client->method('hSet')->willReturnCallback(function (string $key, string $field, mixed $value) use ($client): mixed { + $this->hashWrites[] = $key.' '.$field; + $added = (int) ! isset($this->hashes[$key][$field]); + $this->hashes[$key][$field] = $this->text($value); + + return $this->reply($client, $added); + }); + $client->method('hMSet')->willReturnCallback(function (string $key, mixed $fields) use ($client): mixed { + foreach (\is_array($fields) ? $fields : [] as $field => $value) { + $this->hashes[$key][(string) $field] = $this->text($value); + } + + return $this->reply($client, true); + }); + $client->method('hGet')->willReturnCallback(fn (string $key, string $field): mixed => $this->reply($client, $this->hashes[$key][$field] ?? false)); + $client->method('hGetAll')->willReturnCallback(fn (string $key): mixed => $this->reply($client, $this->hashes[$key] ?? [])); + $client->method('hDel')->willReturnCallback(function (string $key, string ...$fields) use ($client): mixed { + $removed = 0; + foreach ($fields as $field) { + $removed += (int) isset($this->hashes[$key][$field]); + unset($this->hashes[$key][$field]); + } + if (($this->hashes[$key] ?? null) === []) { + unset($this->hashes[$key]); + } + + return $this->reply($client, $removed); + }); + $client->method('rawCommand')->willThrowException(new \RedisException('MEMORY USAGE is not available')); + $client->method('type')->willReturnCallback(fn (string $key): int => match (true) { + isset($this->strings[$key]) => Redis::REDIS_STRING, + isset($this->sets[$key]) => Redis::REDIS_SET, + isset($this->hashes[$key]) => Redis::REDIS_HASH, + default => Redis::REDIS_NOT_FOUND, + }); + $client->method('scan')->willReturnCallback(fn (mixed $iterator, ?string $pattern = null): mixed => $this->keys($pattern ?? '*')); + + return $client; + } + + private function reply(Redis $client, mixed $value): mixed + { + if (! $this->pipelining) { + return $value; + } + $this->queued[] = $value; + + return $client; + } + + private function text(mixed $value): string + { + return \is_scalar($value) ? (string) $value : ''; + } + + /** + * @return list + */ + private function members(string $key): array + { + return \array_map($this->text(...), \array_keys($this->sets[$key] ?? [])); + } + + /** + * @return list + */ + private function keys(string $pattern): array + { + $keys = \array_map($this->text(...), [...\array_keys($this->strings), ...\array_keys($this->sets), ...\array_keys($this->hashes)]); + + return \array_values(\array_filter($keys, static fn (string $key): bool => \fnmatch($pattern, $key))); + } + + private function has(string $key): bool + { + return isset($this->strings[$key]) || isset($this->sets[$key]) || isset($this->hashes[$key]); + } + + private function forget(string $key): void + { + unset($this->strings[$key], $this->sets[$key], $this->hashes[$key]); + } + + /** + * @return list + */ + private function recordedHashWrites(): array + { + return $this->hashWrites; + } +} diff --git a/tests/unit/Adapter/RedisDatabaseRenameTest.php b/tests/unit/Adapter/RedisDatabaseRenameTest.php new file mode 100644 index 0000000000..cdd71d6dd8 --- /dev/null +++ b/tests/unit/Adapter/RedisDatabaseRenameTest.php @@ -0,0 +1,172 @@ +> + */ + private array $keys = []; + + private ?string $failOn = null; + + #[\Override] + protected function setUp(): void + { + $this->keys = [ + self::DATABASES => ['library' => true], + self::DOCUMENT => '{"title":"The Hobbit"}', + self::GRANTS => [self::DOCUMENT => true, 'elsewhere' => true], + self::COLLECTIONS => ['books' => true], + ]; + } + + public function testRenameMovesEveryKeyAndTheGrantMembers(): void + { + $this->assertTrue($this->adapter()->update('library', 'archive')); + + $this->assertSame([ + 'utopia:ns:archive:cols' => ['books' => true], + 'utopia:ns:archive:doc:books:hobbit' => '{"title":"The Hobbit"}', + 'utopia:ns:archive:grants:books:r:any' => ['elsewhere' => true, 'utopia:ns:archive:doc:books:hobbit' => true], + self::DATABASES => ['archive' => true], + ], $this->sorted()); + } + + public function testAFailurePartWayMovesTheMovedKeysBack(): void + { + $before = $this->sorted(); + $this->failOn = self::COLLECTIONS; + + try { + $this->adapter()->update('library', 'archive'); + $this->fail('A failed key move must fail the rename'); + } catch (DatabaseException $error) { + $this->assertStringContainsString('connection lost', $error->getMessage()); + } + + $this->assertSame($before, $this->sorted()); + } + + public function testAKeyGonePartWayMovesTheMovedKeysBack(): void + { + $before = $this->sorted(); + $this->failOn = 'missing'; + + try { + $this->adapter()->update('library', 'archive'); + $this->fail('A key that cannot be moved must fail the rename'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to move '.self::COLLECTIONS.' to utopia:ns:archive:cols', $error->getMessage()); + } + + $this->assertSame($before, $this->sorted()); + } + + public function testSharedTablesRefuseTheRename(): void + { + $before = $this->sorted(); + $adapter = $this->adapter(); + $adapter->setSharedTables(true); + + try { + $adapter->update('library', 'archive'); + $this->fail('A rename under shared tables must be refused'); + } catch (DatabaseException $error) { + $this->assertSame('Cannot rename a database while shared tables are enabled', $error->getMessage()); + } + + $this->assertSame($before, $this->sorted()); + } + + private function adapter(): RedisAdapter + { + $adapter = new RedisAdapter($this->client()); + $adapter->setNamespace('ns'); + + return $adapter; + } + + private function client(): Redis + { + $client = self::createStub(Redis::class); + $client->method('sIsMember')->willReturnCallback(fn (string $key, string $member): bool => \is_array($this->keys[$key] ?? null) && isset($this->keys[$key][$member])); + $client->method('scan')->willReturnCallback(fn (mixed $iterator, ?string $pattern = null): array => \array_values(\array_filter( + \array_keys($this->keys), + static fn (string $key): bool => \fnmatch($pattern ?? '*', $key), + ))); + $client->method('rename')->willReturnCallback(function (string $key, string $target): bool { + if ($key === $this->failOn) { + throw new RedisException('connection lost'); + } + if ($this->failOn === 'missing' && $key === self::COLLECTIONS) { + return false; + } + $this->keys[$target] = $this->keys[$key]; + unset($this->keys[$key]); + + return true; + }); + $client->method('sMembers')->willReturnCallback(fn (string $key): array => \array_map(\strval(...), \array_keys((array) ($this->keys[$key] ?? [])))); + $client->method('del')->willReturnCallback(function (string $key): int { + $existed = isset($this->keys[$key]); + unset($this->keys[$key]); + + return (int) $existed; + }); + $client->method('sAdd')->willReturnCallback(function (string $key, string ...$members): int { + $set = \is_array($this->keys[$key] ?? null) ? $this->keys[$key] : []; + foreach ($members as $member) { + $set[$member] = true; + } + $this->keys[$key] = $set; + + return \count($members); + }); + $client->method('sRem')->willReturnCallback(function (string $key, string ...$members): int { + $set = \is_array($this->keys[$key] ?? null) ? $this->keys[$key] : []; + foreach ($members as $member) { + unset($set[$member]); + } + $this->keys[$key] = $set; + + return \count($members); + }); + + return $client; + } + + /** + * @return array> + */ + private function sorted(): array + { + $keys = $this->keys; + foreach ($keys as &$value) { + if (\is_array($value)) { + \ksort($value); + } + } + unset($value); + \ksort($keys); + + return $keys; + } +} diff --git a/tests/unit/Adapter/RedisUniqueIndexTest.php b/tests/unit/Adapter/RedisUniqueIndexTest.php new file mode 100644 index 0000000000..77fc4f1beb --- /dev/null +++ b/tests/unit/Adapter/RedisUniqueIndexTest.php @@ -0,0 +1,637 @@ + */ + private array $strings = []; + + /** @var array> */ + private array $sets = []; + + /** @var array> */ + private array $hashes = []; + + private bool $pipelining = false; + + private int $memberReads = 0; + + /** @var list */ + private array $queued = []; + + #[\Override] + protected function setUp(): void + { + $this->authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + $this->client = $this->fakeClient(); + } + + public function testUpdateDocumentsRejectsADuplicateUniqueValue(): void + { + $database = $this->usersDatabase(); + + try { + $database->updateDocuments(self::USERS, new Document(['email' => 'first@example.test']), [Query::equal('$id', ['second'])]); + $this->fail('A batch update onto another document\'s unique value must be rejected'); + } catch (UniqueException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + } + + $this->assertSame(['first@example.test', 'second@example.test', 'third@example.test'], $this->emails($database)); + $this->assertSame(1, $database->updateDocuments(self::USERS, new Document(['email' => 'second@example.test']), [Query::equal('$id', ['second'])])); + $this->assertSame(1, $database->updateDocuments(self::USERS, new Document(['email' => 'renamed@example.test']), [Query::equal('$id', ['second'])])); + $this->assertSame(['first@example.test', 'renamed@example.test', 'third@example.test'], $this->emails($database)); + } + + public function testUpsertRejectsADuplicateUniqueValue(): void + { + $database = $this->usersDatabase(); + + try { + $database->upsertDocuments(self::USERS, [new Document(['$id' => 'second', 'email' => 'first@example.test'])]); + $this->fail('An upsert that updates onto another document\'s unique value must be rejected'); + } catch (UniqueException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + } + + $this->assertSame(['first@example.test', 'second@example.test', 'third@example.test'], $this->emails($database)); + $this->assertSame(1, $database->upsertDocuments(self::USERS, [new Document(['$id' => 'second', 'email' => 'renamed@example.test'])])); + $this->assertSame(['first@example.test', 'renamed@example.test', 'third@example.test'], $this->emails($database)); + } + + public function testABatchCannotCollideWithItself(): void + { + $database = $this->usersDatabase(); + + try { + $database->updateDocuments(self::USERS, new Document(['email' => 'shared@example.test']), [Query::equal('$id', ['second', 'third'])]); + $this->fail('A batch update that gives two documents one unique value must be rejected'); + } catch (UniqueException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + } + + try { + $database->upsertDocuments(self::USERS, [ + new Document(['$id' => 'second', 'email' => 'shared@example.test']), + new Document(['$id' => 'third', 'email' => 'shared@example.test']), + ]); + $this->fail('An upsert batch that gives two documents one unique value must be rejected'); + } catch (UniqueException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + } + + $this->assertSame(['first@example.test', 'second@example.test', 'third@example.test'], $this->emails($database)); + } + + public function testAnUpsertBatchReadsTheStoredDocumentsOnceForItsUniqueChecks(): void + { + $database = $this->usersDatabase(); + $this->memberReads = 0; + $this->assertSame(1, $database->upsertDocuments(self::USERS, [new Document(['$id' => 'first', 'email' => 'first-moved@example.test'])])); + $single = $this->memberReads; + $this->memberReads = 0; + + $this->assertSame(4, $database->upsertDocuments(self::USERS, [ + new Document(['$id' => 'third', 'email' => 'moved@example.test']), + new Document(['$id' => 'second', 'email' => 'renamed@example.test']), + new Document(['$id' => 'fourth', 'email' => 'fourth@example.test']), + new Document(['$id' => 'fifth', 'email' => 'fifth@example.test']), + ])); + + $this->assertSame($single, $this->memberReads, 'The unique checks must read the collection once per batch, not once per document'); + $this->assertSame(['fifth@example.test', 'first-moved@example.test', 'fourth@example.test', 'moved@example.test', 'renamed@example.test'], $this->emails($database)); + } + + public function testAnUpsertBatchChecksEachDocumentAgainstTheOnesBeforeIt(): void + { + $database = $this->usersDatabase(); + + try { + $database->upsertDocuments(self::USERS, [ + new Document(['$id' => 'fourth', 'email' => 'second@example.test']), + new Document(['$id' => 'second', 'email' => 'renamed@example.test']), + ]); + $this->fail('A new document that takes a unique value before the document holding it gives it up must be rejected'); + } catch (UniqueException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + } + $this->assertSame(['first@example.test', 'second@example.test', 'third@example.test'], $this->emails($database)); + + $this->assertSame(2, $database->upsertDocuments(self::USERS, [ + new Document(['$id' => 'second', 'email' => 'renamed@example.test']), + new Document(['$id' => 'fourth', 'email' => 'second@example.test']), + ])); + $this->assertSame(['first@example.test', 'renamed@example.test', 'second@example.test', 'third@example.test'], $this->emails($database)); + } + + public function testTenantPerDocumentChecksTheDocumentsTenant(): void + { + $database = $this->database() + ->setSharedTables(true) + ->setTenant(null) + ->setTenantPerDocument(true); + $database->create(); + $this->createUsers($database); + + $database->createDocument(self::USERS, $this->user('first', 'taken@example.test')->setAttribute('$tenant', self::TENANT)); + + try { + $database->createDocument(self::USERS, $this->user('second', 'taken@example.test')->setAttribute('$tenant', self::TENANT)); + $this->fail('A duplicate under the document\'s own tenant must be rejected while another tenant is selected'); + } catch (UniqueException $exception) { + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); + } + + $database->createDocument(self::USERS, $this->user('second', 'taken@example.test')->setAttribute('$tenant', self::OTHER_TENANT)); + + $this->assertSame(['taken@example.test'], $database->withTenant(self::TENANT, fn (): array => $this->emails($database))); + $this->assertSame(['taken@example.test'], $database->withTenant(self::OTHER_TENANT, fn (): array => $this->emails($database))); + } + + /** + * @return array + */ + public static function tenancies(): array + { + return [ + 'dedicated tables' => [false], + 'shared tables' => [true], + ]; + } + + #[DataProvider('tenancies')] + public function testDroppingACollectionNamedLikeAKeySegmentKeepsOtherGrants(bool $sharedTables): void + { + $database = $this->notesDatabase($sharedTables); + $database->createCollection(Collection::create(id: self::KEY_SEGMENT, attributes: [Attribute::string(key: 'title', size: 64)])); + $database->createDocument(self::KEY_SEGMENT, new Document(['$id' => self::NOTE, '$permissions' => [Permission::read(Role::any())], 'title' => 'dropped'])); + + $database->deleteCollection(self::KEY_SEGMENT); + $database->updateDocument(self::NOTES, self::NOTE, $this->readers([self::ALICE])); + + $this->assertSame([self::NOTE], $this->readableBy($database, self::ALICE)); + $this->assertSame([], $this->readableBy($database, self::BOB), 'Dropping another collection must leave the grants a later revoke removes'); + $this->assertSame([], $this->keysOf($sharedTables, self::KEY_SEGMENT), 'Dropping a collection must remove every key it owns'); + } + + #[DataProvider('tenancies')] + public function testDroppingACollectionRemovesGrantsWrittenBeforeTheRegistry(bool $sharedTables): void + { + $database = $this->notesDatabase($sharedTables); + foreach ($this->keys('*:grants:*') as $registry) { + $this->forget($registry); + } + + $database->deleteCollection(self::NOTES); + + $this->assertSame([], $this->keysOf($sharedTables, self::NOTES), 'Dropping a collection must remove the grants written before the registry existed'); + } + + #[DataProvider('tenancies')] + public function testDroppingACollectionRemovesRegisteredGrantsItsIdIndexMisses(bool $sharedTables): void + { + $database = $this->notesDatabase($sharedTables); + foreach ($this->keys('*:idx:*'.self::NOTES) as $index) { + $this->forget($index); + } + + $database->deleteCollection(self::NOTES); + + $grants = \array_filter($this->keysOf($sharedTables, self::NOTES), static fn (string $key): bool => \str_contains($key, ':perm:')); + $this->assertSame([], \array_values($grants), 'Dropping a collection must remove the grants it registered, even those its id index no longer lists'); + } + + #[DataProvider('tenancies')] + public function testSizingACollectionNamedLikeAKeySegmentCountsOnlyItsOwnKeys(bool $sharedTables): void + { + $database = $this->notesDatabase($sharedTables); + $database->createCollection(Collection::create(id: self::KEY_SEGMENT, attributes: [Attribute::string(key: 'title', size: 64)])); + $database->createDocument(self::KEY_SEGMENT, new Document(['$id' => self::NOTE, '$permissions' => [Permission::read(Role::any())], 'title' => 'sized'])); + + $this->assertSame($this->bytesOf($sharedTables, self::KEY_SEGMENT), $database->getSizeOfCollection(self::KEY_SEGMENT), 'A collection named like a key segment must not count other collections\' grants'); + $this->assertSame($this->bytesOf($sharedTables, self::NOTES), $database->getSizeOfCollection(self::NOTES)); + } + + #[DataProvider('tenancies')] + public function testSizingCountsGrantsWrittenBeforeTheRegistry(bool $sharedTables): void + { + $database = $this->notesDatabase($sharedTables); + foreach ($this->keys('*:grants:*') as $registry) { + $this->forget($registry); + } + + $this->assertSame($this->bytesOf($sharedTables, self::NOTES), $database->getSizeOfCollection(self::NOTES), 'Sizing must count the grants written before the registry existed'); + } + + #[DataProvider('tenancies')] + public function testSizingCountsRegisteredGrantsItsIdIndexMisses(bool $sharedTables): void + { + $database = $this->notesDatabase($sharedTables); + foreach ($this->keys('*:idx:*'.self::NOTES) as $index) { + $this->forget($index); + } + $unindexed = $this->keys('*:redis_unique:doc:*'.self::NOTES.':'.self::NOTE); + + $this->assertCount(1, $unindexed); + $expected = $this->bytesOf($sharedTables, self::NOTES) - $this->bytes($unindexed[0]); + + $this->assertSame($expected, $database->getSizeOfCollection(self::NOTES), 'Sizing must count the grants the collection registered, even those its id index no longer lists'); + } + + public function testSizingUnderSharedTablesCountsOnlyTheSelectedTenantsGrants(): void + { + $database = $this->notesDatabase(true); + $expected = $this->bytesOf(true, self::NOTES); + + $database->withTenant(self::OTHER_TENANT, function () use ($database): void { + $database->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $database->createDocument(self::NOTES, $this->readers([self::CAROL])->setAttribute('$id', self::NOTE)->setAttribute('title', 'other tenant')); + }); + + $this->assertNotSame([], $this->keys('*:perm:t:'.self::OTHER_TENANT.':'.self::NOTES.':*')); + $this->assertSame($expected, $database->getSizeOfCollection(self::NOTES), 'Sizing must count only the selected tenant\'s grants'); + } + + private function database(): Database + { + return (new Database(new RedisAdapter($this->client), new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('redis_unique') + ->setNamespace('redis_unique'); + } + + private function usersDatabase(): Database + { + $database = $this->database(); + $database->create(); + $this->createUsers($database); + foreach (['first', 'second', 'third'] as $id) { + $database->createDocument(self::USERS, $this->user($id, $id.'@example.test')); + } + + return $database; + } + + private function createUsers(Database $database): void + { + $database->createCollection(Collection::create( + id: self::USERS, + attributes: [Attribute::string(key: 'email', size: 128)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + $database->createIndex(self::USERS, Index::unique(key: 'emailUnique', attributes: ['email'], lengths: [128])); + } + + private function user(string $id, string $email): Document + { + return new Document(['$id' => $id, 'email' => $email]); + } + + private function notesDatabase(bool $sharedTables): Database + { + $database = $this->database()->setSharedTables($sharedTables); + if ($sharedTables) { + $database->setTenant(self::TENANT); + } + $database->create(); + $database->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: true, + )); + $database->createDocument(self::NOTES, $this->readers([self::ALICE, self::BOB])->setAttribute('$id', self::NOTE)->setAttribute('title', 'kept')); + + return $database; + } + + /** + * @param list $readers + */ + private function readers(array $readers): Document + { + return new Document([ + '$permissions' => \array_map( + static fn (string $reader): string => Permission::read(Role::user($reader)), + $readers, + ), + ]); + } + + /** + * @return list + */ + private function emails(Database $database): array + { + $emails = \array_map( + static fn (Document $document): string => \is_string($email = $document->getAttribute('email')) ? $email : '', + $database->find(self::USERS), + ); + \sort($emails); + + return $emails; + } + + /** + * @return list + */ + private function readableBy(Database $database, string $reader): array + { + $roles = $this->authorization->getRoles(); + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::user($reader)->toString()); + + try { + return \array_values(\array_map( + static fn (Document $document): string => $document->getId(), + $database->find(self::NOTES), + )); + } finally { + $this->authorization->cleanRoles(); + foreach ($roles as $role) { + $this->authorization->addRole($role); + } + } + } + + /** + * @return list + */ + private function keysOf(bool $sharedTables, string $collection): array + { + $prefix = RedisAdapter::KEY_PREFIX.':redis_unique:redis_unique:'; + $tenant = $sharedTables ? 't:'.self::TENANT.':' : ''; + $owned = [ + $prefix.'meta:'.$collection, + $prefix.'grants:'.$collection, + $prefix.'idx:'.$tenant.$collection, + $prefix.'seq:'.$tenant.$collection, + $prefix.'doc:'.$tenant.$collection.':'.self::NOTE, + $prefix.'perm:'.$tenant.'doc:'.$collection.':'.self::NOTE, + ]; + foreach (['r', 'c', 'u', 'd'] as $letter) { + foreach ([Role::any(), Role::user(self::ALICE), Role::user(self::BOB)] as $role) { + $owned[] = $prefix.'perm:'.$tenant.$collection.':'.$letter.':'.$role->toString(); + } + } + + return \array_values(\array_filter($owned, $this->has(...))); + } + + private function bytesOf(bool $sharedTables, string $collection): int + { + $bytes = 0; + foreach ($this->keysOf($sharedTables, $collection) as $key) { + if (\str_contains($key, ':grants:') || \str_contains($key, ':seq:')) { + continue; + } + $bytes += $this->bytes($key); + } + + return $bytes; + } + + private function bytes(string $key): int + { + if (isset($this->strings[$key])) { + return \strlen($key) + \strlen($this->strings[$key]); + } + + $bytes = \strlen($key); + foreach ($this->hashes[$key] ?? [] as $field => $value) { + $bytes += \strlen((string) $field) + \strlen($value); + } + foreach ($this->members($key) as $member) { + $bytes += \strlen($member); + } + + return $bytes; + } + + private function fakeClient(): Redis + { + $client = self::createStub(Redis::class); + $client->method('ping')->willReturn(true); + $client->method('multi')->willReturnCallback(function () use ($client): Redis { + $this->pipelining = true; + $this->queued = []; + + return $client; + }); + $client->method('exec')->willReturnCallback(function (): mixed { + $replies = $this->queued; + $this->pipelining = false; + $this->queued = []; + + return $replies; + }); + $client->method('discard')->willReturnCallback(function (): bool { + $this->pipelining = false; + $this->queued = []; + + return true; + }); + $client->method('get')->willReturnCallback(fn (string $key): mixed => $this->reply($client, $this->strings[$key] ?? false)); + $client->method('mGet')->willReturnCallback(fn (mixed $keys): mixed => $this->reply($client, \array_map( + fn (mixed $key): string|false => $this->strings[$this->text($key)] ?? false, + \is_array($keys) ? \array_values($keys) : [], + ))); + $client->method('set')->willReturnCallback(function (string $key, mixed $value) use ($client): mixed { + $this->forget($key); + $this->strings[$key] = $this->text($value); + + return $this->reply($client, true); + }); + $client->method('incr')->willReturnCallback(function (string $key, int $by = 1) use ($client): mixed { + $value = (int) ($this->strings[$key] ?? 0) + $by; + $this->strings[$key] = (string) $value; + + return $this->reply($client, $value); + }); + $client->method('exists')->willReturnCallback(fn (mixed ...$keys): mixed => $this->reply( + $client, + \count(\array_filter($keys, fn (mixed $key): bool => $this->has($this->text($key)))), + )); + $client->method('del')->willReturnCallback(function (mixed $key, mixed ...$otherKeys) use ($client): mixed { + $removed = 0; + foreach ([...(\is_array($key) ? \array_values($key) : [$key]), ...$otherKeys] as $candidate) { + $candidate = $this->text($candidate); + $removed += (int) $this->has($candidate); + $this->forget($candidate); + } + + return $this->reply($client, $removed); + }); + $client->method('sAdd')->willReturnCallback(function (string $key, mixed ...$members) use ($client): mixed { + $added = 0; + foreach ($members as $member) { + $member = $this->text($member); + $added += (int) ! isset($this->sets[$key][$member]); + $this->sets[$key][$member] = true; + } + + return $this->reply($client, $added); + }); + $client->method('sRem')->willReturnCallback(function (string $key, mixed ...$members) use ($client): mixed { + $removed = 0; + foreach ($members as $member) { + $member = $this->text($member); + $removed += (int) isset($this->sets[$key][$member]); + unset($this->sets[$key][$member]); + } + if (($this->sets[$key] ?? null) === []) { + unset($this->sets[$key]); + } + + return $this->reply($client, $removed); + }); + $client->method('sMembers')->willReturnCallback(function (string $key) use ($client): mixed { + $this->memberReads++; + + return $this->reply($client, $this->members($key)); + }); + $client->method('sIsMember')->willReturnCallback(fn (string $key, mixed $member): mixed => $this->reply($client, isset($this->sets[$key][$this->text($member)]))); + $client->method('sCard')->willReturnCallback(fn (string $key): mixed => $this->reply($client, \count($this->sets[$key] ?? []))); + $client->method('sUnion')->willReturnCallback(fn (string ...$keys): mixed => $this->reply( + $client, + \array_values(\array_unique(\array_merge(...\array_map($this->members(...), $keys)))), + )); + $client->method('hSet')->willReturnCallback(function (string $key, string $field, mixed $value) use ($client): mixed { + $added = (int) ! isset($this->hashes[$key][$field]); + $this->hashes[$key][$field] = $this->text($value); + + return $this->reply($client, $added); + }); + $client->method('hMSet')->willReturnCallback(function (string $key, mixed $fields) use ($client): mixed { + foreach (\is_array($fields) ? $fields : [] as $field => $value) { + $this->hashes[$key][(string) $field] = $this->text($value); + } + + return $this->reply($client, true); + }); + $client->method('hGet')->willReturnCallback(fn (string $key, string $field): mixed => $this->reply($client, $this->hashes[$key][$field] ?? false)); + $client->method('hGetAll')->willReturnCallback(fn (string $key): mixed => $this->reply($client, $this->hashes[$key] ?? [])); + $client->method('hDel')->willReturnCallback(function (string $key, string ...$fields) use ($client): mixed { + $removed = 0; + foreach ($fields as $field) { + $removed += (int) isset($this->hashes[$key][$field]); + unset($this->hashes[$key][$field]); + } + if (($this->hashes[$key] ?? null) === []) { + unset($this->hashes[$key]); + } + + return $this->reply($client, $removed); + }); + $client->method('rawCommand')->willThrowException(new \RedisException('MEMORY USAGE is not available')); + $client->method('type')->willReturnCallback(fn (string $key): int => match (true) { + isset($this->strings[$key]) => Redis::REDIS_STRING, + isset($this->sets[$key]) => Redis::REDIS_SET, + isset($this->hashes[$key]) => Redis::REDIS_HASH, + default => Redis::REDIS_NOT_FOUND, + }); + $client->method('scan')->willReturnCallback(fn (mixed $iterator, ?string $pattern = null): mixed => $this->keys($pattern ?? '*')); + + return $client; + } + + private function reply(Redis $client, mixed $value): mixed + { + if (! $this->pipelining) { + return $value; + } + $this->queued[] = $value; + + return $client; + } + + private function text(mixed $value): string + { + return \is_scalar($value) ? (string) $value : ''; + } + + /** + * @return list + */ + private function members(string $key): array + { + return \array_map($this->text(...), \array_keys($this->sets[$key] ?? [])); + } + + /** + * @return list + */ + private function keys(string $pattern): array + { + $keys = \array_map($this->text(...), [...\array_keys($this->strings), ...\array_keys($this->sets), ...\array_keys($this->hashes)]); + + return \array_values(\array_filter($keys, static fn (string $key): bool => \fnmatch($pattern, $key))); + } + + private function has(string $key): bool + { + return isset($this->strings[$key]) || isset($this->sets[$key]) || isset($this->hashes[$key]); + } + + private function forget(string $key): void + { + unset($this->strings[$key], $this->sets[$key], $this->hashes[$key]); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Join/ChainTest.php b/tests/unit/Adapter/SQL/Hook/Join/ChainTest.php new file mode 100644 index 0000000000..823e3c1c24 --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Join/ChainTest.php @@ -0,0 +1,58 @@ +assertSame(['extra', 'review'], $chain->preceding('note'), 'Joins are keyed by their alias'); + $this->assertTrue($chain->has(JoinType::Cross)); + $this->assertFalse($chain->has(JoinType::Left)); + } + + public function testOnlyTablesWhoseConditionsSitInWherePrecedeALaterJoin(): void + { + $chain = new Chain([ + 'inner' => JoinType::Inner, + 'left' => JoinType::Left, + 'right' => JoinType::Right, + 'full' => JoinType::FullOuter, + 'cross' => JoinType::Cross, + 'last' => JoinType::Right, + ]); + + $this->assertSame(['right', 'full', 'cross'], $chain->preceding('last')); + $this->assertSame(['right'], $chain->preceding('full')); + $this->assertSame([], $chain->preceding('right'), 'Inner and left joins meet their conditions in their own ON'); + $this->assertSame([], $chain->preceding('inner')); + } + + public function testAnAliasOutsideTheChainHasNoPrecedingTables(): void + { + $chain = new Chain(['right' => JoinType::Right, 'last' => JoinType::Right]); + + $this->assertSame([], $chain->preceding('unknown'), 'Repeating conditions of tables the join may not follow would reference aliases it cannot see'); + } + + public function testOnlyRightAndFullOuterJoinsCanLeaveATableMissing(): void + { + $this->assertFalse((new Chain())->hasPreservingOuterJoin()); + $this->assertFalse((new Chain(['a' => JoinType::Inner, 'b' => JoinType::Left, 'c' => JoinType::Cross]))->hasPreservingOuterJoin()); + $this->assertTrue((new Chain(['a' => JoinType::Inner, 'b' => JoinType::Right]))->hasPreservingOuterJoin()); + $this->assertTrue((new Chain(['a' => JoinType::FullOuter]))->hasPreservingOuterJoin()); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Join/OuterChainTest.php b/tests/unit/Adapter/SQL/Hook/Join/OuterChainTest.php new file mode 100644 index 0000000000..030069c22e --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Join/OuterChainTest.php @@ -0,0 +1,92 @@ + + */ + public static function preservingJoins(): iterable + { + yield 'right join' => [JoinType::Right]; + yield 'full outer join' => [JoinType::FullOuter]; + } + + /** + * @return iterable + */ + public static function otherJoins(): iterable + { + yield 'inner join' => [JoinType::Inner]; + yield 'left join' => [JoinType::Left]; + yield 'cross join' => [JoinType::Cross]; + } + + #[DataProvider('preservingJoins')] + public function testEveryEarlierTableFilteredInWhereIsRepeatedInOnRelaxedForMissingRows(JoinType $joinType): void + { + $chain = new Chain([ + 'b' => JoinType::Inner, + 'c' => JoinType::Right, + 'x' => JoinType::Cross, + 'd' => $joinType, + 'e' => JoinType::Right, + ]); + $tenants = new Filter(7); + $conditions = []; + foreach (['b', 'c', 'x', 'd', 'e'] as $alias) { + $conditions[$alias] = $tenants->joined($alias); + } + + $result = (new OuterChain($chain, $conditions))->filterJoin('d', $joinType); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement, 'Only ON decides which rows the join pairs'); + $this->assertSame( + '(`c`._tenant IN (?) OR `c`.`_uid` IS NULL) AND (`x`._tenant IN (?) OR `x`.`_uid` IS NULL)', + $result->condition->expression, + 'The inner-joined table meets its condition in its own ON; a table an earlier outer join left missing must not stop the pairing', + ); + $this->assertSame([7, 7], $result->condition->bindings); + } + + #[DataProvider('otherJoins')] + public function testJoinsThatKeepNoUnmatchedRowsOfEarlierTablesNeedNothing(JoinType $joinType): void + { + $chain = new Chain(['c' => JoinType::Right, 'd' => $joinType]); + + $this->assertNull((new OuterChain($chain, ['c' => new Condition('c.ok')]))->filterJoin('d', $joinType)); + } + + public function testTheFirstOuterJoinAndTablesWithoutConditionsAddNothing(): void + { + $chain = new Chain(['c' => JoinType::Right, 'd' => JoinType::Right]); + + $this->assertNull((new OuterChain($chain, ['c' => new Condition('c.ok')]))->filterJoin('c', JoinType::Right)); + $this->assertNull((new OuterChain($chain, ['d' => new Condition('d.ok')]))->filterJoin('d', JoinType::Right)); + } + + public function testBindingsFollowTheConditionsInOrder(): void + { + $chain = new Chain(['c' => JoinType::FullOuter, 'x' => JoinType::Cross, 'd' => JoinType::Right]); + + $result = (new OuterChain($chain, [ + 'x' => new Condition('x.role IN (?, ?)', ['x1', 'x2']), + 'c' => new Condition('c.role = ?', ['c1']), + ], '"'))->filterJoin('d', JoinType::Right); + + $this->assertNotNull($result); + $this->assertSame('(c.role = ? OR "c"."_uid" IS NULL) AND (x.role IN (?, ?) OR "x"."_uid" IS NULL)', $result->condition->expression); + $this->assertSame(['c1', 'x1', 'x2'], $result->condition->bindings); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Permission/AllowNullUidTest.php b/tests/unit/Adapter/SQL/Hook/Permission/AllowNullUidTest.php new file mode 100644 index 0000000000..7a9b02f48a --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Permission/AllowNullUidTest.php @@ -0,0 +1,62 @@ + 'perms_'.$table, + documentColumn: 'table_main.'.Storage::UID, + ); + $hook = new AllowNullUid($inner, 'table_main.'.Storage::UID); + + $condition = $hook->filter('movies'); + + $this->assertSame( + '('.$inner->filter('movies')->expression.' OR `table_main`.`'.Storage::UID.'` IS NULL)', + $condition->expression, + ); + $this->assertSame($inner->filter('movies')->bindings, $condition->bindings); + } + + public function testQuotesPostgresStyleIdentifiers(): void + { + $inner = new class () implements Filter { + #[\Override] + public function filter(string $table): Condition + { + return new Condition('inner_expr', ['role']); + } + }; + $hook = new AllowNullUid($inner, 'table_main.'.Storage::UID, '"'); + + $condition = $hook->filter('movies'); + + $this->assertSame('(inner_expr OR "table_main"."'.Storage::UID.'" IS NULL)', $condition->expression); + $this->assertSame(['role'], $condition->bindings); + } + + public function testRejectsInvalidDocumentColumn(): void + { + $this->expectException(InvalidArgumentException::class); + + new AllowNullUid( + new PermissionFilter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'perms_'.$table, + ), + 'table_main._uid; DROP TABLE', + ); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Permission/JoinTest.php b/tests/unit/Adapter/SQL/Hook/Permission/JoinTest.php new file mode 100644 index 0000000000..3e7b6f0268 --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Permission/JoinTest.php @@ -0,0 +1,77 @@ +permissionFilter(); + $hook = new Join($filter, 'j0'); + + $result = $hook->filterJoin('j0', JoinType::Left); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement); + $this->assertStringContainsString('`j0`.`'.Storage::UID.'`', $result->condition->expression); + $this->assertNull($hook->filterJoin('j1', JoinType::Left)); + } + + public function testInnerJoinPlacesPermissionInOnClause(): void + { + $hook = new Join($this->permissionFilter(), 'j0'); + $result = $hook->filterJoin('j0', JoinType::Inner); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement); + } + + public function testRightJoinPlacesPermissionInWhereClause(): void + { + $hook = new Join($this->permissionFilter(), 'j0'); + $result = $hook->filterJoin('j0', JoinType::Right); + + $this->assertNotNull($result); + $this->assertSame(Placement::Where, $result->placement); + $this->assertStringContainsString('`j0`.`'.Storage::UID.'`', $result->condition->expression); + $this->assertStringNotContainsString('IS NULL', $result->condition->expression); + } + + public function testFullOuterJoinPlacesPermissionInWhereClauseAndAllowsNullUid(): void + { + $hook = new Join($this->permissionFilter(), 'j0'); + $result = $hook->filterJoin('j0', JoinType::FullOuter); + + $this->assertNotNull($result); + $this->assertSame(Placement::Where, $result->placement); + $this->assertStringContainsString('`j0`.`'.Storage::UID.'`', $result->condition->expression); + $this->assertStringContainsString('IS NULL', $result->condition->expression); + $this->assertSame($this->permissionFilter()->filter('j0')->bindings, $result->condition->bindings); + } + + public function testCrossJoinPlacesPermissionInWhereClause(): void + { + $hook = new Join($this->permissionFilter(), 'j0'); + $result = $hook->filterJoin('j0', JoinType::Cross); + + $this->assertNotNull($result); + $this->assertSame(Placement::Where, $result->placement); + $this->assertStringNotContainsString('IS NULL', $result->condition->expression); + } + + private function permissionFilter(): Filter + { + return new Filter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'perms_'.$table, + documentColumn: 'j0.'.Storage::UID, + ); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Permission/OuterJoinTest.php b/tests/unit/Adapter/SQL/Hook/Permission/OuterJoinTest.php new file mode 100644 index 0000000000..65c80aa662 --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Permission/OuterJoinTest.php @@ -0,0 +1,129 @@ + + */ + public static function preservingJoins(): iterable + { + yield 'right join' => [JoinType::Right]; + yield 'full outer join' => [JoinType::FullOuter]; + } + + /** + * @return iterable + */ + public static function otherJoins(): iterable + { + yield 'inner join' => [JoinType::Inner]; + yield 'left join' => [JoinType::Left]; + yield 'cross join' => [JoinType::Cross]; + } + + /** + * @return iterable + */ + public static function placements(): iterable + { + foreach ([JoinType::Inner, JoinType::Left, JoinType::Right, JoinType::FullOuter, JoinType::Cross] as $joinType) { + foreach (['without' => false, 'with' => true] as $label => $preservingOuterJoin) { + yield "{$joinType->value} {$label} a preserving outer join" => [$joinType, $preservingOuterJoin]; + } + } + } + + #[DataProvider('preservingJoins')] + public function testBothSidesAreCheckedInsideOn(JoinType $joinType): void + { + $result = (new OuterJoin(self::SOURCE, [ + self::SOURCE => $this->permission(self::SOURCE)->filter(self::SOURCE), + self::ALIAS => $this->permission(self::ALIAS)->filter(self::ALIAS), + ]))->filterJoin(self::ALIAS, $joinType); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement, 'Only ON decides which rows the join pairs'); + $this->assertSame( + '('.$this->permission(self::SOURCE)->filter(self::SOURCE)->expression.' OR `main`.`_uid` IS NULL) AND ' + .$this->permission(self::ALIAS)->filter(self::ALIAS)->expression, + $result->condition->expression, + ); + $this->assertSame( + [...$this->permission(self::SOURCE)->filter(self::SOURCE)->bindings, ...$this->permission(self::ALIAS)->filter(self::ALIAS)->bindings], + $result->condition->bindings, + ); + } + + #[DataProvider('otherJoins')] + public function testJoinsThatDropUnreadableRowsInTheirOwnPlacementNeedNothing(JoinType $joinType): void + { + $hook = new OuterJoin(self::SOURCE, [ + self::SOURCE => new Condition('main.ok'), + self::ALIAS => new Condition('j0.ok'), + ]); + + $this->assertNull($hook->filterJoin(self::ALIAS, $joinType)); + } + + public function testATableReadThroughItsCollectionGrantIsNotChecked(): void + { + $sourceOnly = (new OuterJoin(self::SOURCE, [self::SOURCE => new Condition('main.ok = ?', [1])], '"')) + ->filterJoin(self::ALIAS, JoinType::Right); + $joinedOnly = (new OuterJoin(self::SOURCE, [self::ALIAS => new Condition('j0.ok = ?', [2])])) + ->filterJoin(self::ALIAS, JoinType::Right); + + $this->assertNotNull($sourceOnly); + $this->assertSame('(main.ok = ? OR "main"."_uid" IS NULL)', $sourceOnly->condition->expression); + $this->assertSame([1], $sourceOnly->condition->bindings); + $this->assertNotNull($joinedOnly); + $this->assertSame('j0.ok = ?', $joinedOnly->condition->expression); + $this->assertSame([2], $joinedOnly->condition->bindings); + $this->assertNull((new OuterJoin(self::SOURCE, []))->filterJoin(self::ALIAS, JoinType::FullOuter)); + } + + /** + * Permission conditions are placed exactly where tenant conditions are, and let through the + * same missing rows. + */ + #[DataProvider('placements')] + public function testPermissionJoinFilterPlacesItsConditionWhereTenantFilterDoes(JoinType $joinType, bool $preservingOuterJoin): void + { + $permission = (new Join($this->permission(self::ALIAS), self::ALIAS, preservingOuterJoin: $preservingOuterJoin)) + ->filterJoin(self::ALIAS, $joinType); + $tenant = (new TenantFilter(7, allowNullColumn: $preservingOuterJoin ? self::SOURCE.'.'.Storage::UID : '')) + ->filterJoin(self::ALIAS, $joinType); + + $this->assertNotNull($permission); + $this->assertSame($tenant->placement, $permission->placement); + $this->assertSame( + \str_contains($tenant->condition->expression, '`j0`.`_uid` IS NULL'), + \str_contains($permission->condition->expression, '`j0`.`_uid` IS NULL'), + ); + } + + private function permission(string $alias): Filter + { + return new Filter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'perms_'.$table, + documentColumn: $alias.'.'.Storage::UID, + ); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Tenant/FilterTest.php b/tests/unit/Adapter/SQL/Hook/Tenant/FilterTest.php new file mode 100644 index 0000000000..f9d76e9ac5 --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Tenant/FilterTest.php @@ -0,0 +1,168 @@ +filter('perms'); + + $this->assertStringContainsString('IS NULL', $condition->expression, 'The metadata permissions table must match tenantless rows'); + $this->assertSame([989], $condition->bindings); + } + + public function testANonMetadataPermissionsTableStaysStrictlyTenanted(): void + { + $hook = new Filter(989, Database::METADATA, Storage::permissionsTable('orders')); + + $condition = $hook->filter('perms'); + + $this->assertStringNotContainsString('IS NULL', $condition->expression, 'A project collection must not leak across tenants'); + $this->assertSame([989], $condition->bindings); + } + + public function testFilterDoesNotAllowNullTenantByDefault(): void + { + $hook = new Filter(7, '', 'orders'); + + $condition = $hook->filter('table_main'); + + $this->assertSame('`table_main`.'.Storage::TENANT.' IN (?)', $condition->expression); + $this->assertSame([7], $condition->bindings); + } + + public function testFilterAllowsNullTenantWhenColumnProvided(): void + { + $hook = new Filter(7, '', 'orders', 'table_main.'.Storage::TENANT); + + $condition = $hook->filter('table_main'); + + $this->assertStringContainsString('`table_main`.'.Storage::TENANT.' IN (?)', $condition->expression); + $this->assertStringContainsString('IS NULL', $condition->expression); + $this->assertSame([7], $condition->bindings); + } + + public function testFilterJoinLeftPlacesTenantInOnClause(): void + { + $hook = new Filter(7); + $result = $hook->filterJoin('j0', JoinType::Left); + + $this->assertSame(Placement::On, $result->placement); + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + $this->assertSame([7], $result->condition->bindings); + } + + public function testFilterJoinRightPlacesTenantInWhereClause(): void + { + $hook = new Filter(7); + $result = $hook->filterJoin('j0', JoinType::Right); + + $this->assertSame(Placement::Where, $result->placement); + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + $this->assertSame([7], $result->condition->bindings); + } + + public function testFilterJoinFullOuterPlacesTenantInWhereClauseAndAllowsNull(): void + { + $hook = new Filter(7); + $result = $hook->filterJoin('j0', JoinType::FullOuter); + + $this->assertSame(Placement::Where, $result->placement); + $this->assertStringContainsString('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + $this->assertStringContainsString('IS NULL', $result->condition->expression); + $this->assertSame([7], $result->condition->bindings); + } + + public function testFilterJoinInnerPlacesTenantInOnClause(): void + { + $hook = new Filter(7); + $result = $hook->filterJoin('j0', JoinType::Inner); + + $this->assertSame(Placement::On, $result->placement); + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + } + + /** + * @return iterable + */ + public static function onJoins(): iterable + { + yield 'inner' => [JoinType::Inner]; + yield 'left' => [JoinType::Left]; + } + + #[DataProvider('onJoins')] + public function testFilterJoinMatchesTheTenantOfAnUnindexedJoinAsARange(JoinType $joinType): void + { + $hook = new Filter(7, unindexed: ['j0']); + $result = $hook->filterJoin('j0', $joinType); + + $this->assertSame(Placement::On, $result->placement); + $this->assertSame('(`j0`.'.Storage::TENANT.' >= ? AND `j0`.'.Storage::TENANT.' <= ?)', $result->condition->expression); + $this->assertSame([7, 7], $result->condition->bindings); + } + + public function testFilterJoinKeepsTheEqualityOfAnIndexedJoin(): void + { + $hook = new Filter(7, unindexed: ['j1']); + $result = $hook->filterJoin('j0', JoinType::Inner); + + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + $this->assertSame([7], $result->condition->bindings); + } + + /** + * @return iterable + */ + public static function whereJoins(): iterable + { + yield 'right' => [JoinType::Right]; + yield 'full outer' => [JoinType::FullOuter]; + yield 'cross' => [JoinType::Cross]; + } + + #[DataProvider('whereJoins')] + public function testFilterJoinKeepsTheEqualityOfAnUnindexedJoinPlacedInWhere(JoinType $joinType): void + { + $hook = new Filter(7, unindexed: ['j0']); + $result = $hook->filterJoin('j0', $joinType); + + $this->assertSame(Placement::Where, $result->placement); + $this->assertStringContainsString('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + $this->assertStringNotContainsString('>=', $result->condition->expression); + } + + public function testFilterJoinMatchesSeveralTenantsOfAnUnindexedJoinAsAList(): void + { + $hook = new Filter([7, 8], unindexed: ['j0']); + $result = $hook->filterJoin('j0', JoinType::Inner); + + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?, ?)', $result->condition->expression); + $this->assertSame([7, 8], $result->condition->bindings); + } + + public function testFilterJoinCrossPlacesTenantInWhereClause(): void + { + $hook = new Filter(7); + $result = $hook->filterJoin('j0', JoinType::Cross); + + $this->assertSame(Placement::Where, $result->placement); + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Tenant/OuterJoinTest.php b/tests/unit/Adapter/SQL/Hook/Tenant/OuterJoinTest.php new file mode 100644 index 0000000000..4db90e5cf3 --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Tenant/OuterJoinTest.php @@ -0,0 +1,100 @@ + + */ + public static function preservingJoins(): iterable + { + yield 'right join' => [JoinType::Right]; + yield 'full outer join' => [JoinType::FullOuter]; + } + + /** + * @return iterable + */ + public static function otherJoins(): iterable + { + yield 'inner join' => [JoinType::Inner]; + yield 'left join' => [JoinType::Left]; + yield 'cross join' => [JoinType::Cross]; + yield 'natural join' => [JoinType::Natural]; + } + + #[DataProvider('preservingJoins')] + public function testBothSidesAreScopedToTheTenantInsideOn(JoinType $joinType): void + { + $filter = new Filter(7, Database::METADATA, 'orders', self::SOURCE.'.'.Storage::UID); + + $result = (new OuterJoin($filter, self::SOURCE))->filterJoin(self::ALIAS, $joinType); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement, 'Only ON decides which rows the join pairs'); + $this->assertSame( + '(`table_main`._tenant IN (?) OR `table_main`.`_uid` IS NULL) AND `j0`._tenant IN (?)', + $result->condition->expression, + ); + $this->assertSame([7, 7], $result->condition->bindings); + } + + #[DataProvider('otherJoins')] + public function testJoinsTenantFilterAlreadyScopesAreLeftToIt(JoinType $joinType): void + { + $filter = new Filter(7, Database::METADATA, 'orders'); + + $this->assertNull((new OuterJoin($filter, self::SOURCE))->filterJoin(self::ALIAS, $joinType)); + } + + public function testATenantlessMetadataRowStaysMatchable(): void + { + $filter = new Filter(7, Database::METADATA, Database::METADATA); + + $result = (new OuterJoin($filter, self::SOURCE))->filterJoin(self::ALIAS, JoinType::Right); + + $this->assertNotNull($result); + $this->assertSame( + '(`table_main`._tenant IN (?) OR `table_main`._tenant IS NULL) AND `j0`._tenant IN (?)', + $result->condition->expression, + 'A shared pool defines its metadata once, with no tenant, for every tenant to read', + ); + } + + public function testEveryTenantOfACrossTenantReadIsBoundOnBothSides(): void + { + $filter = new Filter([1, 2], Database::METADATA, 'orders'); + + $result = (new OuterJoin($filter, self::SOURCE))->filterJoin(self::ALIAS, JoinType::FullOuter); + + $this->assertNotNull($result); + $this->assertSame('`table_main`._tenant IN (?, ?) AND `j0`._tenant IN (?, ?)', $result->condition->expression); + $this->assertSame([1, 2, 1, 2], $result->condition->bindings); + } + + public function testAFullOuterJoinTreatsOnlyAMissingJoinedRowAsUnmatched(): void + { + $result = (new Filter(7))->filterJoin(self::ALIAS, JoinType::FullOuter); + + $this->assertSame(Placement::Where, $result->placement); + $this->assertSame( + '(`j0`._tenant IN (?) OR `j0`.`_uid` IS NULL)', + $result->condition->expression, + 'A stored row without a tenant is not a missing row, so the tenant column cannot tell them apart', + ); + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Tenant/RawOuterJoinTest.php b/tests/unit/Adapter/SQL/Hook/Tenant/RawOuterJoinTest.php new file mode 100644 index 0000000000..e75e2b0bc1 --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Tenant/RawOuterJoinTest.php @@ -0,0 +1,38 @@ +filterJoin('Book', $type); + $this->assertNull($hook->filterJoin('Book', $type), "A {$type->value} meets its condition where Raw places it"); + $tenants->reset(); + } + + foreach ([JoinType::Right, JoinType::FullOuter] as $type) { + $tenants->filterJoin('Book', $type); + $result = $hook->filterJoin('Book', $type); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement, 'Only ON decides which rows the join pairs'); + $this->assertSame($tenants->outerJoin('Book', $type)->expression, $result->condition->expression); + $this->assertSame( + '(`ns_authors`._tenant IN (?) OR `ns_authors`.`_uid` IS NULL) AND `Book`._tenant IN (?)', + $result->condition->expression, + ); + $tenants->reset(); + } + } +} diff --git a/tests/unit/Adapter/SQL/Hook/Tenant/RawTest.php b/tests/unit/Adapter/SQL/Hook/Tenant/RawTest.php new file mode 100644 index 0000000000..21437657c2 --- /dev/null +++ b/tests/unit/Adapter/SQL/Hook/Tenant/RawTest.php @@ -0,0 +1,177 @@ +filter()->filterJoin('Book', $type); + + $this->assertSame(Placement::On, $result->placement, "A {$type->value} only pairs the tenant's rows"); + $this->assertSame('`Book`._tenant IN (?)', $result->condition->expression); + $this->assertSame([self::TENANT], $result->condition->bindings); + } + } + + public function testOtherJoinsMeetTheirConditionInWhereLettingMissingRowsThrough(): void + { + foreach ([JoinType::Right, JoinType::FullOuter, JoinType::Cross, JoinType::Natural] as $type) { + $result = $this->filter()->filterJoin('Book', $type); + + $this->assertSame(Placement::Where, $result->placement, "ON cannot drop the rows a {$type->value} keeps"); + $this->assertSame( + '(`Book`._tenant IN (?) OR `Book`.`_uid` IS NULL)', + $result->condition->expression, + 'A later outer join may leave the table missing from a row', + ); + $this->assertSame([self::TENANT], $result->condition->bindings); + } + } + + public function testTheMainTableIsNamedAsTheBuilderNamesIt(): void + { + $condition = $this->filter()->filter(self::TABLE); + + $this->assertSame('`appwrite`.`ns_authors`._tenant IN (?)', $condition->expression, 'A bare column is ambiguous once a join is added'); + $this->assertSame([self::TENANT], $condition->bindings); + $this->assertSame('`author`._tenant IN (?)', $this->filter()->filter('author')->expression, 'A main table the caller aliases is named by its alias'); + } + + /** + * @return iterable + */ + public static function joinTypes(): iterable + { + yield 'inner' => [JoinType::Inner, false]; + yield 'left' => [JoinType::Left, false]; + yield 'cross' => [JoinType::Cross, false]; + yield 'natural' => [JoinType::Natural, false]; + yield 'right' => [JoinType::Right, true]; + yield 'full outer' => [JoinType::FullOuter, true]; + } + + #[DataProvider('joinTypes')] + public function testTheMainTableLetsMissingRowsThroughOnlyAfterARightOrFullOuterJoin(JoinType $type, bool $preserving): void + { + $filter = $this->filter(); + $filter->filterJoin('Book', $type); + + $this->assertSame( + $preserving + ? '(`appwrite`.`ns_authors`._tenant IN (?) OR `appwrite`.`ns_authors`.`_uid` IS NULL)' + : '`appwrite`.`ns_authors`._tenant IN (?)', + $filter->filter(self::TABLE)->expression, + ); + } + + public function testMetadataKeepsTheDefinitionsAPoolSharesWithoutATenant(): void + { + $filter = new Raw(self::TENANT, self::TABLE, true, '`'); + + $this->assertSame( + '(`appwrite`.`ns_authors`._tenant IN (?) OR `appwrite`.`ns_authors`._tenant IS NULL)', + $filter->filter(self::TABLE)->expression, + ); + $this->assertSame('`Book`._tenant IN (?)', $filter->filterJoin('Book', JoinType::Inner)->condition->expression, 'Only the main table is metadata'); + } + + public function testEachStatementLearnsItsOwnJoins(): void + { + $filter = $this->filter(); + $filter->filterJoin('Book', JoinType::Right); + $filter->filter(self::TABLE); + + $this->assertSame( + '`appwrite`.`ns_authors`._tenant IN (?)', + $filter->filter(self::TABLE)->expression, + 'An update after a read with a right join has no join of its own', + ); + + $filter->filterJoin('Book', JoinType::Right); + $filter->reset(); + + $this->assertSame('`appwrite`.`ns_authors`._tenant IN (?)', $filter->filter(self::TABLE)->expression, 'A build starts without the joins an abandoned one learned'); + } + + public function testARightOrFullOuterJoinRefusesARenamedMainTable(): void + { + foreach ([JoinType::Right, JoinType::FullOuter] as $type) { + $filter = $this->filter(); + $filter->filterJoin('Book', $type); + + try { + $filter->filter('author'); + $this->fail("A {$type->value} named the main table as Database::from() names it in its ON"); + } catch (QueryException $exception) { + $this->assertStringContainsString(self::TABLE, $exception->getMessage()); + } + } + + $filter = $this->filter(); + $filter->filterJoin('Book', JoinType::Inner); + $this->assertSame('`author`._tenant IN (?)', $filter->filter('author')->expression); + } + + public function testAStatementWithoutATableIsRefused(): void + { + $this->expectException(QueryException::class); + $this->filter()->filter(''); + } + + public function testARightOrFullOuterJoinPairsOnlyRowsTheTenantCouldRead(): void + { + $filter = $this->filter(); + $filter->filterJoin('Extra', JoinType::Cross); + $filter->filterJoin('Note', JoinType::Inner); + $filter->filterJoin('Review', JoinType::Right); + + $condition = $filter->outerJoin('Review', JoinType::Right); + + $this->assertSame( + '(`appwrite`.`ns_authors`._tenant IN (?) OR `appwrite`.`ns_authors`.`_uid` IS NULL)' + .' AND `Review`._tenant IN (?)' + .' AND (`Extra`._tenant IN (?) OR `Extra`.`_uid` IS NULL)', + $condition->expression, + 'The main table, the join itself and the earlier table whose condition sits in WHERE; the inner join met its own in ON', + ); + $this->assertSame([self::TENANT, self::TENANT, self::TENANT], $condition->bindings); + } + + public function testPostgresQuotesEveryTableItNames(): void + { + $filter = new Raw(self::TENANT, 'appwrite.ns_authors', false, '"'); + $filter->filterJoin('Book', JoinType::FullOuter); + + $this->assertSame('("Review"._tenant IN (?) OR "Review"."_uid" IS NULL)', $filter->filterJoin('Review', JoinType::Right)->condition->expression); + $this->assertSame( + '("appwrite"."ns_authors"._tenant IN (?) OR "appwrite"."ns_authors"."_uid" IS NULL) AND "Review"._tenant IN (?) AND ("Book"._tenant IN (?) OR "Book"."_uid" IS NULL)', + $filter->outerJoin('Review', JoinType::Right)->expression, + ); + } + + public function testADigitLeadingOuterJoinAliasAndDatabaseAreQuoted(): void + { + $filter = new Raw(self::TENANT, '1db.ns_authors', false, '`'); + + $this->assertSame('(`9x`._tenant IN (?) OR `9x`.`_uid` IS NULL)', $filter->filterJoin('9x', JoinType::Right)->condition->expression); + $this->assertSame('(`1db`.`ns_authors`._tenant IN (?) OR `1db`.`ns_authors`.`_uid` IS NULL)', $filter->filter('1db.ns_authors')->expression); + } + + private function filter(): Raw + { + return new Raw(self::TENANT, self::TABLE, false, '`'); + } +} diff --git a/tests/unit/Adapter/SQLArrayWritesTest.php b/tests/unit/Adapter/SQLArrayWritesTest.php new file mode 100644 index 0000000000..605dcce39c --- /dev/null +++ b/tests/unit/Adapter/SQLArrayWritesTest.php @@ -0,0 +1,153 @@ +pdo = new PDO('sqlite::memory:'); + $this->adapter = new SQLite($this->pdo); + $this->database = new Database($this->adapter, new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'items', + attributes: [ + Attribute::string('tags', size: 16, array: true), + Attribute::integer('numbers', array: true, default: [1, 2, 2, 3]), + Attribute::string('words', size: 16, array: true, default: ['a', 'b', 'b']), + Attribute::boolean('active'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + } + + public function testCreatedDocumentsKeepTheirArrays(): void + { + $this->database->createDocuments('items', [ + new Document(['$id' => 'first', 'tags' => ['red', 'blue'], 'numbers' => [7, 8]]), + new Document(['$id' => 'second', 'tags' => ['green'], 'numbers' => []]), + ]); + + $this->assertSame(['red', 'blue'], $this->database->getDocument('items', 'first')->getAttribute('tags')); + $this->assertSame([7, 8], $this->database->getDocument('items', 'first')->getAttribute('numbers')); + $this->assertSame(['green'], $this->database->getDocument('items', 'second')->getAttribute('tags')); + $this->assertSame([], $this->database->getDocument('items', 'second')->getAttribute('numbers')); + $this->assertSame(['first'], $this->ids($this->database->find('items', [Query::contains('tags', ['blue'])]))); + $this->assertSame(['["red","blue"]', '["green"]'], $this->stored('tags')); + } + + public function testUpdatedDocumentsStoreArraysAsJsonAndBooleansAsIntegers(): void + { + $this->database->createDocuments('items', [ + new Document(['$id' => 'first', 'tags' => ['old'], 'active' => false]), + new Document(['$id' => 'second', 'active' => false]), + ]); + + $this->assertSame(2, $this->database->updateDocuments('items', new Document(['tags' => ['a', 'b'], 'active' => true]))); + + foreach (['first', 'second'] as $id) { + $document = $this->database->getDocument('items', $id); + $this->assertSame(['a', 'b'], $document->getAttribute('tags')); + $this->assertTrue($document->getAttribute('active')); + } + $this->assertSame(['["a","b"]', '["a","b"]'], $this->stored('tags')); + $this->assertSame([1, 1], $this->stored('active')); + } + + public function testAnUpdateWithNothingToSetChangesNothing(): void + { + $this->database->createDocument('items', new Document(['$id' => 'first', 'tags' => ['kept']])); + $document = $this->database->getDocument('items', 'first'); + + $this->assertSame(0, $this->adapter->updateDocuments($this->database->getCollection('items'), new Document([]), [$document])); + + $this->assertSame(['["kept"]'], $this->stored('tags')); + } + + /** + * @return iterable}> + */ + public static function newDocumentArrayOperators(): iterable + { + yield 'unique integers' => ['numbers', Operator::arrayUnique(), [1, 2, 3]]; + yield 'remove an integer' => ['numbers', Operator::arrayRemove(2), [1, 3]]; + yield 'intersect integers' => ['numbers', Operator::arrayIntersect([2, 3]), [2, 2, 3]]; + yield 'diff integers' => ['numbers', Operator::arrayDiff([1]), [2, 2, 3]]; + yield 'unique strings' => ['words', Operator::arrayUnique(), ['a', 'b']]; + yield 'remove a string' => ['words', Operator::arrayRemove('b'), ['a']]; + yield 'intersect strings' => ['words', Operator::arrayIntersect(['b']), ['b', 'b']]; + yield 'diff strings' => ['words', Operator::arrayDiff(['a']), ['b', 'b']]; + } + + /** + * @param list $expected + */ + #[DataProvider('newDocumentArrayOperators')] + public function testAnArrayOperatorOnANewDocumentKeepsTheElementTypes(string $attribute, Operator $operator, array $expected): void + { + $this->database->upsertDocument('items', new Document(['$id' => 'created', $attribute => $operator])); + + $this->assertSame($expected, $this->database->getDocument('items', 'created')->getAttribute($attribute)); + } + + public function testANestedOperandMatchesNoElementOfANewDocument(): void + { + $this->database->setValidation(false); + + $this->database->upsertDocument('items', new Document(['$id' => 'created', 'numbers' => Operator::arrayIntersect([[2]])])); + + $this->assertSame([], $this->database->getDocument('items', 'created')->getAttribute('numbers')); + } + + /** + * @param array $documents + * @return array + */ + private function ids(array $documents): array + { + return \array_map(static fn (Document $document): string => $document->getId(), $documents); + } + + /** + * @return list + */ + private function stored(string $column): array + { + $statement = $this->pdo->query('SELECT `' . $column . '` FROM `' . self::NAMESPACE . '_items` ORDER BY _id'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + /** @var list */ + return $statement->fetchAll(PDO::FETCH_COLUMN); + } +} diff --git a/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php b/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php new file mode 100644 index 0000000000..1d9398b6ef --- /dev/null +++ b/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php @@ -0,0 +1,123 @@ + */ + private array $statements = []; + + /** + * @return iterable, RelationshipType, RelationshipSide, string|null, string|null, string}> + */ + public static function renames(): iterable + { + $engines = [ + 'MariaDB' => [MariaDB::class, 'ALTER TABLE `database`.`namespace_%s` RENAME COLUMN `%s` TO `%s`;'], + 'Postgres' => [Postgres::class, 'ALTER TABLE "database"."namespace_%s" RENAME COLUMN "%s" TO "%s";'], + ]; + + foreach ($engines as $engine => [$class, $statement]) { + $stored = \sprintf($statement, 'books', 'author', 'writer'); + yield $engine . ' one-to-many key from the child' => [$class, RelationshipType::OneToMany, RelationshipSide::Child, 'writer', null, $stored]; + yield $engine . ' one-to-many two-way key from the parent' => [$class, RelationshipType::OneToMany, RelationshipSide::Parent, null, 'writer', $stored]; + yield $engine . ' many-to-one two-way key from the child' => [$class, RelationshipType::ManyToOne, RelationshipSide::Child, null, 'writer', $stored]; + yield $engine . ' many-to-one key from the parent' => [$class, RelationshipType::ManyToOne, RelationshipSide::Parent, 'writer', null, $stored]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('renames')] + public function testARenameTouchesTheColumnTheSideStores(string $class, RelationshipType $type, RelationshipSide $side, ?string $newKey, ?string $newTwoWayKey, string $expected): void + { + [$collection, $relationship] = $this->relationship($type, $side); + + $this->assertTrue($this->adapter($class)->updateRelationship($collection, $relationship, $side, new RelationshipUpdate(key: $newKey, twoWayKey: $newTwoWayKey))); + + $this->assertSame([$expected], $this->statements); + } + + /** + * @return iterable, RelationshipType, RelationshipSide, string|null, string|null}> + */ + public static function renamesOfColumnsTheSideDoesNotStore(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'Postgres' => Postgres::class] as $engine => $class) { + yield $engine . ' one-to-many two-way key from the child' => [$class, RelationshipType::OneToMany, RelationshipSide::Child, null, 'writer']; + yield $engine . ' one-to-many key from the parent' => [$class, RelationshipType::OneToMany, RelationshipSide::Parent, 'writer', null]; + yield $engine . ' many-to-one key from the child' => [$class, RelationshipType::ManyToOne, RelationshipSide::Child, 'writer', null]; + yield $engine . ' many-to-one two-way key from the parent' => [$class, RelationshipType::ManyToOne, RelationshipSide::Parent, null, 'writer']; + yield $engine . ' unchanged key from the child' => [$class, RelationshipType::OneToMany, RelationshipSide::Child, 'author', null]; + yield $engine . ' unchanged two-way key from the parent' => [$class, RelationshipType::OneToMany, RelationshipSide::Parent, null, 'author']; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('renamesOfColumnsTheSideDoesNotStore')] + public function testARenameOfAColumnTheSideDoesNotStoreSendsNothing(string $class, RelationshipType $type, RelationshipSide $side, ?string $newKey, ?string $newTwoWayKey): void + { + [$collection, $relationship] = $this->relationship($type, $side); + + $this->assertTrue($this->adapter($class)->updateRelationship($collection, $relationship, $side, new RelationshipUpdate(key: $newKey, twoWayKey: $newTwoWayKey))); + + $this->assertSame([], $this->statements); + } + + /** + * @return array{string, Relationship} + */ + private function relationship(RelationshipType $type, RelationshipSide $side): array + { + $booksStoreTheKey = ($type === RelationshipType::OneToMany) === ($side === RelationshipSide::Child); + + return $booksStoreTheKey + ? ['books', self::define($type, relatedCollection: 'authors', key: 'author', twoWayKey: 'books')] + : ['authors', self::define($type, relatedCollection: 'books', key: 'books', twoWayKey: 'author')]; + } + + private static function define(RelationshipType $type, string $relatedCollection, string $key, string $twoWayKey): Relationship + { + return match ($type) { + RelationshipType::OneToMany => Relationship::oneToMany($relatedCollection, $key, twoWay: true, twoWayKey: $twoWayKey), + RelationshipType::ManyToOne => Relationship::manyToOne($relatedCollection, $key, twoWay: true, twoWayKey: $twoWayKey), + default => throw new \LogicException('Only one-to-many and many-to-one relationships are renamed here'), + }; + } + + /** + * @param class-string $class + */ + private function adapter(string $class): SQL + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/SQLDeleteFailureTest.php b/tests/unit/Adapter/SQLDeleteFailureTest.php new file mode 100644 index 0000000000..c7b4bae11d --- /dev/null +++ b/tests/unit/Adapter/SQLDeleteFailureTest.php @@ -0,0 +1,164 @@ +pdo = new PDO('sqlite::memory:'); + $this->adapter = new SQLite($this->pdo); + $this->database = new Database($this->adapter, new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'notes', + attributes: [Attribute::string('body', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + $this->database->createDocument('notes', new Document(['$id' => 'first', 'body' => 'one'])); + $this->database->createDocument('notes', new Document(['$id' => 'second', 'body' => 'two'])); + } + + public function testAFailingWriteHookFailsTheDeleteAndKeepsTheDocument(): void + { + $failure = new RuntimeException('hook'); + $this->adapter->addWriteHook($this->failingHook($failure)); + + try { + $this->database->deleteDocument('notes', 'first'); + $this->fail('A failing write hook must fail the delete'); + } catch (DatabaseException $error) { + $this->assertSame('hook', $error->getMessage()); + $this->assertSame($failure, $this->rootCause($error)); + } + + $this->assertSame(['first', 'second'], $this->storedIds()); + } + + public function testAFailingWriteHookFailsTheBulkDeleteAndKeepsTheDocuments(): void + { + $failure = new RuntimeException('hook'); + $this->adapter->addWriteHook($this->failingHook($failure)); + + try { + $this->database->deleteDocuments('notes'); + $this->fail('A failing write hook must fail the bulk delete'); + } catch (DatabaseException $error) { + $this->assertSame('hook', $error->getMessage()); + $this->assertSame($failure, $this->rootCause($error)); + } + + $this->assertSame(['first', 'second'], $this->storedIds()); + } + + public function testADeleteTheEngineRefusesInSilentModeIsAnError(): void + { + $this->blockDeletes(); + + try { + $this->adapter->deleteDocument(new Document(['$id' => 'notes']), 'first'); + $this->fail('A delete the engine refuses must not be reported as done'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to delete document', $error->getMessage()); + } + + $this->assertSame(['first', 'second'], $this->storedIds()); + } + + public function testABulkDeleteTheEngineRefusesInSilentModeIsAnError(): void + { + $this->blockDeletes(); + $sequences = []; + foreach ($this->database->find('notes') as $document) { + $sequence = $document->getSequence(); + $this->assertNotNull($sequence); + $sequences[] = $sequence; + } + + try { + $this->adapter->deleteDocuments(new Document(['$id' => 'notes']), $sequences, ['first', 'second']); + $this->fail('A bulk delete the engine refuses must not be reported as done'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to delete documents', $error->getMessage()); + } + + $this->assertSame(['first', 'second'], $this->storedIds()); + } + + private function blockDeletes(): void + { + $this->pdo->exec('CREATE TRIGGER block_deletes BEFORE DELETE ON `' . self::NAMESPACE . "_notes` BEGIN SELECT RAISE(ABORT, 'deletes are blocked'); END"); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); + } + + private function failingHook(RuntimeException $failure): Interceptor + { + return new class ($failure) extends Interceptor { + public function __construct(private readonly RuntimeException $failure) + { + } + + #[\Override] + public function afterDocumentDelete(string $collection, array $documentIds, WriteContext $context): void + { + throw $this->failure; + } + }; + } + + private function rootCause(\Throwable $error): \Throwable + { + while ($error->getPrevious() !== null) { + $error = $error->getPrevious(); + } + + return $error; + } + + /** + * @return list + */ + private function storedIds(): array + { + $statement = $this->pdo->query('SELECT _uid FROM `' . self::NAMESPACE . '_notes` ORDER BY _uid'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + /** @var list */ + return $statement->fetchAll(PDO::FETCH_COLUMN); + } +} diff --git a/tests/unit/Adapter/SQLJoinWithoutValidationTest.php b/tests/unit/Adapter/SQLJoinWithoutValidationTest.php new file mode 100644 index 0000000000..26b0b198b0 --- /dev/null +++ b/tests/unit/Adapter/SQLJoinWithoutValidationTest.php @@ -0,0 +1,136 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + + $this->createCollection('customers', [Attribute::string('name', size: 64)]); + $this->createCollection('notes', [Attribute::string('customerId', size: 64), Attribute::string('body', size: 64)]); + + foreach (['c1', 'c2', 'c3'] as $customer) { + $this->database->createDocument('customers', new Document(['$id' => $customer, 'name' => $customer])); + } + foreach (['n1' => 'c1', 'n2' => 'c1', 'n3' => 'c2'] as $note => $customer) { + $this->database->createDocument('notes', new Document(['$id' => $note, 'customerId' => $customer, 'body' => $note])); + } + + $this->database->setValidation(false); + } + + public function testAJoinedSumOfAnInternalAttributeReadsTheMainTable(): void + { + $sequences = []; + foreach ($this->database->find('customers') as $customer) { + $sequences[$customer->getId()] = (int) $customer->getSequence(); + } + + $sum = $this->database->sum('customers', '$sequence', [Query::join('notes', 'note', [Query::on('$id', 'customerId')])]); + + $this->assertSame($sequences['c1'] * 2 + $sequences['c2'], $sum); + } + + public function testAJoinWithANonStringColumnIsAQueryError(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join ON requires left and right columns'); + + $this->database->find('customers', [Query::leftJoin('notes', 'note', [new Query(Method::On, '', ['$id', '=', 5])])]); + } + + public function testANestedJoinConditionWithoutAColumnIsAQueryError(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join ON requires left and right columns'); + + $this->database->find('customers', [Query::leftJoin('notes', 'note', [Query::on('', 'customerId')])]); + } + + public function testANestedJoinConditionWithBothColumnsJoins(): void + { + $rows = $this->database->find('customers', [ + Query::join('notes', 'note', [Query::on('$id', 'customerId')]), + Query::select(['$id', 'note.body']), + ]); + + $this->assertCount(3, $rows); + } + + public function testAJoinWithoutACollectionIsAQueryError(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage("Joined collection '' not found"); + + $this->database->find('customers', [Query::join('', 'j0', [Query::on('$id', 'customerId')])]); + } + + /** + * @return array}> + */ + public static function sumsOverAnUnknownPrefix(): array + { + $join = [Query::join('notes', 'note', [Query::on('$id', 'customerId')])]; + + return [ + 'plain name beside a join' => ['other.body', $join], + 'name holding the quote char beside a join' => ['other`.body', $join], + 'name holding the quote char without a join' => ['other`.body', []], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('sumsOverAnUnknownPrefix')] + public function testASumOverANameWhosePrefixIsNotAJoinAliasIsAnUnknownAttribute(string $attribute, array $queries): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Attribute not found'); + + $this->database->sum('customers', $attribute, $queries); + } + + /** + * @param list $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + } +} diff --git a/tests/unit/Adapter/SQLJoinedReadsTest.php b/tests/unit/Adapter/SQLJoinedReadsTest.php new file mode 100644 index 0000000000..aa9c56e7ed --- /dev/null +++ b/tests/unit/Adapter/SQLJoinedReadsTest.php @@ -0,0 +1,148 @@ +database(new SQLite(new PDO('sqlite::memory:'))); + $join = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + $this->assertSame('c1', $database->getDocument('customers', 'c1', [$join])->getId()); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Cannot lock a document for update when join queries are present'); + + $database->withTransaction(fn (): Document => $database->getDocument('customers', 'c1', [$join], forUpdate: true)); + } + + public function testAFullOuterJoinFollowedByANestedLeftJoinReachesBothHalves(): void + { + $queries = [ + Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::leftJoin('replies', 'reply', [Query::on('note.$id', 'noteId')]), + Query::select(['name', 'note.body', 'reply.text']), + ]; + + $expected = [ + '["c1","n1","r1"]', + '["c1","n2",null]', + '["c2","n3",null]', + '["c3",null,null]', + '[null,"n4","r2"]', + ]; + $this->assertSame($expected, $this->rows($this->emulated()->find('customers', $queries), ['name', 'note.body', 'reply.text'])); + $this->assertSame($expected, $this->rows($this->native()->find('customers', $queries), ['name', 'note.body', 'reply.text'])); + } + + public function testAFullOuterJoinFollowedByANestedRightJoinKeepsOnlyMatchedReplies(): void + { + $queries = [ + Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::rightJoin('replies', 'reply', [Query::on('note.$id', 'noteId')]), + Query::select(['name', 'note.body', 'reply.text']), + ]; + + $expected = ['["c1","n1","r1"]', '[null,"n4","r2"]', '[null,null,"r3"]']; + $this->assertSame($expected, $this->rows($this->emulated()->find('customers', $queries), ['name', 'note.body', 'reply.text'])); + $this->assertSame($expected, $this->rows($this->native()->find('customers', $queries), ['name', 'note.body', 'reply.text'])); + } + + public function testADistinctFullOuterJoinWithoutNamedSelectsIsOrdered(): void + { + $join = Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')]); + + foreach ([[], [Query::select(['*'])]] as $select) { + $queries = [Query::distinct(), $join, ...$select, Query::orderDesc('name')]; + $emulated = $this->ordered($this->emulated()->find('customers', $queries)); + + $this->assertSame(['c3', 'c2', 'c1', 'c1', null], $emulated); + $this->assertSame($this->ordered($this->native()->find('customers', $queries)), $emulated); + } + } + + private function emulated(): Database + { + return $this->database(new SQLite(new PDO('sqlite::memory:'))); + } + + private function native(): Database + { + return $this->database(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + + private function database(SQLite $adapter): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database->setDatabase('joined_reads')->setNamespace('joined_reads')->setAuthorization(new Authorization()); + $database->create(); + + $collections = [ + 'customers' => [Attribute::string('name', size: 16)], + 'notes' => [Attribute::string('customerId', size: 16), Attribute::string('body', size: 16)], + 'replies' => [Attribute::string('noteId', size: 16), Attribute::string('text', size: 16)], + ]; + foreach ($collections as $id => $attributes) { + $database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + } + + foreach (['c1', 'c2', 'c3'] as $customer) { + $database->createDocument('customers', new Document(['$id' => $customer, 'name' => $customer])); + } + foreach (['n1' => 'c1', 'n2' => 'c1', 'n3' => 'c2', 'n4' => 'cx'] as $note => $customer) { + $database->createDocument('notes', new Document(['$id' => $note, 'customerId' => $customer, 'body' => $note])); + } + foreach (['r1' => 'n1', 'r2' => 'n4', 'r3' => 'nx'] as $reply => $note) { + $database->createDocument('replies', new Document(['$id' => $reply, 'noteId' => $note, 'text' => $reply])); + } + + return $database; + } + + /** + * @param array $documents + * @param list $attributes + * @return list + */ + private function rows(array $documents, array $attributes): array + { + $rows = []; + foreach ($documents as $document) { + $rows[] = \json_encode(\array_map(static fn (string $attribute): mixed => $document->getAttribute($attribute), $attributes), JSON_THROW_ON_ERROR); + } + \sort($rows); + + return $rows; + } + + /** + * @param array $documents + * @return list + */ + private function ordered(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): mixed => $document->getAttribute('name'), $documents)); + } +} diff --git a/tests/unit/Adapter/SQLRawStatementTest.php b/tests/unit/Adapter/SQLRawStatementTest.php new file mode 100644 index 0000000000..84e037e399 --- /dev/null +++ b/tests/unit/Adapter/SQLRawStatementTest.php @@ -0,0 +1,93 @@ +assertSame('', $adapter->hostname()); + } + + public function testTheHostnameIsTheOneTheConnectionNames(): void + { + $adapter = new Postgres(new class ('pgsql:host=db.internal;dbname=app') extends DatabasePDO { + public function __construct(string $dsn) + { + $this->dsn = $dsn; + } + }); + + $this->assertSame('db.internal', $adapter->hostname()); + } + + public function testARawReadOnAMissingTableIsNotFound(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $adapter->rawQuery('SELECT * FROM missing_table'); + } + + public function testARawWriteOnAMissingTableIsNotFound(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $adapter->rawMutation('UPDATE missing_table SET value = ?', [1]); + } + + public function testRawStatementsReadAndWriteAnExistingTable(): void + { + $pdo = new PDO('sqlite::memory:'); + $pdo->exec('CREATE TABLE present (value INTEGER)'); + $adapter = new SQLite($pdo); + + $this->assertSame(2, $adapter->rawMutation('INSERT INTO present (value) VALUES (?), (?)', [1, 2])); + $rows = $adapter->rawQuery('SELECT value FROM present WHERE value > ? ORDER BY value', [1]); + + $this->assertCount(1, $rows); + $this->assertSame(2, $rows[0]->getAttribute('value')); + } + + /** + * @return iterable + */ + public static function adapters(): iterable + { + yield 'MariaDB' => [new MariaDB(new \stdClass())]; + yield 'MySQL' => [new MySQL(new \stdClass())]; + yield 'Postgres' => [new Postgres(new \stdClass())]; + yield 'SQLite' => [new SQLite(new PDO('sqlite::memory:'))]; + } + + #[DataProvider('adapters')] + public function testAColumnTypeIsSpelledAsTheCatalogReportsIt(SQL $adapter): void + { + $this->assertSame('VARCHAR(64)', $adapter->getColumnType(Attribute::string(key: 'title', size: 64))); + } + + #[DataProvider('adapters')] + public function testAKnownColumnTypeIsMapped(SQL $adapter): void + { + $this->assertNotNull($adapter->getColumnType(Attribute::integer(key: 'count'))); + } +} diff --git a/tests/unit/Adapter/SQLSchemaBuilderTest.php b/tests/unit/Adapter/SQLSchemaBuilderTest.php new file mode 100644 index 0000000000..8e96368175 --- /dev/null +++ b/tests/unit/Adapter/SQLSchemaBuilderTest.php @@ -0,0 +1,58 @@ +setDatabase(self::NAMESPACE)->setNamespace(self::NAMESPACE)->setAuthorization(new Authorization()); + $database->create(); + + $rows = $database->getAuthorization()->skip(function () use ($database): array { + $table = $database->schema()->table(self::NAMESPACE . '_raw_items'); + $table->integer('value'); + $table->string('label', 16); + $table->create()->execute(); + + $database->mutate($database->from('raw_items')->set(['value' => 7, 'label' => 'seven'])->insert()); + $database->mutate($database->from('raw_items')->set(['value' => 8, 'label' => 'eight'])->insert()); + + return $database->query($database->from('raw_items')->select(['value', 'label'])->filter([BaseQuery::equal('value', [7])])); + }); + + $this->assertCount(1, $rows); + $this->assertSame(['value' => 7, 'label' => 'seven'], $rows[0]->getArrayCopy()); + + $database->getAuthorization()->skip(fn (): mixed => $database->schema()->table(self::NAMESPACE . '_raw_items')->drop()->execute()); + + $statement = $pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '" . self::NAMESPACE . "_raw_items'"); + $this->assertInstanceOf(\PDOStatement::class, $statement); + $this->assertSame([], $statement->fetchAll(PDO::FETCH_COLUMN)); + } + + public function testTheSchemaBuilderIsRefusedWithoutAQueryBuilder(): void + { + $database = new Database(new Memory(), new Cache(new NoCache())); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Schema builder is not supported by this adapter'); + + $database->schema(); + } +} diff --git a/tests/unit/Adapter/SQLVarcharAttributeTest.php b/tests/unit/Adapter/SQLVarcharAttributeTest.php new file mode 100644 index 0000000000..cf3272e1cc --- /dev/null +++ b/tests/unit/Adapter/SQLVarcharAttributeTest.php @@ -0,0 +1,106 @@ + */ + private array $statements = []; + + /** + * @return iterable, bool, int, string}> + */ + public static function invalidSizes(): iterable + { + foreach (['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class, 'Postgres' => Postgres::class] as $engine => $class) { + foreach (['one attribute' => false, 'several attributes' => true] as $shape => $several) { + yield $engine . ' ' . $shape . ' zero' => [$class, $several, 0, 'VARCHAR size 0 is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + yield $engine . ' ' . $shape . ' negative' => [$class, $several, -1, 'VARCHAR size -1 is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + yield $engine . ' ' . $shape . ' above the maximum' => [$class, $several, 16382, 'VARCHAR size 16382 exceeds maximum varchar length 16381. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + } + } + } + + /** + * @param class-string $class + */ + #[DataProvider('invalidSizes')] + public function testAVarcharAttributeOutsideItsSizesIsRefusedAsACollectionColumnIs(string $class, bool $several, int $size, string $message): void + { + $adapter = $this->adapter($class); + + try { + if ($several) { + $adapter->createAttributes('codes', [Attribute::string('name', size: 16), Attribute::varchar('code', size: $size)]); + } else { + $adapter->createAttribute('codes', Attribute::varchar('code', size: $size)); + } + $this->fail('A varchar column outside its sizes must be refused'); + } catch (DatabaseException $error) { + $this->assertSame($message, $error->getMessage()); + } + + try { + $adapter->createCollection('codes', [Attribute::varchar('code', size: $size)]); + $this->fail('A varchar column outside its sizes must be refused'); + } catch (DatabaseException $error) { + $this->assertSame($message, $error->getMessage()); + } + + $this->assertSame([], $this->statements); + } + + /** + * @return iterable, string}> + */ + public static function engines(): iterable + { + yield 'MariaDB' => [MariaDB::class, '`code` VARCHAR(16381)']; + yield 'MySQL' => [MySQL::class, '`code` VARCHAR(16381)']; + yield 'Postgres' => [Postgres::class, '"code" VARCHAR(16381)']; + } + + /** + * @param class-string $class + */ + #[DataProvider('engines')] + public function testAVarcharAttributeWithinItsSizesIsAdded(string $class, string $column): void + { + $this->assertTrue($this->adapter($class)->createAttribute('codes', Attribute::varchar('code', size: 16381))); + + $this->assertCount(1, $this->statements); + $this->assertStringContainsString($column, $this->statements[0]); + } + + /** + * @param class-string $class + */ + private function adapter(string $class): SQL + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + return $statement; + }); + + $adapter = new $class($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php b/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php new file mode 100644 index 0000000000..d3d1579849 --- /dev/null +++ b/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php @@ -0,0 +1,137 @@ +pdo = new PDO('sqlite::memory:'); + $this->database = new Database(new SQLite($this->pdo), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Relationships()); + $this->database->create(); + + foreach (['authors', 'books'] as $collection) { + $this->database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string('name', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + } + } + + public function testAOneToManyKeyRenamedFromTheChildSideRenamesItsColumn(): void + { + $this->database->createRelationship('authors', Relationship::oneToMany( + relatedCollection: 'books', + twoWay: true, + key: 'books', + twoWayKey: 'author', + )); + + $this->database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + + $this->assertContains('writer', $this->columns('books')); + $this->assertNotContains('author', $this->columns('books')); + + $this->database->createDocument('authors', new Document([ + '$id' => 'herbert', + 'name' => 'Herbert', + 'books' => [new Document(['$id' => 'dune', 'name' => 'Dune'])], + ])); + + $this->assertSame('herbert', $this->relatedId($this->database->getDocument('books', 'dune')->getAttribute('writer'))); + $this->assertSame(['dune'], $this->relatedIds($this->database->getDocument('authors', 'herbert')->getAttribute('books'))); + } + + public function testAManyToOneTwoWayKeyRenamedFromTheChildSideRenamesTheParentColumn(): void + { + $this->database->createRelationship('books', Relationship::manyToOne( + relatedCollection: 'authors', + twoWay: true, + key: 'author', + twoWayKey: 'books', + )); + + $this->database->updateRelationship('authors', 'books', new RelationshipUpdate(twoWayKey: 'writer')); + + $this->assertContains('writer', $this->columns('books')); + $this->assertNotContains('author', $this->columns('books')); + + $this->database->createDocument('books', new Document([ + '$id' => 'dune', + 'name' => 'Dune', + 'writer' => new Document(['$id' => 'herbert', 'name' => 'Herbert']), + ])); + + $this->assertSame('herbert', $this->relatedId($this->database->getDocument('books', 'dune')->getAttribute('writer'))); + $this->assertSame(['dune'], $this->relatedIds($this->database->getDocument('authors', 'herbert')->getAttribute('books'))); + } + + /** + * @return list + */ + private function columns(string $collection): array + { + $statement = $this->pdo->query('PRAGMA table_info(`' . self::NAMESPACE . '_' . $collection . '`)'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + $columns = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (\is_array($row) && \is_string($row['name'] ?? null)) { + $columns[] = $row['name']; + } + } + + return $columns; + } + + private function relatedId(mixed $related): string + { + $this->assertInstanceOf(Document::class, $related); + + return $related->getId(); + } + + /** + * @return list + */ + private function relatedIds(mixed $related): array + { + $this->assertIsArray($related); + + return \array_values(\array_map($this->relatedId(...), $related)); + } +} diff --git a/tests/unit/Adapter/SQLiteCreateCollectionCleanupTest.php b/tests/unit/Adapter/SQLiteCreateCollectionCleanupTest.php new file mode 100644 index 0000000000..9acec1c7a9 --- /dev/null +++ b/tests/unit/Adapter/SQLiteCreateCollectionCleanupTest.php @@ -0,0 +1,88 @@ +pdo = new PDO('sqlite::memory:'); + $this->adapter = new SQLite($this->pdo); + $this->adapter->setDatabase('main'); + $this->adapter->setNamespace(self::NAMESPACE); + } + + public function testADeclaredIndexThatFailsLeavesNoTableAndTheCollectionCanBeCreatedAgain(): void + { + $failure = null; + try { + $this->adapter->createCollection('books', [Attribute::string('title', size: 64)], [ + Index::key(key: 'missing_index', attributes: ['missing']), + ]); + } catch (Throwable $error) { + $failure = $error; + } + + $this->assertNotNull($failure, 'A declared index on a missing column must fail the collection'); + $this->assertInstanceOf(NotFoundException::class, $failure); + $this->assertSame('Attribute not found', $failure->getMessage()); + $previous = $failure->getPrevious(); + $this->assertInstanceOf(PDOException::class, $previous); + $this->assertStringContainsString('missing', $previous->getMessage()); + $this->assertSame([], $this->tables()); + + $this->assertTrue($this->adapter->createCollection('books', [Attribute::string('title', size: 64)], [ + Index::key(key: 'title_index', attributes: ['title']), + ])); + $this->assertSame([self::NAMESPACE . '_books', self::NAMESPACE . '_books_perms'], $this->tables()); + } + + public function testACollectionThatAlreadyExistsKeepsItsTables(): void + { + $this->adapter->createCollection('books', [Attribute::string('title', size: 64)]); + + try { + $this->adapter->createCollection('books', [Attribute::string('title', size: 64)]); + $this->fail('An existing collection must be reported'); + } catch (DuplicateException $error) { + $this->assertSame('Collection already exists', $error->getMessage()); + } + + $this->assertSame([self::NAMESPACE . '_books', self::NAMESPACE . '_books_perms'], $this->tables()); + } + + /** + * @return list + */ + private function tables(): array + { + $statement = $this->pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '" . self::NAMESPACE . "\\_%' ESCAPE '\\' ORDER BY name"); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + $names = []; + foreach ($statement->fetchAll(PDO::FETCH_COLUMN) as $name) { + if (\is_string($name)) { + $names[] = $name; + } + } + + return $names; + } +} diff --git a/tests/unit/Adapter/SQLiteManyToManyRelationshipRenameTest.php b/tests/unit/Adapter/SQLiteManyToManyRelationshipRenameTest.php new file mode 100644 index 0000000000..401c47bd28 --- /dev/null +++ b/tests/unit/Adapter/SQLiteManyToManyRelationshipRenameTest.php @@ -0,0 +1,199 @@ +addRole(Role::any()->toString()); + + $this->database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization($authorization); + $this->database->addHook(new Permissions()); + $this->database->addHook(new Relationships()); + $this->database->create(); + + foreach (['books', 'authors'] as $collection) { + $this->database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string('name', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + } + } + + public function testAOneWayTwoWayKeyRenameKeepsExistingAndNewRelations(): void + { + $this->createRelationship(twoWay: false); + $this->createBook('dune', ['herbert']); + + $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(twoWayKey: 'works')); + + $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); + + $this->createBook('emma', ['austen', 'herbert']); + + $this->assertSame(['austen', 'herbert'], $this->relatedIds('books', 'emma', 'authors')); + $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); + } + + public function testAOneWayKeyRenameKeepsExistingAndNewRelations(): void + { + $this->createRelationship(twoWay: false); + $this->createBook('dune', ['herbert']); + + $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(key: 'writers')); + + $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'writers')); + + $this->database->createDocument('books', new Document([ + '$id' => 'emma', + 'name' => 'emma', + 'writers' => [new Document(['$id' => 'austen', 'name' => 'austen'])], + ])); + + $this->assertSame(['austen'], $this->relatedIds('books', 'emma', 'writers')); + } + + public function testAOneWayRenameOfBothKeysKeepsExistingAndNewRelations(): void + { + $this->createRelationship(twoWay: false); + $this->createBook('dune', ['herbert']); + + $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(key: 'writers', twoWayKey: 'works')); + + $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'writers')); + + $this->database->createDocument('books', new Document([ + '$id' => 'emma', + 'name' => 'emma', + 'writers' => [new Document(['$id' => 'austen', 'name' => 'austen'])], + ])); + + $this->assertSame(['austen'], $this->relatedIds('books', 'emma', 'writers')); + } + + public function testATwoWayTwoWayKeyRenameKeepsBothSidesRelated(): void + { + $this->createRelationship(twoWay: true); + $this->createBook('dune', ['herbert']); + + $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(twoWayKey: 'works')); + + $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); + $this->assertSame(['dune'], $this->relatedIds('authors', 'herbert', 'works')); + + $this->createBook('emma', ['herbert']); + + $this->assertSame(['dune', 'emma'], $this->relatedIds('authors', 'herbert', 'works')); + } + + public function testATwoWayKeyRenameFromTheChildSideKeepsBothSidesRelated(): void + { + $this->createRelationship(twoWay: true); + $this->createBook('dune', ['herbert']); + + $this->database->updateRelationship('authors', 'books', new RelationshipUpdate(key: 'works')); + + $this->assertSame(['dune'], $this->relatedIds('authors', 'herbert', 'works')); + $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); + + $this->createBook('emma', ['herbert']); + + $this->assertSame(['dune', 'emma'], $this->relatedIds('authors', 'herbert', 'works')); + } + + public function testATwoWayKeyRenameToAnExistingRelatedAttributeIsRejected(): void + { + $this->createRelationship(twoWay: false); + $this->createBook('dune', ['herbert']); + + try { + $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(twoWayKey: 'name')); + $this->fail('Renaming the two-way key onto an existing attribute should be rejected'); + } catch (DuplicateException $error) { + $this->assertSame('Related attribute already exists', $error->getMessage()); + } + + $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); + } + + private function createRelationship(bool $twoWay): void + { + $this->database->createRelationship('books', Relationship::manyToMany( + relatedCollection: 'authors', + twoWay: $twoWay, + key: 'authors', + twoWayKey: 'books', + )); + } + + /** + * @param list $authors + */ + private function createBook(string $id, array $authors): void + { + $this->database->createDocument('books', new Document([ + '$id' => $id, + 'name' => $id, + 'authors' => \array_map( + fn (string $author): Document|string => $this->database->getDocument('authors', $author)->isEmpty() + ? new Document(['$id' => $author, 'name' => $author]) + : $author, + $authors, + ), + ])); + } + + /** + * @return list + */ + private function relatedIds(string $collection, string $id, string $key): array + { + $related = $this->database->getDocument($collection, $id)->getAttribute($key); + $this->assertIsArray($related); + + $ids = []; + foreach ($related as $document) { + $this->assertInstanceOf(Document::class, $document); + $ids[] = $document->getId(); + } + \sort($ids); + + return $ids; + } +} diff --git a/tests/unit/Adapter/SQLitePowerOperatorTest.php b/tests/unit/Adapter/SQLitePowerOperatorTest.php new file mode 100644 index 0000000000..e53b5a8750 --- /dev/null +++ b/tests/unit/Adapter/SQLitePowerOperatorTest.php @@ -0,0 +1,82 @@ +adapter = new SQLite(new PDO('sqlite::memory:')); + $this->database = new Database($this->adapter, new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'scores', + attributes: [Attribute::integer('value')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $this->database->createDocument('scores', new Document(['$id' => 'first', 'value' => 3])); + } + + /** + * @return iterable + */ + public static function nonNumericExponents(): iterable + { + yield 'word' => ['two']; + yield 'boolean' => [true]; + yield 'list' => [[2]]; + } + + #[DataProvider('nonNumericExponents')] + public function testPowerWithANonNumericExponentIsRefusedAndLeavesTheValue(mixed $exponent): void + { + try { + $this->adapter->updateDocuments( + $this->database->getCollection('scores'), + new Document(['value' => new Operator(OperatorType::Power, 'value', [$exponent])]), + [$this->database->getDocument('scores', 'first')], + ); + $this->fail('A power exponent that is not a number must be refused'); + } catch (OperatorException $error) { + $this->assertSame('Power exponent must be numeric', $error->getMessage()); + } + + $this->assertSame(3, $this->database->getDocument('scores', 'first')->getAttribute('value')); + } + + public function testPowerWithANumericExponentRaisesTheValue(): void + { + $this->database->updateDocument('scores', 'first', new Document(['value' => Operator::power(2)])); + + $this->assertSame(9, $this->database->getDocument('scores', 'first')->getAttribute('value')); + } +} diff --git a/tests/unit/Adapter/SQLiteSchemaTest.php b/tests/unit/Adapter/SQLiteSchemaTest.php new file mode 100644 index 0000000000..49ab262501 --- /dev/null +++ b/tests/unit/Adapter/SQLiteSchemaTest.php @@ -0,0 +1,191 @@ +pdo = new PDO('sqlite::memory:'); + $this->adapter = new SQLite($this->pdo); + $this->database = new Database($this->adapter, new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'notes', + attributes: [Attribute::string('title', size: 64)], + indexes: [Index::key(key: 'title_index', attributes: ['title'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + } + + public function testRenamingAnIndexTheMetadataLacksReturnsFalse(): void + { + $this->assertFalse($this->adapter->renameIndex('notes', 'missing', 'renamed')); + + $this->assertSame([self::NAMESPACE . '__notes_title_index'], $this->indexNames()); + } + + public function testRenamingAnIndexOfAnUnknownCollectionIsNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $this->adapter->renameIndex('missing', 'title_index', 'renamed'); + } + + public function testRenamingAStoredIndexRenamesIt(): void + { + $this->assertTrue($this->adapter->renameIndex('notes', 'title_index', 'renamed')); + + $this->assertSame([self::NAMESPACE . '__notes_renamed'], $this->indexNames()); + } + + /** + * @return iterable + */ + public static function stringSizes(): iterable + { + yield 'longest varchar' => [16381, 'VARCHAR(16381)']; + yield 'above the varchar maximum' => [16382, 'TEXT']; + yield 'above text' => [65536, 'MEDIUMTEXT']; + yield 'above medium text' => [16777216, 'LONGTEXT']; + } + + #[DataProvider('stringSizes')] + public function testAStringColumnGrowsIntoTheTypeItsSizeNeeds(int $size, string $type): void + { + $this->assertTrue($this->adapter->createCollection('texts', [Attribute::string('body', size: $size)])); + + $this->assertSame($type, $this->columnType('texts', 'body')); + } + + public function testALongStringIsStoredWhole(): void + { + $this->database->createCollection(Collection::create( + id: 'articles', + attributes: [Attribute::string('body', size: 20000000)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + $value = \str_repeat('abc', 10000); + + $this->database->createDocument('articles', new Document(['$id' => 'long', 'body' => $value])); + + $this->assertSame('LONGTEXT', $this->columnType('articles', 'body')); + $this->assertSame($value, $this->database->getDocument('articles', 'long')->getAttribute('body')); + } + + /** + * @return iterable + */ + public static function invalidVarcharSizes(): iterable + { + yield 'zero' => [0, 'VARCHAR size 0 is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + yield 'negative' => [-1, 'VARCHAR size -1 is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + yield 'above the maximum' => [16382, 'VARCHAR size 16382 exceeds maximum varchar length 16381. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.']; + } + + #[DataProvider('invalidVarcharSizes')] + public function testAVarcharColumnOutsideItsSizesIsRefused(int $size, string $message): void + { + try { + $this->adapter->createCollection('codes', [Attribute::varchar('code', size: $size)]); + $this->fail('A varchar column outside its sizes must be refused'); + } catch (DatabaseException $error) { + $this->assertSame($message, $error->getMessage()); + } + + $this->assertFalse($this->adapter->collectionExists(self::NAMESPACE, 'codes')); + } + + public function testAVarcharColumnWithinItsSizesIsCreated(): void + { + $this->assertTrue($this->adapter->createCollection('codes', [Attribute::varchar('code', size: 16381)])); + + $this->assertSame('VARCHAR(16381)', $this->columnType('codes', 'code')); + } + + public function testASpatialColumnIsUntypedAndKeepsItsWktAsText(): void + { + $this->assertTrue($this->adapter->createCollection('places', [Attribute::point('position')])); + $this->assertSame('', $this->columnType('places', 'position')); + + $collection = new Document([ + '$id' => 'places', + 'attributes' => [ + new Document(['$id' => 'position', 'key' => 'position', 'type' => ColumnType::Point->value]), + ], + ]); + $this->adapter->createDocuments($collection, [ + new Document(['$id' => 'origin', '$permissions' => [], 'position' => 'POINT(1 2)']), + ]); + + $statement = $this->pdo->query('SELECT `position` FROM `' . self::NAMESPACE . "_places` WHERE `_uid` = 'origin'"); + $this->assertInstanceOf(\PDOStatement::class, $statement); + $this->assertSame('POINT(1 2)', $statement->fetchColumn()); + } + + /** + * @return list + */ + private function indexNames(): array + { + $statement = $this->pdo->query("SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name"); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + $names = []; + foreach ($statement->fetchAll(PDO::FETCH_COLUMN) as $name) { + if (\is_string($name) && (\str_ends_with($name, '_title_index') || \str_ends_with($name, '_renamed'))) { + $names[] = $name; + } + } + + return $names; + } + + private function columnType(string $table, string $column): ?string + { + $statement = $this->pdo->query('PRAGMA table_info(`' . self::NAMESPACE . '_' . $table . '`)'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (\is_array($row) && ($row['name'] ?? null) === $column) { + return \is_string($row['type'] ?? null) ? $row['type'] : null; + } + } + + return null; + } +} diff --git a/tests/unit/Adapter/SQLiteSharedFulltextTest.php b/tests/unit/Adapter/SQLiteSharedFulltextTest.php new file mode 100644 index 0000000000..c50ae52cff --- /dev/null +++ b/tests/unit/Adapter/SQLiteSharedFulltextTest.php @@ -0,0 +1,162 @@ +pdo = new PDO('sqlite::memory:'); + $this->database = new Database(new SQLite($this->pdo), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setSharedTables(true) + ->setTenant(self::TENANTS[0]) + ->setAuthorization(new Authorization()); + $this->database->create(); + + foreach (self::TENANTS as $tenant) { + $this->database->setTenant($tenant); + $this->database->createCollection(Collection::create( + id: 'notes', + attributes: [Attribute::string('body', size: 128)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + $this->database->createDocument('notes', new Document(['$id' => 'early', 'body' => 'shared word written before the index'])); + $this->database->createIndex('notes', Index::fulltext(key: 'body_search', attributes: ['body'])); + $this->database->createDocument('notes', new Document(['$id' => 'late', 'body' => 'shared word written after the index'])); + } + } + + public function testAStringTenantsFulltextIndexSeesOnlyItsOwnRows(): void + { + foreach (self::TENANTS as $tenant) { + $this->assertSame(['early', 'late'], $this->search($tenant, 'shared'), $tenant . ' finds its own rows'); + $this->assertSame($this->sequences($tenant), $this->indexedRows($tenant), $tenant . ' indexes only its own rows'); + } + } + + public function testAnotherTenantsWritesLeaveTheIndexAlone(): void + { + $this->database->setTenant(self::TENANTS[1]); + $this->database->updateDocument('notes', 'late', new Document(['body' => 'rewritten'])); + $this->database->deleteDocument('notes', 'early'); + + $this->assertSame(['early', 'late'], $this->search(self::TENANTS[0], 'shared')); + $this->assertSame($this->sequences(self::TENANTS[0]), $this->indexedRows(self::TENANTS[0])); + + $this->assertSame([], $this->search(self::TENANTS[1], 'shared')); + $this->assertSame(['late'], $this->search(self::TENANTS[1], 'rewritten')); + $this->assertSame($this->sequences(self::TENANTS[1]), $this->indexedRows(self::TENANTS[1])); + } + + /** + * @return list + */ + private function search(string $tenant, string $term): array + { + $this->database->setTenant($tenant); + $ids = \array_map( + static fn (Document $document): string => $document->getId(), + $this->database->find('notes', [Query::search('body', $term)]), + ); + \sort($ids); + + return $ids; + } + + /** + * @return list + */ + private function sequences(string $tenant): array + { + $statement = $this->pdo->prepare('SELECT _id FROM `' . self::NAMESPACE . '_notes` WHERE _tenant = ? ORDER BY _id'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + $statement->execute([$tenant]); + + return \array_values(\array_map(static function (mixed $value): int { + self::assertIsNumeric($value); + + return (int) $value; + }, $statement->fetchAll(PDO::FETCH_COLUMN))); + } + + public function testAFulltextIndexForATenantTheDriverCannotQuoteIsRefused(): void + { + $pdo = new class ('sqlite::memory:') extends PDO { + #[\Override] + public function quote(string $string, int $type = PDO::PARAM_STR): string|false + { + return false; + } + }; + $adapter = new SQLite($pdo); + $adapter->setDatabase(self::NAMESPACE); + $adapter->setNamespace(self::NAMESPACE); + $adapter->setSharedTables(true); + $adapter->setTenant('tenant-c'); + $adapter->createCollection('notes', [Attribute::string('body', size: 128)]); + + try { + $adapter->createIndex('notes', Index::fulltext(key: 'body_search', attributes: ['body'])); + $this->fail('a tenant that cannot be written into the index triggers must refuse the index'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to quote SQLite tenant', $error->getMessage()); + } + } + + /** + * @return list + */ + private function indexedRows(string $tenant): array + { + $tables = $this->pdo->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? AND name LIKE '%\\_fts' ESCAPE '\\'"); + $this->assertInstanceOf(\PDOStatement::class, $tables); + $tables->execute([self::NAMESPACE . '_' . \str_replace("'", '', $tenant) . '_notes_%']); + $names = $tables->fetchAll(PDO::FETCH_COLUMN); + $this->assertCount(1, $names, 'one fulltext table for ' . $tenant); + $name = $names[0]; + $this->assertIsString($name); + + $statement = $this->pdo->query('SELECT rowid FROM `' . $name . '` WHERE `' . $name . "` MATCH 'word OR rewritten' ORDER BY rowid"); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + return \array_values(\array_map(static function (mixed $value): int { + self::assertIsNumeric($value); + + return (int) $value; + }, $statement->fetchAll(PDO::FETCH_COLUMN))); + } +} diff --git a/tests/unit/Adapter/SQLiteStatementPreparationTest.php b/tests/unit/Adapter/SQLiteStatementPreparationTest.php new file mode 100644 index 0000000000..c440e1fc3a --- /dev/null +++ b/tests/unit/Adapter/SQLiteStatementPreparationTest.php @@ -0,0 +1,156 @@ +file !== '' && \is_file($this->file)) { + \unlink($this->file); + } + } + + public function testATransformErrorReachesTheCallerUnchanged(): void + { + $refusal = new DatabaseException('refused'); + $adapter = $this->adapter(new PDO('sqlite::memory:')); + $adapter->addTransform('refuse', $this->transform(static function (Event $event, string $query) use ($refusal): string { + if ($event === Event::CollectionCreate) { + throw $refusal; + } + + return $query; + })); + + try { + $adapter->createCollection('notes', [Attribute::string('body', size: 64)]); + $this->fail('The transform refusal must reach the caller'); + } catch (DatabaseException $error) { + $this->assertSame($refusal, $error); + } + + $this->assertFalse($adapter->collectionExists('main', 'notes')); + } + + public function testAStatementTheDriverCannotPrepareIsAnAdapterError(): void + { + $adapter = $this->adapter(new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT])); + $adapter->addTransform('break', $this->transform(static fn (Event $event, string $query): string => 'NOT SQL')); + + try { + $adapter->createCollection('notes', [Attribute::string('body', size: 64)]); + $this->fail('A statement the driver cannot prepare must be refused'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to prepare SQLite statement', $error->getMessage()); + $previous = $error->getPrevious(); + $this->assertInstanceOf(DatabaseException::class, $previous); + $this->assertSame('Failed to prepare SQL statement', $previous->getMessage()); + } + } + + public function testAStrayDriverTransactionIsRolledBackBeforeTheNextOne(): void + { + $pdo = new PDO('sqlite::memory:'); + $pdo->exec('CREATE TABLE stray (value INTEGER)'); + $adapter = $this->adapter($pdo); + + $pdo->beginTransaction(); + $pdo->exec('INSERT INTO stray VALUES (1)'); + + $this->assertTrue($adapter->startTransaction()); + $pdo->exec('INSERT INTO stray VALUES (2)'); + $this->assertTrue($adapter->commitTransaction()); + + $this->assertFalse($pdo->inTransaction()); + $this->assertSame([2], $this->values($pdo)); + } + + public function testABusyDatabaseFailsToStartATransaction(): void + { + $this->file = \tempnam(\sys_get_temp_dir(), 'sqlite-busy-') ?: ''; + $this->assertNotSame('', $this->file); + + $holder = new PDO('sqlite:' . $this->file); + $holder->exec('BEGIN IMMEDIATE'); + + $adapter = $this->adapter(new PDO('sqlite:' . $this->file, options: [PDO::ATTR_TIMEOUT => 0])); + + try { + $adapter->startTransaction(); + $this->fail('A transaction must not start while another connection holds the writer lock'); + } catch (TransactionException $error) { + $this->assertStringStartsWith('Failed to start transaction: ', $error->getMessage()); + $this->assertStringContainsString('database is locked', $error->getMessage()); + } finally { + $holder->exec('ROLLBACK'); + } + + $this->assertFalse($adapter->inTransaction()); + $this->assertTrue($adapter->startTransaction()); + $this->assertTrue($adapter->commitTransaction()); + } + + private function adapter(PDO $pdo): SQLite + { + $adapter = new SQLite($pdo); + $adapter->setDatabase('main'); + $adapter->setNamespace('preparation'); + + return $adapter; + } + + /** + * @param callable(Event, string): string $callback + */ + private function transform(callable $callback): Transform + { + return new class ($callback) implements Transform { + /** + * @var callable(Event, string): string + */ + private $callback; + + /** + * @param callable(Event, string): string $callback + */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + #[\Override] + public function transform(Event $event, string $query): string + { + return ($this->callback)($event, $query); + } + }; + } + + /** + * @return list + */ + private function values(PDO $pdo): array + { + $statement = $pdo->query('SELECT value FROM stray ORDER BY value'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + return \array_values(\array_map(static function (mixed $value): int { + self::assertIsNumeric($value); + + return (int) $value; + }, $statement->fetchAll(PDO::FETCH_COLUMN))); + } +} diff --git a/tests/unit/Adapter/SQLiteUpsertArrayOperatorLimitTest.php b/tests/unit/Adapter/SQLiteUpsertArrayOperatorLimitTest.php new file mode 100644 index 0000000000..364b943806 --- /dev/null +++ b/tests/unit/Adapter/SQLiteUpsertArrayOperatorLimitTest.php @@ -0,0 +1,105 @@ +adapter = new SQLite(new PDO('sqlite::memory:')); + $this->database = new Database($this->adapter, new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'lists', + attributes: [Attribute::integer('numbers', array: true)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $this->database->createDocument('lists', new Document(['$id' => 'first', 'numbers' => [1, 2, 3]])); + } + + /** + * @return iterable): Operator}> + */ + public static function arrayOperators(): iterable + { + yield 'append' => [Operator::arrayAppend(...)]; + yield 'prepend' => [Operator::arrayPrepend(...)]; + yield 'intersect' => [Operator::arrayIntersect(...)]; + yield 'diff' => [Operator::arrayDiff(...)]; + } + + /** + * @param callable(list): Operator $operator + */ + #[DataProvider('arrayOperators')] + public function testAnOversizedArrayOperandIsRefusedAndLeavesTheArray(callable $operator): void + { + $size = Operator::MAX_ARRAY_OPERATOR_SIZE + 1; + + try { + $this->upsert($operator(\range(1, $size))); + $this->fail('An array operand above the limit must be refused'); + } catch (DatabaseException $error) { + $this->assertSame( + 'Array size ' . $size . ' exceeds maximum allowed size of ' . Operator::MAX_ARRAY_OPERATOR_SIZE . ' for array operations', + $error->getMessage(), + ); + } + + $this->assertSame([1, 2, 3], $this->database->getDocument('lists', 'first')->getAttribute('numbers')); + } + + public function testAnArrayOperandAtTheLimitIsApplied(): void + { + $this->upsert(Operator::arrayAppend(\array_fill(0, Operator::MAX_ARRAY_OPERATOR_SIZE, 9))); + + $numbers = $this->database->getDocument('lists', 'first')->getAttribute('numbers'); + $this->assertIsArray($numbers); + $this->assertCount(Operator::MAX_ARRAY_OPERATOR_SIZE + 3, $numbers); + } + + private function upsert(Operator $operator): void + { + $existing = $this->database->getDocument('lists', 'first'); + + $this->adapter->upsertDocuments( + $this->database->getCollection('lists'), + [new Change($existing, new Document([ + '$id' => 'first', + '$permissions' => [], + '$createdAt' => $existing->getCreatedAt(), + '$updatedAt' => $existing->getUpdatedAt(), + 'numbers' => $operator, + ]))], + ); + } +} diff --git a/tests/unit/Adapter/SQLiteUpsertArrayRemoveTest.php b/tests/unit/Adapter/SQLiteUpsertArrayRemoveTest.php new file mode 100644 index 0000000000..f18b9d0b80 --- /dev/null +++ b/tests/unit/Adapter/SQLiteUpsertArrayRemoveTest.php @@ -0,0 +1,74 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'items', + attributes: [ + Attribute::integer('numbers', array: true), + Attribute::float('ratios', array: true), + Attribute::string('words', size: 16, array: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + } + + /** + * @return iterable, int|float|string, list}> + */ + public static function removals(): iterable + { + yield 'an integer' => ['numbers', [1, 2, 2, 3], 2, [1, 3]]; + yield 'a float' => ['ratios', [0.5, 1.5, 0.5], 0.5, [1.5]]; + yield 'a string' => ['words', ['a', 'b', 'b'], 'b', ['a']]; + yield 'a numeric string' => ['words', ['1', '2', '2'], '2', ['1']]; + } + + /** + * @param list $stored + * @param list $expected + */ + #[DataProvider('removals')] + public function testAnUpsertOfAnExistingDocumentRemovesTheElementAsAnUpdateDoes(string $attribute, array $stored, int|float|string $removed, array $expected): void + { + $this->database->createDocument('items', new Document(['$id' => 'upserted', $attribute => $stored])); + $this->database->createDocument('items', new Document(['$id' => 'updated', $attribute => $stored])); + + $this->database->upsertDocument('items', new Document(['$id' => 'upserted', $attribute => Operator::arrayRemove($removed)])); + $this->database->updateDocument('items', 'updated', new Document([$attribute => Operator::arrayRemove($removed)])); + + $this->assertSame($expected, $this->database->getDocument('items', 'updated')->getAttribute($attribute)); + $this->assertSame($expected, $this->database->getDocument('items', 'upserted')->getAttribute($attribute)); + } +} diff --git a/tests/unit/Adapter/SQLiteUpsertIntegerOverflowTest.php b/tests/unit/Adapter/SQLiteUpsertIntegerOverflowTest.php new file mode 100644 index 0000000000..c37e0d1ae5 --- /dev/null +++ b/tests/unit/Adapter/SQLiteUpsertIntegerOverflowTest.php @@ -0,0 +1,84 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'counters', + attributes: [ + Attribute::bigInteger('high', default: PHP_INT_MAX - 5), + Attribute::bigInteger('low', default: PHP_INT_MIN + 5), + Attribute::string('digits', size: 64, default: '9223372036854775807'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + } + + /** + * @return iterable + */ + public static function overflows(): iterable + { + yield 'increment past the maximum' => ['high', Operator::increment(10)]; + yield 'multiply past the maximum' => ['high', Operator::multiply(2)]; + yield 'decrement past the minimum' => ['low', Operator::decrement(10)]; + } + + #[DataProvider('overflows')] + public function testAnOperatorThatOverflowsANewDocumentIsRefused(string $attribute, Operator $operator): void + { + try { + $this->database->upsertDocument('counters', new Document(['$id' => 'created', $attribute => $operator])); + $this->fail('A value outside the integer range must be refused'); + } catch (LimitException $error) { + $this->assertSame('Value out of range', $error->getMessage()); + } + + $this->assertTrue($this->database->getDocument('counters', 'created')->isEmpty()); + } + + public function testAnOperatorWithinTheRangeIsStored(): void + { + $this->database->upsertDocument('counters', new Document(['$id' => 'created', 'high' => Operator::increment(5)])); + + $this->assertSame(PHP_INT_MAX, $this->database->getDocument('counters', 'created')->getAttribute('high')); + } + + public function testAStringThatSpellsALargeNumberIsNotRefused(): void + { + $this->database->upsertDocument('counters', new Document(['$id' => 'created', 'digits' => Operator::stringConcat('0')])); + + $this->assertSame('92233720368547758070', $this->database->getDocument('counters', 'created')->getAttribute('digits')); + } +} diff --git a/tests/unit/Adapter/SQLiteVectorQueryTest.php b/tests/unit/Adapter/SQLiteVectorQueryTest.php new file mode 100644 index 0000000000..467080071c --- /dev/null +++ b/tests/unit/Adapter/SQLiteVectorQueryTest.php @@ -0,0 +1,65 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'items', + attributes: [Attribute::string('name', size: 16)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + foreach (['first', 'second', 'third'] as $name) { + $this->database->createDocument('items', new Document(['$id' => $name, 'name' => $name])); + } + } + + public function testAVectorQueryIsRefusedWhereTheEngineHasNoVectors(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute not found in schema: embedding'); + + $this->database->find('items', [Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + } + + public function testWithoutValidationAVectorQueryAddsNoDistanceOrderOrFilter(): void + { + $this->database->setValidation(false); + + $items = $this->database->find('items', [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::orderDesc('name'), + ]); + + $this->assertSame(['third', 'second', 'first'], \array_map(static fn (Document $item): string => $item->getId(), $items)); + } +} diff --git a/tests/unit/Adapter/SchemaIntrospectionTest.php b/tests/unit/Adapter/SchemaIntrospectionTest.php new file mode 100644 index 0000000000..3b05564260 --- /dev/null +++ b/tests/unit/Adapter/SchemaIntrospectionTest.php @@ -0,0 +1,310 @@ +sqlite(); + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'name', size: 64)); + $database->createAttribute(self::COLLECTION, Attribute::integer(key: 'age')); + + $columns = $this->columns($database); + + $this->assertContainsOnlyInstancesOf(Column::class, $database->getSchemaAttributes(self::COLLECTION)); + $this->assertSame($adapter->getColumnType(Attribute::string(key: 'name', size: 64)), $columns['name']->type); + $this->assertSame('VARCHAR(64)', $columns['name']->type); + $this->assertSame(64, $columns['name']->length); + $this->assertTrue($columns['name']->nullable); + $this->assertSame($adapter->getColumnType(Attribute::integer(key: 'age')), $columns['age']->type); + $this->assertNull($columns['age']->length); + $this->assertFalse($columns[Storage::UID]->nullable); + } + + public function testSQLiteReadsItsIndexesBackAsTypedIndexes(): void + { + [$database] = $this->sqlite(); + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'name', size: 64)); + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'email', size: 64)); + $database->createIndex(self::COLLECTION, Index::key(key: 'by_name', attributes: ['name'])); + $database->createIndex(self::COLLECTION, Index::unique(key: 'by_email', attributes: ['email'])); + $database->createIndex(self::COLLECTION, Index::fulltext(key: 'search', attributes: ['name'])); + + $indexes = []; + foreach ($database->getSchemaIndexes(self::COLLECTION) as $index) { + $indexes[$index->name] = $index; + } + + $this->assertSame([IndexType::Key, ['name'], [null]], [$indexes['by_name']->type, $indexes['by_name']->columns, $indexes['by_name']->lengths]); + $this->assertSame([IndexType::Unique, ['email']], [$indexes['by_email']->type, $indexes['by_email']->columns]); + $this->assertSame(IndexType::Fulltext, $indexes['search']->type); + } + + public function testAnEngineOnlyColumnIsReadBackWithoutBecomingAnAttribute(): void + { + [$database] = $this->sqlite(); + $table = '`'.$database->getNamespace().'_'.self::COLLECTION.'`'; + $this->pdo->exec("ALTER TABLE {$table} ADD COLUMN `legacy` TINYINT(4)"); + $this->pdo->exec("ALTER TABLE {$table} ADD COLUMN `settings` JSON"); + + $columns = $this->columns($database); + + $this->assertSame('TINYINT', $columns['legacy']->type); + $this->assertSame('LONGTEXT', $columns['settings']->type); + foreach ([Storage::SEQUENCE, Storage::UID, Storage::CREATED_AT, Storage::UPDATED_AT, Storage::PERMISSIONS] as $internal) { + $this->assertArrayHasKey($internal, $columns); + } + } + + public function testAnAttributeOverAnEngineOnlyColumnOfAnotherTypeReplacesIt(): void + { + [$database, $adapter] = $this->sqlite(); + $this->pdo->exec('ALTER TABLE `'.$database->getNamespace().'_'.self::COLLECTION.'` ADD COLUMN `legacy` TINYINT(4)'); + + $created = $database->createAttribute(self::COLLECTION, Attribute::integer(key: 'legacy')); + + $this->assertSame('legacy', $created->key); + $this->assertSame($adapter->getColumnType(Attribute::integer(key: 'legacy')), $this->columns($database)['legacy']->type); + } + + /** + * @return iterable}> + */ + public static function mariaDBFamily(): iterable + { + yield 'MariaDB' => [MariaDB::class]; + yield 'MySQL' => [MySQL::class]; + } + + /** + * @param class-string $class + */ + #[DataProvider('mariaDBFamily')] + public function testMariaDBReadsTheCatalogIntoTypedColumnsAndIndexes(string $class): void + { + $adapter = new $class($this->catalog([ + 'INFORMATION_SCHEMA.COLUMNS' => [ + ['name' => '_tenant', 'type' => 'int(11) unsigned', 'length' => null, 'nullable' => 'YES'], + ['name' => 'name', 'type' => 'varchar(64)', 'length' => '64', 'nullable' => 'YES'], + ['name' => 'tags', 'type' => 'json', 'length' => null, 'nullable' => 'YES'], + ['name' => 'spot', 'type' => 'point not null', 'length' => null, 'nullable' => 'NO'], + ], + 'INFORMATION_SCHEMA.STATISTICS' => [ + ['name' => 'PRIMARY', 'columnName' => '_id', 'nonUnique' => '0', 'indexType' => 'BTREE', 'subPart' => null], + ['name' => 'by_name', 'columnName' => '_tenant', 'nonUnique' => '1', 'indexType' => 'BTREE', 'subPart' => null], + ['name' => 'by_name', 'columnName' => 'name', 'nonUnique' => '1', 'indexType' => 'BTREE', 'subPart' => '16'], + ['name' => 'search', 'columnName' => 'name', 'nonUnique' => '1', 'indexType' => 'FULLTEXT', 'subPart' => null], + ['name' => 'area', 'columnName' => 'spot', 'nonUnique' => '1', 'indexType' => 'SPATIAL', 'subPart' => null], + ], + ])); + + $this->assertTrue($adapter->supports(Capability::SchemaIntrospection)); + $this->assertEquals([ + new Column('_tenant', 'INT UNSIGNED', null, true), + new Column('name', 'VARCHAR(64)', 64, true), + new Column('tags', 'LONGTEXT', null, true), + new Column('spot', 'POINT', null, false), + ], $adapter->getSchemaAttributes(self::COLLECTION)); + $this->assertSame('VARCHAR(64)', $adapter->getColumnType(Attribute::string(key: 'name', size: 64))); + $this->assertSame('LONGTEXT', $adapter->getColumnType(Attribute::string(key: 'tags', size: 64, array: true))); + $this->assertEquals([ + new SchemaIndex('PRIMARY', IndexType::Unique, ['_id'], [null]), + new SchemaIndex('by_name', IndexType::Key, ['_tenant', 'name'], [null, 16]), + new SchemaIndex('search', IndexType::Fulltext, ['name'], [null]), + new SchemaIndex('area', IndexType::Spatial, ['spot'], [null]), + ], $adapter->getSchemaIndexes(self::COLLECTION)); + } + + public function testPostgresReadsTheCatalogIntoTypedColumnsAndIndexes(): void + { + $adapter = new Postgres($this->catalog([ + 'pg_catalog.pg_attribute' => [ + ['name' => '_id', 'type' => 'bigint', 'length' => null, 'nullable' => false], + ['name' => 'name', 'type' => 'character varying(64)', 'length' => 64, 'nullable' => true], + ['name' => 'born', 'type' => 'timestamp(3) without time zone', 'length' => null, 'nullable' => true], + ['name' => 'spot', 'type' => 'geometry(Point,4326)', 'length' => null, 'nullable' => 't'], + ], + 'pg_catalog.pg_index' => [ + ['name' => 'namespace_people_pkey', 'unique' => true, 'method' => 'btree', 'operator' => 'int8_ops', 'column' => '_id'], + ['name' => 'namespace_2_people_by_name', 'unique' => false, 'method' => 'btree', 'operator' => 'text_ops', 'column' => '_tenant'], + ['name' => 'namespace_2_people_by_name', 'unique' => false, 'method' => 'btree', 'operator' => 'text_ops', 'column' => '"fullName"'], + ['name' => 'namespace_3_people_by_name', 'unique' => true, 'method' => 'btree', 'operator' => 'text_ops', 'column' => 'name'], + ['name' => 'namespace_2_people_area', 'unique' => false, 'method' => 'gist', 'operator' => 'gist_geometry_ops_2d', 'column' => 'spot'], + ['name' => 'namespace_2_people_names', 'unique' => false, 'method' => 'gin', 'operator' => 'gin_trgm_ops', 'column' => 'name'], + ], + ])); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables(true); + $adapter->setTenant(2); + + $this->assertTrue($adapter->supports(Capability::SchemaIntrospection)); + $this->assertEquals([ + new Column('_id', 'BIGINT', null, false), + new Column('name', 'VARCHAR(64)', 64, true), + new Column('born', 'TIMESTAMP(3)', null, true), + new Column('spot', 'GEOMETRY(POINT,4326)', null, true), + ], $adapter->getSchemaAttributes(self::COLLECTION)); + $this->assertSame('VARCHAR(64)', $adapter->getColumnType(Attribute::string(key: 'name', size: 64))); + $this->assertSame('TIMESTAMP(3)', $adapter->getColumnType(Attribute::datetime(key: 'born'))); + $this->assertSame('GEOMETRY(POINT,4326)', $adapter->getColumnType(Attribute::point(key: 'spot'))); + $this->assertEquals([ + new SchemaIndex('namespace_people_pkey', IndexType::Unique, ['_id'], [null]), + new SchemaIndex('by_name', IndexType::Key, ['_tenant', 'fullName'], [null, null]), + new SchemaIndex('namespace_3_people_by_name', IndexType::Unique, ['name'], [null]), + new SchemaIndex('area', IndexType::Spatial, ['spot'], [null]), + new SchemaIndex('names', IndexType::Trigram, ['name'], [null]), + ], $adapter->getSchemaIndexes(self::COLLECTION)); + } + + /** + * @return iterable + */ + public static function adaptersWithoutIntrospection(): iterable + { + yield 'Memory' => [new Memory()]; + yield 'MongoDB' => [new class () extends Mongo { + public function __construct() + { + } + }]; + yield 'Redis' => [new class () extends Redis { + public function __construct() + { + } + }]; + } + + #[DataProvider('adaptersWithoutIntrospection')] + public function testAnAdapterThatCannotIntrospectReportsNothingAndDoesNotDeclareIt(Adapter $adapter): void + { + $this->assertFalse($adapter->supports(Capability::SchemaIntrospection)); + $this->assertSame([], $adapter->getSchemaAttributes(self::COLLECTION)); + $this->assertSame([], $adapter->getSchemaIndexes(self::COLLECTION)); + $this->assertNull($adapter->getColumnType(Attribute::string(key: 'name', size: 64))); + } + + public function testPostgresReportsAFulltextIndexAsTheKeyItStores(): void + { + $postgres = new Postgres(new stdClass()); + $mariadb = new MariaDB(new stdClass()); + + $this->assertSame(IndexType::Key, $postgres->getSchemaIndexType(IndexType::Fulltext)); + $this->assertSame(IndexType::Unique, $postgres->getSchemaIndexType(IndexType::Unique)); + $this->assertSame(IndexType::Fulltext, $mariadb->getSchemaIndexType(IndexType::Fulltext)); + } + + public function testNoReconciliationRunsWithoutIntrospection(): void + { + $database = $this->database(new Memory()); + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'name', size: 64)); + + $this->assertSame([], $database->getSchemaAttributes(self::COLLECTION)); + $this->assertSame([], $database->getSchemaIndexes(self::COLLECTION)); + $this->expectException(DuplicateException::class); + + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'name', size: 64)); + } + + /** + * @return array{Database, SQLite} + */ + private function sqlite(): array + { + $this->pdo = new PDO('sqlite::memory:'); + $adapter = new SQLite($this->pdo); + + return [$this->database($adapter), $adapter]; + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('introspection') + ->setNamespace('introspection_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + + return $database; + } + + /** + * @return array + */ + private function columns(Database $database): array + { + $columns = []; + foreach ($database->getSchemaAttributes(self::COLLECTION) as $column) { + $columns[$column->name] = $column; + } + + return $columns; + } + + /** + * A driver whose statements answer a catalog read with the rows given for the catalog it names. + * + * @param array>> $catalogs + */ + private function catalog(array $catalogs): PDO + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($catalogs): PDOStatement { + $rows = []; + foreach ($catalogs as $catalog => $answer) { + if (\str_contains($query, $catalog)) { + $rows = $answer; + } + } + + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn($rows); + + return $statement; + }); + + return $pdo; + } +} diff --git a/tests/unit/Adapter/ScopedBuilderTest.php b/tests/unit/Adapter/ScopedBuilderTest.php new file mode 100644 index 0000000000..732b18e520 --- /dev/null +++ b/tests/unit/Adapter/ScopedBuilderTest.php @@ -0,0 +1,246 @@ +adapter(); + + $this->expectException(QueryException::class); + + $adapter->builder()->from(Database::METADATA)->from('authors'); + } + + public function testABuilderThatReadACollectionRefusesTheMetadataCollection(): void + { + $adapter = $this->adapter(); + + $this->expectException(QueryException::class); + + $adapter->builder()->from('authors')->from(Database::METADATA); + } + + public function testABuilderThatReadACollectionRefusesAStoredTable(): void + { + $adapter = $this->adapter(); + + $this->expectException(QueryException::class); + + $adapter->builder()->from('authors')->fromTable('namespace_books'); + } + + public function testABuilderThatReadACollectionRefusesAnotherScope(): void + { + $adapter = $this->adapter(); + $scope = (new ReflectionMethod($adapter, 'scope'))->invoke($adapter); + $this->assertInstanceOf(Scope::class, $scope); + + $this->expectException(QueryException::class); + + $adapter->builder()->from('authors')->scope($scope); + } + + public function testReadingTheSameCollectionAgainKeepsItsTenant(): void + { + $builder = $this->adapter()->builder()->from('authors'); + $builder->build(); + + $query = $builder->reset()->from('authors')->select(['name'])->build()->query; + + $this->assertSame(1, \substr_count($query, '_tenant'), $query); + } + + public function testJoinsNameCollectionsKeptToTheTenant(): void + { + $query = $this->adapter()->builder() + ->from('authors') + ->join('books', 'namespace_authors._uid', 'book.authorId', '=', 'book') + ->select(['book.pages']) + ->build() + ->query; + + $this->assertStringContainsString('JOIN `namespace_books` AS `book`', $query); + $this->assertStringContainsString('`book`._tenant IN (?)', $query); + } + + public function testABuilderThatReadACollectionRefusesAnInsertIntoATable(): void + { + $this->expectException(QueryException::class); + + $this->adapter()->builder()->from('authors')->into('namespace_books'); + } + + public function testABuilderThatReadACollectionRefusesAnotherAlias(): void + { + $builder = $this->adapter()->builder()->from('authors'); + + $this->expectException(QueryException::class); + + $builder->reset()->from('authors', 'author'); + } + + /** + * @return iterable + */ + public static function joinedWrites(): iterable + { + yield 'MySQL updateJoin' => [static fn (SQL $adapter) => self::mysqlBuilder($adapter)->updateJoin('books', 'namespace_authors._uid', 'books.authorId')]; + yield 'MySQL deleteJoin' => [static fn (SQL $adapter) => self::mysqlBuilder($adapter)->deleteJoin('namespace_authors', 'books', 'namespace_authors._uid', 'books.authorId')]; + yield 'Postgres updateFrom' => [static fn (SQL $adapter) => self::postgresBuilder($adapter)->updateFrom('books')]; + yield 'Postgres deleteUsing' => [static fn (SQL $adapter) => self::postgresBuilder($adapter)->deleteUsing('books', 'books.authorId = namespace_authors._uid')]; + } + + /** + * @param Closure(SQL): mixed $write + */ + #[DataProvider('joinedWrites')] + public function testABuilderThatReadACollectionRefusesAMultiTableWrite(Closure $write): void + { + $pdo = $this->createStub(PDO::class); + $adapter = \str_contains((string) $this->dataName(), 'Postgres') ? new Postgres($pdo) : new MySQL($pdo); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + $this->expectException(QueryException::class); + + $write($adapter); + } + + public function testAJoinAddedBeforeFromNamesACollection(): void + { + $query = $this->adapter()->builder() + ->join('books', 'namespace_authors._uid', 'book.authorId', '=', 'book') + ->from('authors') + ->select(['book.pages']) + ->build() + ->query; + + $this->assertStringContainsString('JOIN `namespace_books` AS `book`', $query); + $this->assertStringContainsString('`book`._tenant IN (?)', $query); + } + + public function testAFullOuterJoinOfABuilderWithItsOwnJoinMethodsNamesACollection(): void + { + $adapter = new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:')); + $adapter->setNamespace('namespace'); + $builder = $adapter->builder()->from('authors'); + $this->assertInstanceOf(FullOuterJoins::class, $builder); + + $query = $builder->fullOuterJoin('books', 'namespace_authors._uid', 'book.authorId', '=', 'book')->select(['book.pages'])->build()->query; + + $this->assertStringContainsString('FULL OUTER JOIN `namespace_books` AS `book`', $query); + } + + public function testAResetBuilderKeepsTheTenantOfItsCollection(): void + { + $builder = $this->adapter()->builder()->from('authors') + ->rightJoin('books', 'namespace_authors._uid', 'book.authorId', '=', 'book'); + $builder->build(); + + $query = $builder->reset()->from('authors')->select(['name'])->build()->query; + + $this->assertStringEndsWith('WHERE `namespace_authors`._tenant IN (?)', $query); + } + + public function testDatabaseFromNamesTheCollectionByTheAlias(): void + { + $database = $this->database($this->adapter()); + + $query = $database->getAuthorization()->skip(fn (): string => $database->from('authors', 'author')->select(['author.name'])->build()->query); + + $this->assertStringContainsString('FROM `namespace_authors` AS `author`', $query); + $this->assertStringContainsString('`author`._tenant IN (?)', $query); + } + + public function testDatabaseFromRefusesABuilderWithNoScope(): void + { + $adapter = new class () extends Memory implements QueryBuilder { + #[\Override] + public function builder(): Builder&Scoping + { + return new SQLiteBuilder(); + } + + #[\Override] + public function schema(): Schema + { + return new SQLiteSchema(); + } + }; + $database = $this->database($adapter); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('The adapter handed out a query builder with no scope'); + + $database->getAuthorization()->skip(fn () => $database->from('authors')); + } + + private static function mysqlBuilder(SQL $adapter): MySQLBuilder + { + $builder = $adapter->builder()->from('authors'); + \assert($builder instanceof MySQLBuilder); + + return $builder; + } + + private static function postgresBuilder(SQL $adapter): PostgresBuilder + { + $builder = $adapter->builder()->from('authors'); + \assert($builder instanceof PostgresBuilder); + + return $builder; + } + + private function adapter(): SQLite + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + return $adapter; + } + + private function database(\Utopia\Database\Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setAuthorization(new Authorization()); + + return $database; + } +} diff --git a/tests/unit/Adapter/SpatialBindingTest.php b/tests/unit/Adapter/SpatialBindingTest.php new file mode 100644 index 0000000000..fad54a8821 --- /dev/null +++ b/tests/unit/Adapter/SpatialBindingTest.php @@ -0,0 +1,173 @@ +, string, string}> + */ + public static function writes(): iterable + { + $engines = ['MariaDB' => MariaDB::class, 'MySQL' => MySQL::class, 'Postgres' => Postgres::class]; + $operations = ['createDocument', 'createDocuments', 'updateDocument', 'updateDocuments', 'upsertDocuments']; + $answers = [ + 'point' => 'POINT(1 2)', + 'point with trailing text' => 'POINT(1 2) is my answer', + 'linestring' => 'LINESTRING(0 0,1 1)', + 'polygon' => 'POLYGON((0 0,1 1,1 0,0 0))', + 'lowercase point' => 'point (1 2)', + ]; + + foreach ($engines as $engineName => $engine) { + foreach ($operations as $operation) { + foreach ($answers as $answerName => $answer) { + yield $engineName.' '.$operation.' '.$answerName => [$engine, $operation, $answer]; + } + } + } + } + + /** + * @param class-string $engine + */ + #[DataProvider('writes')] + public function testStringColumnHoldingWktIsBoundAsText(string $engine, string $operation, string $answer): void + { + $bindings = $this->write($engine, $operation, [$answer, 'plain text']); + + $this->assertContains([$answer, false], $bindings, 'The string column is bound as the text it holds'); + $this->assertNotContains([$answer, true], $bindings, 'The string column is not wrapped in '.self::GEOMETRY_FUNCTION); + $this->assertNotContains(['plain text', true], $bindings, 'Another document\'s plain text in the same column is not wrapped'); + } + + /** + * @param class-string $engine + */ + #[DataProvider('writes')] + public function testSpatialColumnIsWrappedInGeomFromText(string $engine, string $operation, string $answer): void + { + $bindings = $this->write($engine, $operation, [$answer]); + + $this->assertContains([self::POSITION, true], $bindings); + $this->assertNotContains([self::POSITION, false], $bindings); + } + + /** + * @param class-string $engine + * @param list $answers + * @return list Each bound value paired with whether its placeholder sits inside ST_GeomFromText + */ + private function write(string $engine, string $operation, array $answers): array + { + $bindings = new ArrayObject(); + $adapter = new $engine($this->pdo($bindings)); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + $collection = new Document([ + '$id' => 'answers', + 'attributes' => [ + new Document(['$id' => 'answer', 'key' => 'answer', 'type' => ColumnType::String->value, 'size' => 255]), + new Document(['$id' => 'position', 'key' => 'position', 'type' => ColumnType::Point->value]), + ], + ]); + + $documents = []; + foreach ($answers as $index => $answer) { + $documents[] = new Document([ + '$id' => 'document'.$index, + '$sequence' => (string) ($index + 1), + '$permissions' => [], + '$createdAt' => '2026-09-30 00:00:00.000', + '$updatedAt' => '2026-09-30 00:00:00.000', + 'answer' => $answer, + 'position' => self::POSITION, + ]); + } + + match ($operation) { + 'createDocument' => $adapter->createDocument($collection, $documents[0]), + 'createDocuments' => $adapter->createDocuments($collection, $documents), + 'updateDocument' => $adapter->updateDocument($collection, $documents[0]->getId(), $documents[0], true), + 'updateDocuments' => $adapter->updateDocuments( + $collection, + new Document(['answer' => $answers[0], 'position' => self::POSITION]), + $documents, + ), + 'upsertDocuments' => $adapter->upsertDocuments( + $collection, + \array_map(static fn (Document $document): Change => new Change(new Document(), $document), $documents), + ), + default => throw new LogicException('Unknown write operation: '.$operation), + }; + + return $bindings->getArrayCopy(); + } + + /** + * @param ArrayObject $bindings + */ + private function pdo(ArrayObject $bindings): PDO + { + $pdo = $this->createStub(PDO::class); + $pdo->method('beginTransaction')->willReturn(true); + $pdo->method('lastInsertId')->willReturn('1'); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($bindings): PDOStatement { + $wrapped = $this->placeholdersInsideGeometryFunction($query); + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('rowCount')->willReturn(1); + $statement->method('bindValue')->willReturnCallback( + static function (int|string $position, mixed $value) use ($bindings, $wrapped): bool { + $bindings->append([$value, $wrapped[(int) $position - 1] ?? false]); + + return true; + }, + ); + + return $statement; + }); + + return $pdo; + } + + /** + * @return list + */ + private function placeholdersInsideGeometryFunction(string $query): array + { + $wrapped = []; + $offset = 0; + while (($position = \strpos($query, '?', $offset)) !== false) { + $wrapped[] = \str_ends_with(\substr($query, 0, $position), self::GEOMETRY_FUNCTION); + $offset = $position + 1; + } + + return $wrapped; + } +} diff --git a/tests/unit/Adapter/SpatialCodecTest.php b/tests/unit/Adapter/SpatialCodecTest.php new file mode 100644 index 0000000000..24e9c15b7b --- /dev/null +++ b/tests/unit/Adapter/SpatialCodecTest.php @@ -0,0 +1,223 @@ + + */ + public static function spatialAdapters(): iterable + { + yield 'MariaDB' => [new MariaDB(new stdClass())]; + yield 'MySQL' => [new MySQL(new stdClass())]; + yield 'Postgres' => [new Postgres(new stdClass())]; + } + + /** + * @return iterable, string}> + */ + public static function geometries(): iterable + { + yield 'point' => [ColumnType::Point, [1.5, 2], 'POINT(1.5 2)']; + yield 'linestring' => [ColumnType::Linestring, [[0, 0], [1, 1], [2, 0.5]], 'LINESTRING(0 0, 1 1, 2 0.5)']; + yield 'polygon with a hole' => [ + ColumnType::Polygon, + [[[0, 0], [0, 4], [4, 4], [4, 0], [0, 0]], [[1, 1], [1, 2], [2, 2], [1, 1]]], + 'POLYGON((0 0, 0 4, 4 4, 4 0, 0 0), (1 1, 1 2, 2 2, 1 1))', + ]; + yield 'polygon given as one ring' => [ColumnType::Polygon, [[0, 0], [0, 1], [1, 1], [0, 0]], 'POLYGON((0 0, 0 1, 1 1, 0 0))']; + } + + /** + * @return iterable, string}> + */ + public static function adaptersAndGeometries(): iterable + { + foreach (self::spatialAdapters() as $engine => [$adapter]) { + foreach (self::geometries() as $shape => [$type, $value, $text]) { + yield $shape.' on '.$engine => [$adapter, $type, $value, $text]; + } + } + } + + /** + * @param array $value + */ + #[DataProvider('adaptersAndGeometries')] + public function testAGeometryIsEncodedAsWellKnownText(Adapter&Feature\Spatial $adapter, ColumnType $type, array $value, string $text): void + { + $this->assertSame($text, $adapter->encode($value, $type)); + } + + /** + * @param array $value + */ + #[DataProvider('adaptersAndGeometries')] + public function testAnEncodedGeometryDecodesToItsCoordinates(Adapter&Feature\Spatial $adapter, ColumnType $type, array $value, string $text): void + { + $expected = $type === ColumnType::Polygon && \is_array($value[0] ?? null) && \is_numeric($value[0][0] ?? null) ? [$value] : $value; + + $this->assertSame(self::floats($expected), $adapter->decode($adapter->encode($value, $type), $type)); + } + + #[DataProvider('spatialAdapters')] + public function testAnInvalidGeometryIsRefused(Adapter&Feature\Spatial $adapter): void + { + $this->expectException(StructureException::class); + + $adapter->encode([1], ColumnType::Point); + } + + #[DataProvider('spatialAdapters')] + public function testANonSpatialTypeIsRefused(Adapter&Feature\Spatial $adapter): void + { + $this->expectException(DatabaseException::class); + + $adapter->decode('POINT(1 2)', ColumnType::String); + } + + public function testAPoolEncodesAndDecodesAGeometry(): void + { + $pool = $this->pool(new MariaDB(new stdClass())); + + $this->assertTrue($pool->hasFeature(Feature\Spatial::class)); + $this->assertSame('POINT(3 4)', $pool->encode([3, 4], ColumnType::Point)); + $this->assertSame([3.0, 4.0], $pool->decode('POINT(3 4)', ColumnType::Point)); + } + + /** + * @param array $value + */ + #[DataProvider('geometries')] + public function testAPoolEncodesWithoutAConnection(ColumnType $type, array $value, string $text): void + { + $pool = new Pool(new UtopiaPool(new Stack(), 'unreachable', 1, static function (): never { + throw new RuntimeException('no connection can be opened'); + }, timeout: 0.0)); + + $this->assertSame($text, $pool->encode($value, $type)); + } + + public function testAPoolRefusesAnInvalidGeometry(): void + { + $pool = $this->pool(new MariaDB(new stdClass())); + + $this->expectException(StructureException::class); + + $pool->encode([1], ColumnType::Point); + } + + public function testAPoolOverAnAdapterWithoutSpatialRefusesToDecode(): void + { + $pool = $this->pool(new SQLite(new PDO('sqlite::memory:'))); + + $this->assertFalse($pool->hasFeature(Feature\Spatial::class)); + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support spatial'); + + $pool->decode('POINT(3 4)', ColumnType::Point); + } + + public function testTheDatabaseEncodesASpatialAttributeThroughItsAdapter(): void + { + $database = new Database(new MariaDB(new stdClass()), new Cache(new None())); + $collection = Collection::create(id: 'places', attributes: [Attribute::point(key: 'location')]); + + $encoded = $database->encode($collection, new Document(['$id' => 'home', 'location' => [5, 6]])); + + $this->assertSame('POINT(5 6)', $encoded->getAttribute('location')); + } + + public function testTheDatabaseLeavesAnInvalidGeometryForTheStructureValidator(): void + { + $database = new Database(new MariaDB(new stdClass()), new Cache(new None())); + $collection = Collection::create(id: 'places', attributes: [Attribute::point(key: 'location')]); + + $encoded = $database->encode($collection, new Document(['$id' => 'home', 'location' => [5]])); + + $this->assertSame([5], $encoded->getAttribute('location')); + } + + public function testAFailingEncoderSurfacesThroughTheDatabase(): void + { + $adapter = new class (new stdClass()) extends MariaDB { + #[\Override] + public function encode(mixed $value, ColumnType $type): string + { + throw new RuntimeException('encoder unavailable'); + } + }; + $database = new Database($adapter, new Cache(new None())); + $collection = Collection::create(id: 'places', attributes: [Attribute::point(key: 'location')]); + + try { + $database->encode($collection, new Document(['$id' => 'home', 'location' => [5, 6]])); + $this->fail('An encoder failure must not leave the value as given'); + } catch (DatabaseException $error) { + $this->assertSame('encoder unavailable', $error->getMessage()); + $this->assertInstanceOf(RuntimeException::class, $error->getPrevious()); + } + } + + public function testWithoutSpatialTheDatabaseLeavesTheValueAsGiven(): void + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $collection = Collection::create(id: 'places', attributes: [Attribute::point(key: 'location')]); + + $encoded = $database->encode($collection, new Document(['$id' => 'home', 'location' => [5, 6]])); + + $this->assertSame([5, 6], $encoded->getAttribute('location')); + } + + /** + * @param array $value + * @return array + */ + private static function floats(array $value): array + { + return \array_map(static fn (mixed $node): mixed => match (true) { + \is_array($node) => self::floats($node), + \is_numeric($node) => (float) $node, + default => $node, + }, $value); + } + + private function pool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(static fn (callable $callback): mixed => $callback($adapter)); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } +} diff --git a/tests/unit/Adapter/SpatialColumnDefinitionTest.php b/tests/unit/Adapter/SpatialColumnDefinitionTest.php new file mode 100644 index 0000000000..0d0a131de7 --- /dev/null +++ b/tests/unit/Adapter/SpatialColumnDefinitionTest.php @@ -0,0 +1,149 @@ + */ + private array $statements = []; + + /** + * @return array, 1: string, 2: list}> + */ + public static function definitions(): array + { + return [ + 'MariaDB has no column SRID' => [ + MariaDB::class, + '`database`.`namespace_places`', + ['`location` POINT NOT NULL', '`route` LINESTRING NULL', '`area` POLYGON NOT NULL'], + ], + 'MySQL declares the column SRID' => [ + MySQL::class, + '`database`.`namespace_places`', + ['`location` POINT SRID 4326 NOT NULL', '`route` LINESTRING SRID 4326 NULL', '`area` POLYGON SRID 4326 NOT NULL'], + ], + 'PostgreSQL keeps spatial columns nullable' => [ + Postgres::class, + '"database"."namespace_places"', + ['"location" GEOMETRY(POINT, 4326) NULL', '"route" GEOMETRY(LINESTRING, 4326) NULL', '"area" GEOMETRY(POLYGON, 4326) NULL'], + ], + ]; + } + + /** + * @param class-string $adapterClass + * @param list $columns + */ + #[DataProvider('definitions')] + public function testBatchAndSingleCreationEmitTheSameSpatialColumns(string $adapterClass, string $table, array $columns): void + { + $attributes = [ + Attribute::point(key: 'location', required: true), + Attribute::lineString(key: 'route'), + Attribute::polygon(key: 'area', required: true), + ]; + $adapter = $this->createAdapter($adapterClass); + + foreach ($attributes as $attribute) { + $adapter->createAttribute('places', $attribute); + } + $adapter->createAttributes('places', $attributes); + + $expected = []; + foreach ($columns as $column) { + $expected[] = 'ALTER TABLE '.$table.' ADD COLUMN '.$column; + } + $expected[] = 'ALTER TABLE '.$table.' ADD COLUMN '.\implode(', ADD COLUMN ', $columns); + + $this->assertSame($expected, $this->statements); + } + + public function testMariaDBDefinesSpatialColumnsAlikeOnEveryPath(): void + { + $adapter = $this->createAdapter(MariaDB::class); + $columns = ['`location` POINT NOT NULL', '`route` LINESTRING NULL', '`area` POLYGON NOT NULL']; + $attributes = [ + Attribute::point(key: 'location', required: true), + Attribute::lineString(key: 'route'), + Attribute::polygon(key: 'area', required: true), + ]; + + $adapter->createCollection('places', $attributes); + foreach ($columns as $column) { + $this->assertStringContainsString($column.',', $this->statements[0]); + } + + $this->statements = []; + foreach ($attributes as $attribute) { + $adapter->createAttribute('places', $attribute); + $adapter->updateAttribute('places', $attribute->key, $attribute); + } + + $expected = []; + foreach ($columns as $column) { + $expected[] = 'ALTER TABLE `database`.`namespace_places` ADD COLUMN '.$column; + $expected[] = 'ALTER TABLE `database`.`namespace_places` MODIFY '.$column; + } + $this->assertSame($expected, $this->statements); + } + + public function testMySQLDeclaresRequiredSpatialColumnsNotNullOnCollectionAndUpdatePaths(): void + { + $adapter = $this->createAdapter(MySQL::class); + $columns = ['`location` POINT SRID 4326 NOT NULL', '`route` LINESTRING SRID 4326 NOT NULL', '`area` POLYGON SRID 4326 NOT NULL']; + $attributes = [ + Attribute::point(key: 'location', required: true), + Attribute::lineString(key: 'route', required: true), + Attribute::polygon(key: 'area', required: true), + ]; + + $adapter->createCollection('places', $attributes); + foreach ($columns as $column) { + $this->assertStringContainsString($column.',', $this->statements[0]); + } + + $this->statements = []; + foreach ($attributes as $attribute) { + $adapter->updateAttribute('places', $attribute->key, $attribute); + } + + $expected = []; + foreach ($columns as $column) { + $expected[] = 'ALTER TABLE `database`.`namespace_places` MODIFY '.$column; + } + $this->assertSame($expected, $this->statements); + } + + /** + * @param class-string $adapterClass + */ + private function createAdapter(string $adapterClass): SQL + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + + $adapter = new $adapterClass($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/SpatialMemoTest.php b/tests/unit/Adapter/SpatialMemoTest.php new file mode 100644 index 0000000000..45c547c38e --- /dev/null +++ b/tests/unit/Adapter/SpatialMemoTest.php @@ -0,0 +1,55 @@ + + */ + public function spatialColumnsOf(Document $collection): array + { + return $this->getSpatialAttributes($collection); + } + }; + $collection = new Document([ + '$id' => 'places', + 'attributes' => [ + Attribute::string(key: 'name', size: 64), + Attribute::point(key: 'position'), + ], + ]); + + $adapter->setNamespace('warmup'); + $this->assertSame(['position'], $adapter->spatialColumnsOf($collection)); + \gc_collect_cycles(); + $before = \memory_get_usage(); + + for ($namespace = 0; $namespace < self::NAMESPACES; $namespace++) { + $adapter->setNamespace('tenant_'.$namespace); + $adapter->spatialColumnsOf($collection); + } + + \gc_collect_cycles(); + $growth = \memory_get_usage() - $before; + + $this->assertLessThan(self::GROWTH_LIMIT, $growth, 'Resolving spatial columns for '.self::NAMESPACES.' namespaces grew memory by '.$growth.' bytes'); + } +} diff --git a/tests/unit/Adapter/SyncRecordingMemory.php b/tests/unit/Adapter/SyncRecordingMemory.php new file mode 100644 index 0000000000..0fda2a8fdd --- /dev/null +++ b/tests/unit/Adapter/SyncRecordingMemory.php @@ -0,0 +1,110 @@ + + */ + public array $snapshots = []; + + private bool $schemaless = false; + + /** + * @var (Closure(self): void)|null + */ + private ?Closure $mutation = null; + + /** + * @param Closure(self): void $mutation + */ + public function mutateOnNextPing(Closure $mutation): void + { + $this->mutation = $mutation; + } + + #[\Override] + public function ping(): bool + { + $this->snapshots[] = new SyncSnapshot( + database: $this->getDatabase(), + namespace: $this->getNamespace(), + tenant: $this->getTenant(), + metadata: $this->getMetadata(), + transforms: $this->transforms, + profiler: $this->getProfiler(), + schemaless: $this->schemaless, + timeouts: $this->timeouts, + ); + + $mutation = $this->mutation; + $this->mutation = null; + if ($mutation !== null) { + $mutation($this); + } + + return true; + } + + public function last(): SyncSnapshot + { + $last = \end($this->snapshots); + if ($last === false) { + throw new \LogicException('No call reached the connection'); + } + + return $last; + } + + #[\Override] + public function reconnect(): void + { + } + + #[\Override] + public function id(): string + { + return 'sync-recording'; + } + + #[\Override] + public function hostname(): string + { + return ''; + } + + #[\Override] + public function setSchemaless(bool $schemaless): static + { + $this->schemaless = $schemaless; + + return $this; + } + + #[\Override] + public function isSchemaless(): bool + { + return $this->schemaless; + } + + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + $this->setTimeoutState($milliseconds, $event); + } + + #[\Override] + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); + } +} diff --git a/tests/unit/Adapter/SyncSnapshot.php b/tests/unit/Adapter/SyncSnapshot.php new file mode 100644 index 0000000000..bd42cdf85b --- /dev/null +++ b/tests/unit/Adapter/SyncSnapshot.php @@ -0,0 +1,26 @@ + $metadata + * @param array $transforms + * @param array $timeouts + */ + public function __construct( + public string $database, + public string $namespace, + public int|string|null $tenant, + public array $metadata, + public array $transforms, + public ?Profiler $profiler, + public bool $schemaless, + public array $timeouts, + ) { + } +} diff --git a/tests/unit/Adapter/TimeoutRecordingAdapter.php b/tests/unit/Adapter/TimeoutRecordingAdapter.php new file mode 100644 index 0000000000..15c330af95 --- /dev/null +++ b/tests/unit/Adapter/TimeoutRecordingAdapter.php @@ -0,0 +1,25 @@ +setTimeoutState($milliseconds, $event); + } + + #[\Override] + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); + } +} diff --git a/tests/unit/Adapter/TimeoutsFeatureTest.php b/tests/unit/Adapter/TimeoutsFeatureTest.php new file mode 100644 index 0000000000..cfb59bdf3f --- /dev/null +++ b/tests/unit/Adapter/TimeoutsFeatureTest.php @@ -0,0 +1,122 @@ +pool(new MariaDB(self::createStub(PDO::class))); + + $this->assertTrue($pool->hasFeature(Feature\Timeouts::class)); + $this->assertSame(0, $this->checkouts); + } + + public function testAPoolHoldsItsTimeoutsWhateverItPools(): void + { + $pool = $this->pool(new Memory()); + + $this->assertTrue($pool->hasFeature(Feature\Timeouts::class)); + $this->assertFalse((new Memory())->hasFeature(Feature\Timeouts::class)); + } + + public function testAnEventTimeoutOverridesTheGlobalOneUntilItIsCleared(): void + { + $adapter = new MariaDB(self::createStub(PDO::class)); + $adapter->setTimeout(1_000); + $adapter->setTimeout(250, Event::DocumentFind); + + $this->assertSame(1_000, $adapter->getTimeout()); + $this->assertSame(250, $adapter->getTimeout(Event::DocumentFind)); + $this->assertSame(1_000, $adapter->getTimeout(Event::DocumentCreate)); + + $adapter->clearTimeout(Event::DocumentFind); + + $this->assertSame(1_000, $adapter->getTimeout(Event::DocumentFind)); + } + + public function testClearingTheGlobalTimeoutClearsEveryEvent(): void + { + $adapter = new MariaDB(self::createStub(PDO::class)); + $adapter->setTimeout(1_000); + $adapter->setTimeout(250, Event::DocumentFind); + + $adapter->clearTimeout(); + + $this->assertSame(0, $adapter->getTimeout()); + $this->assertSame(0, $adapter->getTimeout(Event::DocumentFind)); + } + + public function testAnAdapterWithoutTimeoutsHasNoTimeoutUntilOneIsSet(): void + { + $adapter = new MariaDB(self::createStub(PDO::class)); + + $this->assertSame(0, $adapter->getTimeout()); + $this->assertSame(0, $adapter->getTimeout(Event::DocumentFind)); + } + + public function testThePoolReportsTheTimeoutItWasGivenWithoutCheckingOut(): void + { + $pool = $this->pool(new MariaDB(self::createStub(PDO::class))); + $database = $this->database($pool); + + $database->setTimeout(500, Event::DocumentFind); + $database->setTimeout(2_000); + + $this->assertSame(2_000, $pool->getTimeout()); + $this->assertSame(500, $pool->getTimeout(Event::DocumentFind)); + $this->assertSame(0, $this->checkouts); + } + + public function testADatabaseOverAnAdapterWithoutTimeoutsRefusesOne(): void + { + $database = $this->database(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support timeouts'); + + $database->setTimeout(500); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setAuthorization(new Authorization()); + + return $database; + } + + private function pool(Adapter $connection): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(function (callable $callback) use ($connection): mixed { + $this->checkouts++; + + return $callback($connection); + }); + + $pool = new Pool($connections); + $pool->setAuthorization(new Authorization()); + + return $pool; + } +} diff --git a/tests/unit/Adapter/UnfilteredAggregateStatementTest.php b/tests/unit/Adapter/UnfilteredAggregateStatementTest.php new file mode 100644 index 0000000000..9503afc183 --- /dev/null +++ b/tests/unit/Adapter/UnfilteredAggregateStatementTest.php @@ -0,0 +1,142 @@ + */ + private array $statements = []; + + /** @var list */ + private array $bindings = []; + + /** + * @return iterable + */ + public static function aggregates(): iterable + { + $adapters = [ + 'mariadb' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends MariaDB implements AggregateReference { + use BuildsAggregates; + }, + 'mysql' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends MySQL implements AggregateReference { + use BuildsAggregates; + }, + 'postgres' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends Postgres implements AggregateReference { + use BuildsAggregates; + }, + 'sqlite' => static fn (PDO $pdo): SQL&AggregateReference => new class ($pdo) extends SQLite implements AggregateReference { + use BuildsAggregates; + }, + ]; + $tables = [ + 'plain' => ['books', null], + 'shared' => ['books', 3], + 'shared metadata' => [Database::METADATA, 3], + 'shared without a tenant' => ['books', null], + ]; + + foreach ($adapters as $engine => $make) { + foreach (['count', 'sum'] as $operation) { + foreach ($tables as $mode => [$collection, $tenant]) { + foreach (['unauthorized', 'collection permissions', 'document security'] as $authorization) { + foreach ([null, 25, 0] as $max) { + $name = "{$engine} {$operation} {$mode} {$authorization} max ".($max ?? 'none'); + yield $name => [$make, $operation, $collection, $mode !== 'plain', $tenant, $authorization, $max]; + } + } + } + } + } + } + + /** + * @param Closure(PDO): (SQL&AggregateReference) $make + */ + #[DataProvider('aggregates')] + public function testAnAggregateWithoutQueriesRunsTheStatementTheBuilderMakes( + Closure $make, + string $operation, + string $collection, + bool $shared, + ?int $tenant, + string $authorization, + ?int $max, + ): void { + $adapter = $this->adapter($make, $shared, $tenant, $authorization); + $document = Collection::create(id: $collection, documentSecurity: $authorization === 'document security'); + + $expected = $adapter->builtAggregate($operation, $document, [], $max); + $result = $operation === 'count' + ? $adapter->count($document, [], $max) + : $adapter->sum($document, 'price', [], $max); + + $this->assertSame([$expected->query], $this->statements); + $this->assertSame($adapter->boundValues($expected->bindings), $this->bindings); + $this->assertSame(5, $result); + } + + /** + * @template T of SQL + * + * @param Closure(PDO): T $make + * @return T + */ + private function adapter(Closure $make, bool $shared, ?int $tenant, string $authorization): SQL + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('bindValue')->willReturnCallback(function (int|string $parameter, mixed $value): bool { + $this->bindings[] = $value; + + return true; + }); + $statement->method('fetch')->willReturn(['sum' => '5']); + $statement->method('fetchAll')->willReturn([['sum' => '5']]); + $statement->method('closeCursor')->willReturn(true); + + return $statement; + }); + + $adapter = $make($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables($shared); + $adapter->setTenant($tenant); + + $roles = new Authorization(); + foreach (self::ROLES as $role) { + $roles->addRole($role); + } + if ($authorization === 'unauthorized') { + $roles->disable(); + } + $adapter->setAuthorization($roles); + + return $adapter; + } +} diff --git a/tests/unit/Adapter/UpsertDocumentTest.php b/tests/unit/Adapter/UpsertDocumentTest.php new file mode 100644 index 0000000000..2640af21a9 --- /dev/null +++ b/tests/unit/Adapter/UpsertDocumentTest.php @@ -0,0 +1,179 @@ +adapter = new SQLite(new PDO('sqlite::memory:')); + $this->database = new Database($this->adapter, new Cache(new None())); + $this->database + ->setAuthorization(new Authorization()) + ->setDatabase('upserts') + ->setNamespace('upserts_'.\uniqid()); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::integer(key: 'views'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + } + + public function testUpsertDocumentInsertsADocumentNothingStoredYet(): void + { + $written = $this->adapter->upsertDocument($this->collection(), new Change(new Document(), $this->document('one', 'first', 1))); + + $this->assertSame('one', $written->getId()); + $this->assertSame(['first', 1], $this->stored('one')); + } + + public function testUpsertDocumentReplacesTheStoredDocument(): void + { + $this->adapter->upsertDocument($this->collection(), new Change(new Document(), $this->document('one', 'first', 1))); + $stored = $this->database->getDocument(self::COLLECTION, 'one'); + + $written = $this->adapter->upsertDocument($this->collection(), new Change($stored, $this->document('one', 'second', 5))); + + $this->assertSame('second', $written->getAttribute('title')); + $this->assertSame(['second', 5], $this->stored('one')); + $this->assertSame(1, $this->database->count(self::COLLECTION)); + } + + public function testUpsertDocumentsWritesEveryChangeInOrder(): void + { + $written = $this->adapter->upsertDocuments($this->collection(), [ + new Change(new Document(), $this->document('one', 'first', 1)), + new Change(new Document(), $this->document('two', 'second', 2)), + ]); + + $this->assertSame(['one', 'two'], \array_map(static fn (Document $document): string => $document->getId(), $written)); + $this->assertSame(['first', 1], $this->stored('one')); + $this->assertSame(['second', 2], $this->stored('two')); + $this->assertSame([], $this->adapter->upsertDocuments($this->collection(), [])); + } + + public function testAnIncreaseAddsTheValueToTheStoredOne(): void + { + $this->database->upsertDocuments(self::COLLECTION, [$this->document('one', 'first', 2)], increase: 'views'); + $this->database->upsertDocuments(self::COLLECTION, [$this->document('one', 'first', 3)], increase: 'views'); + + $this->assertSame(['first', 5], $this->stored('one')); + } + + public function testWithoutAnIncreaseTheValueIsReplaced(): void + { + $this->database->upsertDocuments(self::COLLECTION, [$this->document('one', 'first', 2)]); + $this->database->upsertDocuments(self::COLLECTION, [$this->document('one', 'first', 3)]); + + $this->assertSame(['first', 3], $this->stored('one')); + } + + public function testASingleUpsertThroughTheDatabaseReturnsTheWrittenDocument(): void + { + $created = $this->database->upsertDocument(self::COLLECTION, $this->document('one', 'first', 1)); + $updated = $this->database->upsertDocument(self::COLLECTION, $this->document('one', 'second', 2)); + + $this->assertSame(['first', 1], [$created->getAttribute('title'), $created->getAttribute('views')]); + $this->assertSame(['second', 2], [$updated->getAttribute('title'), $updated->getAttribute('views')]); + $this->assertSame(['second', 2], $this->stored('one')); + } + + public function testAPoolUpsertsOnItsConnection(): void + { + $pool = $this->pool($this->adapter); + + $written = $pool->upsertDocument($this->collection(), new Change(new Document(), $this->document('one', 'pooled', 4))); + + $this->assertSame('one', $written->getId()); + $this->assertSame(['pooled', 4], $this->stored('one')); + } + + public function testAPoolOverAnAdapterWithoutUpsertsRefusesThem(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support upserts'); + + $this->pool(new Memory())->upsertDocument($this->collection(), new Change(new Document(), $this->document('one', 'first', 1))); + } + + private function collection(): Collection + { + return $this->database->getCollection(self::COLLECTION); + } + + private function document(string $id, string $title, int $views): Document + { + $now = DateTime::now(); + + return new Document([ + Document::ID => $id, + Document::PERMISSIONS => [], + Document::CREATED_AT => $now, + Document::UPDATED_AT => $now, + 'title' => $title, + 'views' => $views, + ]); + } + + /** + * @return array{mixed, mixed} + */ + private function stored(string $id): array + { + $document = $this->database->getDocument(self::COLLECTION, $id); + + return [$document->getAttribute('title'), $document->getAttribute('views')]; + } + + private function pool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(static fn (callable $callback): mixed => $callback($adapter)); + + $pool = new Pool($connections); + $pool->setAuthorization($this->database->getAuthorization()); + $pool->setDatabase($this->database->getDatabase()); + $pool->setNamespace($this->database->getNamespace()); + + return $pool; + } +} diff --git a/tests/unit/Adapter/UpsertNewDocumentOperatorTest.php b/tests/unit/Adapter/UpsertNewDocumentOperatorTest.php new file mode 100644 index 0000000000..94e6dacc0a --- /dev/null +++ b/tests/unit/Adapter/UpsertNewDocumentOperatorTest.php @@ -0,0 +1,147 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'tasks', + attributes: [ + Attribute::datetime('due', default: '2026-01-31T12:30:00.000+00:00'), + Attribute::datetime('reminder'), + Attribute::boolean('active', default: true), + Attribute::boolean('archived'), + Attribute::bigInteger('counter', default: PHP_INT_MAX - 5), + Attribute::bigInteger('floor', default: PHP_INT_MIN + 5), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + } + + /** + * @return iterable + */ + public static function operators(): iterable + { + yield 'days added to the default' => ['due', Operator::dateAddDays(1), '2026-02-01T12:30:00.000+00:00']; + yield 'days subtracted from the default' => ['due', Operator::dateSubDays(31), '2025-12-31T12:30:00.000+00:00']; + yield 'no days added' => ['due', Operator::dateAddDays(0), '2026-01-31T12:30:00.000+00:00']; + yield 'days added without a default' => ['reminder', Operator::dateAddDays(3), null]; + yield 'days subtracted without a default' => ['reminder', Operator::dateSubDays(3), null]; + yield 'default toggled off' => ['active', Operator::toggle(), false]; + yield 'missing default toggled on' => ['archived', Operator::toggle(), true]; + yield 'increment within the limit' => ['counter', Operator::increment(5, PHP_INT_MAX), PHP_INT_MAX]; + yield 'increment past the limit keeps the default' => ['counter', Operator::increment(10, PHP_INT_MAX), PHP_INT_MAX - 5]; + yield 'multiply past the limit keeps the default' => ['counter', Operator::multiply(2, PHP_INT_MAX), PHP_INT_MAX - 5]; + yield 'decrement past the limit keeps the default' => ['floor', Operator::decrement(10, PHP_INT_MIN), PHP_INT_MIN + 5]; + } + + #[DataProvider('operators')] + public function testAnOperatorOnANewDocumentAppliesToTheDefault(string $attribute, Operator $operator, mixed $expected): void + { + $this->database->upsertDocument('tasks', new Document(['$id' => 'created', $attribute => $operator])); + + $this->assertSame($expected, $this->database->getDocument('tasks', 'created')->getAttribute($attribute)); + } + + public function testDaysAddedToANewDocumentMatchTheDaysAddedToAnExistingOne(): void + { + $this->database->createDocument('tasks', new Document(['$id' => 'existing'])); + + $this->database->upsertDocuments('tasks', [ + new Document(['$id' => 'existing', 'due' => Operator::dateAddDays(2)]), + new Document(['$id' => 'created', 'due' => Operator::dateAddDays(2)]), + ]); + + $this->assertSame('2026-02-02T12:30:00.000+00:00', $this->database->getDocument('tasks', 'existing')->getAttribute('due')); + $this->assertSame('2026-02-02T12:30:00.000+00:00', $this->database->getDocument('tasks', 'created')->getAttribute('due')); + } + + public function testAnOperatorOnlyALaterDocumentOfTheBatchCarriesIsApplied(): void + { + $this->database->upsertDocuments('tasks', [ + new Document(['$id' => 'plain', 'archived' => true]), + new Document(['$id' => 'toggled', 'active' => Operator::toggle()]), + ]); + + $this->assertTrue($this->database->getDocument('tasks', 'plain')->getAttribute('active')); + $this->assertTrue($this->database->getDocument('tasks', 'plain')->getAttribute('archived')); + $this->assertFalse($this->database->getDocument('tasks', 'toggled')->getAttribute('active')); + } + + public function testAnEmptyBatchWritesNothing(): void + { + $adapter = $this->database->getAdapter(); + $this->assertInstanceOf(SQLite::class, $adapter); + $collection = $this->database->getCollection('tasks'); + + $this->assertSame([], $adapter->createDocuments($collection, [])); + $this->assertSame([], $adapter->upsertDocuments($collection, [])); + $this->assertSame([], $this->database->find('tasks')); + } + + public function testAnUpsertThatBreaksAUniqueIndexIsAUniqueViolation(): void + { + $this->database->createCollection(Collection::create( + id: 'accounts', + attributes: [Attribute::string('email', size: 64)], + indexes: [Index::unique(key: 'unique_email', attributes: ['email'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $this->database->createDocument('accounts', new Document(['$id' => 'first', 'email' => 'shared@example.com'])); + + try { + $this->database->upsertDocument('accounts', new Document(['$id' => 'second', 'email' => 'shared@example.com'])); + $this->fail('an upsert breaking a unique index must be refused'); + } catch (UniqueException $error) { + $this->assertInstanceOf(\PDOException::class, $error->getPrevious()); + } + + $this->assertTrue($this->database->getDocument('accounts', 'second')->isEmpty()); + } + + public function testNowSetOnANewDocumentIsTheTimeOfTheWrite(): void + { + $before = new \DateTimeImmutable('-1 second'); + $this->database->upsertDocument('tasks', new Document(['$id' => 'created', 'reminder' => Operator::dateSetNow()])); + $after = new \DateTimeImmutable('+1 second'); + + $reminder = $this->database->getDocument('tasks', 'created')->getAttribute('reminder'); + $this->assertIsString($reminder); + $written = new \DateTimeImmutable($reminder); + $this->assertGreaterThanOrEqual($before, $written); + $this->assertLessThanOrEqual($after, $written); + } +} diff --git a/tests/unit/Adapter/UpsertNewDocumentParityTest.php b/tests/unit/Adapter/UpsertNewDocumentParityTest.php new file mode 100644 index 0000000000..b49d90b094 --- /dev/null +++ b/tests/unit/Adapter/UpsertNewDocumentParityTest.php @@ -0,0 +1,89 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'items', + attributes: [ + Attribute::integer('count', default: 7), + Attribute::integer('numbers', array: true, default: [1, 2, 2, 3, 5]), + Attribute::string('words', size: 16, array: true, default: ['a', 'b', 'b']), + Attribute::integer('empty', array: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + } + + /** + * @return iterable + */ + public static function operators(): iterable + { + yield 'increment within its maximum' => ['count', static fn (): Operator => Operator::increment(3, 10), 10]; + yield 'increment past its maximum' => ['count', static fn (): Operator => Operator::increment(5, 10), 7]; + yield 'decrement past its minimum' => ['count', static fn (): Operator => Operator::decrement(5, 5), 7]; + yield 'multiply past its maximum' => ['count', static fn (): Operator => Operator::multiply(3, 20), 7]; + yield 'divide past its minimum' => ['count', static fn (): Operator => Operator::divide(2, 5), 7]; + yield 'power past its maximum' => ['count', static fn (): Operator => Operator::power(2, 40), 7]; + yield 'power within its maximum' => ['count', static fn (): Operator => Operator::power(2, 49), 49]; + yield 'filter greater than' => ['numbers', static fn (): Operator => Operator::arrayFilter('greaterThan', 2), [3, 5]]; + yield 'filter greater than or equal' => ['numbers', static fn (): Operator => Operator::arrayFilter('greaterThanEqual', 3), [3, 5]]; + yield 'filter less than' => ['numbers', static fn (): Operator => Operator::arrayFilter('lessThan', 3), [1, 2, 2]]; + yield 'filter less than or equal' => ['numbers', static fn (): Operator => Operator::arrayFilter('lessThanEqual', 2), [1, 2, 2]]; + yield 'filter equal' => ['numbers', static fn (): Operator => Operator::arrayFilter('equal', 2), [2, 2]]; + yield 'filter not equal' => ['numbers', static fn (): Operator => Operator::arrayFilter('notEqual', 2), [1, 3, 5]]; + yield 'filter null' => ['numbers', static fn (): Operator => Operator::arrayFilter('isNull'), []]; + yield 'filter not null' => ['numbers', static fn (): Operator => Operator::arrayFilter('isNotNull'), [1, 2, 2, 3, 5]]; + yield 'filter equal string' => ['words', static fn (): Operator => Operator::arrayFilter('equal', 'b'), ['b', 'b']]; + yield 'filter a missing array' => ['empty', static fn (): Operator => Operator::arrayFilter('greaterThan', 0), []]; + } + + /** + * @param Closure(): Operator $operator + */ + #[DataProvider('operators')] + public function testANewDocumentGetsWhatAnExistingDocumentWithTheSameValueGets(string $attribute, Closure $operator, mixed $expected): void + { + $this->database->createDocument('items', new Document(['$id' => 'existing'])); + + $this->database->upsertDocuments('items', [ + new Document(['$id' => 'existing', $attribute => $operator()]), + new Document(['$id' => 'created', $attribute => $operator()]), + ]); + + $this->assertSame($expected, $this->database->getDocument('items', 'existing')->getAttribute($attribute), 'existing document'); + $this->assertSame($expected, $this->database->getDocument('items', 'created')->getAttribute($attribute), 'new document'); + } +} diff --git a/tests/unit/AggregateEngineErrorsTest.php b/tests/unit/AggregateEngineErrorsTest.php new file mode 100644 index 0000000000..a2d2dbdf9b --- /dev/null +++ b/tests/unit/AggregateEngineErrorsTest.php @@ -0,0 +1,296 @@ +setDatabase('aggregate_engine_errors') + ->setNamespace('aggregate_engine_errors_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'sensor', size: 20, required: true), + Attribute::integer(key: 'value', required: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + foreach ([['north', 6], ['north', 3], ['south', 5]] as [$sensor, $value]) { + $database->createDocument(self::COLLECTION, new Document([ + 'sensor' => $sensor, + 'value' => $value, + '$permissions' => [Permission::read(Role::any())], + ])); + } + + return $database; + } + + /** + * @return array, 1: string}> + */ + public static function missingAggregateProvider(): array + { + return [ + 'stddev' => [[Query::stddev('value', 'result')], 'stddev'], + 'stddevPop' => [[Query::stddevPop('value', 'result')], 'stddevPop'], + 'stddevSamp' => [[Query::stddevSamp('value', 'result')], 'stddevSamp'], + 'variance' => [[Query::variance('value', 'result')], 'variance'], + 'varPop' => [[Query::varPop('value', 'result')], 'varPop'], + 'varSamp' => [[Query::varSamp('value', 'result')], 'varSamp'], + 'bitAnd' => [[Query::bitAnd('value', 'result')], 'bitAnd'], + 'bitOr' => [[Query::bitOr('value', 'result')], 'bitOr'], + 'bitXor' => [[Query::bitXor('value', 'result')], 'bitXor'], + 'stddev without an alias' => [[Query::stddev('value')], 'stddev'], + 'bitOr per group' => [[Query::bitOr('value', 'result'), Query::groupBy(['sensor'])], 'bitOr'], + 'variance next to a count' => [[Query::count('*', 'rows'), Query::variance('value', 'result')], 'variance'], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('missingAggregateProvider')] + public function testSQLiteRejectsAnAggregateItHasNoFunctionFor(array $queries, string $method): void + { + $this->assertFailsWith( + QueryException::class, + 'Aggregate '.$method.' is not supported by this adapter', + fn () => $this->database()->aggregate(self::COLLECTION, $queries), + ); + } + + public function testSQLiteStillAnswersTheAggregatesItHasFunctionsFor(): void + { + $results = $this->database()->aggregate(self::COLLECTION, [ + Query::count('*', 'rows'), + Query::countDistinct('sensor', 'sensors'), + Query::sum('value', 'total'), + Query::avg('value', 'mean'), + Query::min('value', 'least'), + Query::max('value', 'most'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(3, $results[0]['rows']); + $this->assertSame(2, $results[0]['sensors']); + $this->assertSame(14, $results[0]['total']); + $this->assertEqualsWithDelta(14 / 3, $results[0]['mean'], 1e-9); + $this->assertSame(3, $results[0]['least']); + $this->assertSame(6, $results[0]['most']); + } + + /** + * @return array + */ + public static function adapterProvider(): array + { + return [ + 'MariaDB' => [new MariaDB(new stdClass())], + 'MySQL' => [new MySQL(new stdClass())], + 'Postgres' => [new Postgres(new stdClass())], + 'SQLite' => [new SQLite(new PDO('sqlite::memory:'))], + 'SQLite emulating MySQL' => [new class (new PDO('sqlite::memory:')) extends SQLite { + #[\Override] + protected bool $emulateMySQL = true; + }], + ]; + } + + #[DataProvider('adapterProvider')] + public function testEverySQLAdapterAnswersAggregations(SQL $adapter): void + { + $this->assertTrue($adapter->supports(Capability::Aggregations)); + } + + /** + * @return array, 1: PDOException, 2: class-string, 3: string}> + */ + public static function mappedEngineErrorProvider(): array + { + $unknownColumn = self::engineError('42S22', 1054, "SQLSTATE[42S22]: Column not found: 1054 Unknown column 'no_such_attribute' in 'WHERE'"); + $tooManyTables = self::engineError('HY000', 1116, 'SQLSTATE[HY000]: General error: 1116 Too many tables; MariaDB can only use 61 tables in a join'); + $noFulltextIndex = self::engineError('HY000', 1191, "SQLSTATE[HY000]: General error: 1191 Can't find FULLTEXT index matching the column list"); + $unknownDroppedColumn = self::engineError('42000', 1091, "SQLSTATE[42000]: Syntax error or access violation: 1091 Can't DROP COLUMN `score`; check that it exists"); + + return [ + 'MariaDB unknown column' => [MariaDB::class, $unknownColumn, NotFoundException::class, 'Attribute not found'], + 'MySQL unknown column' => [MySQL::class, $unknownColumn, NotFoundException::class, 'Attribute not found'], + 'MariaDB column that cannot be dropped' => [MariaDB::class, $unknownDroppedColumn, NotFoundException::class, 'Attribute not found'], + 'Postgres unknown column' => [ + Postgres::class, + self::engineError('42703', 7, 'SQLSTATE[42703]: Undefined column: 7 ERROR: column table_main.no_such_attribute does not exist'), + NotFoundException::class, + 'Attribute not found', + ], + 'MariaDB too many tables' => [MariaDB::class, $tooManyTables, QueryException::class, 'Too many tables in a join'], + 'MySQL too many tables' => [MySQL::class, $tooManyTables, QueryException::class, 'Too many tables in a join'], + 'MariaDB no fulltext index' => [MariaDB::class, $noFulltextIndex, QueryException::class, 'Searching requires a fulltext index on the searched attributes'], + 'MySQL no fulltext index' => [MySQL::class, $noFulltextIndex, QueryException::class, 'Searching requires a fulltext index on the searched attributes'], + 'MySQL distinct ordered by an unselected column' => [ + MySQL::class, + self::engineError('HY000', 3065, "SQLSTATE[HY000]: General error: 3065 Expression #1 of ORDER BY clause is not in SELECT list, references column 'utopiaTests.ns_distinct_order.score' which is not in SELECT list; this is incompatible with DISTINCT"), + QueryException::class, + self::DISTINCT_ORDER, + ], + 'Postgres distinct ordered by an unselected column' => [ + Postgres::class, + self::engineError('42P10', 7, "SQLSTATE[42P10]: Invalid column reference: 7 ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list\nLINE 1: ...\"table_main\" ORDER BY \"table_main\".\"score\" ASC"), + QueryException::class, + self::DISTINCT_ORDER, + ], + ]; + } + + /** + * @param class-string $adapter + * @param class-string $expected + */ + #[DataProvider('mappedEngineErrorProvider')] + public function testEngineErrorsAreMappedToLibraryExceptions(string $adapter, PDOException $error, string $expected, string $message): void + { + $processed = $this->readFailure($adapter, $error); + + $this->assertInstanceOf($expected, $processed); + $this->assertSame($message, $processed->getMessage()); + $this->assertSame($error, $processed->getPrevious()); + } + + public function testAnAggregateAliasOfSixtyThreeCharactersIsAccepted(): void + { + $alias = \str_repeat('a', 63); + + $this->assertSame([[$alias => 14]], $this->database()->aggregate(self::COLLECTION, [Query::sum('value', $alias)])); + } + + /** + * @return array, 1: string}> + */ + public static function rejectedAliasProvider(): array + { + return [ + 'sixty-four characters' => [[Query::sum('value', \str_repeat('a', 64))], self::ALIAS_TOO_LONG], + 'sixty-four characters per group' => [[Query::count('*', \str_repeat('a', 64)), Query::groupBy(['sensor'])], self::ALIAS_TOO_LONG], + 'three hundred characters' => [[Query::max('value', \str_repeat('a', 300))], self::ALIAS_TOO_LONG], + 'an invalid character' => [[Query::sum('value', 'bad-alias')], 'Invalid query: Invalid aggregate alias'], + 'a leading digit' => [[Query::sum('value', '1abc')], 'Invalid query: Invalid aggregate alias'], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('rejectedAliasProvider')] + public function testAggregateRejectsAnInvalidAggregateAlias(array $queries, string $message): void + { + $this->assertFailsWith( + QueryException::class, + $message, + fn () => $this->database()->aggregate(self::COLLECTION, $queries), + ); + } + + public function testPostgresLeavesTheOnConflictInvalidColumnReferenceUnmapped(): void + { + $error = self::engineError('42P10', 7, 'SQLSTATE[42P10]: Invalid column reference: 7 ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification'); + + $this->assertSame($error, $this->readFailure(Postgres::class, $error)); + } + + private static function engineError(string $state, int $code, string $message): PDOException + { + $error = new class ($message, $state) extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $error->errorInfo = [$state, $code, $message]; + + return $error; + } + + /** + * @param class-string $adapter + */ + private function readFailure(string $adapter, PDOException $error): Throwable + { + $statement = self::createStub(PDOStatement::class); + $statement->method('execute')->willThrowException($error); + $pdo = self::createStub(PDO::class); + $pdo->method('prepare')->willReturn($statement); + + $sql = new $adapter($pdo); + $sql->setDatabase('database'); + $sql->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $sql->setAuthorization($authorization); + + try { + $sql->find(new Document(['$id' => self::COLLECTION])); + } catch (Throwable $failure) { + return $failure; + } + + $this->fail('The read succeeded'); + } + + /** + * @param class-string $expected + */ + private function assertFailsWith(string $expected, string $message, callable $call): void + { + $error = null; + try { + $call(); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf($expected, $error, $error === null ? 'the call succeeded' : $error::class.': '.$error->getMessage()); + $this->assertSame($message, $error->getMessage()); + } +} diff --git a/tests/unit/Attributes/AdapterRefusalTest.php b/tests/unit/Attributes/AdapterRefusalTest.php new file mode 100644 index 0000000000..92fd21e2bd --- /dev/null +++ b/tests/unit/Attributes/AdapterRefusalTest.php @@ -0,0 +1,131 @@ +database(createAttribute: static fn (): bool => false); + + try { + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'title', size: 64)); + $this->fail('An attribute the adapter did not create must be an error'); + } catch (RefusedException $error) { + $this->assertSame('Failed to create attribute', $error->getMessage()); + } + + $this->assertSame([], $database->getCollection(self::COLLECTION)->attributes()); + } + + public function testABatchTheAdapterDidNotCreateIsAnErrorAndStaysOutOfTheMetadata(): void + { + $database = $this->database(createAttributes: static fn (): bool => false); + + try { + $database->createAttributes(self::COLLECTION, [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'age')]); + $this->fail('A batch the adapter did not create must be an error'); + } catch (RefusedException $error) { + $this->assertSame('Failed to create attributes', $error->getMessage()); + } + + $this->assertSame([], $database->getCollection(self::COLLECTION)->attributes()); + } + + public function testAColumnTheAdapterDidNotCreateWhileCreatingADuplicateBatchOneByOneIsRefused(): void + { + $database = $this->database( + createAttribute: static fn (Attribute $attribute): bool => $attribute->key !== 'age', + createAttributes: static fn (): bool => throw new DuplicateException('Attribute already exists'), + ); + + try { + $database->createAttributes(self::COLLECTION, [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'age')]); + $this->fail('A column the adapter did not create must not be stored as created'); + } catch (RefusedException $error) { + $this->assertSame('Failed to create attribute', $error->getMessage()); + } + + $this->assertSame([], $database->getCollection(self::COLLECTION)->attributes()); + } + + public function testAMismatchWhileCreatingADuplicateBatchOneByOneReachesTheCaller(): void + { + $database = $this->database( + createAttribute: static fn (Attribute $attribute): bool => $attribute->key === 'age' + ? throw new MismatchException('Attribute exists in the shared table with another type') + : true, + createAttributes: static fn (): bool => throw new DuplicateException('Attribute already exists'), + ); + + try { + $database->createAttributes(self::COLLECTION, [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'age')]); + $this->fail('A column of another type must not be skipped as a duplicate'); + } catch (MismatchException $error) { + $this->assertSame('Attribute exists in the shared table with another type', $error->getMessage()); + } + + $this->assertSame([], $database->getCollection(self::COLLECTION)->attributes()); + } + + /** + * @param (Closure(Attribute): bool)|null $createAttribute + * @param (Closure(list): bool)|null $createAttributes + */ + private function database(?Closure $createAttribute = null, ?Closure $createAttributes = null): Database + { + $adapter = new class ($createAttribute, $createAttributes) extends Memory { + /** + * @param (Closure(Attribute): bool)|null $single + * @param (Closure(list): bool)|null $batch + */ + public function __construct(private readonly ?Closure $single, private readonly ?Closure $batch) + { + parent::__construct(); + } + + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool + { + return $this->single === null ? parent::createAttribute($collection, $attribute) : ($this->single)($attribute); + } + + /** + * @param list $attributes + */ + #[\Override] + public function createAttributes(string $collection, array $attributes): bool + { + return $this->batch === null ? parent::createAttributes($collection, $attributes) : ($this->batch)($attributes); + } + }; + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('refusal') + ->setNamespace('refusal_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: self::COLLECTION)); + + return $database; + } +} diff --git a/tests/unit/Attributes/AttributeValidationTest.php b/tests/unit/Attributes/AttributeValidationTest.php new file mode 100644 index 0000000000..30b2a80d7e --- /dev/null +++ b/tests/unit/Attributes/AttributeValidationTest.php @@ -0,0 +1,431 @@ + */ + private array $metadataWrites = []; + + #[\Override] + protected function setUp(): void + { + $this->adapter = self::createStub(Adapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createAttribute')->willReturn(true); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + private function metaCollection(): Document + { + return new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $attributes + */ + private function setupCollection(string $id, array $attributes = []): void + { + $collection = new Document([ + '$id' => $id, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + $meta = $this->metaCollection(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($id, $collection, $meta) { + if ($col->getId() === Database::METADATA && $docId === $id) { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + + return new Document(); + } + ); + $this->adapter->method('updateDocument')->willReturnCallback(function (Document $collection, string $id, Document $document): Document { + $this->metadataWrites[] = $document; + + return $document; + }); + } + + private function lastMetadataWrite(): Collection + { + $write = \end($this->metadataWrites); + if ($write === false) { + $this->fail('no metadata was written'); + } + + return Collection::fromDocument($write); + } + + public function testCreateAttributeOnMissingCollectionThrows(): void + { + $this->adapter->method('getDocument')->willReturn(new Document()); + + $this->expectException(NotFoundException::class); + $this->database->createAttribute('nonexistent', Attribute::string(key: 'name', size: 128)); + } + + public function testCreateAttributeRejectsDuplicateKey(): void + { + $existingAttrs = [ + new Document(['$id' => 'title', 'key' => 'title', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollection('testCol', $existingAttrs); + + $this->expectException(DuplicateException::class); + $this->database->createAttribute('testCol', Attribute::string(key: 'title', size: 128)); + } + + public function testCreateAttributeValidatesSizeLimitsForStrings(): void + { + $this->setupCollection('testCol'); + + $this->expectException(\Utopia\Database\Exception::class); + $this->expectExceptionMessage('Max size allowed for string'); + + $tooBig = $this->adapter->limits()->string + 1; + $this->database->createAttribute('testCol', Attribute::string(key: 'bigstr', size: $tooBig)); + } + + public function testCreateAttributeSucceedsWithValidString(): void + { + $this->setupCollection('testCol'); + + $created = $this->database->createAttribute('testCol', Attribute::string(key: 'name', size: 128)); + + $this->assertSame('name', $created->key); + $this->assertSame(Attribute::string(key: 'name', size: 128)->type, $created->type); + } + + public function testCreateAttributeSucceedsWithInteger(): void + { + $this->setupCollection('testCol'); + + $created = $this->database->createAttribute('testCol', Attribute::integer(key: 'age')); + + $this->assertSame('age', $created->key); + $this->assertSame(Attribute::integer(key: 'age')->type, $created->type); + } + + public function testCreateAttributeSucceedsWithBoolean(): void + { + $this->setupCollection('testCol'); + + $created = $this->database->createAttribute('testCol', Attribute::boolean(key: 'active')); + + $this->assertSame('active', $created->key); + $this->assertSame(Attribute::boolean(key: 'active')->type, $created->type); + } + + public function testCreateAttributeSucceedsWithDouble(): void + { + $this->setupCollection('testCol'); + + $created = $this->database->createAttribute('testCol', Attribute::double(key: 'score')); + + $this->assertSame('score', $created->key); + $this->assertSame(Attribute::double(key: 'score')->type, $created->type); + } + + public function testCreateAttributeEnforcesAttributeCountLimit(): void + { + $adapter = self::createStub(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 2, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(100); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, Capability::DefinedAttributes]); + }); + $adapter->method('startTransaction')->willReturn(true); + $adapter->method('commitTransaction')->willReturn(true); + $adapter->method('rollbackTransaction')->willReturn(true); + $adapter->method('createAttribute')->willReturn(true); + + $collection = new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'testCol', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'testCol') { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + + return new Document(); + } + ); + $adapter->method('updateDocument')->willReturnArgument(2); + + $db = new Database($adapter, new Cache(new None())); + $db->getAuthorization()->addRole(Role::any()->toString()); + + $this->expectException(LimitException::class); + $db->createAttribute('testCol', Attribute::string(key: 'extra', size: 128)); + } + + public function testCreateAttributeEnforcesRowWidthLimit(): void + { + $adapter = self::createStub(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 100, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(200); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, Capability::DefinedAttributes]); + }); + $adapter->method('startTransaction')->willReturn(true); + $adapter->method('commitTransaction')->willReturn(true); + $adapter->method('rollbackTransaction')->willReturn(true); + $adapter->method('createAttribute')->willReturn(true); + + $collection = new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'testCol', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'testCol') { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + + return new Document(); + } + ); + $adapter->method('updateDocument')->willReturnArgument(2); + + $db = new Database($adapter, new Cache(new None())); + $db->getAuthorization()->addRole(Role::any()->toString()); + + $this->expectException(LimitException::class); + $db->createAttribute('testCol', Attribute::string(key: 'wide', size: 128)); + } + + public function testDeleteAttributeRemovesFromCollection(): void + { + $existingAttrs = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollection('testCol', $existingAttrs); + $this->adapter->method('deleteAttribute')->willReturn(true); + + $this->database->deleteAttribute('testCol', 'name'); + + $this->assertSame([], $this->lastMetadataWrite()->attributes()); + } + + public function testDeleteAttributeThrowsOnNotFound(): void + { + $this->setupCollection('testCol'); + $this->expectException(NotFoundException::class); + $this->database->deleteAttribute('testCol', 'nonexistent'); + } + + public function testRenameAttributeThrowsOnDuplicateName(): void + { + $existingAttrs = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + new Document(['$id' => 'title', 'key' => 'title', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollection('testCol', $existingAttrs); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Attribute name already used'); + $this->database->renameAttribute('testCol', 'name', 'title'); + } + + public function testRenameAttributeThrowsOnNotFound(): void + { + $this->setupCollection('testCol'); + $this->expectException(NotFoundException::class); + $this->database->renameAttribute('testCol', 'nonexistent', 'newname'); + } + + public function testCreateAttributesBatchValidatesEach(): void + { + $existingAttrs = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollection('testCol', $existingAttrs); + $this->adapter->method('createAttributes')->willReturn(true); + + $this->expectException(DuplicateException::class); + $this->database->createAttributes('testCol', [ + Attribute::string(key: 'name', size: 128), + ]); + } + + public function testCreateAttributesBatchWithEmptyListThrows(): void + { + $this->setupCollection('testCol'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('No attributes to create'); + $this->database->createAttributes('testCol', []); + } + + public function testCreateAttributesOnMissingCollectionThrows(): void + { + $this->adapter->method('getDocument')->willReturn(new Document()); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + $this->database->createAttributes('nonexistent', [Attribute::string(key: 'name', size: 128)]); + } +} diff --git a/tests/unit/Attributes/CheckAttributeTest.php b/tests/unit/Attributes/CheckAttributeTest.php new file mode 100644 index 0000000000..91940f6a5b --- /dev/null +++ b/tests/unit/Attributes/CheckAttributeTest.php @@ -0,0 +1,95 @@ +database([Attribute::string('first', 16), Attribute::string('second', 16), Attribute::string('third', 16)]); + + $this->assertTrue($database->checkAttribute('items', Attribute::string('fourth', 16))); + } + + public function testAnAttributeOverTheLimitIsRefused(): void + { + $database = $this->database([Attribute::string('first', 16), Attribute::string('second', 16), Attribute::string('third', 16), Attribute::string('fourth', 16)]); + + $this->expectException(LimitException::class); + + $database->checkAttribute('items', Attribute::string('fifth', 16)); + } + + public function testCheckingDoesNotStoreTheAttribute(): void + { + $database = $this->database([Attribute::string('first', 16)]); + + $database->checkAttribute('items', Attribute::string('second', 16)); + + $this->assertSame(['first'], \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection('items')->attributes(), + )); + } + + public function testAMissingCollectionIsNotFound(): void + { + $this->expectException(NotFoundException::class); + + $this->database([])->checkAttribute('missing', Attribute::string('first', 16)); + } + + /** + * @param list $attributes + */ + private function database(array $attributes): Database + { + $adapter = new class () extends Memory { + #[\Override] + public function limits(): Limits + { + $limits = parent::limits(); + + return new Limits( + string: $limits->string, + varchar: $limits->varchar, + integer: $limits->integer, + bigInteger: $limits->bigInteger, + attributes: $limits->defaultAttributes + \count(Database::collectionDefinition()->attributes()), + indexes: $limits->indexes, + defaultAttributes: $limits->defaultAttributes, + defaultIndexes: $limits->defaultIndexes, + indexLength: $limits->indexLength, + uidLength: $limits->uidLength, + documentSize: $limits->documentSize, + minDateTime: $limits->minDateTime, + maxDateTime: $limits->maxDateTime, + idType: $limits->idType, + keywords: $limits->keywords, + internalIndexKeys: $limits->internalIndexKeys, + ); + } + }; + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('check_attribute') + ->setNamespace('check_attribute_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create('items', attributes: $attributes)); + + return $database; + } +} diff --git a/tests/unit/Attributes/CreateAttributesRollbackTest.php b/tests/unit/Attributes/CreateAttributesRollbackTest.php new file mode 100644 index 0000000000..23fd2b04d4 --- /dev/null +++ b/tests/unit/Attributes/CreateAttributesRollbackTest.php @@ -0,0 +1,206 @@ + + */ + private array $lockedColumns = []; + + /** + * @var list + */ + private array $dropped = []; + + private bool $driverErrors = false; + + private ?TypeError $dropError = null; + + public function testCleanupErrorsForEveryColumnThatCouldNotBeDroppedFollowTheMetadataError(): void + { + $database = $this->database(); + $this->lockedColumns = ['nick', 'bio']; + $this->metadataFailure = new RuntimeException('metadata store is read-only'); + + $thrown = null; + $stderr = StderrCapture::during(function () use ($database, &$thrown): void { + try { + $database->createAttributes(self::COLLECTION, $this->attributes('title', 'nick', 'bio')); + } catch (DatabaseException $error) { + $thrown = $error; + } + }); + + $this->assertInstanceOf(DatabaseException::class, $thrown); + $this->assertSame( + self::PREFIX."metadata store is read-only | Cleanup errors: Column 'nick' is locked, Column 'bio' is locked", + $thrown->getMessage() + ); + $this->assertSame($this->metadataFailure, $thrown->getPrevious()); + $this->assertSame(['title'], $this->dropped, 'the column that could be dropped is rolled back'); + $this->assertStringContainsString("Failed to cleanup attribute 'nick' after 3 attempts: Column 'nick' is locked", $stderr); + $this->assertStringContainsString("Failed to cleanup attribute 'bio' after 3 attempts: Column 'bio' is locked", $stderr); + $this->assertStringNotContainsString("'title'", $stderr); + $this->assertSame([], $this->storedKeys($database)); + } + + public function testACleanRollbackReportsOnlyTheMetadataErrorAndLetsTheCreateBeRetried(): void + { + $database = $this->database(); + $this->metadataFailure = new RuntimeException('metadata store is read-only'); + + $thrown = null; + $stderr = StderrCapture::during(function () use ($database, &$thrown): void { + try { + $database->createAttributes(self::COLLECTION, $this->attributes('title', 'nick')); + } catch (DatabaseException $error) { + $thrown = $error; + } + }); + + $this->assertInstanceOf(DatabaseException::class, $thrown); + $this->assertSame('Failed to persist metadata after retries for attributes creation: metadata store is read-only', $thrown->getMessage()); + $this->assertSame($this->metadataFailure, $thrown->getPrevious()); + $this->assertSame(['title', 'nick'], $this->dropped); + $this->assertSame('', $stderr); + $this->assertSame([], $this->storedKeys($database)); + + $this->metadataFailure = null; + $created = $database->createAttributes(self::COLLECTION, $this->attributes('title', 'nick')); + $this->assertSame(['title', 'nick'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $created)); + $this->assertSame(['title', 'nick'], $this->storedKeys($database)); + } + + public function testADriverErrorWhileDroppingAColumnIsCollectedAndTheRemainingColumnsAreStillDropped(): void + { + $database = $this->database(); + $this->lockedColumns = ['nick']; + $this->driverErrors = true; + $this->metadataFailure = new RuntimeException('metadata store is read-only'); + + $thrown = null; + StderrCapture::during(function () use ($database, &$thrown): void { + try { + $database->createAttributes(self::COLLECTION, $this->attributes('nick', 'title')); + } catch (\Throwable $error) { + $thrown = $error; + } + }); + + $this->assertInstanceOf(DatabaseException::class, $thrown); + $this->assertSame(self::PREFIX."metadata store is read-only | Cleanup errors: SQLSTATE[55P03]: lock not available on 'nick'", $thrown->getMessage()); + $this->assertSame($this->metadataFailure, $thrown->getPrevious()); + $this->assertSame(['title'], $this->dropped, 'the columns after the failing one are still dropped'); + $this->assertSame([], $this->storedKeys($database)); + } + + public function testAnErrorWhileDroppingAColumnEscapesTheRollbackUnchanged(): void + { + $database = $this->database(); + $this->lockedColumns = ['nick']; + $this->dropError = new TypeError('deleteAttribute(): Argument #2 ($id) must be of type string'); + $this->metadataFailure = new RuntimeException('metadata store is read-only'); + + $thrown = null; + StderrCapture::during(function () use ($database, &$thrown): void { + try { + $database->createAttributes(self::COLLECTION, $this->attributes('nick', 'title')); + } catch (\Throwable $error) { + $thrown = $error; + } + }); + + $this->assertSame($this->dropError, $thrown, 'a programming error in the rollback is not folded into the metadata failure'); + } + + /** + * @return list + */ + private function attributes(string ...$keys): array + { + return \array_values(\array_map(static fn (string $key): Attribute => Attribute::string(key: $key, size: 32), $keys)); + } + + /** + * @return list + */ + private function storedKeys(Database $database): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection(self::COLLECTION)->attributes() + ); + } + + private function database(): Database + { + $database = new Database($this->adapter(), new Cache(new MemoryCache())); + $database->setDatabase('rollback')->setNamespace('rollback_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: self::COLLECTION)); + + return $database; + } + + private function adapter(): Memory + { + $metadataFailure = fn (): ?RuntimeException => $this->metadataFailure; + $drop = function (string $id): void { + if (\in_array($id, $this->lockedColumns, true)) { + throw $this->dropError ?? ($this->driverErrors + ? new PDOException("SQLSTATE[55P03]: lock not available on '{$id}'") + : new DatabaseException("Column '{$id}' is locked")); + } + + $this->dropped[] = $id; + }; + + return new class ($metadataFailure, $drop) extends Memory { + public function __construct(private readonly \Closure $metadataFailure, private readonly \Closure $drop) + { + parent::__construct(); + } + + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + $failure = ($this->metadataFailure)(); + if ($failure instanceof RuntimeException && $collection->getId() === Database::METADATA) { + throw $failure; + } + + return parent::updateDocument($collection, $id, $document, $skipPermissions); + } + + #[\Override] + public function deleteAttribute(string $collection, string $id): bool + { + ($this->drop)($id); + + return parent::deleteAttribute($collection, $id); + } + }; + } +} diff --git a/tests/unit/Attributes/CreateReturnsStoredTest.php b/tests/unit/Attributes/CreateReturnsStoredTest.php new file mode 100644 index 0000000000..1c03bd8b5d --- /dev/null +++ b/tests/unit/Attributes/CreateReturnsStoredTest.php @@ -0,0 +1,102 @@ +database(); + + $created = $database->createAttribute('items', Attribute::fromArray([ + 'key' => 'at', + 'type' => 'datetime', + 'signed' => true, + ])); + + $this->assertSame(['datetime'], $created->filters); + $this->assertFalse($created->signed); + $this->assertSame($created->toDocument()->getArrayCopy(), $this->stored($database, 'at')->toDocument()->getArrayCopy()); + } + + public function testCreateAttributesReturnsTheStoredModelsInOrder(): void + { + $database = $this->database(); + + $created = $database->createAttributes('items', [ + Attribute::string('title', 32), + Attribute::fromArray(['key' => 'seen', 'type' => 'datetime']), + Attribute::integer('count'), + ]); + + $this->assertSame(['title', 'seen', 'count'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $created)); + foreach ($created as $attribute) { + $this->assertSame($attribute->toDocument()->getArrayCopy(), $this->stored($database, $attribute->key)->toDocument()->getArrayCopy()); + } + $this->assertSame(['datetime'], $created[1]->filters); + } + + public function testCreateAttributeRefusesADuplicateKey(): void + { + $database = $this->database(); + $database->createAttribute('items', Attribute::string('title', 32)); + + $this->expectException(DuplicateException::class); + + $database->createAttribute('items', Attribute::string('title', 32)); + } + + public function testCreateAttributeOnAMissingCollectionIsNotFound(): void + { + $this->expectException(NotFoundException::class); + + $this->database()->createAttribute('missing', Attribute::string('title', 32)); + } + + public function testCreateCollectionStoresTheTypeFilters(): void + { + $database = $this->database(); + + $database->createCollection(Collection::fromArray([ + '$id' => 'events', + 'attributes' => [['key' => 'at', 'type' => 'datetime']], + ])); + + $this->assertSame(['datetime'], $database->getCollection('events')->attributes()[0]->filters); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('create_returns_stored') + ->setNamespace('create_returns_stored_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create('items')); + + return $database; + } + + private function stored(Database $database, string $key): Attribute + { + foreach ($database->getCollection('items')->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + $this->fail('Attribute '.$key.' is missing from the collection metadata'); + } +} diff --git a/tests/unit/Attributes/OrphanColumnTypeTest.php b/tests/unit/Attributes/OrphanColumnTypeTest.php new file mode 100644 index 0000000000..fdd3690eb5 --- /dev/null +++ b/tests/unit/Attributes/OrphanColumnTypeTest.php @@ -0,0 +1,363 @@ + + */ + private const array UNSTORABLE = [ + ColumnType::TinyInteger, + ColumnType::SmallInteger, + ColumnType::Decimal, + ColumnType::Timestamp, + ColumnType::Json, + ColumnType::Binary, + ColumnType::Enum, + ColumnType::Uuid, + ColumnType::Uuid7, + ColumnType::Serial, + ColumnType::BigSerial, + ColumnType::SmallSerial, + ColumnType::Array, + ColumnType::Tuple, + ]; + + /** + * @return array + */ + public static function unstorableTypes(): array + { + $cases = []; + foreach (self::UNSTORABLE as $type) { + $cases[$type->value] = [$type]; + } + + return $cases; + } + + /** + * @return array + */ + public static function sqlAdaptersAndUnstorableTypes(): array + { + $adapters = [ + 'MariaDB' => new MariaDB(new stdClass()), + 'Postgres' => new Postgres(new stdClass()), + 'SQLite' => new SQLite(new PDO('sqlite::memory:')), + ]; + + $cases = []; + foreach ($adapters as $name => $adapter) { + foreach (self::UNSTORABLE as $type) { + $cases[$type->value.' on '.$name] = [$adapter, $type]; + } + } + + return $cases; + } + + #[DataProvider('sqlAdaptersAndUnstorableTypes')] + public function testNoSqlAdapterMapsAnUnstorableTypeToAColumn(SQL $adapter, ColumnType $type): void + { + $this->assertRefused(fn () => $adapter->getColumnType(Attribute::fromArray(['key' => self::KEY, 'type' => $type])), StructureException::class); + } + + #[DataProvider('unstorableTypes')] + public function testCreateAttributeOverAMatchingOrphanColumnRefusesTheType(ColumnType $type): void + { + [$database, $adapter] = $this->database(); + $adapter->createAttribute(self::COLLECTION, Attribute::bigInteger(key: self::KEY)); + + $this->assertRefused(fn () => $database->createAttribute(self::COLLECTION, Attribute::fromArray(['key' => self::KEY, 'type' => $type])), StructureException::class); + + $this->assertSame([], $database->getCollection(self::COLLECTION)->attributes()); + } + + #[DataProvider('unstorableTypes')] + public function testCreateAttributesOverAMatchingOrphanColumnRefusesTheType(ColumnType $type): void + { + [$database, $adapter] = $this->database(); + $adapter->createAttribute(self::COLLECTION, Attribute::bigInteger(key: self::KEY)); + + $this->assertRefused(fn () => $database->createAttributes(self::COLLECTION, [Attribute::fromArray(['key' => self::KEY, 'type' => $type])]), StructureException::class); + + $this->assertSame([], $database->getCollection(self::COLLECTION)->attributes()); + } + + #[DataProvider('unstorableTypes')] + public function testRefusalLeavesAnOrphanColumnOfAnotherTypeInPlace(ColumnType $type): void + { + [$database, $adapter] = $this->database(); + $adapter->createAttribute(self::COLLECTION, Attribute::string(key: self::KEY, size: 64)); + $orphan = $this->schemaColumnType($database); + + $this->assertRefused(fn () => $database->createAttribute(self::COLLECTION, Attribute::fromArray(['key' => self::KEY, 'type' => $type])), StructureException::class); + $this->assertSame($orphan, $this->schemaColumnType($database)); + + $this->assertRefused(fn () => $database->createAttributes(self::COLLECTION, [Attribute::fromArray(['key' => self::KEY, 'type' => $type])]), StructureException::class); + $this->assertSame($orphan, $this->schemaColumnType($database)); + + $this->assertSame([], $database->getCollection(self::COLLECTION)->attributes()); + } + + public function testCreateAttributeReusesAnOrphanColumnOfTheSameType(): void + { + [$database, $adapter] = $this->databaseWithAnOrphanValue(Attribute::string(key: self::KEY, size: 64)); + $orphan = $this->schemaColumnType($database); + + $this->assertSame(self::KEY, $database->createAttribute(self::COLLECTION, Attribute::string(key: self::KEY, size: 64))->key); + + $this->assertSame($orphan, $this->schemaColumnType($database)); + $this->assertSame([self::KEY], $this->keys($database)); + $this->assertSame('kept', $database->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + } + + public function testCreateAttributeReplacesAnOrphanColumnOfAnotherType(): void + { + [$database, $adapter] = $this->databaseWithAnOrphanValue(Attribute::integer(key: self::KEY)); + $orphan = $this->schemaColumnType($database); + + $this->assertSame(self::KEY, $database->createAttribute(self::COLLECTION, Attribute::string(key: self::KEY, size: 64))->key); + + $this->assertNotSame($orphan, $this->schemaColumnType($database)); + $this->assertSame($adapter->getColumnType(Attribute::string(key: self::KEY, size: 64)), $this->schemaColumnType($database)); + $this->assertSame([self::KEY], $this->keys($database)); + $document = $database->getDocument(self::COLLECTION, self::DOCUMENT); + $this->assertSame(self::DOCUMENT, $document->getId()); + $this->assertNull($document->getAttribute(self::KEY)); + } + + public function testCreateAttributesReusesAnOrphanColumnOfTheSameType(): void + { + [$database, $adapter] = $this->databaseWithAnOrphanValue(Attribute::string(key: self::KEY, size: 64)); + $orphan = $this->schemaColumnType($database); + + $created = $database->createAttributes(self::COLLECTION, [ + Attribute::string(key: self::KEY, size: 64), + Attribute::integer(key: 'count'), + ]); + + $this->assertSame([self::KEY, 'count'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $created)); + + $this->assertSame($orphan, $this->schemaColumnType($database)); + $this->assertSame([self::KEY, 'count'], $this->keys($database)); + $this->assertSame('kept', $database->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + } + + public function testCreateAttributesReplacesAnOrphanColumnOfAnotherType(): void + { + [$database, $adapter] = $this->databaseWithAnOrphanValue(Attribute::integer(key: self::KEY)); + $orphan = $this->schemaColumnType($database); + + $created = $database->createAttributes(self::COLLECTION, [ + Attribute::string(key: self::KEY, size: 64), + Attribute::integer(key: 'count'), + ]); + + $this->assertSame([self::KEY, 'count'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $created)); + + $this->assertNotSame($orphan, $this->schemaColumnType($database)); + $this->assertSame($adapter->getColumnType(Attribute::string(key: self::KEY, size: 64)), $this->schemaColumnType($database)); + $this->assertSame([self::KEY, 'count'], $this->keys($database)); + $document = $database->getDocument(self::COLLECTION, self::DOCUMENT); + $this->assertSame(self::DOCUMENT, $document->getId()); + $this->assertNull($document->getAttribute(self::KEY)); + } + + public function testRenameAdoptsAnExistingSchemaOnlyTarget(): void + { + [$database, $adapter] = $this->database(); + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'before', size: 64)); + $adapter->renameAttribute(self::COLLECTION, 'before', 'after'); + + $database->renameAttribute(self::COLLECTION, 'before', 'after'); + + $this->assertSame(['after'], $this->keys($database)); + } + + public function testCreateAttributesRollbackDropsTheBatch(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $database = $this->unvalidatedDatabase($adapter); + + try { + $database->createAttributes(self::UNVALIDATED, [ + Attribute::integer(key: 'age'), + Attribute::string(key: 'nick', size: 16), + ]); + $this->fail('The metadata write of an oversized collection name must fail'); + } catch (DatabaseException $error) { + $this->assertStringStartsWith('Failed to persist metadata after retries for attributes creation: ', $error->getMessage()); + $this->assertInstanceOf(StructureException::class, $error->getPrevious()); + } + + $this->assertSame([], \array_intersect(['age', 'nick'], $this->columns($database, self::UNVALIDATED))); + $this->assertSame([], $database->getCollection(self::UNVALIDATED)->attributes()); + } + + public function testCreateAttributesRollbackCollectsCleanupErrors(): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + #[\Override] + public function deleteAttribute(string $collection, string $id): bool + { + if ($id === 'nick') { + throw new DatabaseException('Column is locked'); + } + + return parent::deleteAttribute($collection, $id); + } + }; + $database = $this->unvalidatedDatabase($adapter); + + try { + $database->createAttributes(self::UNVALIDATED, [ + Attribute::integer(key: 'age'), + Attribute::string(key: 'nick', size: 16), + ]); + $this->fail('The metadata write of an oversized collection name must fail'); + } catch (DatabaseException $error) { + $this->assertStringStartsWith('Failed to persist metadata after retries and cleanup encountered errors for attributes creation: ', $error->getMessage()); + $this->assertStringEndsWith(' | Cleanup errors: Column is locked', $error->getMessage()); + $this->assertInstanceOf(StructureException::class, $error->getPrevious()); + } + + $columns = $this->columns($database, self::UNVALIDATED); + $this->assertNotContains('age', $columns); + $this->assertContains('nick', $columns); + } + + private function unvalidatedDatabase(SQLite $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('orphan_column_type') + ->setNamespace('orphan_column_type_'.\uniqid()); + $database->create(); + $collection = Collection::create(id: self::UNVALIDATED, name: \str_repeat('n', self::OVERSIZED_NAME_LENGTH)); + $database->skipValidation(fn (): Collection => $database->createCollection($collection)); + + return $database; + } + + /** + * @return array + */ + private function columns(Database $database, string $collection): array + { + return \array_map( + static fn (Column $column): string => $column->name, + $database->getSchemaAttributes($collection), + ); + } + + /** + * @return array{Database, SQLite} + */ + private function databaseWithAnOrphanValue(Attribute $orphan): array + { + [$database, $adapter] = $this->database(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->createDocument(self::COLLECTION, new Document([ + Document::ID => self::DOCUMENT, + Document::PERMISSIONS => [Permission::read(Role::any())], + ])); + $adapter->createAttribute(self::COLLECTION, $orphan); + $this->pdo->exec('UPDATE `'.$database->getNamespace().'_'.self::COLLECTION.'` SET `'.self::KEY.'` = \'kept\''); + + return [$database, $adapter]; + } + + /** + * @return list + */ + private function keys(Database $database): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection(self::COLLECTION)->attributes(), + ); + } + + /** + * @return array{Database, SQLite} + */ + private function database(): array + { + $this->pdo = new PDO('sqlite::memory:'); + $adapter = new SQLite($this->pdo); + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('orphan_column_type') + ->setNamespace('orphan_column_type_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: self::COLLECTION)); + + return [$database, $adapter]; + } + + private function schemaColumnType(Database $database): string + { + foreach ($database->getSchemaAttributes(self::COLLECTION) as $column) { + if ($column->name === self::KEY) { + return $column->type; + } + } + + $this->fail('The orphan column is no longer in the schema'); + } + + /** + * @param class-string $expected + */ + private function assertRefused(callable $operation, string $expected = DatabaseException::class): void + { + try { + $operation(); + } catch (DatabaseException $error) { + $this->assertSame($expected, $error::class, $error->getMessage()); + + return; + } + + $this->fail('Expected the unstorable type to be refused'); + } +} diff --git a/tests/unit/Attributes/RelaxRequiredTest.php b/tests/unit/Attributes/RelaxRequiredTest.php new file mode 100644 index 0000000000..6b61f6eee0 --- /dev/null +++ b/tests/unit/Attributes/RelaxRequiredTest.php @@ -0,0 +1,127 @@ +database(new class () extends Memory { + #[\Override] + public function relaxAttributeRequired(string $collection, string $id): bool + { + throw new DatabaseException('Relaxing the column failed'); + } + }); + + try { + $database->updateAttribute('items', 'name', new AttributeUpdate(required: false)); + $this->fail('Expected the failed relax to surface'); + } catch (DatabaseException $error) { + $this->assertSame('Relaxing the column failed', $error->getMessage()); + } + + $this->assertTrue($this->storedAttribute($database, 'name')->required); + } + + public function testUnconfirmedRelaxLeavesTheAttributeRequired(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function relaxAttributeRequired(string $collection, string $id): bool + { + return false; + } + }); + + try { + $database->updateAttribute('items', 'name', new AttributeUpdate(required: false)); + $this->fail('Expected the unconfirmed relax to surface'); + } catch (RefusedException $error) { + $this->assertSame('Failed to update attribute', $error->getMessage()); + } + + $this->assertTrue($this->storedAttribute($database, 'name')->required); + } + + /** + * @return array + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRelaxedAttributeAcceptsANull(Closure $adapter): void + { + $database = $this->database($adapter()); + + $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(required: false)); + + $this->assertFalse($updated->required); + $this->assertFalse($this->storedAttribute($database, 'name')->required); + + $created = $database->createDocument('items', new Document([ + Document::ID => 'nameless', + Document::PERMISSIONS => [Permission::read(Role::any())], + 'name' => null, + ])); + + $this->assertSame('nameless', $created->getId()); + $this->assertNull($database->getDocument('items', 'nameless')->getAttribute('name')); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('relax_required') + ->setNamespace('relax_required_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: 'items', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + )); + + return $database; + } + + private function storedAttribute(Database $database, string $key): Attribute + { + foreach ($database->getCollection('items')->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + $this->fail('Attribute '.$key.' is missing from the collection metadata'); + } +} diff --git a/tests/unit/Attributes/RenamePathsTest.php b/tests/unit/Attributes/RenamePathsTest.php new file mode 100644 index 0000000000..dc85cd4407 --- /dev/null +++ b/tests/unit/Attributes/RenamePathsTest.php @@ -0,0 +1,104 @@ +database(); + + $database->renameAttribute('items', 'name', 'title'); + + $this->assertRenamed($database); + } + + public function testUpdateAttributeWithAKeyRenames(): void + { + $database = $this->database(); + + $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(key: 'title')); + + $this->assertSame('title', $updated->key); + $this->assertRenamed($database); + } + + public function testRenameAttributeRefusesAKeyInUse(): void + { + $database = $this->database(); + + $this->expectException(DuplicateException::class); + + $database->renameAttribute('items', 'name', 'code'); + } + + public function testUpdateAttributeRefusesAKeyInUse(): void + { + $database = $this->database(); + + $this->expectException(DuplicateException::class); + + $database->updateAttribute('items', 'name', new AttributeUpdate(key: 'code')); + } + + public function testThereIsNoOtherPublicRenamePath(): void + { + $database = $this->database(); + + foreach (['updateAttributeMeta', 'updateAttributeRequired', 'updateAttributeFormat', 'updateAttributeFormatOptions', 'updateAttributeFilters', 'updateAttributeDefault'] as $method) { + $this->assertFalse(\is_callable([$database, $method]), $method.' must not be callable'); + } + } + + private function assertRenamed(Database $database): void + { + $collection = $database->getCollection('items'); + $keys = \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributes()); + + $this->assertSame(['title', 'code'], $keys); + $this->assertSame(['title', 'code'], $collection->indexes()[0]->attributes); + + $document = $database->getDocument('items', 'first'); + $this->assertSame('First', $document->getAttribute('title')); + $this->assertFalse($document->offsetExists('name')); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('rename_paths') + ->setNamespace('rename_paths_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + 'items', + attributes: [Attribute::string('name', 64), Attribute::string('code', 16)], + indexes: [Index::key('name_code', ['name', 'code'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument('items', new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + 'code' => 'f', + ])); + + return $database; + } +} diff --git a/tests/unit/Attributes/SharedColumnTest.php b/tests/unit/Attributes/SharedColumnTest.php new file mode 100644 index 0000000000..f467d8d7e7 --- /dev/null +++ b/tests/unit/Attributes/SharedColumnTest.php @@ -0,0 +1,286 @@ +assertIsString($path); + $this->path = $path; + $this->namespace = 'shared_column_'.\uniqid(); + } + + #[\Override] + protected function tearDown(): void + { + if (\is_file($this->path)) { + \unlink($this->path); + } + } + + public function testAnotherTenantsColumnOfAnotherTypeIsNeverDropped(): void + { + $first = $this->tenantWithAge(1); + $second = $this->tenant(2); + + $refusal = $this->refusal(fn (): Attribute => $second->createAttribute(self::COLLECTION, Attribute::string(key: self::KEY, size: 64))); + + $this->assertSame(7, $first->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + $this->assertRefusedAsAnotherType($refusal); + $this->assertSame([], $this->keys($second)); + } + + public function testAnotherTenantsColumnOfTheSameTypeIsReused(): void + { + $first = $this->tenantWithAge(1); + $second = $this->tenant(2); + + $this->assertSame(self::KEY, $second->createAttribute(self::COLLECTION, Attribute::integer(key: self::KEY))->key); + + $this->assertSame(7, $first->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + $this->assertSame([self::KEY], $this->keys($second)); + $second->createDocument(self::COLLECTION, new Document([Document::ID => 'second', self::KEY => 9])); + $this->assertSame(9, $second->getDocument(self::COLLECTION, 'second')->getAttribute(self::KEY)); + } + + public function testCreateAttributesNeverDropsAnotherTenantsColumnOfAnotherType(): void + { + $first = $this->tenantWithAge(1); + $second = $this->tenant(2); + + $refusal = $this->refusal(fn (): array => $second->createAttributes(self::COLLECTION, [ + Attribute::string(key: 'nick', size: 16), + Attribute::string(key: self::KEY, size: 64), + ])); + + $this->assertSame(7, $first->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + $this->assertRefusedAsAnotherType($refusal); + $this->assertSame([], $this->keys($second)); + $this->assertNotContains('nick', $this->columns($first)); + } + + public function testCreateAttributesReusesAnotherTenantsColumnOfTheSameType(): void + { + $first = $this->tenantWithAge(1); + $second = $this->tenant(2); + + $created = $second->createAttributes(self::COLLECTION, [ + Attribute::integer(key: self::KEY), + Attribute::string(key: 'nick', size: 16), + ]); + $this->assertSame([self::KEY, 'nick'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $created)); + + $this->assertSame(7, $first->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + $this->assertSame([self::KEY, 'nick'], $this->keys($second)); + } + + public function testCreateAttributesRollbackKeepsAnotherTenantsReusedColumn(): void + { + $first = $this->tenantWithAge(1); + $second = $this->tenant(2, \str_repeat('n', self::OVERSIZED_NAME_LENGTH)); + + try { + $second->createAttributes(self::COLLECTION, [ + Attribute::integer(key: self::KEY), + Attribute::string(key: 'nick', size: 16), + ]); + $this->fail('The metadata write of an oversized collection name must fail'); + } catch (DatabaseException $error) { + $this->assertInstanceOf(StructureException::class, $error->getPrevious()); + } + + $this->assertSame(7, $first->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + $this->assertNotContains('nick', $this->columns($first)); + } + + /** + * @return array + */ + public static function engineSpellings(): array + { + return [ + 'integer on MariaDB' => [Attribute::integer(key: self::KEY), 'int(11)'], + 'integer on MySQL' => [Attribute::integer(key: self::KEY), 'int'], + 'unsigned integer on MariaDB' => [Attribute::integer(key: self::KEY, signed: false), 'int(10) unsigned'], + 'big integer on MariaDB' => [Attribute::bigInteger(key: self::KEY), 'bigint(20)'], + 'boolean' => [Attribute::boolean(key: self::KEY), 'tinyint(1)'], + 'double' => [Attribute::double(key: self::KEY), 'double'], + 'datetime' => [Attribute::datetime(key: self::KEY), 'datetime(3)'], + 'string' => [Attribute::string(key: self::KEY, size: 64), 'varchar(64)'], + 'array on MariaDB' => [Attribute::string(key: self::KEY, size: 64, array: true), 'longtext'], + 'array on MySQL' => [Attribute::string(key: self::KEY, size: 64, array: true), 'json'], + ]; + } + + #[DataProvider('engineSpellings')] + public function testAnotherTenantsColumnInTheEngineSpellingIsReused(Attribute $attribute, string $reported): void + { + $first = $this->tenantWithAge(1); + $second = $this->tenant(2, adapter: $this->reporting($reported)); + + $this->assertSame($attribute->key, $second->createAttribute(self::COLLECTION, $attribute)->key); + + $this->assertSame(7, $first->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + $this->assertSame([self::KEY], $this->keys($second)); + } + + /** + * @return array + */ + public static function conflictingSpellings(): array + { + return [ + 'integer over big integer' => [Attribute::integer(key: self::KEY), 'bigint(20)'], + 'signed over unsigned' => [Attribute::integer(key: self::KEY), 'int(10) unsigned'], + 'string over integer' => [Attribute::string(key: self::KEY, size: 64), 'int(11)'], + 'string over a longer string' => [Attribute::string(key: self::KEY, size: 64), 'varchar(128)'], + ]; + } + + #[DataProvider('conflictingSpellings')] + public function testAnotherTenantsColumnInAConflictingEngineSpellingIsRefused(Attribute $attribute, string $reported): void + { + $first = $this->tenantWithAge(1); + $second = $this->tenant(2, adapter: $this->reporting($reported)); + + $refusal = $this->refusal(fn (): Attribute => $second->createAttribute(self::COLLECTION, $attribute)); + + $this->assertSame(7, $first->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute(self::KEY)); + $this->assertRefusedAsAnotherType($refusal); + $this->assertSame([], $this->keys($second)); + } + + /** + * @param callable(): mixed $operation + */ + private function refusal(callable $operation): ?DuplicateException + { + try { + $operation(); + } catch (DuplicateException $error) { + return $error; + } + + return null; + } + + private function assertRefusedAsAnotherType(?DuplicateException $refusal): void + { + $this->assertNotNull($refusal, 'A column another tenant stores with another type must be refused'); + $this->assertSame('Attribute exists in the shared table with another type', $refusal->getMessage()); + } + + private function tenantWithAge(int $tenant): Database + { + $database = $this->tenant($tenant); + $database->createAttribute(self::COLLECTION, Attribute::integer(key: self::KEY)); + $database->createDocument(self::COLLECTION, new Document([Document::ID => self::DOCUMENT, self::KEY => 7])); + + return $database; + } + + private function tenant(int $tenant, string $name = self::COLLECTION, ?SQLite $adapter = null): Database + { + $database = new Database($adapter ?? new SQLite(new PDO('sqlite:'.$this->path)), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('shared_column') + ->setNamespace($this->namespace) + ->setSharedTables(true) + ->setTenant($tenant); + $database->getAuthorization()->addRole(Role::any()->toString()); + + if (! $database->exists()) { + $database->create(); + } + + $collection = Collection::create( + id: self::COLLECTION, + name: $name, + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + ); + $database->skipValidation(fn (): Collection => $database->createCollection($collection)); + + return $database; + } + + private function reporting(string $columnType): SQLite + { + return new class (new PDO('sqlite:'.$this->path), $columnType) extends SQLite { + public function __construct(PDO $pdo, private readonly string $columnType) + { + parent::__construct($pdo); + } + + #[\Override] + public function getSchemaAttributes(string $collection): array + { + return \array_map( + fn (Column $column): Column => $column->name === SharedColumnTest::KEY + ? new Column($column->name, $this->canonicalColumnType($this->columnType), $column->length, $column->nullable) + : $column, + parent::getSchemaAttributes($collection), + ); + } + }; + } + + /** + * @return list + */ + private function keys(Database $database): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection(self::COLLECTION)->attributes(), + ); + } + + /** + * @return array + */ + private function columns(Database $database): array + { + return \array_map( + static fn (Column $column): string => $column->name, + $database->getSchemaAttributes(self::COLLECTION), + ); + } +} diff --git a/tests/unit/Attributes/SharedColumnTypeTest.php b/tests/unit/Attributes/SharedColumnTypeTest.php new file mode 100644 index 0000000000..abdbf56d9e --- /dev/null +++ b/tests/unit/Attributes/SharedColumnTypeTest.php @@ -0,0 +1,215 @@ + + */ + private const array COLUMNS = [ + 'age' => 'integer', + 'name' => 'character varying(64)', + 'body' => 'text', + 'price' => 'double precision', + 'at' => 'timestamp(3) without time zone', + 'tags' => 'jsonb', + 'shape' => 'geometry(Point,4326)', + 'embedding' => 'vector(3)', + ]; + + /** @var list */ + private array $statements = []; + + /** + * @return array + */ + public static function sameTypes(): array + { + return [ + 'integer' => [Attribute::integer(key: 'age')], + 'varchar' => [Attribute::string(key: 'name', size: 64)], + 'text' => [Attribute::text(key: 'body')], + 'sizeless string' => [Attribute::string(key: 'body', size: 0)], + 'double' => [Attribute::float(key: 'price')], + 'datetime' => [Attribute::datetime(key: 'at')], + 'array' => [Attribute::string(key: 'tags', size: 32, array: true)], + 'point' => [Attribute::point(key: 'shape', required: true)], + 'vector' => [Attribute::vector(key: 'embedding', dimensions: 3)], + ]; + } + + /** + * @return array + */ + public static function otherTypes(): array + { + return [ + 'string over integer' => [Attribute::string(key: 'age', size: 64)], + 'bigint over integer' => [Attribute::integer(key: 'age', width: IntegerWidth::Bits64)], + 'longer varchar' => [Attribute::string(key: 'name', size: 128)], + 'integer over text' => [Attribute::integer(key: 'body')], + 'linestring over point' => [Attribute::lineString(key: 'shape')], + 'wider vector' => [Attribute::vector(key: 'embedding', dimensions: 4)], + ]; + } + + #[DataProvider('otherTypes')] + public function testPostgresRefusesAnotherTenantsColumnOfAnotherType(Attribute $attribute): void + { + $adapter = $this->createPostgres(sharedTables: true); + + try { + $adapter->createAttribute('items', $attribute); + $this->fail('A column another tenant created with another type must be refused'); + } catch (DuplicateException $e) { + $this->assertInstanceOf(MismatchException::class, $e); + $this->assertSame(self::MESSAGE, $e->getMessage()); + } + + $this->assertCount(1, $this->statements); + $this->assertStringStartsWith(self::CATALOG, $this->statements[0]); + } + + #[DataProvider('sameTypes')] + public function testPostgresLeavesAnotherTenantsColumnOfTheSameTypeToTheEngine(Attribute $attribute): void + { + $adapter = $this->createPostgres(sharedTables: true); + + $this->assertTrue($adapter->createAttribute('items', $attribute)); + + $this->assertCount(2, $this->statements); + $this->assertStringStartsWith(self::CATALOG, $this->statements[0]); + $this->assertStringStartsWith('ALTER TABLE "database"."namespace_items" ADD COLUMN "'.$attribute->key.'"', $this->statements[1]); + } + + public function testPostgresRefusesABatchWithAnotherTenantsColumnOfAnotherType(): void + { + $adapter = $this->createPostgres(sharedTables: true); + + try { + $adapter->createAttributes('items', [Attribute::string(key: 'label', size: 16), Attribute::string(key: 'age', size: 64)]); + $this->fail('A batch holding a column another tenant created with another type must be refused'); + } catch (MismatchException $e) { + $this->assertSame(self::MESSAGE, $e->getMessage()); + } + + $this->assertCount(1, $this->statements); + $this->assertStringStartsWith(self::CATALOG, $this->statements[0]); + } + + public function testPostgresReadsNoCatalogOutsideSharedTables(): void + { + $adapter = $this->createPostgres(sharedTables: false); + + $this->assertTrue($adapter->createAttribute('items', Attribute::string(key: 'age', size: 64))); + $this->assertTrue($adapter->createAttributes('items', [Attribute::string(key: 'label', size: 16)])); + + $this->assertSame([ + 'ALTER TABLE "database"."namespace_items" ADD COLUMN "age" VARCHAR(64) NULL', + 'ALTER TABLE "database"."namespace_items" ADD COLUMN "label" VARCHAR(16) NULL', + ], $this->statements); + } + + public function testDatabaseSurfacesTheRefusalOfASingleAttribute(): void + { + $database = $this->createRefusingDatabase(); + + try { + $database->createAttribute('items', Attribute::string(key: 'age', size: 64)); + $this->fail('The adapter refusal must reach the caller'); + } catch (MismatchException $e) { + $this->assertSame(self::MESSAGE, $e->getMessage()); + } + + $this->assertSame([], $database->getCollection('items')->getAttribute('attributes', [])); + } + + public function testDatabaseSurfacesTheRefusalOfABatch(): void + { + $database = $this->createRefusingDatabase(); + + try { + $database->createAttributes('items', [Attribute::string(key: 'label', size: 16), Attribute::string(key: 'age', size: 64)]); + $this->fail('The adapter refusal must reach the caller'); + } catch (MismatchException $e) { + $this->assertSame(self::MESSAGE, $e->getMessage()); + } + + $this->assertSame([], $database->getCollection('items')->getAttribute('attributes', [])); + } + + private function createPostgres(bool $sharedTables): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query): PDOStatement { + $this->statements[] = $query; + + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn(\str_starts_with($query, self::CATALOG) ? self::COLUMNS : []); + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables($sharedTables); + $adapter->setTenant($sharedTables ? 2 : null); + + return $adapter; + } + + private function createRefusingDatabase(): Database + { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool + { + throw new MismatchException('Attribute exists in the shared table with another type'); + } + + /** + * @param array $attributes + */ + #[\Override] + public function createAttributes(string $collection, array $attributes): bool + { + throw new MismatchException('Attribute exists in the shared table with another type'); + } + }; + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('shared_column_type') + ->setNamespace('shared_column_type'); + $database->create(); + $database->createCollection(Collection::create(id: 'items')); + + return $database; + } +} diff --git a/tests/unit/Attributes/SharedRenameTest.php b/tests/unit/Attributes/SharedRenameTest.php new file mode 100644 index 0000000000..46f01a3053 --- /dev/null +++ b/tests/unit/Attributes/SharedRenameTest.php @@ -0,0 +1,165 @@ +createSharedDatabase(); + + foreach (self::TENANTS as $tenant) { + $database->setTenant($tenant); + $database->renameAttribute(self::COLLECTION, 'age', 'years'); + } + + $this->assertEachTenantReadsItsValuesUnder($database, 'years', 'age'); + } + + public function testTenantsUpdateAnAttributeKeyInTurn(): void + { + $database = $this->createSharedDatabase(); + + foreach (self::TENANTS as $tenant) { + $database->setTenant($tenant); + $this->assertSame('years', $database->updateAttribute(self::COLLECTION, 'age', new AttributeUpdate(required: true, key: 'years'))->key); + } + + $this->assertEachTenantReadsItsValuesUnder($database, 'years', 'age'); + } + + public function testARenameOntoAColumnBesideTheOldOneIsRefused(): void + { + $database = $this->createSharedDatabase(); + $database->setTenant(2); + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'title', size: 32)); + $database->setTenant(1); + + try { + $database->renameAttribute(self::COLLECTION, 'nick', 'title'); + $this->fail('A rename onto another attribute\'s column must be refused while the old column holds values'); + } catch (DuplicateException $e) { + $this->assertSame('Attribute already exists', $e->getMessage()); + } + + try { + $database->updateAttribute(self::COLLECTION, 'nick', new AttributeUpdate(key: 'title')); + $this->fail('A key update onto another attribute\'s column must be refused while the old column holds values'); + } catch (DuplicateException $e) { + $this->assertSame('Attribute already exists', $e->getMessage()); + } + + $this->assertSame(['age', 'nick'], $this->keys($database)); + $this->assertSame('nick1', $database->getDocument(self::COLLECTION, 'user')->getAttribute('nick')); + } + + public function testARenameCompletesAnOrphanedRenameOutsideSharedTables(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $database = $this->createDatabase($adapter); + $database->createCollection($this->definition()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'user', 'age' => 30, 'nick' => 'nick'])); + $adapter->renameAttribute(self::COLLECTION, 'age', 'years'); + + $database->renameAttribute(self::COLLECTION, 'age', 'years'); + + $this->assertSame(['years', 'nick'], $this->keys($database)); + $this->assertSame(30, $database->getDocument(self::COLLECTION, 'user')->getAttribute('years')); + } + + public function testARenameOfAMissingAttributeIsNotFound(): void + { + $database = $this->createSharedDatabase(); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Attribute not found'); + + $database->renameAttribute(self::COLLECTION, 'missing', 'found'); + } + + private function assertEachTenantReadsItsValuesUnder(Database $database, string $key, string $previous): void + { + foreach (self::TENANTS as $tenant) { + $database->setTenant($tenant); + $document = $database->getDocument(self::COLLECTION, 'user'); + + $this->assertSame([$key, 'nick'], $this->keys($database), "Tenant {$tenant} keys"); + $this->assertSame($tenant * 10, $document->getAttribute($key), "Tenant {$tenant} value"); + $this->assertFalse($document->offsetExists($previous), "Tenant {$tenant} old key"); + } + } + + private function createSharedDatabase(): Database + { + $database = $this->createDatabase(new SQLite(new PDO('sqlite::memory:')), sharedTables: true); + + foreach (self::TENANTS as $tenant) { + $database->setTenant($tenant); + $database->createCollection($this->definition()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'user', 'age' => $tenant * 10, 'nick' => "nick{$tenant}"])); + } + + return $database; + } + + private function createDatabase(SQLite $adapter, bool $sharedTables = false): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('shared_rename') + ->setSharedTables($sharedTables) + ->setTenant($sharedTables ? self::TENANTS[0] : null) + ->setNamespace('shared_rename_'.\uniqid()); + $database->create(); + + return $database; + } + + private function definition(): Collection + { + return Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer(key: 'age'), + Attribute::string(key: 'nick', size: 32), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + ); + } + + /** + * @return list + */ + private function keys(Database $database): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection(self::COLLECTION)->attributes(), + ); + } +} diff --git a/tests/unit/Attributes/TypeSample.php b/tests/unit/Attributes/TypeSample.php new file mode 100644 index 0000000000..13d24db39a --- /dev/null +++ b/tests/unit/Attributes/TypeSample.php @@ -0,0 +1,15 @@ + + */ + private const array STORABLE = [ + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText, + ColumnType::Integer, + ColumnType::BigInteger, + ColumnType::Float, + ColumnType::Double, + ColumnType::Boolean, + ColumnType::Datetime, + ColumnType::Id, + ColumnType::Relationship, + ColumnType::Object, + ColumnType::Point, + ColumnType::Linestring, + ColumnType::Polygon, + ColumnType::Vector, + ]; + + /** + * @var list + */ + private const array UNSTORABLE = [ + ColumnType::TinyInteger, + ColumnType::SmallInteger, + ColumnType::Decimal, + ColumnType::Timestamp, + ColumnType::Json, + ColumnType::Binary, + ColumnType::Enum, + ColumnType::Uuid, + ColumnType::Uuid7, + ColumnType::Serial, + ColumnType::BigSerial, + ColumnType::SmallSerial, + ColumnType::Array, + ColumnType::Tuple, + ]; + + /** + * Factories whose type needs a capability the adapter does not have. + * + * @var array, array> + */ + private const array UNAVAILABLE = [ + Memory::class => [ + 'point' => 'Spatial attributes are not supported', + 'lineString' => 'Spatial attributes are not supported', + 'polygon' => 'Spatial attributes are not supported', + 'vector' => 'Vector types are not supported by the current database', + ], + SQLite::class => [ + 'object' => 'Object attributes are not supported', + 'point' => 'Spatial attributes are not supported', + 'lineString' => 'Spatial attributes are not supported', + 'polygon' => 'Spatial attributes are not supported', + 'vector' => 'Vector types are not supported by the current database', + ], + ]; + + /** + * @return array + */ + private static function samples(): array + { + return [ + 'string' => new TypeSample(value: 'text', readType: 'string', default: 'fallback', size: 64), + 'varchar' => new TypeSample(value: 'text', readType: 'string', default: 'fallback', size: 64), + 'text' => new TypeSample(value: 'text', readType: 'string', default: 'fallback'), + 'mediumText' => new TypeSample(value: 'text', readType: 'string', default: 'fallback'), + 'longText' => new TypeSample(value: 'text', readType: 'string', default: 'fallback'), + 'integer' => new TypeSample(value: 5, readType: 'int', default: 1, incrementable: true), + 'bigInteger' => new TypeSample(value: 5, readType: 'int', default: 1, incrementable: true), + 'float' => new TypeSample(value: 1.5, readType: 'float', default: 0.5, incrementable: true), + 'double' => new TypeSample(value: 1.5, readType: 'float', default: 0.5, incrementable: true), + 'boolean' => new TypeSample(value: true, readType: 'bool', default: false), + 'datetime' => new TypeSample(value: '2024-01-01T00:00:00.000+00:00', readType: 'string', default: '2023-06-01T12:00:00.000+00:00'), + 'id' => new TypeSample(value: '7', readType: 'string'), + 'object' => new TypeSample(value: ['colour' => 'red'], readType: 'array', default: ['colour' => 'blue']), + 'point' => new TypeSample(value: [1.0, 2.0], readType: 'array'), + 'lineString' => new TypeSample(value: [[1.0, 2.0], [3.0, 4.0]], readType: 'array'), + 'polygon' => new TypeSample(value: [[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.0, 0.0]]], readType: 'array'), + 'vector' => new TypeSample(value: [1.0, 2.0, 3.0], readType: 'array', size: 3), + ]; + } + + /** + * @return array}> + */ + public static function availableFactories(): array + { + $cases = []; + foreach (\array_keys(self::samples()) as $factory) { + foreach (self::UNAVAILABLE as $adapter => $unavailable) { + if (! \array_key_exists($factory, $unavailable)) { + $cases[$factory.' on '.(new ReflectionClass($adapter))->getShortName()] = [$factory, $adapter]; + } + } + } + + return $cases; + } + + /** + * @return array}> + */ + public static function defaultedFactories(): array + { + return \array_filter( + self::availableFactories(), + fn (array $case): bool => self::samples()[$case[0]]->default !== null, + ); + } + + /** + * @return array, string}> + */ + public static function unavailableFactories(): array + { + $cases = []; + foreach (self::UNAVAILABLE as $adapter => $unavailable) { + foreach ($unavailable as $factory => $message) { + $cases[$factory.' on '.(new ReflectionClass($adapter))->getShortName()] = [$factory, $adapter, $message]; + } + } + + return $cases; + } + + /** + * @return array}> + */ + public static function unstorableTypes(): array + { + $cases = []; + foreach (self::UNSTORABLE as $type) { + foreach (\array_keys(self::UNAVAILABLE) as $adapter) { + $cases[$type->value.' on '.(new ReflectionClass($adapter))->getShortName()] = [$type, $adapter]; + } + } + + return $cases; + } + + /** + * @return array}> + */ + public static function adapters(): array + { + $cases = []; + foreach (\array_keys(self::UNAVAILABLE) as $adapter) { + $cases[(new ReflectionClass($adapter))->getShortName()] = [$adapter]; + } + + return $cases; + } + + /** + * @return array + */ + public static function columnTypes(): array + { + $cases = []; + foreach (ColumnType::cases() as $type) { + $cases[$type->value] = [$type]; + } + + return $cases; + } + + #[DataProvider('columnTypes')] + public function testAColumnTypeIsAnAttributeTypeExactlyWhenStorable(ColumnType $type): void + { + $this->assertSame(\in_array($type, self::STORABLE, true), \in_array($type, Attribute::TYPES, true), $type->value); + } + + public function testTypesBehindACapabilityFollowTheValidatorSupport(): void + { + $gated = [ + 'Object attributes are not supported' => Attribute::object(key: 'value'), + 'Spatial attributes are not supported' => Attribute::point(key: 'value'), + 'Vector types are not supported by the current database' => Attribute::vector(key: 'value', dimensions: 3), + ]; + + foreach ($gated as $message => $attribute) { + $this->assertRejected($message, fn (): bool => $this->typeValidator(supported: false)->checkType($attribute)); + $this->assertTrue($this->typeValidator(supported: true)->checkType($attribute), $attribute->type->value); + } + } + + #[DataProvider('columnTypes')] + public function testEveryColumnTypeIsEitherStorableOrNot(ColumnType $type): void + { + $this->assertNotSame( + \in_array($type, self::STORABLE, true), + \in_array($type, self::UNSTORABLE, true), + $type->value.' must be classified exactly once', + ); + } + + public function testEveryFactoryBuildsAStorableType(): void + { + $factories = []; + foreach ((new ReflectionClass(Attribute::class))->getMethods(ReflectionMethod::IS_STATIC) as $method) { + $returnType = $method->getReturnType(); + if ( + $method->isPublic() + && $returnType instanceof ReflectionNamedType + && \in_array($returnType->getName(), ['self', Attribute::class], true) + && ! \str_starts_with($method->getName(), 'from') + ) { + $factories[] = $method->getName(); + } + } + \sort($factories); + + $expected = [...\array_keys(self::samples()), 'relationship']; + \sort($expected); + + $this->assertSame($expected, $factories); + } + + #[DataProvider('columnTypes')] + public function testCheckTypeFollowsTheTable(ColumnType $type): void + { + if (! \in_array($type, self::STORABLE, true)) { + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: '.$type->value); + } + + $attribute = Attribute::fromArray($this->definition($type)); + + $this->assertTrue($this->typeValidator(supported: true)->checkType($attribute)); + } + + #[DataProvider('columnTypes')] + public function testStructureFollowsTheTable(ColumnType $type): void + { + $structure = new Structure( + new Document([ + Document::ID => 'items', + Document::COLLECTION => Database::METADATA, + 'attributes' => [new Document($this->definition($type))], + ]), + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), + ); + $document = new Document([ + Document::COLLECTION => 'items', + Document::CREATED_AT => '2024-01-01T00:00:00.000+00:00', + Document::UPDATED_AT => '2024-01-01T00:00:00.000+00:00', + 'value' => $this->validValue($type), + ]); + + if (! \in_array($type, self::STORABLE, true)) { + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: '.$type->value); + } + + $this->assertTrue($structure->isValid($document), $structure->getDescription()); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('unstorableTypes')] + public function testUnstorableTypesAreRejectedUpFront(ColumnType $type, string $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'items', permissions: $this->permissions())); + + $message = 'Unknown attribute type: '.$type->value; + $this->assertRejected($message, fn () => $database->createAttribute('items', Attribute::fromArray(['key' => 'value', 'type' => $type]))); + $this->assertRejected($message, fn () => $database->createAttributes('items', [Attribute::fromArray(['key' => 'value', 'type' => $type])])); + $this->assertRejected($message, fn () => $database->createCollection(Collection::create( + id: 'inline', + attributes: [Attribute::fromArray(['key' => 'value', 'type' => $type])], + permissions: $this->permissions(), + ))); + + $this->assertNull($database->findCollection('inline')); + $this->assertSame([], $database->getCollection('items')->attributes()); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('unavailableFactories')] + public function testTypesTheAdapterCannotStoreAreRejectedUpFront(string $factory, string $adapter, string $message): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'items', permissions: $this->permissions())); + + $this->assertRejected($message, fn () => $database->createAttribute('items', $this->attribute($factory))); + $this->assertRejected($message, fn () => $database->createCollection(Collection::create( + id: 'inline', + attributes: [$this->attribute($factory)], + permissions: $this->permissions(), + ))); + + $this->assertNull($database->findCollection('inline')); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('availableFactories')] + public function testTypeRoundTripsThroughCreateAttribute(string $factory, string $adapter): void + { + $sample = self::samples()[$factory]; + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'items', permissions: $this->permissions())); + + $this->assertSame($this->attribute($factory)->type, $database->createAttribute('items', $this->attribute($factory))->type); + $this->write($database, ['value' => $sample->value]); + + $this->assertStored($sample->readType, $sample->value, $database->getDocument('items', 'one')->getAttribute('value')); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('availableFactories')] + public function testTypeRoundTripsThroughCreateCollection(string $factory, string $adapter): void + { + $sample = self::samples()[$factory]; + $database = $this->database($adapter); + $database->createCollection(Collection::create( + id: 'items', + attributes: [$this->attribute($factory)], + permissions: $this->permissions(), + )); + + $this->write($database, ['value' => $sample->value]); + + $this->assertStored($sample->readType, $sample->value, $database->getDocument('items', 'one')->getAttribute('value')); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('availableFactories')] + public function testTypeCanBeUpdated(string $factory, string $adapter): void + { + $sample = self::samples()[$factory]; + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'items', permissions: $this->permissions())); + $database->createAttribute('items', $this->attribute($factory)); + $this->write($database, ['value' => $sample->value]); + + $updated = $database->updateAttribute('items', 'value', new AttributeUpdate(key: 'renamed')); + + $this->assertSame('renamed', $updated->key); + $this->assertSame($this->attribute($factory)->type, $updated->type); + $this->assertStored($sample->readType, $sample->value, $database->getDocument('items', 'one')->getAttribute('renamed')); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('defaultedFactories')] + public function testTypeTakesADefault(string $factory, string $adapter): void + { + $sample = self::samples()[$factory]; + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'items', permissions: $this->permissions())); + $database->createAttribute('items', $this->attribute($factory, $sample->default)); + $this->write($database); + + $this->assertStored($sample->readType, $sample->default, $database->getDocument('items', 'one')->getAttribute('value')); + + $updated = $database->updateAttribute('items', 'value', new AttributeUpdate(default: $sample->value)); + $this->assertSame($sample->value, $updated->default); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testDefaultOnATypeWithoutScalarDefaultsIsAMismatch(string $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'items', permissions: $this->permissions())); + $database->createAttribute('items', Attribute::id(key: 'value')); + + $this->assertRejected( + 'Default value 5 does not match given type id', + fn () => $database->updateAttribute('items', 'value', new AttributeUpdate(default: '5')), + ); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('availableFactories')] + public function testOnlyNumericTypesIncrement(string $factory, string $adapter): void + { + $sample = self::samples()[$factory]; + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'items', permissions: $this->permissions())); + $database->createAttribute('items', $this->attribute($factory)); + $this->write($database, ['value' => $sample->value]); + + if (! $sample->incrementable) { + $this->expectException(TypeException::class); + $database->increaseDocumentAttribute('items', 'one', 'value'); + + return; + } + + $this->assertIsNumeric($sample->value); + $increased = $database->increaseDocumentAttribute('items', 'one', 'value', 2); + $this->assertStored($sample->readType, $sample->value + 2, $increased->getAttribute('value')); + + $decreased = $database->decreaseDocumentAttribute('items', 'one', 'value', 2); + $this->assertStored($sample->readType, $sample->value, $decreased->getAttribute('value')); + } + + private function attribute(string $factory, mixed $default = null): Attribute + { + $size = self::samples()[$factory]->size; + $attribute = match ($factory) { + 'string', 'varchar' => Attribute::{$factory}(key: 'value', size: $size, default: $default), + 'vector' => Attribute::vector(key: 'value', dimensions: $size), + default => Attribute::{$factory}(key: 'value', default: $default), + }; + $this->assertInstanceOf(Attribute::class, $attribute); + + return $attribute; + } + + /** + * @param class-string $adapter + */ + private function database(string $adapter): Database + { + $database = new Database( + $adapter === SQLite::class ? new SQLite(new PDO('sqlite::memory:')) : new Memory(), + new Cache(new None()), + ); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('type_table') + ->setNamespace('type_table_'.\uniqid()); + $database->create(); + + return $database; + } + + /** + * @return list + */ + private function permissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @param array $values + */ + private function write(Database $database, array $values = []): void + { + $database->createDocument('items', new Document([ + Document::ID => 'one', + Document::PERMISSIONS => $this->permissions(), + ...$values, + ])); + } + + private function assertStored(string $readType, mixed $expected, mixed $actual): void + { + $this->assertSame($readType, \get_debug_type($actual)); + $this->assertSame($expected, $actual); + } + + private function typeValidator(bool $supported): AttributeDefinition + { + return new AttributeDefinition( + attributes: [], + profile: Profiles::of(capabilities: [...($supported ? [Capability::Vectors] : []), ...($supported ? [Capability::Objects] : [])], features: [...($supported ? [Feature\Spatial::class] : [])], string: 16777216, varchar: 16381, integer: 4294967295), + ); + } + + /** + * @return array + */ + private function definition(ColumnType $type): array + { + return [ + Document::ID => 'value', + 'key' => 'value', + 'type' => $type->value, + 'size' => $this->validSize($type), + 'options' => ['relatedCollection' => 'others', 'relationType' => RelationshipType::OneToOne->value, 'side' => RelationshipSide::Parent->value], + ]; + } + + private function assertRejected(string $message, callable $operation): void + { + try { + $operation(); + } catch (DatabaseException $error) { + $this->assertStringContainsString($message, $error->getMessage()); + + return; + } + + $this->fail('Expected the operation to be rejected with "'.$message.'"'); + } + + private function validSize(ColumnType $type): int + { + return match ($type) { + ColumnType::String, ColumnType::Varchar => 64, + ColumnType::Vector => 3, + default => 0, + }; + } + + private function validValue(ColumnType $type): mixed + { + return match ($type) { + ColumnType::Integer, ColumnType::BigInteger => 5, + ColumnType::Float, ColumnType::Double => 1.5, + ColumnType::Boolean => true, + ColumnType::Datetime => '2024-01-01T00:00:00.000+00:00', + ColumnType::Id => '7', + ColumnType::Object => ['colour' => 'red'], + ColumnType::Point => [1.0, 2.0], + ColumnType::Linestring => [[1.0, 2.0], [3.0, 4.0]], + ColumnType::Polygon => [[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.0, 0.0]]], + ColumnType::Vector => [1.0, 2.0, 3.0], + ColumnType::TinyInteger, ColumnType::SmallInteger, ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => 5, + default => 'text', + }; + } +} diff --git a/tests/unit/Attributes/UpdateAttributeTest.php b/tests/unit/Attributes/UpdateAttributeTest.php new file mode 100644 index 0000000000..18856a7ac4 --- /dev/null +++ b/tests/unit/Attributes/UpdateAttributeTest.php @@ -0,0 +1,254 @@ +database(); + + $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(size: 128)); + + $this->assertSame(128, $updated->size); + $this->assertSame('none', $updated->default); + $this->assertFalse($updated->required); + $this->assertSame(ColumnType::String, $updated->type); + $this->assertSame($updated->toDocument()->getArrayCopy(), $this->stored($database, 'name')->toDocument()->getArrayCopy()); + } + + public function testANullDefaultClearsTheDefault(): void + { + $database = $this->database(); + + $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(default: null)); + + $this->assertNull($updated->default); + $this->assertNull($this->stored($database, 'name')->default); + } + + public function testADefaultIsSet(): void + { + $database = $this->database(); + + $database->updateAttribute('items', 'name', new AttributeUpdate(default: 'other')); + + $this->assertSame('other', $this->stored($database, 'name')->default); + } + + public function testADefaultOnARequiredAttributeIsRefused(): void + { + $database = $this->database(); + + try { + $database->updateAttribute('items', 'code', new AttributeUpdate(default: 'x')); + $this->fail('Expected a default on a required attribute to be refused'); + } catch (DatabaseException $error) { + $this->assertSame('Cannot set a default value on a required attribute', $error->getMessage()); + } + + $this->assertNull($this->stored($database, 'code')->default); + } + + public function testARequiredAttributeTakesADefaultWhenMadeOptionalInTheSameUpdate(): void + { + $database = $this->database(); + + $updated = $database->updateAttribute('items', 'code', new AttributeUpdate(required: false, default: 'x')); + + $this->assertFalse($updated->required); + $this->assertSame('x', $this->stored($database, 'code')->default); + } + + public function testMakingAnAttributeRequiredClearsItsDefault(): void + { + $database = $this->database(); + + $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(required: true)); + + $this->assertTrue($updated->required); + $this->assertNull($updated->default); + $this->assertNull($this->stored($database, 'name')->default); + } + + public function testMakingAnAttributeOptionalRelaxesItsColumn(): void + { + $database = $this->database(new class () extends Memory { + private bool $codeNullable = false; + + #[\Override] + public function relaxAttributeRequired(string $collection, string $id): bool + { + $this->codeNullable = $this->codeNullable || $id === 'code'; + + return parent::relaxAttributeRequired($collection, $id); + } + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + if ($collection->getId() === 'items' && ! $this->codeNullable && $document->getAttribute('code') === null) { + throw new DatabaseException('Column code is NOT NULL'); + } + + return parent::createDocument($collection, $document); + } + }); + + $database->updateAttribute('items', 'code', new AttributeUpdate(required: false)); + + $created = $database->createDocument('items', new Document([ + '$permissions' => [Permission::read(Role::any())], + ])); + + $this->assertNull($created->getAttribute('code')); + $this->assertFalse($this->stored($database, 'code')->required); + $this->assertNull($database->getDocument('items', $created->getId())->getAttribute('code')); + } + + public function testAnExplicitDefaultWhileMakingAnAttributeRequiredIsRefused(): void + { + $database = $this->database(); + + try { + $database->updateAttribute('items', 'name', new AttributeUpdate(required: true, default: 'kept')); + $this->fail('Expected a default together with required: true to be refused'); + } catch (DatabaseException $error) { + $this->assertSame('Cannot set a default value on a required attribute', $error->getMessage()); + } + + $stored = $this->stored($database, 'name'); + $this->assertFalse($stored->required); + $this->assertSame('none', $stored->default); + } + + public function testMakingAnAttributeRequiredWithANullDefaultIsAccepted(): void + { + $database = $this->database(); + + $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(required: true, default: null)); + + $this->assertTrue($updated->required); + $this->assertNull($this->stored($database, 'name')->default); + } + + public function testFormatAndFiltersAreReplaced(): void + { + $database = $this->database(); + + $database->updateAttribute('items', 'name', new AttributeUpdate(format: null, filters: ['json'])); + + $stored = $this->stored($database, 'name'); + $this->assertNull($stored->format); + $this->assertSame(['json'], $stored->filters); + } + + public function testAnUnknownFormatIsRefused(): void + { + $this->expectException(DatabaseException::class); + + $this->database()->updateAttribute('items', 'name', new AttributeUpdate(format: new Format('not-registered'))); + } + + public function testAMissingAttributeIsNotFound(): void + { + $this->expectException(NotFoundException::class); + + $this->database()->updateAttribute('items', 'missing', new AttributeUpdate(required: true)); + } + + public function testAMissingCollectionIsNotFound(): void + { + $this->expectException(NotFoundException::class); + + $this->database()->updateAttribute('missing', 'name', new AttributeUpdate(required: true)); + } + + public function testAnEmptyUpdateChangesNothing(): void + { + $database = $this->database(); + $before = $this->stored($database, 'name')->toDocument()->getArrayCopy(); + + $updated = $database->updateAttribute('items', 'name', new AttributeUpdate()); + + $this->assertSame($before, $updated->toDocument()->getArrayCopy()); + $this->assertSame($before, $this->stored($database, 'name')->toDocument()->getArrayCopy()); + } + + public function testTheMirrorReplicatesAnUpdate(): void + { + $destination = new Database(new Memory(), new Cache(new None())); + $mirror = new Mirror(new Database(new Memory(), new Cache(new None())), $destination); + $mirror + ->setAuthorization(new Authorization()) + ->setDatabase('update_attribute') + ->setNamespace('update_attribute_'.\uniqid()); + $destination->setAuthorization(new Authorization()); + $mirror->create(); + $mirror->createCollection($this->items()); + + $mirror->updateAttribute('items', 'code', new AttributeUpdate(required: false, default: 'x')); + $mirror->updateAttribute('items', 'name', new AttributeUpdate(default: null)); + + $this->assertSame('x', $this->stored($destination, 'code')->default); + $this->assertFalse($this->stored($destination, 'code')->required); + $this->assertNull($this->stored($destination, 'name')->default); + $this->assertSame('x', $this->stored($mirror->getSource(), 'code')->default); + } + + private function database(?Adapter $adapter = null): Database + { + $database = new Database($adapter ?? new Memory(), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('update_attribute') + ->setNamespace('update_attribute_'.\uniqid()); + $database->create(); + $database->createCollection($this->items()); + + return $database; + } + + private function items(): Collection + { + return Collection::create( + 'items', + attributes: [ + Attribute::string('name', 64, default: 'none'), + Attribute::string('code', 16, required: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: true, + ); + } + + private function stored(Database $database, string $key): Attribute + { + foreach ($database->getCollection('items')->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + $this->fail('Attribute '.$key.' is missing from the collection metadata'); + } +} diff --git a/tests/unit/Attributes/UpdateAttributeValidationTest.php b/tests/unit/Attributes/UpdateAttributeValidationTest.php new file mode 100644 index 0000000000..ef22fe5562 --- /dev/null +++ b/tests/unit/Attributes/UpdateAttributeValidationTest.php @@ -0,0 +1,676 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @return array + */ + private static function updaters(): array + { + return [ + 'required' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(required: false)), + 'format' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(format: new Format('text'))), + 'format options' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(format: new Format('text', ['maximum' => 1]))), + 'filters' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(filters: [])), + 'default' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(default: 'x')), + 'structure' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(size: 128)), + ]; + } + + /** + * @return array + */ + public static function updatersOverAdapters(): array + { + $cases = []; + foreach (self::adapters() as $adapterName => [$adapter]) { + foreach (self::updaters() as $updaterName => $updater) { + $cases["{$updaterName} on {$adapterName}"] = [$adapter, $updater]; + } + } + + return $cases; + } + + /** + * @param \Closure(): Adapter $adapter + * @param \Closure(Database, string, string): mixed $updater + */ + #[DataProvider('updatersOverAdapters')] + public function testAnUpdateOfTheMetadataCollectionIsRefused(\Closure $adapter, \Closure $updater): void + { + $database = $this->database($adapter()); + $before = $this->definitions($database, Database::METADATA); + + try { + $updater($database, Database::METADATA, 'name'); + $this->fail('the metadata collection must not be updated'); + } catch (DatabaseException $error) { + $this->assertSame('Cannot update metadata attributes', $error->getMessage()); + } + + $this->assertSame($before, $this->definitions($database, Database::METADATA)); + } + + /** + * @param \Closure(): Adapter $adapter + * @param \Closure(Database, string, string): mixed $updater + */ + #[DataProvider('updatersOverAdapters')] + public function testAnUpdateOfAnUnknownAttributeIsNotFound(\Closure $adapter, \Closure $updater): void + { + $database = $this->database($adapter()); + $before = $this->definitions($database); + + try { + $updater($database, self::COLLECTION, 'missing'); + $this->fail('an unknown attribute must not be updated'); + } catch (NotFoundException $error) { + $this->assertSame('Attribute not found', $error->getMessage()); + } + + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAFormatOfAnotherTypeIsRefused(\Closure $adapter): void + { + $database = $this->database($adapter()); + Structure::addFormat(self::FORMAT, static fn (mixed $attribute): Text => new Text(0), ColumnType::Integer); + + try { + $before = $this->definitions($database); + $this->assertRefused( + 'Format ("'.self::FORMAT.'") not available for this attribute type ("string")', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(format: new Format(self::FORMAT))), + ); + $this->assertSame($before, $this->definitions($database)); + + $this->assertSame(self::FORMAT, $database->updateAttribute(self::COLLECTION, 'count', new AttributeUpdate(format: new Format(self::FORMAT)))->format?->name); + } finally { + Structure::removeFormat(self::FORMAT); + } + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testADefaultOnARequiredAttributeIsRefused(\Closure $adapter): void + { + $database = $this->database($adapter()); + $before = $this->definitions($database); + + $this->assertRefused( + 'Cannot set a default value on a required attribute', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'name', new AttributeUpdate(default: 'x')), + ); + $this->assertSame($before, $this->definitions($database)); + + $cleared = $database->updateAttribute(self::COLLECTION, 'name', new AttributeUpdate(default: null)); + $this->assertTrue($cleared->required); + $this->assertNull($cleared->default); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAStructureUpdateDropsTheDefaultOfARequiredAttribute(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(default: 'x')); + $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(required: true)); + + $definitions = $this->definitions($database); + foreach (['name', 'label'] as $key) { + $this->assertTrue($definitions[$key]['required'], "{$key} must stay required"); + $this->assertNull($definitions[$key]['default'], "{$key} must have no default"); + } + } + + /** + * @return array + */ + public static function mismatchedDefaults(): array + { + $defaults = [ + 'string given an integer' => ['label', 123, 'Default value 123 does not match given type string'], + 'integer given a string' => ['count', 'abc', 'Default value abc does not match given type integer'], + 'boolean given an integer' => ['flag', 1, 'Default value 1 does not match given type boolean'], + 'float given a string' => ['ratio', 'x', 'Default value x does not match given type float'], + 'datetime given an integer' => ['occurredAt', 5, 'Default value 5 does not match given type datetime'], + 'string array given a list with an integer' => ['tags', ['a', 1], 'Default value 1 does not match given type string'], + ]; + + $cases = []; + foreach (self::adapters() as $adapterName => [$adapter]) { + foreach ($defaults as $name => [$attribute, $default, $message]) { + $cases["{$name} on {$adapterName}"] = [$adapter, $attribute, $default, $message]; + } + } + + return $cases; + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('mismatchedDefaults')] + public function testAnUpdatedDefaultOfTheWrongTypeIsAMismatch(\Closure $adapter, string $attribute, mixed $default, string $message): void + { + $database = $this->database($adapter()); + $before = $this->definitions($database); + + $this->assertRefused($message, fn (): mixed => $database->updateAttribute(self::COLLECTION, $attribute, new AttributeUpdate(default: $default))); + + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAnArrayDefaultOfTheRightTypeIsAccepted(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $this->assertSame(['a', 'b'], $database->updateAttribute(self::COLLECTION, 'tags', new AttributeUpdate(default: ['a', 'b']))->default); + $this->assertSame(['a', 'b'], $this->definitions($database)['tags']['default']); + } + + public function testAnUpdatedVectorDefaultNeedsNumericComponents(): void + { + $database = $this->database($this->vectorMemory()); + $database->createAttribute(self::COLLECTION, Attribute::vector(key: 'embedding', dimensions: 3)); + $before = $this->definitions($database); + + $this->assertRefused( + 'Vector default value must contain only numeric elements', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'embedding', new AttributeUpdate(default: ['a', 'b', 'c'])), + ); + + $this->assertSame($before, $this->definitions($database)); + $this->assertSame([1, 2.5, 3], $database->updateAttribute(self::COLLECTION, 'embedding', new AttributeUpdate(default: [1, 2.5, 3]))->default); + } + + /** + * @return array + */ + public static function unstorableTypes(): array + { + $cases = []; + foreach (self::adapters() as $adapterName => [$adapter]) { + foreach ([ColumnType::Json, ColumnType::Decimal, ColumnType::Uuid, ColumnType::Tuple] as $type) { + $cases["{$type->value} on {$adapterName}"] = [$adapter, $type]; + } + } + + return $cases; + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('unstorableTypes')] + public function testATypeTheAdapterCannotStoreIsAnUnknownTypeOnUpdate(\Closure $adapter, ColumnType $type): void + { + $database = $this->database($adapter()); + $before = $this->definitions($database); + + try { + $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: $type)); + $this->fail('A type the adapter cannot store must be refused'); + } catch (StructureException $error) { + $this->assertSame("Unknown attribute type: {$type->value}", $error->getMessage()); + } + + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testARelationshipCannotBeUpdatedAsAnAttribute(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $this->assertRefused( + 'A relationship attribute cannot change type; use updateRelationship()', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Relationship)), + ); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testTheSizeRulesApplyOnUpdate(\Closure $adapter): void + { + $database = $this->database($adapter()); + $limits = $database->getAdapter()->limits(); + $before = $this->definitions($database); + + $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: 0))); + $this->assertRefused( + 'Max size allowed for string is: '.\number_format($limits->string), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: $limits->string + 1)), + ); + $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Varchar, size: 0))); + $this->assertRefused( + 'Max size allowed for varchar is: '.\number_format($limits->varchar), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Varchar, size: $limits->varchar + 1)), + ); + $signedLimit = $limits->integer / 2; + $this->assertRefused( + 'Max size allowed for int is: '.\number_format($signedLimit), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'count', new AttributeUpdate(size: (int) $signedLimit + 1)), + ); + $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'ratio', new AttributeUpdate(size: 8))); + $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'flag', new AttributeUpdate(size: 1))); + + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testTheObjectRulesApplyOnUpdate(\Closure $adapter): void + { + $database = $this->database($adapter()); + $before = $this->definitions($database); + + if (! $database->getAdapter()->supports(Capability::Objects)) { + $this->assertRefused('Object attributes are not supported', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Object, size: 0))); + $this->assertSame($before, $this->definitions($database)); + + return; + } + + $this->assertRefused('Size must be empty for object attributes', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Object))); + $this->assertRefused('Object attributes cannot be arrays', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Object, size: 0, array: true))); + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testSpatialAndVectorTypesNeedTheirSupportOnUpdate(\Closure $adapter): void + { + $database = $this->database($adapter()); + + foreach ([ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon] as $spatial) { + $this->assertRefused('Spatial attributes are not supported', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: $spatial, size: 0))); + } + $this->assertRefused( + 'Vector types are not supported by the current database', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Vector, size: 3)), + ); + } + + /** + * @return array, string}> + */ + public static function invalidVectorDefinitions(): array + { + return [ + 'an array' => [['array' => true], 'Vector type cannot be an array'], + 'no dimensions' => [['size' => 0], 'Vector dimensions must be a positive integer'], + 'too many dimensions' => [['size' => Database::MAX_VECTOR_DIMENSIONS + 1], 'Vector dimensions cannot exceed '.Database::MAX_VECTOR_DIMENSIONS], + 'a scalar default' => [['default' => 'x'], 'Vector default value must be an array'], + 'a default of the wrong length' => [['default' => [1.0, 2.0]], 'Vector default value must have exactly 3 elements'], + 'a non-numeric default' => [['default' => [1.0, 'a', 2.0]], 'Vector default value must contain only numeric elements'], + ]; + } + + /** + * @param array $change + */ + #[DataProvider('invalidVectorDefinitions')] + public function testTheVectorRulesApplyOnUpdate(array $change, string $message): void + { + $database = $this->database($this->vectorMemory()); + $database->createAttribute(self::COLLECTION, Attribute::vector(key: 'embedding', dimensions: 3)); + $before = $this->definitions($database); + + /** @var int|null $size */ + $size = $change['size'] ?? null; + /** @var bool|null $array */ + $array = $change['array'] ?? null; + $this->assertRefused($message, fn (): mixed => $database->updateAttribute( + self::COLLECTION, + 'embedding', + new AttributeUpdate( + size: $size, + default: \array_key_exists('default', $change) ? $change['default'] : Unchanged::Value, + array: $array, + ), + )); + + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testADatetimeAttributeKeepsItsRequiredFilterOnUpdate(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $updated = $database->updateAttribute(self::COLLECTION, 'occurredAt', new AttributeUpdate(filters: [])); + + $this->assertSame(['datetime'], $updated->filters); + $this->assertSame(['datetime'], $this->definitions($database)['occurredAt']['filters']); + } + + public function testAnUpdatePastTheRowWidthLimitIsRefused(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function limits(): Limits + { + $limits = parent::limits(); + + return new Limits( + string: $limits->string, + varchar: $limits->varchar, + integer: $limits->integer, + bigInteger: $limits->bigInteger, + attributes: $limits->attributes, + indexes: $limits->indexes, + defaultAttributes: $limits->defaultAttributes, + defaultIndexes: $limits->defaultIndexes, + indexLength: $limits->indexLength, + uidLength: $limits->uidLength, + documentSize: 1_000, + minDateTime: $limits->minDateTime, + maxDateTime: $limits->maxDateTime, + idType: $limits->idType, + keywords: $limits->keywords, + internalIndexKeys: $limits->internalIndexKeys, + ); + } + + #[\Override] + public function getAttributeWidth(Document $collection): int + { + return 1_000; + } + }); + $before = $this->definitions($database); + + try { + $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: 128)); + $this->fail('an update past the row width limit must be refused'); + } catch (LimitException $error) { + $this->assertSame('Row width limit reached. Cannot update attribute.', $error->getMessage()); + } + + $this->assertSame($before, $this->definitions($database)); + } + + public function testAnAdapterThatDoesNotUpdateTheColumnFailsTheUpdate(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool + { + return false; + } + }); + $before = $this->definitions($database); + + try { + $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: 128)); + $this->fail('an update the adapter does not apply must be refused'); + } catch (RefusedException $error) { + $this->assertSame('Failed to update attribute', $error->getMessage()); + } + $this->assertSame($before, $this->definitions($database)); + } + + public function testARenameTheAdapterDoesNotApplyIsRefusedWithBothNames(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function renameAttribute(string $collection, string $old, string $new): bool + { + return false; + } + }); + $before = $this->definitions($database); + + try { + $database->renameAttribute(self::COLLECTION, 'label', 'caption'); + $this->fail('a rename the adapter does not apply must be refused'); + } catch (RefusedException $error) { + $this->assertSame("Failed to rename attribute 'label' to 'caption'", $error->getMessage()); + $this->assertNull($error->getPrevious()); + } + + $this->assertSame($before, $this->definitions($database)); + } + + public function testARenameFailureOnASchemaIntrospectingAdapterIsWrappedWithTheCause(): void + { + $cause = new RuntimeException('the engine refused the rename'); + $database = $this->database($this->renameFailingSQLite($cause)); + $before = $this->definitions($database); + + try { + $database->renameAttribute(self::COLLECTION, 'label', 'caption'); + $this->fail('a failed rename must be reported'); + } catch (DatabaseException $error) { + $this->assertNotInstanceOf(RefusedException::class, $error); + $this->assertSame("Failed to rename attribute 'label' to 'caption': the engine refused the rename", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + } + + $this->assertSame($before, $this->definitions($database)); + } + + public function testARenameTheEngineFailsWithALibraryErrorIsWrappedAndNotARefusal(): void + { + $cause = new DatabaseException('SQLSTATE[HY000]: General error'); + $database = $this->database($this->renameFailingSQLite($cause)); + $before = $this->definitions($database); + + try { + $database->renameAttribute(self::COLLECTION, 'label', 'caption'); + $this->fail('a failed rename must be reported'); + } catch (DatabaseException $error) { + $this->assertNotInstanceOf(RefusedException::class, $error); + $this->assertSame("Failed to rename attribute 'label' to 'caption': SQLSTATE[HY000]: General error", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + } + + $this->assertSame($before, $this->definitions($database)); + } + + public function testARenameOntoAColumnTheSchemaHoldsIsTheAdaptersDuplicate(): void + { + $duplicate = new DuplicateException('Attribute already exists'); + $database = $this->database($this->renameFailingSQLite($duplicate)); + $before = $this->definitions($database); + + try { + $database->renameAttribute(self::COLLECTION, 'label', 'caption'); + $this->fail('a rename onto an existing column must be a duplicate'); + } catch (DuplicateException $error) { + $this->assertSame($duplicate, $error); + } + + $this->assertSame($before, $this->definitions($database)); + } + + public function testARenameOfAColumnTheSchemaLacksIsTheAdaptersNotFound(): void + { + $missing = new NotFoundException('Attribute not found'); + $database = $this->database($this->renameFailingSQLite($missing)); + $before = $this->definitions($database); + + try { + $database->renameAttribute(self::COLLECTION, 'label', 'caption'); + $this->fail('a rename of a column the schema lacks must be not found'); + } catch (NotFoundException $error) { + $this->assertSame($missing, $error); + } + + $this->assertSame($before, $this->definitions($database)); + } + + private function renameFailingSQLite(Throwable $failure): SQLite + { + return new class (new PDO('sqlite::memory:'), $failure) extends SQLite { + public function __construct(object $pdo, private readonly Throwable $failure) + { + parent::__construct($pdo); + } + + #[\Override] + public function renameAttribute(string $collection, string $old, string $new): bool + { + throw $this->failure; + } + }; + } + + public function testAValidVectorUpdateIsStored(): void + { + $database = $this->database($this->vectorMemory()); + $database->createAttribute(self::COLLECTION, Attribute::vector(key: 'embedding', dimensions: 3)); + + $updated = $database->updateAttribute(self::COLLECTION, 'embedding', new AttributeUpdate(default: [0.5, 1.5, 2.5])); + + $this->assertSame(ColumnType::Vector, $updated->type); + $this->assertSame([0.5, 1.5, 2.5], $updated->default); + $this->assertSame([0.5, 1.5, 2.5], $this->definitions($database)['embedding']['default']); + } + + private function vectorMemory(): Memory + { + return new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::Vectors]; + } + }; + } + + /** + * @param callable(): mixed $update + */ + private function assertRefused(string $message, callable $update): void + { + $this->assertSame($message, $this->refusal($update)); + } + + /** + * @param callable(): mixed $update + */ + private function refusal(callable $update): string + { + try { + $update(); + } catch (DatabaseException $error) { + return $error->getMessage(); + } + + $this->fail('the update must be refused'); + } + + /** + * @return array> + */ + private function definitions(Database $database, string $collection = self::COLLECTION): array + { + $definitions = []; + /** @var array $attributes */ + $attributes = $database->getCollection($collection)->getAttribute('attributes', []); + foreach ($attributes as $attribute) { + $document = $attribute instanceof Attribute ? $attribute->toDocument() : $attribute; + $definitions[$document->getId()] = $document->getArrayCopy(); + } + + return $definitions; + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('attributes')->setNamespace('update_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + Attribute::string(key: 'label', size: 64), + Attribute::string(key: 'tags', size: 16, array: true), + Attribute::integer(key: 'count'), + Attribute::float(key: 'ratio'), + Attribute::boolean(key: 'flag'), + Attribute::datetime(key: 'occurredAt'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Authorization/AuthorizationTest.php b/tests/unit/Authorization/AuthorizationTest.php new file mode 100644 index 0000000000..a3c0f3043b --- /dev/null +++ b/tests/unit/Authorization/AuthorizationTest.php @@ -0,0 +1,383 @@ +auth = new Authorization(); + } + + public function testDefaultRolesContainAny(): void + { + $roles = $this->auth->getRoles(); + $this->assertContains('any', $roles); + $this->assertCount(1, $roles); + } + + public function testIsValidWithMatchingRole(): void + { + $this->auth->addRole('user:123'); + $input = new Input(PermissionType::Read, ['user:123']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testIsValidWithNonMatchingRole(): void + { + $this->auth->addRole('user:123'); + $input = new Input(PermissionType::Read, ['user:456']); + $this->assertFalse($this->auth->isValid($input)); + } + + public function testIsValidWithAnyRoleMatchesAllPermissions(): void + { + $input = new Input(PermissionType::Read, ['any']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testIsValidReturnsFalseWithEmptyPermissions(): void + { + $input = new Input(PermissionType::Read, []); + $this->assertFalse($this->auth->isValid($input)); + $this->assertStringContainsString('No permissions provided', $this->auth->getDescription()); + } + + public function testIsValidReturnsFalseWithInvalidInput(): void + { + $this->assertFalse($this->auth->isValid('not-an-input')); + $this->assertEquals('Invalid input provided', $this->auth->getDescription()); + } + + public function testAddRole(): void + { + $this->auth->addRole('user:123'); + $this->assertTrue($this->auth->hasRole('user:123')); + $this->assertContains('user:123', $this->auth->getRoles()); + } + + public function testRemoveRole(): void + { + $this->auth->addRole('user:123'); + $this->assertTrue($this->auth->hasRole('user:123')); + + $this->auth->removeRole('user:123'); + $this->assertFalse($this->auth->hasRole('user:123')); + } + + public function testGetRolesReturnsAllRoles(): void + { + $this->auth->addRole('user:123'); + $this->auth->addRole('team:456'); + $this->auth->addRole('users'); + + $roles = $this->auth->getRoles(); + $this->assertContains('any', $roles); + $this->assertContains('user:123', $roles); + $this->assertContains('team:456', $roles); + $this->assertContains('users', $roles); + $this->assertCount(4, $roles); + } + + public function testSkipBypassesAuthorization(): void + { + $this->auth->cleanRoles(); + + $input = new Input(PermissionType::Read, ['user:999']); + $this->assertFalse($this->auth->isValid($input)); + + $result = $this->auth->skip(function () use ($input) { + return $this->auth->isValid($input); + }); + + $this->assertTrue($result); + } + + public function testSkipRestoresStatusAfterCallback(): void + { + $this->assertTrue($this->auth->getStatus()); + + $this->auth->skip(function () { + $this->assertFalse($this->auth->getStatus()); + }); + + $this->assertTrue($this->auth->getStatus()); + } + + public function testSkipRestoresStatusOnException(): void + { + $this->assertTrue($this->auth->getStatus()); + + try { + $this->auth->skip(function () { + throw new \RuntimeException('test'); + }); + } catch (\RuntimeException) { + } + + $this->assertTrue($this->auth->getStatus()); + } + + public function testIsValidWithMultipleRoles(): void + { + $this->auth->addRole('user:123'); + $this->auth->addRole('team:456'); + + $input = new Input(PermissionType::Read, ['team:456']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testIsValidWithMultiplePermissionsMatchesFirst(): void + { + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Read, ['user:123', 'team:456']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testIsValidWithMultiplePermissionsMatchesLast(): void + { + $this->auth->addRole('team:456'); + + $input = new Input(PermissionType::Read, ['user:123', 'team:456']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testIsValidWithGuestsRole(): void + { + $this->auth->addRole('guests'); + + $input = new Input(PermissionType::Read, ['guests']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testIsValidWithUsersRole(): void + { + $this->auth->addRole('users'); + + $input = new Input(PermissionType::Read, ['users']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testIsValidWithDimensionalRole(): void + { + $this->auth->addRole('user:123/admin'); + + $input = new Input(PermissionType::Read, ['user:123/admin']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testDimensionalRoleDoesNotMatchWithoutDimension(): void + { + $this->auth->addRole('user:123/admin'); + + $input = new Input(PermissionType::Read, ['user:123']); + $this->assertFalse($this->auth->isValid($input)); + } + + public function testNonDimensionalRoleDoesNotMatchWithDimension(): void + { + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Read, ['user:123/admin']); + $this->assertFalse($this->auth->isValid($input)); + } + + public function testGetDescriptionOnFailure(): void + { + $this->auth->cleanRoles(); + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Read, ['team:456']); + $this->assertFalse($this->auth->isValid($input)); + + $description = $this->auth->getDescription(); + $this->assertStringContainsString('Missing "read" permission', $description); + $this->assertStringContainsString('team:456', $description); + } + + public function testGetDescriptionOnEmptyPermissions(): void + { + $input = new Input(PermissionType::Write, []); + $this->assertFalse($this->auth->isValid($input)); + $this->assertStringContainsString("No permissions provided for action 'write'", $this->auth->getDescription()); + } + + public function testCleanRolesRemovesAll(): void + { + $this->auth->addRole('user:123'); + $this->auth->addRole('team:456'); + $this->assertCount(3, $this->auth->getRoles()); + + $this->auth->cleanRoles(); + $this->assertCount(0, $this->auth->getRoles()); + $this->assertFalse($this->auth->hasRole('any')); + } + + public function testDisableAndEnable(): void + { + $this->assertTrue($this->auth->getStatus()); + + $this->auth->disable(); + $this->assertFalse($this->auth->getStatus()); + + $this->auth->enable(); + $this->assertTrue($this->auth->getStatus()); + } + + public function testDisabledAuthorizationBypassesAllChecks(): void + { + $this->auth->disable(); + $this->auth->cleanRoles(); + + $input = new Input(PermissionType::Read, ['user:999']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testConstructorSetsTheDefaultStatus(): void + { + $authorization = new Authorization(defaultStatus: false); + $this->assertFalse($authorization->getStatus()); + + $authorization->enable(); + $authorization->reset(); + $this->assertFalse($authorization->getStatus()); + } + + public function testResetRestoresDefaultStatus(): void + { + $this->auth->disable(); + $this->assertFalse($this->auth->getStatus()); + + $this->auth->reset(); + $this->assertTrue($this->auth->getStatus()); + } + + public function testPermissionTypeMatchingRead(): void + { + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Read, ['user:123']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testPermissionTypeMatchingCreate(): void + { + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Create, ['user:123']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testPermissionTypeMatchingUpdate(): void + { + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Update, ['user:123']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testPermissionTypeMatchingDelete(): void + { + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Delete, ['user:123']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testPermissionTypeMatchingWrite(): void + { + $this->auth->addRole('user:123'); + + $input = new Input(PermissionType::Write, ['user:123']); + $this->assertTrue($this->auth->isValid($input)); + } + + public function testHasRole(): void + { + $this->assertTrue($this->auth->hasRole('any')); + $this->assertFalse($this->auth->hasRole('user:123')); + + $this->auth->addRole('user:123'); + $this->assertTrue($this->auth->hasRole('user:123')); + } + + public function testIsArray(): void + { + $this->assertFalse($this->auth->isArray()); + } + + public function testGetType(): void + { + $this->assertEquals('array', $this->auth->getType()); + } + + public function testInputSettersAndGetters(): void + { + $input = new Input(PermissionType::Read, ['user:123']); + $this->assertEquals('read', $input->getAction()); + $this->assertEquals(['user:123'], $input->getPermissions()); + + $input->setAction(PermissionType::Write); + $this->assertEquals('write', $input->getAction()); + + $input->setPermissions(['team:456']); + $this->assertEquals(['team:456'], $input->getPermissions()); + } + + public function testIsValidWithTeamDimensionRole(): void + { + $this->auth->addRole('team:abc/owner'); + + $input = new Input(PermissionType::Read, ['team:abc/owner']); + $this->assertTrue($this->auth->isValid($input)); + + $input = new Input(PermissionType::Read, ['team:abc/member']); + $this->assertFalse($this->auth->isValid($input)); + } + + public function testAddingDuplicateRoleDoesNotDuplicate(): void + { + $this->auth->addRole('user:123'); + $this->auth->addRole('user:123'); + + $roles = array_filter($this->auth->getRoles(), fn ($r) => $r === 'user:123'); + $this->assertCount(1, $roles); + } + + public function testRemovingNonExistentRoleDoesNotThrow(): void + { + $this->auth->removeRole('nonexistent'); + $this->assertFalse($this->auth->hasRole('nonexistent')); + } + + public function testLabelRole(): void + { + $this->auth->addRole('label:vip'); + + $input = new Input(PermissionType::Read, ['label:vip']); + $this->assertTrue($this->auth->isValid($input)); + + $input = new Input(PermissionType::Read, ['label:premium']); + $this->assertFalse($this->auth->isValid($input)); + } + + public function testMemberRole(): void + { + $this->auth->addRole('member:abc123'); + + $input = new Input(PermissionType::Read, ['member:abc123']); + $this->assertTrue($this->auth->isValid($input)); + + $input = new Input(PermissionType::Read, ['member:def456']); + $this->assertFalse($this->auth->isValid($input)); + } +} diff --git a/tests/unit/Authorization/CoroutineRolesTest.php b/tests/unit/Authorization/CoroutineRolesTest.php new file mode 100644 index 0000000000..dd75b23b96 --- /dev/null +++ b/tests/unit/Authorization/CoroutineRolesTest.php @@ -0,0 +1,439 @@ +authorization = new Authorization(); + } + + public function testWithRolesReplacesTheRolesForItsCallback(): void + { + $seen = $this->authorization->withRoles([self::ALICE], fn (): array => [ + $this->authorization->getRoles(), + $this->authorization->hasRole('any'), + $this->authorization->isValid(new Input(PermissionType::Read, [self::ALICE])), + $this->authorization->isValid(new Input(PermissionType::Read, ['any'])), + ]); + + $this->assertSame([[self::ALICE], false, true, false], $seen); + $this->assertSame(['any'], $this->authorization->getRoles()); + } + + public function testRoleChangesInsideWithRolesLastUntilItEnds(): void + { + $seen = $this->authorization->withRoles([self::ALICE], function (): array { + $this->authorization->addRole('team:blue'); + $this->authorization->removeRole(self::ALICE); + $changed = $this->authorization->getRoles(); + $this->authorization->cleanRoles(); + + return [$changed, $this->authorization->getRoles()]; + }); + + $this->assertSame([['team:blue'], []], $seen); + $this->assertSame(['any'], $this->authorization->getRoles()); + } + + public function testWithRolesRestoresTheRolesWhenItsCallbackThrows(): void + { + $thrown = null; + try { + $this->authorization->withRoles([self::ALICE], static fn (): never => throw new RuntimeException('failed')); + } catch (RuntimeException $error) { + $thrown = $error->getMessage(); + } + + $this->assertSame('failed', $thrown); + $this->assertSame(['any'], $this->authorization->getRoles()); + } + + public function testWithNoRolesDeniesEveryPermission(): void + { + $valid = $this->authorization->withRoles([], fn (): bool => $this->authorization->isValid( + new Input(PermissionType::Read, ['any']), + )); + + $this->assertFalse($valid); + } + + public function testWithRolesInOneSiblingIsSeenByItsChildrenButNotByTheParentOrAnotherSibling(): void + { + $this->skipWithoutCoroutines(); + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $entered = new Channel(1); + $released = new Channel(1); + $closed = new Channel(1); + + Coroutine::create(function () use (&$seen, $entered, $released, $closed): void { + $this->authorization->withRoles([self::ALICE], function () use (&$seen, $entered, $released): void { + $childDone = new Channel(1); + Coroutine::create(function () use (&$seen, $childDone): void { + Coroutine::sleep(0.001); + $seen['child'] = $this->authorization->getRoles(); + $childDone->push(true); + }); + $childDone->pop(); + $entered->push(true); + $released->pop(); + $seen['insideAfterSibling'] = $this->authorization->getRoles(); + }); + $closed->push(true); + }); + + $entered->pop(); + $seen['parent'] = $this->authorization->getRoles(); + + Coroutine::create(function () use (&$seen, $released): void { + $this->authorization->addRole('team:blue'); + $seen['sibling'] = $this->authorization->getRoles(); + $released->push(true); + }); + + $closed->pop(); + $seen['after'] = $this->authorization->getRoles(); + }); + + $this->assertSame([ + 'child' => [self::ALICE], + 'parent' => ['any'], + 'sibling' => ['any', 'team:blue'], + 'insideAfterSibling' => [self::ALICE], + 'after' => ['any', 'team:blue'], + ], $seen); + } + + public function testOverlappingWithRolesInSiblingsLeaveTheSharedRoles(): void + { + $this->skipWithoutCoroutines(); + + $this->inCoroutine(function (): void { + $first = new Channel(1); + $second = new Channel(1); + + Coroutine::create(function () use ($first, $second): void { + $this->authorization->withRoles([self::ALICE], function () use ($first, $second): void { + $first->push(true); + $second->pop(); + }); + }); + + Coroutine::create(function () use ($first, $second): void { + $first->pop(); + $this->authorization->withRoles(['team:blue'], function () use ($second): void { + $second->push(true); + Coroutine::sleep(0.001); + }); + }); + }); + + $this->assertSame(['any'], $this->authorization->getRoles()); + } + + public function testRoleChangesInACoroutineStartedInsideWithRolesStayInThatCoroutine(): void + { + $this->skipWithoutCoroutines(); + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->withRoles([self::ALICE], function () use (&$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + $this->authorization->addRole('team:admins'); + $seen['childAfterAdd'] = $this->authorization->getRoles(); + $this->authorization->removeRole(self::ALICE); + $seen['childAfterRemove'] = $this->authorization->getRoles(); + $done->push(true); + }); + + $done->pop(); + $seen['parent'] = $this->authorization->getRoles(); + }); + + $seen['after'] = $this->authorization->getRoles(); + + Coroutine::create(function () use (&$seen): void { + $seen['unrelated'] = $this->authorization->getRoles(); + }); + }); + + $this->assertSame([ + 'childAfterAdd' => [self::ALICE, 'team:admins'], + 'childAfterRemove' => ['team:admins'], + 'parent' => [self::ALICE], + 'after' => ['any'], + 'unrelated' => ['any'], + ], $seen); + $this->assertSame(['any'], $this->authorization->getRoles()); + } + + public function testCleanRolesInACoroutineStartedInsideWithRolesLeavesTheSharedRoles(): void + { + $this->skipWithoutCoroutines(); + $seen = null; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->withRoles([self::ALICE], function () use (&$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + $this->authorization->cleanRoles(); + $seen = $this->authorization->isValid(new Input(PermissionType::Read, [self::ALICE])); + $done->push(true); + }); + + $done->pop(); + }); + }); + + $this->assertFalse($seen); + $this->assertSame(['any'], $this->authorization->getRoles()); + } + + public function testChangesInACoroutineWhoseStarterHasReturnedStayInThatCoroutine(): void + { + $this->skipWithoutCoroutines(); + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->withRoles([self::ALICE], function () use (&$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + Coroutine::create(function () use (&$seen, $done): void { + Coroutine::sleep(0.01); + $this->authorization->addRole('team:admins'); + $this->authorization->disable(); + $seen['detached'] = [$this->authorization->getRoles(), $this->authorization->getStatus()]; + $done->push(true); + }); + }); + + $done->pop(); + $seen['owner'] = [$this->authorization->getRoles(), $this->authorization->getStatus()]; + }); + + $unrelatedDone = new Channel(1); + Coroutine::create(function () use (&$seen, $unrelatedDone): void { + $seen['unrelated'] = [ + $this->authorization->getRoles(), + $this->authorization->getStatus(), + $this->authorization->isValid(new Input(PermissionType::Read, ['team:admins'])), + ]; + $unrelatedDone->push(true); + }); + $unrelatedDone->pop(); + }); + + $this->assertSame([ + 'detached' => [['any', 'team:admins'], false], + 'owner' => [[self::ALICE], true], + 'unrelated' => [['any'], true, false], + ], $seen); + $this->assertSame(['any'], $this->authorization->getRoles()); + $this->assertTrue($this->authorization->getStatus()); + } + + public function testRoleChangesInACoroutineWhoseStarterHasReturnedStayInThatCoroutineWhileASkipIsOpen(): void + { + $this->skipWithoutCoroutines(); + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->skip(function () use (&$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + Coroutine::create(function () use (&$seen, $done): void { + Coroutine::sleep(0.01); + $this->authorization->addRole('team:admins'); + $seen['detached'] = $this->authorization->getRoles(); + $done->push(true); + }); + }); + + $done->pop(); + $seen['owner'] = [$this->authorization->getRoles(), $this->authorization->getStatus()]; + }); + + $unrelatedDone = new Channel(1); + Coroutine::create(function () use (&$seen, $unrelatedDone): void { + $seen['unrelated'] = [ + $this->authorization->getRoles(), + $this->authorization->isValid(new Input(PermissionType::Read, ['team:admins'])), + ]; + $unrelatedDone->push(true); + }); + $unrelatedDone->pop(); + }); + + $this->assertSame([ + 'detached' => ['any', 'team:admins'], + 'owner' => [['any'], false], + 'unrelated' => [['any'], false], + ], $seen); + $this->assertSame(['any'], $this->authorization->getRoles()); + } + + public function testACloneTakenInACoroutineWhoseStarterHasReturnedKeepsItsOwnState(): void + { + $this->skipWithoutCoroutines(); + $seen = []; + $clone = null; + + $this->inCoroutine(function () use (&$seen, &$clone): void { + $this->authorization->withRoles([self::ALICE], function () use (&$seen, &$clone): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, &$clone, $done): void { + Coroutine::create(function () use (&$seen, &$clone, $done): void { + Coroutine::sleep(0.01); + $this->authorization->addRole('team:admins'); + $this->authorization->disable(); + $clone = clone $this->authorization; + $seen['cloneAtStart'] = [$clone->getRoles(), $clone->getStatus()]; + + $clone->addRole('team:blue'); + $clone->enable(); + $this->authorization->removeRole('any'); + $seen['clone'] = [$clone->getRoles(), $clone->getStatus()]; + $seen['detached'] = [$this->authorization->getRoles(), $this->authorization->getStatus()]; + $done->push(true); + }); + }); + + $done->pop(); + $seen['owner'] = $this->authorization->getRoles(); + }); + }); + + $this->assertInstanceOf(Authorization::class, $clone); + $this->assertSame([ + 'cloneAtStart' => [['any', 'team:admins'], false], + 'clone' => [['any', 'team:admins', 'team:blue'], true], + 'detached' => [['team:admins'], false], + 'owner' => [self::ALICE], + ], $seen); + $this->assertSame([['any', 'team:admins', 'team:blue'], true], [$clone->getRoles(), $clone->getStatus()]); + $this->assertSame([['any'], true], [$this->authorization->getRoles(), $this->authorization->getStatus()]); + + $clone->cleanRoles(); + $this->authorization->addRole('team:red'); + + $this->assertSame([], $clone->getRoles()); + $this->assertSame(['any', 'team:red'], $this->authorization->getRoles()); + } + + public function testDisableByTheOwnerOfWithRolesChangesTheSharedStatus(): void + { + $this->skipWithoutCoroutines(); + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $disabled = new Channel(1); + $released = new Channel(1); + $closed = new Channel(1); + + Coroutine::create(function () use ($disabled, $released, $closed): void { + $this->authorization->withRoles([self::ALICE], function () use ($disabled, $released): void { + $this->authorization->disable(); + $disabled->push(true); + $released->pop(); + }); + $closed->push(true); + }); + + $disabled->pop(); + $siblingDone = new Channel(1); + Coroutine::create(function () use (&$seen, $siblingDone): void { + $seen['sibling'] = $this->authorization->getStatus(); + $siblingDone->push(true); + }); + $siblingDone->pop(); + + $released->push(true); + $closed->pop(); + $seen['after'] = $this->authorization->getStatus(); + }); + + $this->assertSame(['sibling' => false, 'after' => false], $seen); + + $this->authorization->enable(); + $this->assertTrue($this->authorization->getStatus()); + } + + public function testDisableByALiveChildInsideWithRolesChangesTheSharedStatus(): void + { + $this->skipWithoutCoroutines(); + + $this->inCoroutine(function (): void { + $this->authorization->withRoles([self::ALICE], function (): void { + $done = new Channel(1); + + Coroutine::create(function () use ($done): void { + Coroutine::sleep(0.001); + $this->authorization->disable(); + $done->push(true); + }); + + $done->pop(); + }); + }); + + $this->assertFalse($this->authorization->getStatus()); + } + + public function testACloneStartsFromTheCurrentRolesAndKeepsItsOwn(): void + { + $clone = $this->authorization->withRoles([self::ALICE], fn (): Authorization => clone $this->authorization); + + $this->assertSame([self::ALICE], $clone->getRoles()); + $this->assertSame(['any'], $this->authorization->getRoles()); + + $clone->addRole('team:blue'); + $this->authorization->cleanRoles(); + + $this->assertSame([self::ALICE, 'team:blue'], $clone->getRoles()); + $this->assertSame([], $this->authorization->getRoles()); + } + + private function skipWithoutCoroutines(): void + { + if (! \extension_loaded('swoole')) { + $this->markTestSkipped('ext-swoole is required for coroutine-scoped roles'); + } + } + + private function inCoroutine(Closure $test): void + { + $hookFlags = Runtime::getHookFlags(); + + try { + run($test); + } finally { + Runtime::setHookFlags($hookFlags); + } + } +} diff --git a/tests/unit/Authorization/CoroutineStatusTest.php b/tests/unit/Authorization/CoroutineStatusTest.php new file mode 100644 index 0000000000..e343d17dc5 --- /dev/null +++ b/tests/unit/Authorization/CoroutineStatusTest.php @@ -0,0 +1,268 @@ +markTestSkipped('ext-swoole is required for coroutine status'); + } + + $this->authorization = new Authorization(); + } + + public function testDisableInACoroutineIsSeenByTheCoroutinesItStarts(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->disable(); + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + $seen['child'] = $this->authorization->getStatus(); + Coroutine::create(function () use (&$seen, $done): void { + Coroutine::sleep(0.001); + $seen['grandchild'] = $this->authorization->getStatus(); + $done->push(true); + }); + }); + + $done->pop(); + }); + + $this->assertSame(['child' => false, 'grandchild' => false], $seen); + $this->assertFalse($this->authorization->getStatus()); + } + + public function testDisableInACoroutineWithoutAScopeIsSeenBySiblings(): void + { + $seen = null; + + $this->inCoroutine(function () use (&$seen): void { + $disabled = new Channel(1); + + Coroutine::create(function () use ($disabled): void { + $this->authorization->disable(); + $disabled->push(true); + }); + + Coroutine::create(function () use (&$seen, $disabled): void { + $disabled->pop(); + $seen = $this->authorization->getStatus(); + }); + }); + + $this->assertFalse($seen); + } + + public function testSkipInOneSiblingChangesNeitherTheParentNorAnotherSibling(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $entered = new Channel(1); + $released = new Channel(1); + + Coroutine::create(function () use (&$seen, $entered, $released): void { + $this->authorization->skip(function () use (&$seen, $entered, $released): void { + $seen['skipping'] = $this->authorization->getStatus(); + $entered->push(true); + $released->pop(); + }); + }); + + $entered->pop(); + $seen['parent'] = $this->authorization->getStatus(); + + Coroutine::create(function () use (&$seen, $released): void { + $seen['sibling'] = $this->authorization->getStatus(); + $released->push(true); + }); + }); + + $this->assertSame(['skipping' => false, 'parent' => true, 'sibling' => true], $seen); + $this->assertTrue($this->authorization->getStatus()); + } + + public function testOverlappingSkipsInSiblingsRestoreTheirOwnStatus(): void + { + $this->inCoroutine(function (): void { + $first = new Channel(1); + $second = new Channel(1); + + Coroutine::create(function () use ($first, $second): void { + $this->authorization->skip(function () use ($first, $second): void { + $first->push(true); + $second->pop(); + }); + }); + + Coroutine::create(function () use ($first, $second): void { + $first->pop(); + $this->authorization->skip(function () use ($second): void { + $second->push(true); + Coroutine::sleep(0.001); + }); + }); + }); + + $this->assertTrue($this->authorization->getStatus()); + } + + public function testSkipIsSeenByTheCoroutinesItStarts(): void + { + $seen = null; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->skip(function () use (&$seen): void { + $done = new Channel(1); + Coroutine::create(function () use (&$seen, $done): void { + $seen = $this->authorization->getStatus(); + $done->push(true); + }); + $done->pop(); + }); + }); + + $this->assertFalse($seen); + $this->assertTrue($this->authorization->getStatus()); + } + + public function testStatusChangesInsideASkipLastUntilTheSkipEnds(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->disable(); + $this->authorization->skip(function () use (&$seen): void { + $this->authorization->enable(); + $seen['enabled'] = $this->authorization->getStatus(); + $this->authorization->reset(); + $seen['reset'] = $this->authorization->getStatus(); + }); + $seen['after'] = $this->authorization->getStatus(); + }); + + $this->assertSame(['enabled' => true, 'reset' => true, 'after' => false], $seen); + } + + public function testResetInACoroutineStartedInsideASkipRestoresTheCheckForThatCoroutineOnly(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->skip(function () use (&$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + $this->authorization->reset(); + $seen['child'] = $this->authorization->getStatus(); + $seen['childValid'] = $this->authorization->isValid(new Input(PermissionType::Read, ['user:x'])); + $done->push(true); + }); + + $done->pop(); + $seen['parent'] = $this->authorization->getStatus(); + }); + + $seen['after'] = $this->authorization->getStatus(); + }); + + $this->assertSame(['child' => true, 'childValid' => false, 'parent' => false, 'after' => true], $seen); + } + + public function testDisableInACoroutineStartedInsideASkipEndsWithTheSkip(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->skip(function (): void { + $done = new Channel(1); + + Coroutine::create(function () use ($done): void { + $this->authorization->reset(); + $this->authorization->disable(); + $done->push(true); + }); + + $done->pop(); + }); + + $seen['after'] = $this->authorization->getStatus(); + + Coroutine::create(function () use (&$seen): void { + $seen['unrelated'] = $this->authorization->getStatus(); + }); + }); + + $this->assertSame(['after' => true, 'unrelated' => true], $seen); + $this->assertTrue($this->authorization->getStatus()); + } + + public function testEnableInACoroutineStartedInsideASkipOfADisabledCheckStaysInThatCoroutine(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->disable(); + $this->authorization->skip(function () use (&$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + $this->authorization->enable(); + $seen['child'] = $this->authorization->getStatus(); + $done->push(true); + }); + + $done->pop(); + $seen['parent'] = $this->authorization->getStatus(); + }); + + $seen['after'] = $this->authorization->getStatus(); + }); + + $this->assertSame(['child' => true, 'parent' => false, 'after' => false], $seen); + } + + public function testACloneStartsFromTheCurrentStatusAndKeepsItsOwn(): void + { + $clone = $this->authorization->skip(fn (): Authorization => clone $this->authorization); + + $this->assertFalse($clone->getStatus()); + $this->assertTrue($this->authorization->getStatus()); + + $clone->enable(); + $this->authorization->disable(); + + $this->assertTrue($clone->getStatus()); + $this->assertFalse($this->authorization->getStatus()); + } + + private function inCoroutine(Closure $test): void + { + $hookFlags = Runtime::getHookFlags(); + + try { + run($test); + } finally { + Runtime::setHookFlags($hookFlags); + } + } +} diff --git a/tests/unit/Authorization/FluentTest.php b/tests/unit/Authorization/FluentTest.php new file mode 100644 index 0000000000..e881995040 --- /dev/null +++ b/tests/unit/Authorization/FluentTest.php @@ -0,0 +1,96 @@ +toString(); + $team = Role::team('abc')->toString(); + + $chained = $authorization + ->cleanRoles() + ->addRole($user) + ->addRole($team) + ->removeRole($team); + + $this->assertSame($authorization, $chained); + $this->assertSame([$user], $authorization->getRoles()); + } + + public function testStatusSettersChainOnTheSameInstance(): void + { + $authorization = new Authorization(); + + $this->assertSame($authorization, $authorization->disable()); + $this->assertFalse($authorization->getStatus()); + + $this->assertSame($authorization, $authorization->enable()); + $this->assertTrue($authorization->getStatus()); + + $this->assertSame($authorization, $authorization->setStatus(false)); + $this->assertFalse($authorization->getStatus()); + } + + public function testTheDefaultStatusIsTrue(): void + { + $authorization = new Authorization(); + + $this->assertTrue($authorization->getStatus()); + + $authorization->disable()->reset(); + $this->assertTrue($authorization->getStatus()); + } + + public function testADisabledDefaultStatusSkipsChecksUntilEnabled(): void + { + $authorization = (new Authorization(defaultStatus: false))->cleanRoles(); + $input = new Input(PermissionType::Read, [Role::user('123')->toString()]); + + $this->assertFalse($authorization->getStatus()); + $this->assertTrue($authorization->isValid($input)); + + $authorization->enable(); + $this->assertFalse($authorization->isValid($input)); + + $authorization->reset(); + $this->assertTrue($authorization->isValid($input)); + } + + public function testSkipLiftsTheCheckOnlyInsideTheCallback(): void + { + $authorization = (new Authorization())->cleanRoles(); + $input = new Input(PermissionType::Delete, [Role::user('123')->toString()]); + + $this->assertTrue($authorization->skip(static fn (): bool => $authorization->isValid($input))); + $this->assertFalse($authorization->isValid($input)); + } + + public function testInputTakesThePermissionType(): void + { + $input = new Input(PermissionType::Update, ['any']); + + $this->assertSame(PermissionType::Update->value, $input->getAction()); + $this->assertSame(['any'], $input->getPermissions()); + + $this->assertSame($input, $input->setAction(PermissionType::Create)->setPermissions(['users'])); + $this->assertSame(PermissionType::Create->value, $input->getAction()); + $this->assertSame(['users'], $input->getPermissions()); + } + + public function testAMissingPermissionNamesTheAction(): void + { + $authorization = (new Authorization())->cleanRoles(); + + $this->assertFalse($authorization->isValid(new Input(PermissionType::Update, [Role::user('123')->toString()]))); + $this->assertStringContainsString('"update"', $authorization->getDescription()); + } +} diff --git a/tests/unit/Authorization/PermissionCheckTest.php b/tests/unit/Authorization/PermissionCheckTest.php new file mode 100644 index 0000000000..688d34845c --- /dev/null +++ b/tests/unit/Authorization/PermissionCheckTest.php @@ -0,0 +1,909 @@ +adapter = self::createStub(Adapter::class); + + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('limits')->willReturn(new Limits( + string: 0, + varchar: 0, + integer: 0, + bigInteger: 0, + attributes: 0, + indexes: 0, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 0, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('1970-01-01 00:00:00'), + maxDateTime: new DateTime('2999-12-31 23:59:59'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return match ($cap) { + Capability::DefinedAttributes => true, + default => false, + }; + }); + $this->adapter->method('withTransaction')->willReturnCallback( + fn (callable $callback) => $callback() + ); + $this->adapter->method('getSequences')->willReturnCallback( + fn (string $collection, array $documents) => $documents + ); + + $cache = new Cache(new NoneAdapter()); + $this->database = new Database($this->adapter, $cache); + $this->database->setValidation(false); + $this->database->setFiltering(false); + + $this->authorization = $this->database->getAuthorization(); + } + + /** + * @param array $permissions + */ + private function buildCollectionDoc( + string $id, + array $permissions = [], + bool $documentSecurity = false + ): Document { + return new Document([ + '$id' => $id, + '$collection' => Database::METADATA, + '$permissions' => $permissions, + 'name' => $id, + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => $documentSecurity, + ]); + } + + private function configureAdapterForCollection(Document $collection): void + { + $collectionId = $collection->getId(); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + + return new Document(); + } + ); + } + + public function testCreateDocumentThrowsWithoutCreatePermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::user('owner')), + Permission::read(Role::any()), + ]); + + $this->configureAdapterForCollection($collection); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:other'); + + $this->expectException(AuthorizationException::class); + + $this->database->createDocument('test_col', new Document([ + '$id' => 'doc1', + '$permissions' => [], + ])); + } + + public function testCreateDocumentSucceedsWithCreatePermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::user('owner')), + Permission::read(Role::any()), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:owner'); + + $result = $this->database->createDocument('test_col', new Document([ + '$id' => 'doc1', + '$permissions' => [], + ])); + + $this->assertEquals('doc1', $result->getId()); + } + + public function testCreateDocumentSucceedsWithCollectionCreatePermissionForAny(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + + $result = $this->database->createDocument('test_col', new Document([ + '$id' => 'doc2', + '$permissions' => [], + ])); + + $this->assertEquals('doc2', $result->getId()); + } + + public function testUpdateDocumentThrowsWithoutUpdatePermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::user('owner')), + ]); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + 'title' => 'old', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:other'); + + $this->expectException(AuthorizationException::class); + + $this->database->updateDocument('test_col', 'doc1', new Document([ + '$id' => 'doc1', + 'title' => 'new', + ])); + } + + public function testUpdateDocumentSucceedsWithUpdatePermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::user('owner')), + ]); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + 'title' => 'old', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->adapter->method('updateDocument')->willReturnCallback( + fn (Document $col, string $id, Document $doc, bool $skipPerms) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:owner'); + + $result = $this->database->updateDocument('test_col', 'doc1', new Document([ + '$id' => 'doc1', + 'title' => 'new', + ])); + + $this->assertNotEmpty($result->getId()); + } + + public function testDeleteDocumentThrowsWithoutDeletePermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::user('owner')), + ]); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:other'); + + $this->expectException(AuthorizationException::class); + + $this->database->deleteDocument('test_col', 'doc1'); + } + + public function testDeleteDocumentSucceedsWithDeletePermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::user('owner')), + ]); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->adapter->method('deleteDocument')->willReturn(true); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:owner'); + + $result = $this->database->deleteDocument('test_col', 'doc1'); + $this->assertTrue($result); + } + + public function testGetDocumentReturnsEmptyWithoutReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:other'); + + $result = $this->database->getDocument('test_col', 'doc1'); + $this->assertTrue($result->isEmpty()); + } + + public function testGetDocumentSucceedsWithReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:owner'); + + $result = $this->database->getDocument('test_col', 'doc1'); + $this->assertEquals('doc1', $result->getId()); + } + + public function testFindThrowsWithoutReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $this->configureAdapterForCollection($collection); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:other'); + + $this->expectException(AuthorizationException::class); + + $this->database->find('test_col'); + } + + public function testFindSucceedsWithReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('find')->willReturn([ + new Document([ + '$id' => 'doc1', + '$permissions' => [], + ]), + ]); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:owner'); + + $results = $this->database->find('test_col'); + $this->assertCount(1, $results); + } + + public function testDocumentLevelSecurityAllowsReadWithDocPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + ], documentSecurity: true); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [ + Permission::read(Role::user('reader')), + ], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:reader'); + + $result = $this->database->getDocument('test_col', 'doc1'); + $this->assertEquals('doc1', $result->getId()); + } + + public function testDocumentLevelSecurityDeniesReadWithoutDocPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + ], documentSecurity: true); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [ + Permission::read(Role::user('reader')), + ], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:stranger'); + + $result = $this->database->getDocument('test_col', 'doc1'); + $this->assertTrue($result->isEmpty()); + } + + public function testAggregatedWritePermissionGrantsCreate(): void + { + $permissions = Permission::aggregate([Permission::write(Role::user('writer'))]); + $permissions[] = Permission::read(Role::any()); + + $collection = $this->buildCollectionDoc('test_col', $permissions); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:writer'); + + $result = $this->database->createDocument('test_col', new Document([ + '$id' => 'doc1', + '$permissions' => [], + ])); + + $this->assertEquals('doc1', $result->getId()); + } + + public function testAggregatedWritePermissionGrantsUpdate(): void + { + $permissions = Permission::aggregate([Permission::write(Role::user('writer'))]); + $permissions[] = Permission::read(Role::any()); + + $collection = $this->buildCollectionDoc('test_col', $permissions); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + 'title' => 'old', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->adapter->method('updateDocument')->willReturnCallback( + fn (Document $col, string $id, Document $doc, bool $skipPerms) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:writer'); + + $result = $this->database->updateDocument('test_col', 'doc1', new Document([ + '$id' => 'doc1', + 'title' => 'new', + ])); + + $this->assertNotEmpty($result->getId()); + } + + public function testAggregatedWritePermissionGrantsDelete(): void + { + $permissions = Permission::aggregate([Permission::write(Role::user('writer'))]); + $permissions[] = Permission::read(Role::any()); + + $collection = $this->buildCollectionDoc('test_col', $permissions); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->adapter->method('deleteDocument')->willReturn(true); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:writer'); + + $result = $this->database->deleteDocument('test_col', 'doc1'); + $this->assertTrue($result); + } + + public function testSkipAuthorizationBypassesAllChecks(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::user('nobody')), + Permission::read(Role::user('nobody')), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->authorization->cleanRoles(); + + $result = $this->authorization->skip(function () { + return $this->database->createDocument('test_col', new Document([ + '$id' => 'doc1', + '$permissions' => [], + ])); + }); + + $this->assertEquals('doc1', $result->getId()); + } + + public function testCountThrowsWithoutReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $this->configureAdapterForCollection($collection); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:other'); + + $this->expectException(AuthorizationException::class); + + $this->database->count('test_col'); + } + + public function testCountSucceedsWithReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('count')->willReturn(5); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:owner'); + + $result = $this->database->count('test_col'); + $this->assertEquals(5, $result); + } + + public function testSumThrowsWithoutReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $this->configureAdapterForCollection($collection); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:other'); + + $this->expectException(AuthorizationException::class); + + $this->database->sum('test_col', 'amount'); + } + + public function testSumSucceedsWithReadPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::user('owner')), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('sum')->willReturn(42.5); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:owner'); + + $result = $this->database->sum('test_col', 'amount'); + $this->assertEquals(42.5, $result); + } + + public function testDocumentSecurityAllowsUpdateWithDocPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::any()), + ], documentSecurity: true); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [ + Permission::update(Role::user('editor')), + Permission::read(Role::any()), + ], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + 'title' => 'old', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->adapter->method('updateDocument')->willReturnCallback( + fn (Document $col, string $id, Document $doc, bool $skipPerms) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:editor'); + + $result = $this->database->updateDocument('test_col', 'doc1', new Document([ + '$id' => 'doc1', + 'title' => 'new', + ])); + + $this->assertNotEmpty($result->getId()); + } + + public function testDocumentSecurityAllowsDeleteWithDocPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::read(Role::any()), + ], documentSecurity: true); + + $existingDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'test_col', + '$permissions' => [ + Permission::delete(Role::user('deleter')), + ], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + + $collectionId = $collection->getId(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id, array $queries = [], bool $forUpdate = false) use ($collection, $collectionId, $existingDoc) { + if ($col->getId() === Database::METADATA && $id === $collectionId) { + return $collection; + } + if ($id === 'doc1') { + return $existingDoc; + } + + return new Document(); + } + ); + + $this->adapter->method('deleteDocument')->willReturn(true); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:deleter'); + + $result = $this->database->deleteDocument('test_col', 'doc1'); + $this->assertTrue($result); + } + + public function testFindWithDocumentSecurityAndNoCollectionPermission(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + ], documentSecurity: true); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('find')->willReturn([ + new Document([ + '$id' => 'doc1', + '$permissions' => [Permission::read(Role::user('viewer'))], + ]), + ]); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:viewer'); + + $results = $this->database->find('test_col'); + $this->assertCount(1, $results); + } + + public function testFindWithDocumentSecurityThrowsWithNoPermissionAtAll(): void + { + $collection = $this->buildCollectionDoc('test_col', [], documentSecurity: false); + + $this->configureAdapterForCollection($collection); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:nobody'); + + $this->expectException(AuthorizationException::class); + + $this->database->find('test_col'); + } + + public function testCountWithDocumentSecurityDoesNotThrow(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::any()), + ], documentSecurity: true); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('count')->willReturn(3); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:viewer'); + + $result = $this->database->count('test_col'); + $this->assertEquals(3, $result); + } + + public function testCreateDocumentWithUsersRole(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::users()), + Permission::read(Role::any()), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('users'); + + $result = $this->database->createDocument('test_col', new Document([ + '$id' => 'doc1', + '$permissions' => [], + ])); + + $this->assertEquals('doc1', $result->getId()); + } + + public function testCreateDocumentWithTeamRole(): void + { + $collection = $this->buildCollectionDoc('test_col', [ + Permission::create(Role::team('abc', 'admin')), + Permission::read(Role::any()), + ]); + + $this->configureAdapterForCollection($collection); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('team:abc/admin'); + + $result = $this->database->createDocument('test_col', new Document([ + '$id' => 'doc1', + '$permissions' => [], + ])); + + $this->assertEquals('doc1', $result->getId()); + } +} diff --git a/tests/unit/BigIntegerSpellingTest.php b/tests/unit/BigIntegerSpellingTest.php new file mode 100644 index 0000000000..910be6c35d --- /dev/null +++ b/tests/unit/BigIntegerSpellingTest.php @@ -0,0 +1,359 @@ +value; + + $this->assertSame($expected, Attribute::storedType($type), $type->name); + $this->assertSame($type, Attribute::typeFromStored(Attribute::storedType($type)), $type->name); + } + } + + public function testBothSpellingsNormalizeToBigInteger(): void + { + foreach ([self::PERSISTED, ColumnType::BigInteger->value] as $spelling) { + $this->assertSame(ColumnType::BigInteger, Attribute::typeFromStored($spelling), $spelling); + } + + $this->expectException(StructureException::class); + Attribute::typeFromStored('huge'); + } + + public function testAttributeModelsHoldThePersistedSpelling(): void + { + $attribute = Attribute::bigInteger(key: 'total'); + + $this->assertSame(ColumnType::BigInteger, $attribute->type); + $this->assertSame(self::PERSISTED, $attribute->toDocument()->getAttribute('type')); + $this->assertSame(self::PERSISTED, Attribute::fromArray([ + '$id' => 'total', + 'type' => ColumnType::BigInteger->value, + ])->toDocument()->getAttribute('type')); + $this->assertSame(ColumnType::BigInteger, Attribute::fromDocument(new Document([ + '$id' => 'total', + 'type' => self::PERSISTED, + ]))->type); + + $changed = Attribute::integer(key: 'count')->apply(new AttributeUpdate(type: ColumnType::BigInteger)); + $this->assertSame(ColumnType::BigInteger, $changed->type); + $this->assertSame(self::PERSISTED, $changed->toDocument()->getAttribute('type')); + + $changed = $changed->apply(new AttributeUpdate(type: ColumnType::Integer)); + $this->assertSame(ColumnType::Integer, $changed->type); + $this->assertSame(ColumnType::Integer->value, $changed->toDocument()->getAttribute('type')); + + $this->expectException(StructureException::class); + Attribute::fromArray(['$id' => 'count', 'type' => 'huge']); + } + + /** + * @return iterable + */ + public static function adapters(): iterable + { + yield 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))]; + yield 'memory' => [static fn (): Adapter => new Memory()]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testNewBigIntegerAttributesPersistTheBigintSpelling(Closure $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create( + id: 'ledger', + attributes: [Attribute::bigInteger(key: 'inline')], + permissions: $this->permissions(), + )); + $database->createAttribute('ledger', Attribute::bigInteger(key: 'single')); + $database->createAttributes('ledger', [Attribute::bigInteger(key: 'batch')]); + $database->createAttribute('ledger', Attribute::integer(key: 'widened')); + $database->updateAttribute('ledger', 'widened', new AttributeUpdate(type: ColumnType::BigInteger)); + $database->updateAttribute('ledger', 'single', new AttributeUpdate(required: true)); + + $this->assertSame([ + 'inline' => self::PERSISTED, + 'single' => self::PERSISTED, + 'batch' => self::PERSISTED, + 'widened' => self::PERSISTED, + ], $this->storedTypes($database, 'ledger')); + + foreach ($database->getCollection('ledger')->attributes() as $attribute) { + $this->assertSame(ColumnType::BigInteger, $attribute->type, $attribute->key); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testStoredBigIntegerSpellingIsWrittenBackAsBigint(Closure $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create( + id: 'ledger', + attributes: [ + Attribute::bigInteger(key: 'total'), + Attribute::bigInteger(key: 'untouched'), + ], + permissions: $this->permissions(), + )); + $this->storeType($database, 'ledger', 'total', ColumnType::BigInteger->value); + $this->storeType($database, 'ledger', 'untouched', ColumnType::BigInteger->value); + + foreach ($database->getCollection('ledger')->attributes() as $attribute) { + $this->assertSame(ColumnType::BigInteger, $attribute->type, $attribute->key); + } + + $database->updateAttribute('ledger', 'total', new AttributeUpdate(required: true)); + + $this->assertSame([ + 'total' => self::PERSISTED, + 'untouched' => self::PERSISTED, + ], $this->storedTypes($database, 'ledger')); + } + + /** + * @return iterable + */ + public static function storedSpellings(): iterable + { + foreach (self::adapters() as $name => [$adapter]) { + yield $name.' '.self::PERSISTED => [$adapter, self::PERSISTED]; + yield $name.' '.ColumnType::BigInteger->value => [$adapter, ColumnType::BigInteger->value]; + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('storedSpellings')] + public function testBothStoredSpellingsBehaveIdentically(Closure $adapter, string $spelling): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create( + id: 'ledger', + attributes: [Attribute::bigInteger(key: 'total')], + permissions: $this->permissions(), + )); + $this->storeType($database, 'ledger', 'total', $spelling); + + $database->createIndex('ledger', Index::key(key: 'totals', attributes: ['total'])); + + $created = $database->createDocument('ledger', new Document([ + '$id' => 'balance', + '$permissions' => $this->permissions(), + 'total' => (string) (PHP_INT_MAX - 1), + ])); + $this->assertSame(PHP_INT_MAX - 1, $created->getAttribute('total')); + $this->assertSame(PHP_INT_MAX - 1, $database->getDocument('ledger', 'balance')->getAttribute('total')); + + $increased = $database->increaseDocumentAttribute('ledger', 'balance', 'total'); + $this->assertSame(PHP_INT_MAX, $increased->getAttribute('total')); + $this->assertCount(1, $database->find('ledger', [Query::equal('total', [PHP_INT_MAX])])); + + try { + $database->createDocument('ledger', new Document([ + '$id' => 'overflow', + '$permissions' => $this->permissions(), + 'total' => '9223372036854775808', + ])); + $this->fail('A signed bigint above PHP_INT_MAX must be rejected'); + } catch (StructureException $exception) { + $this->assertStringContainsString('total', $exception->getMessage()); + } + + $sqlite = $database->getAdapter(); + if (! $sqlite instanceof SQLite) { + return; + } + + $stored = $database->getCollection('ledger')->attributes()[0]; + $this->assertSame('BIGINT', $sqlite->getColumnType($stored)); + + $columnTypes = []; + foreach ($database->getSchemaAttributes('ledger') as $column) { + $columnTypes[$column->name] = $column->type; + } + $this->assertSame($sqlite->getColumnType($stored), $columnTypes['total'] ?? null); + } + + public function testRedisSchemaRecordsPersistTheBigintSpelling(): void + { + /** @var array> $hashes */ + $hashes = []; + $client = self::createStub(Redis::class); + $client->method('exists')->willReturnCallback( + function (mixed $key) use (&$hashes): int { + return \is_string($key) && isset($hashes[$key]) ? 1 : 0; + } + ); + $client->method('hMSet')->willReturnCallback( + function (string $key, array $fields) use (&$hashes): bool { + $hashes[$key] = [...($hashes[$key] ?? []), ...$fields]; + + return true; + } + ); + $client->method('hSet')->willReturnCallback( + function (string $key, mixed ...$fields) use (&$hashes): int { + $field = $fields[0] ?? null; + if (\is_string($field)) { + $hashes[$key][$field] = $fields[1] ?? null; + } + + return 1; + } + ); + $client->method('hGet')->willReturnCallback( + function (string $key, string $field) use (&$hashes): mixed { + return $hashes[$key][$field] ?? false; + } + ); + + $adapter = new RedisAdapter($client); + $adapter->createCollection('ledger', [Attribute::bigInteger(key: 'inline')]); + $adapter->createAttribute('ledger', Attribute::bigInteger(key: 'single')); + $adapter->updateAttribute('ledger', 'inline', Attribute::bigInteger(key: 'inline', required: true)); + + $records = []; + foreach ($hashes as $fields) { + foreach (['attrs', 'schema'] as $field) { + $encoded = $fields[$field] ?? null; + if (! \is_string($encoded)) { + continue; + } + /** @var array $decoded */ + $decoded = \json_decode($encoded, true, flags: JSON_THROW_ON_ERROR); + $attributes = $field === 'schema' ? ($decoded['attributes'] ?? []) : $decoded; + $this->assertIsArray($attributes); + foreach ($attributes as $attribute) { + $this->assertIsArray($attribute); + $key = $attribute['key'] ?? null; + $this->assertIsString($key); + $records[$field.':'.$key] = $attribute['type'] ?? null; + } + } + } + + $this->assertSame([ + 'attrs:inline' => self::PERSISTED, + 'attrs:single' => self::PERSISTED, + 'schema:inline' => self::PERSISTED, + ], $records); + } + + /** + * @param Closure(): Adapter $adapter + */ + private function database(Closure $adapter): Database + { + $database = new Database($adapter(), new Cache(new None())); + $database + ->setDatabase('bigint_spelling') + ->setNamespace('bigint_spelling_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->create(); + + return $database; + } + + /** + * @return list + */ + private function permissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ]; + } + + /** + * @return array + */ + private function storedTypes(Database $database, string $collection): array + { + $types = []; + foreach ($this->storedAttributes($database, $collection) as $attribute) { + $key = $attribute['key'] ?? null; + $this->assertIsString($key); + $types[$key] = $attribute['type'] ?? null; + } + + return $types; + } + + private function storeType(Database $database, string $collection, string $key, string $type): void + { + $attributes = $this->storedAttributes($database, $collection); + foreach ($attributes as $index => $attribute) { + if (($attribute['key'] ?? null) === $key) { + $attributes[$index]['type'] = $type; + } + } + + $database->skipFilters(fn (): Document => $database->getAuthorization()->skip( + fn (): Document => $database->updateDocument(Database::METADATA, $collection, new Document([ + 'attributes' => \json_encode($attributes, JSON_THROW_ON_ERROR), + ])), + )); + $this->assertSame($type, $this->storedTypes($database, $collection)[$key] ?? null); + } + + /** + * @return list> + */ + private function storedAttributes(Database $database, string $collection): array + { + $stored = $database->skipFilters(fn (): Document => $database->getAuthorization()->skip( + fn (): Document => $database->getDocument(Database::METADATA, $collection), + )); + $attributes = $stored->getAttribute('attributes'); + $this->assertIsString($attributes, 'With filters skipped the stored JSON comes back as written'); + + /** @var list> $decoded */ + $decoded = \json_decode($attributes, true, flags: JSON_THROW_ON_ERROR); + + return $decoded; + } +} diff --git a/tests/unit/BigIntegerTest.php b/tests/unit/BigIntegerTest.php new file mode 100644 index 0000000000..aa91a70a27 --- /dev/null +++ b/tests/unit/BigIntegerTest.php @@ -0,0 +1,392 @@ +bounds(); + + $this->assertNotNull($bounds); + $this->assertSame('18446744073709551615', $bounds->max); + $this->assertSame('9223372036854775808', BigInt::add(PHP_INT_MAX, 1)); + $this->assertSame(PHP_INT_MAX, BigInt::subtract('9223372036854775808', 1)); + $this->assertSame('18446744073709551615', BigInt::add('18446744073709551614', 1)); + $this->assertSame('18446744073709551616', BigInt::add(BigInt::UNSIGNED_MAX, 1)); + $this->assertSame('18446744073709551614', BigInt::subtract(BigInt::UNSIGNED_MAX, 1)); + $this->assertSame('18446744073709551614', BigInt::multiply('9223372036854775807', 2)); + $this->assertSame(1, BigInt::modulo(BigInt::UNSIGNED_MAX, 2)); + } + + public function testSqlColumnTypesMapBigIntegerAndLegacyMetadata(): void + { + $mariaDB = new MariaDB(new \stdClass()); + $postgres = new Postgres(new \stdClass()); + + $legacy = Attribute::fromDocument(new Document([ + '$id' => 'total', + 'key' => 'total', + 'type' => 'bigint', + 'size' => 9999, + ])); + + $this->assertSame('BIGINT', $mariaDB->getColumnType(Attribute::bigInteger(key: 'total'))); + $this->assertSame('BIGINT', $mariaDB->getColumnType($legacy)); + $this->assertSame('BIGINT UNSIGNED', $mariaDB->getColumnType(Attribute::bigInteger(key: 'total', signed: false))); + $this->assertSame('BIGINT', $postgres->getColumnType(Attribute::bigInteger(key: 'total'))); + $this->assertSame('BIGINT', $postgres->getColumnType($legacy)); + } + + public function testCastingNormalizesLegacyBigIntegerWithoutPrecisionLoss(): void + { + /** @var Adapter&Stub $adapter */ + $adapter = self::createStub(Adapter::class); + $adapter->method('hasFeature')->willReturn(false); + $database = new Database($adapter, new Cache(new None())); + $collection = new Document([ + 'attributes' => [ + [ + '$id' => 'signed', + 'type' => 'bigint', + 'array' => false, + 'signed' => true, + ], + [ + '$id' => 'unsigned', + 'type' => ColumnType::BigInteger->value, + 'array' => false, + 'signed' => false, + ], + ], + ]); + $document = new Document([ + 'signed' => '9223372036854775807', + 'unsigned' => '18446744073709551615', + ]); + + $result = $database->casting($collection, $document); + + $this->assertSame(PHP_INT_MAX, $result->getAttribute('signed')); + $this->assertSame('18446744073709551615', $result->getAttribute('unsigned')); + } + + public function testSQLiteCreatesAndReadsBigIntegerColumn(): void + { + $adapter = new SQLite(new \PDO('sqlite::memory:')); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $collection = new Document([ + '$id' => 'bigints', + 'attributes' => [new Document([ + '$id' => 'value', + 'type' => ColumnType::BigInteger->value, + 'array' => false, + ])], + ]); + + $this->assertTrue($adapter->createCollection('bigints', [ + Attribute::bigInteger(key: 'value'), + ])); + $adapter->createDocument($collection, new Document([ + '$id' => 'maximum', + '$permissions' => [], + 'value' => PHP_INT_MAX, + ])); + + $stored = $adapter->getDocument($collection, 'maximum')->getAttribute('value'); + $this->assertTrue(\is_int($stored) || \is_string($stored)); + $this->assertSame((string) PHP_INT_MAX, (string) $stored); + } + + public function testMemoryOperatorPreservesUnsignedIntegerStrings(): void + { + $adapter = new class () extends Memory { + public function apply(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }; + + $this->assertSame( + '9223372036854775808', + $adapter->apply(PHP_INT_MAX, Operator::increment(1)), + ); + $this->assertSame( + '18446744073709551615', + $adapter->apply('18446744073709551614', Operator::increment(1)), + ); + $this->assertSame( + PHP_INT_MAX, + $adapter->apply('9223372036854775808', Operator::decrement(1)), + ); + } + + public function testMemoryAndRedisOperatorsLeaveValuesUnchangedWhenBoundsAreCrossed(): void + { + $memory = new class () extends Memory { + public function apply(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }; + $redis = new class (self::createStub(\Redis::class)) extends RedisAdapter { + public function apply(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }; + + $cases = [ + [10, Operator::increment(100, 50), 10], + [5.0, Operator::decrement(10, 0), 5.0], + [10, Operator::multiply(10, 75), 10], + [100.0, Operator::divide(-4, -10), 100.0], + [20.0, Operator::divide(-2, -50), -10.0], + [80.0, Operator::multiply(0.5, 50), 40.0], + [52.0, Operator::increment(-5, 50), 47.0], + [10, Operator::increment(5, 15), 15], + [10, Operator::decrement(5, 5), 5], + [-10.0, Operator::multiply(-2, 50), 20.0], + [5.0, Operator::power(3, 100), 5.0], + [100.0, Operator::power(0.5, 50), 10.0], + [-4.0, Operator::power(2, 20), 16.0], + [-2.0, Operator::power(3, 100), -8.0], + [0.0, Operator::power(-1, 100), 0.0], + [-4.0, Operator::power(0.5, 100), -4.0], + [PHP_INT_MAX, Operator::increment(2, PHP_INT_MAX), PHP_INT_MAX], + [PHP_INT_MAX, Operator::increment(1, BigInt::UNSIGNED_MAX), '9223372036854775808'], + ]; + + foreach ([$memory, $redis] as $adapter) { + foreach ($cases as [$current, $operator, $expected]) { + $this->assertSame($expected, $adapter->apply($current, $operator)); + } + } + } + + public function testMemoryAndRedisRejectUnboundedInvalidPowers(): void + { + $adapters = [ + new class () extends Memory { + public function apply(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }, + new class (self::createStub(\Redis::class)) extends RedisAdapter { + public function apply(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }, + ]; + + foreach ($adapters as $adapter) { + try { + $adapter->apply(0.0, Operator::power(-1)); + $this->fail('Expected invalid power to throw'); + } catch (LimitException $exception) { + $this->assertSame('Value out of range', $exception->getMessage()); + } + } + } + + public function testSqlBuilderPreservesUnsignedIntegerBindings(): void + { + $adapter = new class (new \stdClass()) extends MariaDB { + public function expression(Operator $operator): Expression + { + return $this->getOperatorBuilderExpression('value', $operator); + } + }; + + $result = $adapter->expression(Operator::increment(1, BigInt::UNSIGNED_MAX)); + + $this->assertStringContainsString('CASE', $result->sql); + $this->assertSame([BigInt::UNSIGNED_MAX, 1, 1], $result->bindings); + } + + public function testMongoRejectsUnsignedArithmeticBeforeBsonCoercion(): void + { + $adapter = new Mongo(new class () extends Client { + public function __construct() + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + }); + + $this->expectException(TypeException::class); + $this->expectExceptionMessage('outside the signed 64-bit integer range'); + $adapter->increaseDocumentAttribute(new Document(['$id' => 'collection']), 'document', 'value', BigInt::UNSIGNED_MAX, '2026-01-01T00:00:00.000+00:00'); + } + + public function testMemoryAndRedisHonourAFloatBoundOnTheExactBigIntegerPath(): void + { + $cases = [ + 'increment above a float max' => [PHP_INT_MAX - 5, Operator::increment(10, 9.0e18), PHP_INT_MAX - 5], + 'increment above a whole float max' => [PHP_INT_MAX, Operator::increment(1, 100.0), PHP_INT_MAX], + 'increment onto a float max' => [PHP_INT_MAX, Operator::increment(1, 9223372036854775808.0), '9223372036854775808'], + 'increment below a float max' => [PHP_INT_MAX, Operator::increment(1, 1.0e19), '9223372036854775808'], + 'increment below a whole string max' => [PHP_INT_MAX, Operator::increment(1, '9223372036854775808.0'), '9223372036854775808'], + 'decrement below a float min' => [PHP_INT_MIN, Operator::decrement(1, -9.0e18), PHP_INT_MIN], + 'decrement above a float min' => [PHP_INT_MIN, Operator::decrement(1, -1.0e19), '-9223372036854775809'], + 'multiply above a float max' => [PHP_INT_MAX, Operator::multiply(2, 1.5e19), PHP_INT_MAX], + 'unbounded increment' => [PHP_INT_MAX, Operator::increment(1), '9223372036854775808'], + ]; + + foreach (['memory' => self::memoryOperators(), 'redis' => self::redisOperators()] as $name => $apply) { + foreach ($cases as $case => [$current, $operator, $expected]) { + $this->assertSame($expected, $apply($current, $operator), "{$name}: {$case}"); + } + } + } + + public function testMemoryAndRedisRefuseAFractionalBoundOnTheExactBigIntegerPath(): void + { + $cases = [ + 'float max' => [Operator::increment(1, 102.4), 'Cannot apply increment operator: max/min limit must be a whole number, got 102.4'], + 'string max' => [Operator::increment(1, '9223372036854775808.5'), 'Cannot apply increment operator: max/min limit must be a whole number, got 9223372036854775808.5'], + 'float min' => [Operator::decrement(1, -0.5), 'Cannot apply decrement operator: max/min limit must be a whole number, got -0.5'], + ]; + + foreach (['memory' => self::memoryOperators(), 'redis' => self::redisOperators()] as $name => $apply) { + foreach ($cases as $case => [$operator, $message]) { + $current = $operator->getMethod() === OperatorType::Decrement ? PHP_INT_MIN : PHP_INT_MAX; + try { + $apply($current, $operator); + $this->fail("{$name}: {$case} must be refused"); + } catch (OperatorException $exception) { + $this->assertSame($message, $exception->getMessage(), "{$name}: {$case}"); + } + } + } + } + + public function testRedisOperatorPreservesUnsignedIntegerStrings(): void + { + $apply = self::redisOperators(); + + $this->assertSame('9223372036854775808', $apply(PHP_INT_MAX, Operator::increment(1))); + $this->assertSame('18446744073709551615', $apply('18446744073709551614', Operator::increment(1))); + $this->assertSame(PHP_INT_MAX, $apply('9223372036854775808', Operator::decrement(1))); + } + + public function testRedisKeepsTheStoredValueWhereAnOperatorCannotApply(): void + { + $apply = self::redisOperators(); + + $this->assertSame(10.0, $apply(10.0, Operator::power(400, 1000))); + $this->assertSame(10, $apply(10, new Operator(OperatorType::Divide, 'value', [0]))); + $this->assertSame(10, $apply(10, new Operator(OperatorType::Modulo, 'value', [0]))); + + try { + $apply(10.0, Operator::power(400)); + $this->fail('An unbounded power that overflows must throw'); + } catch (LimitException $exception) { + $this->assertSame('Value out of range', $exception->getMessage()); + } + } + + public function testMemoryKeepsTheStoredValueWhenABoundedPowerOverflows(): void + { + $apply = self::memoryOperators(); + + $this->assertSame(10.0, $apply(10.0, Operator::power(400, 1000))); + + try { + $apply(10.0, Operator::power(400)); + $this->fail('An unbounded power that overflows must throw'); + } catch (LimitException $exception) { + $this->assertSame('Value out of range', $exception->getMessage()); + } + } + + public function testMemoryOperatorsCoerceOperandsAndKeepUnparsableDates(): void + { + $apply = self::memoryOperators(); + + $this->assertSame(3, $apply(1, Operator::increment('2'))); + $this->assertSame(3.75, $apply(1.5, Operator::multiply('2.5'))); + $this->assertSame('ab', $apply('ab', Operator::stringConcat(['x']))); + $this->assertSame('y', $apply(['x'], Operator::stringConcat('y'))); + $this->assertSame('not-a-date', $apply('not-a-date', Operator::dateAddDays(1))); + $this->assertSame('not-a-date', $apply('not-a-date', Operator::dateSubDays(1))); + } + + public function testRedisKeepsAnUnparsableDate(): void + { + $apply = self::redisOperators(); + + $this->assertSame('not-a-date', $apply('not-a-date', Operator::dateAddDays(1))); + $this->assertSame('not-a-date', $apply('not-a-date', Operator::dateSubDays(1))); + } + + /** + * @return Closure(mixed, Operator): mixed + */ + private static function memoryOperators(): Closure + { + $adapter = new class () extends Memory { + public function apply(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }; + + return $adapter->apply(...); + } + + /** + * @return Closure(mixed, Operator): mixed + */ + private static function redisOperators(): Closure + { + $adapter = new class (self::createStub(\Redis::class)) extends RedisAdapter { + public function apply(mixed $current, Operator $operator): mixed + { + return $this->applyOperator($current, $operator); + } + }; + + return $adapter->apply(...); + } +} diff --git a/tests/unit/Builder/PostgresDistanceTest.php b/tests/unit/Builder/PostgresDistanceTest.php new file mode 100644 index 0000000000..bdcb4d4fed --- /dev/null +++ b/tests/unit/Builder/PostgresDistanceTest.php @@ -0,0 +1,77 @@ +compileFilters([self::typed(Query::distanceLessThan('location', [10, 20], 5), ColumnType::Point)]); + + $this->assertSame('(ST_DWithin("location", ST_GeomFromText(?, 4326), ?) AND ST_Distance("location", ST_GeomFromText(?, 4326)) < ?)', $condition->expression); + $this->assertSame(['POINT(10 20)', 5.0, 'POINT(10 20)', 5.0], $condition->bindings); + } + + public function testDistanceLessThanInMetersFromAPointLeadsWithADegreeBox(): void + { + $condition = (new Postgres())->compileFilters([self::typed(Query::distanceLessThan('location', [10, 20], 1000, true), ColumnType::Point)]); + + $this->assertSame('("location" && ST_Expand(ST_GeomFromText(?, 4326), ?, ?) AND ST_Distance(("location"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ?)', $condition->expression); + $this->assertCount(5, $condition->bindings); + $this->assertSame('POINT(10 20)', $condition->bindings[0]); + $this->assertSame('POINT(10 20)', $condition->bindings[3]); + $this->assertSame(1000.0, $condition->bindings[4]); + + $latitudeDegrees = $condition->bindings[2]; + $longitudeDegrees = $condition->bindings[1]; + $this->assertIsFloat($latitudeDegrees); + $this->assertIsFloat($longitudeDegrees); + $this->assertEqualsWithDelta(1000 / 110574, $latitudeDegrees, 1e-12); + $this->assertGreaterThan(1000 / 111319, $longitudeDegrees, 'Away from the equator a degree of longitude is shorter, so the box must be wider'); + } + + /** + * @return array + */ + public static function exactOnly(): array + { + return [ + 'a line column' => [self::typed(Query::distanceLessThan('location', [10, 20], 1000, true), ColumnType::Linestring)], + 'a polygon value' => [self::typed(Query::distanceLessThan('location', [[[0, 0], [0, 1], [1, 1], [0, 0]]], 1000, true), ColumnType::Point)], + 'a box reaching a pole' => [self::typed(Query::distanceLessThan('location', [10, 89.99], 5000, true), ColumnType::Point)], + 'a box reaching the antimeridian' => [self::typed(Query::distanceLessThan('location', [179.99, 0], 5000, true), ColumnType::Point)], + 'a distance that is not a number' => [self::typed(Query::distanceLessThan('location', [10, 20], NAN, true), ColumnType::Point)], + 'an infinite distance' => [self::typed(Query::distanceLessThan('location', [10, 20], INF, true), ColumnType::Point)], + 'a negatively infinite distance' => [self::typed(Query::distanceLessThan('location', [10, 20], -INF, true), ColumnType::Point)], + ]; + } + + #[DataProvider('exactOnly')] + public function testDistanceLessThanInMetersKeepsTheExactCheckOnlyWhenNoBoxHoldsTheRange(Query $query): void + { + $condition = (new Postgres())->compileFilters([$query]); + + $this->assertSame('ST_Distance(("location"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ?', $condition->expression); + $this->assertCount(2, $condition->bindings); + } + + public function testDistanceGreaterThanIsUnchanged(): void + { + $condition = (new Postgres())->compileFilters([self::typed(Query::distanceGreaterThan('location', [10, 20], 5), ColumnType::Point)]); + + $this->assertSame('ST_Distance("location", ST_GeomFromText(?, 4326)) > ?', $condition->expression); + } + + private static function typed(Query $query, ColumnType $type): Query + { + $query->setAttributeType($type->value); + + return $query; + } +} diff --git a/tests/unit/Builder/PostgresObjectPathTest.php b/tests/unit/Builder/PostgresObjectPathTest.php new file mode 100644 index 0000000000..5f87f327d2 --- /dev/null +++ b/tests/unit/Builder/PostgresObjectPathTest.php @@ -0,0 +1,63 @@ + + */ + public static function unsafePaths(): array + { + return [ + 'quote in the last key' => ["meta.a' IN ('x') OR secret='s2' OR 'x"], + 'operator in the last key' => ["meta.a'||(select 1)||'"], + 'comment in the last key' => ["meta.a' OR 1=1 --"], + 'quote in a middle key' => ["meta.a'b.c"], + 'empty key' => ['meta..a'], + ]; + } + + #[DataProvider('unsafePaths')] + public function testAFilterOnAnUnsafeObjectPathIsAQueryError(string $path): void + { + $this->expectException(QueryException::class); + + (new Postgres())->compileFilters([$this->objectFilter(Query::equal($path, ['x']))]); + } + + #[DataProvider('unsafePaths')] + public function testAStatementWithAnUnsafeObjectPathInsideOrIsAQueryError(string $path): void + { + $this->expectException(QueryException::class); + + (new Postgres()) + ->from('docs') + ->filter([Query::or([ + $this->objectFilter(Query::equal('meta.a', ['x'])), + $this->objectFilter(Query::startsWith($path, 'x')), + ])]) + ->build(); + } + + public function testAFilterOnAPlainObjectPathBindsItsValue(): void + { + $condition = (new Postgres())->compileFilters([$this->objectFilter(Query::equal('meta.user-info.home_city2', ['x']))]); + + $this->assertSame(['x'], $condition->bindings); + } + + private function objectFilter(Query $query): Query + { + $query->setAttributeType(ColumnType::Object->value); + + return $query; + } +} diff --git a/tests/unit/Builder/SearchTermTest.php b/tests/unit/Builder/SearchTermTest.php new file mode 100644 index 0000000000..3c6ab33f54 --- /dev/null +++ b/tests/unit/Builder/SearchTermTest.php @@ -0,0 +1,123 @@ + + */ + public static function postgreSQLTerms(): array + { + return [ + 'slash separates words' => ['foo/bar', 'foo or bar'], + 'comma separates words' => ['foo,bar', 'foo or bar'], + 'semicolon separates words' => ['foo;bar', 'foo or bar'], + 'percent separates words' => ['foo%bar', 'foo or bar'], + 'equals separates words' => ['foo=bar', 'foo or bar'], + 'question mark separates words' => ['foo?bar', 'foo or bar'], + 'hash separates words' => ['foo#bar', 'foo or bar'], + 'colon separates words' => ['foo:bar', 'foo or bar'], + 'pipe separates words' => ['foo|bar', 'foo or bar'], + 'ampersand separates words' => ['foo&bar', 'foo or bar'], + 'exclamation mark separates words' => ['foo!bar', 'foo or bar'], + 'underscore stays inside a word' => ['foo_bar', 'foo_bar'], + 'mixed separators and spaces' => ['baz, foo/bar; qux', 'baz or foo or bar or qux'], + 'operators are dropped' => ['+foo -bar* @3 ~qux (quux)', 'foo or bar or 3 or baz or qux or quux'], + 'accented words are kept' => ['@García!', 'García'], + 'exact phrase keeps its quotes' => ['"foo/bar baz"', '"foo bar baz"'], + 'unbalanced quote is not exact' => ['"foo/bar', 'foo or bar'], + ]; + } + + /** + * @return array + */ + public static function mySQLTerms(): array + { + return [ + 'slash separates words' => ['foo/bar', 'foo bar*'], + 'comma separates words' => ['foo,bar', 'foo bar*'], + 'period separates words' => ['foo.bar', 'foo bar*'], + 'apostrophe separates words' => ["foo'bar", 'foo bar*'], + 'trailing punctuation keeps the prefix match on the word' => ['foo.', 'foo*'], + 'underscore stays inside a word' => ['foo_bar', 'foo_bar*'], + 'mixed separators and spaces' => ['baz, foo/bar; qux', 'baz foo bar qux*'], + 'non-operator punctuation is dropped' => ['!!!foo...###', 'foo*'], + 'exact phrase keeps its quotes' => ['"foo/bar baz"', '"foo bar baz"'], + ]; + } + + /** + * @return array + */ + public static function termsWithoutWords(): array + { + return [ + 'separators only' => ['/,;%=?#'], + 'operators only' => ['+-*@<>~()'], + 'punctuation only' => ['!!!...###'], + 'quoted punctuation' => ['"/"'], + 'whitespace only' => [" \t "], + ]; + } + + #[DataProvider('postgreSQLTerms')] + public function testPostgreSQLSearchesEachWordOfATerm(string $term, string $bound): void + { + $this->assertSame([$bound], $this->bindings(new Postgres(), Query::search('title', $term))); + $this->assertSame([$bound], $this->bindings(new Postgres(), Query::notSearch('title', $term))); + } + + #[DataProvider('mySQLTerms')] + public function testMySQLSearchesEachWordOfATerm(string $term, string $bound): void + { + $this->assertSame([$bound], $this->bindings(new MySQL(), Query::search('title', $term))); + $this->assertSame([$bound], $this->bindings(new MySQL(), Query::notSearch('title', $term))); + } + + #[DataProvider('mySQLTerms')] + public function testMariaDBSearchesEachWordOfATerm(string $term, string $bound): void + { + $this->assertSame([$bound], $this->bindings(new MariaDB(), Query::search('title', $term))); + $this->assertSame([$bound], $this->bindings(new MariaDB(), Query::notSearch('title', $term))); + } + + #[DataProvider('termsWithoutWords')] + public function testATermWithoutWordsBindsNothing(string $term): void + { + foreach ([new Postgres(), new MySQL(), new MariaDB()] as $builder) { + $this->assertSame([], $this->bindings($builder, Query::search('title', $term)), $builder::class); + $this->assertSame([], $this->bindings($builder, Query::notSearch('title', $term)), $builder::class); + } + } + + public function testATermIsAlwaysBoundAndNeverWrittenIntoTheCondition(): void + { + $term = "foo'); DROP TABLE docs; --"; + + foreach ([new Postgres(), new MySQL(), new MariaDB()] as $builder) { + $condition = $builder->compileFilters([Query::search('title', $term)]); + + $this->assertStringNotContainsString('DROP', $condition->expression, $builder::class); + $this->assertStringNotContainsString('foo', $condition->expression, $builder::class); + $this->assertCount(1, $condition->bindings, $builder::class); + } + } + + /** + * @return array + */ + private function bindings(Filtering $builder, Query $query): array + { + return $builder->compileFilters([$query])->bindings; + } +} diff --git a/tests/unit/Cache/CountingCache.php b/tests/unit/Cache/CountingCache.php new file mode 100644 index 0000000000..e2884a5a0a --- /dev/null +++ b/tests/unit/Cache/CountingCache.php @@ -0,0 +1,129 @@ + */ + private array $loads = []; + + public function __construct( + private readonly CacheAdapter&Leasable $cache, + ) { + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $this->operations++; + $this->loads[$key] = ($this->loads[$key] ?? 0) + 1; + + return $this->cache->load($key, $ttl, $hash); + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + $this->operations++; + + return $this->cache->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + $this->operations++; + + return $this->cache->touch($key, $hash); + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + $this->operations++; + + return $this->cache->list($key); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + $this->operations++; + + return $this->cache->purge($key, $hash); + } + + #[\Override] + public function flush(): bool + { + $this->operations++; + + return $this->cache->flush(); + } + + #[\Override] + public function ping(): bool + { + $this->operations++; + + return $this->cache->ping(); + } + + #[\Override] + public function getSize(): int + { + $this->operations++; + + return $this->cache->getSize(); + } + + #[\Override] + public function getName(?string $key = null): string + { + return $this->cache->getName($key); + } + + #[\Override] + public function getGeneration(string $key): string + { + $this->operations++; + + return $this->cache->getGeneration($key); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + $this->operations++; + + return $this->cache->saveWithLease($key, $data, $hash, $generation); + } + + public function getOperations(): int + { + return $this->operations; + } + + /** + * @return array + */ + public function getLoads(): array + { + return $this->loads; + } + + public function resetOperations(): void + { + $this->operations = 0; + $this->loads = []; + } +} diff --git a/tests/unit/Cache/DatabaseTest.php b/tests/unit/Cache/DatabaseTest.php new file mode 100644 index 0000000000..530d7f6784 --- /dev/null +++ b/tests/unit/Cache/DatabaseTest.php @@ -0,0 +1,921 @@ +createDatabase(); + $this->createUsers($database); + + $ascending = $database->find('users', [ + Query::orderAsc('$id'), + Query::limit(1), + ]); + $two = $database->find('users', [ + Query::orderAsc('$id'), + Query::limit(2), + ]); + $offset = $database->find('users', [ + Query::orderAsc('$id'), + Query::limit(1), + Query::offset(1), + ]); + $descending = $database->find('users', [ + Query::orderDesc('$id'), + Query::limit(1), + ]); + + $cursorA = $database->getDocument('users', 'a'); + $cursorB = $database->getDocument('users', 'b'); + $afterA = $database->find('users', [ + Query::orderAsc('$id'), + Query::cursorAfter($cursorA), + Query::limit(1), + ]); + $afterB = $database->find('users', [ + Query::orderAsc('$id'), + Query::cursorAfter($cursorB), + Query::limit(1), + ]); + + $this->assertSame(['a'], $this->ids($ascending)); + $this->assertSame(['a', 'b'], $this->ids($two)); + $this->assertSame(['b'], $this->ids($offset)); + $this->assertSame(['c'], $this->ids($descending)); + $this->assertSame(['b'], $this->ids($afterA)); + $this->assertSame(['c'], $this->ids($afterB)); + } + + /** + * @return array + */ + public static function queryCacheFailures(): array + { + return [ + 'epoch or entry load' => ['load'], + 'lease' => ['getGeneration'], + 'fill' => ['saveWithLease'], + ]; + } + + #[DataProvider('queryCacheFailures')] + public function testFindFallsBackToTheDatabaseWhenTheQueryCacheFails(string $method): void + { + $cache = new class (new LeasableHashCache()) extends Cache { + public string $failing = ''; + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $this->assertAvailable('load'); + + return parent::load($key, $ttl, $hash); + } + + #[\Override] + public function getGeneration(string $key): string + { + $this->assertAvailable('getGeneration'); + + return parent::getGeneration($key); + } + + #[\Override] + public function saveWithLease(string $key, mixed $data, string $hash, string $generation): bool|string|array + { + $this->assertAvailable('saveWithLease'); + + return parent::saveWithLease($key, $data, $hash, $generation); + } + + private function assertAvailable(string $method): void + { + if ($this->failing === $method) { + throw new RuntimeException("Cache unavailable during {$method}"); + } + } + }; + $adapter = new ObservedMemory(); + [$database] = $this->createDatabase($adapter, queryCache: false); + $this->createUsers($database); + $database->setQueryCache(new ResultCache($cache)); + $cache->failing = $method; + $adapter->observeFinds('users'); + + $this->assertSame(['a', 'b', 'c'], $this->ids($database->find('users', [Query::orderAsc('$id')]))); + $this->assertSame(1, $adapter->getObservedFinds(), 'A query cache that fails must fall back to reading the database'); + } + + public function testRandomOrderAlwaysBypassesQueryCache(): void + { + $adapter = new ObservedMemory(); + [$database] = $this->createDatabase($adapter); + $this->createUsers($database); + + $adapter->observeFinds('users'); + $database->find('users', [Query::orderRandom(), Query::limit(1)]); + $database->find('users', [Query::orderRandom(), Query::limit(1)]); + + $this->assertSame(2, $adapter->getObservedFinds()); + } + + public function testSetQueryCacheInvalidatesWhileInstalledAndStopsOnceRemoved(): void + { + [$database, $queryAdapter] = $this->createDatabase(queryCache: false); + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + + $queryCache = new ResultCache(new Cache($queryAdapter)); + $database->setQueryCache($queryCache); + $database->setQueryCache($queryCache); + + $this->assertMutationRetiresQueries( + $database, + static fn (Database $database) => $database->createDocument('users', new Document(['$id' => 'a'])), + ); + + $database->setQueryCache(null); + $scope = $this->scope($database); + $before = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($before); + $this->assertTrue($queryCache->set($before, [new Document(['$id' => 'detached'])], $queryCache->getGeneration($before))); + + $database->createDocument('users', new Document(['$id' => 'b'])); + + $after = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($after); + $this->assertSame(['detached'], $this->ids($queryCache->get($after) ?? []), 'A removed query cache must no longer be invalidated by writes'); + } + + public function testOneQueryCacheSharedByDatabasesOfDifferentNamesKeepsTheirResultsApart(): void + { + $queryCache = new ResultCache(new Cache(new LeasableHashCache())); + $namespace = 'cache_'.\uniqid(); + $first = $this->createNamedDatabase('first', $namespace, $queryCache, 'a'); + $second = $this->createNamedDatabase('second', $namespace, $queryCache, 'b'); + + $this->assertSame(['a'], $this->ids($first->find('users'))); + $this->assertSame(['b'], $this->ids($second->find('users')), 'A database must not read what a database of another name cached in the shared query cache'); + + $first->createDocument('users', new Document(['$id' => 'c'])); + + $this->assertSame(['a', 'c'], $this->ids($first->find('users', [Query::orderAsc('$id')])), 'A write must retire what its own database cached'); + $this->assertSame(['b'], $this->ids($second->find('users'))); + + $second->createDocument('users', new Document(['$id' => 'd'])); + + $this->assertSame(['b', 'd'], $this->ids($second->find('users', [Query::orderAsc('$id')])), 'Setting the query cache on another database must not take the invalidation of this one'); + $this->assertSame(['a', 'c'], $this->ids($first->find('users', [Query::orderAsc('$id')]))); + } + + public function testReadsAndWritesUseTheCacheNameSetAfterTheQueryCache(): void + { + $queryCache = new ResultCache(new Cache(new LeasableHashCache())); + $namespace = 'cache_'.\uniqid(); + $named = $this->createNamedDatabase('x', $namespace, $queryCache, 'a'); + + $renamed = new Database(new DatabaseMemory(), new Cache(new LeasableHashCache())); + $renamed + ->setDatabase('cache-tests') + ->setNamespace($namespace) + ->setQueryCache($queryCache) + ->setCacheName('x'); + $renamed->create(); + $renamed->getAuthorization()->addRole(Role::any()->toString()); + $renamed->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + $renamed->createDocument('users', new Document(['$id' => 'b'])); + + $queries = [Query::orderAsc('$id')]; + $this->assertSame(['a'], $this->ids($named->find('users', $queries))); + $this->assertSame(['a'], $this->ids($renamed->find('users', $queries)), 'A read must use the cache name set after the query cache'); + + $renamed->createDocument('users', new Document(['$id' => 'c'])); + + $this->assertSame(['b', 'c'], $this->ids($renamed->find('users', $queries)), 'A write must retire what was cached under the cache name set after the query cache'); + } + + /** + * @return array + */ + public static function schemaAndCollectionMutations(): array + { + return [ + 'update collection' => [static fn (Database $database) => $database->updateCollection('users', new CollectionUpdate(permissions: self::permissions(), documentSecurity: false))], + 'create attribute' => [static fn (Database $database) => $database->createAttribute('users', Attribute::string(key: 'email'))], + 'update attribute' => [static fn (Database $database) => $database->updateAttribute('users', 'name', new AttributeUpdate(size: 128))], + 'delete attribute' => [static fn (Database $database) => $database->deleteAttribute('users', 'title')], + 'create index' => [static fn (Database $database) => $database->createIndex('users', Index::key(key: 'title', attributes: ['title']))], + 'rename index' => [static fn (Database $database) => $database->renameIndex('users', 'name', 'renamed')], + 'delete index' => [static fn (Database $database) => $database->deleteIndex('users', 'name')], + ]; + } + + /** + * @param callable(Database): mixed $mutation + */ + #[DataProvider('schemaAndCollectionMutations')] + public function testSchemaAndCollectionMutationsInvalidateQueries(callable $mutation): void + { + [$database] = $this->createDatabase(); + $database->createCollection(Collection::create( + id: 'users', + attributes: [ + Attribute::string(key: 'name'), + Attribute::string(key: 'title'), + ], + indexes: [Index::key(key: 'name', attributes: ['name'])], + permissions: self::permissions(), + documentSecurity: false, + )); + + $this->assertMutationRetiresQueries($database, $mutation); + } + + public function testDeleteAndRecreateCannotReuseOldCollectionResults(): void + { + [$database] = $this->createDatabase(); + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + $database->createDocument('users', new Document(['$id' => 'old'])); + $this->assertSame(['old'], $this->ids($database->find('users'))); + + $database->deleteCollection('users'); + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + $database->createDocument('users', new Document(['$id' => 'new'])); + + $this->assertSame(['new'], $this->ids($database->find('users'))); + } + + public function testRolledBackTransactionCannotPoisonQueryCache(): void + { + [$database] = $this->createDatabase(); + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + $database->createDocument('users', new Document(['$id' => 'committed'])); + $this->assertSame(['committed'], $this->ids($database->find('users'))); + + try { + $database->withTransaction(function () use ($database): void { + $database->createDocument('users', new Document(['$id' => 'rolled-back'])); + $this->assertSame( + ['committed', 'rolled-back'], + $this->ids($database->find('users', [Query::orderAsc('$id')])), + ); + + throw new Conflict('rollback'); + }); + } catch (Conflict) { + } + + $this->assertSame( + ['committed'], + $this->ids($database->find('users', [Query::orderAsc('$id')])), + ); + } + + public function testMetadataReadStartedBeforePurgeCannotPublish(): void + { + $adapter = new ObservedMemory(); + [$database] = $this->createDatabase($adapter, queryCache: false, dataAdapter: new None()); + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + $database->purgeCachedCollection('users'); + + $adapter->observeMetadata('users', fn () => $database->purgeCachedCollection('users')); + $database->getCollection('users'); + $database->getCollection('users'); + + $this->assertSame(2, $adapter->getObservedMetadataReads()); + } + + public function testValidatorBuildStartedBeforePurgeCannotPublish(): void + { + [$database] = $this->createDatabase(queryCache: false, observeValidators: true); + $this->assertInstanceOf(ObservedDatabase::class, $database); + $database->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name'), + ], permissions: self::permissions(), documentSecurity: false)); + $database->getCollection('users'); + + $byName = static fn (string $name): array => [Query::select(['name']), Query::equal('name', [$name])]; + + $database->observeValidators( + fn () => $database->createAttribute('users', Attribute::integer(key: 'age')), + ); + $database->find('users', $byName('first')); + $straddled = $database->getObservedValidators(); + + $database->find('users', $byName('second')); + $this->assertSame($straddled + 1, $database->getObservedValidators(), 'The build that straddled the purge must not be published'); + + $database->find('users', $byName('third')); + $this->assertSame($straddled + 1, $database->getObservedValidators(), 'The build after the purge must be published and reused'); + } + + public function testANarrowValidationStraddlingAPurgeKeepsNothing(): void + { + $adapter = new ObservedMemory(); + [$database] = $this->createDatabase($adapter, queryCache: false, observeValidators: true); + $this->assertInstanceOf(ObservedDatabase::class, $database); + $database->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name'), + ], permissions: self::permissions(), documentSecurity: false)); + + $adapter->observeMetadata('users', fn () => $database->createAttribute('users', Attribute::integer(key: 'age'))); + $this->assertSame([], $database->find('users', [Query::equal('name', ['first'])])); + $this->assertGreaterThan(0, $adapter->getObservedMetadataReads(), 'The purge must land while the first read holds the old schema'); + + $database->observeValidators(static fn (): null => null); + $this->assertSame([], $database->find('users', [Query::equal('age', [5]), Query::limit(5)]), 'A narrow list must see the schema the purge published'); + $this->assertSame([], $database->find('users', [Query::equal('name', ['second']), Query::orderAsc('age')])); + $this->assertSame(0, $database->getObservedValidators(), 'A narrow list builds no documents validator, so it has none to publish'); + $database->find('users', [Query::select(['name']), Query::equal('age', [5])]); + $this->assertSame(1, $database->getObservedValidators(), 'Any other list builds the documents validator of the current schema'); + + $database->deleteAttribute('users', 'age'); + try { + $database->find('users', [Query::equal('age', [5])]); + $this->fail('A narrow list accepted an attribute the schema no longer has'); + } catch (QueryException $exception) { + $this->assertSame('Invalid query: Attribute not found in schema: age', $exception->getMessage()); + } + } + + public function testMemoryCacheSeparatesRolesAndExecutionShapes(): void + { + [$database] = $this->createDatabase(queryCache: false); + $database->setQueryCache(new ResultCache(new Cache(new MemoryCache()))); + $database->getAuthorization()->skip(function () use ($database): void { + $database->createCollection(Collection::create(id: 'private', permissions: [ + Permission::create(Role::any()), + ])); + + foreach ([ + ['a', 'user-1'], + ['b', 'user-2'], + ['c', 'user-1'], + ] as [$id, $user]) { + $database->createDocument('private', new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::user($user))], + ])); + } + }); + + $authorization = $database->getAuthorization(); + $authorization->addRole(Role::user('user-1')->toString()); + $this->assertSame(['a'], $this->ids($database->find('private', [ + Query::orderAsc('$id'), + Query::limit(1), + ]))); + $this->assertSame(['c'], $this->ids($database->find('private', [ + Query::orderDesc('$id'), + Query::limit(1), + ]))); + $this->assertSame(['a', 'c'], $this->ids($database->find('private', [ + Query::orderAsc('$id'), + Query::limit(2), + ]))); + + $authorization->removeRole(Role::user('user-1')->toString()); + $authorization->addRole(Role::user('user-2')->toString()); + $this->assertSame(['b'], $this->ids($database->find('private', [ + Query::orderAsc('$id'), + Query::limit(2), + ]))); + } + + public function testMemoryCacheSeparatesPointSelectionVariants(): void + { + [$database] = $this->createDatabase(queryCache: false, dataAdapter: new MemoryCache()); + $database->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name'), + Attribute::string(key: 'email'), + ], permissions: self::permissions(), documentSecurity: false)); + $database->createDocument('users', new Document([ + '$id' => 'user', + 'name' => 'Alice', + 'email' => 'alice@example.com', + ])); + + $name = $database->getDocument('users', 'user', [Query::select(['name'])]); + $email = $database->getDocument('users', 'user', [Query::select(['email'])]); + + $this->assertSame('Alice', $name->getAttribute('name')); + $this->assertNull($name->getAttribute('email')); + $this->assertSame('alice@example.com', $email->getAttribute('email')); + $this->assertNull($email->getAttribute('name')); + } + + public function testPermissionRevocationIsFreshAcrossDatabaseInstances(): void + { + $adapter = new DatabaseMemory(); + $authorization = new Authorization(); + $authorization->cleanRoles(); + $authorization->addRole(Role::user('user-1')->toString()); + $writer = new Database($adapter, new Cache(new None())); + $reader = new Database($adapter, new Cache(new None())); + + foreach ([$writer, $reader] as $database) { + $database + ->setAuthorization($authorization) + ->setDatabase('cache-tests') + ->setNamespace('shared_metadata'); + } + + $writer->create(); + $writer->createCollection(Collection::create(id: 'users', permissions: [ + Permission::read(Role::user('user-1')), + Permission::create(Role::user('user-1')), + Permission::update(Role::user('user-1')), + ])); + $reader->getCollection('users'); + + $writer->updateCollection('users', new CollectionUpdate(permissions: [ + Permission::create(Role::user('user-1')), + Permission::update(Role::user('user-1')), + ], documentSecurity: false)); + + $this->expectException(AuthorizationException::class); + $reader->find('users'); + } + + public function testSilentPermissionRevocationStillInvalidatesQueryCache(): void + { + [$database] = $this->createDatabase(queryCache: false); + $database->setQueryCache(new ResultCache(new Cache(new MemoryCache()))); + $database->getAuthorization()->skip(function () use ($database): void { + $database->createCollection(Collection::create(id: 'private', permissions: [ + Permission::create(Role::any()), + ])); + $database->createDocument('private', new Document([ + '$id' => 'secret', + '$permissions' => [ + Permission::read(Role::user('user-1')), + Permission::update(Role::user('user-1')), + ], + ])); + }); + + $database->getAuthorization()->addRole(Role::user('user-1')->toString()); + $this->assertSame(['secret'], $this->ids($database->find('private'))); + + $database->getAuthorization()->skip(fn () => $database->silent( + fn () => $database->updateDocument('private', 'secret', new Document([ + '$permissions' => [Permission::read(Role::user('user-2'))], + ])), + )); + + $this->assertSame([], $database->find('private')); + } + + public function testJoinQueriesAlwaysBypassCacheAfterRelatedMutation(): void + { + $adapter = new JoinMemory(); + [$database] = $this->createDatabase($adapter); + $database->createCollection(Collection::create(id: 'parents', permissions: self::permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'children', permissions: self::permissions(), documentSecurity: false)); + + $queries = [Query::join('children', 'j0', [Query::on('$id', '$id')])]; + $database->find('parents', $queries); + $database->createDocument('children', new Document(['$id' => 'child'])); + $database->find('parents', $queries); + + $this->assertSame(2, $adapter->getJoinFinds()); + } + + public function testMandatoryInvalidationFailureEscapesSilentScope(): void + { + $cache = new FailingMemory(); + [$database] = $this->createDatabase(queryCache: false); + $database->setQueryCache(new ResultCache(new Cache($cache))); + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + $database->find('users'); + $cache->failBlocks(); + + $this->expectException(\RuntimeException::class); + $database->silent(fn () => $database->createDocument('users', new Document(['$id' => 'user']))); + } + + public function testCreateRollsBackWhenMandatoryInvalidationFails(): void + { + $this->assertMutationRollsBackOnInvalidationFailure( + static fn (Database $database) => $database->createDocument('users', new Document([ + '$id' => 'created', + 'name' => 'created', + ])), + ); + } + + public function testBatchCreateRollsBackWhenMandatoryInvalidationFails(): void + { + $this->assertMutationRollsBackOnInvalidationFailure( + static fn (Database $database) => $database->createDocuments('users', [ + new Document(['$id' => 'first', 'name' => 'first']), + new Document(['$id' => 'second', 'name' => 'second']), + ]), + ); + } + + public function testUpsertRollsBackWhenMandatoryInvalidationFails(): void + { + $this->assertMutationRollsBackOnInvalidationFailure( + static fn (Database $database) => $database->upsertDocument('users', new Document([ + '$id' => 'existing', + 'name' => 'updated', + ])), + sqlite: true, + ); + } + + public function testUpdateRollsBackWhenMandatoryInvalidationFails(): void + { + $this->assertMutationRollsBackOnInvalidationFailure( + static fn (Database $database) => $database->updateDocument( + 'users', + 'existing', + new Document(['name' => 'updated']), + ), + ); + } + + public function testDeleteRollsBackWhenMandatoryInvalidationFails(): void + { + $this->assertMutationRollsBackOnInvalidationFailure( + static fn (Database $database) => $database->deleteDocument('users', 'existing'), + ); + } + + public function testSharedBlockedEpochPreventsPreCommitStaleFill(): void + { + [$writer, $reader, $writerAdapter, $readerAdapter, , $path] = $this->createSharedSQLiteDatabases(); + + try { + $this->assertSame( + ['existing' => 'original'], + $this->names($reader->find('users', [Query::orderAsc('$id')])), + ); + $readerAdapter->observeFinds('users'); + + $duringCommit = []; + $writerAdapter->pauseNextCommit(function () use ($reader, &$duringCommit): void { + $duringCommit = $this->names($reader->find('users', [Query::orderAsc('$id')])); + }); + + $writer->updateDocument('users', 'existing', new Document(['name' => 'updated'])); + + $this->assertSame(['existing' => 'original'], $duringCommit); + $this->assertSame( + ['existing' => 'updated'], + $this->names($reader->find('users', [Query::orderAsc('$id')])), + ); + $this->assertSame(2, $readerAdapter->getObservedFinds()); + $this->assertSame( + ['existing' => 'updated'], + $this->names($reader->find('users', [Query::orderAsc('$id')])), + ); + $this->assertSame(2, $readerAdapter->getObservedFinds()); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testActivationFailureAfterCommitLeavesSharedEpochBlocked(): void + { + [$writer, $reader, , $readerAdapter, $cache, $path] = $this->createSharedSQLiteDatabases(); + + try { + $this->assertSame( + ['existing' => 'original'], + $this->names($reader->find('users', [Query::orderAsc('$id')])), + ); + $cache->failActivations(); + + try { + $writer->updateDocument('users', 'existing', new Document(['name' => 'updated'])); + $this->fail('Post-commit query-cache activation failure was not propagated'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('activate query cache', $exception->getMessage()); + } + + $readerAdapter->observeFinds('users'); + $this->assertSame( + ['existing' => 'updated'], + $this->names($reader->find('users', [Query::orderAsc('$id')])), + ); + $this->assertSame( + ['existing' => 'updated'], + $this->names($reader->find('users', [Query::orderAsc('$id')])), + ); + $this->assertSame(2, $readerAdapter->getObservedFinds()); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testPooledRollbackCannotPoisonPointOrQueryCaches(): void + { + $child = new DatabaseMemory(); + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($child), + ); + $database = new Database(new Pool($connections), new Cache(new LeasableHashCache())); + $database + ->setDatabase('cache-tests') + ->setNamespace('pooled_'.\uniqid()) + ->setQueryCache(new ResultCache(new Cache(new MemoryCache()))); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name'), + ], permissions: self::permissions(), documentSecurity: false)); + $database->createDocument('users', new Document([ + '$id' => 'user', + 'name' => 'committed', + ])); + $this->assertSame('committed', $database->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('committed', $database->find('users')[0]->getAttribute('name')); + + try { + $database->withTransaction(function () use ($database): void { + $database->updateDocument('users', 'user', new Document(['name' => 'rolled-back'])); + $this->assertTrue($database->getAdapter()->inTransaction()); + $this->assertSame('rolled-back', $database->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('rolled-back', $database->find('users')[0]->getAttribute('name')); + + throw new Conflict('rollback'); + }); + } catch (Conflict) { + } + + $this->assertSame('committed', $database->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('committed', $database->find('users')[0]->getAttribute('name')); + } + + /** + * @return array{Database, LeasableHashCache} + */ + private function createDatabase( + ?DatabaseMemory $adapter = null, + bool $queryCache = true, + ?CacheAdapter $dataAdapter = null, + bool $observeValidators = false, + ): array { + $dataAdapter ??= new LeasableHashCache(); + $queryAdapter = new LeasableHashCache(); + $database = $observeValidators + ? new ObservedDatabase($adapter ?? new DatabaseMemory(), new Cache($dataAdapter)) + : new Database($adapter ?? new DatabaseMemory(), new Cache($dataAdapter)); + $database + ->setDatabase('cache-tests') + ->setNamespace('cache_'.\uniqid()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + + if ($queryCache) { + $database->setQueryCache(new ResultCache(new Cache($queryAdapter))); + } + + return [$database, $queryAdapter]; + } + + private function createNamedDatabase(string $name, string $namespace, ResultCache $queryCache, string $document): Database + { + $database = new Database(new DatabaseMemory(), new Cache(new LeasableHashCache())); + $database + ->setDatabase('cache-tests') + ->setNamespace($namespace) + ->setCacheName($name) + ->setQueryCache($queryCache); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + $database->createDocument('users', new Document(['$id' => $document])); + + return $database; + } + + private function createUsers(Database $database): void + { + $database->createCollection(Collection::create(id: 'users', permissions: self::permissions(), documentSecurity: false)); + foreach (['a', 'b', 'c'] as $id) { + $database->createDocument('users', new Document(['$id' => $id])); + } + } + + /** + * @param callable(Database): mixed $mutation + */ + private function assertMutationRollsBackOnInvalidationFailure(callable $mutation, bool $sqlite = false): void + { + $adapter = $sqlite + ? new ObservedSQLite(new \PDO('sqlite::memory:')) + : new ObservedMemory(); + $database = new Database($adapter, new Cache(new LeasableHashCache())); + $database + ->setDatabase('cache-tests') + ->setNamespace('cache_'.\uniqid()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: self::permissions(), documentSecurity: false)); + $database->createDocument('users', new Document([ + '$id' => 'existing', + 'name' => 'original', + ])); + + $cache = new FailingMemory(); + $database->setQueryCache(new ResultCache(new Cache($cache))); + $this->assertSame( + ['existing' => 'original'], + $this->names($database->find('users', [Query::orderAsc('$id')])), + ); + + $cache->failBlocks(); + try { + $mutation($database); + $this->fail('Mandatory invalidation failure was not propagated'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('query cache epoch', $exception->getMessage()); + } + + $this->assertSame( + ['existing' => 'original'], + $this->names($database->find('users', [Query::orderAsc('$id')])), + 'A block that never landed must leave a cached result that still matches the rolled-back collection', + ); + + $database->setQueryCache(null); + $adapter->observeFinds('users'); + $this->assertSame( + ['existing' => 'original'], + $this->names($database->find('users', [Query::orderAsc('$id')])), + ); + $this->assertSame(1, $adapter->getObservedFinds(), 'The rollback must be confirmed by the database, not the query cache'); + } + + /** + * @return array{Database, Database, PausedSQLite, ObservedSQLite, LeasableHashCache, string} + */ + private function createSharedSQLiteDatabases(): array + { + $path = \tempnam(\sys_get_temp_dir(), 'database-query-cache-'); + if ($path === false) { + throw new \RuntimeException('Failed to create SQLite test database'); + } + + $attributes = [ + \PDO::ATTR_PERSISTENT => false, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + $writerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $readerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $writerConnection->exec('PRAGMA journal_mode = WAL'); + $writerConnection->exec('PRAGMA busy_timeout = 1000'); + $readerConnection->exec('PRAGMA busy_timeout = 1000'); + + $writerAdapter = new PausedSQLite($writerConnection); + $readerAdapter = new ObservedSQLite($readerConnection); + $writer = new Database($writerAdapter, new Cache(new None())); + $reader = new Database($readerAdapter, new Cache(new None())); + $namespace = 'shared_cache_'.\uniqid(); + foreach ([$writer, $reader] as $database) { + $database + ->setDatabase('cache-tests') + ->setNamespace($namespace); + } + + $writer->create(); + $writer->getAuthorization()->addRole(Role::any()->toString()); + $reader->getAuthorization()->addRole(Role::any()->toString()); + $writer->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: self::permissions(), documentSecurity: false)); + $writer->createDocument('users', new Document([ + '$id' => 'existing', + 'name' => 'original', + ])); + + $cache = new LeasableHashCache(); + $writer->setQueryCache(new ResultCache(new Cache($cache))); + $reader->setQueryCache(new ResultCache(new Cache($cache))); + + return [$writer, $reader, $writerAdapter, $readerAdapter, $cache, $path]; + } + + private function removeSQLiteFiles(string $path): void + { + foreach ([$path, $path.'-wal', $path.'-shm'] as $file) { + if (\is_file($file)) { + \unlink($file); + } + } + } + + private function scope(Database $database): Scope + { + $adapter = $database->getAdapter(); + + return new Scope( + hostname: $database->getHostname() ?? '', + database: $adapter->getDatabase(), + namespace: $adapter->getNamespace(), + tenant: $adapter->getTenant(), + name: $database->getCacheName(), + writerTimeout: $database->getCacheWriterTimeout(), + ); + } + + /** + * @param callable(Database): mixed $mutation + */ + private function assertMutationRetiresQueries(Database $database, callable $mutation): void + { + $queryCache = $database->getQueryCache(); + $this->assertNotNull($queryCache); + $scope = $this->scope($database); + $before = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($before); + $this->assertTrue($queryCache->set($before, [new Document(['$id' => 'stale'])], $queryCache->getGeneration($before))); + $this->assertSame(['stale'], $this->ids($queryCache->get($before) ?? [])); + + $mutation($database); + + $after = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($after, 'The mutation must leave the query cache usable'); + $this->assertNull($queryCache->get($after), 'The mutation must retire what was cached before it'); + $this->assertTrue($queryCache->set($after, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($after))); + $this->assertSame(['fresh'], $this->ids($queryCache->get($after) ?? []), 'The mutation must publish a fresh epoch'); + } + + /** @return list */ + private static function permissions(): array + { + return [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @param array $documents + * @return array + */ + private function ids(array $documents): array + { + return \array_map( + static fn (Document $document): string => $document->getId(), + $documents, + ); + } + + /** + * @param array $documents + * @return array + */ + private function names(array $documents): array + { + $names = []; + foreach ($documents as $document) { + $name = $document->getAttribute('name'); + if (! \is_string($name)) { + throw new \UnexpectedValueException('Expected document name to be a string'); + } + $names[$document->getId()] = $name; + } + + return $names; + } +} diff --git a/tests/unit/Cache/FailingMemory.php b/tests/unit/Cache/FailingMemory.php new file mode 100644 index 0000000000..7cbeb73951 --- /dev/null +++ b/tests/unit/Cache/FailingMemory.php @@ -0,0 +1,34 @@ +failing = true; + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ( + $this->failing + && \str_ends_with($key, '#epoch') + && \is_string($data) + && \str_starts_with($data, 'blocked:') + ) { + return false; + } + + return parent::save($key, $data, $hash); + } +} diff --git a/tests/unit/Cache/InvalidationCache.php b/tests/unit/Cache/InvalidationCache.php new file mode 100644 index 0000000000..c9d3e397db --- /dev/null +++ b/tests/unit/Cache/InvalidationCache.php @@ -0,0 +1,54 @@ + */ + private array $values = []; + + /** @var array */ + private array $generations = []; + + public function __construct() + { + parent::__construct(new Memory()); + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->values[$key] ?? false; + } + + /** + * @return string|array + */ + #[\Override] + public function save(string $key, mixed $data, string $hash = '', int $ttl = 0): string|array + { + if (\is_string($data)) { + $this->values[$key] = $data; + } + + return $data; + } + + #[\Override] + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + unset($this->values[$key]); + + return true; + } +} diff --git a/tests/unit/Cache/JoinMemory.php b/tests/unit/Cache/JoinMemory.php new file mode 100644 index 0000000000..625820a6ed --- /dev/null +++ b/tests/unit/Cache/JoinMemory.php @@ -0,0 +1,65 @@ +getId() === 'parents') { + $this->finds++; + } + + $queries = \array_values(\array_filter( + $queries, + static fn (Query $query): bool => ! \in_array($query->getMethod(), [ + \Utopia\Query\Method::Join, + \Utopia\Query\Method::LeftJoin, + \Utopia\Query\Method::RightJoin, + \Utopia\Query\Method::CrossJoin, + ], true), + )); + + return parent::find( + $collection, + $queries, + $limit, + $offset, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $forPermission, + ); + } + + public function getJoinFinds(): int + { + return $this->finds; + } +} diff --git a/tests/unit/Cache/LeasableHashCache.php b/tests/unit/Cache/LeasableHashCache.php new file mode 100644 index 0000000000..50bf5ebd16 --- /dev/null +++ b/tests/unit/Cache/LeasableHashCache.php @@ -0,0 +1,130 @@ +|string}>> */ + private array $store = []; + + /** @var array */ + private array $generations = []; + + private bool $failActivations = false; + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $hash = $hash === '' ? $key : $hash; + $saved = $this->store[$key][$hash] ?? null; + + return $saved !== null && $saved['time'] + $ttl > \time() ? $saved['data'] : false; + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ($key === '') { + return false; + } + + if ( + $this->failActivations + && \str_ends_with($key, '#epoch') + && \is_string($data) + && \str_starts_with($data, 'active:') + ) { + return false; + } + + $hash = $hash === '' ? $key : $hash; + $this->store[$key][$hash] = ['time' => \time(), 'data' => $data]; + + return $data; + } + + #[\Override] + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if ($this->getGeneration($key) !== $generation) { + return false; + } + + return $this->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + $hash = $hash === '' ? $key : $hash; + if (! isset($this->store[$key][$hash])) { + return false; + } + + $this->store[$key][$hash]['time'] = \time(); + + return true; + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + return \array_keys($this->store[$key] ?? []); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + + if ($hash === '') { + unset($this->store[$key]); + } else { + unset($this->store[$key][$hash]); + } + + return true; + } + + #[\Override] + public function flush(): bool + { + $this->store = []; + $this->generations = []; + + return true; + } + + #[\Override] + public function ping(): bool + { + return true; + } + + #[\Override] + public function getSize(): int + { + return \count($this->store); + } + + #[\Override] + public function getName(?string $key = null): string + { + return 'leasable-hash'; + } + + public function failActivations(): void + { + $this->failActivations = true; + } +} diff --git a/tests/unit/Cache/ObservedDatabase.php b/tests/unit/Cache/ObservedDatabase.php new file mode 100644 index 0000000000..e97168d67e --- /dev/null +++ b/tests/unit/Cache/ObservedDatabase.php @@ -0,0 +1,41 @@ +validatorCallback = $callback; + $this->validators = 0; + } + + public function getObservedValidators(): int + { + return $this->validators; + } + + #[\Override] + protected function createDocumentsValidator(Document $collection): DocumentsValidator + { + if ($this->validatorCallback !== null) { + $this->validators++; + $callback = $this->validatorCallback; + $this->validatorCallback = null; + $callback(); + } elseif ($this->validators > 0) { + $this->validators++; + } + + return parent::createDocumentsValidator($collection); + } +} diff --git a/tests/unit/Cache/ObservedMemory.php b/tests/unit/Cache/ObservedMemory.php new file mode 100644 index 0000000000..1cc9ccdcb8 --- /dev/null +++ b/tests/unit/Cache/ObservedMemory.php @@ -0,0 +1,107 @@ +metadataCollection = $collection; + $this->metadataCallback = $callback; + $this->metadataReads = 0; + } + + public function observeFinds(string $collection): void + { + $this->findCollection = $collection; + $this->finds = 0; + } + + public function pauseNextCommit(Closure $callback): void + { + $this->commitCallback = $callback; + } + + public function getObservedMetadataReads(): int + { + return $this->metadataReads; + } + + public function getObservedFinds(): int + { + return $this->finds; + } + + #[\Override] + public function commitTransaction(): bool + { + $callback = $this->commitCallback; + $this->commitCallback = null; + $callback?->__invoke(); + + return parent::commitTransaction(); + } + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $document = parent::getDocument($collection, $id, $queries, $forUpdate); + + if ($collection->getId() === Database::METADATA && $id === $this->metadataCollection) { + $this->metadataReads++; + $callback = $this->metadataCallback; + $this->metadataCallback = null; + $callback?->__invoke(); + } + + return $document; + } + + #[\Override] + public function find( + Document $collection, + array $queries = [], + ?int $limit = 25, + ?int $offset = null, + array $orderAttributes = [], + array $orderTypes = [], + array $cursor = [], + CursorDirection $cursorDirection = CursorDirection::After, + PermissionType $forPermission = PermissionType::Read, + ): array { + if ($collection->getId() === $this->findCollection) { + $this->finds++; + } + + return parent::find( + $collection, + $queries, + $limit, + $offset, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $forPermission, + ); + } +} diff --git a/tests/unit/Cache/ObservedSQLite.php b/tests/unit/Cache/ObservedSQLite.php new file mode 100644 index 0000000000..dfb7179716 --- /dev/null +++ b/tests/unit/Cache/ObservedSQLite.php @@ -0,0 +1,55 @@ +findCollection = $collection; + $this->finds = 0; + } + + public function getObservedFinds(): int + { + return $this->finds; + } + + #[\Override] + public function find( + Document $collection, + array $queries = [], + ?int $limit = 25, + ?int $offset = null, + array $orderAttributes = [], + array $orderTypes = [], + array $cursor = [], + CursorDirection $cursorDirection = CursorDirection::After, + PermissionType $forPermission = PermissionType::Read, + ): array { + if ($collection->getId() === $this->findCollection) { + $this->finds++; + } + + return parent::find( + $collection, + $queries, + $limit, + $offset, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $forPermission, + ); + } +} diff --git a/tests/unit/Cache/OwnersTest.php b/tests/unit/Cache/OwnersTest.php new file mode 100644 index 0000000000..87bc6be13d --- /dev/null +++ b/tests/unit/Cache/OwnersTest.php @@ -0,0 +1,103 @@ +lists++; + + return parent::list($key); + } + }; + $tokens = ['first', 'second', 'third']; + + foreach ($tokens as $token) { + $this->assertTrue((new Owners($cache))->register(self::KEY, $token)); + } + + foreach ($tokens as $token) { + $registration = (new Owners($cache))->find(self::KEY, $token); + + $this->assertSame([self::KEY.'#owners', $token], [$registration->key, $registration->field]); + $this->assertSame($token, $cache->load($registration->key, self::TTL, $registration->field)); + } + + $this->assertSame(1, $cache->lists, 'Only the first registration should list the owners hash'); + } + + public function testOwnersResolveOnACacheWithoutFields(): void + { + $cache = new class (new Memory()) extends Cache { + public int $lists = 0; + + /** @return string[] */ + #[\Override] + public function list(string $key): array + { + $this->lists++; + + return parent::list($key); + } + }; + + foreach (['first', 'second'] as $token) { + $this->assertTrue((new Owners($cache))->register(self::KEY, $token)); + + $registration = (new Owners($cache))->find(self::KEY, $token); + + $this->assertSame([self::KEY.'#owner:'.$token, ''], [$registration->key, $registration->field]); + $this->assertSame($token, $cache->load($registration->key, self::TTL, $registration->field)); + } + + $this->assertSame(4, $cache->lists, 'A cache that keeps no fields should be asked again on every registration'); + } + + public function testAFlushBeforeTheFirstListDoesNotPinPerTokenKeys(): void + { + $cache = new class (new RedisLeasableCache()) extends Cache { + private bool $flushed = false; + + /** @return string[] */ + #[\Override] + public function list(string $key): array + { + if (! $this->flushed) { + $this->flushed = true; + $this->flush(); + } + + return parent::list($key); + } + }; + + $this->assertTrue((new Owners($cache))->register(self::KEY, 'flushed')); + $flushed = (new Owners($cache))->find(self::KEY, 'flushed'); + $this->assertSame([self::KEY.'#owner:flushed', ''], [$flushed->key, $flushed->field]); + + $this->assertTrue((new Owners($cache))->register(self::KEY, 'kept')); + $kept = (new Owners($cache))->find(self::KEY, 'kept'); + $this->assertSame( + [self::KEY.'#owners', 'kept'], + [$kept->key, $kept->field], + 'A cache that listed no field once should still register later owners as fields', + ); + } +} diff --git a/tests/unit/Cache/OwnershipCache.php b/tests/unit/Cache/OwnershipCache.php new file mode 100644 index 0000000000..08e2e74d59 --- /dev/null +++ b/tests/unit/Cache/OwnershipCache.php @@ -0,0 +1,148 @@ +|string}> */ + private array $store = []; + + /** @var array */ + private array $generations = []; + + private ?Closure $activation = null; + + private bool $flushDuringActivation = false; + + private bool $failDuringActivation = false; + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $saved = $this->store[$key] ?? null; + + return $saved !== null && $saved['time'] + $ttl > \time() ? $saved['data'] : false; + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ($key === '') { + return false; + } + + if ( + $this->activation !== null + && \str_ends_with($key, '#epoch') + && \is_string($data) + && \str_starts_with($data, 'active:') + ) { + $activation = $this->activation; + $this->activation = null; + $activation(); + } + + $this->store[$key] = ['time' => \time(), 'data' => $data]; + + return $data; + } + + #[\Override] + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if ($this->getGeneration($key) !== $generation) { + return false; + } + + return $this->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + if (! isset($this->store[$key])) { + return false; + } + + $this->store[$key]['time'] = \time(); + + return true; + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + return []; + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->flushDuringActivation && \str_ends_with($key, '#finished')) { + $this->flushDuringActivation = false; + + return $this->flush(); + } + if ($this->failDuringActivation && \str_ends_with($key, '#finished')) { + return false; + } + + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + unset($this->store[$key]); + + return true; + } + + #[\Override] + public function flush(): bool + { + $this->store = []; + $this->generations = []; + + return true; + } + + #[\Override] + public function ping(): bool + { + return true; + } + + #[\Override] + public function getSize(): int + { + return \count($this->store); + } + + #[\Override] + public function getName(?string $key = null): string + { + return 'ownership'; + } + + public function pauseNextActivation(Closure $activation): void + { + $this->activation = $activation; + } + + public function flushDuringActivation(): void + { + $this->flushDuringActivation = true; + } + + public function failDuringActivation(): void + { + $this->failDuringActivation = true; + } +} diff --git a/tests/unit/Cache/PausedSQLite.php b/tests/unit/Cache/PausedSQLite.php new file mode 100644 index 0000000000..66a5da2870 --- /dev/null +++ b/tests/unit/Cache/PausedSQLite.php @@ -0,0 +1,26 @@ +commitCallback = $callback; + } + + #[\Override] + public function commitTransaction(): bool + { + $callback = $this->commitCallback; + $this->commitCallback = null; + $callback?->__invoke(); + + return parent::commitTransaction(); + } +} diff --git a/tests/unit/Cache/QueryMetadataTest.php b/tests/unit/Cache/QueryMetadataTest.php new file mode 100644 index 0000000000..d74a46e59e --- /dev/null +++ b/tests/unit/Cache/QueryMetadataTest.php @@ -0,0 +1,86 @@ +setDatabase('metadata') + ->setNamespace('metadata_'.\uniqid()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->setQueryCache(new Query(new Cache(new LeasableHashCache()))); + $database->createCollection(Collection::create(id: 'first', permissions: $this->permissions())); + + $this->assertSame(['first'], $this->listCollectionIds($database)); + + $database->createCollection(Collection::create(id: 'second', permissions: $this->permissions())); + $this->assertSame(['first', 'second'], $this->listCollectionIds($database)); + + $database->deleteCollection('first'); + $this->assertSame(['second'], $this->listCollectionIds($database)); + } + + public function testCollectionListingsFollowDefinitionsSharedWithEveryTenant(): void + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setDatabase('metadata') + ->setNamespace('metadata_'.\uniqid()) + ->setSharedTables(true) + ->setTenant(1); + $database->addHook(new Permissions()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->setQueryCache(new Query(new Cache(new LeasableHashCache()))); + $database->createCollection(Collection::create(id: 'owned', permissions: $this->permissions())); + + $this->assertSame(['owned'], $this->listCollectionIds($database)); + + $database->withTenant(null, fn (): Document => $database->createCollection(Collection::create(id: 'shared', permissions: $this->permissions()))); + + $this->assertSame( + ['owned', 'shared'], + $this->listCollectionIds($database), + 'A tenant-less definition is listed by every tenant, so no tenant\'s invalidation can refresh a cached listing', + ); + } + + /** @return list */ + private function permissions(): array + { + return [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]; + } + + /** @return array */ + private function listCollectionIds(Database $database): array + { + $ids = \array_map( + static fn (Document $collection): string => $collection->getId(), + $database->listCollections(), + ); + \sort($ids); + + return $ids; + } +} diff --git a/tests/unit/Cache/QueryPurgeTest.php b/tests/unit/Cache/QueryPurgeTest.php new file mode 100644 index 0000000000..99f373aba8 --- /dev/null +++ b/tests/unit/Cache/QueryPurgeTest.php @@ -0,0 +1,183 @@ +createDatabases('posts_'.\uniqid()); + + $this->assertSame(['first'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + $bypass->createDocument('posts', new Document(['$id' => 'second'])); + $this->assertSame( + ['first'], + $this->ids($reader->find('posts', [Query::orderAsc('$id')])), + 'A write the query cache never saw stays invisible until the cache is purged', + ); + + $this->assertTrue($reader->purgeCachedQueries('posts')); + + $this->assertSame(['first', 'second'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + } + + public function testPurgeCachedQueriesReachesTheNamespaceItNames(): void + { + $namespace = 'posts_'.\uniqid(); + $queryCache = new ResultCache(new Cache(new LeasableHashCache())); + [$reader, $bypass] = $this->createDatabases($namespace, $queryCache); + $caller = new Database(new DatabaseMemory(), new Cache(new LeasableHashCache())); + $caller + ->setDatabase('purge') + ->setNamespace('caller_'.\uniqid()) + ->setQueryCache($queryCache); + $caller->create(); + + $this->assertSame(['first'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + $bypass->createDocument('posts', new Document(['$id' => 'second'])); + + $this->assertTrue($caller->purgeCachedQueries('posts', $namespace)); + + $this->assertSame(['first', 'second'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + } + + public function testPurgeCachedQueriesReportsAQueryCacheItCouldNotPurge(): void + { + $queryCache = new FailingMemory(); + [$reader] = $this->createDatabases('posts_'.\uniqid(), new ResultCache(new Cache($queryCache))); + $queryCache->failBlocks(); + + $this->assertFalse($reader->purgeCachedQueries('posts')); + } + + public function testPurgeCachedCollectionInvalidatesCachedFinds(): void + { + [$reader, $bypass] = $this->createDatabases('posts_'.\uniqid()); + + $this->assertSame(['first'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + $bypass->createDocument('posts', new Document(['$id' => 'second'])); + + $reader->purgeCachedCollection('posts'); + + $this->assertSame( + ['first', 'second'], + $this->ids($reader->find('posts', [Query::orderAsc('$id')])), + 'Purging a collection must also drop the results find() cached for it', + ); + } + + public function testPurgeCachedQueriesReturnsFalseWhenTheCacheFails(): void + { + $cache = new class () extends MemoryCache { + public bool $failing = false; + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $this->assertAvailable($key); + + return parent::load($key, $ttl, $hash); + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + $this->assertAvailable($key); + + return parent::save($key, $data, $hash); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + $this->assertAvailable($key); + + return parent::purge($key, $hash); + } + + private function assertAvailable(string $key): void + { + if ($this->failing && \str_ends_with($key, ':query#epoch')) { + throw new RuntimeException('Cache unavailable'); + } + } + }; + $database = new Database(new DatabaseMemory(), new Cache($cache)); + $database + ->setDatabase('purge') + ->setNamespace('posts_'.\uniqid()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->createCollection(Collection::create(id: 'posts', permissions: [ + Permission::read(Role::any()), + ], documentSecurity: false)); + $cache->failing = true; + + $this->assertFalse($database->purgeCachedQueries('posts'), 'A cache that fails while the cached queries are purged must be reported, not thrown'); + } + + /** + * A reader with the query cache and a writer on the same data without it, + * like a migration or a worker that bypasses the reader's invalidation. + * + * @return array{Database, Database} + */ + private function createDatabases(string $namespace, ?ResultCache $queryCache = null): array + { + $adapter = new DatabaseMemory(); + $cache = new Cache(new LeasableHashCache()); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $databases = []; + foreach ([0, 1] as $ignored) { + $database = new Database($adapter, $cache); + $database + ->setAuthorization($authorization) + ->setDatabase('purge') + ->setNamespace($namespace); + $databases[] = $database; + } + [$reader, $bypass] = $databases; + + $reader->create(); + $reader->setQueryCache($queryCache ?? new ResultCache(new Cache(new LeasableHashCache()))); + $reader->createCollection(Collection::create(id: 'posts', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], documentSecurity: false)); + $reader->createDocument('posts', new Document(['$id' => 'first'])); + + return [$reader, $bypass]; + } + + /** + * @param array $documents + * @return array + */ + private function ids(array $documents): array + { + return \array_map( + static fn (Document $document): string => $document->getId(), + $documents, + ); + } +} diff --git a/tests/unit/Cache/QueryRoundTripTest.php b/tests/unit/Cache/QueryRoundTripTest.php new file mode 100644 index 0000000000..35802907fb --- /dev/null +++ b/tests/unit/Cache/QueryRoundTripTest.php @@ -0,0 +1,81 @@ +createDatabase(); + $database->find('posts', [Query::orderAsc('$id')]); + $adapter->observeFinds('posts'); + $cache->resetOperations(); + + $this->assertSame(['first'], $this->ids($database->find('posts', [Query::orderAsc('$id')]))); + + $this->assertSame(0, $adapter->getObservedFinds(), 'The second find must be served from the query cache'); + $this->assertLessThanOrEqual(self::HIT_BUDGET, $cache->getOperations(), 'A cached find must stay within its cache round-trip budget (7.3.12 had no query cache, so the budget is the current cost)'); + } + + public function testAnUncachedFindStaysWithinTheMissRoundTripBudget(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $adapter->observeFinds('posts'); + $cache->resetOperations(); + + $this->assertSame(['first'], $this->ids($database->find('posts', [Query::orderAsc('$id')]))); + + $this->assertSame(1, $adapter->getObservedFinds(), 'The first find after a write must read the database'); + $this->assertLessThanOrEqual(self::MISS_BUDGET, $cache->getOperations(), 'An uncached find must stay within its cache round-trip budget (7.3.12 had no query cache, so the budget is the current cost)'); + } + + /** + * @return array{Database, ObservedMemory, CountingCache} + */ + private function createDatabase(): array + { + $adapter = new ObservedMemory(); + $cache = new CountingCache(new LeasableHashCache()); + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('round_trips') + ->setNamespace('round_trips_'.\uniqid()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->setQueryCache(new ResultCache(new Cache($cache))); + $database->createCollection(Collection::create(id: 'posts', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], documentSecurity: false)); + $database->createDocument('posts', new Document(['$id' => 'first'])); + + return [$database, $adapter, $cache]; + } + + /** + * @param array $documents + * @return array + */ + private function ids(array $documents): array + { + return \array_map( + static fn (Document $document): string => $document->getId(), + $documents, + ); + } +} diff --git a/tests/unit/Cache/QueryScopeTest.php b/tests/unit/Cache/QueryScopeTest.php new file mode 100644 index 0000000000..a990ff20f2 --- /dev/null +++ b/tests/unit/Cache/QueryScopeTest.php @@ -0,0 +1,219 @@ + + */ + public static function scopes(): array + { + return [ + 'tenant' => [ + static fn (Database $database) => $database->setSharedTables(true)->setTenant(1), + static fn (Database $database) => $database->setSharedTables(true)->setTenant(2), + ], + 'namespace' => [ + static fn (Database $database) => $database->setNamespace('writer'), + static fn (Database $database) => $database->setNamespace('reader'), + ], + 'database' => [ + static fn (Database $database) => $database->setDatabase('writer'), + static fn (Database $database) => $database->setDatabase('reader'), + ], + ]; + } + + /** + * @param Closure(Database): Database $writerScope + * @param Closure(Database): Database $readerScope + */ + #[DataProvider('scopes')] + public function testAWriteLeavesTheCachedResultsOfAnotherScopeInPlace(Closure $writerScope, Closure $readerScope): void + { + $cache = new LeasableHashCache(); + [$writer] = $this->createDatabase($cache, $writerScope, 'writer-seed'); + [$reader, $readerAdapter] = $this->createDatabase($cache, $readerScope, 'reader-seed'); + + $this->assertSame(['reader-seed'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + $readerAdapter->observeFinds('posts'); + + $writer->createDocument('posts', new Document(['$id' => 'written'])); + + $this->assertSame(['reader-seed'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + $this->assertSame(0, $readerAdapter->getObservedFinds(), 'A write in another scope must not rotate the reader\'s query cache'); + } + + /** + * @param Closure(Database): Database $writerScope + * @param Closure(Database): Database $readerScope + */ + #[DataProvider('scopes')] + public function testAPendingWriteLeavesTheQueryCacheOfAnotherScopeOn(Closure $writerScope, Closure $readerScope): void + { + $cache = new LeasableHashCache(); + [$writer, $writerAdapter] = $this->createDatabase($cache, $writerScope, 'writer-seed'); + [$reader, $readerAdapter] = $this->createDatabase($cache, $readerScope, 'reader-seed'); + + $this->assertSame(['reader-seed'], $this->ids($reader->find('posts', [Query::orderAsc('$id')]))); + $readerAdapter->observeFinds('posts'); + + $duringWrite = []; + $writerAdapter->pauseNextCommit(function () use ($reader, &$duringWrite): void { + $duringWrite = $this->ids($reader->find('posts', [Query::orderAsc('$id')])); + }); + $writer->createDocument('posts', new Document(['$id' => 'written'])); + + $this->assertSame(['reader-seed'], $duringWrite); + $this->assertSame(0, $readerAdapter->getObservedFinds(), 'A write in progress in another scope must not switch the reader\'s query cache off'); + } + + public function testAWriteUnderAnotherTenantOfTheSameDatabaseLeavesTheCachedResultsInPlace(): void + { + [$database, $adapter] = $this->createTenants(); + + $this->assertSame(['seed-2'], $this->ids($database->withTenant(2, fn (): array => $database->find('posts')))); + $adapter->observeFinds('posts'); + + $database->withTenant(1, fn (): Document => $database->createDocument('posts', new Document(['$id' => 'written']))); + + $this->assertSame(['seed-2'], $this->ids($database->withTenant(2, fn (): array => $database->find('posts')))); + $this->assertSame(0, $adapter->getObservedFinds(), 'Tenant 1\'s write must leave tenant 2\'s cached result in place'); + } + + public function testAWriteRefreshesTheCachedResultsOfItsOwnTenant(): void + { + [$database, $adapter] = $this->createTenants(); + + $this->assertSame(['seed-1'], $this->ids($database->withTenant(1, fn (): array => $database->find('posts', [Query::orderAsc('$id')])))); + + $database->withTenant(1, fn (): Document => $database->createDocument('posts', new Document(['$id' => 'written']))); + + $adapter->observeFinds('posts'); + $this->assertSame(['seed-1', 'written'], $this->ids($database->withTenant(1, fn (): array => $database->find('posts', [Query::orderAsc('$id')])))); + $this->assertSame(['seed-1', 'written'], $this->ids($database->withTenant(1, fn (): array => $database->find('posts', [Query::orderAsc('$id')])))); + $this->assertSame(1, $adapter->getObservedFinds(), 'The refreshed result must be cached again under the tenant\'s new epoch'); + } + + public function testATransactionRefreshesEveryTenantItWroteUnder(): void + { + [$database, $adapter] = $this->createTenants(); + + foreach ([1, 2] as $tenant) { + $database->withTenant($tenant, fn (): array => $database->find('posts', [Query::orderAsc('$id')])); + } + + $database->withTransaction(function () use ($database): void { + foreach ([1, 2] as $tenant) { + $database->withTenant($tenant, fn (): Document => $database->createDocument('posts', new Document(['$id' => 'written-'.$tenant]))); + } + }); + + $adapter->observeFinds('posts'); + foreach ([1, 2] as $tenant) { + foreach ([0, 1] as $ignored) { + $this->assertSame( + ['seed-'.$tenant, 'written-'.$tenant], + $this->ids($database->withTenant($tenant, fn (): array => $database->find('posts', [Query::orderAsc('$id')]))), + ); + } + } + $this->assertSame(2, $adapter->getObservedFinds(), 'Each tenant must miss once and then be served from its re-enabled query cache'); + } + + public function testTenantsNeverReadEachOthersCachedResults(): void + { + [$database] = $this->createTenants(); + + foreach ([1, 2, 1, 2] as $tenant) { + $this->assertSame( + ['seed-'.$tenant], + $this->ids($database->withTenant($tenant, fn (): array => $database->find('posts', [Query::orderAsc('$id')]))), + ); + } + } + + /** + * @param Closure(Database): Database $scope + * @return array{Database, ObservedMemory} + */ + private function createDatabase(LeasableHashCache $cache, Closure $scope, string $seed): array + { + $adapter = new ObservedMemory(); + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('scope') + ->setNamespace('scope'); + $scope($database); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->setQueryCache(new ResultCache(new Cache($cache))); + $database->createCollection(Collection::create(id: 'posts', permissions: $this->permissions(), documentSecurity: false)); + $database->createDocument('posts', new Document(['$id' => $seed])); + + return [$database, $adapter]; + } + + /** + * @return array{Database, ObservedMemory} + */ + private function createTenants(): array + { + $adapter = new ObservedMemory(); + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('scope') + ->setNamespace('tenants_'.\uniqid()) + ->setSharedTables(true) + ->setTenant(1); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->setQueryCache(new ResultCache(new Cache(new LeasableHashCache()))); + + foreach ([1, 2] as $tenant) { + $database->withTenant($tenant, function () use ($database, $tenant): void { + $database->createCollection(Collection::create(id: 'posts', permissions: $this->permissions(), documentSecurity: false)); + $database->createDocument('posts', new Document(['$id' => 'seed-'.$tenant])); + }); + } + + return [$database, $adapter]; + } + + /** @return list */ + private function permissions(): array + { + return [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @param array $documents + * @return array + */ + private function ids(array $documents): array + { + return \array_map( + static fn (Document $document): string => $document->getId(), + $documents, + ); + } +} diff --git a/tests/unit/Cache/QueryTenantPerDocumentTest.php b/tests/unit/Cache/QueryTenantPerDocumentTest.php new file mode 100644 index 0000000000..a20bdf362b --- /dev/null +++ b/tests/unit/Cache/QueryTenantPerDocumentTest.php @@ -0,0 +1,337 @@ +adapter = new ObservedSQLite(new PDO('sqlite::memory:')); + $this->authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + + $this->database = (new Database($this->adapter, new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('query_cache_tenant_per_document') + ->setNamespace('query_cache_tenant_per_document') + ->setSharedTables(true) + ->setTenant(null) + ->setTenantPerDocument(true) + ->addHook(new Permissions()); + $this->database->create(); + $this->database->createCollection($this->notes()); + $this->database->setQueryCache(new ResultCache(new Cache(new LeasableHashCache()))); + + foreach ([self::TENANT, self::OTHER_TENANT, self::UNTOUCHED_TENANT] as $tenant) { + $this->database->createDocument(self::COLLECTION, $this->note($tenant, [self::ALICE, self::BOB])); + } + } + + public function testARevokingUpsertWithNoTenantSelectedRefreshesTheDocumentsTenant(): void + { + $this->cacheReads([self::TENANT]); + + $this->database->upsertDocuments(self::COLLECTION, [$this->note(self::TENANT, [self::ALICE], self::FINAL)]); + + $this->assertRevokedUpsertRefreshed(self::TENANT); + } + + public function testARevokingUpsertUnderAnotherTenantRefreshesTheDocumentsTenantOnly(): void + { + $this->cacheReads([self::TENANT, self::OTHER_TENANT]); + + $this->database->withTenant( + self::OTHER_TENANT, + fn (): int => $this->database->upsertDocuments(self::COLLECTION, [$this->note(self::TENANT, [self::ALICE], self::FINAL)]), + ); + + $this->assertRevokedUpsertRefreshed(self::TENANT); + $this->assertServedFromCache([self::OTHER_TENANT], 'The selected tenant holds none of the written documents, so its cached results must stay in place'); + } + + public function testACreateWithNoTenantSelectedRefreshesTheDocumentsTenant(): void + { + $this->cacheReads([self::TENANT]); + + $this->database->createDocument(self::COLLECTION, $this->note(self::TENANT, [self::ALICE, self::BOB], id: self::ADDED)); + + $this->assertAddedTo([self::TENANT]); + } + + public function testABatchCreateWithNoTenantSelectedRefreshesEveryDocumentsTenant(): void + { + $this->cacheReads([self::TENANT, self::OTHER_TENANT, self::UNTOUCHED_TENANT]); + + $this->database->createDocuments(self::COLLECTION, [ + $this->note(self::TENANT, [self::ALICE, self::BOB], id: self::ADDED), + $this->note(self::OTHER_TENANT, [self::ALICE, self::BOB], id: self::ADDED), + ]); + + $this->assertAddedTo([self::TENANT, self::OTHER_TENANT]); + $this->assertServedFromCache([self::UNTOUCHED_TENANT], 'The batch wrote nothing under tenant 7'); + } + + public function testAnUpsertBatchAcrossTenantsRefreshesEachTenant(): void + { + $this->cacheReads([self::TENANT, self::OTHER_TENANT]); + + $this->database->upsertDocuments(self::COLLECTION, [ + $this->note(self::TENANT, [self::ALICE], self::FINAL), + $this->note(self::OTHER_TENANT, [self::ALICE, self::BOB], self::FINAL), + ]); + + $this->assertRevokedUpsertRefreshed(self::TENANT); + $this->adapter->observeFinds(self::COLLECTION); + foreach ([self::ALICE, self::BOB] as $reader) { + $this->assertSame([self::DOCUMENT => self::FINAL], $this->titles($reader, self::OTHER_TENANT)); + } + $this->assertSame(2, $this->adapter->getObservedFinds(), 'Tenant 6\'s readers must reach the database after the batch retitled its note'); + } + + public function testAWriteLeavesTheCachedResultsOfATenantItDoesNotTouchInPlace(): void + { + $this->cacheReads([self::UNTOUCHED_TENANT]); + + $this->database->upsertDocuments(self::COLLECTION, [$this->note(self::TENANT, [self::ALICE], self::FINAL)]); + + $this->assertServedFromCache([self::UNTOUCHED_TENANT], 'An upsert of tenant 5\'s document must leave tenant 7\'s cached results in place'); + } + + public function testAWriteUnderTheDocumentsTenantRefreshesThatTenant(): void + { + $this->cacheReads([self::TENANT]); + + $this->database->withTenant( + self::TENANT, + fn (): int => $this->database->upsertDocuments(self::COLLECTION, [$this->note(self::TENANT, [self::ALICE], self::FINAL)]), + ); + + $this->assertRevokedUpsertRefreshed(self::TENANT); + } + + public function testWithoutTenantPerDocumentAWriteRefreshesTheSelectedTenant(): void + { + $adapter = new ObservedSQLite(new PDO('sqlite::memory:')); + $database = (new Database($adapter, new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('query_cache_selected_tenant') + ->setNamespace('query_cache_selected_tenant') + ->addHook(new Permissions()); + $database->create(); + $database->createCollection($this->notes()); + $database->setQueryCache(new ResultCache(new Cache(new LeasableHashCache()))); + $database->createDocument(self::COLLECTION, $this->note(self::TENANT, [self::ALICE, self::BOB])); + $read = fn (): array => \array_map( + static fn (Document $document): string => $document->getId(), + $this->authorization->skip(fn (): array => $database->find(self::COLLECTION, [Query::orderAsc('$id')])), + ); + $this->assertSame([self::DOCUMENT], $read()); + + $database->createDocuments(self::COLLECTION, [$this->note(self::TENANT, [self::ALICE, self::BOB], id: self::ADDED)]); + + $adapter->observeFinds(self::COLLECTION); + $this->assertSame([self::ADDED, self::DOCUMENT], $read(), 'A document\'s own tenant must not pick the scope when tables are not shared per document'); + $this->assertSame(1, $adapter->getObservedFinds()); + } + + public function testTokensKeyEachDocumentUnderTheTenantItIsStoredUnder(): void + { + $queryCache = new ResultCache(new InvalidationCache()); + $scope = new Scope('host', 'database', 'namespace', self::UNTOUCHED_TENANT); + $documents = [ + new Document(['$collection' => self::COLLECTION, '$tenant' => self::TENANT]), + new Document(['$collection' => self::COLLECTION, '$tenant' => (string) self::OTHER_TENANT]), + new Document(['$collection' => self::COLLECTION]), + ]; + + $keys = \array_keys((new Invalidator($queryCache))->tokens(Event::DocumentsCreate, $documents, $scope, tenantPerDocument: true)); + $expected = [ + $queryCache->getCollectionKey(new Scope('host', 'database', 'namespace', self::TENANT), self::COLLECTION), + $queryCache->getCollectionKey(new Scope('host', 'database', 'namespace', self::OTHER_TENANT), self::COLLECTION), + $queryCache->getCollectionKey($scope, self::COLLECTION), + ]; + \sort($keys); + \sort($expected); + + $this->assertSame($expected, $keys, 'Each document is keyed under its own tenant, and a document without one under the scope\'s'); + $this->assertSame( + [$queryCache->getCollectionKey($scope, self::COLLECTION)], + \array_keys((new Invalidator($queryCache))->tokens(Event::DocumentsCreate, $documents, $scope)), + 'Without tenant-per-document every document is keyed under the one scope', + ); + } + + public function testAWrittenDocumentsOwnOptionsNameNoCollectionToInvalidate(): void + { + $queryCache = new ResultCache(new InvalidationCache()); + $scope = new Scope(namespace: 'namespace', tenant: self::TENANT); + $document = new Document([ + '$collection' => self::COLLECTION, + 'options' => ['relatedCollection' => 'unrelated'], + ]); + + $tokens = (new Invalidator($queryCache))->tokens(Event::DocumentCreate, $document, $scope); + + $this->assertSame([$queryCache->getCollectionKey($scope, self::COLLECTION)], \array_keys($tokens)); + } + + private function notes(): Collection + { + return Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: true, + ); + } + + /** + * @param list $readers + */ + private function note(int $tenant, array $readers, string $title = self::DRAFT, string $id = self::DOCUMENT): Document + { + return new Document([ + '$id' => $id, + '$tenant' => $tenant, + 'title' => $title, + '$permissions' => \array_map( + static fn (string $reader): string => Permission::read(Role::user($reader)), + $readers, + ), + ]); + } + + /** + * Alice and bob read each tenant's notes once from the database and once more from the cache. + * + * @param list $tenants + */ + private function cacheReads(array $tenants): void + { + foreach ($tenants as $tenant) { + foreach ([self::ALICE, self::BOB] as $reader) { + $this->assertSame([self::DOCUMENT => self::DRAFT], $this->titles($reader, $tenant)); + } + } + + $this->assertServedFromCache($tenants, 'Every result must be cached before the write'); + } + + /** + * @param list $tenants + */ + private function assertServedFromCache(array $tenants, string $message): void + { + $this->adapter->observeFinds(self::COLLECTION); + foreach ($tenants as $tenant) { + foreach ([self::ALICE, self::BOB] as $reader) { + $this->assertSame([self::DOCUMENT => self::DRAFT], $this->titles($reader, $tenant), $message); + } + } + $this->assertSame(0, $this->adapter->getObservedFinds(), $message); + } + + private function assertRevokedUpsertRefreshed(int $tenant): void + { + $this->adapter->observeFinds(self::COLLECTION); + $this->assertSame([self::DOCUMENT => self::FINAL], $this->titles(self::ALICE, $tenant)); + $this->assertSame(1, $this->adapter->getObservedFinds(), 'Alice\'s read must reach the database after the upsert'); + + $this->assertNotContains(self::DRAFT, $this->titles(self::BOB, $tenant), 'Bob must not be served the cached title after the upsert'); + $this->assertSame(2, $this->adapter->getObservedFinds(), 'Bob\'s read must reach the database instead of the cache'); + } + + /** + * @param list $tenants + */ + private function assertAddedTo(array $tenants): void + { + $this->adapter->observeFinds(self::COLLECTION); + foreach ($tenants as $tenant) { + $this->assertSame( + [self::ADDED => self::DRAFT, self::DOCUMENT => self::DRAFT], + $this->titles(self::ALICE, $tenant), + "Tenant {$tenant}'s cached list must include the document written under it", + ); + } + $this->assertSame(\count($tenants), $this->adapter->getObservedFinds()); + } + + /** + * @return array Titles by document id + */ + private function titles(string $reader, int $tenant): array + { + $roles = $this->authorization->getRoles(); + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::user($reader)->toString()); + + try { + $titles = []; + foreach ($this->database->withTenant($tenant, fn (): array => $this->database->find(self::COLLECTION, [Query::orderAsc('$id')])) as $document) { + $titles[$document->getId()] = $document->getAttribute('title'); + } + + return $titles; + } finally { + $this->authorization->cleanRoles(); + foreach ($roles as $role) { + $this->authorization->addRole($role); + } + } + } +} diff --git a/tests/unit/Cache/QueryTest.php b/tests/unit/Cache/QueryTest.php new file mode 100644 index 0000000000..517df759f0 --- /dev/null +++ b/tests/unit/Cache/QueryTest.php @@ -0,0 +1,1113 @@ +cache = self::createCache(); + $this->queryCache = new ResultCache($this->cache); + } + + public function testConstructorWithDefaults(): void + { + $queryCache = new ResultCache(self::createCache()); + + $this->assertNotNull($queryCache->getEntry(new Scope(), 'any_collection', [])); + } + + public function testCacheNamesKeepTheirResultsApartOnTheSameCache(): void + { + $adapter = new RedisLeasableCache(); + $default = new ResultCache(new Cache($adapter)); + $custom = new ResultCache(new Cache($adapter)); + $entry = $custom->getEntry(new Scope(name: 'custom'), 'users', []); + $this->assertNotNull($entry); + $this->assertTrue($custom->set($entry, [new Document(['$id' => 'custom'])], $custom->getGeneration($entry))); + + $other = $default->getEntry(new Scope(), 'users', []); + $this->assertNotNull($other); + $this->assertNull($default->get($other), 'A query cache must not serve what a query cache of another name filled'); + + $default->invalidateCollection(new Scope(), 'users'); + + $after = $custom->getEntry(new Scope(name: 'custom'), 'users', []); + $this->assertNotNull($after); + $this->assertSame(['custom'], $this->ids($custom->get($after) ?? []), 'Invalidating one query cache must not retire what a query cache of another name filled'); + } + + public function testOneQueryCacheKeysEachCallByTheNameInItsScope(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $first = $queryCache->getEntry(new Scope(name: 'first'), 'users', []); + $this->assertNotNull($first); + $this->assertTrue($queryCache->set($first, [new Document(['$id' => 'first'])], $queryCache->getGeneration($first))); + + $second = $queryCache->getEntry(new Scope(name: 'second'), 'users', []); + $this->assertNotNull($second); + $this->assertNull($queryCache->get($second), 'A query cache must not serve the results of one name under another'); + + $queryCache->invalidateCollection(new Scope(name: 'second'), 'users'); + + $kept = $queryCache->getEntry(new Scope(name: 'first'), 'users', []); + $this->assertNotNull($kept); + $this->assertSame(['first'], $this->ids($queryCache->get($kept) ?? []), 'Invalidating under one name must not retire what another name filled'); + } + + public function testTheWriterTimeoutComesFromTheScopeOfEachCall(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $queryCache->blockCollection($queryCache->getCollectionKey(new Scope(), 'users'), $queryCache->createToken()); + + $this->assertNull($queryCache->getEntry(new Scope(), 'users', []), 'A write younger than the writer timeout is still in flight'); + $this->assertNotNull($queryCache->getEntry(new Scope(writerTimeout: 0), 'users', []), 'Under a zero writer timeout the write is abandoned at once'); + $this->assertNull($queryCache->getEntry(new Scope(), 'users', []), 'A call under a zero writer timeout must not change the timeout of later calls'); + } + + public function testSetRegionAndGetRegion(): void + { + $region = new Region(ttl: 600, enabled: false); + $this->queryCache->setRegion('users', $region); + + $this->assertSame($region, $this->queryCache->getRegion('users')); + } + + public function testGetRegionReturnsDefaultForUnknownCollection(): void + { + $region = $this->queryCache->getRegion('unknown'); + + $this->assertSame(3600, $region->ttl); + $this->assertTrue($region->enabled); + } + + public function testEntryKeysAreStable(): void + { + $queries = [Query::equal('status', ['active'])]; + $scope = new Scope(namespace: 'ns', tenant: 1); + + $first = $this->queryCache->getEntry($scope, 'users', $queries); + $second = $this->queryCache->getEntry($scope, 'users', $queries); + + $this->assertNotNull($first); + $this->assertNotNull($second); + $this->assertSame($first->key, $second->key); + } + + public function testCollectionKeysSeparateEveryScopeField(): void + { + $key = $this->queryCache->getCollectionKey(new Scope('host', 'database', 'namespace', 1), 'users'); + + foreach ([ + new Scope('other', 'database', 'namespace', 1), + new Scope('host', 'other', 'namespace', 1), + new Scope('host', 'database', 'other', 1), + new Scope('host', 'database', 'namespace', 2), + new Scope('host', 'database', 'namespace', null), + ] as $scope) { + $this->assertNotSame($key, $this->queryCache->getCollectionKey($scope, 'users')); + } + } + + public function testCollectionKeysPreserveTenantType(): void + { + $this->assertNotSame( + $this->queryCache->getCollectionKey(new Scope(tenant: 1), 'users'), + $this->queryCache->getCollectionKey(new Scope(tenant: '1'), 'users'), + ); + } + + public function testCollectionKeysSeparateCollectionsThatDifferOnlyInCase(): void + { + $this->assertNotSame( + \strtolower($this->queryCache->getCollectionKey(new Scope(), 'Users')), + \strtolower($this->queryCache->getCollectionKey(new Scope(), 'users')), + 'Cache keys are case-insensitive by default, so the collection must be part of the scope hash', + ); + } + + public function testDifferentQueriesProduceDifferentEntries(): void + { + $first = $this->queryCache->getEntry(new Scope(), 'users', [Query::equal('a', [1])]); + $second = $this->queryCache->getEntry(new Scope(), 'users', [Query::equal('b', [2])]); + + $this->assertNotNull($first); + $this->assertNotNull($second); + $this->assertNotSame($first->field, $second->field); + } + + public function testDifferentCollectionsProduceDifferentEntries(): void + { + $users = $this->queryCache->getEntry(new Scope(), 'users', []); + $posts = $this->queryCache->getEntry(new Scope(), 'posts', []); + + $this->assertNotNull($users); + $this->assertNotNull($posts); + $this->assertNotSame($users->key, $posts->key); + } + + public function testGetReturnsNullForCacheMiss(): void + { + $this->cache->method('load')->willReturn(false); + + $this->assertNull($this->queryCache->get(new Entry('some-key', 'users'))); + } + + public function testGetReturnsNullForNullData(): void + { + $this->cache->method('load')->willReturn(null); + + $this->assertNull($this->queryCache->get(new Entry('some-key', 'users'))); + } + + public function testFilledResultsAreServedByAnotherQueryCacheOnTheSameCache(): void + { + $adapter = new RedisLeasableCache(); + $writer = new ResultCache(new Cache($adapter)); + $reader = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $entry = $writer->getEntry($scope, 'users', [Query::limit(2)]); + $this->assertNotNull($entry); + + $this->assertTrue($writer->set($entry, [ + new Document(['$id' => 'doc1', 'name' => 'Alice']), + new Document(['$id' => 'doc2', 'name' => 'Bob']), + ], $writer->getGeneration($entry))); + + $served = $reader->getEntry($scope, 'users', [Query::limit(2)]); + $this->assertNotNull($served); + $result = $reader->get($served); + $this->assertNotNull($result); + $this->assertSame(['doc1', 'doc2'], $this->ids($result)); + $this->assertSame(['Alice', 'Bob'], \array_map( + static fn (Document $document): mixed => $document->getAttribute('name'), + $result, + )); + } + + public function testAFillThatStartedBeforeAnInvalidationIsRejected(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $scope = new Scope(namespace: 'ns'); + $entry = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($entry); + $generation = $queryCache->getGeneration($entry); + + $queryCache->invalidateCollection($scope, 'users'); + + $this->assertFalse($queryCache->set($entry, [new Document(['$id' => 'stale'])], $generation), 'A fill must not land once a write has started since its read'); + $fresh = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($fresh); + $this->assertNull($queryCache->get($fresh)); + } + + public function testGetHandlesDocumentObjectsInCache(): void + { + $document = new Document(['$id' => 'doc1', 'name' => 'Alice']); + $this->cache->method('load')->willReturn([ + 'version' => 2, + 'epoch' => '', + 'field' => '', + 'documents' => [$document], + ]); + + $result = $this->queryCache->get(new Entry('some-key', 'users')); + + $this->assertNotNull($result); + $this->assertCount(1, $result); + $this->assertSame($document, $result[0]); + } + + public function testGetPropagatesMalformedPayloadPurgeFailure(): void + { + $this->cache->method('load')->willReturn('not-an-array'); + + $this->expectException(\RuntimeException::class); + $this->queryCache->get(new Entry('some-key', 'users')); + } + + public function testEntriesExpireWithTheRegionButEpochsNeverDo(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $scope = new Scope(namespace: 'ns'); + $queryCache->setRegion('users', new Region(ttl: 0)); + $queryCache->invalidateCollection($scope, 'users'); + + $entry = $queryCache->getEntry($scope, 'users', [Query::limit(10)]); + $this->assertNotNull($entry, 'The epoch a write published must stay usable after the region TTL has passed'); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'filled'])], $queryCache->getGeneration($entry))); + $this->assertNull($queryCache->get($entry), 'A result older than the region TTL must miss'); + + $queryCache->setRegion('users', new Region(ttl: 60)); + + $this->assertSame(['filled'], $this->ids($queryCache->get($entry) ?? []), 'The same result is served within a longer region TTL, so the miss was its expiry'); + } + + /** + * @return iterable)}> + */ + public static function malformedPayloads(): iterable + { + yield 'a string' => [static fn (Entry $entry): string => 'not-an-array']; + yield 'an array of another shape' => [static fn (Entry $entry): array => ['foreign' => 'payload']]; + yield 'documents that are not documents' => [static fn (Entry $entry): array => [ + 'version' => 2, + 'epoch' => $entry->epoch, + 'field' => $entry->field, + 'documents' => ['invalid'], + ]]; + } + + /** + * @param \Closure(Entry): (string|array) $payload + */ + #[DataProvider('malformedPayloads')] + public function testAMalformedResultMissesAndIsReplacedByTheNextFill(\Closure $payload): void + { + $cache = new Cache(new RedisLeasableCache()); + $queryCache = new ResultCache($cache); + $entry = $queryCache->getEntry(new Scope(namespace: 'ns'), 'users', []); + $this->assertNotNull($entry); + $cache->save($entry->key, $payload($entry), $entry->slot); + + $this->assertNull($queryCache->get($entry), 'A result the query cache cannot read must be a miss, not an error'); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($entry))); + $this->assertSame(['fresh'], $this->ids($queryCache->get($entry) ?? [])); + } + + public function testInvalidateCollectionBlocksThenPublishesAFreshEpoch(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + + $this->assertRetired($queryCache, new Scope(), 'users', function () use ($queryCache): void { + $queryCache->invalidateCollection(new Scope(), 'users'); + }); + } + + public function testAnInvalidEpochIsAMiss(): void + { + $cache = new Cache(new Memory()); + $queryCache = new ResultCache($cache); + $scope = new Scope(namespace: 'ns'); + $cache->save($queryCache->getCollectionKey($scope, 'users').'#epoch', ['not' => 'an epoch']); + + $this->assertNull($queryCache->getEntry($scope, 'users', []), 'An epoch value the query cache did not write must disable the cache for that read, not fail it'); + } + + public function testEntriesResolveByDefault(): void + { + $this->assertNotNull($this->queryCache->getEntry(new Scope(), 'any', [])); + } + + public function testEntriesDoNotResolveWhenTheRegionIsDisabled(): void + { + $this->queryCache->setRegion('users', new Region(enabled: false)); + + $this->assertNull($this->queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testFlushDropsEveryCachedResult(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $scope = new Scope(namespace: 'ns'); + $users = $queryCache->getEntry($scope, 'users', []); + $posts = $queryCache->getEntry($scope, 'posts', []); + $this->assertNotNull($users); + $this->assertNotNull($posts); + $this->assertTrue($queryCache->set($users, [new Document(['$id' => 'user'])], $queryCache->getGeneration($users))); + $this->assertTrue($queryCache->set($posts, [new Document(['$id' => 'post'])], $queryCache->getGeneration($posts))); + + $queryCache->flush(); + + foreach (['users', 'posts'] as $collection) { + $entry = $queryCache->getEntry($scope, $collection, []); + $this->assertNotNull($entry, "A flush must leave '{$collection}' usable"); + $this->assertNull($queryCache->get($entry), "A flush must drop what '{$collection}' filled before it"); + } + } + + public function testRegionDefaults(): void + { + $region = new Region(); + + $this->assertSame(3600, $region->ttl); + $this->assertTrue($region->enabled); + } + + public function testRegionCustomValues(): void + { + $region = new Region(ttl: 120, enabled: false); + + $this->assertSame(120, $region->ttl); + $this->assertFalse($region->enabled); + } + + public function testInvalidatorInvalidatesOnDocumentCreate(): void + { + $this->assertInvalidatorInvalidates(Event::DocumentCreate, new Document(['$id' => 'doc1', '$collection' => 'users']), ['users']); + } + + public function testInvalidatorInvalidatesOnDocumentUpdate(): void + { + $this->assertInvalidatorInvalidates(Event::DocumentUpdate, new Document(['$id' => 'doc1', '$collection' => 'posts']), ['posts']); + } + + public function testInvalidatorInvalidatesOnDocumentDelete(): void + { + $this->assertInvalidatorInvalidates(Event::DocumentDelete, new Document(['$id' => 'doc1', '$collection' => 'users']), ['users']); + } + + public function testInvalidatorIgnoresNonWriteEvents(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + + $this->assertKept($queryCache, ['users'], function () use ($queryCache): void { + (new Invalidator($queryCache))->handle(Event::DocumentFind, new Document(['$id' => 'doc1', '$collection' => 'users'])); + }); + } + + public function testInvalidatorExtractsCollectionFromDocument(): void + { + $this->assertInvalidatorInvalidates(Event::DocumentCreate, new Document(['$id' => 'doc1', '$collection' => 'orders']), ['orders']); + } + + public function testInvalidatorHandlesStringData(): void + { + $this->assertInvalidatorInvalidates(Event::DocumentCreate, 'products', ['products']); + } + + public function testInvalidatorIgnoresEmptyCollection(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + + $this->assertKept($queryCache, ['users', ''], function () use ($queryCache): void { + (new Invalidator($queryCache))->handle(Event::DocumentCreate, new Document(['$id' => 'doc1'])); + }); + } + + public function testInvalidatorInvalidatesBothRelationshipCollections(): void + { + $this->assertInvalidatorInvalidates( + Event::AttributeCreate, + Attribute::relationship('author', Relationship::manyToOne('authors'), RelationshipSide::Parent) + ->toDocument() + ->setAttribute('$collection', 'posts'), + ['posts', 'authors'], + ); + } + + public function testInvalidatorUsesCollectionIdentityForCollectionMutations(): void + { + $this->assertInvalidatorInvalidates(Event::CollectionUpdate, new Document([ + '$id' => 'users', + '$collection' => Database::METADATA, + ]), ['users']); + } + + public function testInvalidatorInvalidatesTheScopeItIsGiven(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $scope = new Scope('host', 'database', 'namespace', 7); + $untouched = $queryCache->getEntry(new Scope(), 'users', []); + $this->assertNotNull($untouched); + $this->assertTrue($queryCache->set($untouched, [new Document(['$id' => 'untouched'])], $queryCache->getGeneration($untouched))); + + $this->assertRetired($queryCache, $scope, 'users', function () use ($queryCache, $scope): void { + (new Invalidator($queryCache))->invalidate(Event::DocumentCreate, 'users', $scope); + }); + + $other = $queryCache->getEntry(new Scope(), 'users', []); + $this->assertNotNull($other); + $this->assertSame(['untouched'], $this->ids($queryCache->get($other) ?? []), 'Invalidating one scope must not retire what another scope filled'); + } + + public function testInvalidatorHandlesEventsInTheScopeItWasGiven(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $scope = new Scope(namespace: 'namespace', tenant: 'tenant'); + + $this->assertRetired($queryCache, $scope, 'users', function () use ($queryCache, $scope): void { + (new Invalidator($queryCache, $scope))->handle(Event::DocumentCreate, 'users'); + }); + } + + public function testInvalidatorKeysTokensByTheScopedCollection(): void + { + $queryCache = new ResultCache(new InvalidationCache()); + $scope = new Scope(namespace: 'namespace', tenant: 7); + + $tokens = (new Invalidator($queryCache))->tokens(Event::DocumentCreate, 'users', $scope); + + $this->assertSame([$queryCache->getCollectionKey($scope, 'users')], \array_keys($tokens)); + } + + public function testMemoryAdapterKeepsPhysicalVariantsIsolated(): void + { + $queryCache = new ResultCache(new Cache(new Memory())); + $first = $queryCache->getEntry(new Scope(namespace: 'ns'), 'users', [['limit' => 1]], 'role:user-a'); + $second = $queryCache->getEntry(new Scope(namespace: 'ns'), 'users', [['limit' => 2]], 'role:user-b'); + $this->assertNotNull($first); + $this->assertNotNull($second); + + $this->assertTrue($queryCache->set($first, [new Document(['$id' => 'private-a'])], $queryCache->getGeneration($first))); + $this->assertTrue($queryCache->set($second, [new Document(['$id' => 'private-b'])], $queryCache->getGeneration($second))); + + $this->assertNull($queryCache->get($first), 'A cache without fields holds one result per collection: the second query took the slot, so the first must miss instead of being served its rows'); + $this->assertSame(['private-b'], $this->ids($queryCache->get($second) ?? [])); + + $this->assertTrue($queryCache->set($first, [new Document(['$id' => 'private-a'])], $queryCache->getGeneration($first))); + + $this->assertSame(['private-a'], $this->ids($queryCache->get($first) ?? [])); + $this->assertNull($queryCache->get($second)); + } + + public function testMemoryAdapterStaysBlockedAfterInvalidation(): void + { + $queryCache = new ResultCache(new Cache(new Memory())); + $scope = new Scope(namespace: 'ns'); + $entry = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($entry); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'old'])], $queryCache->getGeneration($entry))); + $this->assertSame(['old'], $this->ids($queryCache->get($entry) ?? [])); + + $queryCache->invalidateCollection($scope, 'users'); + + $this->assertNull( + $queryCache->getEntry($scope, 'users', []), + 'A cache without generations cannot prove a new epoch fresh, so it stays blocked', + ); + } + + public function testConcurrentOwnersCannotEnableCacheEarly(): void + { + $scope = new Scope(namespace: 'ns'); + + for ($iteration = 0; $iteration < 10; $iteration++) { + $adapter = new OwnershipCache(); + $first = new ResultCache(new Cache($adapter)); + $second = new ResultCache(new Cache($adapter)); + $reader = new ResultCache(new Cache($adapter)); + $key = $reader->getCollectionKey($scope, 'users'); + $firstToken = 'first-'.$iteration; + $secondToken = 'second-'.$iteration; + + $entry = $reader->getEntry($scope, 'users', []); + $this->assertNotNull($entry); + $this->assertTrue($reader->set($entry, [new Document(['$id' => 'old'])], $reader->getGeneration($entry))); + $this->assertSame(['old'], $this->ids((new ResultCache(new Cache($adapter)))->get($entry) ?? [])); + + $first->blockCollection($key, $firstToken); + $adapter->pauseNextActivation(function () use ($reader, $second, $scope, $key, $secondToken): void { + $second->blockCollection($key, $secondToken); + + $this->assertNull($reader->getEntry($scope, 'users', [])); + }); + + $first->activateCollection($key, $firstToken, Scope::WRITER_TIMEOUT); + + $this->assertNull($reader->getEntry($scope, 'users', [])); + + $second->activateCollection($key, $secondToken, Scope::WRITER_TIMEOUT); + + $fresh = $reader->getEntry($scope, 'users', []); + $this->assertNotNull($fresh); + $this->assertNull($reader->get($fresh)); + $this->assertTrue($reader->set($fresh, [new Document(['$id' => 'fresh'])], $reader->getGeneration($fresh))); + $this->assertSame(['fresh'], $this->ids((new ResultCache(new Cache($adapter)))->get($fresh) ?? [])); + } + } + + public function testAnEpochPublishedAfterALaterFinishStaysUsable(): void + { + $adapter = new OwnershipCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + + $queryCache->blockCollection($key, 'first'); + $adapter->pauseNextActivation(function () use ($queryCache, $key): void { + $queryCache->blockCollection($key, 'second'); + $queryCache->activateCollection($key, 'second', Scope::WRITER_TIMEOUT); + }); + $queryCache->activateCollection($key, 'first', Scope::WRITER_TIMEOUT); + + $entry = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($entry, 'Once every writer has finished, an epoch published late must still be usable'); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($entry))); + $this->assertSame(['fresh'], $this->ids($queryCache->get($entry) ?? [])); + } + + public function testATombstoneOlderThanItsRegionStillBlocksWhileItsWriterIsInFlight(): void + { + $queryCache = new ResultCache(new Cache(new OwnershipCache())); + $queryCache->setRegion('users', new Region(ttl: 0)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $stale = $queryCache->getEntry(new Scope(), 'users', []); + $this->assertNotNull($stale); + $this->assertTrue($queryCache->set($stale, [new Document(['$id' => 'stale'])], $queryCache->getGeneration($stale))); + + $queryCache->blockCollection($key, 'writer'); + + $this->assertNull( + $queryCache->getEntry(new Scope(), 'users', []), + 'A transaction that outlives the region TTL must keep readers off the cache until it activates', + ); + + $queryCache->activateCollection($key, 'writer', Scope::WRITER_TIMEOUT); + $queryCache->setRegion('users', new Region()); + + $fresh = $queryCache->getEntry(new Scope(), 'users', []); + $this->assertNotNull($fresh, 'The writer must still own its tombstone and publish a fresh epoch'); + $this->assertNull($queryCache->get($fresh), 'The fresh epoch must not serve what was filled before the write'); + $this->assertTrue($queryCache->set($fresh, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($fresh))); + $this->assertSame(['fresh'], $this->ids($queryCache->get($fresh) ?? [])); + } + + public function testAKilledWriterDoesNotDisableTheQueryCacheForever(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns', writerTimeout: 0); + $key = $queryCache->getCollectionKey($scope, 'users'); + $before = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($before); + + $queryCache->blockCollection($key, $queryCache->createToken()); + $this->assertTrue($queryCache->set($before, [new Document(['$id' => 'stale'])], $queryCache->getGeneration($before))); + + $entry = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($entry, 'A tombstone whose writer never activates must lapse once it is older than the writer timeout'); + $this->assertNull($queryCache->get($entry), 'The lapse must not serve a result filled under the epoch the killed writer retired'); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($entry))); + $this->assertSame(['fresh'], $this->ids($queryCache->get($entry) ?? [])); + } + + public function testAWriteAfterAKilledWriterReenablesTheQueryCache(): void + { + $adapter = new RedisLeasableCache(); + $killed = new ResultCache(new Cache($adapter)); + $writer = new ResultCache(new Cache($adapter)); + $reader = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $writerScope = new Scope(namespace: 'ns', writerTimeout: 0); + $killed->blockCollection($killed->getCollectionKey($scope, 'users'), $killed->createToken()); + $this->assertNull($reader->getEntry($scope, 'users', [])); + + $writer->invalidateCollection($writerScope, 'users'); + + $entry = $reader->getEntry($scope, 'users', []); + $this->assertNotNull($entry, 'The next write must reconcile a writer whose registration is older than the writer timeout'); + $this->assertTrue($reader->set($entry, [new Document(['$id' => 'fresh'])], $reader->getGeneration($entry))); + $this->assertSame(['fresh'], $this->ids($reader->get($entry) ?? [])); + + $writer->invalidateCollection($writerScope, 'users'); + + $this->assertNotNull($reader->getEntry($scope, 'users', []), 'Later writes must not be held back by the killed writer either'); + } + + public function testAWriteDoesNotReenableTheQueryCacheWhileAnotherWriterIsLive(): void + { + $adapter = new RedisLeasableCache(); + $live = new ResultCache(new Cache($adapter)); + $writer = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $key = $live->getCollectionKey($scope, 'users'); + $token = $live->createToken(); + $live->blockCollection($key, $token); + + $writer->invalidateCollection($scope, 'users'); + + $this->assertNull($writer->getEntry($scope, 'users', []), 'A writer registered within the writer timeout is still in flight'); + + $live->activateCollection($key, $token, Scope::WRITER_TIMEOUT); + + $this->assertNotNull($writer->getEntry($scope, 'users', [])); + } + + public function testAWriterWhoseTokenHasNoCreationTimeCountsAsLive(): void + { + $adapter = new RedisLeasableCache(); + $live = new ResultCache(new Cache($adapter)); + $writer = new ResultCache(new Cache($adapter)); + $reader = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $live->blockCollection($live->getCollectionKey($scope, 'users'), 'token-without-a-time'); + + $writer->invalidateCollection(new Scope(namespace: 'ns', writerTimeout: 0), 'users'); + + $this->assertNull($reader->getEntry($scope, 'users', []), 'Without a creation time a registration cannot be judged abandoned'); + } + + public function testAWriterPastTheTimeoutRetiresWhatReadersFilledWhileItRan(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns', writerTimeout: 0); + $key = $queryCache->getCollectionKey($scope, 'users'); + $token = $queryCache->createToken(); + $queryCache->blockCollection($key, $token); + $during = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($during); + $this->assertTrue($queryCache->set($during, [new Document(['$id' => 'before-commit'])], $queryCache->getGeneration($during))); + + $queryCache->activateCollection($key, $token, $scope->writerTimeout); + + $after = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($after); + $this->assertNull($queryCache->get($after), 'A result filled while the writer ran must not be served once it activates'); + } + + public function testAWriterJudgedAbandonedStillRetiresWhatReadersFilledWhileItRan(): void + { + $adapter = new RedisLeasableCache(); + $slow = new ResultCache(new Cache($adapter)); + $writer = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $key = $slow->getCollectionKey($scope, 'users'); + $token = $slow->createToken(); + $slow->blockCollection($key, $token); + $writer->invalidateCollection(new Scope(namespace: 'ns', writerTimeout: 0), 'users'); + $during = $slow->getEntry($scope, 'users', []); + $this->assertNotNull($during); + $this->assertTrue($slow->set($during, [new Document(['$id' => 'before-commit'])], $slow->getGeneration($during))); + + $slow->activateCollection($key, $token, Scope::WRITER_TIMEOUT); + + $after = $slow->getEntry($scope, 'users', []); + $this->assertNotNull($after); + $this->assertNull($slow->get($after), 'A writer whose registration was reconciled away must still retire what readers filled before its commit'); + } + + public function testATombstoneOnACacheWithoutGenerationsLapsesWithItsRegion(): void + { + $queryCache = new ResultCache(new Cache(new Memory())); + $queryCache->setRegion('users', new Region(ttl: 0)); + + $queryCache->invalidateCollection(new Scope(), 'users'); + + $this->assertNotNull( + $queryCache->getEntry(new Scope(), 'users', []), + 'Without generations a tombstone is the only guard, and it must lapse with its region', + ); + } + + public function testCacheFlushDuringActivationDoesNotFailInvalidation(): void + { + $adapter = new OwnershipCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $adapter->flushDuringActivation(); + + $queryCache->activateCollection($key, 'owner', Scope::WRITER_TIMEOUT); + + $this->assertNotNull($queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testCacheFlushBeforeActivationDoesNotFailInvalidation(): void + { + $adapter = new OwnershipCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $this->assertTrue($adapter->flush()); + + $queryCache->activateCollection($key, 'owner', Scope::WRITER_TIMEOUT); + + $this->assertNotNull($queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testActivationPurgeFailureStillPropagates(): void + { + $adapter = new OwnershipCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $adapter->failDuringActivation(); + + try { + $queryCache->activateCollection($key, 'owner', Scope::WRITER_TIMEOUT); + $this->fail('Query cache activation purge failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('finish query cache invalidation', $error->getMessage()); + } + + $this->assertNull($queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testFillsAndInvalidationsKeepTheCacheSizeBounded(): void + { + $slots = 4; + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter), slots: $slots); + $scope = new Scope(namespace: 'ns'); + $cycle = function (int $query) use ($queryCache, $scope): void { + $entry = $queryCache->getEntry($scope, 'users', [Query::limit($query)]); + $this->assertNotNull($entry); + $this->assertNull($queryCache->get($entry)); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'query-'.$query])], $queryCache->getGeneration($entry))); + $this->assertSame(['query-'.$query], $this->ids($queryCache->get($entry) ?? [])); + $queryCache->invalidateCollection($scope, 'users'); + }; + + for ($query = 1; $query <= 20; $query++) { + $cycle($query); + } + $keys = $adapter->getSize(); + $values = $adapter->countValues(); + + for ($query = 21; $query <= 100; $query++) { + $cycle($query); + } + + $this->assertLessThanOrEqual($keys + $slots, $adapter->getSize(), 'Redis keeps no expiry on these keys and a purge leaves its key behind, so 80 more fills and writes must not add a key each'); + $this->assertLessThanOrEqual($values + $slots, $adapter->countValues(), 'Redis keeps no expiry on cached results, so the slot count, not the number of distinct queries, must bound what fills leave behind'); + } + + public function testAnInvalidationRetiresEveryCachedResultOfTheScope(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $scope = new Scope(namespace: 'ns'); + for ($query = 1; $query <= 50; $query++) { + $entry = $queryCache->getEntry($scope, 'users', [Query::limit($query)]); + $this->assertNotNull($entry); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'old-'.$query])], $queryCache->getGeneration($entry))); + $this->assertSame(['old-'.$query], $this->ids($queryCache->get($entry) ?? [])); + } + + $queryCache->invalidateCollection($scope, 'users'); + + for ($query = 1; $query <= 50; $query++) { + $entry = $queryCache->getEntry($scope, 'users', [Query::limit($query)]); + $this->assertNotNull($entry, 'The invalidation must leave the scope usable'); + $this->assertNull($queryCache->get($entry), 'The invalidation must retire every result filled before it'); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'new-'.$query])], $queryCache->getGeneration($entry))); + $this->assertSame(['new-'.$query], $this->ids($queryCache->get($entry) ?? []), 'A fill after the invalidation must be served'); + } + } + + public function testQueriesSharingASlotNeverServeEachOther(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter), slots: 1); + $scope = new Scope(namespace: 'ns'); + $first = $queryCache->getEntry($scope, 'users', [Query::limit(1)], 'role:user-a'); + $second = $queryCache->getEntry($scope, 'users', [Query::limit(2)], 'role:user-b'); + $this->assertNotNull($first); + $this->assertNotNull($second); + + $this->assertTrue($queryCache->set($first, [new Document(['$id' => 'private-a'])], $queryCache->getGeneration($first))); + $this->assertTrue($queryCache->set($second, [new Document(['$id' => 'private-b'])], $queryCache->getGeneration($second))); + + $this->assertNull($queryCache->get($first), 'The second query took the only slot, so the first must miss instead of being served its rows'); + $this->assertSame(['private-b'], $this->ids($queryCache->get($second) ?? [])); + } + + public function testAResultFilledBeforeTheFirstWriteIsNeverServedAfterTheEpochIsLost(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + $before = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($before); + $this->assertTrue($queryCache->set($before, [new Document(['$id' => 'stale'])], $queryCache->getGeneration($before))); + + $queryCache->invalidateCollection($scope, 'users'); + $adapter->evict($key.'#epoch'); + $adapter->evict($key.'#started'); + + $entry = $queryCache->getEntry($scope, 'users', []); + $this->assertNull($entry === null ? null : $queryCache->get($entry), 'An evicted epoch must not bring back the initial epoch a result was filled under before the first write'); + } + + public function testAFillUnderARetiredEpochIsNeverServed(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $stale = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($stale); + + $queryCache->invalidateCollection($scope, 'users'); + $this->assertTrue($queryCache->set($stale, [new Document(['$id' => 'stale'])], $queryCache->getGeneration($stale))); + + $fresh = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($fresh); + $this->assertSame($stale->field, $fresh->field); + $this->assertNull($queryCache->get($fresh), 'A reader that resolved its entry before an invalidation fills the old epoch, which the new one must not serve'); + } + + public function testQueriesOfOneCollectionKeepTheirOwnResultsOnACacheWithFields(): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $scope = new Scope(namespace: 'ns'); + $first = $queryCache->getEntry($scope, 'users', [Query::limit(1)], 'role:user-a'); + $second = $queryCache->getEntry($scope, 'users', [Query::limit(2)], 'role:user-b'); + $this->assertNotNull($first); + $this->assertNotNull($second); + + $this->assertTrue($queryCache->set($first, [new Document(['$id' => 'private-a'])], $queryCache->getGeneration($first))); + $this->assertTrue($queryCache->set($second, [new Document(['$id' => 'private-b'])], $queryCache->getGeneration($second))); + + $this->assertSame(['private-a'], $this->ids($queryCache->get($first) ?? [])); + $this->assertSame(['private-b'], $this->ids($queryCache->get($second) ?? [])); + } + + public function testOverlappingInvalidationsSucceedOnACacheWithoutFields(): void + { + $queryCache = new ResultCache(new Cache(new Memory())); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + + $queryCache->blockCollection($key, 'first'); + $queryCache->blockCollection($key, 'second'); + $queryCache->activateCollection($key, 'first', Scope::WRITER_TIMEOUT); + $this->assertNull($queryCache->getEntry(new Scope(), 'users', []), 'The second writer is still in flight'); + $queryCache->activateCollection($key, 'second', Scope::WRITER_TIMEOUT); + + $this->assertNull( + $queryCache->getEntry(new Scope(), 'users', []), + 'A cache without generations cannot prove a new epoch fresh, so it stays blocked', + ); + } + + public function testActivationRejectsACorruptedOwnerRegistration(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $adapter->corruptFieldWrites(); + $queryCache->blockCollection($key, 'owner'); + + try { + $queryCache->activateCollection($key, 'owner', Scope::WRITER_TIMEOUT); + $this->fail('A corrupted owner registration was accepted'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('Invalid query cache owner', $error->getMessage()); + } + + $this->assertNull($queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testActivationPropagatesAnOwnerReleaseFailure(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $adapter->failFieldPurges(); + + try { + $queryCache->activateCollection($key, 'owner', Scope::WRITER_TIMEOUT); + $this->fail('An owner release failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('Failed to release query cache owner', $error->getMessage()); + } + + $this->assertNull($queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testInvalidationPropagatesACacheWriteFailure(): void + { + $queryCache = new ResultCache(new class (new RedisLeasableCache()) extends Cache { + #[\Override] + public function save(string $key, mixed $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $hash === '' ? false : parent::save($key, $data, $hash); + } + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to block query cache epoch'); + $queryCache->invalidateCollection(new Scope(), 'users'); + } + + public function testAFlushedWriterLeavesAnotherWritersTombstoneInPlace(): void + { + $adapter = new OwnershipCache(); + $queryCache = new ResultCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + + $queryCache->blockCollection($key, 'first'); + $this->assertTrue($adapter->flush()); + $queryCache->blockCollection($key, 'second'); + + $queryCache->activateCollection($key, 'first', Scope::WRITER_TIMEOUT); + + $this->assertNull($queryCache->getEntry($scope, 'users', []), 'A writer whose registration was flushed away must not enable the cache while another writer is in flight'); + + $queryCache->activateCollection($key, 'second', Scope::WRITER_TIMEOUT); + + $this->assertNotNull($queryCache->getEntry($scope, 'users', [])); + } + + public function testAnOwnerReleasedByAConcurrentFlushIsNotReported(): void + { + $cache = new class (new RedisLeasableCache()) extends Cache { + private bool $armed = false; + + public function flushOnNextFieldPurge(): void + { + $this->armed = true; + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->armed && $hash !== '') { + $this->armed = false; + $this->flush(); + } + + return parent::purge($key, $hash); + } + }; + $queryCache = new ResultCache($cache); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + $queryCache->blockCollection($key, 'owner'); + $cache->flushOnNextFieldPurge(); + + $queryCache->activateCollection($key, 'owner', Scope::WRITER_TIMEOUT); + + $entry = $queryCache->getEntry($scope, 'users', []); + $this->assertNotNull($entry, 'An owner that a flush removed before its release must still publish a fresh epoch'); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($entry))); + $this->assertSame(['fresh'], $this->ids($queryCache->get($entry) ?? [])); + } + + public function testInvalidationPropagatesAnOwnerRegistrationFailure(): void + { + $queryCache = new ResultCache(new class (new RedisLeasableCache()) extends Cache { + #[\Override] + public function save(string $key, mixed $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $hash === '' ? parent::save($key, $data, $hash) : false; + } + }); + $before = $queryCache->getEntry(new Scope(), 'users', []); + $this->assertNotNull($before); + $this->assertTrue($queryCache->set($before, [new Document(['$id' => 'cached'])], $queryCache->getGeneration($before))); + + try { + $queryCache->invalidateCollection(new Scope(), 'users'); + $this->fail('An owner registration failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('Failed to register query cache owner', $error->getMessage()); + } + + $after = $queryCache->getEntry(new Scope(), 'users', []); + $this->assertNotNull($after, 'A write whose owner was not registered must not block the epoch'); + $this->assertSame(['cached'], $this->ids($queryCache->get($after) ?? [])); + } + + public function testFlushFailureIsReported(): void + { + $cache = self::createStub(Cache::class); + $cache->method('flush')->willReturn(false); + $queryCache = new ResultCache($cache); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to flush query cache'); + $queryCache->flush(); + } + + /** + * @param array $collections + */ + private function assertInvalidatorInvalidates(Event $event, mixed $data, array $collections): void + { + $queryCache = new ResultCache(new Cache(new RedisLeasableCache())); + $action = function () use ($queryCache, $event, $data): void { + (new Invalidator($queryCache))->handle($event, $data); + }; + + foreach ($collections as $collection) { + $action = function () use ($queryCache, $collection, $action): void { + $this->assertRetired($queryCache, new Scope(), $collection, $action); + }; + } + + $action(); + } + + private function assertRetired(ResultCache $queryCache, Scope $scope, string $collection, callable $action): void + { + $before = $queryCache->getEntry($scope, $collection, []); + $this->assertNotNull($before); + $this->assertTrue($queryCache->set($before, [new Document(['$id' => 'stale'])], $queryCache->getGeneration($before))); + $this->assertSame(['stale'], $this->ids($queryCache->get($before) ?? [])); + + $action(); + + $after = $queryCache->getEntry($scope, $collection, []); + $this->assertNotNull($after, "The invalidation must leave '{$collection}' usable"); + $this->assertNull($queryCache->get($after), "The invalidation must retire what '{$collection}' filled before it"); + $this->assertTrue($queryCache->set($after, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($after))); + $this->assertSame(['fresh'], $this->ids($queryCache->get($after) ?? []), "The invalidation must publish a fresh epoch for '{$collection}'"); + } + + /** + * @param array $collections + */ + private function assertKept(ResultCache $queryCache, array $collections, callable $action): void + { + foreach ($collections as $collection) { + $before = $queryCache->getEntry(new Scope(), $collection, []); + $this->assertNotNull($before); + $this->assertTrue($queryCache->set($before, [new Document(['$id' => 'cached'])], $queryCache->getGeneration($before))); + } + + $action(); + + foreach ($collections as $collection) { + $after = $queryCache->getEntry(new Scope(), $collection, []); + $this->assertNotNull($after, "The event must leave '{$collection}' usable"); + $this->assertSame(['cached'], $this->ids($queryCache->get($after) ?? []), "The event must not retire what '{$collection}' filled"); + } + } + + private static function createCache(): Cache&Stub + { + $cache = self::createStub(Cache::class); + $cache->method('getGeneration')->willReturn('0'); + + return $cache; + } + + /** + * @param array $documents + * @return array + */ + private function ids(array $documents): array + { + return \array_map( + static fn (Document $document): string => $document->getId(), + $documents, + ); + } +} diff --git a/tests/unit/Cache/RacedReleaseCache.php b/tests/unit/Cache/RacedReleaseCache.php new file mode 100644 index 0000000000..5b6c371411 --- /dev/null +++ b/tests/unit/Cache/RacedReleaseCache.php @@ -0,0 +1,99 @@ +cache = new RedisLeasableCache(); + } + + public function releaseBeforeNextOwnerRelease(): void + { + $this->racing = true; + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->cache->load($key, $ttl, $hash); + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $this->cache->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + return $this->cache->touch($key, $hash); + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + return $this->cache->list($key); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->racing && $hash !== '' && \str_ends_with($key, '#owners')) { + $this->racing = false; + $this->cache->purge($key, $hash); + } + + return $this->cache->purge($key, $hash); + } + + #[\Override] + public function flush(): bool + { + return $this->cache->flush(); + } + + #[\Override] + public function ping(): bool + { + return true; + } + + #[\Override] + public function getSize(): int + { + return $this->cache->getSize(); + } + + #[\Override] + public function getName(?string $key = null): string + { + return 'raced-release'; + } + + #[\Override] + public function getGeneration(string $key): string + { + return $this->cache->getGeneration($key); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + return $this->cache->saveWithLease($key, $data, $hash, $generation); + } +} diff --git a/tests/unit/Cache/RedisLeasableCache.php b/tests/unit/Cache/RedisLeasableCache.php new file mode 100644 index 0000000000..5dc00f59b9 --- /dev/null +++ b/tests/unit/Cache/RedisLeasableCache.php @@ -0,0 +1,216 @@ +|string}>> */ + private array $fields = []; + + /** @var array */ + private array $generations = []; + + private bool $failingFieldPurges = false; + + private bool $corruptingFieldWrites = false; + + private int $leaseGraceWindow = 0; + + /** @var array */ + private array $tombstones = []; + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $saved = $this->fields[$key][$this->field($key, $hash)] ?? null; + + return $saved !== null && $saved['time'] + $ttl > \time() ? $saved['data'] : false; + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ($key === '' || empty($data)) { + return false; + } + + if ($hash !== '' && $this->corruptingFieldWrites) { + $data = 'corrupted'; + } + + $this->fields[$key][$this->field($key, $hash)] = ['time' => \time(), 'data' => $data]; + + return $data; + } + + #[\Override] + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if ($this->getGeneration($key) !== $generation) { + return false; + } + + if (($this->tombstones[$key] ?? 0.0) > \microtime(true)) { + return false; + } + + unset($this->tombstones[$key]); + + return $this->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + $field = $this->field($key, $hash); + if (! isset($this->fields[$key][$field])) { + return false; + } + + $this->fields[$key][$field]['time'] = \time(); + + return true; + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + return \array_map(\strval(...), \array_keys($this->fields[$key] ?? [])); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '' && $this->failingFieldPurges) { + return false; + } + + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + if ($this->leaseGraceWindow > 0) { + $this->tombstones[$key] = \microtime(true) + $this->leaseGraceWindow / 1000; + } + + if ($hash === '') { + $removed = \count($this->fields[$key] ?? []); + unset($this->fields[$key]); + + return $removed > 0; + } + + $removed = isset($this->fields[$key][$hash]); + unset($this->fields[$key][$hash]); + if (($this->fields[$key] ?? null) === []) { + unset($this->fields[$key]); + } + + return $removed; + } + + #[\Override] + public function flush(): bool + { + $this->fields = []; + $this->generations = []; + $this->tombstones = []; + + return true; + } + + #[\Override] + public function ping(): bool + { + return true; + } + + #[\Override] + public function getSize(): int + { + return \count($this->keys()); + } + + #[\Override] + public function getName(?string $key = null): string + { + return 'redis-leasable'; + } + + /** + * Every key the cache holds, including keys that only hold a generation. + * + * @return array + */ + public function keys(): array + { + $keys = \array_map(\strval(...), \array_keys($this->fields + $this->generations)); + \sort($keys); + + return $keys; + } + + /** + * Refuse every leased save of a key for the given milliseconds after each purge of it, as the adapters' + * purge tombstone does (LUA_SAVE_WITH_LEASE). + */ + public function setLeaseGraceWindow(int $milliseconds): void + { + $this->leaseGraceWindow = \max(0, $milliseconds); + } + + public function expireTombstones(): void + { + $this->tombstones = []; + } + + /** + * Fail every purge of a single field, leaving the field in place. + */ + public function failFieldPurges(): void + { + $this->failingFieldPurges = true; + } + + /** + * Store a different value than the one given on every write to a single field. + */ + public function corruptFieldWrites(bool $corrupting = true): void + { + $this->corruptingFieldWrites = $corrupting; + } + + /** + * Drop a key with its generation, as Redis does when it evicts the key under memory pressure. + */ + public function evict(string $key): void + { + unset($this->fields[$key], $this->generations[$key], $this->tombstones[$key]); + } + + /** + * Every value the cache holds across its keys, leaving out generations. + */ + public function countValues(): int + { + return \array_sum(\array_map(\count(...), $this->fields)); + } + + private function field(string $key, string $hash): string + { + return $hash === '' ? $key : $hash; + } +} diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index aa94573624..ee604ade18 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -3,28 +3,35 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; +use Tests\Unit\Adapter\FeatureAdapterStub; use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; use Utopia\Database\Adapter; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Filter\Callback; +use Utopia\Database\Hook\Relationships; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Query\Schema\ColumnType; class CacheKeyTest extends TestCase { /** - * @param array $instanceFilters + * @param list $filters */ - private function createDatabase(array $instanceFilters = [], string $database = 'test'): Database + private function createDatabase(array $filters = [], string $database = 'test'): Database { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(false); + $adapter = self::createStub(Adapter::class); + $adapter->method('supports')->willReturn(false); $adapter->method('getTenant')->willReturn(null); $adapter->method('getNamespace')->willReturn('test'); $adapter->method('getDatabase')->willReturn($database); - return new Database($adapter, new Cache(new None()), $instanceFilters); + return new Database($adapter, new Cache(new None()), $filters); } private function getHashKey(Database $db, string $collection = 'col', string $docId = 'doc1'): string @@ -35,12 +42,14 @@ private function getHashKey(Database $db, string $collection = 'col', string $do public function testBaseKeysMatchScopedVariantKeys(): void { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(true); - $adapter->method('getHostname')->willReturn('mysql-project'); + $adapter = self::createStub(FeatureAdapterStub::class); + $adapter->method('hasFeature')->willReturnCallback( + fn (string $feature): bool => $feature === Feature\Connection::class + ); + $adapter->method('hostname')->willReturn('mysql-project'); $adapter->method('getNamespace')->willReturn('project'); $adapter->method('getTenant')->willReturn(42); - $adapter->method('getSharedTables')->willReturn(true); + $adapter->method('hasSharedTables')->willReturn(true); $db = new Database($adapter, new Cache(new None())); $db->setGlobalCollections(['global']); @@ -54,6 +63,21 @@ public function testBaseKeysMatchScopedVariantKeys(): void } } + public function testCacheKeysSeparateDatabasesSharingANamespace(): void + { + $console = $this->createDatabase(database: 'console'); + $project = $this->createDatabase(database: 'project'); + + $this->assertNotSame( + $console->getCacheKeys('users', 'user1'), + $project->getCacheKeys('users', 'user1'), + ); + $this->assertNotSame( + $console->getQueryCacheKey('users'), + $project->getQueryCacheKey('users'), + ); + } + public function testSameConfigProducesSameCacheKey(): void { $db1 = $this->createDatabase(); @@ -82,51 +106,34 @@ public function testSelectOrderDoesNotAffectCacheKey(): void $this->assertEquals($hashA, $hashB); } - public function testInstanceFilterOverrideProducesDifferentCacheKey(): void + public function testConstructorFilterProducesDifferentCacheKey(): void { - $noop = function (mixed $value) { - return $value; - }; - - $dbDefault = $this->createDatabase(); - $dbOverride = $this->createDatabase([ - 'json' => [ - 'encode' => $noop, - 'decode' => $noop, - ], - ]); + $noop = static fn (mixed $value): mixed => $value; - $this->assertNotEquals( - $this->getHashKey($dbDefault), - $this->getHashKey($dbOverride) + $this->assertNotSame( + $this->getHashKey($this->createDatabase()), + $this->getHashKey($this->createDatabase([new Callback('myFilter', $noop, $noop)])), ); } - public function testDifferentInstanceFilterCallablesProduceDifferentCacheKeys(): void + public function testDifferentConstructorFilterClosuresProduceDifferentCacheKeys(): void { - $noopA = function (mixed $value) { - return $value; - }; - $noopB = function (mixed $value) { - return $value; - }; - - $dbA = $this->createDatabase([ - 'myFilter' => [ - 'encode' => $noopA, - 'decode' => $noopA, - ], - ]); - $dbB = $this->createDatabase([ - 'myFilter' => [ - 'encode' => $noopB, - 'decode' => $noopB, - ], - ]); + $noopA = static fn (mixed $value): mixed => $value; + $noopB = static fn (mixed $value): mixed => $value; - $this->assertNotEquals( - $this->getHashKey($dbA), - $this->getHashKey($dbB) + $this->assertNotSame( + $this->getHashKey($this->createDatabase([new Callback('myFilter', $noopA, $noopA)])), + $this->getHashKey($this->createDatabase([new Callback('myFilter', $noopB, $noopB)])), + ); + } + + public function testSameConstructorFilterClosuresShareCacheKeys(): void + { + $noop = static fn (mixed $value): mixed => $value; + + $this->assertSame( + $this->getHashKey($this->createDatabase([new Callback('myFilter', $noop, $noop)])), + $this->getHashKey($this->createDatabase([new Callback('myFilter', $noop, $noop)])), ); } @@ -149,41 +156,47 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void $hashEnabled = $this->getHashKey($db); - $db->disableFilters(); + $db->setFiltering(false); $hashDisabled = $this->getHashKey($db); - $db->enableFilters(); + $db->setFiltering(true); $this->assertNotEquals($hashEnabled, $hashDisabled); } public function testQueryCacheKeyUsesQueryCacheShape(): void { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(true); - $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter = self::createStub(FeatureAdapterStub::class); + $adapter->method('hasFeature')->willReturnCallback( + fn (string $feature): bool => $feature === Feature\Connection::class + ); + $adapter->method('hostname')->willReturn('mysql-console'); + $adapter->method('getDatabase')->willReturn('console'); $adapter->method('getNamespace')->willReturn('_39'); $adapter->method('getTenant')->willReturn(null); $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache-mysql-console:_39::collection:ttl_cache_table:query', + 'default-cache-mysql-console:console:_39::collection:ttl_cache_table:query', $db->getQueryCacheKey('ttl_cache_table'), ); } public function testQueryCacheKeyCanOverrideNamespaceSegment(): void { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(true); - $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter = self::createStub(FeatureAdapterStub::class); + $adapter->method('hasFeature')->willReturnCallback( + fn (string $feature): bool => $feature === Feature\Connection::class + ); + $adapter->method('hostname')->willReturn('mysql-console'); + $adapter->method('getDatabase')->willReturn('console'); $adapter->method('getNamespace')->willReturn(''); $adapter->method('getTenant')->willReturn(null); $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache-mysql-console:_39::collection:wafrules:query', + 'default-cache-mysql-console:console:_39::collection:wafrules:query', $db->getQueryCacheKey('wafrules', '_39'), ); } @@ -194,8 +207,8 @@ public function testQueryCacheFieldUsesQueryCacheShape(): void $collection = new Document([ '$id' => 'wafRules', 'attributes' => [ - new Document(['$id' => 'projectId', 'type' => Database::VAR_STRING]), - new Document(['$id' => 'enabled', 'type' => Database::VAR_BOOLEAN]), + new Document(['$id' => 'projectId', 'type' => ColumnType::String->value]), + new Document(['$id' => 'enabled', 'type' => ColumnType::Boolean->value]), ], 'indexes' => [ new Document(['$id' => 'project_enabled', 'attributes' => ['projectId', 'enabled']]), @@ -207,13 +220,9 @@ public function testQueryCacheFieldUsesQueryCacheShape(): void Query::orderAsc('priority'), ]; - $schemaHash = \md5( - (\json_encode($collection->getAttribute('attributes', [])) ?: '') - . (\json_encode($collection->getAttribute('indexes', [])) ?: '') - . (\json_encode($collection->getAttribute('$permissions', [])) ?: '') - . (\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') - ); + $schemaHash = Collection::fromDocument($collection)->fingerprint(); $field = $db->getQueryCacheField($collection, $queries); + $this->assertNotNull($field); $this->assertStringStartsWith("{$schemaHash}:", $field); $this->assertStringEndsWith(':documents', $field); @@ -226,24 +235,27 @@ public function testQueryCacheFieldChangesWithInputs(): void $field = $db->getQueryCacheField( new Document([ - 'attributes' => [new Document(['$id' => 'name', 'type' => Database::VAR_STRING])], + 'attributes' => [new Document(['$id' => 'name', 'type' => ColumnType::String->value])], 'indexes' => [], ]), [Query::limit(10)], ); + $this->assertNotNull($field); $this->assertNotSame( $field, $db->getQueryCacheField( new Document([ - 'attributes' => [new Document(['$id' => 'status', 'type' => Database::VAR_STRING])], + 'attributes' => [new Document(['$id' => 'status', 'type' => ColumnType::String->value])], 'indexes' => [], ]), [Query::limit(10)], ), ); $this->assertNotSame($field, $db->getQueryCacheField(null, [Query::limit(20)])); - $this->assertStringEndsWith(':total', $db->getQueryCacheField(null, [Query::limit(10)], 'total')); + $total = $db->getQueryCacheField(null, [Query::limit(10)], 'total'); + $this->assertNotNull($total); + $this->assertStringEndsWith(':total', $total); } public function testQueryCacheFieldChangesWithActiveAuthorizationContext(): void @@ -269,7 +281,7 @@ public function testQueryCacheFieldReturnsNullForNonReadPermission(): void { $db = $this->createDatabase(); - $this->assertNull($db->getQueryCacheField(forPermission: Database::PERMISSION_UPDATE)); + $this->assertNull($db->getQueryCacheField(forPermission: PermissionType::Update)); } public function testQueryCacheFieldIncludesCursorDocumentPayload(): void @@ -297,6 +309,7 @@ public function testQueryCacheFieldIncludesCursorDocumentPayload(): void public function testQueryCacheFieldIncludesAmbientState(): void { $db = $this->createDatabase(); + $db->addHook(new Relationships()); $field = $db->getQueryCacheField(null, [Query::limit(10)]); @@ -326,11 +339,14 @@ public function testParseHostname(): void { $hostname = 'database_db_nyc3_self_hosted_0_0'; - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(true); - $adapter->method('getHostname')->willReturn($hostname); + $adapter = self::createStub(FeatureAdapterStub::class); + $adapter->method('hasFeature')->willReturnCallback( + fn (string $feature): bool => $feature === Feature\Connection::class + ); + $adapter->method('hostname')->willReturn($hostname); + $adapter->method('getDatabase')->willReturn('appwrite'); $adapter->method('getTenant')->willReturn(999); - $adapter->method('getSharedTables')->willReturn(true); + $adapter->method('hasSharedTables')->willReturn(true); $adapter->method('getNamespace')->willReturn('_ns'); $db = new Database($adapter, new Cache(new None()), []); @@ -339,7 +355,7 @@ public function testParseHostname(): void * Check DSN is parsed correctly */ [$collectionKey, $documentKey] = $db->getCacheKeys('users'); - $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:_ns:999:collection:users', $collectionKey); + $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:appwrite:_ns:999:collection:users', $collectionKey); $this->assertEquals('', $documentKey); $db->setGlobalCollections(['users']); @@ -350,14 +366,14 @@ public function testParseHostname(): void */ [$collectionKey, $documentKey] = $db->getCacheKeys(Database::METADATA, 'audit'); - $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:_ns:999:collection:_metadata', $collectionKey); - $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:_ns:999:collection:_metadata:audit', $documentKey); + $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:appwrite:_ns:999:collection:_metadata', $collectionKey); + $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:appwrite:_ns:999:collection:_metadata:audit', $documentKey); /** * Check that tenant 999 was removed */ [$collectionKey, $documentKey] = $db->getCacheKeys(Database::METADATA, 'users'); - $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:_ns::collection:_metadata', $collectionKey); - $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:_ns::collection:_metadata:users', $documentKey); + $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:appwrite:_ns::collection:_metadata', $collectionKey); + $this->assertEquals('default-cache-database_db_nyc3_self_hosted_0_0:appwrite:_ns::collection:_metadata:users', $documentKey); } } diff --git a/tests/unit/ChangeTest.php b/tests/unit/ChangeTest.php new file mode 100644 index 0000000000..5396d191ac --- /dev/null +++ b/tests/unit/ChangeTest.php @@ -0,0 +1,58 @@ + 'doc1', 'name' => 'Old Name']); + $new = new Document(['$id' => 'doc1', 'name' => 'New Name']); + + $change = new Change($old, $new); + + $this->assertSame($old, $change->old); + $this->assertSame($new, $change->new); + } + + public function testOldAndNewCarryTheirDocuments(): void + { + $old = new Document(['$id' => 'test', 'status' => 'draft']); + $new = new Document(['$id' => 'test', 'status' => 'published']); + + $change = new Change($old, $new); + + $this->assertSame('draft', $change->old->getAttribute('status')); + $this->assertSame('published', $change->new->getAttribute('status')); + $this->assertSame('test', $change->old->getId()); + $this->assertSame('test', $change->new->getId()); + } + + public function testDocumentsCannotBeReplacedOnceSet(): void + { + $change = new Change(new Document(['$id' => 'doc', 'val' => 1]), new Document(['$id' => 'doc', 'val' => 2])); + + $this->expectException(\Error::class); + $this->expectExceptionMessage('Cannot modify readonly property Utopia\Database\Change::$old'); + + (static function (Change $change): void { + /** @phpstan-ignore-next-line property.readOnlyAssignOutOfClass */ + $change->old = new Document(['$id' => 'doc', 'val' => 0]); + })($change); + } + + public function testWithEmptyDocuments(): void + { + $old = new Document(); + $new = new Document(); + + $change = new Change($old, $new); + + $this->assertTrue($change->old->isEmpty()); + $this->assertTrue($change->new->isEmpty()); + } +} diff --git a/tests/unit/CollectionDefinitionTest.php b/tests/unit/CollectionDefinitionTest.php new file mode 100644 index 0000000000..615b11ea52 --- /dev/null +++ b/tests/unit/CollectionDefinitionTest.php @@ -0,0 +1,75 @@ +assertNotContains('externalId', $this->keys($definition)); + $this->assertSame(false, $definition->isSet('externalId')); + } + + public function testCollectionDefinitionKeys(): void + { + $definition = Database::collectionDefinition(); + + $this->assertSame(Database::METADATA, $definition->getId()); + $this->assertSame(Database::METADATA, $definition->getAttribute(Document::COLLECTION)); + $this->assertSame('collections', $definition->name()); + $this->assertSame(['name', 'attributes', 'indexes', 'documentSecurity'], $this->keys($definition)); + } + + public function testCollectionDefinitionAttributeTypes(): void + { + $byKey = []; + foreach (Database::collectionDefinition()->attributes() as $attribute) { + $byKey[$attribute->key] = $attribute; + } + + $this->assertSame(ColumnType::String, $byKey['name']->type); + $this->assertSame(256, $byKey['name']->size); + $this->assertSame(true, $byKey['name']->required); + + $this->assertSame(ColumnType::String, $byKey['attributes']->type); + $this->assertSame(1000000, $byKey['attributes']->size); + $this->assertSame(true, \in_array(Filter::Json->value, $byKey['attributes']->filters, true)); + + $this->assertSame(ColumnType::String, $byKey['indexes']->type); + $this->assertSame(1000000, $byKey['indexes']->size); + $this->assertSame(true, \in_array(Filter::Json->value, $byKey['indexes']->filters, true)); + + $this->assertSame(ColumnType::Boolean, $byKey['documentSecurity']->type); + $this->assertSame(true, $byKey['documentSecurity']->required); + } + + public function testMetadataSchemaHasNoExternalId(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database->setDatabase('testing')->setNamespace('collections'); + $database->create(); + + $this->assertNotContains('externalId', $this->keys($database->getCollection(Database::METADATA))); + } + + /** + * @return list + */ + private function keys(Collection $collection): array + { + return \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributes()); + } +} diff --git a/tests/unit/Collections/AnalyzeCollectionTest.php b/tests/unit/Collections/AnalyzeCollectionTest.php new file mode 100644 index 0000000000..a2beb5c507 --- /dev/null +++ b/tests/unit/Collections/AnalyzeCollectionTest.php @@ -0,0 +1,71 @@ + */ + private array $statements = []; + + public function testSQLiteRecordsStatisticsForTheTableAndItsPermissions(): void + { + $pdo = new PDO('sqlite::memory:'); + $database = new Database(new SQLite($pdo), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('analyze') + ->setNamespace('analyze'); + $database->create(); + $database->createCollection(Collection::create( + id: 'places', + attributes: [Attribute::string(key: 'name', size: 32)], + permissions: [Permission::create(Role::any())], + )); + + foreach (\range(1, 20) as $number) { + $document = $database->createDocument('places', new Document(['name' => 'place'.$number])); + $pdo->exec("INSERT INTO `analyze_places_perms` (`_type`, `_permission`, `_document`) VALUES ('read', 'any', '{$document->getId()}')"); + } + + $this->assertTrue($database->analyzeCollection('places')); + + $statement = $pdo->query('SELECT DISTINCT tbl FROM sqlite_stat1 ORDER BY tbl'); + $this->assertInstanceOf(PDOStatement::class, $statement); + $this->assertSame(['analyze_places', 'analyze_places_perms'], $statement->fetchAll(PDO::FETCH_COLUMN)); + } + + public function testPostgresAnalyzesTheTableAndItsPermissions(): void + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + $this->assertTrue($adapter->analyzeCollection('places')); + $this->assertSame(['ANALYZE "database"."namespace_places"; ANALYZE "database"."namespace_places_perms"'], $this->statements); + } +} diff --git a/tests/unit/Collections/CollectionDefinitionTest.php b/tests/unit/Collections/CollectionDefinitionTest.php new file mode 100644 index 0000000000..11f21d619f --- /dev/null +++ b/tests/unit/Collections/CollectionDefinitionTest.php @@ -0,0 +1,156 @@ +assertSame(Database::METADATA, $definition->getId()); + $this->assertSame('collections', $definition->name()); + $this->assertSame(Database::METADATA, $definition->getCollection()); + $this->assertFalse($definition->documentSecurity()); + $this->assertSame([], $definition->indexes()); + } + + public function testTheMetadataAttributesKeepTheirStoredShape(): void + { + $attributes = []; + foreach (Database::collectionDefinition()->attributes() as $attribute) { + $attributes[$attribute->key] = [$attribute->type, $attribute->size, $attribute->required, $attribute->filters]; + } + + $this->assertSame([ + 'name' => [ColumnType::String, 256, true, []], + 'attributes' => [ColumnType::String, 1_000_000, false, ['json']], + 'indexes' => [ColumnType::String, 1_000_000, false, ['json']], + 'documentSecurity' => [ColumnType::Boolean, null, true, []], + ], $attributes); + } + + public function testEveryCallReturnsAnIndependentCopy(): void + { + $first = Database::collectionDefinition(); + $first->setAttribute('name', 'changed'); + $first->setAttribute('attributes', []); + + $second = Database::collectionDefinition(); + + $this->assertSame('collections', $second->name()); + $this->assertCount(4, $second->attributes()); + } + + public function testGetCollectionReturnsTheDefinitionForTheMetadataCollection(): void + { + $database = $this->database(sharedTables: false); + + $this->assertSame( + Database::collectionDefinition()->getArrayCopy(), + $database->getCollection(Database::METADATA)->getArrayCopy(), + ); + } + + public function testInternalAttributesLeaveOutTheTenantWithoutSharedTables(): void + { + $keys = \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $this->database(sharedTables: false)->internalAttributes(), + ); + + $this->assertSame([ + Document::ID, + Document::SEQUENCE, + Document::COLLECTION, + Document::CREATED_AT, + Document::UPDATED_AT, + Document::PERMISSIONS, + ], $keys); + } + + public function testInternalAttributesIncludeTheTenantUnderSharedTables(): void + { + $keys = \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $this->database(sharedTables: true)->internalAttributes(), + ); + + $this->assertSame([ + Document::ID, + Document::SEQUENCE, + Document::COLLECTION, + Document::TENANT, + Document::CREATED_AT, + Document::UPDATED_AT, + Document::PERMISSIONS, + ], $keys); + } + + public function testInternalAttributesFollowTheSharedTablesSetting(): void + { + $database = $this->database(sharedTables: false); + $database->setSharedTables(true); + + $this->assertContains(Document::TENANT, $this->internalKeys($database)); + + $database->setSharedTables(false); + + $this->assertNotContains(Document::TENANT, $this->internalKeys($database)); + } + + /** + * @return list + */ + private function internalKeys(Database $database): array + { + return \array_map(static fn (Attribute $attribute): string => $attribute->key, $database->internalAttributes()); + } + + public function testInternalAttributesKeepTheirStoredShape(): void + { + $stored = []; + foreach ($this->database(sharedTables: true)->internalAttributes() as $attribute) { + $document = $attribute->toDocument(); + $stored[$attribute->key] = [ + $document->getAttribute('type'), + $document->getAttribute('size'), + $document->getAttribute('required'), + $document->getAttribute('default'), + $document->getAttribute('signed'), + $document->getAttribute('array'), + $document->getAttribute('filters'), + ]; + } + + $this->assertSame([ + Document::ID => ['string', Database::LENGTH_KEY, true, null, true, false, []], + Document::SEQUENCE => ['id', 0, true, null, true, false, []], + Document::COLLECTION => ['string', Database::LENGTH_KEY, true, null, true, false, []], + Document::TENANT => ['id', 0, false, null, true, false, []], + Document::CREATED_AT => ['datetime', 0, false, null, false, false, ['datetime']], + Document::UPDATED_AT => ['datetime', 0, false, null, false, false, ['datetime']], + Document::PERMISSIONS => ['string', 1_000_000, false, [], true, false, ['json']], + ], $stored); + } + + private function database(bool $sharedTables): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('definition') + ->setNamespace('definition_'.\uniqid()) + ->setSharedTables($sharedTables); + + return $database; + } +} diff --git a/tests/unit/Collections/CollectionGuardsTest.php b/tests/unit/Collections/CollectionGuardsTest.php new file mode 100644 index 0000000000..453d6943a9 --- /dev/null +++ b/tests/unit/Collections/CollectionGuardsTest.php @@ -0,0 +1,349 @@ +database($adapter); + + $this->assertTrue($adapter->collectionExists('guards', Database::METADATA)); + $database->deleteCollection(Database::METADATA); + $this->assertFalse($adapter->collectionExists('guards', Database::METADATA)); + } + + public function testDeletingTheMetadataCollectionDropsItsTableOnSQLite(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $database = $this->database($adapter); + + $database->deleteCollection(Database::METADATA); + $this->assertFalse($adapter->collectionExists('guards', Database::METADATA)); + } + + public function testAMetadataFailureWhoseCleanupAlsoFailsKeepsTheMetadataFailure(): void + { + $cause = new RuntimeException('the definition could not be written'); + $adapter = new class () extends Memory { + public bool $failDrops = false; + + #[\Override] + public function deleteCollection(string $id): bool + { + if ($this->failDrops) { + throw new RuntimeException('the table could not be dropped'); + } + + return parent::deleteCollection($id); + } + }; + $database = new class ($adapter, new Cache(new None()), $cause) extends Database { + public function __construct(Adapter $adapter, Cache $cache, private readonly RuntimeException $cause) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function createDocument(string $collection, Document $document): Document + { + if ($collection === self::METADATA && $document->getId() === 'failing') { + throw $this->cause; + } + + return parent::createDocument($collection, $document); + } + }; + $database->setDatabase('guards')->setNamespace('guards_'.\uniqid()); + $database->create(); + $adapter->failDrops = true; + + $error = null; + $log = StderrCapture::during(function () use ($database, &$error): void { + try { + $database->createCollection(Collection::create(id: 'failing')); + } catch (DatabaseException $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(DatabaseException::class, $error, 'a collection whose definition is not written must not be created'); + $this->assertSame("Failed to create collection metadata for 'failing': the definition could not be written", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + $this->assertStringContainsString("Failed to rollback collection 'failing': the table could not be dropped", $log, 'the failed cleanup is logged'); + } + + public function testATenantCannotChangeATenantlessCollection(): void + { + $database = $this->sharedDatabase(); + $permissions = $database->getCollection(self::COLLECTION)->getPermissions(); + + $database->setTenant(self::TENANT); + try { + $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::user('intruder'))], documentSecurity: true)); + $this->fail('a tenant must not change a collection it does not own'); + } catch (NotFoundException $error) { + $this->assertSame('Collection not found', $error->getMessage()); + } + + $database->setTenant(null); + $this->assertSame($permissions, $database->getCollection(self::COLLECTION)->getPermissions()); + } + + public function testATenantCannotDeleteATenantlessCollection(): void + { + $database = $this->sharedDatabase(); + + $database->setTenant(self::TENANT); + try { + $database->deleteCollection(self::COLLECTION); + $this->fail('a tenant must not delete a collection it does not own'); + } catch (NotFoundException $error) { + $this->assertSame('Collection not found', $error->getMessage()); + } + + $database->setTenant(null); + $this->assertNotNull($database->findCollection(self::COLLECTION)); + } + + public function testADefinitionThatCannotBeDeletedRestoresTheTable(): void + { + $adapter = new Memory(); + $cause = new RuntimeException('the definition could not be deleted'); + $database = new class ($adapter, new Cache(new None()), $cause) extends Database { + public function __construct(Adapter $adapter, Cache $cache, private readonly RuntimeException $cause) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function deleteDocument(string $collection, string $id): bool + { + if ($collection === self::METADATA) { + throw $this->cause; + } + + return parent::deleteDocument($collection, $id); + } + }; + $this->prepare($database); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'row', 'name' => 'row'])); + + try { + $database->deleteCollection(self::COLLECTION); + $this->fail('a collection whose definition stays must not lose its table'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to persist metadata for collection deletion '".self::COLLECTION."': the definition could not be deleted", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + } + + $this->assertTrue($adapter->collectionExists('guards', self::COLLECTION), 'the table is created again'); + $this->assertNotNull($database->findCollection(self::COLLECTION)); + $this->assertSame([], $database->find(self::COLLECTION), 'the restored table is empty: only its definition survives'); + } + + public function testTheSizeOfAMissingCollectionIsNotFound(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + + foreach ([ + fn (): int => $database->getSizeOfCollection('missing'), + fn (): int => $database->getSizeOfCollectionOnDisk('missing'), + ] as $size) { + try { + $size(); + $this->fail('a missing collection has no size'); + } catch (NotFoundException $error) { + $this->assertSame('Collection not found', $error->getMessage()); + } + } + } + + public function testATenantCannotReadTheSizeOfATenantlessCollection(): void + { + $database = $this->sharedDatabase(); + $this->assertGreaterThanOrEqual(0, $database->getSizeOfCollection(self::COLLECTION)); + + $database->setTenant(self::TENANT); + foreach ([ + fn (): int => $database->getSizeOfCollection(self::COLLECTION), + fn (): int => $database->getSizeOfCollectionOnDisk(self::COLLECTION), + ] as $size) { + try { + $size(); + $this->fail('a tenant must not read the size of a collection it does not own'); + } catch (NotFoundException $error) { + $this->assertSame('Collection not found', $error->getMessage()); + } + } + } + + public function testTheSizeOnDiskNeedsATenantUnderSharedTables(): void + { + $database = $this->sharedDatabase(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Missing tenant. Tenant must be set when table sharing is enabled.'); + + $database->getSizeOfCollectionOnDisk(self::COLLECTION); + } + + public function testALostCreationRaceKeepsTheTableAndLogsAFailedCachePurge(): void + { + $adapter = new Memory(); + $winner = new DuplicateException('Document already exists'); + $database = new class ($adapter, new Cache(new None()), $winner) extends Database { + public function __construct(Adapter $adapter, Cache $cache, private readonly DuplicateException $winner) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function createDocument(string $collection, Document $document): Document + { + if ($collection === self::METADATA && $document->getId() === 'raced') { + throw $this->winner; + } + + return parent::createDocument($collection, $document); + } + + #[\Override] + public function purgeCachedDocument(string $collection, string $id): void + { + if ($id === 'raced') { + throw new RuntimeException('the cache is down'); + } + + parent::purgeCachedDocument($collection, $id); + } + }; + $database->setDatabase('guards')->setNamespace('guards_'.\uniqid()); + $database->create(); + + $error = null; + $log = StderrCapture::during(function () use ($database, &$error): void { + try { + $database->createCollection(Collection::create(id: 'raced')); + } catch (DuplicateException $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(DuplicateException::class, $error); + $this->assertSame('Collection raced already exists', $error->getMessage()); + $this->assertSame($winner, $error->getPrevious()); + $this->assertStringContainsString('Warning: Failed to purge stale collection cache: the cache is down', $log); + $this->assertTrue($adapter->collectionExists('guards', 'raced'), 'the table the winner described is kept'); + } + + public function testADefinitionThatCannotBeDeletedKeepsItsFailureWhenTheTableCannotBeRestored(): void + { + $adapter = new class () extends Memory { + public bool $failCreates = false; + + #[\Override] + public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + { + if ($this->failCreates) { + throw new RuntimeException('the table could not be created again'); + } + + return parent::createCollection($name, $attributes, $indexes); + } + }; + $cause = new RuntimeException('the definition could not be deleted'); + $database = new class ($adapter, new Cache(new None()), $cause) extends Database { + public function __construct(Adapter $adapter, Cache $cache, private readonly RuntimeException $cause) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function deleteDocument(string $collection, string $id): bool + { + if ($collection === self::METADATA) { + throw $this->cause; + } + + return parent::deleteDocument($collection, $id); + } + }; + $this->prepare($database); + $adapter->failCreates = true; + + try { + $database->deleteCollection(self::COLLECTION); + $this->fail('a collection whose definition stays must not be reported deleted'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to persist metadata for collection deletion '".self::COLLECTION."': the definition could not be deleted", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + } + + $this->assertFalse($adapter->collectionExists('guards', self::COLLECTION), 'the table stays dropped'); + $this->assertNotNull($database->findCollection(self::COLLECTION), 'the definition stays'); + } + + private function database(Adapter $adapter): Database + { + return $this->prepare(new Database($adapter, new Cache(new None()))); + } + + private function prepare(Database $database): Database + { + $database->setDatabase('guards')->setNamespace('guards_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } + + private function sharedDatabase(): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('guards') + ->setNamespace('guards_'.\uniqid()) + ->setSharedTables(true) + ->setTenant(null); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Collections/CollectionValidationTest.php b/tests/unit/Collections/CollectionValidationTest.php new file mode 100644 index 0000000000..c6f178ccd0 --- /dev/null +++ b/tests/unit/Collections/CollectionValidationTest.php @@ -0,0 +1,537 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createCollection')->willReturn(true); + $this->adapter->method('deleteCollection')->willReturn(true); + $this->adapter->method('createDocument')->willReturnArgument(1); + $this->adapter->method('updateDocument')->willReturnArgument(2); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + private function metaCollection(): Document + { + return new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + private function setupExistingCollection(string $id): void + { + $collection = new Document([ + '$id' => $id, + '$collection' => Database::METADATA, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => $id, + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + $meta = $this->metaCollection(); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($id, $collection, $meta) { + if ($col->getId() === Database::METADATA && $docId === $id) { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + + return new Document(); + } + ); + } + + private function setupEmptyMetadata(): void + { + $meta = $this->metaCollection(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($meta) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + + return new Document(); + } + ); + } + + public function testCreateCollectionThrowsOnDuplicateId(): void + { + $this->setupExistingCollection('existing'); + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('already exists'); + $this->database->createCollection(Collection::create(id: 'existing')); + } + + public function testCreateCollectionValidatesPermissionsFormat(): void + { + $this->setupEmptyMetadata(); + $this->database->setValidation(true); + + $this->expectException(DatabaseException::class); + $this->database->createCollection(Collection::create(id: 'newCol', permissions: ['bad-format'])); + } + + public function testCreateCollectionWithAttributeLimits(): void + { + $adapter = self::createStub(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 1, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(100); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $adapter->method('createCollection')->willReturn(true); + $adapter->method('deleteCollection')->willReturn(true); + $adapter->method('getDocument')->willReturn(new Document()); + + $db = new Database($adapter, new Cache(new None())); + $db->getAuthorization()->addRole(Role::any()->toString()); + + $attr = Attribute::string( + key: 'name', + size: 128, + required: false, + ); + + $this->expectException(LimitException::class); + $this->expectExceptionMessage('Attribute limit'); + $db->createCollection(Collection::create(id: 'newCol', attributes: [$attr])); + } + + public function testCreateCollectionRejectsPointAttributeOnMemoryWhenValidateIsOn(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('testing') + ->setNamespace('collections') + ->setValidation(true); + $database->create(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Spatial attributes are not supported'); + + $database->createCollection(Collection::create(id: 'places', attributes: [ + Attribute::point(key: 'location'), + ])); + } + + public function testCreateCollectionAllowsJsonAndRequiredDefaultsWhenValidateIsOn(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('testing') + ->setNamespace('collections') + ->setValidation(true); + $database->create(); + + $collection = $database->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'prefs', size: 65535, default: new \stdClass(), filters: [Filter::Json]), + Attribute::string(key: 'status', size: 32, required: true, default: 'active'), + ])); + + $this->assertSame('users', $collection->getId()); + } + + public function testCreateCollectionAcceptsCollectionModel(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('testing') + ->setNamespace('collections') + ->setValidation(true); + $database->create(); + + $collection = $database->createCollection(Collection::create( + id: 'users', + name: 'Users', + attributes: [Attribute::string(key: 'name', required: true)], + )); + + $this->assertSame('users', $collection->getId()); + $this->assertSame('Users', $collection->getAttribute('name')); + + $attributes = $collection->attributes(); + $this->assertSame(1, \count($attributes)); + $this->assertSame('name', $attributes[0]->key); + $this->assertSame(ColumnType::String, $attributes[0]->type); + } + + public function testCreateCollectionEmptyPermissionsUsesDefault(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('testing') + ->setNamespace('collections') + ->setValidation(true); + $database->create(); + + $anon = $database->createCollection(Collection::create(id: 'anon')); + $control = $database->createCollection(Collection::create(id: 'control')); + $locked = $database->createCollection(Collection::create(id: 'locked', permissions: [])); + + $this->assertSame($control->getPermissions(), $anon->getPermissions()); + $this->assertSame([Permission::create(Role::any())], $anon->getPermissions()); + $this->assertSame([], $locked->getPermissions()); + } + + public function testCreateCollectionWithIndexLimits(): void + { + $adapter = self::createStub(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 0, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(100); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $adapter->method('createCollection')->willReturn(true); + $adapter->method('deleteCollection')->willReturn(true); + $adapter->method('getDocument')->willReturn(new Document()); + + $db = new Database($adapter, new Cache(new None())); + $db->getAuthorization()->addRole(Role::any()->toString()); + + $attr = Attribute::string( + key: 'name', + size: 128, + required: false, + ); + $index = Index::key( + key: 'idx_name', + attributes: ['name'], + ); + + $this->expectException(LimitException::class); + $this->expectExceptionMessage('Index limit'); + $db->createCollection(Collection::create(id: 'newCol', attributes: [$attr], indexes: [$index])); + } + + public function testDeleteCollectionThrowsOnNotFound(): void + { + $this->adapter->method('getDocument')->willReturn(new Document()); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + $this->database->deleteCollection('nonexistent'); + } + + public function testUpdateCollectionUpdatesPermissions(): void + { + $existingCol = new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'testCol', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => false, + ]); + + $metaAttributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ]; + $metaCollection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'collections', + 'attributes' => $metaAttributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($existingCol, $metaCollection) { + if ($col->getId() === Database::METADATA && $docId === 'testCol') { + return $existingCol; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $metaCollection; + } + + return new Document(); + } + ); + $this->adapter->method('updateDocument')->willReturnArgument(2); + + $newPermissions = [Permission::read(Role::any()), Permission::create(Role::user('admin'))]; + $result = $this->database->updateCollection('testCol', new CollectionUpdate(permissions: $newPermissions, documentSecurity: true)); + $this->assertTrue($result->getAttribute('documentSecurity')); + } + + public function testUpdateCollectionUpdatesDocumentSecurity(): void + { + $existingCol = new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'testCol', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => false, + ]); + + $metaAttributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ]; + $metaCollection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'collections', + 'attributes' => $metaAttributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($existingCol, $metaCollection) { + if ($col->getId() === Database::METADATA && $docId === 'testCol') { + return $existingCol; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $metaCollection; + } + + return new Document(); + } + ); + $this->adapter->method('updateDocument')->willReturnArgument(2); + + $result = $this->database->updateCollection('testCol', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + $this->assertTrue($result->getAttribute('documentSecurity')); + } + + public function testUpdateCollectionThrowsOnNotFound(): void + { + $this->adapter->method('getDocument')->willReturn(new Document()); + $this->expectException(NotFoundException::class); + $this->database->updateCollection('nonexistent', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + } + + public function testListCollectionsReturnsCollectionDocuments(): void + { + $col1 = new Document([ + '$id' => 'col1', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any())], + 'name' => 'col1', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $metaAttributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ]; + + $metaCollection = new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], + 'name' => 'collections', + 'attributes' => $metaAttributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($metaCollection) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $metaCollection; + } + + return new Document(); + } + ); + $this->adapter->method('find')->willReturn([$col1]); + + $result = $this->database->listCollections(); + $this->assertCount(1, $result); + $this->assertSame('col1', $result[0]->getId()); + } + + public function testGetCollectionReturnsCollectionDocument(): void + { + $this->setupExistingCollection('myCol'); + + $result = $this->database->findCollection('myCol'); + $this->assertNotNull($result); + $this->assertSame('myCol', $result->getId()); + } + + public function testCollectionExistsDelegatesToAdapter(): void + { + $this->adapter->method('getDatabase')->willReturn('testdb'); + $this->adapter->method('collectionExists')->willReturn(true); + + $result = $this->database->collectionExists('testCol', 'testdb'); + $this->assertTrue($result); + } +} diff --git a/tests/unit/Collections/DefinitionModelCacheTest.php b/tests/unit/Collections/DefinitionModelCacheTest.php new file mode 100644 index 0000000000..3505329cdc --- /dev/null +++ b/tests/unit/Collections/DefinitionModelCacheTest.php @@ -0,0 +1,203 @@ +database($adapter, new Cache(new MemoryCache())); + + $this->assertSame(['title'], $this->cachedRead($database, $adapter)['attributes']); + + $database->createAttribute(self::COLLECTION, Attribute::integer(key: 'pages')); + $this->assertSame(['title', 'pages'], $this->cachedRead($database, $adapter)['attributes']); + + $database->updateAttribute(self::COLLECTION, 'title', new AttributeUpdate(size: 128)); + $read = $this->cachedRead($database, $adapter); + $this->assertSame(128, $read['sizes']['title']); + + $database->renameAttribute(self::COLLECTION, 'pages', 'length'); + $this->assertSame(['title', 'length'], $this->cachedRead($database, $adapter)['attributes']); + + $database->createIndex(self::COLLECTION, Index::key(key: 'by_length', attributes: ['length'])); + $read = $this->cachedRead($database, $adapter); + $this->assertSame(['by_length'], $read['indexes']); + + $database->deleteIndex(self::COLLECTION, 'by_length'); + $database->deleteAttribute(self::COLLECTION, 'length'); + $read = $this->cachedRead($database, $adapter); + $this->assertSame(['title'], $read['attributes']); + $this->assertSame([], $read['indexes']); + } + + public function testASchemaChangeByAnotherInstanceIsServed(): void + { + $adapter = new CountingMemory(); + $cache = new Cache(new MemoryCache()); + $reader = $this->database($adapter, $cache); + $writer = new Database($adapter, $cache); + $writer->setDatabase($reader->getDatabase())->setNamespace($reader->getNamespace()); + + $this->assertSame(['title'], $this->cachedRead($reader, $adapter)['attributes']); + + $writer->createAttribute(self::COLLECTION, Attribute::boolean(key: 'lent')); + + $this->assertSame(['title', 'lent'], $this->cachedRead($reader, $adapter)['attributes']); + } + + public function testChangingAReadModelDoesNotChangeTheNextRead(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter, new Cache(new MemoryCache())); + $expected = $this->cachedRead($database, $adapter); + + $collection = $database->getCollection(self::COLLECTION); + /** @var list $attributes */ + $attributes = $collection->getAttribute('attributes'); + $attributes[0]->setAttribute('key', 'renamed'); + $attributes[0]->setAttribute('size', 1); + $collection->setAttribute('name', 'changed'); + $collection->setAttribute('attributes', []); + + $this->assertSame($expected, $this->cachedRead($database, $adapter)); + } + + public function testTheCachedDefinitionServesItsCurrentPermissions(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter, new Cache(new MemoryCache())); + $this->cachedRead($database, $adapter); + + $this->assertSame(['any'], $database->getCollection(self::COLLECTION)->getPermissionsByType(PermissionType::Read)); + + $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::users()), Permission::update(Role::any())], documentSecurity: false)); + $this->cachedRead($database, $adapter); + $collection = $database->getCollection(self::COLLECTION); + $this->assertSame(['users'], $collection->getPermissionsByType(PermissionType::Read)); + $this->assertSame(['any'], $collection->getPermissionsByType(PermissionType::Update)); + + $collection->setAttribute('$permissions', [Permission::read(Role::guests())]); + $this->assertSame(['guests'], $collection->getPermissionsByType(PermissionType::Read)); + $this->assertSame([], $collection->getPermissionsByType(PermissionType::Update)); + $this->assertSame(['users'], $database->getCollection(self::COLLECTION)->getPermissionsByType(PermissionType::Read)); + } + + public function testReadsOfAnUnchangedCachedDefinitionShareItsModels(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter, new Cache(new MemoryCache())); + $this->cachedRead($database, $adapter); + + $first = $database->getCollection(self::COLLECTION); + $second = $database->getCollection(self::COLLECTION); + + $this->assertNotSame($first, $second); + $this->assertSame($first->attributes()[0], $second->attributes()[0]); + $this->assertSame('title', $second->attributes()[0]->key); + } + + public function testADefinitionWithAnUnbuildableRelationshipIsServedFromTheCache(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter, new Cache(new MemoryCache())); + $database->updateDocument(Database::METADATA, self::COLLECTION, new Document([ + 'attributes' => [new Document([ + Document::ID => 'author', + 'key' => 'author', + 'type' => 'relationship', + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + 'options' => [ + 'relatedCollection' => 'authors', + 'relationType' => 'oneToOne', + 'twoWay' => false, + 'twoWayKey' => 'books', + 'onDelete' => 'bogus', + 'side' => 'parent', + ], + ])], + ])); + + $database->getCollection(self::COLLECTION); + $adapter->reset(); + $collection = $database->getCollection(self::COLLECTION); + + $this->assertSame(0, $adapter->metadataReads, 'the definition was not served by the cache'); + $this->assertSame(self::COLLECTION, $collection->getId()); + $this->expectException(RelationshipException::class); + $collection->attributes(); + } + + public function testEveryCopyOfTheMetadataDefinitionSharesItsModels(): void + { + $first = Database::collectionDefinition(); + $second = Database::collectionDefinition(); + + $this->assertNotSame($first, $second); + $this->assertSame($first->attributes()[0], $second->attributes()[0]); + $this->assertSame($first->indexes(), $second->indexes()); + $this->assertSame('name', $second->attributes()[0]->key); + } + + /** + * Reads the definition twice and checks the second read was served by the cache. + * + * @return array{attributes: list, sizes: array, indexes: list, name: string} + */ + private function cachedRead(Database $database, CountingMemory $adapter): array + { + $database->getCollection(self::COLLECTION); + $adapter->reset(); + $collection = $database->getCollection(self::COLLECTION); + $this->assertSame(0, $adapter->metadataReads, 'the definition was not served by the cache'); + + $sizes = []; + foreach ($collection->attributes() as $attribute) { + $sizes[$attribute->key] = $attribute->size; + } + + return [ + 'attributes' => \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributes()), + 'sizes' => $sizes, + 'indexes' => \array_map(static fn (Index $index): string => $index->key, $collection->indexes()), + 'name' => $collection->name(), + ]; + } + + private function database(CountingMemory $adapter, Cache $cache): Database + { + $database = new Database($adapter, $cache); + $database->setDatabase('definitions')->setNamespace('definitions_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Collections/ExtrasTest.php b/tests/unit/Collections/ExtrasTest.php new file mode 100644 index 0000000000..582bb5f07d --- /dev/null +++ b/tests/unit/Collections/ExtrasTest.php @@ -0,0 +1,87 @@ + 'books', + 'attributes' => [Attribute::string('title', 64)], + 'category' => 'fiction', + ]); + + $this->assertSame('fiction', $collection->getAttribute('category')); + $this->assertSame('fiction', $collection->toDocument()->getAttribute('category')); + } + + public function testDocumentSecurityFalseIsStored(): void + { + $collection = Collection::create('books', documentSecurity: false); + + $this->assertFalse($collection->documentSecurity()); + $this->assertFalse($collection->getAttribute('documentSecurity')); + } + + public function testDocumentSecurityFalseIsStoredWithoutAnId(): void + { + $created = Collection::create('', documentSecurity: false); + $hydrated = Collection::fromArray(['documentSecurity' => false]); + + $this->assertFalse($created->documentSecurity()); + $this->assertFalse($created->getAttribute('documentSecurity')); + $this->assertFalse($hydrated->documentSecurity()); + $this->assertFalse($hydrated->getAttribute('documentSecurity')); + } + + public function testKnownKeysSurviveCreateCollection(): void + { + $database = $this->database(); + + $database->createCollection(Collection::fromArray([ + '$id' => 'books', + 'name' => 'Books', + 'documentSecurity' => false, + 'attributes' => [Attribute::string('title', 64)], + ])); + + $stored = $database->getCollection('books'); + + $this->assertSame('Books', $stored->name()); + $this->assertFalse($stored->documentSecurity()); + $this->assertSame(['title'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $stored->attributes())); + } + + public function testUnknownExtraThrowsOnCreateCollection(): void + { + $database = $this->database(); + + try { + $database->createCollection(Collection::create('books', documentSecurity: false, metadata: ['category' => 'fiction'])); + $this->fail('An unknown collection key must be rejected'); + } catch (StructureException $error) { + $this->assertStringContainsString('category', $error->getMessage()); + } + + $this->assertNull($database->findCollection('books')); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database->setDatabase('extras')->setNamespace('extras_'.\uniqid()); + $database->create(); + + return $database; + } +} diff --git a/tests/unit/Collections/FindCollectionTest.php b/tests/unit/Collections/FindCollectionTest.php new file mode 100644 index 0000000000..9652b1852f --- /dev/null +++ b/tests/unit/Collections/FindCollectionTest.php @@ -0,0 +1,85 @@ +database(); + $database->createCollection(Collection::create('books', 'Books', [Attribute::string('title', 64)])); + + $found = $database->findCollection('books'); + + $this->assertInstanceOf(Collection::class, $found); + $this->assertSame('books', $found->getId()); + $this->assertSame('Books', $found->name()); + $this->assertSame(['title'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $found->attributes())); + } + + public function testReturnsNullForAMissingCollection(): void + { + $this->assertNull($this->database()->findCollection('missing')); + } + + public function testReturnsTheMetadataDefinition(): void + { + $found = $this->database()->findCollection(Database::METADATA); + + $this->assertInstanceOf(Collection::class, $found); + $this->assertSame(Database::METADATA, $found->getId()); + } + + public function testReturnsNullAfterTheCollectionIsDeleted(): void + { + $database = $this->database(); + $database->createCollection(Collection::create('books')); + + $database->deleteCollection('books'); + + $this->assertNull($database->findCollection('books')); + } + + public function testAnotherTenantsCollectionIsNotFound(): void + { + $database = $this->database(sharedTables: true); + $database->setTenant(1); + $database->createCollection(Collection::create('books')); + + $this->assertSame('books', $database->findCollection('books')?->getId()); + + $database->setTenant(2); + + $this->assertNull($database->findCollection('books')); + + try { + $database->getCollection('books'); + $this->fail('Expected another tenant\'s collection to be not found'); + } catch (NotFoundException $error) { + $this->assertSame('Collection not found', $error->getMessage()); + } + } + + private function database(bool $sharedTables = false): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('find_collection') + ->setNamespace('find_collection_'.\uniqid()) + ->setSharedTables($sharedTables); + $database->create(); + + return $database; + } +} diff --git a/tests/unit/Collections/GetCollectionThrowsTest.php b/tests/unit/Collections/GetCollectionThrowsTest.php new file mode 100644 index 0000000000..f8849c783e --- /dev/null +++ b/tests/unit/Collections/GetCollectionThrowsTest.php @@ -0,0 +1,94 @@ +expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $this->database()->getCollection('missing'); + } + + public function testGetCollectionReturnsAnExistingCollection(): void + { + $database = $this->database(); + $database->createCollection(Collection::create('books')); + + $this->assertSame('books', $database->getCollection('books')->getId()); + } + + public function testUpdateCollectionThrowsForAMissingCollection(): void + { + $this->expectException(NotFoundException::class); + + $this->database()->updateCollection('missing', new CollectionUpdate(documentSecurity: false)); + } + + public function testDeleteCollectionThrowsForAMissingCollection(): void + { + $this->expectException(NotFoundException::class); + + $this->database()->deleteCollection('missing'); + } + + public function testSizeOfAMissingCollectionThrows(): void + { + $this->expectException(NotFoundException::class); + + $this->database()->getSizeOfCollection('missing'); + } + + public function testUpdateCollectionChangesOnlyTheGivenFields(): void + { + $database = $this->database(); + $permissions = [Permission::read(Role::any())]; + $database->createCollection(Collection::create('books', permissions: $permissions, documentSecurity: true)); + + $updated = $database->updateCollection('books', new CollectionUpdate(documentSecurity: false)); + + $this->assertFalse($updated->documentSecurity()); + $this->assertSame($permissions, $updated->getPermissions()); + $this->assertFalse($database->getCollection('books')->documentSecurity()); + $this->assertSame($permissions, $database->getCollection('books')->getPermissions()); + } + + public function testUpdateCollectionReplacesPermissions(): void + { + $database = $this->database(); + $database->createCollection(Collection::create('books', documentSecurity: false)); + $permissions = [Permission::read(Role::users())]; + + $database->updateCollection('books', new CollectionUpdate(permissions: $permissions)); + + $stored = $database->getCollection('books'); + $this->assertSame($permissions, $stored->getPermissions()); + $this->assertFalse($stored->documentSecurity()); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('get_collection') + ->setNamespace('get_collection_'.\uniqid()); + $database->create(); + + return $database; + } +} diff --git a/tests/unit/Collections/MetadataModelTest.php b/tests/unit/Collections/MetadataModelTest.php new file mode 100644 index 0000000000..da7969b42f --- /dev/null +++ b/tests/unit/Collections/MetadataModelTest.php @@ -0,0 +1,74 @@ +getArrayCopy())->getArrayCopy(); + + $first = $database->getCollection(Database::METADATA); + $this->assertSame($expected, $first->getArrayCopy()); + + /** @var list $attributes */ + $attributes = $first->getAttribute('attributes'); + $attributes[0]->setAttribute('key', 'renamed'); + $attributes[0]->setAttribute('size', 1); + $first->setAttribute('name', 'changed'); + + $second = $database->getCollection(Database::METADATA); + $this->assertSame($expected, $second->getArrayCopy()); + $this->assertNotSame($first, $second); + /** @var list $secondAttributes */ + $secondAttributes = $second->getAttribute('attributes'); + $this->assertNotSame($attributes[0], $secondAttributes[0]); + } + + public function testMetadataDefinitionReadsAreIndependentCopies(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $expected = Database::collectionDefinition()->getArrayCopy(); + + $first = $database->getDocument(Database::METADATA, Database::METADATA); + $this->assertSame($expected, $first->getArrayCopy()); + + /** @var list $attributes */ + $attributes = $first->getAttribute('attributes'); + $attributes[0]->setAttribute('key', 'renamed'); + $first->setAttribute('name', 'changed'); + + $second = $database->getDocument(Database::METADATA, Database::METADATA); + $this->assertSame($expected, $second->getArrayCopy()); + /** @var list $secondAttributes */ + $secondAttributes = $second->getAttribute('attributes'); + $this->assertNotSame($attributes[0], $secondAttributes[0]); + } + + public function testMetadataDefinitionReadUnderADecoratorIsTheDefinition(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database->addHook(new class () implements Decorator { + #[\Override] + public function decorate(Event $event, Document $collection, Document $document): Document + { + return $document->setAttribute('decorated', true); + } + }); + + $definition = $database->getDocument(Database::METADATA, Database::METADATA); + + $this->assertSame(Database::collectionDefinition()->getArrayCopy(), $definition->getArrayCopy()); + } +} diff --git a/tests/unit/Collections/MetadataWriteRecorder.php b/tests/unit/Collections/MetadataWriteRecorder.php new file mode 100644 index 0000000000..ca814c5127 --- /dev/null +++ b/tests/unit/Collections/MetadataWriteRecorder.php @@ -0,0 +1,32 @@ + + */ + private array $validations = []; + + /** + * @return list + */ + public function getValidations(): array + { + return $this->validations; + } + + #[\Override] + public function updateDocument(string $collection, string $id, Document $document): Document + { + if ($collection === self::METADATA) { + $this->validations[] = $this->isValidating(); + } + + return parent::updateDocument($collection, $id, $document); + } +} diff --git a/tests/unit/Collections/MetadataWriteValidationTest.php b/tests/unit/Collections/MetadataWriteValidationTest.php new file mode 100644 index 0000000000..8ba27d4cdd --- /dev/null +++ b/tests/unit/Collections/MetadataWriteValidationTest.php @@ -0,0 +1,175 @@ + + */ + public static function adapters(): array + { + return [ + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + 'memory' => [static fn (): Adapter => new Memory()], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCreateAttributeValidatesTheMetadataDocumentLikeCreateCollection(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->createUnvalidatedCollection($database); + + try { + $database->createAttribute('unvalidated', Attribute::integer(key: 'age')); + $this->fail('createAttribute() must not re-persist metadata that createCollection() rejects'); + } catch (DatabaseException $exception) { + $this->assertInstanceOf(StructureException::class, $exception->getPrevious()); + } + + $this->assertSame([], $this->keys($database, 'unvalidated')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCreateRelationshipValidatesTheMetadataDocumentsLikeCreateCollection(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->createUnvalidatedCollection($database); + $database->createCollection(Collection::create(id: 'related')); + + try { + $database->createRelationship('unvalidated', $this->relationship()); + $this->fail('createRelationship() must not re-persist metadata that createCollection() rejects'); + } catch (DatabaseException $exception) { + $this->assertStringStartsWith('Failed to create relationship: Invalid document structure', $exception->getMessage()); + } + + $this->assertSame([], $this->keys($database, 'unvalidated')); + $this->assertSame([], $this->keys($database, 'related')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeleteRelationshipValidatesTheMetadataDocumentsLikeCreateCollection(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->createUnvalidatedCollection($database); + $database->createCollection(Collection::create(id: 'related')); + $database->skipValidation(fn (): Relationship => $database->createRelationship('unvalidated', $this->relationship())); + + try { + $database->deleteRelationship('unvalidated', 'owner'); + $this->fail('deleteRelationship() must not re-persist metadata that createCollection() rejects'); + } catch (DatabaseException $exception) { + $this->assertStringStartsWith("Failed to persist metadata after retries for relationship deletion 'owner'", $exception->getMessage()); + $this->assertInstanceOf(StructureException::class, $exception->getPrevious()); + } + + $this->assertSame(['owner'], $this->keys($database, 'unvalidated')); + $this->assertSame(['owned'], $this->keys($database, 'related')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCreateRelationshipRollbackValidatesItsMetadataWrites(Closure $adapter): void + { + $database = new MetadataWriteRecorder($adapter(), new Cache(new None())); + $this->configure($database); + $database->createCollection(Collection::create(id: 'posts', attributes: [Attribute::string(key: 'owner', size: 64)])); + $database->createCollection(Collection::create(id: 'users')); + $database->createIndex('posts', Index::key(key: '_index_author', attributes: ['owner'])); + $setupWrites = \count($database->getValidations()); + + try { + $database->createRelationship('posts', Relationship::manyToOne( + relatedCollection: 'users', + key: 'author', + )); + $this->fail('createRelationship() must fail when the index it creates already exists'); + } catch (DatabaseException $exception) { + $this->assertSame('Failed to create relationship indexes: Index already exists', $exception->getMessage()); + } + + $this->assertSame( + [true, true, true, true], + \array_slice($database->getValidations(), $setupWrites), + "The relationship and its rollback must each write both collections' metadata with validation on", + ); + $this->assertSame(['owner'], $this->keys($database, 'posts')); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $this->configure($database); + + return $database; + } + + private function configure(Database $database): void + { + $database + ->setDatabase('metadata_write_validation') + ->setNamespace('metadata_write_validation_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + } + + private function createUnvalidatedCollection(Database $database): void + { + $collection = Collection::create(id: 'unvalidated', name: \str_repeat('n', self::OVERSIZED_NAME_LENGTH)); + $database->skipValidation(fn (): Collection => $database->createCollection($collection)); + } + + private function relationship(): Relationship + { + return Relationship::oneToMany( + relatedCollection: 'related', + twoWay: true, + key: 'owner', + twoWayKey: 'owned', + ); + } + + /** + * @return list + */ + private function keys(Database $database, string $collection): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection($collection)->attributes(), + ); + } +} diff --git a/tests/unit/Collections/UpdateCollectionValidationTest.php b/tests/unit/Collections/UpdateCollectionValidationTest.php new file mode 100644 index 0000000000..d15487908e --- /dev/null +++ b/tests/unit/Collections/UpdateCollectionValidationTest.php @@ -0,0 +1,107 @@ + + */ + public static function adapters(): array + { + return [ + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + 'memory' => [static fn (): Adapter => new Memory()], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUpdateCollectionValidatesTheMetadataDocumentLikeCreateCollection(Closure $adapter): void + { + $database = $this->database($adapter()); + $name = \str_repeat('n', 257); + + try { + $database->createCollection(Collection::create(id: 'validated', name: $name)); + $this->fail('createCollection() must reject metadata that fails structure validation'); + } catch (DatabaseException $exception) { + $this->assertInstanceOf(StructureException::class, $exception->getPrevious()); + } + + $database->skipValidation(fn (): Collection => $database->createCollection(Collection::create(id: 'unvalidated', name: $name))); + + $this->expectException(StructureException::class); + + $database->updateCollection('unvalidated', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUpdateCollectionPersistsHydratedAttributesIndexesAndRelationships(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createCollection(Collection::create( + id: 'books', + attributes: [ + Attribute::string('title', size: 64, required: true), + Attribute::integer('pages', required: false), + Attribute::string('tags', size: 16, required: false, array: true), + ], + indexes: [Index::key(key: 'title_index', attributes: ['title'])], + permissions: [Permission::create(Role::any())], + )); + $database->createCollection(Collection::create(id: 'authors', attributes: [Attribute::string('name', size: 64, required: false)])); + $database->createRelationship('books', Relationship::manyToOne( + relatedCollection: 'authors', + twoWay: true, + key: 'author', + twoWayKey: 'books', + )); + + $permissions = [Permission::read(Role::any()), Permission::update(Role::any())]; + $database->updateCollection('books', new CollectionUpdate(permissions: $permissions, documentSecurity: false)); + + $books = $database->getCollection('books'); + $this->assertSame($permissions, $books->getPermissions()); + $this->assertFalse($books->getAttribute('documentSecurity')); + $this->assertSame(['title', 'pages', 'tags', 'author'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $books->attributes())); + $this->assertSame(['title_index', '_index_author'], \array_map(static fn (Index $index): string => $index->key, $books->indexes())); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('update_collection') + ->setNamespace('update_collection_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + + return $database; + } +} diff --git a/tests/unit/ConnectionTest.php b/tests/unit/ConnectionTest.php new file mode 100644 index 0000000000..6e9994d0ce --- /dev/null +++ b/tests/unit/ConnectionTest.php @@ -0,0 +1,74 @@ + + */ + public static function lostConnectionErrors(): iterable + { + yield 'MySQL disconnected an idle client' => ['HY000', 4031]; + yield 'MySQL server has gone away' => ['HY000', 2006]; + yield 'MySQL connection lost during a query' => ['HY000', 2013]; + yield 'MySQL cannot connect' => ['HY000', 2002]; + yield 'MySQL server shutting down' => ['08S01', 1053]; + yield 'connection exception class' => ['08006', 7]; + yield 'PostgreSQL administrator shutdown' => ['57P01', 7]; + yield 'PostgreSQL crash shutdown' => ['57P02', 7]; + yield 'PostgreSQL cannot connect now' => ['57P03', 7]; + yield 'PostgreSQL database dropped' => ['57P04', 7]; + yield 'PostgreSQL idle session timeout' => ['57P05', 7]; + } + + /** + * The message names no symptom any message list knows, so only the driver's + * error code can classify it. + */ + #[DataProvider('lostConnectionErrors')] + public function testDriverErrorCodesOfALostConnectionAreDetected(string $state, int $code): void + { + $this->assertTrue(Connection::hasError($this->driverError($state, $code))); + } + + /** + * @return iterable + */ + public static function otherErrors(): iterable + { + yield 'MariaDB statement timeout' => ['70100', 1969]; + yield 'MySQL statement timeout' => ['HY000', 3024]; + yield 'PostgreSQL statement timeout' => ['57014', 7]; + yield 'duplicate key' => ['23000', 1062]; + yield 'syntax error' => ['42000', 1064]; + yield 'deadlock' => ['40001', 1213]; + } + + #[DataProvider('otherErrors')] + public function testDriverErrorCodesOfALiveConnectionAreNotDetected(string $state, int $code): void + { + $this->assertFalse(Connection::hasError($this->driverError($state, $code))); + } + + public function testConnectFailureCarryingTheDriverCodeIsDetected(): void + { + $error = new PDOException('SQLSTATE[HY000] [2002] refused', 2002); + $error->errorInfo = ['HY000', 2002, 'refused']; + + $this->assertTrue(Connection::hasError($error)); + } + + private function driverError(string $state, int $code): PDOException + { + $error = new PDOException("SQLSTATE[{$state}]: driver code {$code}"); + $error->errorInfo = [$state, $code, "driver code {$code}"]; + + return $error; + } +} diff --git a/tests/unit/CoreMinorsTest.php b/tests/unit/CoreMinorsTest.php new file mode 100644 index 0000000000..ba0d3b4f1f --- /dev/null +++ b/tests/unit/CoreMinorsTest.php @@ -0,0 +1,1005 @@ + + */ + public static function deterministicFailures(): array + { + return [ + 'authorization' => [new AuthorizationException('denied')], + 'character' => [new CharacterException('bad character')], + 'duplicate' => [new DuplicateException('duplicate')], + 'limit' => [new LimitException('limit')], + 'not found' => [new NotFoundException('missing')], + 'order' => [new OrderException('order')], + 'query' => [new QueryException('query')], + 'relationship' => [new RelationshipException('relationship')], + 'restricted' => [new RestrictedException('restricted')], + 'structure' => [new StructureException('structure')], + 'type' => [new TypeException('type')], + 'conflict' => [new ConflictException('conflict')], + 'dependency' => [new DependencyException('dependency')], + 'index' => [new IndexException('index')], + 'operator' => [new OperatorException('operator')], + 'timeout' => [new TimeoutException('timeout')], + 'truncate' => [new TruncateException('truncate')], + 'mismatch' => [new MismatchException('mismatch')], + 'unique' => [new UniqueException(UniqueException::MESSAGE)], + 'refused' => [new RefusedException('refused')], + ]; + } + + #[DataProvider('deterministicFailures')] + public function testDeterministicFailuresAreNotRetried(Throwable $failure): void + { + $writes = 0; + $database = $this->metadataFailing($failure, $writes); + + $error = $this->attempt(fn (): Attribute => $database->createAttribute('logs', Attribute::integer(key: 'count'))); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame($failure, $error->getPrevious(), 'The deterministic failure must reach the caller'); + $this->assertSame(1, $writes, 'A deterministic failure must not be retried'); + } + + public function testTransientFailuresAreRetried(): void + { + $failure = new RuntimeException('connection reset'); + $writes = 0; + $database = $this->metadataFailing($failure, $writes); + + $error = $this->attempt(fn (): Attribute => $database->createAttribute('logs', Attribute::integer(key: 'count'))); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame($failure, $error->getPrevious()); + $this->assertSame(3, $writes, 'An unknown failure must still be retried'); + } + + /** + * A failure the metadata write's transaction does not retry, such as an unavailable cache, + * is still attempted up to three times by the schema call. + */ + public function testAFailureTheTransactionDoesNotRetryIsRetriedByTheSchemaCall(): void + { + $failure = new RuntimeException('cache unavailable'); + $writes = 0; + $database = $this->metadataFailing($failure, $writes, inTransaction: true); + + $error = $this->attempt(fn (): Attribute => $database->createAttribute('logs', Attribute::integer(key: 'count'))); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame($failure, $error->getPrevious()); + $this->assertSame(3, $writes, 'The schema call must retry a failure its transaction did not'); + } + + /** + * A contended metadata write is retried by its transaction; the schema call must not run + * that transaction, with all its retries, again. + */ + public function testAContendedMetadataWriteRunsOnlyItsTransactionsAttempts(): void + { + $begins = 0; + $contended = false; + $database = $this->contendedDatabase($begins, $contended); + $database->createCollection(Collection::create(id: 'logs')); + + $contended = true; + $error = $this->attempt(fn (): Attribute => $database->createAttribute('logs', Attribute::integer(key: 'count'))); + $contended = false; + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertInstanceOf(ContentionException::class, $error->getPrevious()); + $this->assertSame(self::TRANSACTION_ATTEMPTS, $begins); + } + + public function testAContendedRelationshipDefinitionRunsOnlyItsTransactionsAttempts(): void + { + $begins = 0; + $contended = false; + $database = $this->contendedDatabase($begins, $contended); + $database->createCollection(Collection::create(id: 'profiles')); + $database->createCollection(Collection::create(id: 'accounts')); + + $contended = true; + $error = $this->attempt(fn (): Relationship => $database->createRelationship('profiles', $this->profileAccount())); + $contended = false; + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertInstanceOf(ContentionException::class, $error->getPrevious()); + $this->assertSame(self::TRANSACTION_ATTEMPTS, $begins); + $this->assertSame([], $this->attributeKeys($database, 'profiles'), 'The failed relationship must be rolled back'); + } + + public function testAContendedRelationshipDeletionRunsOnlyItsTransactionsAttempts(): void + { + $begins = 0; + $contended = false; + $database = $this->contendedDatabase( + $begins, + $contended, + afterDeleteRelationship: function () use (&$contended): void { + $contended = true; + }, + beforeCreateRelationship: function () use (&$contended): void { + $contended = false; + }, + ); + $database->createCollection(Collection::create(id: 'profiles')); + $database->createCollection(Collection::create(id: 'accounts')); + $database->createRelationship('profiles', $this->profileAccount()); + + $error = $this->attempt(fn () => $database->deleteRelationship('profiles', 'account')); + $contended = false; + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertInstanceOf(ContentionException::class, $error->getPrevious()); + $this->assertSame(self::TRANSACTION_ATTEMPTS, $begins); + $this->assertSame(['account'], $this->attributeKeys($database, 'profiles'), 'The failed deletion must keep the relationship'); + } + + public function testMetadataFailureKeepsThePersistenceErrorFirst(): void + { + $failure = new StructureException('metadata rejected'); + /** @var bool $failing */ + $failing = false; + $adapter = $this->interceptingAdapter(beforeDeleteIndex: function () use (&$failing): void { + if ($failing) { + throw new RuntimeException('index cleanup failed'); + } + }); + $database = $this->interceptingMetadataWrites(function () use (&$failing, $failure): void { + if ($failing) { + throw $failure; + } + }, $adapter); + $this->configure($database); + $database->createCollection(Collection::create(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $failing = true; + + $error = $this->attempt(fn (): Index => $database->createIndex('logs', Index::key(key: 'by_count', attributes: ['count']))); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame( + "Failed to persist metadata after retries and cleanup failed for index creation 'by_count': metadata rejected | Cleanup error: index cleanup failed", + $error->getMessage(), + ); + $this->assertSame($failure, $error->getPrevious(), 'The persistence error must stay the cause'); + } + + public function testSilentRollbackKeepsThePersistenceError(): void + { + $failure = new StructureException('metadata rejected'); + /** @var bool $failing */ + $failing = false; + $adapter = $this->interceptingAdapter(beforeCreateIndex: function () use (&$failing): void { + if ($failing) { + throw new RuntimeException('index restore failed'); + } + }); + $database = $this->interceptingMetadataWrites(function () use (&$failing, $failure): void { + if ($failing) { + throw $failure; + } + }, $adapter); + $this->configure($database); + $database->createCollection(Collection::create( + id: 'logs', + attributes: [Attribute::integer(key: 'count')], + indexes: [Index::key(key: 'by_count', attributes: ['count'])], + )); + $failing = true; + + $error = $this->attempt(fn () => $database->deleteIndex('logs', 'by_count')); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame( + "Failed to persist metadata after retries for index deletion 'by_count': metadata rejected | Cleanup error: index restore failed", + $error->getMessage(), + ); + $this->assertSame($failure, $error->getPrevious(), 'A failed silent rollback must not replace the persistence error'); + } + + public function testRollbackWhoseCleanupKeepsFailingRethrows(): void + { + /** @var bool $failing */ + $failing = false; + /** @var int $deletes */ + $deletes = 0; + $adapter = $this->interceptingAdapter(beforeDeleteIndex: function () use (&$failing, &$deletes): void { + if ($failing) { + $deletes++; + + throw new RuntimeException('index cleanup failed'); + } + }); + $database = $this->interceptingMetadataWrites(function () use (&$failing): void { + if ($failing) { + throw new StructureException('metadata rejected'); + } + }, $adapter); + $this->configure($database); + $database->createCollection(Collection::create(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $failing = true; + + $error = $this->attempt(fn (): Index => $database->createIndex('logs', Index::key(key: 'by_count', attributes: ['count']))); + $failing = false; + + $this->assertInstanceOf(DatabaseException::class, $error, 'createIndex() must fail when its rollback keeps failing'); + $this->assertStringStartsWith( + "Failed to persist metadata after retries and cleanup failed for index creation 'by_count'", + $error->getMessage(), + ); + $this->assertSame(3, $deletes, 'The index cleanup must be attempted three times'); + $this->assertSame([], $database->getCollection('logs')->indexes(), 'The metadata must list no index'); + } + + /** + * The definition with the new index committed and only the cache invalidation after the commit + * failed: the index stays, the write is not repeated, and the failure reaches the caller as raised. + */ + public function testCreateIndexKeepsItsIndexWhenTheInvalidationAfterTheCommitFails(): void + { + /** @var bool $failing */ + $failing = false; + /** @var list $failures */ + $failures = []; + $cache = $this->interceptingCache(function () use (&$failing, &$failures): void { + if ($failing) { + $failures[] = $failure = new RuntimeException('cache unavailable'); + + throw $failure; + } + }); + /** @var bool $armed */ + $armed = false; + /** @var int $writes */ + $writes = 0; + /** @var int $deletes */ + $deletes = 0; + $adapter = $this->interceptingAdapter( + beforeDeleteIndex: function () use (&$deletes): void { + $deletes++; + }, + afterCommit: function () use (&$armed, &$failing): void { + $failing = $armed; + }, + ); + $database = $this->interceptingMetadataWrites(function () use (&$armed, &$writes): void { + if ($armed) { + $writes++; + } + }, $adapter, new Cache($cache)); + $this->configure($database); + $database->createCollection(Collection::create(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $armed = true; + + $error = $this->attempt(fn (): Index => $database->createIndex('logs', Index::key(key: 'by_count', attributes: ['count']))); + $armed = false; + $failing = false; + + $this->assertSame(0, $deletes, 'An index whose definition committed must not be rolled back'); + $this->assertSame(1, $writes, 'A write that committed must not be repeated'); + $this->assertSame($failures[0] ?? null, $error, 'The failure after the commit must reach the caller as it was raised'); + $this->assertSame(['by_count'], $this->indexKeys($database, 'logs')); + $this->assertTrue($this->hasSchemaIndex($database, 'logs', 'by_count'), 'The committed index must still exist'); + } + + /** + * A failure of the metadata write itself still rolls the index back. + */ + public function testCreateIndexRollsItsIndexBackWhenTheDefinitionIsNotStored(): void + { + /** @var bool $failing */ + $failing = false; + /** @var int $deletes */ + $deletes = 0; + $adapter = $this->interceptingAdapter(beforeDeleteIndex: function () use (&$deletes): void { + $deletes++; + }); + $database = $this->interceptingMetadataWrites(function () use (&$failing): void { + if ($failing) { + throw new StructureException('metadata rejected'); + } + }, $adapter); + $this->configure($database); + $database->createCollection(Collection::create(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $failing = true; + + $error = $this->attempt(fn (): Index => $database->createIndex('logs', Index::key(key: 'by_count', attributes: ['count']))); + $failing = false; + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame(1, $deletes, 'An index without a stored definition must be dropped'); + $this->assertSame([], $database->getCollection('logs')->indexes()); + } + + /** + * The cache invalidation fails after every commit while the writes themselves succeed: the + * relationship keeps its columns and definitions, each of its indexes is still created, and + * the first failure reaches the caller as raised once they are. + */ + public function testCreateRelationshipKeepsItsWorkWhenTheInvalidationAfterTheCommitFails(): void + { + /** @var bool $failing */ + $failing = false; + /** @var list $failures */ + $failures = []; + $cache = $this->interceptingCache(function () use (&$failing, &$failures): void { + if ($failing) { + $failures[] = $failure = new RuntimeException('cache unavailable'); + + throw $failure; + } + }); + /** @var bool $armed */ + $armed = false; + $adapter = $this->interceptingAdapter( + beforeTransaction: function () use (&$failing): void { + $failing = false; + }, + afterCommit: function () use (&$armed, &$failing): void { + $failing = $armed; + }, + ); + $database = $this->interceptingMetadataWrites(static function (): void { + }, $adapter, new Cache($cache)); + $this->configure($database); + $database->createCollection(Collection::create(id: 'profiles')); + $database->createCollection(Collection::create(id: 'accounts')); + $armed = true; + + $error = $this->attempt(fn (): Relationship => $database->createRelationship('profiles', Relationship::oneToOne( + relatedCollection: 'accounts', + twoWay: true, + key: 'account', + twoWayKey: 'profile', + ))); + $armed = false; + $failing = false; + + $this->assertTrue($this->hasSchemaAttribute($database, 'profiles', 'account'), 'A committed relationship must keep its column'); + $this->assertTrue($this->hasSchemaAttribute($database, 'accounts', 'profile'), 'A committed relationship must keep its column'); + $this->assertSame(['account'], $this->attributeKeys($database, 'profiles'), 'A committed relationship must keep its definition'); + $this->assertSame(['profile'], $this->attributeKeys($database, 'accounts'), 'A committed relationship must keep its definition'); + $this->assertSame(['_index_account'], $this->indexKeys($database, 'profiles'), 'The relationship index must still be created'); + $this->assertSame(['_index_profile'], $this->indexKeys($database, 'accounts'), 'The two-way index must still be created'); + $this->assertTrue($this->hasSchemaIndex($database, 'profiles', '_index_account')); + $this->assertTrue($this->hasSchemaIndex($database, 'accounts', '_index_profile')); + $this->assertSame($failures[0] ?? null, $error, 'The failure after the commit must reach the caller as it was raised'); + } + + /** + * The cache stays unavailable after the relationship's definitions committed, so its index + * cannot be recorded and the relationship is rolled back; the definitions cannot be removed + * either, so the columns they describe must stay with them. + */ + public function testCreateRelationshipKeepsItsColumnsWhenItsDefinitionsCannotBeRemoved(): void + { + /** @var bool $failing */ + $failing = false; + $cache = $this->interceptingCache(function () use (&$failing): void { + if ($failing) { + throw new RuntimeException('cache unavailable'); + } + }); + /** @var bool $armed */ + $armed = false; + $adapter = $this->interceptingAdapter(afterCommit: function () use (&$armed, &$failing): void { + if ($armed) { + $failing = true; + } + }); + $database = $this->interceptingMetadataWrites(static function (): void { + }, $adapter, new Cache($cache)); + $this->configure($database); + $database->createCollection(Collection::create(id: 'profiles')); + $database->createCollection(Collection::create(id: 'accounts')); + $armed = true; + + $error = $this->attempt(fn (): Relationship => $database->createRelationship('profiles', Relationship::oneToOne( + relatedCollection: 'accounts', + twoWay: true, + key: 'account', + twoWayKey: 'profile', + ))); + $armed = false; + $failing = false; + $fresh = $this->uncached($adapter, $database); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertStringStartsWith('Failed to create relationship indexes: ', $error->getMessage()); + $this->assertSame(['account'], $this->attributeKeys($fresh, 'profiles')); + $this->assertSame(['profile'], $this->attributeKeys($fresh, 'accounts')); + $this->assertTrue($this->hasSchemaAttribute($fresh, 'profiles', 'account'), 'A column whose definition stays must not be dropped'); + $this->assertTrue($this->hasSchemaAttribute($fresh, 'accounts', 'profile'), 'A column whose definition stays must not be dropped'); + } + + public function testTypeMismatchMessagesSayBigint(): void + { + $validator = new AttributeDefinition(attributes: [], profile: Profiles::of()); + $error = $this->attempt(fn (): bool => $validator->isValid(Attribute::bigInteger(key: 'total', default: 'many'))); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame('Default value "many" does not match given type bigint', $error->getMessage()); + + $database = $this->interceptingMetadataWrites(static function (): void { + }); + $this->configure($database); + $database->createCollection(Collection::create(id: 'logs', attributes: [Attribute::bigInteger(key: 'total')])); + + $error = $this->attempt(fn (): Attribute => $database->updateAttribute('logs', 'total', new AttributeUpdate(default: 'many'))); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame('Default value many does not match given type bigint', $error->getMessage()); + + $error = $this->attempt(fn (): bool => $validator->isValid(Attribute::fromArray(['key' => 'value', 'type' => ColumnType::Timestamp]))); + + $this->assertInstanceOf(StructureException::class, $error); + $this->assertSame('Unknown attribute type: timestamp', $error->getMessage()); + } + + /** + * @return array + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testEveryUniqueViolationUsesOneMessage(Closure $adapter): void + { + $database = $this->interceptingMetadataWrites(static function (): void { + }, $adapter()); + $this->configure($database); + $database->createCollection(Collection::create( + id: 'users', + attributes: [Attribute::string(key: 'email', size: 64)], + indexes: [Index::unique(key: 'by_email', attributes: ['email'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $database->createDocument('users', new Document([Document::ID => 'first', 'email' => 'a@example.com'])); + $database->createDocument('users', new Document([Document::ID => 'second', 'email' => 'b@example.com'])); + + $violations = [ + 'create' => fn (): Document => $database->createDocument('users', new Document([Document::ID => 'third', 'email' => 'a@example.com'])), + 'create many' => fn (): int => $database->createDocuments('users', [ + new Document([Document::ID => 'fourth', 'email' => 'c@example.com']), + new Document([Document::ID => 'fifth', 'email' => 'c@example.com']), + ]), + 'update' => fn (): Document => $database->updateDocument('users', 'second', new Document(['email' => 'a@example.com'])), + 'update many into one value' => fn (): int => $database->updateDocuments('users', new Document(['email' => 'd@example.com'])), + 'update many into a stored value' => fn (): int => $database->updateDocuments( + 'users', + new Document(['email' => 'a@example.com']), + [Query::equal(Document::ID, ['second'])], + ), + ]; + + foreach ($violations as $name => $violation) { + $error = $this->attempt($violation); + + $this->assertInstanceOf(UniqueException::class, $error, $name); + $this->assertSame('Document with the requested unique attributes already exists', $error->getMessage(), $name); + } + } + + /** + * @return array, string}> + */ + public static function invalidSpatialDefaults(): array + { + return [ + 'point with one coordinate' => [ColumnType::Point, [1.0], 'Point must be an array of two numeric values [x, y]'], + 'point out of range' => [ColumnType::Point, [200.0, 0.0], 'Longitude'], + 'linestring with one point' => [ColumnType::Linestring, [[0.0, 0.0]], 'LineString must contain at least two points'], + 'polygon with an open ring' => [ColumnType::Polygon, [[[0.0, 0.0], [1.0, 1.0]]], 'must contain at least 4 points'], + ]; + } + + /** + * @param array $default + */ + #[DataProvider('invalidSpatialDefaults')] + public function testSpatialDefaultsAreValidated(ColumnType $type, array $default, string $reason): void + { + $validator = new AttributeDefinition(attributes: [], profile: Profiles::of(features: [Feature\Spatial::class])); + $created = $this->attempt(fn (): bool => $validator->isValid(Attribute::fromArray(['key' => 'shape', 'type' => $type, 'default' => $default]))); + + $this->assertInstanceOf(DatabaseException::class, $created, 'A create must reject the default'); + $this->assertStringContainsString($reason, $created->getMessage()); + + $database = new class ($this->adapter(), new Cache(new None())) extends Database { + public function checkDefault(ColumnType $type, mixed $default): void + { + $this->validateDefaultTypes($type, $default); + } + }; + $updated = $this->attempt(function () use ($database, $type, $default): void { + $database->checkDefault($type, $default); + }); + + $this->assertInstanceOf(DatabaseException::class, $updated, 'An update must reject the default'); + $this->assertStringContainsString($reason, $updated->getMessage()); + } + + public function testValidSpatialDefaultsAreAccepted(): void + { + $defaults = [ + [ColumnType::Point, [1.0, 2.0]], + [ColumnType::Linestring, [[0.0, 0.0], [1.0, 1.0]]], + [ColumnType::Polygon, [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]]], + ]; + $validator = new AttributeDefinition(attributes: [], profile: Profiles::of(features: [Feature\Spatial::class])); + $database = new class ($this->adapter(), new Cache(new None())) extends Database { + public function checkDefault(ColumnType $type, mixed $default): void + { + $this->validateDefaultTypes($type, $default); + } + }; + + foreach ($defaults as [$type, $default]) { + $this->assertTrue($validator->isValid(Attribute::fromArray(['key' => 'shape', 'type' => $type, 'default' => $default])), $type->value); + $database->checkDefault($type, $default); + } + } + + public function testStoredObjectValueDoesNotBlockAnUpdateOfAnotherAttribute(): void + { + $database = $this->interceptingMetadataWrites(static function (): void { + }, new Memory()); + $this->configure($database); + $database->createCollection(Collection::create( + id: 'items', + attributes: [Attribute::string(key: 'title', size: 64), Attribute::object(key: 'meta')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $database->skipValidation(fn (): Document => $database->createDocument('items', new Document([ + Document::ID => 'stored', + 'title' => 'first', + 'meta' => [1, 2], + ]))); + + $renamed = $database->updateDocument('items', 'stored', new Document(['title' => 'renamed'])); + + $this->assertSame('renamed', $renamed->getAttribute('title')); + $this->assertSame([1, 2], $renamed->getAttribute('meta')); + + $returned = $database->updateDocument('items', 'stored', $database->getDocument('items', 'stored')->setAttribute('title', 'again')); + + $this->assertSame('again', $returned->getAttribute('title'), 'A stored value passed back unchanged must not block the update'); + + $error = $this->attempt(fn (): Document => $database->updateDocument('items', 'stored', new Document(['meta' => [3, 4]]))); + + $this->assertInstanceOf(StructureException::class, $error, 'A list written as an object must still be rejected'); + $this->assertSame([1, 2], $database->getDocument('items', 'stored')->getAttribute('meta')); + } + + public function testAnAssociativeVectorIsRejectedNamingItsAttribute(): void + { + $adapter = new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::Vectors]; + } + }; + $database = $this->interceptingMetadataWrites(static function (): void { + }, $adapter); + $this->configure($database); + $database->createCollection(Collection::create( + id: 'embeddings', + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createAttribute('embeddings', Attribute::vector(key: 'embedding', dimensions: 3)); + $filters = []; + foreach ($database->getCollection('embeddings')->attributes() as $attribute) { + if ($attribute->key === 'embedding') { + $filters = $attribute->filters; + } + } + $this->assertContains(ColumnType::Vector->value, $filters, 'createAttribute() must add the vector filter'); + + $error = $this->attempt(fn (): Document => $database->createDocument('embeddings', new Document([ + 'embedding' => ['x' => 1.0, 'y' => 0.0, 'z' => 0.0], + ]))); + + $this->assertInstanceOf(StructureException::class, $error); + $this->assertSame( + 'Invalid document structure: Attribute "embedding" has invalid type. Value must be an array of 3 numeric values', + $error->getMessage(), + ); + } + + /** + * A database with a `logs` collection whose later metadata writes count into $writes and throw $failure, + * before their transaction begins or, with $inTransaction, inside it. + */ + private function metadataFailing(Throwable $failure, int &$writes, bool $inTransaction = false): Database + { + /** @var bool $failing */ + $failing = false; + $intercept = function () use (&$failing, &$writes, $failure): void { + if (! $failing) { + return; + } + + $writes++; + + throw $failure; + }; + $database = $inTransaction + ? $this->interceptingMetadataWrites(static function (): void { + }, $this->interceptingAdapter(beforeMetadataWrite: $intercept)) + : $this->interceptingMetadataWrites($intercept); + $this->configure($database); + $database->createCollection(Collection::create(id: 'logs')); + $failing = true; + + return $database; + } + + /** + * A configured database whose outermost transactions fail to begin with a lock conflict while + * $contended holds, counting each such attempt into $begins. + * + * @param (Closure(): void)|null $afterDeleteRelationship + * @param (Closure(): void)|null $beforeCreateRelationship + */ + private function contendedDatabase( + int &$begins, + bool &$contended, + ?Closure $afterDeleteRelationship = null, + ?Closure $beforeCreateRelationship = null, + ): Database { + $adapter = $this->interceptingAdapter( + beforeTransaction: function () use (&$begins, &$contended): void { + if (! $contended) { + return; + } + + $begins++; + + throw new ContentionException('Database is locked'); + }, + afterDeleteRelationship: $afterDeleteRelationship, + beforeCreateRelationship: $beforeCreateRelationship, + ); + $database = new Database($adapter, new Cache(new None())); + $this->configure($database); + + return $database; + } + + private function profileAccount(): Relationship + { + return Relationship::oneToOne( + relatedCollection: 'accounts', + twoWay: true, + key: 'account', + twoWayKey: 'profile', + ); + } + + /** + * A database that runs $intercept before every write of a collection definition. + * + * @param Closure(): void $intercept + */ + private function interceptingMetadataWrites(Closure $intercept, ?Adapter $adapter = null, ?Cache $cache = null): Database + { + return new class ($adapter ?? $this->adapter(), $cache ?? new Cache(new None()), $intercept) extends Database { + /** + * @param Closure(): void $intercept + */ + public function __construct(Adapter $adapter, Cache $cache, private readonly Closure $intercept) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function updateDocument(string $collection, string $id, Document $document): Document + { + if ($collection === self::METADATA) { + ($this->intercept)(); + } + + return parent::updateDocument($collection, $id, $document); + } + }; + } + + /** + * An adapter that runs the given hooks ahead of each index creation and deletion, ahead of + * each outermost transaction, after each outermost commit, ahead of each metadata write inside + * its transaction, after each relationship deletion and ahead of each relationship creation. + * + * @param (Closure(): void)|null $beforeCreateIndex + * @param (Closure(): void)|null $beforeDeleteIndex + * @param (Closure(): void)|null $beforeTransaction + * @param (Closure(): void)|null $afterCommit + */ + private function interceptingAdapter( + ?Closure $beforeCreateIndex = null, + ?Closure $beforeDeleteIndex = null, + ?Closure $beforeTransaction = null, + ?Closure $afterCommit = null, + ?Closure $beforeMetadataWrite = null, + ?Closure $afterDeleteRelationship = null, + ?Closure $beforeCreateRelationship = null, + ): SQLite { + return new class (new PDO('sqlite::memory:'), $beforeCreateIndex, $beforeDeleteIndex, $beforeTransaction, $afterCommit, $beforeMetadataWrite, $afterDeleteRelationship, $beforeCreateRelationship) extends SQLite { + /** + * @param (Closure(): void)|null $beforeCreateIndex + * @param (Closure(): void)|null $beforeDeleteIndex + * @param (Closure(): void)|null $beforeTransaction + * @param (Closure(): void)|null $afterCommit + * @param (Closure(): void)|null $beforeMetadataWrite + * @param (Closure(): void)|null $afterDeleteRelationship + * @param (Closure(): void)|null $beforeCreateRelationship + */ + public function __construct( + PDO $pdo, + private readonly ?Closure $beforeCreateIndex, + private readonly ?Closure $beforeDeleteIndex, + private readonly ?Closure $beforeTransaction, + private readonly ?Closure $afterCommit, + private readonly ?Closure $beforeMetadataWrite, + private readonly ?Closure $afterDeleteRelationship, + private readonly ?Closure $beforeCreateRelationship, + ) { + parent::__construct($pdo); + } + + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + if ($collection->getId() === Database::METADATA) { + $this->beforeMetadataWrite?->__invoke(); + } + + return parent::updateDocument($collection, $id, $document, $skipPermissions); + } + + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + $deleted = parent::deleteRelationship($collection, $relationship, $side); + $this->afterDeleteRelationship?->__invoke(); + + return $deleted; + } + + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool + { + $this->beforeCreateRelationship?->__invoke(); + + return parent::createRelationship($collection, $relationship); + } + + #[\Override] + public function startTransaction(): bool + { + if (! $this->inTransaction()) { + $this->beforeTransaction?->__invoke(); + } + + return parent::startTransaction(); + } + + #[\Override] + public function createIndex( + string $collection, + Index $index, + array $indexAttributeTypes = [], + array $collation = [], + Event $event = Event::IndexCreate, + ): bool { + $this->beforeCreateIndex?->__invoke(); + + return parent::createIndex($collection, $index, $indexAttributeTypes, $collation, $event); + } + + #[\Override] + public function deleteIndex(string $collection, string $id, Event $event = Event::IndexDelete): bool + { + $this->beforeDeleteIndex?->__invoke(); + + return parent::deleteIndex($collection, $id, $event); + } + + #[\Override] + public function commitTransaction(): bool + { + $committed = parent::commitTransaction(); + if (! $this->inTransaction()) { + $this->afterCommit?->__invoke(); + } + + return $committed; + } + }; + } + + /** + * @return list + */ + private function attributeKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection($collection)->attributes(), + ); + } + + /** + * @return list + */ + private function indexKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Index $index): string => $index->key, + $database->getCollection($collection)->indexes(), + ); + } + + private function hasSchemaAttribute(Database $database, string $collection, string $key): bool + { + foreach ($database->getSchemaAttributes($collection) as $column) { + if ($column->name === $key) { + return true; + } + } + + return false; + } + + private function hasSchemaIndex(Database $database, string $collection, string $key): bool + { + foreach ($database->getSchemaIndexes($collection) as $index) { + if (\str_contains($index->name, $key)) { + return true; + } + } + + return false; + } + + /** + * A cache that runs $beforeWrite ahead of each save and purge. + * + * @param Closure(): void $beforeWrite + */ + private function interceptingCache(Closure $beforeWrite): MemoryCache + { + return new class ($beforeWrite) extends MemoryCache { + /** + * @param Closure(): void $beforeWrite + */ + public function __construct(private readonly Closure $beforeWrite) + { + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + ($this->beforeWrite)(); + + return parent::save($key, $data, $hash); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + ($this->beforeWrite)(); + + return parent::purge($key, $hash); + } + }; + } + + /** + * A second database over the same adapter and namespace that reads definitions past the cache. + */ + private function uncached(Adapter $adapter, Database $database): Database + { + return (new Database($adapter, new Cache(new None()))) + ->setDatabase($database->getDatabase()) + ->setNamespace($database->getNamespace()); + } + + private function adapter(): Adapter + { + return new SQLite(new PDO('sqlite::memory:')); + } + + private function configure(Database $database): void + { + $database + ->setDatabase('core_minors') + ->setNamespace('core_minors_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + } + + /** + * @param callable(): mixed $operation + */ + private function attempt(callable $operation): ?Throwable + { + try { + $operation(); + } catch (Throwable $error) { + return $error; + } + + return null; + } +} diff --git a/tests/unit/CreateCollectionCleanupTest.php b/tests/unit/CreateCollectionCleanupTest.php new file mode 100644 index 0000000000..e66535753b --- /dev/null +++ b/tests/unit/CreateCollectionCleanupTest.php @@ -0,0 +1,189 @@ +failing) { + throw $this->failure; + } + + return parent::save($key, $data, $hash); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->failing) { + throw $this->failure; + } + + return parent::purge($key, $hash); + } + }; + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public ?Closure $afterCommit = null; + + #[\Override] + public function commitTransaction(): bool + { + $committed = parent::commitTransaction(); + if (! $this->inTransaction()) { + $this->afterCommit?->__invoke(); + } + + return $committed; + } + }; + $database = (new Database($adapter, new Cache($cache))) + ->setAuthorization(new Authorization()) + ->setDatabase('cleanup') + ->setNamespace('cleanup_'.\uniqid()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + + $adapter->afterCommit = static function () use ($cache): void { + $cache->failing = true; + }; + + $error = null; + try { + $database->createCollection(Collection::create( + id: 'logs', + attributes: [Attribute::string(key: 'message', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } catch (Throwable $thrown) { + $error = $thrown; + } + + $adapter->afterCommit = null; + $cache->failing = false; + + $this->assertNotNull($error, 'A failed invalidation after the commit must reach the caller'); + $this->assertTrue($adapter->collectionExists('cleanup', 'logs'), 'A collection whose definition committed must keep its table'); + $this->assertSame('logs', $database->getCollection('logs')->getId()); + $this->assertSame($failure, $error, 'The failure after the commit must reach the caller as it was raised'); + + $database->createDocument('logs', new Document(['message' => 'kept'])); + $this->assertSame(1, $database->count('logs')); + } + + /** + * A definition row that never committed leaves the table behind it without a + * collection, so the table is dropped. + */ + public function testCreateCollectionDropsItsTableWhenTheDefinitionIsNotStored(): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public bool $refuseDefinitions = false; + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + if ($this->refuseDefinitions && $collection->getId() === Database::METADATA) { + throw new RuntimeException('refused'); + } + + return parent::createDocument($collection, $document); + } + }; + $database = (new Database($adapter, new Cache(new MemoryCache()))) + ->setAuthorization(new Authorization()) + ->setDatabase('cleanup') + ->setNamespace('cleanup_'.\uniqid()); + $database->create(); + $adapter->refuseDefinitions = true; + + $error = null; + try { + $database->createCollection(Collection::create( + id: 'logs', + attributes: [Attribute::string(key: 'message', size: 64)], + )); + } catch (Throwable $thrown) { + $error = $thrown; + } + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertFalse($adapter->collectionExists('cleanup', 'logs'), 'A table without a stored definition must be dropped'); + $this->assertNull($database->findCollection('logs')); + } + + public function testACleanupThatFailsKeepsTheOriginalErrorAndLogsTheCleanupFailure(): void + { + $indexFailure = new RuntimeException('the index could not be built'); + $adapter = new class (new PDO('sqlite::memory:'), $indexFailure) extends SQLite { + public function __construct(PDO $pdo, private readonly RuntimeException $indexFailure) + { + parent::__construct($pdo); + } + + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = [], Event $event = Event::IndexCreate): bool + { + throw $this->indexFailure; + } + + #[\Override] + public function deleteCollection(string $id): bool + { + throw new RuntimeException('database is locked'); + } + }; + $adapter->setDatabase('cleanup'); + $adapter->setNamespace('cleanup_'.\uniqid()); + + $error = null; + $log = StderrCapture::during(function () use ($adapter, &$error): void { + try { + $adapter->createCollection('logs', [Attribute::string(key: 'message', size: 64)], [ + Index::key(key: 'byMessage', attributes: ['message']), + ]); + } catch (Throwable $caught) { + $error = $caught; + } + }); + + $this->assertSame($indexFailure, $error, 'the index failure reaches the caller, not the failed drop'); + $this->assertStringContainsString("Failed to rollback collection 'logs': database is locked", $log, 'the failed cleanup is logged'); + } +} diff --git a/tests/unit/CreateCollectionRaceTest.php b/tests/unit/CreateCollectionRaceTest.php index 6a4393802b..bde680380d 100644 --- a/tests/unit/CreateCollectionRaceTest.php +++ b/tests/unit/CreateCollectionRaceTest.php @@ -6,12 +6,14 @@ use Utopia\Cache\Adapter\Memory as CacheMemory; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\Role; class CreateCollectionRaceTest extends TestCase { @@ -26,12 +28,7 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void $database->create(); $collection = 'preCommitCreate'; - $name = new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - ]); + $name = Attribute::string(key: 'name', size: 128); $adapter->createCollection($collection, [$name], []); @@ -39,7 +36,7 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void '$id' => $collection, '$collection' => Database::METADATA, 'name' => $collection, - 'attributes' => [$name], + 'attributes' => [$name->toDocument()], 'indexes' => [], 'documentSecurity' => true, '$permissions' => [ @@ -51,16 +48,16 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void ]); $adapter->createDocument($schema, new Document([ - '$id' => ID::custom('written'), + '$id' => Id::custom('written'), '$permissions' => [Permission::read(Role::any())], 'name' => 'peer', ])); try { - $database->createCollection($collection, [$name], permissions: [ + $database->createCollection(Collection::create(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); $this->fail('Expected DuplicateException for an existing physical collection'); } catch (DuplicateException) { } @@ -77,6 +74,7 @@ public function testCreateCollectionStillReportsDuplicateWhenCachePurgeFails(): $cacheAdapter = new class () extends CacheMemory { public bool $failPurge = false; + #[\Override] public function purge(string $key, string $hash = ''): bool { if ($this->failPurge) { @@ -96,22 +94,17 @@ public function purge(string $key, string $hash = ''): bool $database->create(); $collection = 'preCommitCreatePurgeFail'; - $name = new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - ]); + $name = Attribute::string(key: 'name', size: 128); $adapter->createCollection($collection, [$name], []); $cacheAdapter->failPurge = true; try { - $database->createCollection($collection, [$name], permissions: [ + $database->createCollection(Collection::create(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); $this->fail('Expected DuplicateException even when cache purge fails'); } catch (DuplicateException $exception) { $this->assertSame('Collection ' . $collection . ' already exists', $exception->getMessage()); diff --git a/tests/unit/CustomDocumentTypeTest.php b/tests/unit/CustomDocumentTypeTest.php new file mode 100644 index 0000000000..3cf0110a95 --- /dev/null +++ b/tests/unit/CustomDocumentTypeTest.php @@ -0,0 +1,337 @@ +getAttribute('email', ''); + + return $value; + } + + public function getName(): string + { + /** @var string $value */ + $value = $this->getAttribute('name', ''); + + return $value; + } + + public function isActive(): bool + { + return $this->getAttribute('status') === 'active'; + } +} + +class TestPostDocument extends Document +{ + public function getTitle(): string + { + /** @var string $value */ + $value = $this->getAttribute('title', ''); + + return $value; + } + + public function getContent(): string + { + /** @var string $value */ + $value = $this->getAttribute('content', ''); + + return $value; + } +} + +class CustomDocumentTypeTest extends TestCase +{ + private Database $database; + + private Adapter&Stub $adapter; + + #[\Override] + protected function setUp(): void + { + $this->adapter = self::createStub(Adapter::class); + + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('limits')->willReturn(new Limits( + string: 0, + varchar: 0, + integer: 0, + bigInteger: 0, + attributes: 0, + indexes: 0, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 0, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('1970-01-01 00:00:00'), + maxDateTime: new DateTime('2999-12-31 23:59:59'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return match ($cap) { + Capability::DefinedAttributes => true, + default => false, + }; + }); + $this->adapter->method('withTransaction')->willReturnCallback( + fn (callable $callback) => $callback() + ); + $this->adapter->method('getSequences')->willReturnCallback( + fn (Document $collection, array $documents) => $documents + ); + + $cache = new Cache(new NoneAdapter()); + $this->database = new Database($this->adapter, $cache); + $this->database->setValidation(false); + $this->database->setFiltering(false); + } + + public function testSetDocumentTypeStoresMapping(): void + { + $this->database->setDocumentType('users', TestUserDocument::class); + $this->assertEquals(TestUserDocument::class, $this->database->getDocumentType('users')); + } + + public function testGetDocumentTypeReturnsClass(): void + { + $this->database->setDocumentType('posts', TestPostDocument::class); + $this->assertEquals(TestPostDocument::class, $this->database->getDocumentType('posts')); + } + + public function testGetDocumentTypeReturnsNullForUnmapped(): void + { + $this->assertNull($this->database->getDocumentType('nonexistent')); + } + + public function testSetDocumentTypeValidatesClassExists(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not exist'); + + $this->database->setDocumentType('users', 'NonExistentClass'); + } + + public function testSetDocumentTypeValidatesClassExtendsDocument(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('must extend'); + + $this->database->setDocumentType('users', \stdClass::class); + } + + public function testClearDocumentTypeRemovesMapping(): void + { + $this->database->setDocumentType('users', TestUserDocument::class); + $this->assertEquals(TestUserDocument::class, $this->database->getDocumentType('users')); + + $this->database->clearDocumentType('users'); + $this->assertNull($this->database->getDocumentType('users')); + } + + public function testClearAllDocumentTypesRemovesAll(): void + { + $this->database->setDocumentType('users', TestUserDocument::class); + $this->database->setDocumentType('posts', TestPostDocument::class); + + $this->assertEquals(TestUserDocument::class, $this->database->getDocumentType('users')); + $this->assertEquals(TestPostDocument::class, $this->database->getDocumentType('posts')); + + $this->database->clearDocumentTypes(); + + $this->assertNull($this->database->getDocumentType('users')); + $this->assertNull($this->database->getDocumentType('posts')); + } + + public function testMethodChaining(): void + { + $this->database + ->setDocumentType('users', TestUserDocument::class) + ->setDocumentType('posts', TestPostDocument::class); + + $this->assertEquals(TestUserDocument::class, $this->database->getDocumentType('users')); + $this->assertEquals(TestPostDocument::class, $this->database->getDocumentType('posts')); + } + + public function testClearDocumentTypeReturnsSelf(): void + { + $this->database->setDocumentType('users', TestUserDocument::class); + $this->database->clearDocumentType('users'); + $this->assertNull($this->database->getDocumentType('users')); + } + + public function testClearAllDocumentTypesReturnsSelf(): void + { + $this->database->setDocumentType('users', TestUserDocument::class); + $this->database->clearDocumentTypes(); + $this->assertNull($this->database->getDocumentType('users')); + } + + public function testCreateDocumentInstanceReturnsCorrectType(): void + { + $collection = new Document([ + '$id' => 'users', + '$collection' => Database::METADATA, + '$permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + 'name' => 'users', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => false, + ]); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id) use ($collection) { + if ($col->getId() === Database::METADATA && $id === 'users') { + return $collection; + } + + return new Document(); + } + ); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->database->setDocumentType('users', TestUserDocument::class); + + $this->database->getAuthorization()->cleanRoles(); + $this->database->getAuthorization()->addRole('any'); + + $result = $this->database->createDocument('users', new Document([ + '$id' => 'user1', + '$permissions' => [], + 'email' => 'test@example.com', + 'name' => 'Test User', + 'status' => 'active', + ])); + + $this->assertInstanceOf(TestUserDocument::class, $result); + $this->assertEquals('test@example.com', $result->getEmail()); + $this->assertEquals('Test User', $result->getName()); + $this->assertTrue($result->isActive()); + } + + public function testFindResultsUseMappedType(): void + { + $collection = new Document([ + '$id' => 'posts', + '$collection' => Database::METADATA, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + 'name' => 'posts', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => false, + ]); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id) use ($collection) { + if ($col->getId() === Database::METADATA && $id === 'posts') { + return $collection; + } + + return new Document(); + } + ); + + $this->adapter->method('find')->willReturn([ + new Document([ + '$id' => 'post1', + '$permissions' => [], + 'title' => 'First Post', + 'content' => 'Content of first post', + ]), + new Document([ + '$id' => 'post2', + '$permissions' => [], + 'title' => 'Second Post', + 'content' => 'Content of second post', + ]), + ]); + + $this->database->setDocumentType('posts', TestPostDocument::class); + + $this->database->getAuthorization()->cleanRoles(); + $this->database->getAuthorization()->addRole('any'); + + $results = $this->database->find('posts'); + + $this->assertCount(2, $results); + $this->assertInstanceOf(TestPostDocument::class, $results[0]); + $this->assertInstanceOf(TestPostDocument::class, $results[1]); + $this->assertEquals('First Post', $results[0]->getTitle()); + $this->assertEquals('Second Post', $results[1]->getTitle()); + } + + public function testUnmappedCollectionReturnsBaseDocument(): void + { + $collection = new Document([ + '$id' => 'generic', + '$collection' => Database::METADATA, + '$permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + 'name' => 'generic', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => false, + ]); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $id) use ($collection) { + if ($col->getId() === Database::METADATA && $id === 'generic') { + return $collection; + } + + return new Document(); + } + ); + + $this->adapter->method('createDocument')->willReturnCallback( + fn (Document $col, Document $doc) => $doc + ); + + $this->database->getAuthorization()->cleanRoles(); + $this->database->getAuthorization()->addRole('any'); + + $result = $this->database->createDocument('generic', new Document([ + '$id' => 'doc1', + '$permissions' => [], + 'data' => 'test', + ])); + + $this->assertNotInstanceOf(TestUserDocument::class, $result); + $this->assertNotInstanceOf(TestPostDocument::class, $result); + } +} diff --git a/tests/unit/DatabaseGuardsTest.php b/tests/unit/DatabaseGuardsTest.php new file mode 100644 index 0000000000..fb3be39f30 --- /dev/null +++ b/tests/unit/DatabaseGuardsTest.php @@ -0,0 +1,161 @@ + + */ + public static function adaptersWithoutTimeouts(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + public function testFromIsRefusedWithoutAQueryBuilder(): void + { + $database = $this->database(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Query builder is not supported by this adapter'); + + $database->getAuthorization()->skip(fn () => $database->from('anything')); + } + + public function testSchemaIsRefusedWithoutAQueryBuilder(): void + { + $database = $this->database(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Schema builder is not supported by this adapter'); + + $database->schema(); + } + + public function testQueryAndMutateAreRefusedWithoutRawQueries(): void + { + $database = $this->database(new Memory()); + $statement = new Statement('SELECT 1', [], readOnly: true); + + foreach ([$database->query(...), $database->mutate(...)] as $run) { + try { + $database->getAuthorization()->skip(fn () => $run($statement)); + $this->fail('A raw statement ran without raw query support'); + } catch (DatabaseException $error) { + $this->assertSame('Raw queries are not supported by this adapter', $error->getMessage()); + } + } + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adaptersWithoutTimeouts')] + public function testSetTimeoutIsRefusedWithoutTimeouts(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support timeouts'); + + $database->setTimeout(1_000); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adaptersWithoutTimeouts')] + public function testClearTimeoutIsRefusedWithoutTimeouts(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support timeouts'); + + $database->clearTimeout(); + } + + public function testGetConnectionIdIsNullWithoutAConnection(): void + { + $this->assertNull($this->database(new Memory())->getConnectionId()); + } + + public function testSchemaIntrospectionWithoutTheFeatureListsNothing(): void + { + $database = $this->database(new Memory()); + $database->create(); + $database->createCollection(Collection::create( + id: 'introspected', + attributes: [Attribute::string(key: 'name', size: 32)], + indexes: [Index::key(key: 'byName', attributes: ['name'])], + )); + + $this->assertSame([], $database->getSchemaAttributes('introspected')); + $this->assertSame([], $database->getSchemaIndexes('introspected')); + } + + public function testDropUnknownAttributesGetterFollowsTheSetter(): void + { + $database = $this->database(new Memory()); + + $this->assertFalse($database->isDroppingUnknownAttributes()); + $this->assertTrue($database->setDropUnknownAttributes(true)->isDroppingUnknownAttributes()); + $this->assertFalse($database->setDropUnknownAttributes(false)->isDroppingUnknownAttributes()); + } + + public function testSkipRelationshipsWithoutTheHookRunsTheCallback(): void + { + $database = $this->database(new Memory()); + + $this->assertNull($database->getRelationshipHook()); + $result = new stdClass(); + $this->assertSame($result, $database->skipRelationships(static fn (): stdClass => $result)); + $this->assertNull($database->getRelationshipHook(), 'skipping relationships does not install the hook'); + } + + public function testSkipRelationshipsExistCheckWithoutTheHookRunsTheCallback(): void + { + $database = $this->database(new Memory()); + + $result = new stdClass(); + $this->assertSame($result, $database->skipRelationshipsExistCheck(static fn (): stdClass => $result)); + $this->assertNull($database->getRelationshipHook()); + } + + public function testRawQueryIsRefusedWithoutRawQueries(): void + { + $database = $this->database(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Raw queries are not supported by this adapter'); + + $database->getAuthorization()->skip(fn (): array => $database->rawQuery('SELECT 1')); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('guards')->setNamespace('guards_'.\uniqid()); + + return $database; + } +} diff --git a/tests/unit/DateTimeTest.php b/tests/unit/DateTimeTest.php new file mode 100644 index 0000000000..61536f6f1d --- /dev/null +++ b/tests/unit/DateTimeTest.php @@ -0,0 +1,56 @@ +assertSame('2999-01-01 00:00:00.124', DateTime::nowAfter($future)); + } + + public function testNowAfterUsesCurrentTimeForPriorTimestamp(): void + { + $result = DateTime::nowAfter('2000-01-01 00:00:00.000'); + + $this->assertGreaterThan('2000-01-01 00:00:00.000', $result); + } + + public function testFormatTzReturnsUnparseableInputUnchanged(): void + { + $this->assertSame('not a date', DateTime::formatTz('not a date')); + $this->assertNull(DateTime::formatTz(null)); + $this->assertSame('2024-05-06T07:08:09.123+02:00', DateTime::formatTz('2024-05-06 07:08:09.123+02:00')); + } + + public function testNowAfterRejectsAnUnparseablePreviousTimestamp(): void + { + try { + DateTime::nowAfter('not a date'); + $this->fail('nowAfter() accepted an unparseable previous timestamp'); + } catch (DatabaseException $error) { + $previous = $error->getPrevious(); + $this->assertInstanceOf(DateMalformedStringException::class, $previous); + $this->assertSame($previous->getMessage(), $error->getMessage()); + } + } + + public function testSetTimezoneWrapsAnUnparseableValue(): void + { + try { + DateTime::setTimezone('not a date'); + $this->fail('setTimezone() accepted an unparseable value'); + } catch (DatabaseException $error) { + $previous = $error->getPrevious(); + $this->assertInstanceOf(DateMalformedStringException::class, $previous); + $this->assertSame($previous->getMessage(), $error->getMessage()); + } + } +} diff --git a/tests/unit/DefaultFilterDecodeTest.php b/tests/unit/DefaultFilterDecodeTest.php new file mode 100644 index 0000000000..f5e20b7d91 --- /dev/null +++ b/tests/unit/DefaultFilterDecodeTest.php @@ -0,0 +1,120 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @return array + */ + public static function storedScalars(): array + { + return [ + 'integer' => ['5', 5], + 'boolean' => ['true', true], + 'string' => ['"x"', 'x'], + 'float' => ['1.5', 1.5], + ]; + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testJsonAttributeHoldingAScalarDecodesToTheScalar(\Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'payload', size: 64, filters: [Filter::Json])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + foreach (self::storedScalars() as $id => [$stored]) { + $database->createDocument(self::COLLECTION, new Document([Document::ID => $id, 'payload' => $stored])); + } + + foreach (self::storedScalars() as $id => [, $decoded]) { + $this->assertSame($decoded, $database->getDocument(self::COLLECTION, $id)->getAttribute('payload'), "getDocument() of {$id}"); + $found = $database->findOne(self::COLLECTION, [Query::equal(Document::ID, [$id])]); + $this->assertSame($decoded, $found->getAttribute('payload'), "find() of {$id}"); + } + } + + /** + * @return array + */ + public static function spatialFilters(): array + { + $cases = []; + foreach (self::adapters() as $name => [$adapter]) { + $cases["{$name} point"] = [$adapter, ColumnType::Point, 'POINT(1 2)']; + $cases["{$name} linestring"] = [$adapter, ColumnType::Linestring, 'LINESTRING(1 2, 3 4)']; + $cases["{$name} polygon"] = [$adapter, ColumnType::Polygon, 'POLYGON((0 0, 0 1, 1 1, 0 0))']; + } + + return $cases; + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('spatialFilters')] + public function testSpatialFilterOnANonSpatialAdapterDecodesToNull(\Closure $adapter, ColumnType $type, string $stored): void + { + $database = $this->database($adapter()); + $collection = new Document([ + Document::ID => self::COLLECTION, + 'attributes' => [new Document([ + Document::ID => 'shape', + 'type' => ColumnType::String->value, + 'array' => false, + 'filters' => [$type->value], + ])], + ]); + + $decoded = $database->decode($collection, new Document([Document::ID => 'shape', 'shape' => $stored])); + + $this->assertNull($decoded->getAttribute('shape'), "{$type->value} decode without spatial support must read back as null"); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('filters')->setNamespace('decode_'.\uniqid()); + $database->create(); + + return $database; + } +} diff --git a/tests/unit/DefensiveCopyTest.php b/tests/unit/DefensiveCopyTest.php new file mode 100644 index 0000000000..fc2a26ff52 --- /dev/null +++ b/tests/unit/DefensiveCopyTest.php @@ -0,0 +1,136 @@ +attributes(); + $indexes = $this->indexes(); + $definitions = $this->snapshot($attributes, $indexes); + + $first = $this->database(new SQLite(new PDO('sqlite::memory:'))) + ->createCollection(Collection::create(id: 'databases', attributes: $attributes, indexes: $indexes)); + + $this->assertSame($definitions, $this->snapshot($attributes, $indexes), 'createCollection() rewrote the definitions it was given'); + + $second = $this->database(new Memory()) + ->createCollection(Collection::create(id: 'databases', attributes: $attributes, indexes: $indexes)); + $fresh = $this->database(new Memory()) + ->createCollection(Collection::create(id: 'databases', attributes: $this->attributes(), indexes: $this->indexes())); + + $this->assertSame($definitions, $this->snapshot($attributes, $indexes), 'createCollection() rewrote the definitions it was given'); + $this->assertSame($this->snapshot($fresh->attributes(), $fresh->indexes()), $this->snapshot($second->attributes(), $second->indexes())); + $this->assertSame($this->snapshot($first->attributes(), $first->indexes()), $this->snapshot($second->attributes(), $second->indexes())); + + $this->assertSame(['datetime'], $second->attributes()[2]->filters); + $this->assertSame([null], $second->indexes()[0]->lengths); + $this->assertSame([OrderDirection::Asc], $second->indexes()[0]->orders); + $this->assertSame([Database::MAX_ARRAY_INDEX_LENGTH], $second->indexes()[1]->lengths); + $this->assertSame([null], $second->indexes()[1]->orders); + } + + public function testCreateAttributeLeavesCallerAttributeUntouched(): void + { + $database = $this->database(new Memory()); + $database->createCollection(Collection::create(id: 'events')); + $attribute = Attribute::datetime(key: 'startsAt'); + $definition = $attribute->toDocument()->getArrayCopy(); + + $database->createAttribute('events', $attribute); + + $this->assertSame($definition, $attribute->toDocument()->getArrayCopy()); + $this->assertSame(['datetime'], $database->getCollection('events')->attributes()[0]->filters); + } + + public function testCreateAttributesLeavesCallerAttributesUntouched(): void + { + $database = $this->database(new Memory()); + $database->createCollection(Collection::create(id: 'events')); + $attributes = [Attribute::datetime(key: 'endsAt'), Attribute::string(key: 'label', size: 32)]; + $definitions = $this->snapshot($attributes, []); + + $database->createAttributes('events', $attributes); + + $this->assertSame($definitions, $this->snapshot($attributes, [])); + $this->assertSame(['datetime'], $database->getCollection('events')->attributes()[0]->filters); + } + + public function testCreateIndexLeavesCallerIndexUntouched(): void + { + $database = $this->database(new Memory()); + $database->createCollection(Collection::create(id: 'events', attributes: [Attribute::string(key: 'tags', size: 64, array: true)])); + $index = Index::key(key: '_key_tags', attributes: ['tags'], lengths: [64], orders: [OrderDirection::Desc]); + $definition = $index->toDocument()->getArrayCopy(); + + $database->createIndex('events', $index); + + $this->assertSame($definition, $index->toDocument()->getArrayCopy()); + $stored = $database->getCollection('events')->indexes()[0]; + $this->assertSame([Database::MAX_ARRAY_INDEX_LENGTH], $stored->lengths); + $this->assertSame([null], $stored->orders); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('definitions') + ->setNamespace('definitions_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->create(); + + return $database; + } + + /** + * @return list + */ + private function attributes(): array + { + return [ + Attribute::string(key: 'name', size: 256, required: true), + Attribute::string(key: 'tags', size: 64, array: true), + Attribute::datetime(key: 'expiresAt'), + ]; + } + + /** + * @return list + */ + private function indexes(): array + { + return [ + Index::key(key: '_key_name', attributes: ['name'], lengths: [256], orders: [OrderDirection::Asc]), + Index::key(key: '_key_tags', attributes: ['tags'], lengths: [64], orders: [OrderDirection::Desc]), + ]; + } + + /** + * @param array $attributes + * @param array $indexes + * @return array{attributes: list>, indexes: list>} + */ + private function snapshot(array $attributes, array $indexes): array + { + return [ + 'attributes' => \array_values(\array_map(static fn (Attribute $attribute): array => $attribute->toDocument()->getArrayCopy(), $attributes)), + 'indexes' => \array_values(\array_map(static fn (Index $index): array => $index->toDocument()->getArrayCopy(), $indexes)), + ]; + } +} diff --git a/tests/unit/DocumentAdvancedTest.php b/tests/unit/DocumentAdvancedTest.php new file mode 100644 index 0000000000..49bf28286d --- /dev/null +++ b/tests/unit/DocumentAdvancedTest.php @@ -0,0 +1,329 @@ + 'inner', 'value' => 'original']); + $middle = new Document(['$id' => 'middle', 'child' => $inner]); + $outer = new Document(['$id' => 'outer', 'child' => $middle]); + + $cloned = clone $outer; + + /** @var Document $clonedMiddle */ + $clonedMiddle = $cloned->getAttribute('child'); + /** @var Document $clonedInner */ + $clonedInner = $clonedMiddle->getAttribute('child'); + + $clonedInner->setAttribute('value', 'modified'); + + $this->assertSame('original', $inner->getAttribute('value')); + $this->assertSame('modified', $clonedInner->getAttribute('value')); + } + + public function testDeepCloneWithArrayOfDocuments(): void + { + $doc = new Document([ + '$id' => 'parent', + 'items' => [ + new Document(['$id' => 'a', 'val' => 1]), + new Document(['$id' => 'b', 'val' => 2]), + ], + ]); + + $cloned = clone $doc; + + /** @var array $clonedItems */ + $clonedItems = $cloned->getAttribute('items'); + $clonedItems[0]->setAttribute('val', 99); + + /** @var array $originalItems */ + $originalItems = $doc->getAttribute('items'); + $this->assertSame(1, $originalItems[0]->getAttribute('val')); + $this->assertSame(99, $clonedItems[0]->getAttribute('val')); + } + + public function testOnlyKeepsTheGivenKeys(): void + { + $doc = new Document([ + '$id' => 'test', + 'name' => 'John', + 'email' => 'john@example.com', + 'age' => 30, + ]); + + $copy = $doc->only(['name', 'email']); + + $this->assertArrayHasKey('name', $copy); + $this->assertArrayHasKey('email', $copy); + $this->assertArrayNotHasKey('$id', $copy); + $this->assertArrayNotHasKey('age', $copy); + } + + public function testExceptDropsTheGivenKeys(): void + { + $doc = new Document([ + '$id' => 'test', + 'name' => 'John', + 'secret' => 'hidden', + 'password' => '12345', + ]); + + $copy = $doc->except(['secret', 'password']); + + $this->assertArrayHasKey('$id', $copy); + $this->assertArrayHasKey('name', $copy); + $this->assertArrayNotHasKey('secret', $copy); + $this->assertArrayNotHasKey('password', $copy); + } + + public function testGetArrayCopyWithNestedDocuments(): void + { + $doc = new Document([ + '$id' => 'parent', + 'child' => new Document(['$id' => 'child', 'value' => 'test']), + ]); + + $copy = $doc->getArrayCopy(); + $this->assertIsArray($copy['child']); + $this->assertSame('child', $copy['child']['$id']); + $this->assertSame('test', $copy['child']['value']); + } + + public function testGetArrayCopyWithArrayOfDocuments(): void + { + $doc = new Document([ + '$id' => 'parent', + 'children' => [ + new Document(['$id' => 'a']), + new Document(['$id' => 'b']), + ], + ]); + + $copy = $doc->getArrayCopy(); + $children = $copy['children'] ?? null; + $this->assertIsArray($children); + $this->assertCount(2, $children); + $first = $children[0] ?? null; + $second = $children[1] ?? null; + $this->assertIsArray($first); + $this->assertIsArray($second); + $this->assertSame('a', $first['$id'] ?? null); + $this->assertSame('b', $second['$id'] ?? null); + } + + public function testIsEmptyOnDifferentStates(): void + { + $empty = new Document(); + $this->assertTrue($empty->isEmpty()); + + $withId = new Document(['$id' => 'test']); + $this->assertFalse($withId->isEmpty()); + + $withAttribute = new Document(['name' => 'test']); + $this->assertFalse($withAttribute->isEmpty()); + } + + public function testGetAttributeWithDefaultValue(): void + { + $doc = new Document(['$id' => 'test', 'name' => 'John']); + + $this->assertSame('John', $doc->getAttribute('name', 'default')); + $this->assertSame('default', $doc->getAttribute('missing', 'default')); + $this->assertNull($doc->getAttribute('missing')); + $this->assertSame(0, $doc->getAttribute('missing', 0)); + $this->assertSame([], $doc->getAttribute('missing', [])); + $this->assertFalse($doc->getAttribute('missing', false)); + } + + public function testRemoveAttribute(): void + { + $doc = new Document([ + '$id' => 'test', + 'name' => 'John', + 'email' => 'john@example.com', + ]); + + $doc->removeAttribute('name'); + + $this->assertNull($doc->getAttribute('name')); + $this->assertFalse($doc->isSet('name')); + $this->assertSame('john@example.com', $doc->getAttribute('email')); + } + + public function testRemoveAttributeReturnsSelf(): void + { + $doc = new Document(['$id' => 'test', 'a' => 1, 'b' => 2]); + + $doc->removeAttribute('a')->removeAttribute('b'); + + $this->assertFalse($doc->isSet('a')); + $this->assertFalse($doc->isSet('b')); + } + + public function testSetAttributesBatch(): void + { + $doc = new Document(['$id' => 'test']); + + $doc->setAttributes([ + 'name' => 'John', + 'email' => 'john@example.com', + 'age' => 25, + ]); + + $this->assertSame('John', $doc->getAttribute('name')); + $this->assertSame('john@example.com', $doc->getAttribute('email')); + $this->assertSame(25, $doc->getAttribute('age')); + } + + public function testSetAttributesBatchOverwrites(): void + { + $doc = new Document(['$id' => 'test', 'name' => 'Old']); + + $doc->setAttributes(['name' => 'New', 'extra' => 'added']); + + $this->assertSame('New', $doc->getAttribute('name')); + $this->assertSame('added', $doc->getAttribute('extra')); + } + + public function testSetAttributesBatchReturnsSelf(): void + { + $doc = new Document(['$id' => 'test']); + $result = $doc->setAttributes(['a' => 1]); + + $this->assertSame($doc, $result); + } + + public function testGetAttributesFiltersInternalKeys(): void + { + $doc = new Document([ + '$id' => 'test', + '$collection' => 'users', + '$permissions' => ['read("any")'], + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + 'name' => 'John', + 'email' => 'john@example.com', + ]); + + $attrs = $doc->getAttributes(); + + $this->assertArrayHasKey('name', $attrs); + $this->assertArrayHasKey('email', $attrs); + $this->assertArrayNotHasKey('$id', $attrs); + $this->assertArrayNotHasKey('$collection', $attrs); + $this->assertArrayNotHasKey('$permissions', $attrs); + $this->assertArrayNotHasKey('$createdAt', $attrs); + $this->assertArrayNotHasKey('$updatedAt', $attrs); + } + + public function testSetTypeAppend(): void + { + $doc = new Document(['$id' => 'test', 'tags' => ['php']]); + + $doc->setAttribute('tags', 'laravel', SetType::Append); + + $this->assertSame(['php', 'laravel'], $doc->getAttribute('tags')); + } + + public function testSetTypeAppendOnNonArray(): void + { + $doc = new Document(['$id' => 'test', 'value' => 'scalar']); + + $doc->setAttribute('value', 'item', SetType::Append); + + $this->assertSame(['item'], $doc->getAttribute('value')); + } + + public function testSetTypeAppendOnMissing(): void + { + $doc = new Document(['$id' => 'test']); + + $doc->setAttribute('newList', 'first', SetType::Append); + + $this->assertSame(['first'], $doc->getAttribute('newList')); + } + + public function testSetTypePrepend(): void + { + $doc = new Document(['$id' => 'test', 'tags' => ['php']]); + + $doc->setAttribute('tags', 'html', SetType::Prepend); + + $this->assertSame(['html', 'php'], $doc->getAttribute('tags')); + } + + public function testSetTypePrependOnNonArray(): void + { + $doc = new Document(['$id' => 'test', 'value' => 'scalar']); + + $doc->setAttribute('value', 'item', SetType::Prepend); + + $this->assertSame(['item'], $doc->getAttribute('value')); + } + + public function testSetTypePrependOnMissing(): void + { + $doc = new Document(['$id' => 'test']); + + $doc->setAttribute('newList', 'first', SetType::Prepend); + + $this->assertSame(['first'], $doc->getAttribute('newList')); + } + + public function testSetTypeAssign(): void + { + $doc = new Document(['$id' => 'test', 'name' => 'old']); + + $doc->setAttribute('name', 'new', SetType::Assign); + + $this->assertSame('new', $doc->getAttribute('name')); + } + + public function testConstructorAutoConvertsNestedArraysToDocuments(): void + { + $doc = new Document([ + '$id' => 'parent', + 'child' => ['$id' => 'child_id', 'name' => 'nested'], + ]); + + $child = $doc->getAttribute('child'); + $this->assertInstanceOf(Document::class, $child); + $this->assertSame('child_id', $child->getId()); + } + + public function testConstructorAutoConvertsArrayOfNestedDocuments(): void + { + $doc = new Document([ + '$id' => 'parent', + 'children' => [ + ['$id' => 'a', 'name' => 'first'], + ['$id' => 'b', 'name' => 'second'], + ], + ]); + + $children = $doc->getDocuments('children'); + $this->assertCount(2, $children); + $this->assertSame('a', $children[0]->getId()); + $this->assertSame('b', $children[1]->getId()); + } + + public function testGetArrayCopyWithEmptyArrayValues(): void + { + $doc = new Document([ + '$id' => 'test', + 'empty_list' => [], + 'non_empty' => ['a'], + ]); + + $copy = $doc->getArrayCopy(); + $this->assertSame([], $copy['empty_list']); + $this->assertSame(['a'], $copy['non_empty']); + } +} diff --git a/tests/unit/DocumentFromStorageTest.php b/tests/unit/DocumentFromStorageTest.php new file mode 100644 index 0000000000..29d30449a6 --- /dev/null +++ b/tests/unit/DocumentFromStorageTest.php @@ -0,0 +1,307 @@ + 'legacy', + Document::PERMISSIONS => [ + 42, + Permission::read(Role::any()), + null, + 1.5, + true, + ['read("any")'], + Permission::read(Role::any()), + Permission::update(Role::users()), + ], + ]); + + $this->assertSame( + [Permission::read(Role::any()), Permission::update(Role::users())], + $document->getAttribute(Document::PERMISSIONS), + ); + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + $this->assertSame(['users'], $document->getPermissionsByType(PermissionType::Update)); + } + + public function testTheConstructorStillRejectsNonStringPermissions(): void + { + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Every permission must be of type string'); + + new Document([Document::PERMISSIONS => [Permission::read(Role::any()), 42]]); + } + + public function testBuildsNestedDocumentsLikeTheConstructor(): void + { + $data = [ + Document::ID => 'parent', + Document::PERMISSIONS => [Permission::read(Role::any())], + 'author' => [Document::ID => 'author', 'name' => 'Ada'], + 'category' => [Document::COLLECTION => 'categories', 'name' => 'Books'], + 'comments' => [ + [Document::ID => 'first', 'body' => 'one'], + [Document::COLLECTION => 'comments', 'body' => 'two'], + ['body' => 'three'], + 'plain', + ], + 'tags' => ['a', 'b'], + 'settings' => ['theme' => 'dark', 'nested' => ['depth' => 2]], + 'count' => 3, + ]; + + $document = Document::fromStorage($data); + + $this->assertSame(self::describe(new Document($data)), self::describe($document)); + $this->assertInstanceOf(Document::class, $document->getAttribute('author')); + $this->assertInstanceOf(Document::class, $document->getAttribute('category')); + $comments = $document->getArray('comments'); + $this->assertInstanceOf(Document::class, $comments[0]); + $this->assertInstanceOf(Document::class, $comments[1]); + $this->assertSame(['body' => 'three'], $comments[2]); + $this->assertSame('plain', $comments[3]); + } + + public function testDropsNonStringPermissionsOfNestedDocuments(): void + { + $document = Document::fromStorage([ + Document::ID => 'parent', + 'author' => [ + Document::ID => 'author', + Document::PERMISSIONS => [42, Permission::read(Role::any())], + 'publisher' => [Document::ID => 'publisher', Document::PERMISSIONS => [false, Permission::read(Role::users())]], + ], + 'comments' => [ + [Document::ID => 'first', Document::PERMISSIONS => [null, Permission::delete(Role::any())]], + ], + ]); + + $author = $document->getDocument('author'); + $this->assertSame([Permission::read(Role::any())], $author->getPermissions()); + $this->assertSame([Permission::read(Role::users())], $author->getDocument('publisher')->getPermissions()); + $this->assertSame([Permission::delete(Role::any())], $document->getDocuments('comments')[0]->getPermissions()); + } + + /** + * @return array, string}> + */ + public static function malformed(): array + { + return [ + 'non-string id' => [[Document::ID => 42], Document::ID.' must be of type string'], + 'permissions that are not an array' => [ + [Document::PERMISSIONS => Permission::read(Role::any())], + Document::PERMISSIONS.' must be of type array', + ], + ]; + } + + /** + * @param array $data + */ + #[DataProvider('malformed')] + public function testRejectsWhatTheConstructorRejectsBesidesPermissionEntries(array $data, string $message): void + { + $this->expectException(StructureException::class); + $this->expectExceptionMessage($message); + + Document::fromStorage($data); + } + + public function testJsonFilterDecodesADocumentShapedValueWithANonStringPermission(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $collection = Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'prefs', size: 1024, filters: [Filter::Json]), + Attribute::string(key: 'settings', size: 1024, filters: [Filter::Json]), + ]); + + $decoded = $database->decode($collection, new Document([ + 'prefs' => '{"$id":"x","$permissions":["read(\\"any\\")",42]}', + 'settings' => '{"outer":{"$id":"y","$permissions":[7]}}', + ])); + + $prefs = $decoded->getAttribute('prefs'); + $this->assertInstanceOf(Document::class, $prefs); + $this->assertSame([Permission::read(Role::any())], $prefs->getPermissions()); + + $settings = $decoded->getAttribute('settings'); + $this->assertIsArray($settings); + $this->assertInstanceOf(Document::class, $settings['outer']); + $this->assertSame([], $settings['outer']->getPermissions()); + } + + public function testQueryCacheRebuildsCachedDocumentsWithANonStringPermission(): void + { + $queryCache = new Query(new Cache(new MemoryCache())); + $entry = $queryCache->getEntry(new Scope(), 'users', []); + $this->assertNotNull($entry); + $stored = new Document(); + $stored->exchangeArray([Document::ID => 'legacy', Document::PERMISSIONS => [Permission::read(Role::any()), 42]]); + $this->assertTrue($queryCache->set($entry, [$stored], $queryCache->getGeneration($entry))); + + $cached = $queryCache->get($entry); + + $this->assertNotNull($cached); + $this->assertCount(1, $cached); + $this->assertSame('legacy', $cached[0]->getId()); + $this->assertSame([Permission::read(Role::any())], $cached[0]->getPermissions()); + } + + /** + * A json value 7.x stored with a non-string permission stays readable through a mapped document + * type, cold and from the cache, and does not block an update of another attribute. + */ + public function testAMappedTypeReadsAStoredJsonValueWithANonStringPermission(): void + { + $reads = 0; + $adapter = new class ($reads) extends Memory { + public function __construct(private int &$reads) + { + parent::__construct(); + } + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + if ($collection->getId() === 'users') { + $this->reads++; + } + + return parent::getDocument($collection, $id, $queries, $forUpdate); + } + }; + $database = new Database($adapter, new Cache($this->jsonRoundTripCache())); + $database + ->setDatabase('from_storage') + ->setNamespace('from_storage_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: 'users', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'prefs', size: 1024, filters: [Filter::Json]), + ], + permissions: [Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $user = new class ([]) extends Document { + }; + $database->setDocumentType('users', $user::class); + $database->getAuthorization()->skip(fn (): Document => $adapter->createDocument($database->getCollection('users'), new Document([ + Document::ID => 'legacy', + Document::PERMISSIONS => [], + Document::CREATED_AT => '2024-01-01T00:00:00.000+00:00', + Document::UPDATED_AT => '2024-01-01T00:00:00.000+00:00', + 'name' => 'Ada', + 'prefs' => '{"$id":"x","$permissions":["read(\\"any\\")",42],"theme":"dark"}', + ]))); + + $cold = $database->getDocument('users', 'legacy'); + $cached = $database->getDocument('users', 'legacy'); + $this->assertSame(1, $reads, 'The second read must be served from the cache'); + + foreach (['cold' => $cold, 'cached' => $cached] as $read => $document) { + $this->assertInstanceOf($user::class, $document, $read); + $prefs = $document->getAttribute('prefs'); + $this->assertInstanceOf(Document::class, $prefs, $read); + $this->assertSame('dark', $prefs->getAttribute('theme'), $read); + $this->assertSame([Permission::read(Role::any())], $prefs->getPermissions(), $read); + } + + $found = $database->find('users'); + $this->assertCount(1, $found); + $this->assertInstanceOf($user::class, $found[0]); + + $renamed = $database->updateDocument('users', 'legacy', new Document(['name' => 'Grace'])); + $this->assertSame('Grace', $renamed->getAttribute('name')); + } + + public function testAStorageRebuildKeepsTheMappedTypeAndDropsNonStringPermissions(): void + { + $user = new class ([]) extends Document { + }; + $database = new class (new Memory(), new Cache(new None())) extends Database { + /** + * @param array $data + */ + public function rebuild(string $collection, array $data): Document + { + return $this->newDocument($collection, $data); + } + }; + $database->setDocumentType('users', $user::class); + + $document = $database->rebuild('users', [ + Document::ID => 'legacy', + Document::PERMISSIONS => [Permission::read(Role::any()), 42], + 'prefs' => [Document::ID => 'x', Document::PERMISSIONS => [7, Permission::update(Role::any())]], + 'devices' => [[Document::ID => 'phone', Document::PERMISSIONS => [false, Permission::delete(Role::any())]]], + ]); + + $this->assertInstanceOf($user::class, $document); + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + $this->assertSame([Permission::update(Role::any())], $document->getDocument('prefs')->getPermissions()); + $this->assertSame([Permission::delete(Role::any())], $document->getDocuments('devices')[0]->getPermissions()); + } + + private function jsonRoundTripCache(): MemoryCache + { + return new class () extends MemoryCache { + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if (\is_array($data)) { + /** @var array $data */ + $data = \json_decode((string) \json_encode($data), true); + } + + return parent::save($key, $data, $hash); + } + }; + } + + /** + * @return array + */ + private static function describe(Document $document): array + { + return \array_map(self::describeValue(...), \iterator_to_array($document)); + } + + private static function describeValue(mixed $value): mixed + { + if ($value instanceof Document) { + return [Document::class => self::describe($value)]; + } + + if (\is_array($value)) { + return \array_map(self::describeValue(...), $value); + } + + return $value; + } +} diff --git a/tests/unit/DocumentTest.php b/tests/unit/DocumentTest.php index 5eae1a17ad..705ca47a8f 100644 --- a/tests/unit/DocumentTest.php +++ b/tests/unit/DocumentTest.php @@ -2,73 +2,67 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use stdClass; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Exception\Structure as StructureException; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; +use Utopia\Database\Role; +use Utopia\Database\SetType; class DocumentTest extends TestCase { - /** - * @var Document - */ - protected ?Document $document = null; + protected Document $document; - /** - * @var Document - */ - protected ?Document $empty = null; + protected Document $empty; - /** - * @var string - */ - protected ?string $id = null; + protected string $id; - /** - * @var string - */ - protected ?string $collection = null; + protected string $collection; - public function setUp(): void + #[\Override] + protected function setUp(): void { $this->id = uniqid(); $this->collection = uniqid(); $this->document = new Document([ - '$id' => ID::custom($this->id), - '$collection' => ID::custom($this->collection), - '$permissions' => [ - Permission::read(Role::user(ID::custom('123'))), - Permission::read(Role::team(ID::custom('123'))), + Document::ID => Id::custom($this->id), + Document::COLLECTION => Id::custom($this->collection), + Document::PERMISSIONS => [ + Permission::read(Role::user(Id::custom('123'))), + Permission::read(Role::team(Id::custom('123'))), Permission::create(Role::any()), - Permission::create(Role::user(ID::custom('creator'))), + Permission::create(Role::user(Id::custom('creator'))), Permission::update(Role::any()), - Permission::update(Role::user(ID::custom('updater'))), + Permission::update(Role::user(Id::custom('updater'))), Permission::delete(Role::any()), - Permission::delete(Role::user(ID::custom('deleter'))), + Permission::delete(Role::user(Id::custom('deleter'))), ], 'title' => 'This is a test.', 'list' => [ - 'one' + 'one', ], 'children' => [ new Document(['name' => 'x']), new Document(['name' => 'y']), new Document(['name' => 'z']), - ] + ], ]); $this->empty = new Document(); } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } - public function testDocumentNulls(): void + public function test_document_nulls(): void { $data = [ 'cat' => null, @@ -86,93 +80,158 @@ public function testDocumentNulls(): void $this->assertEquals('dog', $document->getAttribute('dog', 'dog')); } - public function testId(): void + public function test_id(): void { $this->assertEquals($this->id, $this->document->getId()); $this->assertEquals(null, $this->empty->getId()); } - public function testCollection(): void + public function test_non_string_id_throws(): void + { + $this->expectException(StructureException::class); + $this->expectExceptionMessage(Document::ID.' must be of type string'); + + new Document([ + Document::ID => 123, + ]); + } + + public function testFromRowCoercesNullIdToEmptyString(): void + { + $document = Document::fromRow([ + Document::ID => null, + 'name' => 'unmatched', + ]); + + $this->assertSame('', $document->getId()); + $this->assertSame('unmatched', $document->getAttribute('name')); + } + + public function testFromRowDropsPdoColumnIndexes(): void + { + $document = Document::fromRow([ + 0 => 1, + 1 => 'migration', + Document::ID => 'migration', + 'state' => 'pending', + Document::SEQUENCE => '1', + ]); + + $this->assertSame(['$id', 'state', '$sequence'], \array_keys($document->getArrayCopy())); + $this->assertSame('migration', $document->getId()); + $this->assertSame('pending', $document->getAttribute('state')); + $this->assertNull($document->getAttribute('0')); + } + + public function test_id_and_collection_accessors(): void + { + $document = new Document([ + Document::ID => 'doc-1', + Document::COLLECTION => 'users', + ]); + + $this->assertSame('doc-1', $document->getId()); + $this->assertSame('users', $document->getCollection()); + } + + public function test_collection(): void { $this->assertEquals($this->collection, $this->document->getCollection()); $this->assertEquals(null, $this->empty->getCollection()); } - public function testGetCreate(): void + public function test_get_create(): void { - $this->assertEquals(['any', 'user:creator'], $this->document->getCreate()); - $this->assertEquals([], $this->empty->getCreate()); + $this->assertEquals(['any', 'user:creator'], $this->document->getPermissionsByType(PermissionType::Create)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Create)); } - public function testGetRead(): void + public function test_get_read(): void { - $this->assertEquals(['user:123', 'team:123'], $this->document->getRead()); - $this->assertEquals([], $this->empty->getRead()); + $this->assertEquals(['user:123', 'team:123'], $this->document->getPermissionsByType(PermissionType::Read)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Read)); } - public function testGetUpdate(): void + public function test_get_update(): void { - $this->assertEquals(['any', 'user:updater'], $this->document->getUpdate()); - $this->assertEquals([], $this->empty->getUpdate()); + $this->assertEquals(['any', 'user:updater'], $this->document->getPermissionsByType(PermissionType::Update)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Update)); } - public function testGetDelete(): void + public function test_get_delete(): void { - $this->assertEquals(['any', 'user:deleter'], $this->document->getDelete()); - $this->assertEquals([], $this->empty->getDelete()); + $this->assertEquals(['any', 'user:deleter'], $this->document->getPermissionsByType(PermissionType::Delete)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Delete)); } - public function testGetPermissionByType(): void + public function test_get_permission_by_type(): void { - $this->assertEquals(['any','user:creator'], $this->document->getPermissionsByType(Database::PERMISSION_CREATE)); - $this->assertEquals([], $this->empty->getPermissionsByType(Database::PERMISSION_CREATE)); + $this->assertEquals(['any', 'user:creator'], $this->document->getPermissionsByType(PermissionType::Create)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Create)); - $this->assertEquals(['user:123','team:123'], $this->document->getPermissionsByType(Database::PERMISSION_READ)); - $this->assertEquals([], $this->empty->getPermissionsByType(Database::PERMISSION_READ)); + $this->assertEquals(['user:123', 'team:123'], $this->document->getPermissionsByType(PermissionType::Read)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Read)); - $this->assertEquals(['any','user:updater'], $this->document->getPermissionsByType(Database::PERMISSION_UPDATE)); - $this->assertEquals([], $this->empty->getPermissionsByType(Database::PERMISSION_UPDATE)); + $this->assertEquals(['any', 'user:updater'], $this->document->getPermissionsByType(PermissionType::Update)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Update)); + + $this->assertEquals(['any', 'user:deleter'], $this->document->getPermissionsByType(PermissionType::Delete)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Delete)); + } + + public function test_get_permissions_by_custom_type(): void + { + $document = new Document([ + '$permissions' => [ + 'execute("any")', + 'execute("user:runner")', + Permission::read(Role::any()), + ], + ]); - $this->assertEquals(['any','user:deleter'], $this->document->getPermissionsByType(Database::PERMISSION_DELETE)); - $this->assertEquals([], $this->empty->getPermissionsByType(Database::PERMISSION_DELETE)); + $this->assertSame(['any', 'user:runner'], $document->getPermissionsByType('execute')); + $this->assertSame(['any'], $document->getPermissionsByType('read')); + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + $this->assertSame([], $document->getPermissionsByType('subscribe')); + $this->assertSame([], $document->getPermissionsByType('exec')); } - public function testGetPermissions(): void + public function test_get_permissions(): void { $this->assertEquals([ - Permission::read(Role::user(ID::custom('123'))), - Permission::read(Role::team(ID::custom('123'))), + Permission::read(Role::user(Id::custom('123'))), + Permission::read(Role::team(Id::custom('123'))), Permission::create(Role::any()), - Permission::create(Role::user(ID::custom('creator'))), + Permission::create(Role::user(Id::custom('creator'))), Permission::update(Role::any()), - Permission::update(Role::user(ID::custom('updater'))), + Permission::update(Role::user(Id::custom('updater'))), Permission::delete(Role::any()), - Permission::delete(Role::user(ID::custom('deleter'))), + Permission::delete(Role::user(Id::custom('deleter'))), ], $this->document->getPermissions()); } - public function testGetAttributes(): void + public function test_get_attributes(): void { $this->assertEquals([ 'title' => 'This is a test.', 'list' => [ - 'one' + 'one', ], 'children' => [ new Document(['name' => 'x']), new Document(['name' => 'y']), new Document(['name' => 'z']), - ] + ], ], $this->document->getAttributes()); } - public function testGetAttribute(): void + public function test_get_attribute(): void { $this->assertEquals('This is a test.', $this->document->getAttribute('title', '')); $this->assertEquals('', $this->document->getAttribute('titlex', '')); } - public function testSetAttribute(): void + public function test_set_attribute(): void { $this->assertEquals('This is a test.', $this->document->getAttribute('title', '')); $this->assertEquals(['one'], $this->document->getAttribute('list', [])); @@ -183,22 +242,22 @@ public function testSetAttribute(): void $this->assertEquals('New title', $this->document->getAttribute('title', '')); $this->assertEquals('', $this->document->getAttribute('titlex', '')); - $this->document->setAttribute('list', 'two', Document::SET_TYPE_APPEND); + $this->document->setAttribute('list', 'two', SetType::Append); $this->assertEquals(['one', 'two'], $this->document->getAttribute('list', [])); - $this->document->setAttribute('list', 'zero', Document::SET_TYPE_PREPEND); + $this->document->setAttribute('list', 'zero', SetType::Prepend); $this->assertEquals(['zero', 'one', 'two'], $this->document->getAttribute('list', [])); - $this->document->setAttribute('list', ['one'], Document::SET_TYPE_ASSIGN); + $this->document->setAttribute('list', ['one'], SetType::Assign); $this->assertEquals(['one'], $this->document->getAttribute('list', [])); } - public function testSetAttributes(): void + public function test_set_attributes(): void { - $document = new Document(['$id' => ID::custom(''), '$collection' => 'users']); + $document = new Document(['$id' => Id::custom(''), '$collection' => 'users']); $otherDocument = new Document([ - '$id' => ID::custom('new'), + '$id' => Id::custom('new'), '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::user('new')), @@ -217,129 +276,26 @@ public function testSetAttributes(): void $this->assertEquals($otherDocument->getAttribute('prefs'), $document->getAttribute('prefs')); } - public function testRemoveAttribute(): void + public function test_remove_attribute(): void { $this->document->removeAttribute('list'); $this->assertEquals([], $this->document->getAttribute('list', [])); } - public function testFind(): void - { - $this->assertEquals(null, $this->document->find('find', 'one')); - - $this->document->setAttribute('findString', 'demo'); - $this->assertEquals($this->document, $this->document->find('findString', 'demo')); - - $this->document->setAttribute('findArray', ['demo']); - $this->assertEquals(null, $this->document->find('findArray', 'demo')); - $this->assertEquals($this->document, $this->document->find('findArray', ['demo'])); - - $this->assertEquals($this->document->getAttribute('children')[0], $this->document->find('name', 'x', 'children')); - $this->assertEquals($this->document->getAttribute('children')[2], $this->document->find('name', 'z', 'children')); - $this->assertEquals(null, $this->document->find('name', 'v', 'children')); - } - - public function testFindAndReplace(): void - { - $document = new Document([ - '$id' => ID::custom($this->id), - '$collection' => ID::custom($this->collection), - '$permissions' => [ - Permission::read(Role::user(ID::custom('123'))), - Permission::read(Role::team(ID::custom('123'))), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'title' => 'This is a test.', - 'list' => [ - 'one' - ], - 'children' => [ - new Document(['name' => 'x']), - new Document(['name' => 'y']), - new Document(['name' => 'z']), - ] - ]); - - $this->assertEquals(true, $document->findAndReplace('name', 'x', new Document(['name' => '1', 'test' => true]), 'children')); - $this->assertEquals('1', $document->getAttribute('children')[0]['name']); - $this->assertEquals(true, $document->getAttribute('children')[0]['test']); - - // Array with wrong value - $this->assertEquals(false, $document->findAndReplace('name', 'xy', new Document(['name' => '1', 'test' => true]), 'children')); - - // Array with wrong key - $this->assertEquals(false, $document->findAndReplace('namex', 'x', new Document(['name' => '1', 'test' => true]), 'children')); - - // No array - $this->assertEquals(true, $document->findAndReplace('title', 'This is a test.', 'new')); - $this->assertEquals('new', $document->getAttribute('title')); - - // No array with wrong value - $this->assertEquals(false, $document->findAndReplace('title', 'test', 'new')); - - // No array with wrong key - $this->assertEquals(false, $document->findAndReplace('titlex', 'This is a test.', 'new')); - } - - public function testFindAndRemove(): void - { - $document = new Document([ - '$id' => ID::custom($this->id), - '$collection' => ID::custom($this->collection), - '$permissions' => [ - Permission::read(Role::user(ID::custom('123'))), - Permission::read(Role::team(ID::custom('123'))), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'title' => 'This is a test.', - 'list' => [ - 'one' - ], - 'children' => [ - new Document(['name' => 'x']), - new Document(['name' => 'y']), - new Document(['name' => 'z']), - ] - ]); - $this->assertEquals(true, $document->findAndRemove('name', 'x', 'children')); - $this->assertEquals('y', $document->getAttribute('children')[1]['name']); - $this->assertCount(2, $document->getAttribute('children')); - - // Array with wrong value - $this->assertEquals(false, $document->findAndRemove('name', 'xy', 'children')); - - // Array with wrong key - $this->assertEquals(false, $document->findAndRemove('namex', 'x', 'children')); - - // No array - $this->assertEquals(true, $document->findAndRemove('title', 'This is a test.')); - $this->assertEquals(false, $document->isset('title')); - - // No array with wrong value - $this->assertEquals(false, $document->findAndRemove('title', 'new')); - - // No array with wrong key - $this->assertEquals(false, $document->findAndRemove('titlex', 'This is a test.')); - } - - public function testIsEmpty(): void + public function test_is_empty(): void { $this->assertEquals(false, $this->document->isEmpty()); $this->assertEquals(true, $this->empty->isEmpty()); } - public function testIsSet(): void + public function test_is_set(): void { $this->assertEquals(false, $this->document->isSet('titlex')); $this->assertEquals(false, $this->empty->isSet('titlex')); $this->assertEquals(true, $this->document->isSet('title')); } - public function testClone(): void + public function test_clone(): void { $before = new Document([ 'level' => 0, @@ -358,65 +314,82 @@ public function testClone(): void 'children' => [ new Document([ 'level' => 3, - 'name' => 'i' + 'name' => 'i', ]), - ] - ]) - ] - ]) - ] + ], + ]), + ], + ]), + ], ]); $after = clone $before; $before->setAttribute('name', 'before'); - $before->getAttribute('document')->setAttribute('name', 'before_one'); - $before->getAttribute('children')[0]->setAttribute('name', 'before_a'); - $before->getAttribute('children')[0]->getAttribute('document')->setAttribute('name', 'before_two'); - $before->getAttribute('children')[0]->getAttribute('children')[0]->setAttribute('name', 'before_x'); + /** @var Document $beforeDoc */ + $beforeDoc = $before->getAttribute('document'); + $beforeDoc->setAttribute('name', 'before_one'); + /** @var array $beforeChildren */ + $beforeChildren = $before->getAttribute('children'); + $beforeChildren[0]->setAttribute('name', 'before_a'); + /** @var Document $beforeChildDoc */ + $beforeChildDoc = $beforeChildren[0]->getAttribute('document'); + $beforeChildDoc->setAttribute('name', 'before_two'); + /** @var array $beforeChildChildren */ + $beforeChildChildren = $beforeChildren[0]->getAttribute('children'); + $beforeChildChildren[0]->setAttribute('name', 'before_x'); $this->assertEquals('_', $after->getAttribute('name')); - $this->assertEquals('zero', $after->getAttribute('document')->getAttribute('name')); - $this->assertEquals('a', $after->getAttribute('children')[0]->getAttribute('name')); - $this->assertEquals('one', $after->getAttribute('children')[0]->getAttribute('document')->getAttribute('name')); - $this->assertEquals('x', $after->getAttribute('children')[0]->getAttribute('children')[0]->getAttribute('name')); + /** @var Document $afterDoc */ + $afterDoc = $after->getAttribute('document'); + $this->assertEquals('zero', $afterDoc->getAttribute('name')); + /** @var array $afterChildren */ + $afterChildren = $after->getAttribute('children'); + $this->assertEquals('a', $afterChildren[0]->getAttribute('name')); + /** @var Document $afterChildDoc */ + $afterChildDoc = $afterChildren[0]->getAttribute('document'); + $this->assertEquals('one', $afterChildDoc->getAttribute('name')); + /** @var array $afterChildChildren */ + $afterChildChildren = $afterChildren[0]->getAttribute('children'); + $this->assertEquals('x', $afterChildChildren[0]->getAttribute('name')); } - public function testGetArrayCopy(): void + public function test_get_array_copy(): void { $this->assertEquals([ - '$id' => ID::custom($this->id), - '$collection' => ID::custom($this->collection), + '$id' => Id::custom($this->id), + '$collection' => Id::custom($this->collection), '$permissions' => [ - Permission::read(Role::user(ID::custom('123'))), - Permission::read(Role::team(ID::custom('123'))), + Permission::read(Role::user(Id::custom('123'))), + Permission::read(Role::team(Id::custom('123'))), Permission::create(Role::any()), - Permission::create(Role::user(ID::custom('creator'))), + Permission::create(Role::user(Id::custom('creator'))), Permission::update(Role::any()), - Permission::update(Role::user(ID::custom('updater'))), + Permission::update(Role::user(Id::custom('updater'))), Permission::delete(Role::any()), - Permission::delete(Role::user(ID::custom('deleter'))), + Permission::delete(Role::user(Id::custom('deleter'))), ], 'title' => 'This is a test.', 'list' => [ - 'one' + 'one', ], 'children' => [ ['name' => 'x'], ['name' => 'y'], ['name' => 'z'], - ] + ], ], $this->document->getArrayCopy()); $this->assertEquals([], $this->empty->getArrayCopy()); } - public function testEmptyDocumentSequence(): void + public function test_empty_document_sequence(): void { $empty = new Document(); $this->assertNull($empty->getSequence()); $this->assertNotSame('', $empty->getSequence()); } + public function testConstructionPreservesScalarArraysAndConvertsOnlyDocuments(): void { $object = new \stdClass(); @@ -428,15 +401,20 @@ public function testConstructionPreservesScalarArraysAndConvertsOnlyDocuments(): ]; $document = new Document($input); + $child = $document->getAttribute('child'); + $children = $document->getArray('children'); + $this->assertSame([], $document->getAttribute('empty')); $this->assertSame($input['values'], $document->getAttribute('values')); - $this->assertSame('child', $document->getAttribute('child')->getId()); - $this->assertSame('first', $document->getAttribute('children')['first']->getId()); - $this->assertSame('plain', $document->getAttribute('children')[9]); - $this->assertSame(['$id' => 'first'], $input['children']['first']); + $this->assertInstanceOf(Document::class, $child); + $this->assertSame('child', $child->getId()); + $this->assertInstanceOf(Document::class, $children['first']); + $this->assertSame('first', $children['first']->getId()); + $this->assertSame('plain', $children[9]); + $this->assertSame($input['children']['first'], $children['first']->getArrayCopy()); } - public function testArrayCopyPreservesKeysAndFiltersNestedDocuments(): void + public function testExceptPreservesKeysAndFiltersTopLevelKeysOnly(): void { $document = new Document([ 'name' => 'parent', @@ -444,17 +422,21 @@ public function testArrayCopyPreservesKeysAndFiltersNestedDocuments(): void 'values' => [7 => 'seven', 'null' => null, 'empty' => []], 'children' => ['child' => new Document(['name' => 'nested', 'secret' => 'hidden'])], ]); - $copy = $document->getArrayCopy(['name', 'secret', 'values', 'children'], ['secret']); + $copy = $document->except(['secret']); $this->assertSame([ 'name' => 'parent', 'values' => [7 => 'seven', 'null' => null, 'empty' => []], - 'children' => ['child' => ['name' => 'nested']], + 'children' => ['child' => ['name' => 'nested', 'secret' => 'hidden']], ], $copy); $copy['values'][7] = 'changed'; $copy['children']['child']['name'] = 'changed'; - $this->assertSame('seven', $document->getAttribute('values')[7]); - $this->assertSame('nested', $document->getAttribute('children')['child']->getAttribute('name')); + + $nested = $document->getArray('children')['child']; + + $this->assertSame('seven', $document->getArray('values')[7]); + $this->assertInstanceOf(Document::class, $nested); + $this->assertSame('nested', $nested->getAttribute('name')); } public function testClonePreservesScalarKeysAndIsolatesNestedDocuments(): void @@ -466,15 +448,22 @@ public function testClonePreservesScalarKeysAndIsolatesNestedDocuments(): void 'children' => ['child' => new Document(['name' => 'nested']), 9 => 'plain'], ]); $copy = clone $original; + $this->assertIsArray($copy['values']); $copy['values'][7] = 'changed'; - $copy->getAttribute('children')['child']->setAttribute('name', 'changed'); + + $copiedChild = $copy->getArray('children')['child']; + $this->assertInstanceOf(Document::class, $copiedChild); + $copiedChild->setAttribute('name', 'changed'); + + $originalChild = $original->getArray('children')['child']; $this->assertSame([], $copy->getAttribute('empty')); - $this->assertSame([7, 'object'], array_keys($copy->getAttribute('values'))); - $this->assertSame($object, $copy->getAttribute('values')['object']); - $this->assertSame('seven', $original->getAttribute('values')[7]); - $this->assertSame('nested', $original->getAttribute('children')['child']->getAttribute('name')); - $this->assertSame('plain', $copy->getAttribute('children')[9]); + $this->assertSame([7, 'object'], array_keys($copy->getArray('values'))); + $this->assertSame($object, $copy->getArray('values')['object']); + $this->assertSame('seven', $original->getArray('values')[7]); + $this->assertInstanceOf(Document::class, $originalChild); + $this->assertSame('nested', $originalChild->getAttribute('name')); + $this->assertSame('plain', $copy->getArray('children')[9]); } public function testArrayCopyAndCloneDetachReferencedArrayElements(): void @@ -490,21 +479,279 @@ public function testArrayCopyAndCloneDetachReferencedArrayElements(): void $expected = ['first' => 'before', 7 => ['value' => 'before'], 'last' => false]; $this->assertSame($expected, $export['values']); $this->assertSame($expected, $clone->getAttribute('values')); - $this->assertSame('after', $document->getAttribute('values')['first']); + $this->assertSame('after', $document->getArray('values')['first']); } + /** + * getArrayCopy() must not wrap a scalar array's elements in references. + * + * That is a pure allocation property, and deliberately measured as one. + * PHP unwraps a reference whose refcount is 1 when the array is copied, + * so an export whose elements were wrapped is indistinguishable from a + * clean one through every userland probe: write-through in either + * direction, ReflectionReference::fromArrayElement(), var_dump() and + * serialize() all report the wrapped array as unwrapped. The detachment + * assertions in this file therefore do not cover it, and there is no + * behavioural assertion that would. + */ public function testScalarArrayExportAvoidsReferenceAllocationOverhead(): void { - $document = new Document(['values' => range(1, 100_000)]); + $values = range(1, 100_000); + $document = new Document(['values' => $values]); + + // What one honest copy of this array costs on this build, measured in + // the same process, so the bound below is a multiple of the allocator + // in front of it rather than a byte count tuned to one platform. + memory_reset_peak_usage(); + $mark = memory_get_usage(); + $plain = $values; + $plain[0] = 0; + $plainCost = memory_get_peak_usage() - $mark; + unset($plain); + memory_reset_peak_usage(); - $before = memory_get_usage(); + $mark = memory_get_usage(); $copy = $document->getArrayCopy(); - $allocated = memory_get_peak_usage() - $before; + $exportCost = memory_get_peak_usage() - $mark; + $this->assertIsArray($copy['values']); $this->assertCount(100_000, $copy['values']); - $this->assertLessThan(3 * 1024 * 1024, $allocated, 'Export should copy the array without wrapping every element in a reference'); + $this->assertGreaterThan(0, $plainCost, 'The allocator reported no cost for a plain copy, so the ratio below is meaningless'); + $this->assertLessThan( + 2 * $plainCost, + $exportCost, + \sprintf( + 'Export allocated %d bytes where a plain copy of the same array costs %d: elements are being wrapped in references.', + $exportCost, + $plainCost, + ), + ); $copy['values'][0] = 0; - $this->assertSame(1, $document->getAttribute('values')[0]); + $this->assertSame(1, $document->getArray('values')[0]); + } + + public function testArrayAccessPermissionWriteRefreshesRoles(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::user('old'))]]); + $this->assertSame(['user:old'], $document->getPermissionsByType(PermissionType::Read)); + + $document[Document::PERMISSIONS] = [Permission::read(Role::any())]; + + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + } + + public function testUnsetPermissionsClearsRoles(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::any())]]); + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + + unset($document[Document::PERMISSIONS]); + + $this->assertSame([], $document->getPermissionsByType(PermissionType::Read)); + $this->assertSame([], $document->getPermissions()); + } + + public function testExchangeArrayRefreshesRoles(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::any())]]); + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + + $document->exchangeArray([Document::PERMISSIONS => [Permission::read(Role::user('new'))]]); + + $this->assertSame(['user:new'], $document->getPermissionsByType(PermissionType::Read)); + } + + public function testReferenceWriteToPermissionsRefreshesRoles(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::user('old'))]]); + $this->assertSame(['user:old'], $document->getPermissionsByType(PermissionType::Read)); + + $permissions = &$document[Document::PERMISSIONS]; + $this->assertIsArray($permissions); + $permissions[] = Permission::update(Role::any()); + unset($permissions); + + $this->assertSame(['user:old'], $document->getPermissionsByType(PermissionType::Read)); + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Update)); + } + + public function testGetPermissionsNormalisesArrayAccessWrites(): void + { + $document = new Document(); + $document[Document::PERMISSIONS] = [ + 5 => Permission::read(Role::any()), + 9 => Permission::read(Role::any()), + 12 => Permission::update(Role::user('editor')), + ]; + + $this->assertSame( + [Permission::read(Role::any()), Permission::update(Role::user('editor'))], + $document->getPermissions(), + ); + $this->assertSame('["read(\"any\")","update(\"user:editor\")"]', \json_encode($document->getPermissions())); + $this->assertSame(['user:editor'], $document->getPermissionsByType(PermissionType::Update)); + } + + public function testConstructorRejectsNonStringPermissions(): void + { + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Every permission must be of type string'); + + new Document([Document::PERMISSIONS => [123, Permission::read(Role::any())]]); + } + + public function testConstructorDeduplicatesPermissions(): void + { + $document = new Document([Document::PERMISSIONS => [ + 2 => Permission::read(Role::any()), + 4 => Permission::read(Role::any()), + 6 => Permission::delete(Role::user('owner')), + ]]); + + $this->assertSame( + [Permission::read(Role::any()), Permission::delete(Role::user('owner'))], + $document->getAttribute(Document::PERMISSIONS), + ); + } + + public function testSetAttributeRejectsNonStringPermissions(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::any())]]); + + $writes = [ + [SetType::Assign, [Permission::update(Role::any()), 123]], + [SetType::Append, 123], + ]; + + foreach ($writes as [$type, $value]) { + try { + $document->setAttribute(Document::PERMISSIONS, $value, $type); + $this->fail('A non-string permission was accepted'); + } catch (StructureException $error) { + $this->assertSame('Every permission must be of type string', $error->getMessage()); + } + } + + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + } + + public function testSetAttributeRejectsPermissionsThatAreNotAnArray(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::any())]]); + + try { + $document->setAttribute(Document::PERMISSIONS, Permission::update(Role::any())); + $this->fail('A permissions value that is not an array was accepted'); + } catch (StructureException $error) { + $this->assertSame(Document::PERMISSIONS.' must be of type array', $error->getMessage()); + } + + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + + $document->setAttribute(Document::PERMISSIONS, null); + + $this->assertSame([], $document->getPermissions()); } + public function testFromRowFiltersNonStringPermissionsFromStoredRows(): void + { + $document = Document::fromRow([ + Document::ID => 'legacy', + Document::PERMISSIONS => [123, Permission::read(Role::any()), null, Permission::read(Role::any())], + ]); + + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + } + + /** + * @param array $row + */ + #[DataProvider('malformedRows')] + public function testFromRowRejectsANonStringIdAndNonArrayPermissions(array $row, string $message): void + { + $this->expectException(StructureException::class); + $this->expectExceptionMessage($message); + + Document::fromRow($row); + } + + /** + * @return iterable, string}> + */ + public static function malformedRows(): iterable + { + yield 'an integer $id' => [[Document::ID => 5], '$id must be of type string']; + yield 'a string $permissions' => [[Document::PERMISSIONS => 'read("any")'], '$permissions must be of type array']; + } + + #[DataProvider('tenantsOfAnotherType')] + public function testATenantOfAnotherTypeReadsAsNoTenant(mixed $tenant): void + { + $document = new Document([Document::TENANT => $tenant]); + + $this->assertNull($document->getTenant()); + } + + /** + * @return iterable + */ + public static function tenantsOfAnotherType(): iterable + { + yield 'a float' => [1.0]; + yield 'a boolean' => [true]; + yield 'an array' => [[1]]; + yield 'an object' => [new stdClass()]; + } + + public function testGetDocumentsWrapsArrayItemsAndSkipsScalars(): void + { + $first = new Document(['name' => 'first']); + $document = new Document([ + 'items' => [$first, ['name' => 'x', 0 => 'y'], 'scalar', null], + 'text' => 'not a list', + ]); + + $documents = $document->getDocuments('items'); + + $this->assertCount(2, $documents); + $this->assertSame($first, $documents[0]); + $this->assertSame(['name' => 'x'], $documents[1]->getArrayCopy()); + $this->assertSame([], $document->getDocuments('missing')); + $this->assertSame([], $document->getDocuments('text')); + } + + #[DataProvider('valuesThatAreNotADocument')] + public function testGetDocumentReturnsAnEmptyDocumentForAValueThatIsNotOne(mixed $value): void + { + $document = new Document(['value' => $value]); + + $this->assertTrue($document->getDocument('value')->isEmpty()); + $this->assertTrue($document->getDocument('missing')->isEmpty()); + } + + /** + * @return iterable + */ + public static function valuesThatAreNotADocument(): iterable + { + yield 'null' => [null]; + yield 'an empty array' => [[]]; + yield 'a list' => [['a', 'b']]; + yield 'a string' => ['x']; + } + + public function testGetDocumentWrapsTheStringKeysOfAnAssociativeArray(): void + { + $child = new Document(['name' => 'child']); + $document = new Document([ + 'map' => ['name' => 'x', 0 => 'y'], + 'child' => $child, + ]); + + $this->assertSame(['name' => 'x'], $document->getDocument('map')->getArrayCopy()); + $this->assertSame($child, $document->getDocument('child')); + } } diff --git a/tests/unit/DocumentTrimTest.php b/tests/unit/DocumentTrimTest.php new file mode 100644 index 0000000000..7d85d7a7db --- /dev/null +++ b/tests/unit/DocumentTrimTest.php @@ -0,0 +1,103 @@ + 'parent', + 'name' => 'Parent', + 'secret' => 'hidden', + 'child' => new Document([Document::ID => 'child', 'name' => 'Child', 'secret' => 'nested']), + 'children' => [ + new Document([Document::ID => 'first', 'secret' => 'first']), + 'plain', + ], + 'tags' => ['a', 'b'], + ]); + } + + public function testGetArrayCopyConvertsNestedDocumentsRecursively(): void + { + $this->assertSame([ + Document::ID => 'parent', + 'name' => 'Parent', + 'secret' => 'hidden', + 'child' => [Document::ID => 'child', 'name' => 'Child', 'secret' => 'nested'], + 'children' => [ + [Document::ID => 'first', 'secret' => 'first'], + 'plain', + ], + 'tags' => ['a', 'b'], + ], $this->document()->getArrayCopy()); + } + + public function testGetArrayCopyConvertsDocumentsNestedInNestedDocuments(): void + { + $document = new Document([ + 'child' => new Document(['grandchildren' => [new Document([Document::ID => 'grandchild'])]]), + ]); + + $this->assertSame(['child' => ['grandchildren' => [[Document::ID => 'grandchild']]]], $document->getArrayCopy()); + } + + public function testOnlyKeepsTheGivenTopLevelKeysInDocumentOrder(): void + { + $this->assertSame([ + 'name' => 'Parent', + 'child' => [Document::ID => 'child', 'name' => 'Child', 'secret' => 'nested'], + ], $this->document()->only(['child', 'name'])); + } + + public function testOnlyIgnoresKeysTheDocumentLacks(): void + { + $this->assertSame(['name' => 'Parent'], $this->document()->only(['name', 'missing'])); + } + + public function testOnlyWithNoKeysKeepsNothing(): void + { + $this->assertSame([], $this->document()->only([])); + } + + public function testExceptDropsTheGivenTopLevelKeysOnly(): void + { + $this->assertSame([ + Document::ID => 'parent', + 'name' => 'Parent', + 'child' => [Document::ID => 'child', 'name' => 'Child', 'secret' => 'nested'], + 'children' => [ + [Document::ID => 'first', 'secret' => 'first'], + 'plain', + ], + ], $this->document()->except(['secret', 'tags'])); + } + + public function testExceptWithNoKeysEqualsGetArrayCopy(): void + { + $document = $this->document(); + + $this->assertSame($document->getArrayCopy(), $document->except([])); + } + + public function testTrimmingLeavesTheDocumentUntouched(): void + { + $document = $this->document(); + + $only = $document->only(['child']); + $this->assertIsArray($only['child']); + $only['child']['name'] = 'changed'; + $except = $document->except(['name']); + $this->assertIsArray($except['children']); + $this->assertIsArray($except['children'][0]); + $except['children'][0]['secret'] = 'changed'; + + $this->assertSame('Parent', $document->getAttribute('name')); + $this->assertSame('Child', $document->getDocument('child')->getAttribute('name')); + $this->assertSame('first', $document->getDocuments('children')[0]->getAttribute('secret')); + } +} diff --git a/tests/unit/Documents/AggregateOrderTest.php b/tests/unit/Documents/AggregateOrderTest.php new file mode 100644 index 0000000000..3ee64d6ebe --- /dev/null +++ b/tests/unit/Documents/AggregateOrderTest.php @@ -0,0 +1,225 @@ +database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + } + + /** + * @return iterable, string}> + */ + public static function ungroupedOrders(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + yield 'an attribute next to a count' => [[Query::count('*', 'rows'), Query::orderAsc('balance')], 'balance']; + yield 'an attribute next to a sum' => [[Query::sum('balance', 'total'), Query::orderDesc('name')], 'name']; + yield 'an internal attribute next to a count' => [[Query::count('*', 'rows'), Query::orderAsc('$createdAt')], '$createdAt']; + yield 'an attribute outside the groups' => [[Query::count('*', 'rows'), Query::groupBy(['status']), Query::orderAsc('balance')], 'balance']; + yield 'an attribute next to a groupBy without an aggregate' => [[Query::groupBy(['status']), Query::orderAsc('name')], 'name']; + yield 'an attribute next to a distinct count' => [[Query::distinct(), Query::count('*', 'rows'), Query::orderAsc('name')], 'name']; + yield 'a joined attribute next to a count' => [[$note, Query::count('*', 'rows'), Query::orderAsc('note.score')], 'note.score']; + yield 'a joined attribute when the main one is grouped' => [[$note, Query::count('*', 'rows'), Query::groupBy(['name']), Query::orderAsc('note.name')], 'note.name']; + yield 'a main attribute when the joined one is grouped' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::orderAsc('name')], 'name']; + yield 'a joined internal attribute outside the groups' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::orderAsc('note.$id')], 'note.$id']; + } + + /** + * @param list $queries + */ + #[DataProvider('ungroupedOrders')] + public function testOrderByAnUngroupedAttributeInAnAggregationQueryIsAnInvalidQuery(array $queries, string $attribute): void + { + $this->assertInvalidQuery(self::ungrouped($attribute), fn (): mixed => $this->database->aggregate('customers', $queries)); + } + + public function testUngroupedOrderIsRejectedWhereverAQuerySetIsValidated(): void + { + $queries = [Query::count('*', 'rows'), Query::groupBy(['status']), Query::orderAsc('name')]; + + $this->assertInvalidQuery(self::ungrouped('name'), fn (): mixed => $this->database->count('customers', $queries), 'count()'); + $this->assertInvalidQuery(self::ungrouped('name'), fn (): mixed => $this->database->sum('customers', 'balance', $queries), 'sum()'); + } + + /** + * @return iterable, string, list}> + */ + public static function groupedOrders(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + yield 'a group' => [[Query::count('*', 'rows'), Query::groupBy(['status']), Query::orderDesc('status')], 'status', ['b', 'a']]; + yield 'an aggregate alias' => [[Query::count('*', 'rows'), Query::groupBy(['status']), Query::orderDesc('rows')], 'rows', [2, 1]]; + yield 'an aggregate alias and a group' => [ + [Query::sum('balance', 'total'), Query::groupBy(['status']), Query::orderAsc('total'), Query::orderAsc('status')], + 'total', + [20, 40], + ]; + yield 'a joined group' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::orderDesc('note.name')], 'name', ['second', 'first']]; + yield 'a joined group named without its alias' => [ + [$note, Query::count('*', 'rows'), Query::groupBy(['score']), Query::orderDesc('note.score')], + 'score', + [5, 4, 3], + ]; + } + + /** + * @param list $queries + * @param list $expected + */ + #[DataProvider('groupedOrders')] + public function testOrderByAGroupOrAnAggregateReturnsTheGroupsInThatOrder(array $queries, string $attribute, array $expected): void + { + $groups = $this->database->aggregate('customers', $queries); + + $this->assertSame($expected, \array_map(static fn (array $group): mixed => $group[$attribute], $groups)); + } + + public function testRandomOrderNextToAnAggregateIsAccepted(): void + { + $groups = $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['status']), Query::orderRandom()]); + + $this->assertCount(2, $groups); + } + + public function testOrdersOutsideAnAggregationQueryAreUnchanged(): void + { + $customers = $this->database->find('customers', [Query::orderDesc('balance')]); + + $this->assertSame(['Three', 'Two', 'One'], \array_map(static fn (Document $customer): mixed => $customer->getAttribute('name'), $customers)); + } + + public function testOrderValidatorAcceptsOnlyGroupsAndAggregatesOfAnAggregationQuery(): void + { + $validator = new Order([ + new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value]), + new Document(['$id' => 'status', 'key' => 'status', 'type' => ColumnType::String->value]), + ]); + + $validator->addAggregationAliases(['rows']); + $validator->setAggregations([Query::count('*', 'rows')]); + $validator->setGroupBy(['status']); + + $this->assertTrue($validator->isValid(Query::orderAsc('status')), $validator->getDescription()); + $this->assertTrue($validator->isValid(Query::orderDesc('rows')), $validator->getDescription()); + $this->assertTrue($validator->isValid(Query::orderRandom()), $validator->getDescription()); + $this->assertFalse($validator->isValid(Query::orderAsc('name'))); + $this->assertSame('Cannot order by "name": an aggregation query can only order by its groups and aggregates', $validator->getDescription()); + + $validator->setAggregations([]); + $validator->setGroupBy([]); + + $this->assertTrue($validator->isValid(Query::orderAsc('name')), 'without an aggregate or a group an order names any attribute'); + } + + private static function ungrouped(string $attribute): string + { + return 'Invalid query: Cannot order by "'.$attribute.'": an aggregation query can only order by its groups and aggregates'; + } + + /** + * @param Closure(): mixed $read + */ + private function assertInvalidQuery(string $message, Closure $read, string $label = ''): void + { + $prefix = $label === '' ? '' : $label.': '; + + try { + $read(); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage(), $prefix.'the rejection names the order'); + + return; + } + + $this->fail($prefix.'the shape was accepted: '.$message); + } + + private function database(SQLite $adapter): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase('aggregate_orders') + ->setNamespace('aggregate_orders_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + $database->create(); + + $this->createCollection($database, 'customers', [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'status', size: 16), + Attribute::integer(key: 'balance'), + ]); + $this->createCollection($database, 'notes', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'score'), + ]); + + $this->createDocument($database, 'customers', 'c1', ['name' => 'One', 'status' => 'a', 'balance' => 10]); + $this->createDocument($database, 'customers', 'c2', ['name' => 'Two', 'status' => 'b', 'balance' => 20]); + $this->createDocument($database, 'customers', 'c3', ['name' => 'Three', 'status' => 'a', 'balance' => 30]); + $this->createDocument($database, 'notes', 'n1', ['customerId' => 'c1', 'name' => 'first', 'score' => 3]); + $this->createDocument($database, 'notes', 'n2', ['customerId' => 'c1', 'name' => 'second', 'score' => 4]); + $this->createDocument($database, 'notes', 'n3', ['customerId' => 'c2', 'name' => 'first', 'score' => 5]); + $this->createDocument($database, 'notes', 'n4', ['customerId' => 'c9', 'name' => 'third', 'score' => 6]); + + return $database; + } + + /** + * @param list $attributes + */ + private function createCollection(Database $database, string $id, array $attributes): void + { + $database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + } + + /** + * @param array $attributes + */ + private function createDocument(Database $database, string $collection, string $id, array $attributes): void + { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Documents/AggregateSelectTest.php b/tests/unit/Documents/AggregateSelectTest.php new file mode 100644 index 0000000000..7c3dc02726 --- /dev/null +++ b/tests/unit/Documents/AggregateSelectTest.php @@ -0,0 +1,442 @@ +database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + } + + /** + * @return iterable, string}> + */ + public static function ungroupedSelects(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + yield 'an attribute next to a count' => [[Query::count('*', 'rows'), Query::select(['name'])], 'name']; + yield 'an attribute next to a sum' => [[Query::sum('balance', 'total'), Query::select(['name'])], 'name']; + yield 'an internal attribute next to a count' => [[Query::count('*', 'rows'), Query::select(['$id'])], '$id']; + yield '$collection next to a count' => [[Query::count('*', 'rows'), Query::select(['$collection'])], '$collection']; + yield 'an attribute outside the groups' => [[Query::count('*', 'rows'), Query::groupBy(['status']), Query::select(['name'])], 'name']; + yield 'a group and an attribute outside the groups' => [[Query::count('*', 'rows'), Query::groupBy(['status']), Query::select(['status', 'name'])], 'name']; + yield 'a wildcard and an attribute outside the groups' => [[Query::count('*', 'rows'), Query::groupBy(['status']), Query::select(['*', 'name'])], 'name']; + yield 'an attribute next to a groupBy without an aggregate' => [[Query::groupBy(['status']), Query::select(['name'])], 'name']; + yield 'an attribute next to a distinct count' => [[Query::distinct(), Query::count('*', 'rows'), Query::select(['name'])], 'name']; + yield 'an attribute of a related document' => [[Query::count('*', 'rows'), Query::select(['account.plan'])], 'account.plan']; + yield 'a wildcard under an attribute that is not a relationship' => [[Query::count('*', 'rows'), Query::select(['name.*'])], 'name.*']; + yield 'a joined attribute next to a count' => [[$note, Query::count('*', 'rows'), Query::select(['note.score'])], 'note.score']; + yield 'a joined attribute when the main one is grouped' => [[$note, Query::count('*', 'rows'), Query::groupBy(['name']), Query::select(['note.name'])], 'note.name']; + yield 'a main attribute when the joined one is grouped' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select(['name'])], 'name']; + yield 'a joined internal attribute outside the groups' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select(['note.$id'])], 'note.$id']; + yield 'the wildcard of a join alias' => [[$note, Query::count('*', 'rows'), Query::select(['note.*'])], 'note.*']; + } + + /** + * @param list $queries + */ + #[DataProvider('ungroupedSelects')] + public function testSelectOfAnUngroupedAttributeInAnAggregationQueryIsAnInvalidQuery(array $queries, string $attribute): void + { + $this->assertInvalidQuery(self::ungrouped($attribute), fn (): mixed => $this->database->aggregate('customers', $queries)); + } + + public function testUngroupedSelectIsRejectedWhereverAQuerySetIsValidated(): void + { + $queries = [Query::count('*', 'rows'), Query::select(['name'])]; + + $this->assertInvalidQuery(self::ungrouped('name'), fn (): mixed => $this->database->count('customers', $queries), 'count()'); + $this->assertInvalidQuery(self::ungrouped('name'), fn (): mixed => $this->database->sum('customers', 'balance', $queries), 'sum()'); + } + + /** + * @return iterable, list>}> + */ + public static function wildcardSelects(): iterable + { + yield 'a count' => [[Query::count('*', 'rows'), Query::select(['*'])], [['rows' => 3]]]; + yield 'a count and a sum' => [[Query::count('*', 'rows'), Query::sum('balance', 'total'), Query::select(['*'])], [['rows' => 3, 'total' => 60]]]; + yield 'a count over a join' => [[Query::join('notes', 'note', [Query::on('$id', 'customerId')]), Query::count('*', 'rows'), Query::select(['*'])], [['rows' => 3]]]; + yield 'a count over a left join' => [[Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), Query::count('*', 'rows'), Query::select(['*'])], [['rows' => 4]]]; + } + + /** + * @param list $queries + * @param list> $expected + */ + #[DataProvider('wildcardSelects')] + public function testWildcardSelectNextToAnAggregateReturnsOnlyTheAggregates(array $queries, array $expected): void + { + $this->assertSame($expected, $this->database->aggregate('customers', $queries)); + } + + /** + * The selects a listing gets when it asks for every attribute and every related document: `*` + * and a wildcard for each relationship, nested ones included. + * + * @return iterable}> + */ + public static function relationshipWildcards(): iterable + { + yield 'one level' => [['*', 'account.*']]; + yield 'two levels' => [['*', 'account.*', 'account.region.*']]; + yield 'a nested wildcard alone' => [['account.region.*']]; + } + + /** + * @param list $selects + */ + #[DataProvider('relationshipWildcards')] + public function testRelationshipWildcardsNextToAnAggregateAddNothingToTheRows(array $selects): void + { + $this->assertSame([['rows' => 3]], $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::select($selects)])); + $this->assertSame( + [['rows' => 2, 'status' => 'a'], ['rows' => 1, 'status' => 'b']], + $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['status']), Query::select($selects), Query::orderAsc('status')]), + ); + $this->assertSame( + [['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second']], + $this->database->aggregate('customers', [Query::join('notes', 'note', [Query::on('$id', 'customerId')]), Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select($selects), Query::orderAsc('note.name')]), + ); + } + + /** + * @return iterable + */ + public static function fullOuterJoins(): iterable + { + yield 'emulated full outer join' => [false]; + yield 'native full outer join' => [true]; + } + + #[DataProvider('fullOuterJoins')] + public function testWildcardsNextToAnAggregateOverAFullOuterJoinAddNothingToTheRows(bool $native): void + { + if ($native) { + $this->database = $this->database(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + $note = Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')]); + + $this->assertSame([['rows' => 5]], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::select(['*'])])); + $this->assertSame([['rows' => 5]], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::select(['*', 'account.*', 'account.region.*'])])); + $this->assertSame( + [['rows' => 1, 'name' => null], ['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second'], ['rows' => 1, 'name' => 'third']], + $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select(['note.name', '*', 'account.*', 'account.region.*']), Query::orderAsc('note.name')]), + ); + $this->assertInvalidQuery(self::ungrouped('name'), fn (): mixed => $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::select(['name'])])); + } + + /** + * @return iterable, list>}> + */ + public static function groupedSelects(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + yield 'a grouped attribute' => [ + [Query::count('*', 'rows'), Query::groupBy(['status']), Query::select(['status']), Query::orderAsc('status')], + [['rows' => 2, 'status' => 'a'], ['rows' => 1, 'status' => 'b']], + ]; + yield 'a grouped attribute selected before its groupBy' => [ + [Query::select(['status']), Query::count('*', 'rows'), Query::groupBy(['status']), Query::orderAsc('status')], + [['rows' => 2, 'status' => 'a'], ['rows' => 1, 'status' => 'b']], + ]; + yield 'a grouped attribute and a wildcard' => [ + [Query::count('*', 'rows'), Query::groupBy(['status']), Query::select(['status', '*']), Query::orderAsc('status')], + [['rows' => 2, 'status' => 'a'], ['rows' => 1, 'status' => 'b']], + ]; + yield 'a wildcard next to a group' => [ + [Query::count('*', 'rows'), Query::groupBy(['status']), Query::select(['*']), Query::orderAsc('status')], + [['rows' => 2, 'status' => 'a'], ['rows' => 1, 'status' => 'b']], + ]; + yield 'a grouped attribute without an aggregate' => [ + [Query::groupBy(['status']), Query::select(['status']), Query::orderAsc('status')], + [['status' => 'a'], ['status' => 'b']], + ]; + yield 'two groups' => [ + [Query::count('*', 'rows'), Query::groupBy(['status', 'name']), Query::select(['name', 'status']), Query::orderAsc('name')], + [['rows' => 1, 'status' => 'a', 'name' => 'One'], ['rows' => 1, 'status' => 'a', 'name' => 'Three'], ['rows' => 1, 'status' => 'b', 'name' => 'Two']], + ]; + yield 'a grouped internal attribute' => [ + [Query::count('*', 'rows'), Query::groupBy(['$id']), Query::select(['$id']), Query::orderAsc('$id')], + [['rows' => 1, Storage::UID => 'c1'], ['rows' => 1, Storage::UID => 'c2'], ['rows' => 1, Storage::UID => 'c3']], + ]; + yield 'a grouped joined attribute' => [ + [$note, Query::count('*', 'rows'), Query::groupBy(['note.name']), Query::select(['note.name']), Query::orderAsc('note.name')], + [['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second']], + ]; + yield 'a joined attribute grouped by the bare name only its join declares' => [ + [$note, Query::count('*', 'rows'), Query::groupBy(['score']), Query::select(['note.score']), Query::orderAsc('note.score')], + [['rows' => 1, 'score' => 3], ['rows' => 1, 'score' => 4], ['rows' => 1, 'score' => 5]], + ]; + yield 'a grouped joined internal attribute' => [ + [$note, Query::count('*', 'rows'), Query::groupBy(['note.$id']), Query::select(['note.$id']), Query::orderAsc('note.$id')], + [['rows' => 1, Storage::UID => 'n1'], ['rows' => 1, Storage::UID => 'n2'], ['rows' => 1, Storage::UID => 'n3']], + ]; + } + + /** + * @param list $queries + * @param list> $expected + */ + #[DataProvider('groupedSelects')] + public function testGroupedSelectReturnsEachGroupOnceWithItsAggregates(array $queries, array $expected): void + { + $this->assertSame($expected, $this->database->aggregate('customers', $queries)); + } + + #[DataProvider('fullOuterJoins')] + public function testGroupedSelectOverAFullOuterJoinReturnsEachGroupOnce(bool $native): void + { + if ($native) { + $this->database = $this->database(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + + $this->assertSame( + [['rows' => 1, 'name' => null], ['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second'], ['rows' => 1, 'name' => 'third']], + $this->database->aggregate('customers', [ + Query::fullOuterJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::count('*', 'rows'), + Query::groupBy(['note.name']), + Query::select(['note.name']), + Query::orderAsc('note.name'), + ]), + ); + } + + public function testTenantIsSelectedNextToAnAggregateOnlyWhenGrouped(): void + { + $this->database = $this->database(new SQLite(new PDO('sqlite::memory:')), sharedTables: true); + + $this->assertInvalidQuery(self::ungrouped('$tenant'), fn (): mixed => $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::select(['$tenant'])])); + $this->assertSame( + [['rows' => 3, Storage::TENANT => 1]], + $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['$tenant']), Query::select(['$tenant'])]), + ); + } + + /** + * With validation skipped, a select still never reaches the statement of an aggregation query. + */ + public function testSelectNextToAnAggregateNeverReachesTheEngine(): void + { + $this->assertSame( + [['rows' => 3]], + $this->database->skipValidation(fn (): array => $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::select(['name'])])), + ); + $this->assertSame( + [['rows' => 3]], + $this->database->skipValidation(fn (): array => $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::select(['*', 'account.*'])])), + ); + } + + public function testSelectsOutsideAnAggregationQueryAreUnchanged(): void + { + $customers = $this->database->find('customers', [Query::select(['name', '$collection']), Query::orderAsc('name')]); + $this->assertSame(['One', 'Three', 'Two'], \array_map(static fn (Document $customer): mixed => $customer->getAttribute('name'), $customers)); + $this->assertSame(['customers', 'customers', 'customers'], \array_map(static fn (Document $customer): string => $customer->getCollection(), $customers)); + + $statuses = $this->database->find('customers', [Query::distinct(), Query::select(['status']), Query::orderAsc('status')]); + $this->assertSame(['a', 'b'], \array_map(static fn (Document $customer): mixed => $customer->getAttribute('status'), $statuses)); + + $withAccount = $this->database->find('customers', [Query::select(['name', 'account.*', 'account.region.*']), Query::equal('$id', ['c1'])]); + $this->assertCount(1, $withAccount); + $account = $withAccount[0]->getAttribute('account'); + $this->assertInstanceOf(Document::class, $account); + $this->assertSame('pro', $account->getAttribute('plan')); + $region = $account->getAttribute('region'); + $this->assertInstanceOf(Document::class, $region); + $this->assertSame('eu', $region->getAttribute('code')); + + $joined = $this->database->find('customers', [Query::join('notes', 'note', [Query::on('$id', 'customerId')]), Query::select(['name', 'note.name']), Query::orderAsc('note.$id')]); + $this->assertSame(['first', 'second', 'first'], \array_map(static fn (Document $customer): mixed => $customer->getAttribute('note.name'), $joined)); + } + + /** + * A validator that cannot run aggregates, or a document read, keeps rejecting the aggregate + * itself rather than the select next to it. + */ + public function testValidatorsWithoutAggregatesRejectTheAggregateItself(): void + { + $attributes = [new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, 'array' => false])]; + $queries = [Query::select(['name']), Query::count('*', 'rows')]; + + $documents = new DocumentsValidator($attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom], idType: ColumnType::String)); + $this->assertFalse($documents->isValid($queries)); + $this->assertSame('Invalid query method: count', $documents->getDescription()); + + $document = new DocumentValidator($attributes, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + $this->assertFalse($document->isValid($queries)); + $this->assertSame('Invalid query method: count', $document->getDescription()); + } + + public function testSelectValidatorAcceptsOnlyGroupsAndWildcardsOfAnAggregationQuery(): void + { + $validator = new Select([ + new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value]), + new Document(['$id' => 'status', 'key' => 'status', 'type' => ColumnType::String->value]), + new Document([ + '$id' => 'account', + 'key' => 'account', + 'type' => ColumnType::Relationship->value, + 'options' => ['relatedCollection' => 'accounts', 'relationType' => RelationshipType::ManyToOne->value, 'side' => RelationshipSide::Parent->value], + ]), + ]); + + $validator->setAggregations([Query::count('*', 'rows')]); + $validator->setGroupBy(['status']); + + $this->assertTrue($validator->isValid(Query::select(['status', '*', 'account.*', 'account.region.*'])), $validator->getDescription()); + $this->assertFalse($validator->isValid(Query::select(['name']))); + $this->assertSame('Cannot select "name": an aggregation query can only select the attributes it groups by', $validator->getDescription()); + + $validator->setAggregations([]); + $validator->setGroupBy([]); + + $this->assertTrue($validator->isValid(Query::select(['name'])), 'without an aggregate or a group a select is a projection'); + + $schemaless = new Select([], supportForAttributes: false); + $schemaless->setAggregations([Query::count('*', 'rows')]); + $schemaless->setGroupBy(['anything']); + + $this->assertTrue($schemaless->isValid(Query::select(['anything', '*'])), $schemaless->getDescription()); + $this->assertFalse($schemaless->isValid(Query::select(['other']))); + $this->assertFalse($schemaless->isValid(Query::select(['unknown.*'])), 'a wildcard is a relationship wildcard only under a declared relationship'); + } + + private static function ungrouped(string $attribute): string + { + return 'Invalid query: Cannot select "'.$attribute.'": an aggregation query can only select the attributes it groups by'; + } + + /** + * @param Closure(): mixed $read + */ + private function assertInvalidQuery(string $message, Closure $read, string $label = ''): void + { + $prefix = $label === '' ? '' : $label.': '; + + try { + $read(); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage(), $prefix.'the rejection names the select'); + + return; + } + + $this->fail($prefix.'the shape was accepted: '.$message); + } + + private function database(SQLite $adapter, bool $sharedTables = false): Database + { + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setDatabase('aggregate_selects') + ->setNamespace('aggregate_selects_'.\uniqid()) + ->setAuthorization(new Authorization()); + if ($sharedTables) { + $database->setSharedTables(true)->setTenant(null); + } + $database->addHook(new Permissions()); + $database->addHook(new Relationships()); + $database->create(); + + $this->createCollection($database, 'customers', [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'status', size: 16), + Attribute::integer(key: 'balance'), + ]); + $this->createCollection($database, 'accounts', [Attribute::string(key: 'plan', size: 16)]); + $this->createCollection($database, 'regions', [Attribute::string(key: 'code', size: 16)]); + $this->createCollection($database, 'notes', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'score'), + ]); + $database->createRelationship('customers', Relationship::oneToOne(relatedCollection: 'accounts', key: 'account', twoWayKey: 'customer')); + $database->createRelationship('accounts', Relationship::manyToOne(relatedCollection: 'regions', key: 'region', twoWayKey: 'accounts')); + + if ($sharedTables) { + $database->setTenant(self::TENANT); + } + + $this->createDocument($database, 'regions', 'r1', ['code' => 'eu']); + $this->createDocument($database, 'accounts', 'a1', ['plan' => 'pro', 'region' => 'r1']); + $this->createDocument($database, 'customers', 'c1', ['name' => 'One', 'status' => 'a', 'balance' => 10, 'account' => 'a1']); + $this->createDocument($database, 'customers', 'c2', ['name' => 'Two', 'status' => 'b', 'balance' => 20]); + $this->createDocument($database, 'customers', 'c3', ['name' => 'Three', 'status' => 'a', 'balance' => 30]); + $this->createDocument($database, 'notes', 'n1', ['customerId' => 'c1', 'name' => 'first', 'score' => 3]); + $this->createDocument($database, 'notes', 'n2', ['customerId' => 'c1', 'name' => 'second', 'score' => 4]); + $this->createDocument($database, 'notes', 'n3', ['customerId' => 'c2', 'name' => 'first', 'score' => 5]); + $this->createDocument($database, 'notes', 'n4', ['customerId' => 'c9', 'name' => 'third', 'score' => 6]); + + return $database; + } + + /** + * @param list $attributes + */ + private function createCollection(Database $database, string $id, array $attributes): void + { + $database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + } + + /** + * @param array $attributes + */ + private function createDocument(Database $database, string $collection, string $id, array $attributes): void + { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Documents/AggregateTest.php b/tests/unit/Documents/AggregateTest.php new file mode 100644 index 0000000000..1a6e99cc0a --- /dev/null +++ b/tests/unit/Documents/AggregateTest.php @@ -0,0 +1,138 @@ +_` (or + * `` for count('*')), and is the only way to run aggregate and groupBy queries. + */ +final class AggregateTest extends TestCase +{ + private Database $database; + + #[\Override] + protected function setUp(): void + { + $this->database = HookFixture::sqlite(); + HookFixture::seed($this->database, ['first', 'second', 'third']); + } + + public function testAnUnaliasedAggregateComesBackUnderItsDefaultAlias(): void + { + $rows = $this->database->aggregate(HookFixture::COLLECTION, [ + Query::count(), + Query::sum('views'), + Query::max('views'), + ]); + + $this->assertSame([['count' => 3, 'sum_views' => 6, 'max_views' => 3]], $rows); + } + + public function testAnAliasIsKept(): void + { + $rows = $this->database->aggregate(HookFixture::COLLECTION, [Query::count('*', 'total'), Query::sum('views')]); + + $this->assertSame([['total' => 3, 'sum_views' => 6]], $rows); + } + + /** + * @return iterable, string}> + */ + public static function duplicateDefaultAliases(): iterable + { + yield 'the same aggregate twice' => [[Query::sum('views'), Query::sum('views')], 'sum_views']; + yield 'an explicit alias equal to a default one' => [[Query::count('*', 'sum_views'), Query::sum('views')], 'sum_views']; + } + + /** + * @param list $queries + */ + #[DataProvider('duplicateDefaultAliases')] + public function testADefaultAliasTakenTwiceIsRefused(array $queries, string $alias): void + { + try { + $this->database->aggregate(HookFixture::COLLECTION, $queries); + $this->fail('Two aggregates came back under one alias'); + } catch (QueryException $error) { + $this->assertSame("The default aggregate alias '{$alias}' is used by more than one aggregate; give each of them an explicit alias", $error->getMessage()); + } + } + + public function testADefaultAliasTakenTwiceIsRefusedWithValidationOff(): void + { + try { + $this->database->skipValidation(fn (): array => $this->database->aggregate(HookFixture::COLLECTION, [Query::max('views'), Query::max('views')])); + $this->fail('Two aggregates came back under one alias'); + } catch (QueryException $error) { + $this->assertSame("The default aggregate alias 'max_views' is used by more than one aggregate; give each of them an explicit alias", $error->getMessage()); + } + } + + public function testAnExplicitAliasGivenTwiceIsRefusedTheSameWithValidationOff(): void + { + $queries = [Query::sum('views', 'total'), Query::max('views', 'total')]; + $message = 'Invalid query: Aggregate alias "total" is given to more than one aggregate'; + + try { + $this->database->aggregate(HookFixture::COLLECTION, $queries); + $this->fail('Two aggregates came back under one alias'); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage()); + } + + try { + $this->database->skipValidation(fn (): array => $this->database->aggregate(HookFixture::COLLECTION, $queries)); + $this->fail('Two aggregates came back under one alias with validation off'); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage()); + } + } + + public function testExplicitAliasesSeparateTheSameAggregate(): void + { + $rows = $this->database->aggregate(HookFixture::COLLECTION, [Query::sum('views', 'first'), Query::sum('views', 'second')]); + + $this->assertSame([['first' => 6, 'second' => 6]], $rows); + } + + public function testEachGroupIsOneRow(): void + { + $this->database->updateDocument(HookFixture::COLLECTION, 'third', new Document(['title' => 'first'])); + + $rows = $this->database->aggregate(HookFixture::COLLECTION, [ + Query::count(), + Query::groupBy(['title']), + Query::orderAsc('title'), + ]); + + $this->assertSame([['count' => 2, 'title' => 'first'], ['count' => 1, 'title' => 'second']], $rows); + } + + public function testFindRefusesAggregateAndGroupByQueries(): void + { + foreach ([[Query::count()], [Query::groupBy(['title'])]] as $queries) { + try { + $this->database->find(HookFixture::COLLECTION, $queries); + $this->fail('find() ran an aggregation'); + } catch (QueryException $error) { + $this->assertSame('find() does not run aggregate or groupBy queries: use aggregate()', $error->getMessage()); + } + } + } + + public function testAggregateNeedsAnAggregation(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('aggregate() needs an aggregate or groupBy query'); + + $this->database->aggregate(HookFixture::COLLECTION, [Query::equal('title', ['first'])]); + } +} diff --git a/tests/unit/Documents/AggregationErrorTest.php b/tests/unit/Documents/AggregationErrorTest.php new file mode 100644 index 0000000000..e015246fc0 --- /dev/null +++ b/tests/unit/Documents/AggregationErrorTest.php @@ -0,0 +1,198 @@ + $capabilities + */ + private function buildDatabase(array $capabilities): Database + { + $adapter = self::createStub(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) use ($capabilities) { + return in_array($cap, $capabilities); + }); + + $collection = new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], + 'name' => 'testCol', + 'attributes' => [ + new Document(['$id' => 'amount', 'key' => 'amount', 'type' => 'double', 'size' => 0, 'required' => false, 'array' => false]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'testCol') { + return $collection; + } + + return new Document(); + } + ); + + $adapter->method('find')->willReturn([]); + $adapter->method('count')->willReturn(0); + $adapter->method('sum')->willReturn(0); + + $cache = new Cache(new None()); + $db = new Database($adapter, $cache); + $db->getAuthorization()->addRole(Role::any()->toString()); + + return $db; + } + + public function testAggregateOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Aggregation queries are not supported'); + $db->skipValidation(fn () => $db->aggregate('testCol', [Query::count('*', 'cnt')])); + } + + public function testAggregateSkipsRelationshipPopulation(): void + { + $db = $this->buildDatabase([ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Aggregations, + ]); + + $results = $db->skipValidation(fn () => $db->aggregate('testCol', [Query::count('*', 'cnt')])); + $this->assertSame([], $results); + } + + public function testAggregateWithCursorThrows(): void + { + $db = $this->buildDatabase([ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Aggregations, + ]); + + $cursorDoc = new Document([ + '$id' => 'c1', + '$collection' => 'testCol', + '$sequence' => '100', + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Cursor pagination is not supported with aggregation'); + $db->skipValidation(fn () => $db->aggregate('testCol', [ + Query::count('*', 'cnt'), + Query::cursorAfter($cursorDoc), + ])); + } + + public function testFindWithJoinOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported'); + $db->skipValidation(fn () => $db->find('testCol', [Query::join('other', 'j0', [Query::on('fk', '$id')])])); + } + + public function testCountWithJoinOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported'); + $db->skipValidation(fn () => $db->count('testCol', [Query::join('other', 'j0', [Query::on('fk', '$id')])])); + } + + public function testSumWithJoinOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported'); + $db->skipValidation(fn () => $db->sum('testCol', 'amount', [Query::join('other', 'j0', [Query::on('fk', '$id')])])); + } + + public function testSumValidatesQueriesWhenEnabled(): void + { + $db = $this->buildDatabase([ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + $db->setValidation(true); + + $this->expectException(QueryException::class); + $db->sum('testCol', 'amount', [Query::equal('nonexistent', ['val'])]); + } +} diff --git a/tests/unit/Documents/BatchSizeTest.php b/tests/unit/Documents/BatchSizeTest.php new file mode 100644 index 0000000000..277cab7222 --- /dev/null +++ b/tests/unit/Documents/BatchSizeTest.php @@ -0,0 +1,123 @@ + + */ + public static function batchedCalls(): iterable + { + yield 'createDocuments' => [static fn (Database $database, int $batchSize): int => $database->createDocuments( + HookFixture::COLLECTION, + [new Document([Document::ID => 'third', 'title' => 'third', 'views' => 3])], + $batchSize, + )]; + yield 'updateDocuments' => [static fn (Database $database, int $batchSize): int => $database->updateDocuments( + HookFixture::COLLECTION, + new Document(['views' => 10]), + batchSize: $batchSize, + )]; + yield 'upsertDocuments' => [static fn (Database $database, int $batchSize): int => $database->upsertDocuments( + HookFixture::COLLECTION, + [new Document([Document::ID => 'first', 'title' => 'first', 'views' => 10])], + $batchSize, + )]; + yield 'deleteDocuments' => [static fn (Database $database, int $batchSize): int => $database->deleteDocuments( + HookFixture::COLLECTION, + batchSize: $batchSize, + )]; + yield 'cursor' => [static fn (Database $database, int $batchSize): mixed => $database->cursor( + HookFixture::COLLECTION, + batchSize: $batchSize, + )]; + } + + /** + * @param Closure(Database, int): mixed $call + */ + #[DataProvider('batchedCalls')] + public function testABatchSizeAboveTheMaximumIsRefusedBeforeAnyWrite(Closure $call): void + { + $database = $this->database(); + + try { + $call($database, Database::BATCH_SIZE + 1); + $this->fail('A batch size above '.Database::BATCH_SIZE.' was accepted'); + } catch (LimitException $error) { + $this->assertSame('Batch size must be at most 1000, got 1001', $error->getMessage()); + } + + $this->assertSame([['first', 1], ['second', 2]], $this->stored($database)); + } + + /** + * @param Closure(Database, int): mixed $call + */ + #[DataProvider('batchedCalls')] + public function testTheMaximumBatchSizeIsAccepted(Closure $call): void + { + $database = $this->database(); + + $result = $call($database, Database::BATCH_SIZE); + + if ($result instanceof Generator) { + $this->assertCount(2, \iterator_to_array($result, false)); + } else { + $this->assertIsInt($result); + $this->assertGreaterThan(0, $result); + } + } + + public function testBulkWritesWorkThroughBatchesSmallerThanTheInput(): void + { + $database = $this->database(); + $created = []; + + $count = $database->createDocuments(HookFixture::COLLECTION, \array_map( + static fn (int $views): Document => new Document([Document::ID => 'more'.$views, 'title' => 'more', 'views' => $views]), + \range(3, 7), + ), 2, function (Document $document) use (&$created): void { + $created[] = $document->getId(); + }); + + $this->assertSame(5, $count); + $this->assertSame(['more3', 'more4', 'more5', 'more6', 'more7'], $created); + $this->assertSame(7, $database->updateDocuments(HookFixture::COLLECTION, new Document(['title' => 'same']), batchSize: 2)); + $this->assertSame(7, $database->deleteDocuments(HookFixture::COLLECTION, batchSize: 2)); + $this->assertSame([], $this->stored($database)); + } + + private function database(): Database + { + $database = HookFixture::sqlite(); + HookFixture::seed($database, ['first', 'second']); + + return $database; + } + + /** + * @return list + */ + private function stored(Database $database): array + { + return \array_values(\array_map( + static fn (Document $document): array => [$document->getId(), $document->getAttribute('views')], + $database->find(HookFixture::COLLECTION), + )); + } +} diff --git a/tests/unit/Documents/BulkWriteGuardsTest.php b/tests/unit/Documents/BulkWriteGuardsTest.php new file mode 100644 index 0000000000..db6c8aaccc --- /dev/null +++ b/tests/unit/Documents/BulkWriteGuardsTest.php @@ -0,0 +1,282 @@ +database(new Memory(), [Permission::read(Role::any())]); + + $this->assertRefused(AuthorizationException::class, null, fn (): int => $database->createDocuments(self::COLLECTION, [$this->task('a', 1)])); + $this->assertSame(0, $database->count(self::COLLECTION)); + } + + public function testAnUpdateWithoutChangesUpdatesNothing(): void + { + $database = $this->database(new Memory()); + $database->createDocuments(self::COLLECTION, [$this->task('a', 1)]); + + $this->assertSame(0, $database->updateDocuments(self::COLLECTION, new Document())); + $this->assertSame(1, $database->getDocument(self::COLLECTION, 'a')->getAttribute('rank')); + } + + public function testABulkUpdateOfAMissingCollectionIsRefused(): void + { + $database = $this->database(new Memory()); + + $this->assertRefused(DatabaseException::class, 'Collection not found', fn (): int => $database->updateDocuments('missing', new Document(['rank' => 2]))); + } + + public function testABulkUpdateWithAnInvalidQueryIsRefused(): void + { + $database = $this->database(new Memory()); + $database->createDocuments(self::COLLECTION, [$this->task('a', 1)]); + + $this->assertRefused(QueryException::class, 'Invalid query: Attribute not found in schema: missing', fn (): int => $database->updateDocuments( + self::COLLECTION, + new Document(['rank' => 2]), + [Query::equal('missing', ['x'])], + )); + $this->assertSame(1, $database->getDocument(self::COLLECTION, 'a')->getAttribute('rank')); + } + + public function testABulkUpdateWithACursorOfAnotherCollectionIsRefused(): void + { + $database = $this->database(new Memory()); + $database->createDocuments(self::COLLECTION, [$this->task('a', 1)]); + $foreign = new Document([Document::ID => 'a', Document::COLLECTION => 'other']); + + $this->assertRefused(DatabaseException::class, 'Cursor document must be from the same Collection.', fn (): int => $database->updateDocuments( + self::COLLECTION, + new Document(['rank' => 2]), + [Query::cursorAfter($foreign)], + )); + } + + public function testABulkUpdateWithALimitAboveTheBatchSizeUpdatesExactlyTheLimit(): void + { + $database = $this->database(new Memory()); + $database->createDocuments(self::COLLECTION, \array_map(fn (int $rank): Document => $this->task("t{$rank}", $rank), \range(1, 7))); + + $this->assertSame(5, $database->updateDocuments( + self::COLLECTION, + new Document(['label' => 'done']), + [Query::orderAsc('rank'), Query::limit(5)], + batchSize: 2, + )); + + $labels = []; + foreach ($database->find(self::COLLECTION, [Query::orderAsc('rank')]) as $task) { + $labels[$task->getId()] = $task->getAttribute('label'); + } + $this->assertSame(['t1' => 'done', 't2' => 'done', 't3' => 'done', 't4' => 'done', 't5' => 'done', 't6' => 'open', 't7' => 'open'], $labels); + } + + public function testABulkUpdateOverAnUnreadableUpdateTimeIsRefused(): void + { + $database = $this->database(new Memory()); + $database->createDocuments(self::COLLECTION, [$this->task('a', 1)]); + $this->corruptUpdateTime($database, 'a'); + + $this->assertRefused(DatabaseException::class, null, fn (): mixed => $database->skipValidation( + fn (): mixed => $database->withPreserveDates( + true, + fn (): int => $database->updateDocuments(self::COLLECTION, new Document(['rank' => 2, Document::UPDATED_AT => 'not-a-date'])), + ), + )); + $this->assertSame(1, $database->getDocument(self::COLLECTION, 'a')->getAttribute('rank')); + } + + public function testUpsertingAnUnchangedDocumentReturnsTheStoredOne(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $created = $database->upsertDocument(self::COLLECTION, $this->task('a', 1)); + + $again = $database->upsertDocument(self::COLLECTION, $this->task('a', 1)); + + $this->assertSame($created->getSequence(), $again->getSequence()); + $this->assertSame($created->getUpdatedAt(), $again->getUpdatedAt()); + $this->assertSame(1, $again->getAttribute('rank')); + $this->assertSame('open', $again->getAttribute('label')); + } + + public function testUpsertingNothingWritesNothing(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + + $this->assertSame(0, $database->upsertDocuments(self::COLLECTION, [])); + $this->assertSame(0, $database->upsertDocuments(self::COLLECTION, [], increase: 'rank')); + $this->assertSame(0, $database->upsertDocuments('missing', []), 'an empty upsert reads nothing, not even the collection'); + } + + public function testAnUpsertThatCreatesNeedsCreatePermission(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:')), [Permission::read(Role::any()), Permission::update(Role::any())]); + + $this->assertRefused(AuthorizationException::class, null, fn (): int => $database->upsertDocuments(self::COLLECTION, [$this->task('new', 1)])); + $this->assertSame(0, $database->count(self::COLLECTION)); + } + + public function testAnUpsertCallbackFailureAbortsTheCallAfterItsBatchIsWritten(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + + $this->assertRefused(RuntimeException::class, 'the consumer refused a', fn (): int => $database->upsertDocuments( + self::COLLECTION, + [$this->task('a', 1), $this->task('b', 2)], + onNext: static function (Document $document): void { + if ($document->getId() === 'a') { + throw new RuntimeException('the consumer refused a'); + } + }, + )); + + $this->assertSame(2, $database->count(self::COLLECTION)); + } + + public function testAnUpsertOfADocumentWithAnUnreadableUpdateTimeIsRefused(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $database->createDocuments(self::COLLECTION, [$this->task('a', 1)]); + $this->corruptUpdateTime($database, 'a'); + + $this->assertRefused(DatabaseException::class, null, fn (): Document => $database->upsertDocument(self::COLLECTION, $this->task('a', 5))); + } + + public function testTheCursorReadsPagesOfItsBatchSize(): void + { + /** @var list $limits */ + $limits = []; + $record = static function (?int $limit) use (&$limits): void { + $limits[] = $limit; + }; + $database = new class (new Memory(), new Cache(new None()), $record) extends Database { + public function __construct(Adapter $adapter, Cache $cache, private readonly \Closure $record) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function find(string $collection, array $queries = [], PermissionType $forPermission = PermissionType::Read): array + { + if ($collection === 'tasks') { + $limit = null; + foreach ($queries as $query) { + $value = $query->getValue(); + if ($query->getMethod() === Method::Limit && \is_int($value)) { + $limit = $value; + } + } + ($this->record)($limit); + } + + return parent::find($collection, $queries, $forPermission); + } + }; + $this->prepare($database); + $database->createDocuments(self::COLLECTION, \array_map(fn (int $rank): Document => $this->task("t{$rank}", $rank), \range(1, 60))); + /** @var list $limits */ + $limits = []; + + $seen = []; + foreach ($database->cursor(self::COLLECTION, [Query::orderAsc('rank')], batchSize: 25) as $task) { + $seen[] = $task->getAttribute('rank'); + } + + $this->assertSame(\range(1, 60), $seen); + $this->assertSame([25, 25, 25], $limits); + } + + private function corruptUpdateTime(Database $database, string $id): void + { + $collection = $database->getCollection(self::COLLECTION); + $adapter = $database->getAdapter(); + $stored = $adapter->getDocument($collection, $id); + $stored->setAttribute(Document::UPDATED_AT, 'not-a-date'); + $adapter->updateDocument($collection, $id, $stored, true); + } + + /** + * @param class-string $exception + * @param callable(): mixed $write + */ + private function assertRefused(string $exception, ?string $message, callable $write): void + { + $error = null; + try { + $write(); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf($exception, $error); + if ($message !== null) { + $this->assertSame($message, $error->getMessage()); + } + } + + private function task(string $id, int $rank): Document + { + return new Document([ + Document::ID => $id, + Document::PERMISSIONS => [Permission::read(Role::any()), Permission::update(Role::any())], + 'rank' => $rank, + 'label' => 'open', + ]); + } + + /** + * @param list|null $permissions + */ + private function database(Adapter $adapter, ?array $permissions = null): Database + { + return $this->prepare(new Database($adapter, new Cache(new None())), $permissions); + } + + /** + * @param list|null $permissions + */ + private function prepare(Database $database, ?array $permissions = null): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database + ->setAuthorization($authorization) + ->setDatabase('bulk') + ->setNamespace('bulk_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::integer(key: 'rank'), Attribute::string(key: 'label', size: 16)], + permissions: $permissions ?? [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Documents/BulkWriteJoinTest.php b/tests/unit/Documents/BulkWriteJoinTest.php new file mode 100644 index 0000000000..b1c676ebc3 --- /dev/null +++ b/tests/unit/Documents/BulkWriteJoinTest.php @@ -0,0 +1,190 @@ + + */ + public static function adapters(): array + { + return [ + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + 'memory' => [static fn (): Adapter => new Memory()], + ]; + } + + /** + * @param callable(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUpdateDocumentsRejectsJoins(callable $adapter): void + { + $database = $this->createDatabase($adapter()); + + try { + $database->updateDocuments('orders', new Document(['amount' => 0]), [ + Query::join('customers', 'j0', [Query::on('customerId', '$id')]), + ]); + $this->fail('A join on a bulk update must be rejected'); + } catch (QueryException $exception) { + $this->assertSame('Join queries are not supported for bulk updates', $exception->getMessage()); + } + + $this->assertSame(10, $database->getDocument('orders', 'o1')->getAttribute('amount')); + } + + /** + * @param callable(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeleteDocumentsRejectsJoins(callable $adapter): void + { + $database = $this->createDatabase($adapter()); + + try { + $database->deleteDocuments('orders', [ + Query::leftJoin('customers', 'j0', [Query::on('customerId', '$id')]), + ]); + $this->fail('A join on a bulk delete must be rejected'); + } catch (QueryException $exception) { + $this->assertSame('Join queries are not supported for bulk deletes', $exception->getMessage()); + } + + $this->assertFalse($database->getDocument('orders', 'o1')->isEmpty()); + } + + /** + * @param callable(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testJoinsAreRejectedWithoutQueryValidation(callable $adapter): void + { + $database = $this->createDatabase($adapter()); + + $database->skipValidation(function () use ($database): void { + try { + $database->updateDocuments('orders', new Document(['amount' => 0]), [ + Query::join('customers', 'j0', [Query::on('customerId', '$id')]), + ]); + $this->fail('A join on a bulk update must be rejected'); + } catch (QueryException $exception) { + $this->assertSame('Join queries are not supported for bulk updates', $exception->getMessage()); + } + + try { + $database->deleteDocuments('orders', [ + Query::join('customers', 'j0', [Query::on('customerId', '$id')]), + ]); + $this->fail('A join on a bulk delete must be rejected'); + } catch (QueryException $exception) { + $this->assertSame('Join queries are not supported for bulk deletes', $exception->getMessage()); + } + }); + + $this->assertSame(10, $database->getDocument('orders', 'o1')->getAttribute('amount')); + } + + public function testMirrorRejectsJoinsBeforeWritingToTheSource(): void + { + $source = $this->createDatabase(new SQLite(new PDO('sqlite::memory:'))); + $mirror = new Mirror($source); + + try { + $mirror->updateDocuments('orders', new Document(['amount' => 0]), [ + Query::join('customers', 'j0', [Query::on('customerId', '$id')]), + ]); + $this->fail('A join on a bulk update must be rejected'); + } catch (QueryException $exception) { + $this->assertSame('Join queries are not supported for bulk updates', $exception->getMessage()); + } + + try { + $mirror->deleteDocuments('orders', [ + Query::join('customers', 'j0', [Query::on('customerId', '$id')]), + ]); + $this->fail('A join on a bulk delete must be rejected'); + } catch (QueryException $exception) { + $this->assertSame('Join queries are not supported for bulk deletes', $exception->getMessage()); + } + + $this->assertSame(10, $source->getDocument('orders', 'o1')->getAttribute('amount')); + } + + /** + * @param callable(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testBulkWritesWithoutJoinsStillApply(callable $adapter): void + { + $database = $this->createDatabase($adapter()); + + $this->assertSame(1, $database->updateDocuments('orders', new Document(['amount' => 20]), [ + Query::equal('customerId', ['c1']), + ])); + $this->assertSame(20, $database->getDocument('orders', 'o1')->getAttribute('amount')); + + $this->assertSame(1, $database->deleteDocuments('orders', [ + Query::equal('customerId', ['c1']), + ])); + $this->assertTrue($database->getDocument('orders', 'o1')->isEmpty()); + } + + private function createDatabase(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('bulk_join') + ->setNamespace('bulk_join_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->create(); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(Collection::create( + id: 'customers', + attributes: [Attribute::string(key: 'name')], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: 'orders', + attributes: [ + Attribute::string(key: 'customerId'), + Attribute::integer(key: 'amount'), + ], + permissions: $permissions, + documentSecurity: false, + )); + + $database->createDocument('customers', new Document(['$id' => 'c1', 'name' => 'Customer'])); + $database->createDocument('orders', new Document(['$id' => 'o1', 'customerId' => 'c1', 'amount' => 10])); + + return $database; + } +} diff --git a/tests/unit/Documents/CaseOnlyRenameTest.php b/tests/unit/Documents/CaseOnlyRenameTest.php new file mode 100644 index 0000000000..cc592f3571 --- /dev/null +++ b/tests/unit/Documents/CaseOnlyRenameTest.php @@ -0,0 +1,141 @@ + + */ + public static function adapters(): array + { + return [ + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + 'memory' => [static fn (): Adapter => new Memory()], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCaseOnlyRenameIsPerformed(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'abc', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'renamed', + ])); + + $renamed = $database->updateDocument(self::COLLECTION, 'abc', new Document(['$id' => 'ABC'])); + + $this->assertSame('ABC', $renamed->getId()); + $this->assertSame(['ABC'], \array_map(static fn (Document $document): string => $document->getId(), $database->find(self::COLLECTION))); + $stored = $database->getDocument(self::COLLECTION, 'ABC'); + $this->assertSame('ABC', $stored->getId()); + $this->assertSame('renamed', $stored->getAttribute('name')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCaseOnlyRenameKeepsDocumentLevelPermissionsWithTheDocument(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'abc', + '$permissions' => [ + Permission::read(Role::user('alice')), + Permission::update(Role::user('alice')), + ], + 'name' => 'private', + ])); + + $database->updateDocument(self::COLLECTION, 'abc', new Document(['$id' => 'ABC'])); + + $authorization = $database->getAuthorization(); + $authorization->cleanRoles(); + $authorization->addRole(Role::user('alice')->toString()); + + $this->assertSame('ABC', $database->getDocument(self::COLLECTION, 'ABC')->getId()); + $this->assertSame(['ABC'], \array_map(static fn (Document $document): string => $document->getId(), $database->find(self::COLLECTION))); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRenameOntoAnotherDocumentIdInDifferentCaseStillConflicts(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'abc'])); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'xyz'])); + + $this->expectException(DuplicateException::class); + + $database->updateDocument(self::COLLECTION, 'abc', new Document(['$id' => 'XYZ'])); + } + + public function testCaseOnlyRenameMovesSqlPermissionRowsToTheNewId(): void + { + $pdo = new PDO('sqlite::memory:'); + $database = $this->database(new SQLite($pdo)); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'abc', + '$permissions' => [Permission::read(Role::user('alice'))], + ])); + + $database->updateDocument(self::COLLECTION, 'abc', new Document(['$id' => 'ABC'])); + + $rows = $pdo->query('SELECT _document, _type, _permission FROM "'.$database->getNamespace().'_'.self::COLLECTION.'_perms"'); + $this->assertNotFalse($rows); + $this->assertSame( + [['_document' => 'ABC', '_type' => 'read', '_permission' => 'user:alice']], + $rows->fetchAll(PDO::FETCH_ASSOC), + ); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->addHook(new Permissions()); + $database + ->setDatabase('case_rename') + ->setNamespace('case_rename_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string('name', size: 32, required: false)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + + return $database; + } +} diff --git a/tests/unit/Documents/ConvertQueriesCapabilityTest.php b/tests/unit/Documents/ConvertQueriesCapabilityTest.php new file mode 100644 index 0000000000..d7eb62bff9 --- /dev/null +++ b/tests/unit/Documents/ConvertQueriesCapabilityTest.php @@ -0,0 +1,82 @@ + $capability !== Capability::Objects)); + } + + #[\Override] + public function supports(Capability $feature): bool + { + if ($feature === Capability::DefinedAttributes) { + $this->definedAttributesAsked++; + } + + return parent::supports($feature); + } + }; + $database = $this->database($adapter); + $collection = $database->getCollection(self::COLLECTION); + $adapter->definedAttributesAsked = 0; + + $converted = $database->convertQueries($collection, [Query::equal('title', ['Dune'])]); + $database->convertQuery($collection, Query::equal('title', ['Dune'])); + + $this->assertSame(0, $adapter->definedAttributesAsked); + $this->assertSame(['Dune'], $converted[0]->getValues()); + $this->assertSame(1, $database->count(self::COLLECTION, [Query::equal('title', ['Dune'])])); + } + + public function testAnAdapterWithObjectsStillConvertsAPathIntoAnObject(): void + { + $database = $this->database(new Memory()); + $database->createAttribute(self::COLLECTION, Attribute::object(key: 'meta')); + $collection = $database->getCollection(self::COLLECTION); + + [$converted] = $database->convertQueries($collection, [Query::equal('meta.level', ['x'])]); + + $this->assertSame(ColumnType::Object->value, $converted->getAttributeType()); + $this->assertSame(ColumnType::Object->value, $database->convertQuery($collection, Query::equal('meta.level', ['x']))->getAttributeType()); + } + + private function database(Memory $adapter): Database + { + $database = new Database($adapter, new Cache(new MemoryCache())); + $database->setDatabase('convert')->setNamespace('convert_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune'])); + + return $database; + } +} diff --git a/tests/unit/Documents/CreateDocumentLogicTest.php b/tests/unit/Documents/CreateDocumentLogicTest.php new file mode 100644 index 0000000000..f53bf8e7fe --- /dev/null +++ b/tests/unit/Documents/CreateDocumentLogicTest.php @@ -0,0 +1,318 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createDocument')->willReturnArgument(1); + $this->adapter->method('createDocuments')->willReturnCallback(function (Document $col, array $docs) { + return $docs; + }); + $this->adapter->method('getSequences')->willReturnArgument(1); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + /** + * @param array $attributes + * @param array $permissions + */ + private function setupCollection(string $id, array $attributes = [], array $permissions = []): void + { + if (empty($permissions)) { + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + $collection = new Document([ + '$id' => $id, + '$collection' => Database::METADATA, + '$permissions' => $permissions, + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($id, $collection) { + if ($col->getId() === Database::METADATA && $docId === $id) { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + + return new Document(); + } + ); + } + + public function testCreateDocumentSetsCreatedAtAndUpdatedAt(): void + { + $this->setupCollection('testCol'); + + $doc = new Document([ + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'testCol', + ]); + + $result = $this->database->createDocument('testCol', $doc); + $this->assertNotNull($result->getCreatedAt()); + $this->assertNotNull($result->getUpdatedAt()); + } + + public function testCreateDocumentGeneratesIdIfEmpty(): void + { + $this->setupCollection('testCol'); + + $doc = new Document([ + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'testCol', + ]); + + $result = $this->database->createDocument('testCol', $doc); + $this->assertNotEmpty($result->getId()); + } + + public function testCreateDocumentUsesProvidedId(): void + { + $this->setupCollection('testCol'); + + $doc = new Document([ + '$id' => 'custom-id', + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'testCol', + ]); + + $result = $this->database->createDocument('testCol', $doc); + $this->assertSame('custom-id', $result->getId()); + } + + public function testCreateDocumentValidatesStructureWhenEnabled(): void + { + $attributes = [ + new Document(['$id' => 'title', 'key' => 'title', 'type' => 'string', 'size' => 128, 'required' => true, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollection('testCol', $attributes); + $this->database->setValidation(true); + + $doc = new Document([ + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'testCol', + ]); + + $this->expectException(StructureException::class); + $this->database->createDocument('testCol', $doc); + } + + public function testCreateDocumentSkipsValidationWhenDisabled(): void + { + $attributes = [ + new Document(['$id' => 'title', 'key' => 'title', 'type' => 'string', 'size' => 128, 'required' => true, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollection('testCol', $attributes); + + $doc = new Document([ + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'testCol', + ]); + + $result = $this->database->skipValidation(fn () => $this->database->createDocument('testCol', $doc)); + $this->assertNotEmpty($result->getId()); + } + + public function testCreateDocumentChecksCreatePermission(): void + { + $collection = new Document([ + '$id' => 'restricted', + '$collection' => Database::METADATA, + '$permissions' => [Permission::create(Role::user('admin'))], + 'name' => 'restricted', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'restricted') { + return $collection; + } + + return new Document(); + } + ); + + $db = new Database($this->adapter, new Cache(new None())); + + $this->expectException(AuthorizationException::class); + $db->createDocument('restricted', new Document([ + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'restricted', + ])); + } + + public function testCreateDocumentSetsCollectionAttribute(): void + { + $this->setupCollection('testCol'); + + $doc = new Document([ + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'testCol', + ]); + + $result = $this->database->createDocument('testCol', $doc); + $this->assertSame('testCol', $result->getAttribute('$collection')); + } + + public function testCreateDocumentValidatesPermissionsFormat(): void + { + $this->setupCollection('testCol'); + $this->database->setValidation(true); + + $doc = new Document([ + '$permissions' => ['invalid-permission-format'], + '$collection' => 'testCol', + ]); + + $this->expectException(\Utopia\Database\Exception::class); + $this->database->createDocument('testCol', $doc); + } + + public function testCreateDocumentsCreatesEveryDocument(): void + { + $this->setupCollection('testCol'); + + $docs = [ + new Document(['$permissions' => [Permission::read(Role::any())], '$collection' => 'testCol']), + new Document(['$permissions' => [Permission::read(Role::any())], '$collection' => 'testCol']), + ]; + + $count = 0; + $this->database->createDocuments('testCol', $docs, 100, function (Document $doc) use (&$count) { + $count++; + }); + + $this->assertSame(2, $count); + } + + public function testCreateDocumentsCallsOnNextCallbackPerDoc(): void + { + $this->setupCollection('testCol'); + + $docs = [ + new Document(['$permissions' => [Permission::read(Role::any())], '$collection' => 'testCol']), + ]; + + $called = false; + $this->database->createDocuments('testCol', $docs, 100, function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function testCreateDocumentsLetsAnOnNextFailureReachTheCaller(): void + { + $this->setupCollection('testCol'); + + $docs = [ + new Document(['$permissions' => [Permission::read(Role::any())], '$collection' => 'testCol']), + ]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('onNext error'); + + $this->database->createDocuments('testCol', $docs, 100, function () { + throw new \RuntimeException('onNext error'); + }); + } + + public function testCreateDocumentsReturnsZeroForEmptyArray(): void + { + $this->setupCollection('testCol'); + $count = $this->database->createDocuments('testCol', []); + $this->assertSame(0, $count); + } + + public function testCreateDocumentSetsEmptyPermissionsWhenNoneProvided(): void + { + $this->setupCollection('testCol'); + + $doc = new Document([ + '$collection' => 'testCol', + ]); + + $result = $this->database->createDocument('testCol', $doc); + $this->assertSame([], $result->getPermissions()); + } +} diff --git a/tests/unit/Documents/CursorTest.php b/tests/unit/Documents/CursorTest.php new file mode 100644 index 0000000000..473f98ac65 --- /dev/null +++ b/tests/unit/Documents/CursorTest.php @@ -0,0 +1,181 @@ + $limits */ + $limits = []; + $database = $this->pagingDatabase(static function (?int $limit) use (&$limits): void { + $limits[] = $limit; + }); + HookFixture::seed($database, \array_map(static fn (int $index): string => 'doc'.$index, \range(1, 150))); + + $this->assertCount(150, \iterator_to_array($database->cursor(HookFixture::COLLECTION), false)); + $this->assertSame([100, 100], $limits); + } + + public function testALimitCapsTheDocumentsYielded(): void + { + $database = $this->seeded(7); + + $this->assertSame(['doc1', 'doc2', 'doc3'], $this->ids($database->cursor(HookFixture::COLLECTION, [Query::orderAsc('views'), Query::limit(3)], 2))); + } + + public function testAnOffsetOrACursorPositionsTheFirstPage(): void + { + $database = $this->seeded(5); + $second = $database->getDocument(HookFixture::COLLECTION, 'doc2'); + + $this->assertSame(['doc3', 'doc4', 'doc5'], $this->ids($database->cursor(HookFixture::COLLECTION, [Query::orderAsc('views'), Query::offset(2)], 2))); + $this->assertSame(['doc3', 'doc4', 'doc5'], $this->ids($database->cursor(HookFixture::COLLECTION, [Query::orderAsc('views'), Query::cursorAfter($second)], 2))); + } + + public function testACursorBeforeIsRefusedWhenTheCursorIsCreated(): void + { + $database = $this->seeded(2); + $first = $database->getDocument(HookFixture::COLLECTION, 'doc1'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cursor before not supported in this method.'); + + $database->cursor(HookFixture::COLLECTION, [Query::cursorBefore($first)]); + } + + public function testAnArrayCursorIsRefusedAsAQueryErrorWithValidationOff(): void + { + $database = $this->seeded(2); + + try { + $database->skipValidation(fn (): \Generator => $database->cursor(HookFixture::COLLECTION, [Query::cursorAfter([Document::ID => 'doc1'])])); + $this->fail('An array cursor was accepted'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Invalid cursor: a cursor must be a document, array given', $error->getMessage()); + } + } + + public function testAnArrayCursorIsRefusedByFindWithValidationOff(): void + { + $database = $this->seeded(2); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Invalid cursor: a cursor must be a document, array given'); + + $database->skipValidation(fn (): array => $database->find(HookFixture::COLLECTION, [Query::cursorAfter([Document::ID => 'doc1'])])); + } + + public function testThePermissionTheCursorReadsUnder(): void + { + $database = HookFixture::sqlite(); + $database->addHook(new Permissions()); + $database->createCollection(Collection::create( + id: 'notes', + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $database->createDocument('notes', new Document([ + Document::ID => 'editable', + Document::PERMISSIONS => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'editable', + ])); + $database->createDocument('notes', new Document([ + Document::ID => 'readable', + Document::PERMISSIONS => [Permission::read(Role::any())], + 'title' => 'readable', + ])); + + $this->assertSame(['editable', 'readable'], $this->ids($database->cursor('notes', [Query::orderAsc('title')]))); + $this->assertSame(['editable'], $this->ids($database->cursor('notes', [Query::orderAsc('title')], forPermission: PermissionType::Update))); + } + + private function seeded(int $count): Database + { + $database = HookFixture::sqlite(); + HookFixture::seed($database, \array_map(static fn (int $index): string => 'doc'.$index, \range(1, $count))); + + return $database; + } + + /** + * @param Closure(?int): void $record + */ + private function pagingDatabase(Closure $record): Database + { + $database = new class (new Memory(), new Cache(new None()), $record) extends Database { + public function __construct(Adapter $adapter, Cache $cache, private readonly Closure $record) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function find(string $collection, array $queries = [], PermissionType $forPermission = PermissionType::Read): array + { + if ($collection === HookFixture::COLLECTION) { + foreach ($queries as $query) { + if ($query->getMethod() === Method::Limit) { + $limit = $query->getValue(); + ($this->record)(\is_int($limit) ? $limit : null); + } + } + } + + return parent::find($collection, $queries, $forPermission); + } + }; + $database + ->setAuthorization(new Authorization()) + ->setDatabase('hooks') + ->setNamespace('cursor_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: HookFixture::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'views')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } + + /** + * @param iterable $documents + * @return list + */ + private function ids(iterable $documents): array + { + $ids = []; + foreach ($documents as $document) { + $ids[] = $document->getId(); + } + + return $ids; + } +} diff --git a/tests/unit/Documents/DistinctCapabilityTest.php b/tests/unit/Documents/DistinctCapabilityTest.php new file mode 100644 index 0000000000..b0c2f4d0be --- /dev/null +++ b/tests/unit/Documents/DistinctCapabilityTest.php @@ -0,0 +1,91 @@ +database(new Memory()); + $this->assertFalse($database->getAdapter()->supports(Capability::Aggregations)); + + try { + $database->skipValidation(fn (): array => $database->find(self::COLLECTION, [ + Query::select(['name']), + Query::distinct(), + ])); + $this->fail('A distinct() read must be refused by an adapter that cannot deduplicate rows'); + } catch (QueryException $exception) { + $this->assertSame('Distinct queries are not supported by this adapter', $exception->getMessage()); + } + } + + public function testValidatedDistinctIsRefusedWithoutAggregationSupport(): void + { + $database = $this->database(new Memory()); + + $this->expectException(QueryException::class); + $database->find(self::COLLECTION, [Query::select(['name']), Query::distinct()]); + } + + public function testDistinctDeduplicatesWithAggregationSupport(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $this->assertTrue($database->getAdapter()->supports(Capability::Aggregations)); + + $rows = $database->skipValidation(fn (): array => $database->find(self::COLLECTION, [ + Query::select(['name']), + Query::distinct(), + ])); + + $this->assertSame(['red'], \array_map(static fn (Document $row): mixed => $row->getAttribute('name'), $rows)); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->addHook(new Permissions()); + $database + ->setDatabase('distinct_capability') + ->setNamespace('distinct_capability_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string('name', size: 32, required: false)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + )); + + foreach (['first', 'second'] as $id) { + $database->createDocument(self::COLLECTION, new Document([ + '$id' => $id, + 'name' => 'red', + ])); + } + + return $database; + } +} diff --git a/tests/unit/Documents/DocumentCacheActivationTest.php b/tests/unit/Documents/DocumentCacheActivationTest.php new file mode 100644 index 0000000000..acd2633ff8 --- /dev/null +++ b/tests/unit/Documents/DocumentCacheActivationTest.php @@ -0,0 +1,83 @@ + + */ + public static function interleavings(): array + { + return [ + 'equal generations' => [static fn (): CacheAdapter => new MemoryCache(), false], + 'the first writer flushed away' => [static fn (): CacheAdapter => new LeasableHashCache(), true], + ]; + } + + /** + * @param \Closure(): CacheAdapter $cacheAdapter + */ + #[DataProvider('interleavings')] + public function testAWriterActivatingWhileAnotherIsInFlightLeavesItsBarrierInPlace(\Closure $cacheAdapter, bool $flush): void + { + $cache = new Cache($cacheAdapter()); + $namespace = 'activation_'.\uniqid(); + $firstAdapter = new CountingMemory(); + $first = $this->database($firstAdapter, $cache, $namespace); + $cache->flush(); + $second = $this->database(new CountingMemory(), $cache, $namespace); + $cache->flush(); + + $second->withTransaction(function () use ($first, $second, $cache, $flush): void { + $first->withTransaction(function () use ($first, $second, $cache, $flush): void { + $first->updateDocuments(self::COLLECTION, new Document(['name' => 'first'])); + if ($flush) { + $cache->flush(); + } + $second->updateDocuments(self::COLLECTION, new Document(['name' => 'second'])); + }); + + $this->assertSame('first', $first->getDocument(self::COLLECTION, 'hook')->getAttribute('name')); + UncachedTwin::of($first)->updateDocument(self::COLLECTION, 'hook', new Document(['name' => 'changed'])); + $this->assertSame('changed', $first->getDocument(self::COLLECTION, 'hook')->getAttribute('name'), 'no read is cached while the second writer is in flight'); + }); + + $this->assertSame('changed', $first->getDocument(self::COLLECTION, 'hook')->getAttribute('name')); + $firstAdapter->reset(); + $this->assertSame('changed', $first->getDocument(self::COLLECTION, 'hook')->getAttribute('name')); + $this->assertSame(0, $firstAdapter->documentReads, 'caching resumes once no writer is in flight'); + } + + private function database(CountingMemory $adapter, Cache $cache, string $namespace): Database + { + $database = new Database($adapter, $cache); + $database->setDatabase('activation')->setNamespace($namespace); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'hook', 'name' => 'original'])); + + return $database; + } +} diff --git a/tests/unit/Documents/DocumentCacheEpochTest.php b/tests/unit/Documents/DocumentCacheEpochTest.php new file mode 100644 index 0000000000..5dcb16ed52 --- /dev/null +++ b/tests/unit/Documents/DocumentCacheEpochTest.php @@ -0,0 +1,960 @@ +createSharedSQLiteDatabases(); + + try { + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + + $duringCommit = null; + $adapter->pauseNextCommit(function () use ($reader, &$duringCommit): void { + $duringCommit = $reader->getDocument('users', 'user')->getAttribute('name'); + }); + + $writer->withTransaction(function () use ($writer): void { + $writer->updateDocument('users', 'user', new Document(['name' => 'updated'])); + }); + + $this->assertSame('original', $duringCommit); + $this->assertSame('updated', $reader->getDocument('users', 'user')->getAttribute('name')); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testPurgeCachedCollectionRetiresItsCachedDocuments(): void + { + $database = $this->createDatabaseWithCache(new RedisLeasableCache()); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + UncachedTwin::of($database)->updateDocument('webhooks', 'hook', new Document(['name' => 'changed'])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A document written without this cache leaves the cached copy in place'); + + $database->purgeCachedCollection('webhooks'); + + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'purgeCachedCollection() must retire every cached document of the collection'); + } + + public function testBlockFailureRollsBackTheMutation(): void + { + $cache = new FailDocumentEpochMemory(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $cache->failBlocks(); + + try { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $this->fail('Document cache block failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('block document cache epoch', $error->getMessage()); + } + + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + public function testActivationFailureAfterCommitLeavesTheEpochBlocked(): void + { + $cache = new FailDocumentEpochMemory(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $cache->failActivations(); + + try { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $this->fail('Document cache activation failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('activate document cache epoch', $error->getMessage()); + } + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + UncachedTwin::of($database)->updateDocument('webhooks', 'hook', new Document(['name' => 'changed'])); + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A collection whose activation failed stays uncached'); + } + + public function testActivationFailureDoesNotStrandOtherCollectionEpochs(): void + { + $cache = new FailDocumentEpochMemory(); + $adapter = new CountingMemory(); + $database = $this->createDatabaseWithCache($cache, adapter: $adapter); + $database->createCollection(Collection::create(id: 'logs', attributes: [ + Attribute::string(key: 'name'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $database->createDocument('logs', new Document([ + '$id' => 'log', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame('original', $database->getDocument('logs', 'log')->getAttribute('name')); + $cache->failActivations('collection:webhooks#epoch'); + + try { + $database->withTransaction(function () use ($database): void { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $this->renameDocument($database, 'logs', 'log', 'updated'); + }); + $this->fail('Document cache activation failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('activate document cache epoch', $error->getMessage()); + } + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + UncachedTwin::of($database)->updateDocument('webhooks', 'hook', new Document(['name' => 'changed'])); + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'The collection whose activation failed stays uncached'); + + $this->assertSame('updated', $database->getDocument('logs', 'log')->getAttribute('name')); + $adapter->reset(); + $this->assertSame('updated', $database->getDocument('logs', 'log')->getAttribute('name')); + $this->assertSame(0, $adapter->documentReads, 'The other collection of the transaction is cached again'); + } + + public function testCacheFlushDuringTransactionCannotPreserveAStalePointCacheEntry(): void + { + [$writer, $reader, $adapter, $path] = $this->createSharedSQLiteDatabases(); + + try { + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + + $duringCommit = null; + $adapter->pauseNextCommit(function () use ($reader, &$duringCommit): void { + $this->assertTrue($reader->getCache()->flush()); + $duringCommit = $reader->getDocument('users', 'user')->getAttribute('name'); + }); + + $writer->withTransaction(function () use ($writer): void { + $writer->updateDocument('users', 'user', new Document(['name' => 'updated'])); + }); + + $this->assertSame('original', $duringCommit); + $this->assertSame('updated', $reader->getDocument('users', 'user')->getAttribute('name')); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testCacheFlushDuringActivationDoesNotFailTheCommittedMutation(): void + { + $cache = new FlushDuringActivationMemory(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + $this->assertTrue($cache->flush()); + $cache->flushDuringActivation(); + $this->assertSame(1, $this->renameDocument($database, 'webhooks', 'hook', 'updated')); + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + public function testCacheFlushAfterActivationReadsGenerationsDoesNotFailTheCommittedMutation(): void + { + $cache = new FlushDuringActivationMemory(); + $database = $this->createDatabaseWithCache($cache); + $this->assertTrue($cache->flush()); + $database->purgeCachedCollection('webhooks'); + $cache->flushAfterReading('collection:webhooks#finished'); + + $database->deleteCollection('webhooks'); + $this->assertNull($database->findCollection('webhooks')); + } + + public function testActivationPurgeFailureStillPropagates(): void + { + $cache = new FlushDuringActivationMemory(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertTrue($cache->flush()); + $cache->failDuringActivation(); + + try { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $this->fail('Document cache activation purge failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('finish document cache invalidation', $error->getMessage()); + } + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + public function testWritesDoNotAddKeysToACacheThatKeepsPurgedKeys(): void + { + $cache = new RedisLeasableCache(); + $database = $this->createDatabaseWithCache($cache); + $database->setQueryCache(new ResultCache(new Cache($cache))); + for ($index = 0; $index <= 20; $index++) { + $database->createDocument('webhooks', new Document([ + '$id' => 'hook'.$index, + 'name' => 'hook '.$index, + ])); + } + $readEveryHook = function () use ($database): void { + for ($index = 0; $index <= 20; $index++) { + $this->assertFalse($database->getDocument('webhooks', 'hook'.$index)->isEmpty()); + } + }; + $readEveryHook(); + + $keys = 0; + for ($round = 1; $round <= 3; $round++) { + $database->withTransaction(function () use ($database, $round): void { + $database->updateDocument('webhooks', 'hook1', new Document(['name' => 'updated '.$round])); + $database->updateDocument('webhooks', 'hook2', new Document(['name' => 'updated '.$round])); + }); + $this->renameDocument($database, 'webhooks', 'hook3', 'updated '.$round); + $readEveryHook(); + + if ($round === 1) { + $keys = \count($cache->keys()); + } + } + + $this->assertSame($keys, \count($cache->keys()), 'A purged key stays behind in Redis, so writes and the reads between them must not leave keys of their own'); + $this->assertSame('updated 3', $database->getDocument('webhooks', 'hook1')->getAttribute('name')); + } + + public function testOverlappingWritesSucceedOnACacheWithoutFields(): void + { + $cache = new FailDocumentEpochMemory(); + $writer = $this->createDatabaseWithCache($cache); + $other = $this->createDatabaseWithCache($cache, $writer->getNamespace()); + foreach ([$writer, $other] as $database) { + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + } + + $writer->withTransaction(function () use ($writer, $other, $cache): void { + $this->renameDocument($writer, 'webhooks', 'hook', 'updated'); + $cache->failBlocks(); + try { + $this->renameDocument($other, 'webhooks', 'hook', 'updated'); + $this->fail('The other writer\'s block did not fail'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('block document cache epoch', $error->getMessage()); + } finally { + $cache->failBlocks(false); + } + }); + + $this->assertSame('updated', $writer->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + public function testActivationRejectsACorruptedOwnerRegistration(): void + { + $cache = new RedisLeasableCache(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $cache->corruptFieldWrites(); + + try { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $this->fail('A corrupted document cache owner registration was accepted'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('Invalid document cache owner', $error->getMessage()); + } + + $cache->corruptFieldWrites(false); + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + UncachedTwin::of($database)->updateDocument('webhooks', 'hook', new Document(['name' => 'changed'])); + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A collection whose activation was refused stays uncached'); + } + + public function testActivationPropagatesAnOwnerReleaseFailure(): void + { + $cache = new RedisLeasableCache(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $cache->failFieldPurges(); + + try { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $this->fail('A document cache owner release failure was not propagated'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('Failed to release document cache owner', $error->getMessage()); + } + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + UncachedTwin::of($database)->updateDocument('webhooks', 'hook', new Document(['name' => 'changed'])); + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A collection whose owner release failed stays uncached'); + } + + private function renameDocument(Database $database, string $collection, string $id, string $name): int + { + return $database->updateDocuments($collection, new Document(['name' => $name]), [Query::equal('$id', [$id])]); + } + + private function createDatabaseWithCache(CacheAdapter $cache, ?string $namespace = null, DatabaseMemory $adapter = new DatabaseMemory()): Database + { + $database = new Database($adapter, new Cache($cache)); + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace ?? 'epoch_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: 'webhooks', attributes: [ + Attribute::string(key: 'name'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + return $database; + } + + /** + * @return array{Database, Database, PausedDocumentSQLite, string} + */ + private function createSharedSQLiteDatabases(): array + { + $path = \tempnam(\sys_get_temp_dir(), 'document-cache-epoch-'); + if ($path === false) { + throw new \RuntimeException('Failed to create SQLite test database'); + } + + $attributes = [ + \PDO::ATTR_PERSISTENT => false, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + $writerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $readerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $writerConnection->exec('PRAGMA journal_mode = WAL'); + $writerConnection->exec('PRAGMA busy_timeout = 1000'); + $readerConnection->exec('PRAGMA busy_timeout = 1000'); + + $adapter = new PausedDocumentSQLite($writerConnection); + $cache = new MemoryCache(); + $writer = new Database($adapter, new Cache($cache)); + $reader = new Database(new SQLite($readerConnection), new Cache($cache)); + $namespace = 'shared_epoch_'.\uniqid(); + foreach ([$writer, $reader] as $database) { + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace); + $database->getAuthorization()->addRole(Role::any()->toString()); + } + + $writer->create(); + $writer->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $writer->createDocument('users', new Document([ + '$id' => 'user', + 'name' => 'original', + ])); + + return [$writer, $reader, $adapter, $path]; + } + + private function removeSQLiteFiles(string $path): void + { + foreach ([$path, $path.'-wal', $path.'-shm'] as $file) { + if (\is_file($file)) { + \unlink($file); + } + } + } + + public function testALostActivationDoesNotKeepTheCollectionUncached(): void + { + $cache = new FailDocumentEpochMemory(); + $adapter = new CountingMemory(); + $database = $this->createCountedDatabase($adapter, $cache); + $cache->failActivations(); + + try { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $this->fail('The activation did not fail'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('activate document cache epoch', $error->getMessage()); + } + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + UncachedTwin::of($database)->updateDocument('webhooks', 'hook', new Document(['name' => 'changed'])); + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A collection whose write has not activated stays uncached while the write is younger than the writer timeout'); + + $database->setCacheWriterTimeout(0); + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $adapter->reset(); + for ($read = 0; $read < 3; $read++) { + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + $this->assertSame(0, $adapter->documentReads, 'Past the writer timeout the lost activation lapses and reads are served from the cache again, without a flush'); + } + + public function testAWriteAfterAnAbandonedWriteReenablesTheCollection(): void + { + $cache = new AbandoningCache(); + $adapter = new CountingMemory(); + $database = $this->createCountedDatabase($adapter, $cache); + $cache->abandonNextWrite(); + + try { + $this->renameDocument($database, 'webhooks', 'hook', 'abandoned'); + $this->fail('The abandoned write released its registration'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('release document cache owner', $error->getMessage()); + } + + $this->renameDocument($database, 'webhooks', 'hook', 'second'); + $this->assertSame('second', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + UncachedTwin::of($database)->updateDocument('webhooks', 'hook', new Document(['name' => 'changed'])); + $this->assertSame('changed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A write younger than the writer timeout counts as in flight, so the next write leaves the collection uncached'); + + $database->setCacheWriterTimeout(0); + $this->renameDocument($database, 'webhooks', 'hook', 'third'); + $database->setCacheWriterTimeout(3600); + + $this->assertSame('third', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $adapter->reset(); + $this->assertSame('third', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame(0, $adapter->documentReads, 'The first write after the abandoned one passed the writer timeout releases it and re-enables the collection'); + } + + public function testAWriterYoungerThanTheTimeoutKeepsItsBlockUntilItsActivation(): void + { + $cache = new FailDocumentEpochMemory(); + [$writer, $reader, $path] = $this->createSQLiteDatabasesSharing($cache); + + try { + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + + try { + $writer->withTransaction(function () use ($writer, $reader, $cache): void { + $this->renameDocument($writer, 'users', 'user', 'updated'); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + $cache->failActivations(); + }); + $this->fail('The activation did not fail'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('activate document cache epoch', $error->getMessage()); + } + + $this->assertSame('updated', $reader->getDocument('users', 'user')->getAttribute('name'), 'A reader must not cache what it read while a write younger than the writer timeout was in flight'); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testAWriterPastTheTimeoutRetiresWhatReadersFilledWhileItRan(): void + { + [$writer, $reader, $path] = $this->createSQLiteDatabasesSharing(new MemoryCache()); + $writer->setCacheWriterTimeout(0); + $reader->setCacheWriterTimeout(0); + + try { + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + + $writer->withTransaction(function () use ($writer, $reader): void { + $this->renameDocument($writer, 'users', 'user', 'updated'); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + }); + + $this->assertSame('updated', $reader->getDocument('users', 'user')->getAttribute('name'), 'What readers filled after the block lapsed must be retired when the write activates'); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testAWriteFinishingWhileAnotherIsInFlightLeavesTheCollectionBlocked(): void + { + $cache = new AbandoningCache(); + [$writer, $reader, $path] = $this->createSQLiteDatabasesSharing($cache); + + try { + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + + try { + $writer->withTransaction(function () use ($writer, $reader, $cache): void { + $this->renameDocument($writer, 'users', 'user', 'updated'); + $reader->purgeCachedCollection('users'); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + $cache->abandonNextWrite(); + }); + $this->fail('The abandoned write released its registration'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString('release document cache owner', $error->getMessage()); + } + + $this->assertSame('updated', $reader->getDocument('users', 'user')->getAttribute('name'), 'A write that finishes while another is in flight must not re-enable the collection'); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testAWriterReleasedAsAbandonedStillRetiresWhatReadersFilledWhileItRan(): void + { + [$writer, $reader, $path] = $this->createSQLiteDatabasesSharing(new RedisLeasableCache()); + $reader->setCacheWriterTimeout(0); + + try { + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + + $writer->withTransaction(function () use ($writer, $reader): void { + $this->renameDocument($writer, 'users', 'user', 'updated'); + $reader->purgeCachedCollection('users'); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + $this->assertSame('original', $reader->getDocument('users', 'user')->getAttribute('name')); + }); + + $this->assertSame('updated', $reader->getDocument('users', 'user')->getAttribute('name'), 'A write another writer released as abandoned must still retire, when it activates, what readers filled while it ran'); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testATransactionReadsItsBatchWriteAfterAnotherReaderRefilledTheDefinition(): void + { + $cache = new MemoryCache(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $definitionKey = \strtolower($database->getCacheBaseKeys(Database::METADATA, 'webhooks')[1]); + $definition = $cache->load($definitionKey, Database::TTL); + if (! \is_array($definition)) { + $this->fail("No cached definition under '{$definitionKey}' to save again"); + } + + $read = $database->withTransaction(function () use ($database, $cache, $definitionKey, $definition): mixed { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + $cache->save($definitionKey, $definition); + $document = $database->getDocument('webhooks', 'hook'); + + return $document->getAttribute('name'); + }); + + $this->assertSame('updated', $read, 'A transaction must read what its batch write changed even when another reader saved the definition as it was before the write'); + } + + public function testADefinitionFilledAcrossAWriteOnACacheWithoutGenerationsIsDropped(): void + { + $cache = new InterleavingMemory(); + $database = $this->createDatabaseWithCache($cache); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + $database->purgeCachedDocument(Database::METADATA, 'webhooks'); + $cache->beforeNextSave(':_metadata:webhooks', function () use ($database): void { + $this->renameDocument($database, 'webhooks', 'hook', 'updated'); + }); + $database->getCollection('webhooks'); + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A definition saved after a write retired its epoch must not keep serving that epoch'); + } + + public function testARegistrationReleasedJustBeforeItsOwnReleaseFinishesTheWrite(): void + { + $cache = new RacedReleaseCache(); + $adapter = new CountingMemory(); + $database = $this->createCountedDatabase($adapter, $cache); + $cache->releaseBeforeNextOwnerRelease(); + + $this->assertSame(1, $this->renameDocument($database, 'webhooks', 'hook', 'updated')); + + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $adapter->reset(); + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame(0, $adapter->documentReads, 'A registration another worker already released is not a failed release: the write activates and reads are cached again'); + } + + private function createCountedDatabase(CountingMemory $adapter, CacheAdapter $cache): Database + { + $database = new Database($adapter, new Cache($cache)); + $database + ->setDatabase('utopiaTests') + ->setNamespace('epoch_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: 'webhooks', attributes: [ + Attribute::string(key: 'name'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $database->createDocument('webhooks', new Document([ + '$id' => 'hook', + 'name' => 'original', + ])); + $this->assertSame('original', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + return $database; + } + + /** + * @return array{Database, Database, string} + */ + private function createSQLiteDatabasesSharing(CacheAdapter $cache): array + { + $path = \tempnam(\sys_get_temp_dir(), 'document-cache-lapse-'); + if ($path === false) { + throw new \RuntimeException('Failed to create SQLite test database'); + } + + $attributes = [ + \PDO::ATTR_PERSISTENT => false, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + $writerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $readerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $writerConnection->exec('PRAGMA journal_mode = WAL'); + $writerConnection->exec('PRAGMA busy_timeout = 1000'); + $readerConnection->exec('PRAGMA busy_timeout = 1000'); + + $writer = new Database(new SQLite($writerConnection), new Cache($cache)); + $reader = new Database(new SQLite($readerConnection), new Cache($cache)); + $namespace = 'lapse_'.\uniqid(); + foreach ([$writer, $reader] as $database) { + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace); + $database->getAuthorization()->addRole(Role::any()->toString()); + } + + $writer->create(); + $writer->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $writer->createDocument('users', new Document([ + '$id' => 'user', + 'name' => 'original', + ])); + + return [$writer, $reader, $path]; + } +} + +final class PausedDocumentSQLite extends SQLite +{ + private ?Closure $commitCallback = null; + + public function pauseNextCommit(Closure $callback): void + { + $this->commitCallback = $callback; + } + + #[\Override] + public function commitTransaction(): bool + { + if ($this->inTransaction === 1) { + $callback = $this->commitCallback; + $this->commitCallback = null; + $callback?->__invoke(); + } + + return parent::commitTransaction(); + } +} + +final class FailDocumentEpochMemory extends MemoryCache +{ + private bool $failingBlocks = false; + + private ?string $activationFailure = null; + + public function failBlocks(bool $failing = true): void + { + $this->failingBlocks = $failing; + } + + public function failActivations(?string $key = null): void + { + $this->activationFailure = $key ?? '#epoch'; + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if (\str_ends_with($key, '#epoch') && \is_string($data)) { + if ($this->failingBlocks && \str_starts_with($data, 'blocked:')) { + return false; + } + if ( + $this->activationFailure !== null + && \str_contains($key, $this->activationFailure) + && ! \str_starts_with($data, 'blocked:') + ) { + return false; + } + } + + return parent::save($key, $data, $hash); + } +} + +final class FlushDuringActivationMemory extends MemoryCache implements Leasable +{ + /** @var array */ + private array $generations = []; + + private bool $flushDuringActivation = false; + + private bool $failDuringActivation = false; + + private ?string $flushAfterReading = null; + + public function flushDuringActivation(): void + { + $this->flushDuringActivation = true; + } + + public function failDuringActivation(): void + { + $this->failDuringActivation = true; + } + + public function flushAfterReading(string $key): void + { + $this->flushAfterReading = $key; + } + + #[\Override] + public function getGeneration(string $key): string + { + $generation = (string) ($this->generations[$key] ?? 0); + if ($this->flushAfterReading !== null && \str_ends_with($key, $this->flushAfterReading)) { + $this->flushAfterReading = null; + $this->flush(); + } + + return $generation; + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if ($this->getGeneration($key) !== $generation) { + return false; + } + + return $this->save($key, $data, $hash); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->flushDuringActivation && \str_ends_with($key, '#finished')) { + $this->flushDuringActivation = false; + + return $this->flush(); + } + if ($this->failDuringActivation && \str_ends_with($key, '#finished')) { + return false; + } + + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + parent::purge($key, $hash); + + return true; + } + + #[\Override] + public function flush(): bool + { + $this->generations = []; + + return parent::flush(); + } +} + +/** + * A Redis-like cache that can abandon a write the way a killed worker does: the write stays registered and its + * collection stays blocked, because the release of its registration is refused and its activation stops there. + */ +final class AbandoningCache implements CacheAdapter, Leasable +{ + private RedisLeasableCache $cache; + + private bool $abandoning = false; + + public function __construct() + { + $this->cache = new RedisLeasableCache(); + } + + public function abandonNextWrite(): void + { + $this->abandoning = true; + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->cache->load($key, $ttl, $hash); + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $this->cache->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + return $this->cache->touch($key, $hash); + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + return $this->cache->list($key); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->abandoning && $hash !== '' && \str_ends_with($key, '#owners')) { + $this->abandoning = false; + + return false; + } + + return $this->cache->purge($key, $hash); + } + + #[\Override] + public function flush(): bool + { + return $this->cache->flush(); + } + + #[\Override] + public function ping(): bool + { + return true; + } + + #[\Override] + public function getSize(): int + { + return $this->cache->getSize(); + } + + #[\Override] + public function getName(?string $key = null): string + { + return 'abandoning'; + } + + #[\Override] + public function getGeneration(string $key): string + { + return $this->cache->getGeneration($key); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + return $this->cache->saveWithLease($key, $data, $hash, $generation); + } +} + +/** + * A cache without generations that runs a callback just before one save lands, as when a write commits between a + * reader's database read and its fill. + */ +final class InterleavingMemory extends MemoryCache +{ + private ?string $fragment = null; + + private ?Closure $callback = null; + + public function beforeNextSave(string $fragment, Closure $callback): void + { + $this->fragment = $fragment; + $this->callback = $callback; + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + $callback = $this->callback; + if ($callback !== null && $this->fragment !== null && \str_contains($key, $this->fragment)) { + $this->callback = null; + $callback(); + } + + return parent::save($key, $data, $hash); + } +} diff --git a/tests/unit/Documents/DocumentCacheInvalidationTest.php b/tests/unit/Documents/DocumentCacheInvalidationTest.php new file mode 100644 index 0000000000..1d854f9faf --- /dev/null +++ b/tests/unit/Documents/DocumentCacheInvalidationTest.php @@ -0,0 +1,440 @@ +createDatabase(new CountingMemory(), $cache); + for ($index = 0; $index < self::DOCUMENTS; $index++) { + $database->createDocument('webhooks', $this->hook('hook'.$index)); + } + + $keysAfterFirstRound = 0; + for ($round = 1; $round <= 20; $round++) { + for ($index = 0; $index < self::DOCUMENTS; $index++) { + $database->updateDocument('webhooks', 'hook'.$index, new Document(['name' => 'round '.$round])); + $this->assertSame('round '.$round, $database->getDocument('webhooks', 'hook'.$index)->getAttribute('name')); + } + + if ($round === 1) { + $keysAfterFirstRound = \count($cache->keys()); + } + } + + $this->assertSame( + $keysAfterFirstRound, + \count($cache->keys()), + 'A purged key stays behind in Redis with no expiry, so writes and reads of the same documents must not add keys', + ); + } + + public function testACacheWithoutFieldsServesEachSelectionItsOwnCopy(): void + { + $database = $this->createDatabase(new CountingMemory(), new MemoryCache()); + $database->createDocument('webhooks', $this->hook('hook')); + + for ($round = 0; $round < 2; $round++) { + $plain = $database->getDocument('webhooks', 'hook'); + $this->assertSame('description', $plain->getAttribute('description'), 'A read without a selection must get every attribute'); + + $projected = $database->getDocument('webhooks', 'hook', [Query::select(['name'])]); + $this->assertSame('hook', $projected->getAttribute('name')); + $this->assertFalse($projected->offsetExists('description'), 'A projected read must get only what it selected'); + } + } + + public function testACachedMissUnderOneCasingDoesNotHideAnotherCasing(): void + { + $database = $this->createDatabase($this->caseSensitiveAdapter(), new MemoryCache()); + $database->createDocument('webhooks', $this->hook('Hook')); + + $this->assertTrue($database->getDocument('webhooks', 'hook')->isEmpty(), 'The adapter stores ids case-sensitively'); + $this->assertSame('Hook', $database->getDocument('webhooks', 'Hook')->getId(), 'A cached miss for one casing must not answer another'); + $this->assertTrue($database->getDocument('webhooks', 'hook')->isEmpty(), 'A cached document must not answer another casing of its id'); + $this->assertSame('Hook', $database->getDocument('webhooks', 'Hook')->getId()); + } + + public function testAWriteKeepsItsSiblingsCached(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $database->createDocument('webhooks', $this->hook('written')); + $database->createDocument('webhooks', $this->hook('sibling')); + $sibling = $database->getDocument('webhooks', 'sibling'); + + $database->updateDocument('webhooks', 'written', new Document(['name' => 'renamed'])); + $adapter->reset(); + + $this->assertSame($sibling->getArrayCopy(), $database->getDocument('webhooks', 'sibling')->getArrayCopy()); + $this->assertSame(0, $adapter->documentReads, 'A write to one document must leave its siblings cached (7.3.12: 0 reads)'); + $this->assertSame('renamed', $database->getDocument('webhooks', 'written')->getAttribute('name')); + } + + /** + * @return array + */ + public static function singleDocumentWrites(): array + { + return [ + 'createDocument' => [ + static fn (Database $database): Document => $database->createDocument('webhooks', new Document([ + '$id' => 'created', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'created', + ])), + ], + 'updateDocument' => [ + static fn (Database $database): Document => $database->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])), + ], + 'increaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->increaseDocumentAttribute('webhooks', 'hook', 'count'), + ], + 'decreaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->decreaseDocumentAttribute('webhooks', 'hook', 'count'), + ], + 'deleteDocument' => [ + static fn (Database $database): bool => $database->deleteDocument('webhooks', 'hook'), + ], + ]; + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('singleDocumentWrites')] + public function testSingleDocumentWritesDoNotBlockTheCollection(Closure $write): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $database->createDocument('webhooks', $this->hook('hook')); + $database->createDocument('webhooks', $this->hook('sibling')); + $database->getDocument('webhooks', 'hook'); + $database->getDocument('webhooks', 'sibling'); + + $write($database); + $adapter->reset(); + + $this->assertSame('hook', $database->getDocument('webhooks', 'sibling')->getAttribute('name')); + $this->assertSame(0, $adapter->documentReads, 'A single-document write must leave the rest of its collection cached'); + } + + public function testACollectionDefinitionWriteKeepsTheOtherDefinitionsCached(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $database->createCollection(Collection::create(id: 'logs', permissions: [Permission::read(Role::any())])); + $database->getCollection('webhooks'); + $database->getCollection('logs'); + + $database->updateCollection('logs', new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::create(Role::any())], documentSecurity: true)); + $adapter->reset(); + + $this->assertNotNull($database->findCollection('webhooks')); + $this->assertSame(0, $adapter->metadataReads, 'A write to one collection definition must leave the other definitions cached'); + $this->assertTrue($database->getCollection('logs')->getAttribute('documentSecurity'), 'The written definition must be read again'); + } + + public function testAFailedPurgeInsideTheTransactionRollsTheWriteBack(): void + { + /** @var bool $refusing */ + $refusing = false; + $database = $this->createDatabase(new CountingMemory(), $this->purgeRefusingCache( + static function () use (&$refusing): bool { + $refused = $refusing; + $refusing = false; + + return $refused; + } + )); + $database->createDocument('webhooks', $this->hook('hook')); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + $refusing = true; + $failure = null; + try { + $database->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])); + } catch (Throwable $error) { + $failure = $error->getMessage(); + } + + $this->assertSame(self::PURGE_FAILURE, $failure, 'A failed purge inside the transaction must reach the caller'); + + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A write whose document could not be purged must roll back'); + } + + public function testAFailedPostCommitPurgeNeverServesAPreCommitFill(): void + { + $path = \tempnam(\sys_get_temp_dir(), 'document-cache-invalidation-'); + if ($path === false) { + throw new RuntimeException('Failed to create SQLite test database'); + } + + try { + $attributes = [ + \PDO::ATTR_PERSISTENT => false, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + $writerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $readerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $writerConnection->exec('PRAGMA journal_mode = WAL'); + $writerConnection->exec('PRAGMA busy_timeout = 1000'); + $readerConnection->exec('PRAGMA busy_timeout = 1000'); + + /** @var bool $refusing */ + $refusing = false; + $cache = $this->purgeRefusingCache(static function () use (&$refusing): bool { + $refused = $refusing; + $refusing = false; + + return $refused; + }); + $adapter = new PausedSQLite($writerConnection); + $writer = $this->createDatabase($adapter, $cache, 'shared_invalidation_'.\uniqid()); + $reader = $this->configure(new Database(new SQLite($readerConnection), new Cache($cache)), $writer->getNamespace()); + $writer->createDocument('webhooks', $this->hook('hook')); + $this->assertSame('hook', $reader->getDocument('webhooks', 'hook')->getAttribute('name')); + + $duringCommit = null; + $adapter->pauseNextCommit(function () use ($reader, &$duringCommit, &$refusing): void { + $duringCommit = $reader->getDocument('webhooks', 'hook')->getAttribute('name'); + $refusing = true; + }); + + $failure = null; + try { + $writer->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])); + } catch (Throwable $error) { + $failure = $error->getMessage(); + } + + $this->assertSame(self::PURGE_FAILURE, $failure, 'A failed purge after the commit must reach the caller'); + + $this->assertSame('hook', $duringCommit, 'A reader outside the transaction reads the committed row and caches it'); + $this->assertSame('renamed', $reader->getDocument('webhooks', 'hook')->getAttribute('name'), 'A fill made before the commit must never be served after it, even when the purge after the commit fails'); + } finally { + foreach ([$path, $path.'-wal', $path.'-shm'] as $file) { + if (\is_file($file)) { + \unlink($file); + } + } + } + } + + public function testARollbackKeepsServingTheCommittedDocument(): void + { + $database = $this->createDatabase(new CountingMemory(), new RedisLeasableCache()); + $database->createDocument('webhooks', $this->hook('hook')); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + try { + $database->withTransaction(function () use ($database): void { + $database->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + } + + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + public function testCreateAndDeleteChurnLeavesOnlyAGenerationPerDocument(): void + { + $cache = new RedisLeasableCache(); + $database = $this->createDatabase(new CountingMemory(), $cache); + $database->createDocument('webhooks', $this->hook('warm')); + $database->getDocument('webhooks', 'warm'); + $keys = \count($cache->keys()); + $values = $cache->countValues(); + + $churned = 10; + for ($index = 0; $index < $churned; $index++) { + $id = 'churn'.$index; + $database->createDocument('webhooks', $this->hook($id)); + $this->assertFalse($database->getDocument('webhooks', $id)->isEmpty()); + $this->assertTrue($database->deleteDocument('webhooks', $id)); + $this->assertTrue($database->getDocument('webhooks', $id)->isEmpty()); + $database->purgeCachedDocument('webhooks', $id); + } + + $this->assertLessThanOrEqual($keys + $churned, \count($cache->keys()), 'A purge keeps at most one generation-only key per document id ever written'); + $this->assertLessThanOrEqual($values, $cache->countValues(), 'A churned document must leave no cached value behind'); + } + + private function hook(string $id): Document + { + return new Document([ + '$id' => $id, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'hook', + 'description' => 'description', + ]); + } + + private function caseSensitiveAdapter(): DatabaseMemory + { + return new class () extends DatabaseMemory { + #[\Override] + protected function documentKey(string $id, int|string|null $tenant = null): string + { + return $this->sharedTables ? ($tenant ?? $this->getTenant()).'|'.$id : $id; + } + }; + } + + /** + * @param Closure(): bool $refuses Whether the purge throws, asked once per purge + */ + private function purgeRefusingCache(Closure $refuses): CacheAdapter&Leasable + { + return new class ($refuses) implements CacheAdapter, Leasable { + private RedisLeasableCache $cache; + + /** + * @param Closure(): bool $refuses + */ + public function __construct(private readonly Closure $refuses) + { + $this->cache = new RedisLeasableCache(); + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->cache->load($key, $ttl, $hash); + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $this->cache->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + return $this->cache->touch($key, $hash); + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + return $this->cache->list($key); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if (($this->refuses)()) { + throw new RuntimeException(DocumentCacheInvalidationTest::PURGE_FAILURE); + } + + return $this->cache->purge($key, $hash); + } + + #[\Override] + public function flush(): bool + { + return $this->cache->flush(); + } + + #[\Override] + public function ping(): bool + { + return true; + } + + #[\Override] + public function getSize(): int + { + return $this->cache->getSize(); + } + + #[\Override] + public function getName(?string $key = null): string + { + return 'purge-failing'; + } + + #[\Override] + public function getGeneration(string $key): string + { + return $this->cache->getGeneration($key); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + return $this->cache->saveWithLease($key, $data, $hash, $generation); + } + }; + } + + private function createDatabase(DatabaseAdapter $adapter, CacheAdapter $cache, ?string $namespace = null): Database + { + $database = $this->configure(new Database($adapter, new Cache($cache)), $namespace ?? 'document_cache_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: 'webhooks', attributes: [ + Attribute::string(key: 'name'), + Attribute::string(key: 'description'), + Attribute::integer(key: 'count', default: 10), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + + return $database; + } + + private function configure(Database $database, string $namespace): Database + { + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace); + $database->getAuthorization()->addRole(Role::any()->toString()); + + return $database; + } +} diff --git a/tests/unit/Documents/DocumentCacheRoundTripTest.php b/tests/unit/Documents/DocumentCacheRoundTripTest.php new file mode 100644 index 0000000000..6e5027a77f --- /dev/null +++ b/tests/unit/Documents/DocumentCacheRoundTripTest.php @@ -0,0 +1,334 @@ +createDatabase(); + $database->getCollection('webhooks'); + + $adapter->reset(); + $cache->resetOperations(); + $this->assertNotNull($database->findCollection('webhooks')); + + $this->assertLessThanOrEqual(1, $cache->getOperations(), '7.3.12: 1'); + $this->assertSame(0, $adapter->metadataReads); + } + + public function testAGetDocumentHitStaysWithinSevenThreeRoundTrips(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('hook')); + $database->getDocument('webhooks', 'hook'); + + $adapter->reset(); + $cache->resetOperations(); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + $this->assertLessThanOrEqual(2, $cache->getOperations(), '7.3.12: 2'); + $this->assertSame(0, $adapter->documentReads + $adapter->metadataReads); + } + + public function testACachedMissStaysWithinSevenThreeRoundTrips(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->getDocument('webhooks', 'missing'); + + $adapter->reset(); + $cache->resetOperations(); + $this->assertTrue($database->getDocument('webhooks', 'missing')->isEmpty()); + + $this->assertLessThanOrEqual(2, $cache->getOperations(), '7.3.12: 2'); + $this->assertSame(0, $adapter->documentReads); + } + + public function testAnUncachedGetDocumentStaysWithinSevenThreeRoundTrips(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('hook')); + $database->getCollection('webhooks'); + + $adapter->reset(); + $cache->resetOperations(); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + $this->assertLessThanOrEqual(5, $cache->getOperations(), '7.3.12: 5'); + $this->assertSame(1, $adapter->documentReads, 'An uncached document is read once'); + } + + /** + * @return array + */ + public static function collectionReads(): array + { + return [ + 'find' => [static fn (Database $database): array => $database->find('webhooks', [Query::equal('name', ['hook'])])], + 'count' => [static fn (Database $database): int => $database->count('webhooks', [Query::equal('name', ['hook'])])], + 'sum' => [static fn (Database $database): int|float => $database->sum('webhooks', 'count')], + ]; + } + + /** + * @param Closure(Database): mixed $read + */ + #[DataProvider('collectionReads')] + public function testCollectionReadsStayWithinSevenThreeRoundTrips(Closure $read): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('hook')); + $read($database); + + $adapter->reset(); + $cache->resetOperations(); + $read($database); + + $this->assertLessThanOrEqual(1, $cache->getOperations(), '7.3.12: 1'); + $this->assertSame(0, $adapter->metadataReads); + } + + public function testASiblingReadAfterAWriteStaysWithinSevenThreeRoundTripsAndReadsNothing(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('written')); + $database->createDocument('webhooks', $this->hook('sibling')); + $database->getDocument('webhooks', 'sibling'); + $database->updateDocument('webhooks', 'written', new Document(['name' => 'renamed'])); + + $adapter->reset(); + $cache->resetOperations(); + $this->assertSame('hook', $database->getDocument('webhooks', 'sibling')->getAttribute('name')); + + $this->assertLessThanOrEqual(2, $cache->getOperations(), '7.3.12: 2'); + $this->assertSame(0, $adapter->documentReads + $adapter->metadataReads, '7.3.12: 0'); + } + + /** + * @return array + */ + public static function singleDocumentWrites(): array + { + return [ + 'createDocument' => [ + static fn (Database $database): Document => $database->createDocument('webhooks', new Document([ + '$id' => 'created', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'created', + ])), + 3, + ], + 'updateDocument' => [ + static fn (Database $database): Document => $database->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])), + 6, + ], + 'increaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->increaseDocumentAttribute('webhooks', 'hook', 'count'), + 4, + ], + 'decreaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->decreaseDocumentAttribute('webhooks', 'hook', 'count'), + 4, + ], + 'deleteDocument' => [ + static fn (Database $database): bool => $database->deleteDocument('webhooks', 'hook'), + 6, + ], + ]; + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('singleDocumentWrites')] + public function testSingleDocumentWritesStayWithinSevenThreeRoundTrips(Closure $write, int $baseline): void + { + [$database, , $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('hook')); + $database->getDocument('webhooks', 'hook'); + + $cache->resetOperations(); + $write($database); + + $this->assertLessThanOrEqual($baseline, $cache->getOperations(), "7.3.12: {$baseline}"); + } + + public function testAnUpdateAndAReadInATransactionStayWithinSevenThreeRoundTrips(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('written')); + $database->createDocument('webhooks', $this->hook('sibling')); + $database->getDocument('webhooks', 'written'); + $database->getDocument('webhooks', 'sibling'); + + $adapter->reset(); + $cache->resetOperations(); + $read = $database->withTransaction(function () use ($database): Document { + $database->updateDocument('webhooks', 'written', new Document(['name' => 'renamed'])); + + return $database->getDocument('webhooks', 'sibling'); + }); + + $this->assertSame('hook', $read->getAttribute('name')); + $this->assertLessThanOrEqual(11, $cache->getOperations(), '7.3.12: 11'); + $this->assertSame(0, $adapter->metadataReads, '7.3.12: 0'); + $this->assertLessThanOrEqual(1, $adapter->documentReads, '7.3.12: 1'); + } + + public function testAnUpdateAndAReadOfItStayWithinSevenThreeRoundTrips(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('hook')); + $database->getDocument('webhooks', 'hook'); + + $adapter->reset(); + $cache->resetOperations(); + for ($round = 1; $round <= 10; $round++) { + $database->updateDocument('webhooks', 'hook', new Document(['name' => 'round '.$round])); + $this->assertSame('round '.$round, $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + $this->assertLessThanOrEqual(110, $cache->getOperations(), '7.3.12: 110'); + $this->assertLessThanOrEqual(20, $adapter->documentReads, '7.3.12: 20'); + $this->assertSame(0, $adapter->metadataReads); + } + + public function testATenantNeverServesUnderAnotherTenantsEpochOfAGlobalDefinition(): void + { + $adapter = new CountingMemory(); + $database = new Database($adapter, new Cache(new RedisLeasableCache())); + $database + ->setDatabase('utopiaTests') + ->setNamespace('global_'.\uniqid()) + ->setSharedTables(true) + ->setTenant(null) + ->setGlobalCollections(['webhooks']); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create(id: 'webhooks', attributes: [ + Attribute::string(key: 'name'), + Attribute::integer(key: 'count', default: 10), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + foreach ([1, 2] as $tenant) { + $database->setTenant($tenant); + $database->createDocument('webhooks', $this->hook('hook')); + } + $database->purgeCachedDocument(Database::METADATA, 'webhooks'); + + $database->setTenant(2); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $database->setTenant(1); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + + $database->updateDocuments('webhooks', new Document(['name' => 'renamed'])); + $database->setTenant(2); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $database->setTenant(1); + + $this->assertSame('renamed', $database->getDocument('webhooks', 'hook')->getAttribute('name'), 'A global definition is shared by every tenant, but the epoch it carries is each tenant\'s own'); + } + + public function testPurgingTheMetadataCollectionRetiresEveryCachedDefinition(): void + { + [$database] = $this->createDatabase(); + $database->createCollection(Collection::create(id: 'logs', permissions: [Permission::read(Role::any())])); + $this->assertTrue($database->getCollection('webhooks')->getAttribute('documentSecurity')); + $this->assertTrue($database->getCollection('logs')->getAttribute('documentSecurity')); + + $uncached = UncachedTwin::of($database); + $uncached->updateCollection('webhooks', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: false)); + $uncached->updateCollection('logs', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: false)); + $this->assertTrue($database->getCollection('webhooks')->getAttribute('documentSecurity'), 'A definition written without this cache leaves the cached definition in place'); + + $database->purgeCachedCollection(Database::METADATA); + + $this->assertFalse($database->getCollection('webhooks')->getAttribute('documentSecurity'), 'purgeCachedCollection(\'_metadata\') must retire every cached definition'); + $this->assertFalse($database->getCollection('logs')->getAttribute('documentSecurity'), 'purgeCachedCollection(\'_metadata\') must retire every cached definition'); + } + + public function testPurgingTheMetadataCollectionRetiresACachedMissingCollection(): void + { + [$database] = $this->createDatabase(); + $this->assertNull($database->findCollection('logs')); + + $uncached = UncachedTwin::of($database); + $uncached->createCollection(Collection::create(id: 'logs', permissions: [Permission::read(Role::any())])); + $this->assertNull($database->findCollection('logs'), 'A definition written without this cache leaves the cached miss in place'); + + $database->purgeCachedCollection(Database::METADATA); + + $this->assertNotNull($database->findCollection('logs'), 'purgeCachedCollection(\'_metadata\') must retire a cached missing collection'); + } + + /** + * @return array{Database, CountingMemory, CountingCache} + */ + private function createDatabase(): array + { + $adapter = new CountingMemory(); + $cache = new CountingCache(new RedisLeasableCache()); + $database = $this->configure(new Database($adapter, new Cache($cache)), 'round_trips_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: 'webhooks', attributes: [ + Attribute::string(key: 'name'), + Attribute::integer(key: 'count', default: 10), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + + return [$database, $adapter, $cache]; + } + + private function hook(string $id): Document + { + return new Document([ + '$id' => $id, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'hook', + 'count' => 1, + ]); + } + + private function configure(Database $database, string $namespace): Database + { + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace); + $database->getAuthorization()->addRole(Role::any()->toString()); + + return $database; + } +} diff --git a/tests/unit/Documents/DocumentMinorsTest.php b/tests/unit/Documents/DocumentMinorsTest.php new file mode 100644 index 0000000000..0d3a9c3177 --- /dev/null +++ b/tests/unit/Documents/DocumentMinorsTest.php @@ -0,0 +1,298 @@ +database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'entry', 'ratio' => 10.0, 'count' => 1])); + + $this->assertSame(11.5, $database->increaseDocumentAttribute(self::COLLECTION, 'entry', 'ratio', '1.5')->getAttribute('ratio')); + $this->assertSame(13.5, $database->increaseDocumentAttribute(self::COLLECTION, 'entry', 'ratio', '2e0')->getAttribute('ratio')); + $this->assertSame(10.5, $database->decreaseDocumentAttribute(self::COLLECTION, 'entry', 'ratio', '3')->getAttribute('ratio')); + + $schemaless = $this->database($this->without(Capability::DefinedAttributes)); + $schemaless->createDocument(self::COLLECTION, new Document([Document::ID => 'entry'])); + $this->assertSame(3, $schemaless->increaseDocumentAttribute(self::COLLECTION, 'entry', 'hits', '3')->getAttribute('hits')); + $this->assertSame(4.5, $schemaless->increaseDocumentAttribute(self::COLLECTION, 'entry', 'hits', '1.5')->getAttribute('hits')); + } + + public function testANonNumericBoundIsRefused(): void + { + $database = $this->database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'entry', 'ratio' => 10.0])); + + $this->assertThrows(TypeException::class, 'Value must be numeric.', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'entry', 'ratio', 1, 'plenty')); + $this->assertSame(10.0, $database->getDocument(self::COLLECTION, 'entry')->getAttribute('ratio')); + } + + public function testATimeToLiveIndexWithoutAPeriodIsRefused(): void + { + $this->assertThrows( + IndexException::class, + 'TTL must be at least 1 second', + fn (): Index => Index::fromArray(['key' => 'expiry', 'type' => IndexType::Ttl, 'attributes' => ['recordedAt'], 'ttl' => 0]), + ); + } + + public function testAnUnchangedListOfRelatedIdsIsNotAChange(): void + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new class (new Memory(), new Cache(new None())) extends Database { + #[\Override] + public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $document = parent::getDocument($collection, $id, $queries, $forUpdate); + if ($forUpdate && $collection === 'parents') { + /** @var array $children */ + $children = $document->getAttribute('children', []); + $document->setAttribute('children', \array_map( + static fn (Document|string $child): string => $child instanceof Document ? $child->getId() : $child, + $children, + )); + } + + return $document; + } + }; + $database->setAuthorization($authorization)->setDatabase('minors')->setNamespace('minors_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + $readOnly = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: 'parents', permissions: $readOnly)); + $database->createCollection(Collection::create(id: 'children', permissions: [...$readOnly, Permission::update(Role::any())])); + $database->createRelationship('parents', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, key: 'children', twoWayKey: 'parent')); + $database->createDocument('children', new Document([Document::ID => 'c1'])); + $database->createDocument('children', new Document([Document::ID => 'c2'])); + $database->createDocument('parents', new Document([Document::ID => 'p1', 'children' => ['c1', 'c2']])); + + $updated = $database->updateDocument('parents', 'p1', new Document(['children' => ['c1', 'c2']])); + + $this->assertSame('p1', $updated->getId(), 'an unchanged list needs no update permission'); + } + + public function testBulkDeleteGuards(): void + { + $database = $this->database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'entry', 'count' => 1])); + + $this->assertThrows(DatabaseException::class, 'Collection not found', fn (): int => $database->deleteDocuments('missing')); + $this->assertThrows(QueryException::class, 'Invalid query: Attribute not found in schema: missing', fn (): int => $database->deleteDocuments(self::COLLECTION, [Query::equal('missing', ['x'])])); + $this->assertThrows(DatabaseException::class, 'Cursor document must be from the same Collection.', fn (): int => $database->deleteDocuments(self::COLLECTION, [Query::cursorAfter(new Document([Document::ID => 'entry', Document::COLLECTION => 'other']))])); + + $collection = $database->getCollection(self::COLLECTION); + $stored = $database->getAdapter()->getDocument($collection, 'entry'); + $stored->setAttribute(Document::UPDATED_AT, 'not-a-date'); + $database->getAdapter()->updateDocument($collection, 'entry', $stored, true); + $this->assertThrows(DatabaseException::class, null, fn (): int => $database->deleteDocuments(self::COLLECTION)); + + $this->assertSame(1, $database->count(self::COLLECTION)); + } + + public function testAWriteWhoseCacheOwnerCannotBeRegisteredIsRolledBack(): void + { + $cache = new class () extends MemoryCache { + public bool $refuseOwners = false; + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ($this->refuseOwners && \str_contains($key, '#owner')) { + return false; + } + + return parent::save($key, $data, $hash); + } + }; + $database = $this->database(new Memory(), new Cache($cache)); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'entry', 'count' => 1])); + $database->getDocument(self::COLLECTION, 'entry'); + $cache->refuseOwners = true; + + $this->assertThrows(RuntimeException::class, null, fn (): int => $database->updateDocuments(self::COLLECTION, new Document(['count' => 2]))); + $cache->refuseOwners = false; + + $this->assertSame(1, $database->getDocument(self::COLLECTION, 'entry')->getAttribute('count')); + } + + public function testMixedOrNestedDocumentListsAreNotCached(): void + { + $database = $this->database(new Memory(), new Cache(new MemoryCache())); + $entry = $database->createDocument(self::COLLECTION, new Document([Document::ID => 'entry', 'count' => 1])); + $foreign = new Document([Document::ID => 'foreign', Document::COLLECTION => 'other']); + $loose = new Document([Document::ID => 'loose']); + + foreach ([ + 'documents of two collections' => [$entry, $foreign], + 'a document then a value' => [$entry, 'x'], + 'a value then a document' => ['x', $entry], + 'a document without a collection' => [$loose], + 'a document inside a list' => [[$entry]], + 'a document deep inside a list' => ['x', ['y', [$entry]]], + ] as $case => $value) { + $this->calls = 0; + $key = 'minors:'.\md5($case); + $database->withCache($key, fn (): array => $this->tally($value)); + $database->withCache($key, fn (): array => $this->tally($value)); + $this->assertSame(2, $this->calls, "{$case} is computed every time"); + } + + $this->calls = 0; + $database->withCache('minors:plain', fn (): array => $this->tally(['x', ['y', 'z']])); + $this->assertSame(['x', ['y', 'z']], $database->withCache('minors:plain', fn (): array => $this->tally(['other']))); + $this->assertSame(1, $this->calls, 'a list of plain values is cached'); + } + + public function testCountAndSumWithARelationshipFilterThatMatchesNothingAreZero(): void + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new Database(new Memory(), new Cache(new None())); + $database->setAuthorization($authorization)->setDatabase('minors')->setNamespace('minors_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: 'books', attributes: [Attribute::integer(key: 'pages')], permissions: $permissions)); + $database->createCollection(Collection::create(id: 'authors', attributes: [Attribute::string(key: 'name', size: 32)], permissions: $permissions)); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $database->createDocument('authors', new Document([Document::ID => 'ada', 'name' => 'Ada'])); + $database->createDocument('books', new Document([Document::ID => 'notes', 'pages' => 120, 'author' => 'ada'])); + + $this->assertSame(1, $database->count('books', [Query::equal('author.name', ['Ada'])])); + $this->assertSame(120, $database->sum('books', 'pages', [Query::equal('author.name', ['Ada'])])); + $this->assertSame(0, $database->count('books', [Query::equal('author.name', ['Nobody'])])); + $this->assertSame(0, $database->sum('books', 'pages', [Query::equal('author.name', ['Nobody'])])); + } + + public function testAJoinOnAMissingCollectionIsRefused(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $join = Query::join('missing', 'gone', [Query::on('count', 'count')]); + + foreach ([ + fn (): mixed => $database->find(self::COLLECTION, [$join]), + fn (): mixed => $database->count(self::COLLECTION, [$join]), + ] as $read) { + $this->assertThrows(QueryException::class, "Joined collection 'missing' not found", fn (): mixed => $database->skipValidation($read)); + } + } + + public function testSelectionsMustNameDeclaredAttributes(): void + { + $database = $this->database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'entry', 'count' => 1])); + + $this->assertThrows(QueryException::class, 'Cannot select attributes: unknown', fn (): mixed => $database->skipValidation( + fn (): array => $database->find(self::COLLECTION, [Query::select(['count', 'unknown'])]), + )); + $this->assertThrows(QueryException::class, 'Select queries must contain only string attributes.', fn (): mixed => $database->skipValidation( + fn (): array => $database->find(self::COLLECTION, [new Query(Method::Select, '', ['count', 5])]), + )); + $this->assertSame(1, $database->skipValidation(fn (): array => $database->find(self::COLLECTION, [Query::select(['count'])]))[0]->getAttribute('count')); + } + + /** + * @param array $value + * @return array + */ + private function tally(array $value): array + { + $this->calls++; + + return $value; + } + + private function without(Capability $missing): Memory + { + return new class ($missing) extends Memory { + public function __construct(private readonly Capability $missing) + { + parent::__construct(); + } + + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + fn (Capability $capability): bool => $capability !== $this->missing, + )); + } + }; + } + + /** + * @param class-string $exception + * @param callable(): mixed $operation + */ + private function assertThrows(string $exception, ?string $message, callable $operation): void + { + $error = null; + try { + $operation(); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf($exception, $error); + if ($message !== null) { + $this->assertSame($message, $error->getMessage()); + } + } + + private function database(Adapter $adapter, ?Cache $cache = null): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new Database($adapter, $cache ?? new Cache(new None())); + $database->setAuthorization($authorization)->setDatabase('minors')->setNamespace('minors_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::integer(key: 'count'), Attribute::float(key: 'ratio'), Attribute::datetime(key: 'recordedAt')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Documents/DocumentWriteMinorsTest.php b/tests/unit/Documents/DocumentWriteMinorsTest.php new file mode 100644 index 0000000000..64ec0e384b --- /dev/null +++ b/tests/unit/Documents/DocumentWriteMinorsTest.php @@ -0,0 +1,274 @@ +database($adapter); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'abc', + 'name' => 'renamed', + ])); + + $renamed = $database->updateDocument(self::COLLECTION, 'ABC', new Document(['$id' => 'ABC'])); + + $this->assertSame('ABC', $renamed->getId()); + $this->assertSame( + [['_uid' => 'ABC', 'name' => 'renamed']], + $this->rows($pdo, 'SELECT _uid, name FROM "'.$database->getNamespace().'_'.self::COLLECTION.'"'), + ); + } + + public function testFindLeavesTheCallersCursorUnchanged(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + foreach (['first', 'second'] as $id) { + $database->createDocument(self::COLLECTION, new Document([ + '$id' => $id, + 'secret' => $id, + 'seen' => '2026-01-02T03:04:05.678+00:00', + ])); + } + $cursor = $database->getDocument(self::COLLECTION, 'first'); + $before = $cursor->getArrayCopy(); + + $page = $database->find(self::COLLECTION, [Query::cursorAfter($cursor), Query::limit(1)]); + + $this->assertSame(['second'], \array_map(static fn (Document $document): string => $document->getId(), $page)); + $this->assertSame($before, $cursor->getArrayCopy()); + } + + public function testBulkUpdateInsideARequestTimestampComparesTheStoredTimestamp(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'first', 'counter' => 1])); + $requestTimestamp = new DateTime(); + \usleep(5_000); + + $modified = $database->withRequestTimestamp( + $requestTimestamp, + fn (): int => $database->updateDocuments(self::COLLECTION, new Document(['counter' => 2])), + ); + + $this->assertSame(1, $modified); + $this->assertSame(2, $database->getDocument(self::COLLECTION, 'first')->getAttribute('counter')); + } + + public function testBulkUpdateOfADocumentWrittenAfterTheRequestTimestampConflicts(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'first', 'counter' => 1])); + + try { + $database->withRequestTimestamp( + new DateTime('-1 hour'), + fn (): int => $database->updateDocuments(self::COLLECTION, new Document(['counter' => 2])), + ); + $this->fail('A bulk update of a document written after the request timestamp was accepted'); + } catch (ConflictException $exception) { + $this->assertSame('Document was updated after the request timestamp', $exception->getMessage()); + } + + $this->assertSame(1, $database->getDocument(self::COLLECTION, 'first')->getAttribute('counter')); + } + + public function testBulkUpdateHandsOnNextDecodedValuesWithASelect(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'first', 'name' => 'one', 'counter' => 1, 'data' => ['k' => 1]])); + /** @var list $handed */ + $handed = []; + + $database->updateDocuments( + self::COLLECTION, + new Document(['data' => ['k' => 2]]), + [Query::select(['counter'])], + onNext: function (Document $document) use (&$handed): void { + $handed[] = $document; + }, + ); + + $this->assertCount(1, $handed); + $this->assertSame(['k' => 2], $handed[0]->getAttribute('data')); + $this->assertSame(1, $handed[0]->getAttribute('counter')); + $this->assertFalse($handed[0]->offsetExists('name')); + $this->assertSame(['k' => 2], $database->getDocument(self::COLLECTION, 'first')->getAttribute('data')); + } + + public function testRetriedBulkUpdateHandsOnNextDecodedValues(): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public int $commitFailures = 0; + + #[\Override] + public function commitTransaction(): bool + { + if ($this->commitFailures > 0) { + $this->commitFailures--; + + throw new TransactionException('Failed to commit transaction: Commit failed'); + } + + return parent::commitTransaction(); + } + }; + $database = $this->database($adapter); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'first', 'counter' => 1, 'secret' => 'alpha'])); + /** @var list $handed */ + $handed = []; + $adapter->commitFailures = 1; + + $modified = $database->updateDocuments( + self::COLLECTION, + new Document(['counter' => 2]), + onNext: function (Document $document) use (&$handed): void { + $handed[] = $document; + }, + ); + + $this->assertSame(0, $adapter->commitFailures); + $this->assertSame(1, $modified); + $this->assertCount(1, $handed); + $this->assertSame('alpha', $handed[0]->getAttribute('secret')); + $this->assertSame(2, $handed[0]->getAttribute('counter')); + $stored = $database->getDocument(self::COLLECTION, 'first'); + $this->assertSame('alpha', $stored->getAttribute('secret')); + $this->assertSame(2, $stored->getAttribute('counter')); + } + + /** + * @return array}> + */ + public static function bulkUpdateSelections(): array + { + return [ + 'without a select' => [[]], + 'with a select that leaves the updated attribute out' => [[Query::select(['counter'])]], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('bulkUpdateSelections')] + public function testBulkUpdateHandsOnNextUpdatedValuesDecodedOnce(array $queries): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'first', 'counter' => 1, 'secret' => 'alpha'])); + /** @var list $handed */ + $handed = []; + + $database->updateDocuments( + self::COLLECTION, + new Document(['secret' => 'gamma']), + $queries, + onNext: function (Document $document) use (&$handed): void { + $handed[] = $document; + }, + ); + + $this->assertCount(1, $handed); + $this->assertSame('gamma', $handed[0]->getAttribute('secret')); + $this->assertSame(1, $handed[0]->getAttribute('counter')); + $this->assertSame('gamma', $database->getDocument(self::COLLECTION, 'first')->getAttribute('secret')); + } + + /** + * @return list> + */ + private function rows(PDO $pdo, string $sql): array + { + $statement = $pdo->query($sql); + $this->assertNotFalse($statement); + + /** @var list> $rows */ + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + return $rows; + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None()), [ + new Callback( + self::WRAPPED, + static fn (mixed $value): ?string => $value === null ? null : \json_encode(['value' => $value], JSON_THROW_ON_ERROR), + static function (mixed $value): mixed { + if ($value === null) { + return null; + } + + $decoded = \is_string($value) ? \json_decode($value, true) : null; + if (! \is_array($decoded) || ! \array_key_exists('value', $decoded)) { + throw new RuntimeException('Decoded a value that was never encoded: '.\var_export($value, true)); + } + + return $decoded['value']; + }, + ), + ]); + $database->addHook(new Permissions()); + $database + ->setDatabase('write_minors') + ->setNamespace('write_minors_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string('name', size: 64, required: false), + Attribute::integer('counter', required: false), + Attribute::string('secret', size: 1024, required: false, filters: [self::WRAPPED]), + Attribute::string('data', size: 1024, required: false, filters: [Filter::Json]), + Attribute::datetime('seen', required: false), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + + return $database; + } +} diff --git a/tests/unit/Documents/DocumentsValidatorCacheTest.php b/tests/unit/Documents/DocumentsValidatorCacheTest.php new file mode 100644 index 0000000000..7ba8466fc4 --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorCacheTest.php @@ -0,0 +1,64 @@ +orders = new Document([ + '$id' => 'orders', + 'attributes' => [], + 'indexes' => [], + ]); + $this->customers = new Document([ + '$id' => 'customers', + 'attributes' => [], + 'indexes' => [], + ]); + } + + public function testValidatorIsReusedWithoutJoinedCollections(): void + { + $database = new DocumentsValidatorDatabase(new Memory(), new Cache(new None())); + + $this->assertSame( + $database->documentsValidator($this->orders), + $database->documentsValidator($this->orders), + ); + } + + public function testJoinedCollectionsBypassTheCache(): void + { + $database = new DocumentsValidatorDatabase(new Memory(), new Cache(new None())); + + $cached = $database->documentsValidator($this->orders); + $joined = $database->documentsValidator($this->orders, [$this->customers]); + + $this->assertNotSame($cached, $joined); + $this->assertNotSame($joined, $database->documentsValidator($this->orders, [$this->customers])); + $this->assertSame($cached, $database->documentsValidator($this->orders)); + } + + public function testMirrorForwardsJoinedCollectionsToTheSource(): void + { + $mirror = new DocumentsValidatorMirror(new Database(new Memory(), new Cache(new None()))); + + $cached = $mirror->documentsValidator($this->orders); + + $this->assertSame($cached, $mirror->documentsValidator($this->orders)); + $this->assertNotSame($cached, $mirror->documentsValidator($this->orders, [$this->customers])); + } +} diff --git a/tests/unit/Documents/DocumentsValidatorDatabase.php b/tests/unit/Documents/DocumentsValidatorDatabase.php new file mode 100644 index 0000000000..f101843c66 --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorDatabase.php @@ -0,0 +1,41 @@ + $joinedCollections + */ + public function documentsValidator(Document $collection, array $joinedCollections = []): DocumentsValidator + { + return $this->getDocumentsValidator($collection, $joinedCollections); + } + + /** + * @param array $queries + */ + public function queriesValidator(Document $collection, array $queries): Base + { + return $this->getQueriesValidator($collection, $queries); + } + + /** + * @param array $joinedCollections + */ + #[\Override] + protected function getDocumentsValidator(Document $collection, array $joinedCollections = []): DocumentsValidator + { + $this->documentsValidators++; + + return parent::getDocumentsValidator($collection, $joinedCollections); + } +} diff --git a/tests/unit/Documents/DocumentsValidatorFingerprintTest.php b/tests/unit/Documents/DocumentsValidatorFingerprintTest.php new file mode 100644 index 0000000000..854f459f24 --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorFingerprintTest.php @@ -0,0 +1,71 @@ +assertSame( + $database->documentsValidator($this->collection()), + $database->documentsValidator($this->collection()), + ); + $this->assertSame( + $database->documentsValidator($this->collection()), + $database->documentsValidator(clone $this->collection()), + ); + } + + public function testEveryPartOfTheSchemaSelectsItsOwnValidator(): void + { + $database = new DocumentsValidatorDatabase(new Memory(), new Cache(new None())); + $original = $database->documentsValidator($this->collection()); + + $renamed = $this->collection(); + $renamed->setAttribute('attributes', [Attribute::string(key: 'author', size: 64)->toDocument()]); + $resized = $this->collection(); + $resized->setAttribute('attributes', [Attribute::string(key: 'title', size: 32)->toDocument()]); + $added = $this->collection(); + $added->setAttribute('attributes', Attribute::integer(key: 'pages')->toDocument(), SetType::Append); + $indexed = $this->collection(); + $indexed->setAttribute('indexes', []); + $permitted = $this->collection(); + $permitted->setAttribute('$permissions', [Permission::read(Role::users())]); + $secured = $this->collection(); + $secured->setAttribute('documentSecurity', true); + + foreach (['renamed' => $renamed, 'resized' => $resized, 'added' => $added, 'indexed' => $indexed, 'permitted' => $permitted, 'secured' => $secured] as $change => $collection) { + $this->assertNotSame($original, $database->documentsValidator($collection), $change); + } + + $this->assertTrue($database->documentsValidator($renamed)->isValid([Query::equal('author', ['ada'])])); + $this->assertFalse($database->documentsValidator($renamed)->isValid([Query::equal('title', ['ada'])])); + $this->assertTrue($database->documentsValidator($added)->isValid([Query::equal('pages', [1])])); + $this->assertFalse($database->documentsValidator($this->collection())->isValid([Query::equal('pages', [1])])); + } + + private function collection(): Collection + { + return Collection::create( + id: 'books', + attributes: [Attribute::string(key: 'title', size: 64)], + indexes: [Index::key(key: 'by_title', attributes: ['title'])], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + ); + } +} diff --git a/tests/unit/Documents/DocumentsValidatorGrammarTest.php b/tests/unit/Documents/DocumentsValidatorGrammarTest.php new file mode 100644 index 0000000000..adcede2a93 --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorGrammarTest.php @@ -0,0 +1,71 @@ +orders = new Document([ + '$id' => 'orders', + 'attributes' => [ + new Document([ + '$id' => 'amount', + 'key' => 'amount', + 'type' => ColumnType::Integer->value, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], + 'indexes' => [], + ]); + } + + public function testAdaptersWithoutJoinsOrAggregationsKeepTheFilterGrammar(): void + { + $validator = (new DocumentsValidatorDatabase(new Memory(), new Cache(new None())))->documentsValidator($this->orders); + + $this->assertFalse($validator->isValid([Query::join('customers', 'j0', [Query::on('$id', 'customerId')])])); + $this->assertSame('Invalid query method: join', $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::sum('amount', 'total')])); + $this->assertSame('Invalid query method: sum', $validator->getDescription()); + } + + public function testAdaptersWithJoinsAndAggregationsAcceptThem(): void + { + $validator = (new DocumentsValidatorDatabase(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())))->documentsValidator($this->orders); + + $this->assertTrue($validator->isValid([Query::join('customers', 'j0', [Query::on('$id', 'customerId')])]), $validator->getDescription()); + $this->assertTrue($validator->isValid([Query::sum('amount', 'total')]), $validator->getDescription()); + } + + public function testTheCachedValidatorFollowsTheProfile(): void + { + $database = new DocumentsValidatorDatabase(new Memory(), new Cache(new None())); + $queries = [Query::select(['$tenant'])]; + + $this->assertFalse($database->documentsValidator($this->orders)->isValid($queries)); + + $database->setSharedTables(true); + + $validator = $database->documentsValidator($this->orders); + $this->assertTrue($validator->isValid($queries), $validator->getDescription()); + } +} diff --git a/tests/unit/Documents/DocumentsValidatorMirror.php b/tests/unit/Documents/DocumentsValidatorMirror.php new file mode 100644 index 0000000000..50632ef6ef --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorMirror.php @@ -0,0 +1,18 @@ + $joinedCollections + */ + public function documentsValidator(Document $collection, array $joinedCollections = []): DocumentsValidator + { + return $this->getDocumentsValidator($collection, $joinedCollections); + } +} diff --git a/tests/unit/Documents/FindLogicTest.php b/tests/unit/Documents/FindLogicTest.php new file mode 100644 index 0000000000..d5407424fb --- /dev/null +++ b/tests/unit/Documents/FindLogicTest.php @@ -0,0 +1,1251 @@ +useAdapter(self::createStub(Adapter::class)); + } + + private function mockAdapter(): Adapter&MockObject + { + $adapter = $this->createMock(Adapter::class); + $this->useAdapter($adapter); + + return $adapter; + } + + private function useAdapter(Adapter&Stub $adapter): void + { + $this->adapter = $adapter; + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Vectors, + ]); + }); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + /** + * @param array $attributes + * @param array $indexes + * @param array $permissions + */ + private function collectionDoc(string $id, array $attributes = [], array $indexes = [], array $permissions = [], bool $documentSecurity = true): Document + { + if (empty($permissions)) { + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + return new Document([ + '$id' => $id, + '$collection' => Database::METADATA, + '$permissions' => $permissions, + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => $documentSecurity, + ]); + } + + /** + * @param array $attributes + * @param array $indexes + * @param array $permissions + * @param array $collections + */ + private function setupCollectionLookup(string $id, array $attributes = [], array $indexes = [], array $permissions = [], bool $documentSecurity = true, array $collections = []): void + { + $map = [$id => $this->collectionDoc($id, $attributes, $indexes, $permissions, $documentSecurity)]; + foreach ($collections as $collection) { + $map[$collection->getId()] = $collection; + } + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($map) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + if ($col->getId() === Database::METADATA && isset($map[$docId])) { + return $map[$docId]; + } + + return new Document(); + } + ); + } + + public function testFindWithEmptyQueriesReturnsAdapterResults(): void + { + $this->setupCollectionLookup('testCol'); + $doc = new Document(['$id' => 'doc1', 'name' => 'test']); + $this->adapter->method('find')->willReturn([$doc]); + + $results = $this->database->find('testCol'); + $this->assertCount(1, $results); + $this->assertSame('doc1', $results[0]->getId()); + } + + public function testFindThrowsNotFoundExceptionForMissingCollection(): void + { + $this->adapter->method('getDocument')->willReturn(new Document()); + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + $this->database->find('nonexistent'); + } + + public function testFindValidatesQueriesViaDocumentsValidator(): void + { + $this->setupCollectionLookup('testCol'); + $this->database->setValidation(true); + $this->expectException(QueryException::class); + $this->database->find('testCol', [Query::equal('nonexistent_attr', ['val'])]); + } + + public function testFindRespectsDefaultLimit(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + 25, + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol'); + } + + public function testFindRespectsCustomLimit(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + 10, + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::limit(10)]); + } + + public function testFindRespectsOffset(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + 5, + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::offset(5)]); + } + + public function testFindAddsSequenceToOrderByForUniqueness(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(function (array $orderAttributes) { + return in_array('$sequence', $orderAttributes, true); + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol'); + } + + public function testFindSkipsSequenceWhenIdAlreadyInOrder(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(function (array $orderAttributes) { + return in_array('$id', $orderAttributes, true) + && ! in_array('$sequence', $orderAttributes, true); + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::orderAsc('$id')]); + } + + public function testFindSkipsSequenceWhenSequenceAlreadyInOrder(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(function (mixed $orderAttributes): bool { + if (! \is_array($orderAttributes)) { + return false; + } + $values = \array_filter($orderAttributes, \is_string(...)); + $sequenceCount = \array_count_values($values)[Document::SEQUENCE] ?? 0; + + return $sequenceCount === 1; + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::orderAsc('$sequence')]); + } + + public function testVectorFindOrdersByDistanceAloneWithoutCursor(): void + { + $adapter = $this->mockAdapter(); + $attributes = [ + new Document(['$id' => 'embedding', 'key' => 'embedding', 'type' => ColumnType::Vector->value, 'size' => 2, 'required' => false, 'array' => false]), + ]; + $this->setupCollectionLookup('testCol', $attributes); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + [], + [], + $this->anything(), + $this->anything(), + $this->anything(), + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::vectorCosine('embedding', [0.1, 0.2])]); + } + + public function testVectorFindWithCursorKeepsSequenceTieBreaker(): void + { + $adapter = $this->mockAdapter(); + $attributes = [ + new Document(['$id' => 'embedding', 'key' => 'embedding', 'type' => ColumnType::Vector->value, 'size' => 2, 'required' => false, 'array' => false]), + ]; + $this->setupCollectionLookup('testCol', $attributes); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + ['$sequence'], + [OrderDirection::Asc], + $this->anything(), + $this->anything(), + $this->anything(), + ) + ->willReturn([]); + + $cursor = new Document([ + '$id' => 'page', + '$sequence' => 17, + '$collection' => 'testCol', + '$distance' => 0.25, + ]); + + $this->database->find('testCol', [ + Query::vectorCosine('embedding', [0.1, 0.2]), + Query::cursorAfter($cursor), + ]); + } + + public function testDateOrderAppendsMatchingSequenceTieBreakLast(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + ['$createdAt', '$updatedAt', '$sequence'], + [OrderDirection::Desc, OrderDirection::Asc, OrderDirection::Desc], + $this->anything(), + $this->anything(), + $this->anything(), + ) + ->willReturn([]); + + $this->database->find('testCol', [ + Query::orderDesc('$createdAt'), + Query::orderAsc('$updatedAt'), + ]); + } + + public function testFindCursorValidationThrowsOnEmptyCursorAttribute(): void + { + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false]), + new Document(['$id' => 'age', 'key' => 'age', 'type' => 'integer', 'size' => 0, 'required' => false, 'array' => false]), + ]; + $this->setupCollectionLookup('testCol', $attributes); + + $cursorDoc = new Document([ + '$id' => 'cursor1', + '$collection' => 'testCol', + 'name' => 'test', + ]); + + $this->expectException(OrderException::class); + $this->expectExceptionMessage('Order attribute'); + $this->database->skipValidation(fn () => $this->database->find('testCol', [ + Query::orderAsc('name'), + Query::orderAsc('age'), + Query::cursorAfter($cursorDoc), + ])); + } + + public function testFindCursorCollectionMismatchThrows(): void + { + $this->setupCollectionLookup('testCol'); + + $cursorDoc = new Document([ + '$id' => 'cursor1', + '$collection' => 'otherCollection', + '$sequence' => '1', + ]); + + $this->expectException(\Utopia\Database\Exception::class); + $this->expectExceptionMessage('cursor Document must be from the same Collection'); + $this->database->find('testCol', [Query::cursorAfter($cursorDoc)]); + } + + public function testFindPassesQueriesToAdapter(): void + { + $adapter = $this->mockAdapter(); + $attributes = [ + new Document(['$id' => 'status', 'key' => 'status', 'type' => 'string', 'size' => 64, 'required' => false, 'array' => false]), + ]; + $indexes = [ + new Document(['$id' => 'idx_status', 'key' => 'idx_status', 'type' => 'key', 'attributes' => ['status'], 'lengths' => [], 'orders' => []]), + ]; + $this->setupCollectionLookup('testCol', $attributes, $indexes); + + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->callback(function (mixed $queries): bool { + if (! \is_array($queries)) { + return false; + } + foreach ($queries as $q) { + if ($q instanceof Query && $q->getAttribute() === 'status') { + return true; + } + } + + return false; + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::equal('status', ['active'])]); + } + + public function testFindDecodesDocumentsAfterRetrieval(): void + { + $this->setupCollectionLookup('testCol'); + $rawDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]); + $this->adapter->method('find')->willReturn([$rawDoc]); + + $results = $this->database->find('testCol'); + $this->assertCount(1, $results); + $this->assertSame('testCol', $results[0]->getAttribute('$collection')); + } + + public function testFindEncodesCursorBeforePassingToAdapter(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $cursorDoc = new Document([ + '$id' => 'c1', + '$collection' => 'testCol', + '$sequence' => '100', + ]); + + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(function ($cursor) { + return is_array($cursor) && ! empty($cursor); + }), + CursorDirection::After, + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::cursorAfter($cursorDoc)]); + } + + public function testAggregateOnUnsupportedAdapterThrows(): void + { + $this->setupCollectionLookup('testCol'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Aggregation queries are not supported'); + $this->database->skipValidation(fn () => $this->database->aggregate('testCol', [ + Query::count('*', 'cnt'), + ])); + } + + public function testFindRefusesAnAggregate(): void + { + $this->setupCollectionLookup('testCol'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('find() does not run aggregate or groupBy queries: use aggregate()'); + $this->database->skipValidation(fn () => $this->database->find('testCol', [ + Query::count('*', 'cnt'), + ])); + } + + public function testFindWithJoinOnUnsupportedAdapterThrows(): void + { + $this->setupCollectionLookup('testCol'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported'); + $this->database->skipValidation(fn () => $this->database->find('testCol', [ + Query::join('other', 'j0', [Query::on('fk', '$id')]), + ])); + } + + public function testAggregateWithCursorThrows(): void + { + $db = $this->buildDbWithCapabilities(self::createStub(Adapter::class), [ + Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, + Capability::DefinedAttributes, Capability::Aggregations, + ]); + + $cursorDoc = new Document([ + '$id' => 'c1', + '$collection' => 'testCol', + '$sequence' => '100', + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Cursor pagination is not supported with aggregation queries'); + $db->skipValidation(fn () => $db->aggregate('testCol', [ + Query::count('*', 'cnt'), + Query::cursorAfter($cursorDoc), + ])); + } + + public function testAggregateWithGroupBy(): void + { + $db = $this->buildDbWithCapabilities($this->createMock(Adapter::class), [ + Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, + Capability::DefinedAttributes, Capability::Aggregations, + ], function (Adapter&MockObject $adapter): void { + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->callback(function (mixed $queries): bool { + if (! \is_array($queries)) { + return false; + } + foreach ($queries as $q) { + if ($q instanceof Query && $q->getMethod()->value === 'groupBy') { + return true; + } + } + + return false; + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([new Document(['status' => 'active', 'cnt' => 5])]); + }); + + $results = $db->skipValidation(fn () => $db->aggregate('testCol', [ + Query::groupBy(['status']), + Query::count('*', 'cnt'), + ])); + $this->assertSame([['status' => 'active', 'cnt' => 5]], $results); + } + + public function testFindWithDistinct(): void + { + $db = $this->buildDbWithCapabilities($this->createMock(Adapter::class), [ + Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, + Capability::DefinedAttributes, Capability::Aggregations, + ], function (Adapter&MockObject $adapter): void { + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->callback(function (mixed $queries): bool { + if (! \is_array($queries)) { + return false; + } + foreach ($queries as $q) { + if ($q instanceof Query && $q->getMethod()->value === 'distinct') { + return true; + } + } + + return false; + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + }); + + $db->skipValidation(fn () => $db->find('testCol', [Query::distinct()])); + } + + public function testFindDistinctDoesNotAppendSequenceOrder(): void + { + $db = $this->buildDbWithCapabilities($this->createMock(Adapter::class), [ + Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, + Capability::DefinedAttributes, Capability::Aggregations, + ], function (Adapter&MockObject $adapter): void { + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(function (array $orderAttributes) { + return ! \in_array(Document::SEQUENCE, $orderAttributes, true); + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + }); + + $db->skipValidation(fn () => $db->find('testCol', [ + Query::distinct(), + ])); + } + + public function testFindSkipsAuthorizationWhenCollectionAllowsRead(): void + { + $this->setupCollectionLookup('testCol'); + $this->adapter->method('find')->willReturnCallback(function () { + $this->assertFalse($this->database->getAuthorization()->getStatus()); + + return []; + }); + + $this->database->skipValidation(fn () => $this->database->find('testCol')); + } + + public function testFindKeepsAuthorizationWithoutCollectionRead(): void + { + $this->setupCollectionLookup('testCol', permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + $this->adapter->method('find')->willReturnCallback(function () { + $this->assertTrue($this->database->getAuthorization()->getStatus()); + + return []; + }); + + $this->database->skipValidation(fn () => $this->database->find('testCol')); + } + + public function testFindKeepsAuthorizationEnabledOnJoins(): void + { + $authOnFind = null; + $db = null; + $db = $this->buildDbWithCapabilities(self::createStub(Adapter::class), [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Joins, + ], function (Adapter&Stub $adapter) use (&$authOnFind, &$db): void { + $adapter->method('find')->willReturnCallback(function () use (&$authOnFind, &$db) { + $authOnFind = $db?->getAuthorization()->getStatus(); + + return []; + }); + }, extraCollections: ['other' => $this->collectionDoc('other')]); + + $db->skipValidation(fn () => $db->find('testCol', [ + Query::join('other', 'j0', [Query::on('fk', '$id')]), + ])); + + $this->assertTrue($authOnFind); + } + + public function testCountKeepsAuthorizationEnabledOnJoins(): void + { + $authOnCount = null; + $captured = null; + $db = null; + $db = $this->buildDbWithCapabilities(self::createStub(Adapter::class), [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Joins, + ], function (Adapter&Stub $adapter) use (&$authOnCount, &$captured, &$db): void { + $adapter->method('count')->willReturnCallback(function (Document $collection, array $queries) use (&$authOnCount, &$captured, &$db) { + $authOnCount = $db?->getAuthorization()->getStatus(); + $captured = $queries; + + return 0; + }); + }, extraCollections: ['other' => $this->collectionDoc('other')]); + + $db->skipValidation(fn () => $db->count('testCol', [ + Query::join('other', 'j0', [Query::on('fk', '$id')]), + Query::equal('status', ['ok']), + ])); + + $this->assertTrue($authOnCount); + $this->assertIsArray($captured); + $hasJoin = false; + foreach ($captured as $query) { + if ($query instanceof Query && $query->getMethod()->isJoin()) { + $hasJoin = true; + break; + } + } + $this->assertTrue($hasJoin); + } + + public function testSumKeepsAuthorizationEnabledOnJoins(): void + { + $authOnSum = null; + $db = null; + $db = $this->buildDbWithCapabilities(self::createStub(Adapter::class), [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Joins, + ], function (Adapter&Stub $adapter) use (&$authOnSum, &$db): void { + $adapter->method('sum')->willReturnCallback(function () use (&$authOnSum, &$db) { + $authOnSum = $db?->getAuthorization()->getStatus(); + + return 0; + }); + }, extraCollections: ['other' => $this->collectionDoc('other')]); + + $db->skipValidation(fn () => $db->sum('testCol', 'status', [ + Query::join('other', 'j0', [Query::on('fk', '$id')]), + ])); + + $this->assertTrue($authOnSum); + } + + public function testFindStampsJoinDocumentSecurityForPhysicalIds(): void + { + $captured = null; + $join = $this->collectionDoc('jp_public', documentSecurity: false); + $db = $this->buildDbWithCapabilities( + self::createStub(Adapter::class), + [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Joins, + ], + function (Adapter&Stub $adapter) use (&$captured): void { + $adapter->method('find')->willReturnCallback(function (Document $collection) use (&$captured) { + $captured = $collection->getAttribute('joinDocumentSecurity'); + + return []; + }); + }, + extraCollections: [ + 'database_1_collection_1' => $this->collectionDoc('database_1_collection_1', documentSecurity: false), + 'database_1_collection_2' => $join, + ], + ); + + $db->skipValidation(fn () => $db->find('database_1_collection_1', [ + Query::leftJoin('database_1_collection_2', 'rev', [Query::on('$id', 'mainId')]), + ])); + + $this->assertIsArray($captured); + $this->assertSame(false, $captured['database_1_collection_2'] ?? true); + $this->assertSame(false, $captured['jp_public'] ?? true); + } + + public function testGetDocumentKeepsAuthorizationEnabledOnJoins(): void + { + $authOnGet = null; + $db = null; + $db = $this->buildDbWithCapabilities( + self::createStub(Adapter::class), + [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Joins, + ], + extraCollections: ['other' => $this->collectionDoc('other')], + getDocumentOverride: function (Document $col, string $docId) use (&$authOnGet, &$db): ?Document { + if ($col->getId() === Database::METADATA) { + return null; + } + + $authOnGet = $db?->getAuthorization()->getStatus(); + + return new Document([ + '$id' => $docId, + '$collection' => $col->getId(), + ]); + }, + ); + + $db->skipValidation(fn () => $db->getDocument('testCol', 'doc1', [ + Query::join('other', 'j0', [Query::on('fk', '$id')]), + ])); + + $this->assertTrue($authOnGet); + } + + public function testFindWithSelectFiltersResults(): void + { + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false]), + new Document(['$id' => 'age', 'key' => 'age', 'type' => 'integer', 'size' => 0, 'required' => false, 'array' => false]), + ]; + $this->setupCollectionLookup('testCol', $attributes); + + $rawDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + 'name' => 'Alice', + 'age' => 30, + ]); + $this->adapter->method('find')->willReturn([$rawDoc]); + + $results = $this->database->find('testCol', [Query::select(['name'])]); + $this->assertCount(1, $results); + } + + public function testCountDelegatesToAdapter(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $adapter->expects($this->once()) + ->method('count') + ->willReturn(42); + + $result = $this->database->count('testCol'); + $this->assertSame(42, $result); + } + + public function testSumDelegatesToAdapter(): void + { + $adapter = $this->mockAdapter(); + $attributes = [ + new Document(['$id' => 'amount', 'key' => 'amount', 'type' => 'double', 'size' => 0, 'required' => false, 'array' => false]), + ]; + $this->setupCollectionLookup('testCol', $attributes); + $adapter->expects($this->once()) + ->method('sum') + ->willReturn(150.5); + + $result = $this->database->sum('testCol', 'amount'); + $this->assertSame(150.5, $result); + } + + public function testCursorYieldsDocumentsFromBatches(): void + { + $this->setupCollectionLookup('testCol'); + + $doc1 = new Document(['$id' => 'd1', '$collection' => 'testCol', '$sequence' => '1']); + $doc2 = new Document(['$id' => 'd2', '$collection' => 'testCol', '$sequence' => '2']); + $doc3 = new Document(['$id' => 'd3', '$collection' => 'testCol', '$sequence' => '3']); + + $callCount = 0; + $this->adapter->method('find')->willReturnCallback( + function () use (&$callCount, $doc1, $doc2, $doc3) { + $callCount++; + if ($callCount === 1) { + return [$doc1, $doc2]; + } + if ($callCount === 2) { + return [$doc3]; + } + + return []; + } + ); + + $results = []; + foreach ($this->database->cursor('testCol', [], 2) as $doc) { + $results[] = $doc; + } + $this->assertCount(3, $results); + } + + public function testCursorStopsOnEmptyBatch(): void + { + $this->setupCollectionLookup('testCol'); + $this->adapter->method('find')->willReturn([]); + + $results = []; + foreach ($this->database->cursor('testCol', [], 10) as $doc) { + $results[] = $doc; + } + $this->assertCount(0, $results); + } + + public function testCursorStopsWhenBatchSmallerThanBatchSize(): void + { + $this->setupCollectionLookup('testCol'); + $doc1 = new Document(['$id' => 'd1', '$collection' => 'testCol', '$sequence' => '1']); + + $this->adapter->method('find')->willReturn([$doc1]); + + $results = []; + foreach ($this->database->cursor('testCol', [], 5) as $doc) { + $results[] = $doc; + } + $this->assertCount(1, $results); + } + + public function testAggregateReturnsTheAdapterRowsAsArrays(): void + { + $db = $this->buildDbWithCapabilities($this->createMock(Adapter::class), [ + Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, + Capability::DefinedAttributes, Capability::Aggregations, + ], function (Adapter&MockObject $adapter): void { + $aggResult = new Document(['cnt' => 10]); + $adapter->expects($this->once()) + ->method('find') + ->willReturn([$aggResult]); + }); + + $results = $db->skipValidation(fn () => $db->aggregate('testCol', [Query::count('*', 'cnt')])); + $this->assertSame([['cnt' => 10]], $results); + } + + public function testFindWithValidationDisabledAllowsUnknownAttributes(): void + { + $this->setupCollectionLookup('testCol'); + $this->adapter->method('find')->willReturn([]); + + $results = $this->database->skipValidation( + fn () => $this->database->find('testCol', [Query::equal('nonexistent', ['val'])]) + ); + $this->assertCount(0, $results); + } + + public function testFindAuthorizationCheckWhenNoPermission(): void + { + $collection = new Document([ + '$id' => 'restricted', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::user('admin'))], + 'name' => 'restricted', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => false, + ]); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'restricted') { + return $collection; + } + + return new Document(); + } + ); + + $db = new Database($this->adapter, new Cache(new None())); + + $this->expectException(AuthorizationException::class); + $db->find('restricted'); + } + + public function testFindAllowsDocumentSecurityWhenCollectionPermissionFails(): void + { + $collection = new Document([ + '$id' => 'docSec', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::user('admin'))], + 'name' => 'docSec', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'docSec') { + return $collection; + } + + return new Document(); + } + ); + $this->adapter->method('find')->willReturn([]); + + $db = new Database($this->adapter, new Cache(new None())); + $results = $db->find('docSec'); + $this->assertCount(0, $results); + } + + public function testFindSetsCollectionAttributeOnResults(): void + { + $this->setupCollectionLookup('testCol'); + $doc = new Document(['$id' => 'doc1']); + $this->adapter->method('find')->willReturn([$doc]); + + $results = $this->database->find('testCol'); + $this->assertSame('testCol', $results[0]->getAttribute('$collection')); + } + + public function testFindCursorBeforePassesDirection(): void + { + $adapter = $this->mockAdapter(); + $this->setupCollectionLookup('testCol'); + $cursorDoc = new Document([ + '$id' => 'c1', + '$collection' => 'testCol', + '$sequence' => '100', + ]); + + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + CursorDirection::Before, + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [Query::cursorBefore($cursorDoc)]); + } + + public function testFindMultipleOrderAttributes(): void + { + $adapter = $this->mockAdapter(); + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false]), + new Document(['$id' => 'age', 'key' => 'age', 'type' => 'integer', 'size' => 0, 'required' => false, 'array' => false]), + ]; + $this->setupCollectionLookup('testCol', $attributes); + + $adapter->expects($this->once()) + ->method('find') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->callback(function (array $orderAttributes) { + return $orderAttributes[0] === 'name' + && $orderAttributes[1] === 'age' + && in_array('$sequence', $orderAttributes, true); + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn([]); + + $this->database->find('testCol', [ + Query::orderAsc('name'), + Query::orderDesc('age'), + ]); + } + + public function testCountThrowsNotFoundExceptionForMissingCollection(): void + { + $this->adapter->method('getDocument')->willReturn(new Document()); + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + $this->database->count('nonexistent'); + } + + public function testSumThrowsNotFoundExceptionForMissingCollection(): void + { + $this->adapter->method('getDocument')->willReturn(new Document()); + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + $this->database->sum('nonexistent', 'amount'); + } + + public function testSumValidatesQueries(): void + { + $this->setupCollectionLookup('testCol'); + $this->database->setValidation(true); + + $this->expectException(QueryException::class); + $this->database->sum('testCol', 'amount', [Query::equal('unknown_field', ['val'])]); + } + + /** + * @template TAdapter of Adapter&Stub + * + * @param TAdapter $adapter + * @param list $capabilities + * @param (callable(TAdapter): void)|null $adapterSetup + * @param array $extraCollections + * @param (callable(Document, string): ?Document)|null $getDocumentOverride + */ + private function buildDbWithCapabilities( + Adapter&Stub $adapter, + array $capabilities, + ?callable $adapterSetup = null, + array $extraCollections = [], + ?callable $getDocumentOverride = null, + ): Database { + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) use ($capabilities) { + return in_array($cap, $capabilities); + }); + + $collectionMap = [ + 'testCol' => new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], + 'name' => 'testCol', + 'attributes' => [ + new Document(['$id' => 'status', 'key' => 'status', 'type' => 'string', 'size' => 64, 'required' => false, 'array' => false]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]), + ...$extraCollections, + ]; + if ($adapterSetup !== null) { + $adapterSetup($adapter); + } else { + $adapter->method('find')->willReturn([]); + } + + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collectionMap, $getDocumentOverride) { + if ($getDocumentOverride !== null) { + $override = $getDocumentOverride($col, $docId); + if ($override instanceof Document) { + return $override; + } + } + if ($col->getId() === Database::METADATA && isset($collectionMap[$docId])) { + return $collectionMap[$docId]; + } + + return new Document(); + } + ); + + $cache = new Cache(new None()); + $db = new Database($adapter, $cache); + $db->getAuthorization()->addRole(Role::any()->toString()); + + return $db; + } + + public function testGetDocumentWithJoinOnUnsupportedAdapterThrows(): void + { + $this->setupCollectionLookup('testCol'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported by this adapter'); + $this->database->skipValidation(fn () => $this->database->getDocument('testCol', 'doc1', [ + Query::join('other', 'j0', [Query::on('fk', '$id')]), + ])); + } +} diff --git a/tests/unit/Documents/FractionalBoundTest.php b/tests/unit/Documents/FractionalBoundTest.php new file mode 100644 index 0000000000..7b454a485d --- /dev/null +++ b/tests/unit/Documents/FractionalBoundTest.php @@ -0,0 +1,200 @@ + + */ + public static function lanes(): iterable + { + yield 'defined attributes' => [true]; + yield 'schemaless' => [false]; + } + + #[DataProvider('lanes')] + public function testIncreaseWithAFractionalMaximumOnAnIntegerIsRefused(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + try { + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 102.4); + $this->fail('A fractional maximum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Max must be an integer.', $error->getMessage()); + } + + $this->assertSame(100, $this->stored($database, 'count')); + } + + #[DataProvider('lanes')] + public function testDecreaseWithAFractionalMinimumOnAnIntegerIsRefused(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + try { + $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 0.5); + $this->fail('A fractional minimum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Min must be an integer.', $error->getMessage()); + } + + $this->assertSame(100, $this->stored($database, 'count')); + } + + #[DataProvider('lanes')] + public function testANonNumericBoundOnAnIntegerIsRefused(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + $this->expectException(TypeException::class); + $this->expectExceptionMessage('Max must be an integer.'); + + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '102.5'); + } + + #[DataProvider('lanes')] + public function testWholeFloatBoundsOnAnIntegerAreComparedExactly(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + $this->assertSame(101, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 101.0)->getAttribute('count')); + + try { + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 101.0); + $this->fail('An increase past a whole float maximum was accepted'); + } catch (LimitException $error) { + $this->assertSame('Attribute value exceeds maximum limit: 101', $error->getMessage()); + } + + $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 9.0e18)->getAttribute('count')); + $this->assertSame(101, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, -9.0e18)->getAttribute('count')); + $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 1.0e19)->getAttribute('count')); + $this->assertSame(101, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, -1.0e19)->getAttribute('count')); + $this->assertSame(101, $this->stored($database, 'count')); + } + + #[DataProvider('lanes')] + public function testWholeNumberStringBoundsOnAnIntegerAreAcceptedAsOperatorLimitsAre(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + $this->assertSame(101, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '101.0')->getAttribute('count')); + + try { + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '101.00'); + $this->fail('An increase past a whole number string maximum was accepted'); + } catch (LimitException $error) { + $this->assertSame('Attribute value exceeds maximum limit: 101', $error->getMessage()); + } + + $this->assertSame(100, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '-5.0')->getAttribute('count')); + $this->assertSame(101, $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document([ + 'count' => Operator::increment(1, '101.0'), + ]))->getAttribute('count')); + $this->assertSame(101, $this->stored($database, 'count')); + } + + #[DataProvider('lanes')] + public function testAFractionalChangeValueOnAnIntegerIsRefused(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + foreach ([ + 'increase' => static fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5), + 'decrease' => static fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 0.5), + 'increase by a numeric string' => static fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '1.5'), + ] as $case => $change) { + try { + $change(); + $this->fail("A fractional change value on an integer attribute was accepted ({$case})"); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage(), $case); + } + } + + $this->assertSame(100, $this->stored($database, 'count')); + $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2)->getAttribute('count')); + $this->assertSame(3.0, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 1.5)->getAttribute('ratio')); + } + + #[DataProvider('lanes')] + public function testFractionalBoundsOnADoubleAreAccepted(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + $this->assertSame(2.5, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 1, 2.5)->getAttribute('ratio')); + $this->assertSame(2.0, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 0.5, 2.0)->getAttribute('ratio')); + $this->assertSame(2.0, $this->stored($database, 'ratio')); + } + + private function database(bool $definedAttributes): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $adapter = $definedAttributes ? new Memory() : new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes, + )); + } + }; + + $database = (new Database($adapter, new Cache(new None()))) + ->setAuthorization($authorization) + ->setDatabase('fractional_bound') + ->setNamespace('fractional_bound'); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer(key: 'count'), + Attribute::double(key: 'ratio'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => self::DOCUMENT, + 'count' => 100, + 'ratio' => 1.5, + ])); + + return $database; + } + + private function stored(Database $database, string $attribute): mixed + { + return $database->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute($attribute); + } +} diff --git a/tests/unit/Documents/FractionalOperatorLimitTest.php b/tests/unit/Documents/FractionalOperatorLimitTest.php new file mode 100644 index 0000000000..621e83a4ce --- /dev/null +++ b/tests/unit/Documents/FractionalOperatorLimitTest.php @@ -0,0 +1,87 @@ +database(); + + try { + $database->updateDocument(self::COLLECTION, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame(self::REFUSAL, $exception->getMessage()); + } + + $this->assertSame(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); + } + + public function testUpdateDocumentsRefusesAFractionalLimitBeforeTheWrite(): void + { + $database = $this->database(); + + try { + $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(1, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame(self::REFUSAL, $exception->getMessage()); + } + + $this->assertSame(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); + } + + public function testAWholeFloatLimitHoldsABigIntegerAtTheSignedEdge(): void + { + $database = $this->database(); + $database->updateDocument(self::COLLECTION, 'counter', new Document(['big' => PHP_INT_MAX - 5])); + + $updated = $database->updateDocument(self::COLLECTION, 'counter', new Document(['big' => Operator::increment(10, 9.0e18)])); + + $this->assertSame(PHP_INT_MAX - 5, $updated->getAttribute('big')); + $this->assertSame(PHP_INT_MAX - 5, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('big')); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('fractional_limits') + ->setNamespace('fractional_limits_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer(key: 'count'), + Attribute::bigInteger(key: 'big'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'counter', 'count' => 100, 'big' => 0])); + + return $database; + } +} diff --git a/tests/unit/Documents/GetDocumentFallbackTest.php b/tests/unit/Documents/GetDocumentFallbackTest.php new file mode 100644 index 0000000000..c2f7679ce8 --- /dev/null +++ b/tests/unit/Documents/GetDocumentFallbackTest.php @@ -0,0 +1,173 @@ +database(new Memory(), new Cache(new MemoryCache())); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $database->getDocument('', 'session'); + } + + public function testAJoinOnAnAdapterWithoutJoinsIsRefused(): void + { + $database = $this->database(new Memory(), new Cache(new MemoryCache())); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query method: join'); + + $database->getDocument(self::COLLECTION, 'session', [Query::join(self::COLLECTION, 'twin', [Query::on('owner', 'owner')])]); + } + + public function testACacheThatCannotBeReadOrWrittenFallsBackToTheDatabase(): void + { + [$cache, $fail] = $this->failingCache(); + $database = $this->database(new Memory(), new Cache($cache)); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'session', 'owner' => 'ada'])); + $fail(); + + $document = null; + $missing = null; + $log = StderrCapture::during(function () use ($database, &$document, &$missing): void { + $document = $database->getDocument(self::COLLECTION, 'session'); + $missing = $database->getDocument(self::COLLECTION, 'absent'); + }); + + $this->assertInstanceOf(Document::class, $document); + $this->assertSame('ada', $document->getAttribute('owner')); + $this->assertInstanceOf(Document::class, $missing); + $this->assertTrue($missing->isEmpty()); + $this->assertStringContainsString('Warning: Failed to get document from cache: the cache refused load', $log); + $this->assertStringContainsString('Warning: Failed to get cache generation: the cache refused getGeneration', $log); + $this->assertStringContainsString('Failed to save document to cache: the cache refused saveWithLease', $log); + $this->assertStringContainsString('Failed to save empty document to cache: the cache refused saveWithLease', $log); + } + + public function testACachedDocumentPastItsTimeToLiveReadsAsEmpty(): void + { + $database = $this->database($this->ttlMemory(), new Cache(new MemoryCache())); + $database->createIndex(self::COLLECTION, Index::ttl(key: 'expiry', attribute: 'startedAt', ttl: 1)); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'session', 'owner' => 'ada', 'startedAt' => DateTime::now()])); + + $this->assertSame('ada', $database->getDocument(self::COLLECTION, 'session')->getAttribute('owner')); + \sleep(2); + + $this->assertTrue($database->getDocument(self::COLLECTION, 'session')->isEmpty(), 'the cached copy is past its time to live'); + } + + private function ttlMemory(): Memory + { + return new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::IndexTtl]; + } + }; + } + + /** + * @return array{MemoryCache, Closure(): void} + */ + private function failingCache(): array + { + $cache = new class (self::COLLECTION) extends MemoryCache implements Leasable { + public bool $failing = false; + + public function __construct(private readonly string $collection) + { + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $this->refuse('load', $key); + + return parent::load($key, $ttl, $hash); + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + return parent::save($key, $data, $hash); + } + + #[\Override] + public function getGeneration(string $key): string + { + $this->refuse('getGeneration', $key); + + return '0'; + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + $this->refuse('saveWithLease', $key); + + return parent::save($key, $data, $hash); + } + + private function refuse(string $operation, string $key): void + { + if ($this->failing && ! \str_contains($key, '#') && \str_contains($key, ':'.$this->collection.':')) { + throw new RuntimeException("the cache refused {$operation}"); + } + } + }; + + return [$cache, static function () use ($cache): void { + $cache->failing = true; + }]; + } + + private function database(Memory $adapter, Cache $cache): Database + { + $database = new Database($adapter, $cache); + $database->setDatabase('fallback')->setNamespace('fallback_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'owner', size: 32), Attribute::datetime(key: 'startedAt')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Documents/GetDocumentQueryValidationTest.php b/tests/unit/Documents/GetDocumentQueryValidationTest.php new file mode 100644 index 0000000000..25ce4b6892 --- /dev/null +++ b/tests/unit/Documents/GetDocumentQueryValidationTest.php @@ -0,0 +1,78 @@ +database(); + + $document = $database->getDocument(self::COLLECTION, 'note'); + $cached = $database->getDocument(self::COLLECTION, 'note'); + + $this->assertSame('ada', $document->getAttribute('author')); + $this->assertSame('hello', $document->getAttribute('body')); + $this->assertSame($document->getArrayCopy(), $cached->getArrayCopy()); + } + + public function testASelectOfAnUnknownAttributeIsRejected(): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute not found in schema: missing'); + + $database->getDocument(self::COLLECTION, 'note', [Query::select(['missing'])]); + } + + public function testAFilterIsRejected(): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query method: equal'); + + $database->getDocument(self::COLLECTION, 'note', [Query::equal('author', ['ada'])]); + } + + public function testASelectOfAKnownAttributeIsApplied(): void + { + $database = $this->database(); + + $document = $database->getDocument(self::COLLECTION, 'note', [Query::select(['author'])]); + + $this->assertSame('ada', $document->getAttribute('author')); + $this->assertFalse($document->offsetExists('body')); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new MemoryCache())); + $database->setDatabase('validation')->setNamespace('validation_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'author', size: 32), Attribute::string(key: 'body', size: 256)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'note', 'author' => 'ada', 'body' => 'hello'])); + + return $database; + } +} diff --git a/tests/unit/Documents/IncreaseDecreaseTest.php b/tests/unit/Documents/IncreaseDecreaseTest.php new file mode 100644 index 0000000000..80b3eb7465 --- /dev/null +++ b/tests/unit/Documents/IncreaseDecreaseTest.php @@ -0,0 +1,574 @@ +adapter = self::createStub(CastingAdapterStub::class); + $this->adapter->method('hasFeature')->willReturnCallback( + static fn (string $feature): bool => $feature === Feature\Casting::class, + ); + $this->adapter->method('castBefore')->willReturnArgument(1); + $this->adapter->method('castAfter')->willReturnArgument(1); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + if ($cap === Capability::DefinedAttributes) { + return $this->definedAttributes; + } + + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::UnsignedBigInt, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('increaseDocumentAttribute')->willReturn(true); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + /** + * @param array $attributes + */ + private function setupCollectionWithDocument( + string $collectionId, + Document $existingDoc, + array $attributes = [], + ): void { + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $collection = new Document([ + '$id' => $collectionId, + '$collection' => Database::METADATA, + '$permissions' => $permissions, + 'name' => $collectionId, + 'attributes' => $attributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collectionId, $collection, $existingDoc) { + if ($col->getId() === Database::METADATA && $docId === $collectionId) { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + if ($col->getId() === $collectionId && $docId === $existingDoc->getId()) { + return $existingDoc; + } + + return new Document(); + } + ); + } + + private function intAttribute(string $key): Document + { + return new Document([ + '$id' => $key, + 'key' => $key, + 'type' => ColumnType::Integer->value, + 'size' => 0, + 'required' => false, + 'array' => false, + 'signed' => true, + 'filters' => [], + ]); + } + + private function floatAttribute(string $key): Document + { + return new Document([ + '$id' => $key, + 'key' => $key, + 'type' => ColumnType::Double->value, + 'size' => 0, + 'required' => false, + 'array' => false, + 'signed' => true, + 'filters' => [], + ]); + } + + private function numericAttribute(string $key, ColumnType|string $type, bool $signed = true): Document + { + return new Document([ + '$id' => $key, + 'key' => $key, + 'type' => $type instanceof ColumnType ? $type->value : $type, + 'size' => 0, + 'required' => false, + 'array' => false, + 'signed' => $signed, + 'filters' => [], + ]); + } + + public function testIncreaseDocumentAttribute(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 5, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $result = $this->database->increaseDocumentAttribute('testCol', 'doc1', 'counter'); + $this->assertSame(6, $result->getAttribute('counter')); + } + + public function testSchemalessIncreaseDefaultsOnlyMissingAttributeToZero(): void + { + $this->definedAttributes = false; + $missing = new Document([ + '$id' => 'missing', + '$collection' => 'testCol', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ]); + $this->setupCollectionWithDocument('testCol', $missing); + + $result = $this->database->increaseDocumentAttribute('testCol', 'missing', 'counter'); + + $this->assertSame(1, $result->getAttribute('counter')); + } + + public function testSchemalessIncreaseRejectsExplicitNull(): void + { + $this->definedAttributes = false; + $nullable = new Document([ + '$id' => 'nullable', + '$collection' => 'testCol', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => null, + ]); + $this->setupCollectionWithDocument('testCol', $nullable); + + $this->expectException(TypeException::class); + $this->database->increaseDocumentAttribute('testCol', 'nullable', 'counter'); + } + + public function testIncreaseDocumentAttributeByCustomValue(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'score' => 10.0, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->floatAttribute('score')]); + + $result = $this->database->increaseDocumentAttribute('testCol', 'doc1', 'score', 2.5); + $this->assertSame(12.5, $result->getAttribute('score')); + } + + public function testIncreaseAcceptsFloatBigIntegerAndLegacyMetadata(): void + { + $types = [ + 'float' => ColumnType::Float, + 'biginteger' => ColumnType::BigInteger, + 'legacy' => 'bigint', + ]; + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'float' => 5, + 'biginteger' => 5, + 'legacy' => 5, + ]); + $attributes = []; + foreach ($types as $key => $type) { + $attributes[] = $this->numericAttribute($key, $type); + } + $this->setupCollectionWithDocument('testCol', $doc, $attributes); + + foreach (\array_keys($types) as $key) { + $result = $this->database->increaseDocumentAttribute('testCol', 'doc1', $key); + + $this->assertSame(6, $result->getAttribute($key), $key); + } + } + + /** + * @return array + */ + public static function serialTypes(): array + { + return [ + 'serial' => [ColumnType::Serial], + 'bigserial' => [ColumnType::BigSerial], + 'smallserial' => [ColumnType::SmallSerial], + ]; + } + + #[DataProvider('serialTypes')] + public function testIncreaseRejectsSerialColumns(ColumnType $type): void + { + $this->setupCollectionWithDocument('testCol', $this->sequenceDocument(), [ + $this->numericAttribute('sequence', $type), + ]); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: '.$type->value); + $this->database->increaseDocumentAttribute('testCol', 'doc1', 'sequence'); + } + + #[DataProvider('serialTypes')] + public function testDecreaseRejectsSerialColumns(ColumnType $type): void + { + $this->setupCollectionWithDocument('testCol', $this->sequenceDocument(), [ + $this->numericAttribute('sequence', $type), + ]); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: '.$type->value); + $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'sequence'); + } + + private function sequenceDocument(): Document + { + return new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'sequence' => 5, + ]); + } + + public function testIncreaseRejectsBigIntegerOverflow(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => PHP_INT_MAX, + ]); + $this->setupCollectionWithDocument('testCol', $doc, [ + $this->numericAttribute('value', ColumnType::BigInteger), + ]); + + $this->expectException(LimitException::class); + $this->database->increaseDocumentAttribute('testCol', 'doc1', 'value'); + } + + public function testDecreaseRejectsUnsignedBigIntegerUnderflow(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 0, + ]); + $this->setupCollectionWithDocument('testCol', $doc, [ + $this->numericAttribute('value', ColumnType::BigInteger, false), + ]); + + $this->expectException(LimitException::class); + $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'value'); + } + + public function testUnsignedBigIntegerCrossesPhpIntegerBoundaryExactly(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => PHP_INT_MAX, + ]); + $this->setupCollectionWithDocument('testCol', $doc, [ + $this->numericAttribute('value', ColumnType::BigInteger, false), + ]); + + $increased = $this->database->increaseDocumentAttribute('testCol', 'doc1', 'value'); + $this->assertSame('9223372036854775808', $increased->getAttribute('value')); + + $decreased = $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'value'); + $this->assertSame(PHP_INT_MAX, $decreased->getAttribute('value')); + } + + public function testUnsignedBigIntegerReachesExactMaximum(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => '18446744073709551614', + ]); + $this->setupCollectionWithDocument('testCol', $doc, [ + $this->numericAttribute('value', ColumnType::BigInteger, false), + ]); + + $result = $this->database->increaseDocumentAttribute( + 'testCol', + 'doc1', + 'value', + 1, + '18446744073709551615', + ); + + $this->assertSame('18446744073709551615', $result->getAttribute('value')); + } + + public function testUnsignedBigIntegerRejectsOverflowAboveExactMaximum(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => '18446744073709551615', + ]); + $this->setupCollectionWithDocument('testCol', $doc, [ + $this->numericAttribute('value', ColumnType::BigInteger, false), + ]); + + $this->expectException(LimitException::class); + $this->database->increaseDocumentAttribute('testCol', 'doc1', 'value'); + } + + public function testIncreaseDocumentAttributeWithMax(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 8, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $result = $this->database->increaseDocumentAttribute('testCol', 'doc1', 'counter', 1, 10); + $this->assertSame(9, $result->getAttribute('counter')); + } + + public function testIncreaseDocumentAttributeExceedsMax(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 10, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $this->expectException(LimitException::class); + $this->database->increaseDocumentAttribute('testCol', 'doc1', 'counter', 1, 10); + } + + public function testIncreaseDocumentAttributeWithZeroValue(): void + { + $this->expectException(TypeException::class); + $this->expectExceptionMessage('Value must be numeric and greater than 0'); + + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 5, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $this->database->increaseDocumentAttribute('testCol', 'doc1', 'counter', 0); + } + + public function testIncreaseDocumentAttributeWithNegativeValue(): void + { + $this->expectException(TypeException::class); + $this->expectExceptionMessage('Value must be numeric and greater than 0'); + + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 5, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $this->database->increaseDocumentAttribute('testCol', 'doc1', 'counter', -1); + } + + public function testIncreaseDocumentAttributeNotFound(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 5, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $this->expectException(NotFoundException::class); + $this->database->increaseDocumentAttribute('testCol', 'nonexistent', 'counter'); + } + + public function testDecreaseDocumentAttribute(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 10, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $result = $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'counter'); + $this->assertSame(9, $result->getAttribute('counter')); + } + + public function testDecreaseDocumentAttributeWithMin(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 5, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $result = $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'counter', 1, 0); + $this->assertSame(4, $result->getAttribute('counter')); + } + + public function testDecreaseDocumentAttributeExceedsMin(): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 3, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $this->expectException(LimitException::class); + $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'counter', 5, 0); + } + + public function testDecreaseDocumentAttributeWithZeroValue(): void + { + $this->expectException(TypeException::class); + $this->expectExceptionMessage('Value must be numeric and greater than 0'); + + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 5, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->intAttribute('counter')]); + + $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'counter', 0); + } + + /** + * @return array + */ + public static function directions(): array + { + return [ + 'increase' => [true], + 'decrease' => [false], + ]; + } + + #[DataProvider('directions')] + public function testANotANumberChangeOfAFloatIsRefused(bool $increase): void + { + $doc = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'score' => 1.5, + ]); + + $this->setupCollectionWithDocument('testCol', $doc, [$this->floatAttribute('score')]); + + $this->expectException(TypeException::class); + $this->expectExceptionMessage('Attribute value must be a finite numeric value.'); + + $increase + ? $this->database->increaseDocumentAttribute('testCol', 'doc1', 'score', \NAN) + : $this->database->decreaseDocumentAttribute('testCol', 'doc1', 'score', \NAN); + } +} diff --git a/tests/unit/Documents/IncreaseValueTest.php b/tests/unit/Documents/IncreaseValueTest.php new file mode 100644 index 0000000000..2d990ef4a2 --- /dev/null +++ b/tests/unit/Documents/IncreaseValueTest.php @@ -0,0 +1,81 @@ + + */ + public static function invalidChanges(): iterable + { + yield 'zero' => [0]; + yield 'negative integer' => [-1]; + yield 'zero float' => [0.0]; + yield 'negative float' => [-0.5]; + yield 'zero string' => ['0']; + yield 'negative integer string' => ['-4']; + yield 'negative decimal string' => ['-1.5']; + yield 'non-numeric string' => ['many']; + } + + #[DataProvider('invalidChanges')] + public function testIncreaseRefusesAChangeThatIsNotAPositiveNumber(int|float|string $change): void + { + $database = $this->database(); + + $this->assertRefused(fn () => $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', $change)); + $this->assertSame(1, $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('views')); + } + + #[DataProvider('invalidChanges')] + public function testDecreaseRefusesAChangeThatIsNotAPositiveNumber(int|float|string $change): void + { + $database = $this->database(); + + $this->assertRefused(fn () => $database->decreaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', $change)); + $this->assertSame(1, $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('views')); + } + + public function testTheRefusalIsADatabaseException(): void + { + $database = $this->database(); + + $this->expectException(DatabaseException::class); + + $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', 0); + } + + public function testAPositiveChangeIsApplied(): void + { + $database = $this->database(); + + $this->assertSame(6, $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', '5')->getAttribute('views')); + $this->assertSame(4, $database->decreaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', 2)->getAttribute('views')); + } + + private function assertRefused(callable $operation): void + { + try { + $operation(); + $this->fail('A change that is not a positive number was accepted'); + } catch (TypeException $error) { + $this->assertSame('Value must be numeric and greater than 0', $error->getMessage()); + } + } + + private function database(): Database + { + $database = HookFixture::memory(); + HookFixture::seed($database, ['first']); + + return $database; + } +} diff --git a/tests/unit/Documents/InvalidationRestoreFailureTest.php b/tests/unit/Documents/InvalidationRestoreFailureTest.php new file mode 100644 index 0000000000..065460662e --- /dev/null +++ b/tests/unit/Documents/InvalidationRestoreFailureTest.php @@ -0,0 +1,113 @@ +failingEpochCache(); + $queryCache = new Query(new Cache(new MemoryCache())); + $database = new Database(new Memory(), new Cache($cache)); + $database->setDatabase('restore')->setNamespace('restore_'.\uniqid()); + $database->setQueryCache($queryCache); + $database->addHook($this->failingInvalidator($queryCache)); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'entry', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'kept', 'entry' => 'kept'])); + $this->assertSame('kept', $database->getDocument(self::COLLECTION, 'kept')->getAttribute('entry')); + + $failure = new DomainException('the write failed'); + try { + $database->withTransaction(function () use ($database, $failure): never { + $database->updateDocuments(self::COLLECTION, new Document(['entry' => 'rolled back'])); + $this->failing = true; + + throw $failure; + }); + } catch (\Throwable $error) { + $this->assertSame($failure, $error, 'the write\'s own error reaches the caller, not the failed restore'); + } finally { + $this->failing = false; + } + + $this->assertSame('kept', $database->getDocument(self::COLLECTION, 'kept')->getAttribute('entry')); + + UncachedTwin::of($database)->updateDocument(self::COLLECTION, 'kept', new Document(['entry' => 'changed'])); + $this->assertSame('changed', $database->getDocument(self::COLLECTION, 'kept')->getAttribute('entry'), 'a restore that failed leaves the document cache fail-closed'); + } + + private function failingEpochCache(): MemoryCache + { + $failing = fn (): bool => $this->failing; + + return new class ($failing) extends MemoryCache { + public function __construct(private readonly \Closure $failing) + { + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if (($this->failing)() && \str_ends_with($key, '#epoch') && \is_string($data) && ! \str_starts_with($data, 'blocked:')) { + throw new RuntimeException('the cache refused the epoch'); + } + + return parent::save($key, $data, $hash); + } + }; + } + + private function failingInvalidator(Query $queryCache): Invalidator + { + $failing = fn (): bool => $this->failing; + + return new class ($queryCache, $failing) extends Invalidator { + public function __construct(Query $queryCache, private readonly \Closure $failing) + { + parent::__construct($queryCache); + } + + /** + * @param array $tokens + */ + #[\Override] + public function activate(array $tokens, int $writerTimeout): void + { + if (($this->failing)()) { + throw new RuntimeException('the query cache refused the activation'); + } + + parent::activate($tokens, $writerTimeout); + } + }; + } +} diff --git a/tests/unit/Documents/LiteralUniqueIdTest.php b/tests/unit/Documents/LiteralUniqueIdTest.php new file mode 100644 index 0000000000..b4619fb1fe --- /dev/null +++ b/tests/unit/Documents/LiteralUniqueIdTest.php @@ -0,0 +1,223 @@ + + */ + public static function adapters(): iterable + { + yield 'SQLite' => [new SQLite(new PDO('sqlite::memory:'))]; + yield 'Memory' => [new Memory()]; + } + + /** + * @return iterable + */ + public static function upsertAdapters(): iterable + { + foreach (self::adapters() as $name => [$adapter]) { + if ($adapter->hasFeature(Upserts::class)) { + yield $name => [$adapter]; + } + } + } + + #[DataProvider('adapters')] + public function testCreateDocumentStoresTheLiteralId(Adapter $adapter): void + { + $database = $this->database($adapter, self::WIDGETS); + + $created = $database->createDocument(self::WIDGETS, new Document([ + Document::ID => self::LITERAL_ID, + 'name' => 'created', + ])); + + $this->assertSame(self::LITERAL_ID, $created->getId()); + $this->assertStoredUnderLiteralId($database, self::WIDGETS, 'created'); + } + + #[DataProvider('adapters')] + public function testCreateDocumentsStoresTheLiteralId(Adapter $adapter): void + { + $database = $this->database($adapter, self::WIDGETS); + + $created = []; + $count = $database->createDocuments( + self::WIDGETS, + [new Document([Document::ID => self::LITERAL_ID, 'name' => 'batched'])], + onNext: function (Document $document) use (&$created): void { + $created[] = $document->getId(); + }, + ); + + $this->assertSame(1, $count); + $this->assertSame([self::LITERAL_ID], $created); + $this->assertStoredUnderLiteralId($database, self::WIDGETS, 'batched'); + } + + #[DataProvider('upsertAdapters')] + public function testUpsertDocumentsStoresAndUpdatesTheLiteralId(Adapter $adapter): void + { + $database = $this->database($adapter, self::WIDGETS); + + foreach (['inserted', 'updated'] as $name) { + $upserted = []; + $database->upsertDocuments( + self::WIDGETS, + [new Document([Document::ID => self::LITERAL_ID, 'name' => $name])], + onNext: function (Document $document) use (&$upserted): void { + $upserted[] = $document->getId(); + }, + ); + + $this->assertSame([self::LITERAL_ID], $upserted); + $this->assertStoredUnderLiteralId($database, self::WIDGETS, $name); + } + } + + #[DataProvider('adapters')] + public function testANestedRelatedDocumentStoresTheLiteralId(Adapter $adapter): void + { + $database = $this->database($adapter, self::ALBUMS, self::ARTISTS); + $database->createRelationship(self::ALBUMS, Relationship::manyToOne( + relatedCollection: self::ARTISTS, + key: self::ARTIST, + )); + + $database->createDocument(self::ALBUMS, new Document([ + Document::ID => 'album', + 'name' => 'album', + self::ARTIST => [Document::ID => self::LITERAL_ID, 'name' => 'nested'], + ])); + + $artist = $database->getDocument(self::ALBUMS, 'album')->getAttribute(self::ARTIST); + $this->assertInstanceOf(Document::class, $artist); + $this->assertSame(self::LITERAL_ID, $artist->getId(), 'the parent must link to the literal id'); + $this->assertStoredUnderLiteralId($database, self::ARTISTS, 'nested'); + } + + #[DataProvider('adapters')] + public function testCreateDocumentStillGeneratesAnIdForAnEmptyId(Adapter $adapter): void + { + $database = $this->database($adapter, self::WIDGETS); + + $created = $database->createDocument(self::WIDGETS, new Document(['name' => 'generated'])); + + $this->assertGeneratedIds($database, [$created->getId()], 1); + } + + #[DataProvider('adapters')] + public function testCreateDocumentsStillGeneratesAnIdForAnEmptyId(Adapter $adapter): void + { + $database = $this->database($adapter, self::WIDGETS); + + $created = []; + $database->createDocuments( + self::WIDGETS, + [new Document(['name' => 'first']), new Document(['name' => 'second'])], + onNext: function (Document $document) use (&$created): void { + $created[] = $document->getId(); + }, + ); + + $this->assertGeneratedIds($database, $created, 2); + } + + #[DataProvider('upsertAdapters')] + public function testUpsertDocumentsStillGeneratesAnIdForAnEmptyId(Adapter $adapter): void + { + $database = $this->database($adapter, self::WIDGETS); + + $upserted = []; + $database->upsertDocuments( + self::WIDGETS, + [new Document(['name' => 'first']), new Document(['name' => 'second'])], + onNext: function (Document $document) use (&$upserted): void { + $upserted[] = $document->getId(); + }, + ); + + $this->assertGeneratedIds($database, $upserted, 2); + } + + private function database(Adapter $adapter, string ...$collections): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('literal_unique_id') + ->setNamespace('literal_unique_id_'.\uniqid()) + ->addHook(new Relationships()) + ->addHook(new Permissions()); + $database->create(); + + foreach ($collections as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string('name', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + } + + return $database; + } + + private function assertStoredUnderLiteralId(Database $database, string $collection, string $name): void + { + $this->assertSame($name, $database->getDocument($collection, self::LITERAL_ID)->getAttribute('name')); + $this->assertSame( + [self::LITERAL_ID], + \array_map(static fn (Document $document): string => $document->getId(), $database->find($collection)), + 'nothing may be stored under a generated id', + ); + } + + /** + * @param list $ids + */ + private function assertGeneratedIds(Database $database, array $ids, int $count): void + { + $this->assertCount($count, $ids); + $this->assertCount($count, \array_unique($ids), 'each document without an id gets its own id'); + + foreach ($ids as $id) { + $this->assertNotSame('', $id); + $this->assertSame($id, $database->getDocument(self::WIDGETS, $id)->getId()); + } + } +} diff --git a/tests/unit/Documents/MetadataCacheTest.php b/tests/unit/Documents/MetadataCacheTest.php new file mode 100644 index 0000000000..cb8f82df6e --- /dev/null +++ b/tests/unit/Documents/MetadataCacheTest.php @@ -0,0 +1,119 @@ +adapter = new CountingMemory(); + $this->database = new Database($this->adapter, new Cache(new CacheMemory())); + $this->database + ->setDatabase('utopiaTests') + ->setNamespace('metadata_cache_'.\uniqid()); + $this->database->addHook(new Relationships()); + + $this->database->create(); + + $this->database->createCollection(Collection::create(id: 'authors')); + $this->database->createAttribute('authors', Attribute::string(key: 'name')); + + $this->database->createCollection(Collection::create(id: 'books')); + $this->database->createAttribute('books', Attribute::string(key: 'title')); + $this->database->createRelationship('books', Relationship::oneToOne( + relatedCollection: 'authors', + twoWay: false, + key: 'author', + )); + + for ($i = 0; $i < 10; $i++) { + $this->database->createDocument('books', new Document([ + '$id' => 'book'.$i, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'title'.$i, + 'author' => [ + '$id' => 'author'.$i, + '$permissions' => [Permission::read(Role::any())], + 'name' => 'author'.$i, + ], + ])); + } + } + + public function testPointReadsDoNotReplayTheMetadataRead(): void + { + $this->database->getDocument('books', 'book0'); + + $this->adapter->reset(); + + for ($i = 1; $i < 10; $i++) { + $this->assertSame('title'.$i, $this->database->getDocument('books', 'book'.$i)->getAttribute('title')); + } + + $this->assertSame(0, $this->adapter->metadataReads, 'getDocument re-read the collection definition from the adapter'); + $this->assertGreaterThanOrEqual(9, $this->adapter->documentReads, 'the reads under test did not reach the adapter at all'); + } + + public function testRepeatedRelationshipReadsDoNotReplayTheMetadataRead(): void + { + $this->database->getDocument('books', 'book0'); + + $this->adapter->reset(); + + for ($i = 0; $i < 10; $i++) { + $author = $this->database->getDocument('books', 'book'.$i)->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('author'.$i, $author->getAttribute('name')); + } + + $this->assertSame(0, $this->adapter->metadataReads, 'the relationship hook re-read collection definitions from the adapter'); + } + + public function testFindDoesNotReplayTheMetadataRead(): void + { + $this->database->find('books', [Query::limit(10)]); + + $this->adapter->reset(); + + $found = $this->database->find('books', [Query::limit(10), Query::notEqual('title', 'title0')]); + + $this->assertCount(9, $found); + $this->assertSame(0, $this->adapter->metadataReads, 'find re-read the collection definition from the adapter'); + $this->assertGreaterThanOrEqual(1, $this->adapter->finds, 'the find under test did not reach the adapter at all'); + } + + public function testRowWritesDoNotDiscardTheCachedCollectionDefinition(): void + { + $this->database->getDocument('books', 'book0'); + $this->database->updateDocument('books', 'book0', new Document(['title' => 'updated'])); + + $this->adapter->reset(); + + $document = $this->database->getDocument('books', 'book0'); + + $this->assertSame('updated', $document->getAttribute('title')); + $this->assertSame(0, $this->adapter->metadataReads, 'a row write invalidated the cached collection definition'); + } +} diff --git a/tests/unit/Documents/MetadataTenantInvalidationTest.php b/tests/unit/Documents/MetadataTenantInvalidationTest.php new file mode 100644 index 0000000000..ff3c4d2f86 --- /dev/null +++ b/tests/unit/Documents/MetadataTenantInvalidationTest.php @@ -0,0 +1,206 @@ +databases(); + + $this->assertNull( + $reader->findCollection('targets'), + 'The collection does not exist yet, so the read must miss', + ); + + $writer->createCollection(Collection::create(id: 'targets', attributes: [ + Attribute::string(key: 'name', size: 64), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $this->assertNotNull( + $reader->findCollection('targets'), + 'A miss cached before provisioning must not outlive the collection being created', + ); + } + + public function testACollectionIsReadableByItsOwnWriterAfterCreation(): void + { + [$writer] = $this->databases(); + + $writer->createCollection(Collection::create(id: 'targets', attributes: [ + Attribute::string(key: 'name', size: 64), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $this->assertCount( + 1, + $this->attributesOf($writer), + 'createCollection() reads the collection first, and that miss must not survive the create', + ); + + $writer->createAttribute('targets', Attribute::string(key: 'provider', size: 64)); + + $this->assertCount( + 2, + $this->attributesOf($writer), + 'A schema change must not be hidden by a stale collection definition', + ); + } + + /** + * The same guarantee without shared tables, which is how a project with its + * own database is configured: sharedTables false, tenant null, namespace + * per project. withDocumentTenant() never switches a tenant here, so the + * read key and the rotated key are the same one for a different reason - + * worth pinning, because this is the configuration the maintenance sweep + * runs under when it reads a collection a project has not provisioned yet. + */ + public function testACollectionIsReadableAfterCreationWithoutSharedTables(): void + { + $adapter = new DatabaseMemory(); + $cache = new Cache(new LeasedMemoryCacheAdapter()); + $namespace = '_'.\uniqid(); + + $databases = []; + foreach ([0, 1] as $ignored) { + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace) + ->setSharedTables(false) + ->setTenant(null); + $database->getAuthorization()->addRole(Role::any()->toString()); + $databases[] = $database; + } + [$writer, $reader] = $databases; + $writer->create(); + + $this->assertNull( + $reader->findCollection('targets'), + 'The collection does not exist yet, so the read must miss', + ); + + $writer->createCollection(Collection::create(id: 'targets', attributes: [ + Attribute::string(key: 'name', size: 64), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $this->assertNotNull( + $reader->findCollection('targets'), + 'A miss cached before provisioning must not outlive the collection being created', + ); + + $this->assertCount(1, $this->attributesOf($reader)); + } + + /** + * @return array + */ + private function attributesOf(Database $database): array + { + $attributes = $database->getCollection('targets')->getAttribute('attributes', []); + $this->assertIsArray($attributes); + + return $attributes; + } + + /** + * @return array{Database, Database} + */ + private function databases(): array + { + $adapter = new DatabaseMemory(); + $cache = new Cache(new LeasedMemoryCacheAdapter()); + $namespace = 'metadata_tenant_'.\uniqid(); + + $databases = []; + foreach ([0, 1] as $ignored) { + $database = new Database($adapter, $cache); + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace) + ->setSharedTables(true) + ->setTenantPerDocument(true) + ->setTenant(self::TENANT); + $database->getAuthorization()->addRole(Role::any()->toString()); + $databases[] = $database; + } + + $databases[0]->create(); + + return [$databases[0], $databases[1]]; + } +} + +final class LeasedMemoryCacheAdapter extends MemoryCache implements Leasable +{ + /** @var array */ + private array $generations = []; + + #[\Override] + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if ($this->getGeneration($key) !== $generation) { + return false; + } + + return $this->save($key, $data, $hash); + } + + #[Override] + public function purge(string $key, string $hash = ''): bool + { + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + + return parent::purge($key, $hash); + } + + #[Override] + public function flush(): bool + { + $this->generations = []; + + return parent::flush(); + } +} diff --git a/tests/unit/Documents/MissingCollectionAggregateTest.php b/tests/unit/Documents/MissingCollectionAggregateTest.php new file mode 100644 index 0000000000..c47fd03987 --- /dev/null +++ b/tests/unit/Documents/MissingCollectionAggregateTest.php @@ -0,0 +1,111 @@ + + */ + public static function aggregates(): array + { + return [ + 'count' => [static fn (Database $database): int => $database->count(self::MISSING)], + 'sum' => [static fn (Database $database): int|float => $database->sum(self::MISSING, 'value')], + ]; + } + + /** + * @return array + */ + public static function adapterAggregates(): array + { + return [ + 'count' => [static fn (Database $database): int => $database->count(self::MISSING), 'count'], + 'sum' => [static fn (Database $database): int|float => $database->sum(self::MISSING, 'value'), 'sum'], + ]; + } + + /** + * @param Closure(Database): (int|float) $aggregate + */ + #[DataProvider('aggregates')] + public function testMissingCollectionThrowsNotFoundWithAuthorizationEnabled(Closure $aggregate): void + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setDatabase('missing_collection') + ->setNamespace('missing_collection_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $aggregate($database); + } + + /** + * @param Closure(Database): (int|float) $aggregate + * @param non-empty-string $method + */ + #[DataProvider('adapterAggregates')] + public function testMissingCollectionNeverReachesTheAdapterWhenAuthorizationIsSkipped(Closure $aggregate, string $method): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 0, + varchar: 0, + integer: 0, + bigInteger: 0, + attributes: 0, + indexes: 0, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 0, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback( + static fn (Capability $capability): bool => $capability === Capability::DefinedAttributes + ); + $adapter->method('getDocument')->willReturn(new Document()); + $adapter->expects($this->never())->method($method); + + $database = new Database($adapter, new Cache(new None())); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $database->getAuthorization()->skip(static fn (): int|float => $aggregate($database)); + } +} diff --git a/tests/unit/Documents/NegativeCacheEpochTest.php b/tests/unit/Documents/NegativeCacheEpochTest.php new file mode 100644 index 0000000000..19273d951d --- /dev/null +++ b/tests/unit/Documents/NegativeCacheEpochTest.php @@ -0,0 +1,182 @@ +createDatabase($adapter); + + $adapter->interceptNextGetDocument('webhooks', 'hook', function () use ($database): void { + $database->createDocument('webhooks', $this->hook()); + }); + + $this->assertTrue($database->getDocument('webhooks', 'hook')->isEmpty(), 'The read observed the row before it was created'); + $this->assertSame( + 'created', + $database->getDocument('webhooks', 'hook')->getAttribute('name'), + 'A miss observed before a concurrent create must not be served after it', + ); + } + + public function testAMissObservedBeforeAConcurrentBatchCreateIsNotServedAfterIt(): void + { + $adapter = new InterceptingMemory(); + $database = $this->createDatabase($adapter); + + $adapter->interceptNextGetDocument('webhooks', 'hook', function () use ($database): void { + $database->createDocuments('webhooks', [$this->hook()]); + }); + + $this->assertTrue($database->getDocument('webhooks', 'hook')->isEmpty(), 'The read observed the row before it was created'); + $this->assertSame( + 'created', + $database->getDocument('webhooks', 'hook')->getAttribute('name'), + 'A miss observed before a concurrent batch create must not be served after it', + ); + } + + public function testAMissWithNoConcurrentWriteIsServedFromTheCache(): void + { + $adapter = new InterceptingMemory(); + $database = $this->createDatabase($adapter); + + $this->assertTrue($database->getDocument('webhooks', 'absent')->isEmpty()); + $adapter->reset(); + + $this->assertTrue($database->getDocument('webhooks', 'absent')->isEmpty()); + $this->assertSame(0, $adapter->documentReads, 'Without a concurrent write the miss must be served from the negative cache'); + } + + private function hook(): Document + { + return new Document([ + '$id' => 'hook', + 'name' => 'created', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + } + + private function createDatabase(InterceptingMemory $adapter): Database + { + $database = new Database($adapter, new Cache(new LeasedMemoryCache())); + $database + ->setDatabase('utopiaTests') + ->setNamespace('negative_cache_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create(id: 'webhooks', attributes: [ + Attribute::string(key: 'name'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + return $database; + } +} + +/** + * Runs a one-shot callback in the middle of a single getDocument(), after the + * adapter has decided what the row looks like, so a test can land a concurrent + * write between the observation and whatever the caller does with it. + */ +final class InterceptingMemory extends CountingMemory +{ + private ?Closure $callback = null; + + private string $collection = ''; + + private string $document = ''; + + public function interceptNextGetDocument(string $collection, string $id, Closure $callback): void + { + $this->collection = $collection; + $this->document = $id; + $this->callback = $callback; + } + + #[Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $document = parent::getDocument($collection, $id, $queries, $forUpdate); + + if ( + $this->callback !== null + && $collection->getId() === $this->collection + && $id === $this->document + ) { + $callback = $this->callback; + $this->callback = null; + $callback(); + } + + return $document; + } +} + +/** + * A memory cache with the generations of the Leasable contract, so a save that + * raced a purge is refused as it is on Redis. + */ +final class LeasedMemoryCache extends MemoryCache implements Leasable +{ + /** @var array */ + private array $generations = []; + + #[\Override] + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if ($this->getGeneration($key) !== $generation) { + return false; + } + + return $this->save($key, $data, $hash); + } + + #[Override] + public function purge(string $key, string $hash = ''): bool + { + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + + return parent::purge($key, $hash); + } + + #[Override] + public function flush(): bool + { + $this->generations = []; + + return parent::flush(); + } +} diff --git a/tests/unit/Documents/NullNumericIncrementTest.php b/tests/unit/Documents/NullNumericIncrementTest.php new file mode 100644 index 0000000000..bc528ffce1 --- /dev/null +++ b/tests/unit/Documents/NullNumericIncrementTest.php @@ -0,0 +1,268 @@ +database = new Database(new Memory(), new Cache(new None())); + $this->database + ->setDatabase('null_increment') + ->setNamespace('null_increment_'.\uniqid()); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer('integer', required: false), + Attribute::bigInteger('bigInteger', required: false), + Attribute::float('float', required: false), + Attribute::double('double', required: false), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + $this->database->createDocument(self::COLLECTION, new Document(['$id' => self::DOCUMENT])); + } + + /** + * @return array + */ + public static function increments(): array + { + return [ + 'integer' => ['integer', 3, 3], + 'bigInteger' => ['bigInteger', 4, 4], + 'float' => ['float', 1.5, 1.5], + 'double' => ['double', 2.25, 2.25], + ]; + } + + /** + * @return array + */ + public static function decrements(): array + { + return [ + 'integer' => ['integer', 2, -2], + 'bigInteger' => ['bigInteger', 5, -5], + 'float' => ['float', 0.5, -0.5], + 'double' => ['double', 1.25, -1.25], + ]; + } + + #[DataProvider('increments')] + public function testIncreaseTreatsAnUnsetOptionalNumberAsZero(string $attribute, int|float $value, int|float $expected): void + { + $this->assertNull($this->database->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute($attribute)); + + $increased = $this->database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value); + + $this->assertSame($expected, $increased->getAttribute($attribute)); + $this->assertSame($expected, $this->database->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute($attribute)); + } + + #[DataProvider('decrements')] + public function testDecreaseTreatsAnUnsetOptionalNumberAsZero(string $attribute, int|float $value, int|float $expected): void + { + $decreased = $this->database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value); + + $this->assertSame($expected, $decreased->getAttribute($attribute)); + $this->assertSame($expected, $this->database->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute($attribute)); + } + + public function testIncreaseOfAnUnsetNumberStillEnforcesTheMaximum(): void + { + $this->expectException(LimitException::class); + + $this->database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'integer', 5, max: 4); + } + + public function testDecreaseOfAnUnsetNumberStillEnforcesTheMinimum(): void + { + $this->expectException(LimitException::class); + + $this->database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'float', 2.5, min: -2); + } + + public function testIncreaseOfAnUnsetNumberWithinTheMaximumSucceeds(): void + { + $increased = $this->database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'integer', 4, max: 4); + + $this->assertSame(4, $increased->getAttribute('integer')); + } + + /** + * @return array + */ + public static function boundedIncrements(): array + { + return [ + 'integer' => ['integer', 3, 10, 3], + 'bigInteger' => ['bigInteger', 4, 10, 4], + 'float' => ['float', 1.5, 10.5, 1.5], + 'double' => ['double', 2.25, 10.25, 2.25], + ]; + } + + /** + * @return array + */ + public static function boundedDecrements(): array + { + return [ + 'integer' => ['integer', 2, -10, -2], + 'bigInteger' => ['bigInteger', 5, -10, -5], + 'float' => ['float', 0.5, -10.5, -0.5], + 'double' => ['double', 1.25, -10.25, -1.25], + ]; + } + + /** + * @return array + */ + public static function excludingBounds(): array + { + return [ + 'integer maximum below zero' => ['integer', 5, -1, null], + 'float maximum below zero' => ['float', 2.5, -1.5, null], + 'integer minimum above zero' => ['integer', -2, null, 1], + 'float minimum above zero' => ['float', -0.5, null, 0.5], + ]; + } + + #[DataProvider('increments')] + public function testIncreasingAnUnsetNumberStoresTheIncrement(string $attribute, int|float $value, int|float $expected): void + { + $database = $this->sqlite(); + + $increased = $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value); + + $this->assertSame($expected, $increased->getAttribute($attribute)); + $this->assertSame($expected, $this->storedValue($attribute)); + } + + #[DataProvider('decrements')] + public function testDecreasingAnUnsetNumberStoresTheDecrement(string $attribute, int|float $value, int|float $expected): void + { + $database = $this->sqlite(); + + $decreased = $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value); + + $this->assertSame($expected, $decreased->getAttribute($attribute)); + $this->assertSame($expected, $this->storedValue($attribute)); + } + + #[DataProvider('boundedIncrements')] + public function testIncreasingAnUnsetNumberWithinAMaximumStoresTheIncrement(string $attribute, int|float $value, int|float $max, int|float $expected): void + { + $this->sqlite()->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value, max: $max); + + $this->assertSame($expected, $this->storedValue($attribute)); + } + + #[DataProvider('boundedDecrements')] + public function testDecreasingAnUnsetNumberWithinAMinimumStoresTheDecrement(string $attribute, int|float $value, int|float $min, int|float $expected): void + { + $this->sqlite()->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value, min: $min); + + $this->assertSame($expected, $this->storedValue($attribute)); + } + + #[DataProvider('excludingBounds')] + public function testABoundThatExcludesZeroLeavesAnUnsetNumberUnset(string $attribute, int|float $value, int|float|null $max, int|float|null $min): void + { + $this->sqlite()->getAdapter()->increaseDocumentAttribute( + new Document(['$id' => self::COLLECTION]), + self::DOCUMENT, + $attribute, + $value, + DateTime::now(), + min: $min, + max: $max, + ); + + $this->assertNull($this->storedValue($attribute)); + } + + public function testIncreasingAStoredNumberWithinAMaximumStillAddsToIt(): void + { + $database = $this->sqlite(); + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'integer', 3); + + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'integer', 4, max: 7); + + $this->assertSame(7, $this->storedValue('integer')); + } + + private function sqlite(): Database + { + $this->pdo = new PDO('sqlite::memory:'); + $database = $this->build(new SQLite($this->pdo)); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer('integer', required: false), + Attribute::bigInteger('bigInteger', required: false), + Attribute::float('float', required: false), + Attribute::double('double', required: false), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + $database->createDocument(self::COLLECTION, new Document(['$id' => self::DOCUMENT])); + + return $database; + } + + private function storedValue(string $attribute): mixed + { + return $this->build(new SQLite($this->pdo)) + ->getDocument(self::COLLECTION, self::DOCUMENT) + ->getAttribute($attribute); + } + + private function build(SQLite $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('null_increment') + ->setNamespace('null_increment_sqlite'); + $database->getAuthorization()->addRole(Role::any()->toString()); + + return $database; + } +} diff --git a/tests/unit/Documents/NumericUpdateGuardsTest.php b/tests/unit/Documents/NumericUpdateGuardsTest.php new file mode 100644 index 0000000000..afa3e5cc38 --- /dev/null +++ b/tests/unit/Documents/NumericUpdateGuardsTest.php @@ -0,0 +1,268 @@ +unsigned : parent::supports($feature); + } + }; + $database = $this->database($adapter, [Attribute::bigInteger(key: 'total', signed: false)]); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', 'total' => 5])); + $adapter->unsigned = false; + + foreach ([ + fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'total'), + fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'total'), + ] as $change) { + $this->assertRefused(TypeException::class, 'Unsigned 64-bit arithmetic is not supported by this adapter.', $change); + } + + $adapter->unsigned = true; + $this->assertSame(5, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('total')); + } + + /** + * @return array, string}> + */ + public static function storedValuesOutsideTheArithmetic(): array + { + return [ + 'an integer holding a fraction' => ['count', '1.5', true, TypeException::class, 'Attribute value must be an integer.'], + 'an integer holding text' => ['count', 'abc', false, TypeException::class, 'Attribute value must be an integer.'], + 'a float holding text' => ['ratio', 'abc', true, TypeException::class, 'Attribute value must be numeric.'], + 'a float holding infinity' => ['ratio', \INF, true, TypeException::class, 'Attribute value must be a finite numeric value.'], + 'an unsigned float below zero' => ['share', -1.0, true, LimitException::class, 'Attribute value exceeds minimum limit: 0'], + ]; + } + + /** + * @param class-string<\Throwable> $exception + */ + #[DataProvider('storedValuesOutsideTheArithmetic')] + public function testAStoredValueTheArithmeticCannotUseIsRefused(string $attribute, mixed $stored, bool $increase, string $exception, string $message): void + { + $database = $this->database($this->castingItself()); + $database->skipValidation(fn (): Document => $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', $attribute => $stored]))); + + $this->assertRefused($exception, $message, fn (): Document => $increase + ? $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', $attribute) + : $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', $attribute)); + } + + public function testAFloatIncreasePastTheMaximumIsRefused(): void + { + $database = $this->database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', 'ratio' => Database::MAX_DOUBLE])); + + $this->assertRefused( + LimitException::class, + 'Attribute value exceeds maximum limit: '.Database::MAX_DOUBLE, + fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'ratio', Database::MAX_DOUBLE), + ); + $this->assertSame(Database::MAX_DOUBLE, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('ratio')); + } + + public function testAFloatDecreasePastTheMinimumIsRefused(): void + { + $database = $this->database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', 'share' => 0.5, 'ratio' => -Database::MAX_DOUBLE])); + + $this->assertRefused( + LimitException::class, + 'Attribute value exceeds minimum limit: 0', + fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'share', 1), + ); + $this->assertRefused( + LimitException::class, + 'Attribute value exceeds minimum limit: '.(-Database::MAX_DOUBLE), + fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'ratio', Database::MAX_DOUBLE), + ); + $this->assertSame(0.5, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('share')); + } + + public function testAnIntegerStringChangeIsCheckedAsAnInteger(): void + { + $database = $this->database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', 'count' => 10])); + + $this->assertSame(15, $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', '5')->getAttribute('count')); + $this->assertSame(12, $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', '3')->getAttribute('count')); + + foreach (['0', '-4'] as $change) { + $this->assertRefused(TypeException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); + $this->assertRefused(TypeException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); + } + $this->assertSame(12, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('count')); + } + + public function testAnUnknownAttributeOrDocumentIsNotFound(): void + { + $database = $this->database(new Memory()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', 'count' => 10])); + + $this->assertRefused(NotFoundException::class, 'Attribute not found', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'missing')); + $this->assertRefused(NotFoundException::class, 'Attribute not found', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'missing')); + $this->assertRefused(NotFoundException::class, 'Document not found', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'missing', 'count')); + $this->assertRefused(NotFoundException::class, 'Document not found', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'missing', 'count')); + + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $guarded = $this->database(new Memory(), authorization: $authorization, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: true); + $this->assertRefused(NotFoundException::class, 'Document not found', fn (): Document => $guarded->increaseDocumentAttribute(self::COLLECTION, 'missing', 'count')); + $this->assertRefused(NotFoundException::class, 'Document not found', fn (): Document => $guarded->decreaseDocumentAttribute(self::COLLECTION, 'missing', 'count')); + } + + public function testIncreasingAndDecreasingNeedUpdatePermission(): void + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = $this->database(new Memory(), authorization: $authorization, permissions: [Permission::create(Role::any()), Permission::read(Role::any())]); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', 'count' => 10])); + + $this->assertRefused(AuthorizationException::class, null, fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count')); + $this->assertRefused(AuthorizationException::class, null, fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count')); + $this->assertSame(10, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('count')); + } + + public function testASchemalessDecreaseStartsAnUnsetAttributeAtZeroAndRefusesText(): void + { + $database = $this->database($this->schemaless()); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ledger', 'label' => 'text'])); + + $this->assertSame(-2, $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'balance', 2)->getAttribute('balance')); + $this->assertSame(-2, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('balance')); + $this->assertRefused(TypeException::class, 'Attribute value must be numeric.', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'label')); + $this->assertRefused(TypeException::class, 'Attribute value must be numeric.', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'label')); + } + + public function testASchemalessChangeOfADefinitionSkipsTheDocumentPermissions(): void + { + $database = $this->database($this->schemaless()); + + $this->assertSame(1, $database->increaseDocumentAttribute(Database::METADATA, self::COLLECTION, 'revision')->getAttribute('revision')); + $this->assertSame(0, $database->decreaseDocumentAttribute(Database::METADATA, self::COLLECTION, 'revision')->getAttribute('revision')); + } + + private function schemaless(): Memory + { + return $this->without(Capability::DefinedAttributes); + } + + private function castingItself(): Memory + { + return new class () extends Memory implements Feature\Casting { + #[\Override] + public function castBefore(Document $collection, Document $document): Document + { + return $document; + } + + #[\Override] + public function castAfter(Document $collection, array $documents): array + { + return $documents; + } + + #[\Override] + public function castDatetime(string $value): mixed + { + return DateTime::setTimezone($value); + } + }; + } + + private function without(Capability $missing): Memory + { + return new class ($missing) extends Memory { + public function __construct(private readonly Capability $missing) + { + parent::__construct(); + } + + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + fn (Capability $capability): bool => $capability !== $this->missing, + )); + } + }; + } + + /** + * @param class-string<\Throwable> $exception + * @param callable(): mixed $change + */ + private function assertRefused(string $exception, ?string $message, callable $change): void + { + $error = null; + try { + $change(); + } catch (\Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf($exception, $error); + if ($message !== null) { + $this->assertSame($message, $error->getMessage()); + } + } + + /** + * @param list|null $attributes + * @param list|null $permissions + */ + private function database(Memory $adapter, ?array $attributes = null, ?Authorization $authorization = null, ?array $permissions = null, bool $documentSecurity = false): Database + { + $database = new Database($adapter, new Cache(new None())); + if ($authorization !== null) { + $database->setAuthorization($authorization); + } + $database->setDatabase('numeric')->setNamespace('numeric_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: $attributes ?? [ + Attribute::integer(key: 'count'), + Attribute::float(key: 'ratio'), + Attribute::float(key: 'share', signed: false), + ], + permissions: $permissions ?? [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: $documentSecurity, + )); + + return $database; + } +} diff --git a/tests/unit/Documents/OnNextTest.php b/tests/unit/Documents/OnNextTest.php new file mode 100644 index 0000000000..c135fb8437 --- /dev/null +++ b/tests/unit/Documents/OnNextTest.php @@ -0,0 +1,104 @@ +database = HookFixture::sqlite(); + HookFixture::seed($this->database, ['first', 'second']); + } + + public function testCreatePassesNoPreviousDocument(): void + { + $calls = $this->record(fn (callable $onNext): int => $this->database->createDocuments(HookFixture::COLLECTION, [ + new Document([Document::ID => 'third', 'title' => 'third', 'views' => 3]), + ], onNext: $onNext)); + + $this->assertSame([['third', null]], $calls); + } + + public function testUpdatePassesTheDocumentAsStoredBeforeTheUpdate(): void + { + $calls = $this->record(fn (callable $onNext): int => $this->database->updateDocuments( + HookFixture::COLLECTION, + new Document(['title' => 'renamed']), + [Query::equal(Document::ID, ['first'])], + onNext: $onNext, + ), 'title'); + + $this->assertSame([['renamed', 'first']], $calls); + } + + public function testUpsertPassesNullForACreateAndTheStoredDocumentForAnUpdate(): void + { + $calls = $this->record(fn (callable $onNext): int => $this->database->upsertDocuments(HookFixture::COLLECTION, [ + new Document([Document::ID => 'first', 'title' => 'renamed', 'views' => 1]), + new Document([Document::ID => 'third', 'title' => 'third', 'views' => 3]), + ], onNext: $onNext), 'title'); + + $this->assertSame([['renamed', 'first'], ['third', null]], $calls); + } + + public function testDeletePassesACopyOfTheStoredDocumentAsThePreviousOne(): void + { + $pairs = []; + $deleted = $this->database->deleteDocuments( + HookFixture::COLLECTION, + [Query::equal(Document::ID, ['second'])], + onNext: function (Document $document, ?Document $previous) use (&$pairs): void { + $pairs[] = [$document, $previous]; + }, + ); + + $this->assertSame(1, $deleted); + $this->assertCount(1, $pairs); + [$document, $previous] = $pairs[0]; + $this->assertSame('second', $document->getId()); + $this->assertNotNull($previous); + $this->assertNotSame($document, $previous, 'The previous document is a copy, so changing one leaves the other'); + $this->assertSame($document->getArrayCopy(), $previous->getArrayCopy()); + $this->assertSame(2, $previous->getAttribute('views')); + } + + public function testAThrowingCallbackAbortsTheWrite(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('stop'); + + $this->database->createDocuments(HookFixture::COLLECTION, [ + new Document([Document::ID => 'third', 'title' => 'third', 'views' => 3]), + ], onNext: static function (): void { + throw new RuntimeException('stop'); + }); + } + + /** + * @param callable(callable(Document, ?Document): void): int $write + * @return list Each call's document and previous document, by id or by $attribute + */ + private function record(callable $write, string $attribute = Document::ID): array + { + $calls = []; + $write(static function (Document $document, ?Document $previous = null) use (&$calls, $attribute): void { + $calls[] = [$document->getAttribute($attribute), $previous?->getAttribute($attribute)]; + }); + + return $calls; + } +} diff --git a/tests/unit/Documents/QueriesValidatorTest.php b/tests/unit/Documents/QueriesValidatorTest.php new file mode 100644 index 0000000000..c26b346141 --- /dev/null +++ b/tests/unit/Documents/QueriesValidatorTest.php @@ -0,0 +1,191 @@ +database(); + + $this->assertSame(1, $database->count(self::COLLECTION, [Query::equal('title', ['Dune'])])); + $this->assertSame(6, $database->sum(self::COLLECTION, 'year', [Query::greaterThan('year', 1)])); + $this->assertCount(1, $database->find(self::COLLECTION, [Query::equal('title', ['Dune']), Query::orderDesc('year'), Query::limit(5), Query::offset(0)])); + $this->assertSame(0, $database->documentsValidators); + } + + public function testAnyOtherReadStillUsesTheDocumentsValidator(): void + { + $database = $this->database(); + $database->createIndex(self::COLLECTION, Index::fulltext('title_fulltext', ['title'])); + + $database->count(self::COLLECTION, [Query::search('title', 'Dune')]); + $database->find(self::COLLECTION, [Query::select(['title']), Query::equal('title', ['Dune'])]); + + $this->assertSame(2, $database->documentsValidators); + } + + public function testANarrowReadFollowsEverySchemaChange(): void + { + $database = $this->database(); + $byYear = [Query::equal('year', [6])]; + + $this->assertSame(1, $database->count(self::COLLECTION, $byYear)); + + $database->updateAttribute(self::COLLECTION, 'year', new AttributeUpdate(type: ColumnType::String, size: 8)); + $this->assertRefused($database, $byYear, 'Invalid query: Query value is invalid for attribute "year"'); + $this->assertSame(0, $database->count(self::COLLECTION, [Query::equal('year', ['six'])])); + + $database->renameAttribute(self::COLLECTION, 'year', 'published'); + $this->assertRefused($database, [Query::equal('year', ['six'])], 'Invalid query: Attribute not found in schema: year'); + $this->assertSame(0, $database->count(self::COLLECTION, [Query::equal('published', ['six'])])); + + $database->updateAttribute(self::COLLECTION, 'published', new AttributeUpdate(filters: ['encrypt'])); + $this->assertRefused($database, [Query::equal('published', ['six'])], 'Invalid query: Cannot query encrypted attribute: published'); + + $database->deleteAttribute(self::COLLECTION, 'published'); + $this->assertRefused($database, [Query::equal('published', ['six'])], 'Invalid query: Attribute not found in schema: published'); + + $database->createAttribute(self::COLLECTION, Attribute::integer(key: 'published')); + $this->assertSame(0, $database->count(self::COLLECTION, [Query::equal('published', [6])])); + $this->assertRefused($database, [Query::equal('published', ['six'])], 'Invalid query: Query value is invalid for attribute "published"'); + } + + public function testAnIndexChangeReachesTheListsThatNeedAnIndex(): void + { + $database = $this->database(); + $search = [Query::search('title', 'Dune')]; + + $this->assertRefused($database, $search, 'Searching by attribute "title" requires a fulltext index.'); + + $database->createIndex(self::COLLECTION, Index::fulltext('title_fulltext', ['title'])); + $database->count(self::COLLECTION, $search); + + $database->deleteIndex(self::COLLECTION, 'title_fulltext'); + $this->assertRefused($database, $search, 'Searching by attribute "title" requires a fulltext index.'); + } + + public function testACollectionChangedInPlaceIsJudgedAsItIsNow(): void + { + $database = $this->database(); + $collection = $database->getCollection(self::COLLECTION); + $queries = [Query::equal('title', ['Dune'])]; + + $this->assertTrue($database->queriesValidator($collection, $queries)->isValid($queries)); + + $attributes = $collection->getAttribute('attributes', []); + $this->assertIsArray($attributes); + $title = $attributes[0]; + $this->assertInstanceOf(Document::class, $title); + $title->setAttribute('type', ColumnType::Integer->value); + $collection->setAttribute('attributes', $attributes); + $validator = $database->queriesValidator($collection, $queries); + $this->assertFalse($validator->isValid($queries)); + $this->assertSame('Invalid query: Query value is invalid for attribute "title"', $validator->getDescription()); + + $collection->setAttribute('attributes', []); + $validator = $database->queriesValidator($collection, $queries); + $this->assertFalse($validator->isValid($queries)); + $this->assertSame('Invalid query: Attribute not found in schema: title', $validator->getDescription()); + + $collection->setAttribute('attributes', [Attribute::string(key: 'title', size: 64)->toDocument()]); + $this->assertTrue($database->queriesValidator($collection, $queries)->isValid($queries)); + } + + public function testACollectionALifecycleHookChangedIsJudgedAsItWasHanded(): void + { + $database = $this->database(); + $database->addHook(new class () implements Lifecycle { + #[\Override] + public function handle(Domain $event): void + { + if ($event instanceof Event\Collection\Read) { + $event->definition->setAttribute('attributes', [Attribute::integer(key: 'title')->toDocument()]); + } + } + }); + $queries = [Query::equal('title', ['Dune'])]; + + $changed = $database->getCollection(self::COLLECTION); + $validator = $database->queriesValidator($changed, $queries); + $this->assertFalse($validator->isValid($queries)); + $this->assertSame('Invalid query: Query value is invalid for attribute "title"', $validator->getDescription()); + + $this->assertTrue($database->queriesValidator($database->silent(fn () => $database->getCollection(self::COLLECTION)), $queries)->isValid($queries)); + $this->assertSame(1, $database->count(self::COLLECTION, $queries)); + } + + public function testEachTenantIsJudgedByItsOwnCollection(): void + { + $database = $this->database(sharedTables: true); + $queries = [Query::equal('year', [6])]; + + $this->assertSame(1, $database->count(self::COLLECTION, $queries)); + + $database->setTenant(2); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64), Attribute::string(key: 'year', size: 8)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $this->assertRefused($database, $queries, 'Invalid query: Query value is invalid for attribute "year"'); + + $database->setTenant(1); + $this->assertSame(1, $database->count(self::COLLECTION, $queries)); + } + + /** + * @param array $queries + */ + private function assertRefused(Database $database, array $queries, string $message): void + { + foreach (['count', 'find'] as $read) { + try { + $read === 'count' ? $database->count(self::COLLECTION, $queries) : $database->find(self::COLLECTION, $queries); + $this->fail($read.' accepted a list the schema refuses'); + } catch (QueryException $exception) { + $this->assertSame($message, $exception->getMessage(), $read); + } + } + } + + private function database(bool $sharedTables = false): DocumentsValidatorDatabase + { + $database = new DocumentsValidatorDatabase(new Memory(), new Cache(new MemoryCache())); + $database->setDatabase('queries_validator')->setNamespace('queries_validator_'.\uniqid()); + if ($sharedTables) { + $database->setSharedTables(true)->setTenant(1); + } + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'year')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune', 'year' => 6])); + $database->documentsValidators = 0; + + return $database; + } +} diff --git a/tests/unit/Documents/ResolvedCollectionReadTest.php b/tests/unit/Documents/ResolvedCollectionReadTest.php new file mode 100644 index 0000000000..a4443e1636 --- /dev/null +++ b/tests/unit/Documents/ResolvedCollectionReadTest.php @@ -0,0 +1,416 @@ + + */ + public static function writes(): array + { + return [ + 'updateDocument' => [static fn (Database $database): Document => $database->updateDocument(self::COLLECTION, 'hook', new Document(['name' => 'renamed']))], + 'increaseDocumentAttribute' => [static fn (Database $database): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'hook', 'count')], + 'decreaseDocumentAttribute' => [static fn (Database $database): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'hook', 'count')], + 'deleteDocument' => [static fn (Database $database): bool => $database->deleteDocument(self::COLLECTION, 'hook')], + ]; + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testAWriteOfACachedDocumentTakesThreeRoundTrips(Closure $write): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + + $adapter->reset(); + $cache->resetOperations(); + $write($database); + + $this->assertSame(3, $cache->getOperations(), 'One definition load, the purge inside the transaction and the purge after it commits'); + $this->assertSame(0, $adapter->metadataReads); + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testAWriteLoadsItsDefinitionOnce(Closure $write): void + { + [$database, , $cache] = $this->createDatabase(); + $definitionKey = \strtolower($database->getCacheKeys(Database::METADATA, self::COLLECTION)[1]); + + $cache->resetOperations(); + $write($database); + + $this->assertSame(1, $cache->getLoads()[$definitionKey] ?? 0); + } + + public function testAnUpdateReturnsTheDocumentAReadReturnsAfterIt(): void + { + [$database] = $this->createDatabase(); + + $updated = $database->updateDocument(self::COLLECTION, 'hook', new Document(['name' => 'renamed'])); + + $this->assertSame('renamed', $updated->getAttribute('name')); + $this->assertSame(UncachedTwin::of($database)->getDocument(self::COLLECTION, 'hook')->getArrayCopy(), $updated->getArrayCopy()); + $this->assertSame($updated->getArrayCopy(), $database->getDocument(self::COLLECTION, 'hook')->getArrayCopy()); + } + + public function testTheCountersReturnTheDocumentAReadReturnsAfterThem(): void + { + [$database] = $this->createDatabase(); + + $increased = $database->increaseDocumentAttribute(self::COLLECTION, 'hook', 'count', 5); + $this->assertSame(6, $increased->getAttribute('count')); + $this->assertSame('hook', $increased->getAttribute('name')); + $this->assertSame($increased->getId(), UncachedTwin::of($database)->getDocument(self::COLLECTION, 'hook')->getId()); + $this->assertSame(6, $database->getDocument(self::COLLECTION, 'hook')->getAttribute('count')); + + $decreased = $database->decreaseDocumentAttribute(self::COLLECTION, 'hook', 'count', 2); + $this->assertSame(4, $decreased->getAttribute('count')); + $this->assertSame('hook', $decreased->getAttribute('name')); + $this->assertSame(4, UncachedTwin::of($database)->getDocument(self::COLLECTION, 'hook')->getAttribute('count')); + $this->assertSame(4, $database->getDocument(self::COLLECTION, 'hook')->getAttribute('count')); + } + + public function testADeleteRemovesTheDocumentForEveryReader(): void + { + [$database] = $this->createDatabase(); + + $this->assertTrue($database->deleteDocument(self::COLLECTION, 'hook')); + + $this->assertTrue($database->getDocument(self::COLLECTION, 'hook')->isEmpty()); + $this->assertTrue(UncachedTwin::of($database)->getDocument(self::COLLECTION, 'hook')->isEmpty()); + } + + public function testWritesOfAMissingDocumentStillFail(): void + { + [$database] = $this->createDatabase(); + + $this->assertTrue($database->updateDocument(self::COLLECTION, 'missing', new Document(['name' => 'renamed']))->isEmpty()); + $this->assertFalse($database->deleteDocument(self::COLLECTION, 'missing')); + + $this->expectException(NotFoundException::class); + $database->increaseDocumentAttribute(self::COLLECTION, 'missing', 'count'); + } + + /** + * A schema change committed after a write resolved its definition and before its transaction locks the row is + * not seen by the locked read: the row is cast and decoded under the definition the write validates, encodes and + * writes with, as that write never honoured the newer one. + * + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testTheLockedReadUsesTheDefinitionTheWriteResolved(Closure $write): void + { + $adapter = new class () extends CountingMemory { + private const string LOCKED = 'webhooks'; + + /** @var (Closure(): void)|null */ + public ?Closure $beforeTransaction = null; + + /** @var list> */ + public array $lockedReads = []; + + #[\Override] + public function startTransaction(): bool + { + $before = $this->beforeTransaction; + $this->beforeTransaction = null; + if ($before !== null) { + $before(); + } + + return parent::startTransaction(); + } + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + if ($forUpdate && $collection->getId() === self::LOCKED) { + $this->lockedReads[] = \array_map( + static fn (Attribute $attribute): string => $attribute->key, + Collection::fromDocument($collection)->attributes(), + ); + } + + return parent::getDocument($collection, $id, $queries, $forUpdate); + } + }; + [$database, , , $cache] = $this->createDatabase($adapter); + $concurrent = (new Database($adapter, $cache)) + ->setAuthorization($database->getAuthorization()) + ->setDatabase($database->getDatabase()) + ->setNamespace($database->getNamespace()); + $adapter->beforeTransaction = static function () use ($concurrent): void { + $concurrent->createAttribute(self::COLLECTION, Attribute::string(key: 'label')); + }; + + $write($database); + + $this->assertNull($adapter->beforeTransaction, 'The schema change ran between the definition lookup and the locked read'); + $this->assertSame([['name', 'count']], $adapter->lockedReads); + $this->assertSame(['name', 'count', 'label'], \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection(self::COLLECTION)->attributes(), + ), 'The next call resolves the changed definition'); + } + + /** + * @return array + */ + public static function bulkWrites(): array + { + return [ + 'updateDocuments' => [static fn (Database $database, ?callable $onNext): int => $database->updateDocuments(self::COLLECTION, new Document(['name' => 'renamed']), batchSize: 2, onNext: $onNext)], + 'deleteDocuments' => [static fn (Database $database, ?callable $onNext): int => $database->deleteDocuments(self::COLLECTION, batchSize: 2, onNext: $onNext)], + ]; + } + + /** + * @param Closure(Database, ?callable): int $write + */ + #[DataProvider('bulkWrites')] + public function testABulkWriteReadsNoDefinitionForItsPages(Closure $write): void + { + [$database, $adapter] = $this->createDatabase(); + $this->createSiblings($database); + + $adapter->reset(); + $this->assertSame(6, $write($database, null)); + + $this->assertSame(0, $adapter->metadataReads, 'Three pages of a cached definition read no _metadata row'); + } + + /** + * @param Closure(Database, ?callable): int $write + */ + #[DataProvider('bulkWrites')] + public function testABulkWriteOfAnUncachedDefinitionReadsItOnce(Closure $write): void + { + [$database, $adapter] = $this->createDatabase(); + $this->createSiblings($database); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + $adapter->reset(); + $this->assertSame(6, $write($database, null)); + + $this->assertSame(1, $adapter->metadataReads); + } + + /** + * A schema change committed while a bulk write pages is not seen by its later pages: every page is read under + * the definition the call validated its queries and encoded its updates with. + * + * @param Closure(Database, ?callable): int $write + */ + #[DataProvider('bulkWrites')] + public function testEveryPageOfABulkWriteUsesTheDefinitionItResolved(Closure $write): void + { + $adapter = new class () extends CountingMemory { + private const string PAGED = 'webhooks'; + + /** @var list> */ + public array $pages = []; + + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array + { + if ($collection->getId() === self::PAGED && $forPermission !== PermissionType::Read) { + $this->pages[] = \array_map( + static fn (Attribute $attribute): string => $attribute->key, + Collection::fromDocument($collection)->attributes(), + ); + } + + return parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); + } + }; + [$database, , , $cache] = $this->createDatabase($adapter); + $this->createSiblings($database); + $concurrent = (new Database($adapter, $cache)) + ->setAuthorization($database->getAuthorization()) + ->setDatabase($database->getDatabase()) + ->setNamespace($database->getNamespace()); + $changed = false; + $onNext = static function () use ($concurrent, &$changed): void { + if (! $changed) { + $changed = true; + $concurrent->createAttribute(self::COLLECTION, Attribute::string(key: 'label')); + } + }; + + $this->assertSame(6, $write($database, $onNext)); + + $this->assertTrue($changed); + $this->assertSame(\array_fill(0, 4, ['name', 'count']), $adapter->pages); + $this->assertSame(['name', 'count', 'label'], \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection(self::COLLECTION)->attributes(), + ), 'The next call resolves the changed definition'); + } + + public function testADecoratorMutatingTheMetadataDefinitionLeavesTheNextReadIntact(): void + { + [$database] = $this->createDatabase(); + $database->addHook($this->metadataMutatingDecorator()); + + foreach ([1, 2] as $read) { + $stored = $database->getDocument(Database::METADATA, self::COLLECTION); + $this->assertSame(self::COLLECTION, $stored->getId(), "Read {$read}"); + $attributes = $stored->getAttribute(Collection::ATTRIBUTES); + $this->assertIsArray($attributes, "Read {$read} decodes the attributes under the metadata definition"); + $this->assertCount(2, $attributes); + } + + $definition = Database::collectionDefinition(); + $this->assertSame('collections', $definition->getAttribute(Collection::NAME)); + $this->assertCount(4, $definition->attributes()); + $this->assertSame(['name', 'count'], \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection(self::COLLECTION)->attributes(), + )); + } + + public function testAFirstDefinitionReadBuildsTheCollectionKeyOnce(): void + { + [$database, $adapter] = $this->createDatabase(); + $reader = new class ($adapter, new Cache(new RedisLeasableCache())) extends Database { + /** @var array */ + public array $collectionKeys = []; + + #[\Override] + public function getCacheBaseKeys(string $collectionId, ?string $documentId = null): array + { + if ($documentId === null) { + $this->collectionKeys[$collectionId] = ($this->collectionKeys[$collectionId] ?? 0) + 1; + } + + return parent::getCacheBaseKeys($collectionId, $documentId); + } + }; + $reader + ->setAuthorization($database->getAuthorization()) + ->setDatabase($database->getDatabase()) + ->setNamespace($database->getNamespace()); + + $this->assertSame(self::COLLECTION, $reader->getCollection(self::COLLECTION)->getId()); + + $this->assertSame([self::COLLECTION => 1], $reader->collectionKeys, 'The definition\'s fill checks the epoch under the key it read it with'); + $this->assertSame(self::COLLECTION, $reader->getCollection(self::COLLECTION)->getId()); + } + + public function testADecoratorAddedDuringAMetadataReadLeavesTheSharedDefinitionIntact(): void + { + $adapter = new ObservedMemory(); + $database = (new Database($adapter, new Cache(new RedisLeasableCache()))) + ->setDatabase('utopiaTests') + ->setNamespace('resolved_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name')], + permissions: [Permission::read(Role::any())], + )); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $adapter->observeMetadata(self::COLLECTION, fn () => $database->addHook($this->metadataMutatingDecorator())); + + $stored = $database->getDocument(Database::METADATA, self::COLLECTION); + + $this->assertSame(1, $adapter->getObservedMetadataReads()); + $this->assertSame(self::COLLECTION, $stored->getId()); + $definition = Database::collectionDefinition(); + $this->assertSame('collections', $definition->getAttribute(Collection::NAME), 'The decorator changed the shared metadata definition'); + $this->assertCount(4, $definition->attributes()); + } + + private function metadataMutatingDecorator(): Decorator + { + return new class () implements Decorator { + #[\Override] + public function decorate(Event $event, Document $collection, Document $document): Document + { + if ($collection->getId() === Database::METADATA) { + $collection->setAttribute(Collection::NAME, 'mutated'); + $collection->setAttribute(Collection::ATTRIBUTES, []); + $collection->setAttribute(Collection::INDEXES, []); + } + + return $document; + } + }; + } + + private function createSiblings(Database $database): void + { + foreach (['b', 'c', 'd', 'e', 'f'] as $id) { + $database->createDocument(self::COLLECTION, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => $id, + 'count' => 1, + ])); + } + $database->getCollection(self::COLLECTION); + } + + /** + * @return array{Database, CountingMemory, CountingCache, Cache} + */ + private function createDatabase(?CountingMemory $adapter = null): array + { + $adapter ??= new CountingMemory(); + $counting = new CountingCache(new RedisLeasableCache()); + $cache = new Cache($counting); + $database = (new Database($adapter, $cache)) + ->setDatabase('utopiaTests') + ->setNamespace('resolved_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create(id: self::COLLECTION, attributes: [ + Attribute::string(key: 'name'), + Attribute::integer(key: 'count', default: 10), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'hook', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => 'hook', + 'count' => 1, + ])); + $database->getDocument(self::COLLECTION, 'hook'); + + return [$database, $adapter, $counting, $cache]; + } +} diff --git a/tests/unit/Documents/SkipDuplicatesPermissionTest.php b/tests/unit/Documents/SkipDuplicatesPermissionTest.php new file mode 100644 index 0000000000..d7cc783648 --- /dev/null +++ b/tests/unit/Documents/SkipDuplicatesPermissionTest.php @@ -0,0 +1,362 @@ + + */ + public static function modes(): array + { + $modes = []; + foreach (self::returning() as $name => [$returning]) { + $modes['dedicated tables, '.$name] = [false, false, $returning]; + $modes['shared tables, '.$name] = [true, false, $returning]; + $modes['tenant per document, '.$name] = [true, true, $returning]; + } + + return $modes; + } + + /** + * @return array + */ + public static function returning(): array + { + return [ + 'returning' => [true], + 'read-back' => [false], + ]; + } + + #[DataProvider('modes')] + public function testASkippedDuplicateGrantsNothingOnTheExistingDocument(bool $sharedTables, bool $tenantPerDocument, bool $returning): void + { + $this->open($sharedTables, $tenantPerDocument, $returning); + $this->database->createDocument(self::COLLECTION, $this->note(self::EXISTING, Role::user(self::ALICE), 5)); + + $recorder = $this->recordCreatedDocuments(); + /** @var ArrayObject $emitted */ + $emitted = new ArrayObject(); + $created = $this->database->ignoreDuplicates(fn (): int => $this->database->createDocuments( + self::COLLECTION, + [ + $this->note(self::EXISTING, Role::any(), 7), + $this->note(self::FRESH, Role::any(), 3), + ], + onNext: static function (Document $document) use ($emitted): void { + $emitted->append($document->getId()); + }, + )); + + $this->assertSame([['read', 'user:'.self::ALICE]], $this->grants(self::EXISTING), 'The existing document keeps its own grants'); + $this->assertSame([self::FRESH], $this->readableIds(), 'A guest must not find the existing document'); + $this->assertSame(1, $this->read(fn (): int => $this->database->count(self::COLLECTION)), 'A guest must not count the existing document'); + $this->assertSame(3, $this->read(fn (): int|float => $this->database->sum(self::COLLECTION, self::RANK)), 'A guest must not sum the existing document'); + + $this->assertSame(1, $created, 'Only the inserted document is counted as created'); + $this->assertSame([self::FRESH], $emitted->getArrayCopy(), 'Only the inserted document is handed to onNext'); + $this->assertSame([[self::FRESH]], $recorder->created, 'Write hooks see only the inserted documents'); + + $this->authorization->addRole(Role::user(self::ALICE)->toString()); + $this->assertSame([self::EXISTING, self::FRESH], $this->readableIds()); + $this->assertSame(5, $this->read(fn (): Document => $this->database->getDocument(self::COLLECTION, self::EXISTING))->getAttribute(self::RANK)); + } + + #[DataProvider('modes')] + public function testARepeatedIdInOneBatchIsWrittenWithTheFirstCopysGrants(bool $sharedTables, bool $tenantPerDocument, bool $returning): void + { + $this->open($sharedTables, $tenantPerDocument, $returning); + + $recorder = $this->recordCreatedDocuments(); + $created = $this->database->ignoreDuplicates(fn (): int => $this->database->createDocuments( + self::COLLECTION, + [ + $this->note(self::FRESH, Role::user(self::ALICE), 1), + $this->note(self::FRESH, Role::any(), 2), + ], + )); + + $this->assertSame([['read', 'user:'.self::ALICE]], $this->grants(self::FRESH), 'The second copy of the id must not add its grants'); + $this->assertSame([], $this->readableIds()); + $this->assertSame(1, $created); + $this->assertSame([[self::FRESH]], $recorder->created); + } + + #[DataProvider('modes')] + public function testADocumentSkippedForAnotherUniqueValueGrantsNothing(bool $sharedTables, bool $tenantPerDocument, bool $returning): void + { + $this->open($sharedTables, $tenantPerDocument, $returning); + $this->database->createDocument(self::COLLECTION, $this->note(self::EXISTING, Role::user(self::ALICE), 5)); + + $recorder = $this->recordCreatedDocuments(); + $created = $this->database->ignoreDuplicates(fn (): int => $this->database->createDocuments( + self::COLLECTION, + [ + $this->note(self::FRESH, Role::any(), 3, slug: self::EXISTING), + $this->note('other', Role::any(), 4), + ], + )); + + $this->assertSame([], $this->grants(self::FRESH), 'A document the engine skipped leaves no grants behind'); + $this->assertSame(['other'], $this->readableIds()); + $this->assertSame(1, $created); + $this->assertSame([['other']], $recorder->created); + + $this->expectException(DuplicateException::class); + $this->database->createDocument(self::COLLECTION, $this->note(self::FRESH, Role::any(), 3, slug: self::EXISTING)); + } + + #[DataProvider('modes')] + public function testARepeatedIdWhoseFirstCopyIsSkippedIsNotWrittenFromTheSecond(bool $sharedTables, bool $tenantPerDocument, bool $returning): void + { + $this->open($sharedTables, $tenantPerDocument, $returning); + $this->database->createDocument(self::COLLECTION, $this->note(self::EXISTING, Role::user(self::ALICE), 5)); + + $recorder = $this->recordCreatedDocuments(); + $created = $this->database->ignoreDuplicates(fn (): int => $this->database->createDocuments( + self::COLLECTION, + [ + $this->note(self::FRESH, Role::any(), 3, slug: self::EXISTING), + $this->note(self::FRESH, Role::user(self::ALICE), 4), + ], + )); + + $this->assertSame([], $this->grants(self::FRESH), 'The skipped first copy must not lend its grants to a row of the second'); + $this->assertSame([], $this->readableIds()); + $this->assertSame(0, $created, 'Of a repeated id only the first copy is written, and it was skipped'); + $this->assertSame([], $recorder->created); + } + + #[DataProvider('returning')] + public function testTheSameIdUnderAnotherTenantIsStillInserted(bool $returning): void + { + $this->open(true, true, $returning); + $this->database->createDocument(self::COLLECTION, $this->note(self::EXISTING, Role::user(self::ALICE), 5)); + + $recorder = $this->recordCreatedDocuments(); + $created = $this->database->ignoreDuplicates(fn (): int => $this->database->createDocuments( + self::COLLECTION, + [ + $this->note(self::EXISTING, Role::any(), 7), + $this->note(self::EXISTING, Role::any(), 9, self::OTHER_TENANT), + ], + )); + + $this->assertSame([], $this->readableIds(self::TENANT), 'The existing document keeps its own grants'); + $this->assertSame([self::EXISTING], $this->readableIds(self::OTHER_TENANT), 'The id is new under the other tenant'); + $this->assertSame(1, $created); + $this->assertSame([[self::EXISTING]], $recorder->created); + } + + public function testTheMemoryAdapterCountsOnlyInsertedDocuments(): void + { + $this->authorization = new Authorization(); + $this->database = (new Database(new Memory(), new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer(key: self::RANK), + Attribute::string(key: self::SLUG, size: 64), + ], + indexes: [Index::unique(key: self::SLUG, attributes: [self::SLUG])], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $this->database->createDocument(self::COLLECTION, $this->note(self::EXISTING, Role::user(self::ALICE), 5)); + + /** @var ArrayObject $emitted */ + $emitted = new ArrayObject(); + $created = $this->database->ignoreDuplicates(fn (): int => $this->database->createDocuments( + self::COLLECTION, + [ + $this->note(self::EXISTING, Role::any(), 7), + $this->note('other', Role::any(), 4, slug: self::EXISTING), + $this->note(self::FRESH, Role::any(), 3), + $this->note(self::FRESH, Role::any(), 2), + ], + onNext: static function (Document $document) use ($emitted): void { + $emitted->append($document->getId()); + }, + )); + + $this->assertSame([self::FRESH], $this->readableIds()); + $this->assertSame(1, $created); + $this->assertSame([self::FRESH], $emitted->getArrayCopy()); + } + + private function open(bool $sharedTables, bool $tenantPerDocument, bool $returning): void + { + $this->pdo = new PDO('sqlite::memory:'); + $this->authorization = new Authorization(); + $adapter = $returning ? new SQLite($this->pdo) : new class ($this->pdo) extends SQLite { + #[\Override] + protected function supportsInsertReturning(): bool + { + return false; + } + }; + + $this->database = (new Database($adapter, new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setSharedTables($sharedTables) + ->setTenant($sharedTables && ! $tenantPerDocument ? self::TENANT : null) + ->setTenantPerDocument($tenantPerDocument) + ->addHook(new Permissions()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer(key: self::RANK), + Attribute::string(key: self::SLUG, size: 64), + ], + indexes: [Index::unique(key: self::SLUG, attributes: [self::SLUG])], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + } + + /** + * @return object{created: list>} + */ + private function recordCreatedDocuments(): object + { + $recorder = new class () extends Interceptor { + /** @var list> */ + public array $created = []; + + #[\Override] + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + $this->created[] = \array_map(static fn (Document $document): string => $document->getId(), \array_values($documents)); + } + }; + $this->database->addHook($recorder); + + return $recorder; + } + + private function note(string $id, Role $reader, int $rank, int $tenant = self::TENANT, ?string $slug = null): Document + { + $note = new Document([ + '$id' => $id, + '$permissions' => [Permission::read($reader)], + self::RANK => $rank, + self::SLUG => $slug ?? $id, + ]); + + if ($this->database->isTenantPerDocument()) { + $note->setAttribute('$tenant', $tenant); + } + + return $note; + } + + /** + * @return array + */ + private function readableIds(int $tenant = self::TENANT): array + { + return $this->read(fn (): array => \array_map( + static fn (Document $document): string => $document->getId(), + $this->database->find(self::COLLECTION, [Query::orderAsc('$id')]), + ), $tenant); + } + + /** + * Under tenant per document no tenant is selected, and a read has to name one. + * + * @template T + * + * @param callable(): T $read + * @return T + */ + private function read(callable $read, int $tenant = self::TENANT): mixed + { + return $this->database->isTenantPerDocument() ? $this->database->withTenant($tenant, $read) : $read(); + } + + /** + * @return list + */ + private function grants(string $document): array + { + $statement = $this->pdo->prepare( + 'SELECT "'.Storage::PERMISSIONS_TYPE.'", "'.Storage::PERMISSIONS_PERMISSION.'" FROM "'.self::NAMESPACE.'_'.self::COLLECTION.'_perms"' + .' WHERE "'.Storage::PERMISSIONS_DOCUMENT.'" = ? ORDER BY "'.Storage::PERMISSIONS_PERMISSION.'"' + ); + $statement->execute([$document]); + + $grants = []; + foreach ($statement->fetchAll(PDO::FETCH_NUM) as $row) { + $this->assertIsArray($row); + [$type, $permission] = $row; + $this->assertIsString($type); + $this->assertIsString($permission); + $grants[] = [$type, $permission]; + } + + return $grants; + } +} diff --git a/tests/unit/Documents/SkipPermissionsTest.php b/tests/unit/Documents/SkipPermissionsTest.php new file mode 100644 index 0000000000..b325a7a1ec --- /dev/null +++ b/tests/unit/Documents/SkipPermissionsTest.php @@ -0,0 +1,179 @@ +method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $adapter->method('startTransaction')->willReturn(true); + $adapter->method('commitTransaction')->willReturn(true); + $adapter->method('rollbackTransaction')->willReturn(true); + $adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $adapter->method('createDocument')->willReturnArgument(1); + $adapter->method('getSequences')->willReturnArgument(1); + + return $adapter; + } + + private function buildDatabase(Adapter&Stub $adapter): Database + { + $cache = new Cache(new None()); + + return new Database($adapter, $cache); + } + + public function testGetDocumentWithSkippedPermissions(): void + { + $adapter = $this->makeAdapter(); + + $restrictedDoc = new Document([ + '$id' => 'doc1', + '$collection' => 'secret', + '$permissions' => [Permission::read(Role::user('admin'))], + 'title' => 'Confidential', + ]); + + $collection = new Document([ + '$id' => 'secret', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::user('admin'))], + 'name' => 'secret', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection, $restrictedDoc) { + if ($col->getId() === Database::METADATA && $docId === 'secret') { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + if ($col->getId() === 'secret' && $docId === 'doc1') { + return $restrictedDoc; + } + + return new Document(); + } + ); + + $db = $this->buildDatabase($adapter); + + $noPermResult = $db->getDocument('secret', 'doc1'); + $this->assertTrue($noPermResult->isEmpty()); + + $result = $db->getAuthorization()->skip(function () use ($db) { + return $db->getDocument('secret', 'doc1'); + }); + + $this->assertFalse($result->isEmpty()); + $this->assertSame('doc1', $result->getId()); + $this->assertSame('Confidential', $result->getAttribute('title')); + } + + public function testCreateDocumentWithSkippedPermissions(): void + { + $adapter = $this->makeAdapter(); + + $titleAttr = new Document([ + '$id' => 'title', + 'key' => 'title', + 'type' => 'string', + 'size' => 256, + 'required' => false, + 'array' => false, + 'signed' => true, + 'filters' => [], + ]); + + $collection = new Document([ + '$id' => 'restricted', + '$collection' => Database::METADATA, + '$permissions' => [Permission::create(Role::user('admin'))], + 'name' => 'restricted', + 'attributes' => [$titleAttr], + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'restricted') { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + + return new Document(); + } + ); + + $db = $this->buildDatabase($adapter); + + $result = $db->getAuthorization()->skip(function () use ($db) { + return $db->createDocument('restricted', new Document([ + '$permissions' => [Permission::read(Role::any())], + '$collection' => 'restricted', + 'title' => 'Created via skip', + ])); + }); + + $this->assertNotEmpty($result->getId()); + $this->assertSame('Created via skip', $result->getAttribute('title')); + } +} diff --git a/tests/unit/Documents/SumAttributeTest.php b/tests/unit/Documents/SumAttributeTest.php new file mode 100644 index 0000000000..02650830c3 --- /dev/null +++ b/tests/unit/Documents/SumAttributeTest.php @@ -0,0 +1,88 @@ +database = new Database(new Memory(), new Cache(new MemoryCache())); + $this->database->setDatabase('sums')->setNamespace('sums_'.\uniqid()); + $this->database->create(); + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $this->database->createCollection(Collection::create(id: 'authors', permissions: $permissions)); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::integer(key: 'pages'), + Attribute::float(key: 'rating'), + Attribute::integer(key: 'chapters', array: true), + ], + permissions: $permissions, + )); + $this->database->createRelationship(self::COLLECTION, Relationship::manyToOne(relatedCollection: 'authors', key: 'author', twoWayKey: 'books')); + $this->database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune', 'pages' => 7, 'rating' => 4.5, 'chapters' => [1, 2]])); + $this->database->createDocument(self::COLLECTION, new Document([Document::ID => 'emma', 'title' => 'Emma', 'pages' => 5, 'rating' => 3.0, 'chapters' => [3]])); + } + + public function testADeclaredIntegerIsSummed(): void + { + $this->assertSame(12, $this->database->sum(self::COLLECTION, 'pages')); + } + + public function testADeclaredFloatIsSummed(): void + { + $this->assertSame(7.5, $this->database->sum(self::COLLECTION, 'rating')); + } + + /** + * @return array + */ + public static function refusedAttributes(): array + { + return [ + 'string' => ['title', 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: title'], + 'numeric array' => ['chapters', 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: chapters'], + 'virtual relationship' => ['books', 'Invalid query: Attribute not found in schema: books'], + 'relationship' => ['author', 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: author'], + 'undeclared' => ['missing', 'Invalid query: Attribute not found in schema: missing'], + ]; + } + + #[DataProvider('refusedAttributes')] + public function testAnAttributeThatIsNotASingleNumberIsRefused(string $attribute, string $message): void + { + $this->assertSame($message, $this->failure(fn () => $this->database->sum(self::COLLECTION, $attribute))); + } + + private function failure(callable $sum): ?string + { + try { + $sum(); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/tests/unit/Documents/SumMaxTest.php b/tests/unit/Documents/SumMaxTest.php new file mode 100644 index 0000000000..1ee9e5afb6 --- /dev/null +++ b/tests/unit/Documents/SumMaxTest.php @@ -0,0 +1,67 @@ + + */ + public static function nonPositiveMaxima(): iterable + { + $sum = static fn (Database $database, int $max): mixed => $database->sum(HookFixture::COLLECTION, 'views', max: $max); + $count = static fn (Database $database, int $max): mixed => $database->count(HookFixture::COLLECTION, max: $max); + + yield 'sum with a zero max' => [$sum, 0]; + yield 'sum with a negative max' => [$sum, -1]; + yield 'count with a zero max' => [$count, 0]; + yield 'count with a negative max' => [$count, -1]; + } + + /** + * @param Closure(Database, int): mixed $aggregate + */ + #[DataProvider('nonPositiveMaxima')] + public function testANonPositiveMaxIsRefused(Closure $aggregate, int $max): void + { + $database = $this->database(); + + try { + $aggregate($database, $max); + $this->fail('A max of '.$max.' was accepted'); + } catch (QueryException $error) { + $this->assertSame('Max must be greater than 0', $error->getMessage()); + } + } + + public function testAPositiveMaxCapsTheDocumentsRead(): void + { + $database = $this->database(); + + $this->assertSame(2, $database->count(HookFixture::COLLECTION, max: 2)); + $this->assertSame(3, $database->sum(HookFixture::COLLECTION, 'views', max: 2)); + } + + public function testNoMaxReadsEveryDocument(): void + { + $database = $this->database(); + + $this->assertSame(3, $database->count(HookFixture::COLLECTION)); + $this->assertSame(6, $database->sum(HookFixture::COLLECTION, 'views')); + } + + private function database(): Database + { + $database = HookFixture::memory(); + HookFixture::seed($database, ['first', 'second', 'third']); + + return $database; + } +} diff --git a/tests/unit/Documents/SumValidatorCacheTest.php b/tests/unit/Documents/SumValidatorCacheTest.php new file mode 100644 index 0000000000..3bb691692b --- /dev/null +++ b/tests/unit/Documents/SumValidatorCacheTest.php @@ -0,0 +1,74 @@ +database(); + + for ($i = 0; $i < 3; $i++) { + $this->assertSame(7, $database->sum(self::COLLECTION, 'pages')); + $this->assertSame( + 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: title', + $this->failure(fn () => $database->sum(self::COLLECTION, 'title')), + ); + } + } + + public function testASchemaChangeSelectsAnotherValidator(): void + { + $database = $this->database(); + $this->assertSame(7, $database->sum(self::COLLECTION, 'pages')); + + $database->deleteAttribute(self::COLLECTION, 'pages'); + $this->assertNotNull($this->failure(fn () => $database->sum(self::COLLECTION, 'pages'))); + + $database->createAttribute(self::COLLECTION, Attribute::string(key: 'pages', size: 8)); + $this->assertSame( + 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: pages', + $this->failure(fn () => $database->sum(self::COLLECTION, 'pages')), + ); + } + + private function failure(callable $sum): ?string + { + try { + $sum(); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new MemoryCache())); + $database->setDatabase('sums')->setNamespace('sums_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'pages')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune', 'pages' => 7])); + + return $database; + } +} diff --git a/tests/unit/Documents/TenantSequencesTest.php b/tests/unit/Documents/TenantSequencesTest.php new file mode 100644 index 0000000000..092001ebb4 --- /dev/null +++ b/tests/unit/Documents/TenantSequencesTest.php @@ -0,0 +1,178 @@ + + */ + public static function fetchModes(): array + { + return [ + 'native fetches' => [false], + 'stringified fetches' => [true], + ]; + } + + #[DataProvider('fetchModes')] + public function testAnUpsertReportsEachTenantsOwnSequenceForTheSameNewId(bool $stringifyFetches): void + { + $this->database = $this->database($stringifyFetches); + + $reported = $this->upsert([ + $this->note(self::TENANT, 'shared'), + $this->note(self::OTHER_TENANT, 'shared'), + ]); + + $this->assertSame($this->stored(['shared' => [self::TENANT, self::OTHER_TENANT]]), $reported); + $this->assertNotSame($reported[self::TENANT]['shared'], $reported[self::OTHER_TENANT]['shared']); + } + + #[DataProvider('fetchModes')] + public function testAnUpsertOfThreeDocumentsOverTwoTenantsReportsEachDocumentsOwnSequence(bool $stringifyFetches): void + { + $this->database = $this->database($stringifyFetches); + $this->database->createDocument(self::COLLECTION, $this->note(self::OTHER_TENANT, 'existing')); + + $reported = $this->upsert([ + $this->note(self::OTHER_TENANT, 'shared'), + $this->note(self::TENANT, 'shared'), + $this->note(self::OTHER_TENANT, 'existing', 'renamed'), + ]); + + $this->assertSame( + $this->stored(['shared' => [self::TENANT, self::OTHER_TENANT], 'existing' => [self::OTHER_TENANT]]), + $reported, + ); + } + + public function testATenantGivenAsADigitStringMatchesItsIntegerRow(): void + { + $this->database = $this->database(false); + + $reported = $this->upsert([ + $this->note(self::TENANT, 'shared'), + $this->note((string) self::OTHER_TENANT, 'shared'), + ]); + + $this->assertSame($this->stored(['shared' => [self::TENANT, self::OTHER_TENANT]]), $reported); + } + + private function database(bool $stringifyFetches): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = (new Database( + new SQLite(new PDO('sqlite::memory:', options: [PDO::ATTR_STRINGIFY_FETCHES => $stringifyFetches])), + new Cache(new None()), + )) + ->setAuthorization($authorization) + ->setDatabase('tenant_sequences') + ->setNamespace('tenant_sequences') + ->setSharedTables(true) + ->setTenant(null) + ->setTenantPerDocument(true); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64, required: false)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + + return $database; + } + + private function note(int|string $tenant, string $id, string $title = 'note'): Document + { + return new Document([ + Document::ID => $id, + Document::TENANT => $tenant, + 'title' => $title, + ]); + } + + /** + * @param array $documents + * @return array> + */ + private function upsert(array $documents): array + { + $reported = []; + $this->database->upsertDocuments( + self::COLLECTION, + $documents, + onNext: function (Document $document) use (&$reported): void { + $reported[(int) $document->getTenant()][$document->getId()] = (string) $document->getSequence(); + }, + ); + + return $this->sorted($reported); + } + + /** + * @param array> $tenantsById + * @return array> + */ + private function stored(array $tenantsById): array + { + $stored = []; + foreach ($tenantsById as $id => $tenants) { + foreach ($tenants as $tenant) { + $document = $this->database->withTenant( + $tenant, + fn (): Document => $this->database->getDocument(self::COLLECTION, $id), + ); + $this->assertFalse($document->isEmpty(), "Tenant {$tenant} must hold '{$id}'"); + $stored[$tenant][$id] = (string) $document->getSequence(); + } + } + + return $this->sorted($stored); + } + + /** + * @param array> $sequences + * @return array> + */ + private function sorted(array $sequences): array + { + \ksort($sequences); + foreach ($sequences as &$byId) { + \ksort($byId); + } + + return $sequences; + } +} diff --git a/tests/unit/Documents/TenantWriteGuardsTest.php b/tests/unit/Documents/TenantWriteGuardsTest.php new file mode 100644 index 0000000000..71eb12fda8 --- /dev/null +++ b/tests/unit/Documents/TenantWriteGuardsTest.php @@ -0,0 +1,127 @@ +database(shared: false); + $database->setTenantPerDocument(true); + + $this->assertRefused(self::PER_DOCUMENT_WITHOUT_SHARING, fn (): mixed => $database->createDocument(self::COLLECTION, $this->note('a', 1))); + $this->assertRefused(self::PER_DOCUMENT_WITHOUT_SHARING, fn (): mixed => $database->createDocuments(self::COLLECTION, [$this->note('a', 1)])); + $this->assertRefused(self::PER_DOCUMENT_WITHOUT_SHARING, fn (): mixed => $database->upsertDocuments(self::COLLECTION, [$this->note('a', 1)])); + + $database->setTenantPerDocument(false); + $this->assertSame(0, $database->count(self::COLLECTION)); + } + + public function testATenantPerDocumentWriteNeedsTheDocumentsTenant(): void + { + $database = $this->database(shared: true); + $database->setTenantPerDocument(true); + + $this->assertRefused(self::MISSING_DOCUMENT_TENANT, fn (): mixed => $database->createDocument(self::COLLECTION, $this->note('a', null))); + $this->assertRefused(self::MISSING_DOCUMENT_TENANT, fn (): mixed => $database->createDocuments(self::COLLECTION, [$this->note('b', 1), $this->note('c', null)])); + $this->assertRefused(self::MISSING_DOCUMENT_TENANT, fn (): mixed => $database->upsertDocuments(self::COLLECTION, [$this->note('d', null)])); + + $this->assertSame(1, $database->createDocuments(self::COLLECTION, [$this->note('e', 1)])); + $this->assertSame(1, $database->withTenant(1, fn (): Document => $database->getDocument(self::COLLECTION, 'e'))->getTenant()); + } + + public function testAnUpsertUnderAnotherTenantWritesThatTenantsOwnDocument(): void + { + $database = $this->database(shared: true); + $database->setTenantPerDocument(true); + $database->createDocument(self::COLLECTION, $this->note('shared', 1)); + + $database->upsertDocument(self::COLLECTION, $this->note('shared', 2)->setAttribute('body', 'second')); + + $first = $database->withTenant(1, fn (): Document => $database->getDocument(self::COLLECTION, 'shared')); + $second = $database->withTenant(2, fn (): Document => $database->getDocument(self::COLLECTION, 'shared')); + $this->assertSame([1, 'shared'], [$first->getTenant(), $first->getAttribute('body')]); + $this->assertSame([2, 'second'], [$second->getTenant(), $second->getAttribute('body')]); + } + + public function testBulkWritesUnderSharedTablesNeedATenant(): void + { + $database = $this->database(shared: true); + + $this->assertRefused(self::MISSING_TENANT, fn (): mixed => $database->createDocuments(self::COLLECTION, [$this->note('a', null)])); + $this->assertRefused(self::MISSING_TENANT, fn (): mixed => $database->upsertDocuments(self::COLLECTION, [$this->note('a', null)])); + + $database->setTenant(3); + $this->assertSame(1, $database->createDocuments(self::COLLECTION, [$this->note('a', null)])); + $this->assertSame(3, $database->getDocument(self::COLLECTION, 'a')->getTenant()); + } + + /** + * @param callable(): mixed $write + */ + private function assertRefused(string $message, callable $write): void + { + $error = null; + try { + $write(); + } catch (DatabaseException $caught) { + $error = $caught; + } + + $this->assertInstanceOf(DatabaseException::class, $error, 'the write must be refused'); + $this->assertSame($message, $error->getMessage()); + } + + private function note(string $id, ?int $tenant): Document + { + return new Document([ + Document::ID => $id, + Document::TENANT => $tenant, + Document::PERMISSIONS => [Permission::read(Role::any()), Permission::update(Role::any())], + 'body' => $id, + ]); + } + + private function database(bool $shared): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('tenants') + ->setNamespace('tenants_'.\uniqid()) + ->setSharedTables($shared) + ->setTenant(null); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'body', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + + return $database; + } +} diff --git a/tests/unit/Documents/TransactionCacheReadTest.php b/tests/unit/Documents/TransactionCacheReadTest.php new file mode 100644 index 0000000000..ef20c28c0f --- /dev/null +++ b/tests/unit/Documents/TransactionCacheReadTest.php @@ -0,0 +1,481 @@ + + */ + public static function singleDocumentWrites(): array + { + return [ + 'updateDocument' => [ + static fn (Database $database): Document => $database->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])), + ], + 'increaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->increaseDocumentAttribute('webhooks', 'hook', 'count'), + ], + 'decreaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->decreaseDocumentAttribute('webhooks', 'hook', 'count'), + ], + 'deleteDocument' => [ + static fn (Database $database): bool => $database->deleteDocument('webhooks', 'hook'), + ], + ]; + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('singleDocumentWrites')] + public function testWritesReadTheirCollectionDefinitionFromTheCache(Closure $write): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $database->createDocument('webhooks', $this->hook('hook')); + $database->getDocument('webhooks', 'hook'); + + $adapter->reset(); + $write($database); + + $this->assertSame(0, $adapter->metadataReads, 'A write must read its collection definition from the cache inside its own transaction (7.3.12: 0 reads)'); + } + + public function testReadsInsideATransactionAreServedFromTheCache(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $database->createDocument('webhooks', $this->hook('written')); + $database->createDocument('webhooks', $this->hook('sibling')); + $database->getDocument('webhooks', 'written'); + $sibling = $database->getDocument('webhooks', 'sibling'); + + $adapter->reset(); + $read = $database->withTransaction(function () use ($database): Document { + $database->updateDocument('webhooks', 'written', new Document(['name' => 'renamed'])); + + return $database->getDocument('webhooks', 'sibling'); + }); + + $this->assertSame($sibling->getArrayCopy(), $read->getArrayCopy()); + $this->assertSame(0, $adapter->metadataReads, 'withTransaction(update + get) must read no collection definition on a warm cache (7.3.12: 0 reads)'); + $this->assertSame(1, $adapter->documentReads, 'Only the locking read of the written document may reach the adapter (7.3.12: 1 read)'); + } + + public function testATransactionReadsItsOwnWrites(): void + { + [$writer, $reader, $path] = $this->createSharedSQLiteDatabases(); + + try { + $this->assertSame('original', $writer->getDocument('users', 'user')->getAttribute('name')); + + $observed = $writer->withTransaction(function () use ($writer, $reader): array { + $writer->updateDocument('users', 'user', new Document(['name' => 'renamed'])); + $committed = $reader->getDocument('users', 'user')->getAttribute('name'); + $own = $writer->getDocument('users', 'user'); + + return [$committed, $own->getAttribute('name')]; + }); + + $this->assertSame('original', $observed[0], 'A reader outside the transaction reads the committed row and caches it'); + $this->assertSame('renamed', $observed[1], 'A transaction must read its own write, not a copy another reader cached after it'); + $this->assertSame('renamed', $reader->getDocument('users', 'user')->getAttribute('name')); + } finally { + $this->removeSQLiteFiles($path); + } + } + + public function testATransactionReadsItsOwnWritesUnderAnotherCasing(): void + { + [$writer, $reader] = $this->createSharedMemoryDatabases(); + $this->assertSame('hook', $writer->getDocument('webhooks', 'HOOK')->getAttribute('name')); + + $read = $writer->withTransaction(function () use ($writer, $reader): Document { + $writer->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])); + $this->assertSame('hook', $reader->getDocument('webhooks', 'HOOK')->getAttribute('name')); + + return $writer->getDocument('webhooks', 'HOOK'); + }); + + $this->assertSame('renamed', $read->getAttribute('name'), 'A transaction must read its own write under any casing the adapter matches to the written id'); + } + + public function testATransactionStartedOnTheAdapterReadsItsOwnWrites(): void + { + [$writer, $reader] = $this->createSharedMemoryDatabases(); + + $read = $writer->getAdapter()->withTransaction(function () use ($writer, $reader): Document { + $writer->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])); + $this->assertSame('hook', $reader->getDocument('webhooks', 'hook')->getAttribute('name')); + + return $writer->withTransaction(fn (): Document => $writer->getDocument('webhooks', 'hook')); + }); + + $this->assertSame('renamed', $read->getAttribute('name'), 'A transaction the database did not start must not serve cached copies of what it wrote'); + } + + public function testATransactionReadsTheDocumentsItsBatchWriteChanged(): void + { + [$writer, $reader] = $this->createSharedMemoryDatabases(); + + $read = $writer->withTransaction(function () use ($writer, $reader): Document { + $writer->updateDocuments('webhooks', new Document(['name' => 'renamed'])); + $this->assertSame('hook', $reader->getDocument('webhooks', 'hook')->getAttribute('name')); + + return $writer->getDocument('webhooks', 'hook'); + }); + + $this->assertSame('renamed', $read->getAttribute('name'), 'A transaction must read the documents its batch write changed'); + } + + public function testATransactionReadsTheCollectionDefinitionItChanged(): void + { + [$writer, $reader] = $this->createSharedMemoryDatabases(); + + $read = $writer->withTransaction(function () use ($writer, $reader): Document { + $writer->updateCollection('webhooks', new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: false)); + $this->assertTrue($reader->getCollection('webhooks')->getAttribute('documentSecurity')); + + return $writer->getCollection('webhooks'); + }); + + $this->assertFalse($read->getAttribute('documentSecurity'), 'A transaction must read the collection definition its schema change wrote'); + } + + public function testReadsInsideATransactionFillNothing(): void + { + $fills = 0; + $database = $this->createDatabase(new CountingMemory(), $this->fillCountingCache(function () use (&$fills): void { + $fills++; + })); + $database->createDocument('webhooks', $this->hook('hook')); + $database->createDocument('webhooks', $this->hook('cold')); + $database->getDocument('webhooks', 'hook'); + + $before = $fills; + $database->withTransaction(function () use ($database): void { + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame('hook', $database->getDocument('webhooks', 'cold')->getAttribute('name')); + $this->assertTrue($database->getDocument('webhooks', 'absent')->isEmpty()); + $this->assertNull($database->findCollection('absent')); + }); + + $this->assertSame($before, $fills, 'A read inside a transaction must not save to the cache'); + } + + public function testANestedRelationshipCreateReadsNoCollectionDefinition(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $database->addHook(new Relationships()); + $database->createCollection(Collection::create(id: 'libraries', attributes: [ + Attribute::string(key: 'name'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $database->createCollection(Collection::create(id: 'books', attributes: [ + Attribute::string(key: 'title'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $database->createRelationship('libraries', Relationship::oneToMany( + relatedCollection: 'books', + twoWay: true, + key: 'books', + twoWayKey: 'library', + )); + $database->createDocument('libraries', $this->library('warm', 'warm')); + + $adapter->reset(); + $database->createDocument('libraries', $this->library('library', 'book')); + + $this->assertSame(0, $adapter->metadataReads, 'A create with 3 nested documents must read no collection definition on a warm cache (7.3.12: 0 reads)'); + $books = $database->getDocument('libraries', 'library')->getAttribute('books'); + $this->assertIsArray($books); + $this->assertCount(3, $books); + } + + public function testAMissingCollectionCostsOneMetadataRead(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + + $adapter->reset(); + $this->assertNull($database->findCollection('missing')); + $this->assertSame(1, $adapter->metadataReads, 'A missing collection must cost one read of its definition (7.3.12: 1 read)'); + + $this->assertNull($database->findCollection('missing')); + $this->assertSame(1, $adapter->metadataReads, 'A missing collection must be served from the cache once read'); + } + + public function testCreateCollectionChecksItsIdWithOneMetadataRead(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + + $adapter->reset(); + $database->createCollection(Collection::create(id: 'logs', attributes: [ + Attribute::string(key: 'message'), + ], permissions: [Permission::read(Role::any())])); + + $this->assertSame(1, $adapter->metadataReads, 'createCollection() must check that its id is free with one read of the definition (7.3.12: 1 read)'); + } + + public function testCreateCollectionAfterAProbeReadsNoDefinition(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $this->assertNull($database->findCollection('logs')); + $database->createCollection(Collection::create(id: 'audits', permissions: [Permission::read(Role::any())])); + + $adapter->reset(); + $database->createCollection(Collection::create(id: 'logs', permissions: [Permission::read(Role::any())])); + + $this->assertSame(0, $adapter->metadataReads, 'A cached miss for the new id must survive other definitions being written (7.3.12: 0 reads)'); + } + + private function hook(string $id): Document + { + return new Document([ + '$id' => $id, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'hook', + ]); + } + + private function library(string $id, string $prefix): Document + { + $books = []; + for ($index = 0; $index < 3; $index++) { + $books[] = [ + '$id' => $prefix.$index, + '$permissions' => [Permission::read(Role::any())], + 'title' => $prefix.$index, + ]; + } + + return new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'name' => $id, + 'books' => $books, + ]); + } + + /** + * @param Closure(): void $onFill Called on every save to the cache + */ + private function fillCountingCache(Closure $onFill): CacheAdapter&Leasable + { + return new class ($onFill) implements CacheAdapter, Leasable { + private RedisLeasableCache $cache; + + /** + * @param Closure(): void $onFill + */ + public function __construct(private readonly Closure $onFill) + { + $this->cache = new RedisLeasableCache(); + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->cache->load($key, $ttl, $hash); + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + ($this->onFill)(); + + return $this->cache->save($key, $data, $hash); + } + + #[\Override] + public function touch(string $key, string $hash = ''): bool + { + return $this->cache->touch($key, $hash); + } + + /** @return array */ + #[\Override] + public function list(string $key): array + { + return $this->cache->list($key); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + return $this->cache->purge($key, $hash); + } + + #[\Override] + public function flush(): bool + { + return $this->cache->flush(); + } + + #[\Override] + public function ping(): bool + { + return true; + } + + #[\Override] + public function getSize(): int + { + return $this->cache->getSize(); + } + + #[\Override] + public function getName(?string $key = null): string + { + return 'fill-counting'; + } + + #[\Override] + public function getGeneration(string $key): string + { + return $this->cache->getGeneration($key); + } + + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + ($this->onFill)(); + + return $this->cache->saveWithLease($key, $data, $hash, $generation); + } + }; + } + + /** + * @return array{Database, Database} + */ + private function createSharedMemoryDatabases(): array + { + $cache = new RedisLeasableCache(); + $namespace = 'transaction_cache_'.\uniqid(); + $writer = $this->createDatabase(new CountingMemory(), $cache, $namespace); + $reader = $this->createDatabase(new CountingMemory(), $cache, $namespace); + foreach ([$writer, $reader] as $database) { + $database->createDocument('webhooks', $this->hook('hook')); + } + + return [$writer, $reader]; + } + + /** + * @return array{Database, Database, string} + */ + private function createSharedSQLiteDatabases(): array + { + $path = \tempnam(\sys_get_temp_dir(), 'transaction-cache-'); + if ($path === false) { + throw new \RuntimeException('Failed to create SQLite test database'); + } + + $attributes = [ + \PDO::ATTR_PERSISTENT => false, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => true, + \PDO::ATTR_STRINGIFY_FETCHES => true, + ]; + $writerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $readerConnection = new \PDO('sqlite:'.$path, null, null, $attributes); + $writerConnection->exec('PRAGMA journal_mode = WAL'); + $writerConnection->exec('PRAGMA busy_timeout = 1000'); + $readerConnection->exec('PRAGMA busy_timeout = 1000'); + + $cache = new Cache(new RedisLeasableCache()); + $writer = new Database(new SQLite($writerConnection), $cache); + $reader = new Database(new SQLite($readerConnection), $cache); + $namespace = 'transaction_cache_'.\uniqid(); + foreach ([$writer, $reader] as $database) { + $this->configure($database, $namespace); + } + + $writer->create(); + $writer->createCollection(Collection::create(id: 'users', attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $writer->createDocument('users', new Document([ + '$id' => 'user', + 'name' => 'original', + ])); + + return [$writer, $reader, $path]; + } + + private function removeSQLiteFiles(string $path): void + { + foreach ([$path, $path.'-wal', $path.'-shm'] as $file) { + if (\is_file($file)) { + \unlink($file); + } + } + } + + private function createDatabase(DatabaseAdapter $adapter, CacheAdapter $cache, ?string $namespace = null): Database + { + $database = $this->configure(new Database($adapter, new Cache($cache)), $namespace ?? 'transaction_cache_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: 'webhooks', attributes: [ + Attribute::string(key: 'name'), + Attribute::integer(key: 'count', default: 10), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + + return $database; + } + + private function configure(Database $database, string $namespace): Database + { + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace); + $database->getAuthorization()->addRole(Role::any()->toString()); + + return $database; + } +} diff --git a/tests/unit/Documents/TransactionDefinitionReadTest.php b/tests/unit/Documents/TransactionDefinitionReadTest.php new file mode 100644 index 0000000000..df627e605a --- /dev/null +++ b/tests/unit/Documents/TransactionDefinitionReadTest.php @@ -0,0 +1,528 @@ +database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + $reads = $database->withTransaction(function () use ($database, $adapter): int { + $adapter->reset(); + $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])); + $database->getDocument(self::COLLECTION, 'ada'); + $database->getCollection(self::COLLECTION); + + return $adapter->metadataReads; + }); + + $this->assertSame(1, $reads); + $this->assertSame(2, $database->getDocument(self::COLLECTION, 'ada')->getAttribute('balance')); + } + + public function testADefinitionATransactionReadIsCachedForTheNextTransaction(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $database->withTransaction(fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2]))); + + $reads = $database->withTransaction(function () use ($database, $adapter): int { + $adapter->reset(); + $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 3])); + $database->getDocument(self::COLLECTION, 'ada'); + + return $adapter->metadataReads; + }); + + $this->assertSame(0, $reads); + $this->assertSame(3, $database->getDocument(self::COLLECTION, 'ada')->getAttribute('balance')); + } + + public function testADefinitionChangedAfterATransactionReadItIsReadAfresh(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $database->withTransaction(fn (): Collection => $database->getCollection(self::COLLECTION)); + $this->assertSame(0, $this->transactionReads($database, $adapter, fn (): Collection => $database->getCollection(self::COLLECTION)), 'the transaction did not cache its definition'); + + $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); + + $collection = $database->withTransaction(fn (): Collection => $database->getCollection(self::COLLECTION)); + + $this->assertTrue($collection->getAttribute('documentSecurity')); + } + + public function testATransactionWithoutAUsableCacheReadsItsDefinitionOnce(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter, new Cache(new NoCache())); + + $this->assertSame(1, $this->transactionReads($database, $adapter, fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])))); + $this->assertSame(1, $this->transactionReads($database, $adapter, fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 3])))); + + $adapter->reset(); + $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 4])); + $database->getCollection(self::COLLECTION); + + $this->assertSame(2, $adapter->metadataReads, 'Outside a transaction every call reads the definition again'); + $this->assertSame(4, $database->getDocument(self::COLLECTION, 'ada')->getAttribute('balance')); + } + + public function testACacheThatFailsDefinitionReadsAddsNoDefinitionRead(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter, new Cache(new class () extends MemoryCache { + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + if (\str_contains($key, ':'.Database::METADATA.':')) { + throw new RuntimeException('cache unreachable'); + } + + return parent::load($key, $ttl, $hash); + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if (\str_contains($key, ':'.Database::METADATA.':')) { + throw new RuntimeException('cache unreachable'); + } + + return parent::save($key, $data, $hash); + } + })); + + $reads = $this->transactionReads($database, $adapter, fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2]))); + + $this->assertSame(1, $reads); + $this->assertSame(2, $database->getDocument(self::COLLECTION, 'ada')->getAttribute('balance')); + } + + public function testACacheThatRefusedADefinitionIsFilledByTransactionsOnceItAcceptsAgain(): void + { + $adapter = new CountingMemory(); + $cache = new class () extends MemoryCache { + public bool $refusing = false; + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ($this->refusing && \str_contains($key, ':'.Database::METADATA.':')) { + return false; + } + + return parent::save($key, $data, $hash); + } + }; + $database = $this->database($adapter, new Cache($cache)); + $update = fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])); + + $cache->refusing = true; + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $this->assertSame(2, $this->transactionReads($database, $adapter, $update)); + $this->assertSame(1, $this->transactionReads($database, $adapter, $update)); + $this->assertSame(1, $this->transactionReads($database, $adapter, $update)); + + $cache->refusing = false; + $database->getCollection(self::COLLECTION); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $this->assertSame(2, $this->transactionReads($database, $adapter, $update)); + $this->assertSame(0, $this->transactionReads($database, $adapter, $update), 'the transaction did not cache its definition'); + } + + public function testAFillRefusedInItsGraceWindowLeavesTransactionsFillingOtherDefinitions(): void + { + $adapter = new CountingMemory(); + $cache = new RedisLeasableCache(); + $database = $this->database($adapter, new Cache($cache)); + $this->createLedgers($database); + $cache->setLeaseGraceWindow(60_000); + + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $database->getCollection(self::COLLECTION); + $database->purgeCachedDocument(Database::METADATA, self::LEDGERS); + $cache->expireTombstones(); + + $read = fn (): Collection => $database->getCollection(self::LEDGERS); + $this->assertSame(2, $this->transactionReads($database, $adapter, $read), 'the refused fill stopped the refills'); + $this->assertSame(0, $this->transactionReads($database, $adapter, $read), 'the transaction did not cache its definition'); + } + + public function testARefillRefusedByTheCommitsOwnPurgeLeavesLaterTransactionsFillingTheirs(): void + { + $adapter = new CountingMemory(); + $cache = new RedisLeasableCache(); + $database = $this->database($adapter, new Cache($cache)); + $this->createLedgers($database); + $cache->setLeaseGraceWindow(60_000); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $database->purgeCachedDocument(Database::METADATA, self::LEDGERS); + $cache->expireTombstones(); + + $database->withTransaction(fn (): int => $database->updateDocuments(self::COLLECTION, new Document(['balance' => 2]))); + $cache->expireTombstones(); + + $read = fn (): Collection => $database->getCollection(self::LEDGERS); + $this->assertSame(2, $this->transactionReads($database, $adapter, $read), 'the refill the commit\'s purge refused stopped the refills'); + $this->assertSame(0, $this->transactionReads($database, $adapter, $read), 'the transaction did not cache its definition'); + $this->assertSame(2, $database->getDocument(self::COLLECTION, 'ada')->getAttribute('balance')); + } + + public function testATransactionOnAReplicaReadPoolReadsItsDefinitionOnce(): void + { + $primary = new CountingMemory(); + $replica = new CountingMemory(); + foreach ([$primary, $replica] as $adapter) { + $this->database($adapter, new Cache(new NoCache()), 'replicated'); + } + $pool = new ReadWritePool($this->connections($primary), $this->connections($replica)); + $pool->setSticky(false); + $database = new Database($pool, new Cache(new RedisLeasableCache())); + $database->setDatabase('transactions')->setNamespace('replicated'); + $update = fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])); + + $database->withTransaction($update); + $primary->reset(); + $replica->reset(); + $database->withTransaction($update); + + $this->assertSame(1, $primary->metadataReads + $replica->metadataReads); + } + + public function testATransactionUnderAnotherTenantCachesThatTenantsDefinition(): void + { + $adapter = new CountingMemory(); + $database = new Database($adapter, new Cache(new RedisLeasableCache())); + $database->setDatabase('transactions')->setNamespace('transactions_'.\uniqid()); + $database->setSharedTables(true)->setTenant(1); + $database->create(); + foreach ([1, 2] as $tenant) { + $database->withTenant($tenant, function () use ($database): void { + $this->createAccounts($database); + $database->getCollection(self::COLLECTION); + }); + } + $database->withTenant(2, function () use ($database): void { + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + }); + $update = fn (): Document => $database->withTenant(2, fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2]))); + + $this->assertSame(2, $this->readsOf($adapter, fn (): Document => $database->withTransaction($update))); + $this->assertSame(0, $this->readsOf($adapter, fn (): Document => $database->withTransaction($update)), 'the transaction did not cache its tenant\'s definition'); + } + + public function testATransactionReadingARawDefinitionCachesTheRawDefinition(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $read = fn (): Document => $database->skipFilters(fn (): Document => $database->getDocument(Database::METADATA, self::COLLECTION)); + + $this->assertSame(2, $this->readsOf($adapter, fn (): Document => $database->withTransaction($read))); + $this->assertSame(0, $this->transactionReads($database, $adapter, $read), 'the transaction did not cache the raw definition'); + $this->assertIsString($database->withTransaction($read)->getAttribute('attributes')); + $this->assertIsArray($database->getDocument(Database::METADATA, self::COLLECTION)->getAttribute('attributes')); + } + + public function testATransactionWhoseInvalidationFailedLeavesItsDefinitionReadUncached(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $failure = new TypeError('purge listener failed'); + $database->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)); + + try { + $database->withTransaction(fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2]))); + $this->fail('the purge listener did not fail the transaction'); + } catch (TypeError $error) { + $this->assertSame($failure, $error); + } + + $adapter->reset(); + $database->getCollection(self::COLLECTION); + + $this->assertSame(1, $adapter->metadataReads); + } + + public function testANestedTransactionCachesItsDefinitionOnlyAfterTheOutermostCommit(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $adapter->reset(); + + $readsBeforeOuterCommit = $database->withTransaction(function () use ($database, $adapter): int { + $database->withTransaction(fn (): Document => $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2]))); + + return $adapter->metadataReads; + }); + + $this->assertSame(1, $readsBeforeOuterCommit); + $this->assertSame(2, $adapter->metadataReads); + $this->assertSame(0, $this->transactionReads($database, $adapter, fn (): Collection => $database->getCollection(self::COLLECTION))); + } + + public function testADefinitionChangedWhileItIsCachedAfterCommitIsNotCachedStale(): void + { + $adapter = new InterleavingMemory(); + $cache = new Cache(new RedisLeasableCache()); + $database = $this->database($adapter, $cache); + $writer = new Database($adapter, $cache); + $writer->setDatabase($database->getDatabase())->setNamespace($database->getNamespace()); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + $database->withTransaction(function () use ($database, $writer, $adapter): void { + $database->getCollection(self::COLLECTION); + $adapter->afterNextDefinitionRead(static function () use ($writer): void { + $writer->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); + }); + }); + + $read = fn (): Collection => $database->getCollection(self::COLLECTION); + $this->assertSame(2, $this->transactionReads($database, $adapter, $read), 'the stale definition was cached, or the lost lease stopped the refills'); + $this->assertSame(0, $this->transactionReads($database, $adapter, $read), 'the transaction did not cache its definition'); + $this->assertTrue($database->getCollection(self::COLLECTION)->getAttribute('documentSecurity')); + } + + public function testARolledBackTransactionLeavesItsDefinitionReadUncached(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + try { + $database->withTransaction(function () use ($database): void { + $database->getCollection(self::COLLECTION); + + throw new \DomainException('rolled back'); + }); + } catch (\DomainException) { + } + + $adapter->reset(); + $database->getCollection(self::COLLECTION); + + $this->assertSame(1, $adapter->metadataReads); + } + + public function testASchemaChangeInsideTheTransactionIsRead(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + [$before, $after] = $database->withTransaction(function () use ($database): array { + $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])); + $before = $database->getCollection(self::COLLECTION); + $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::users())], documentSecurity: true)); + + return [$before, $database->getCollection(self::COLLECTION)]; + }); + + $this->assertFalse($before->getAttribute('documentSecurity')); + $this->assertTrue($after->getAttribute('documentSecurity')); + $this->assertSame(['read("users")'], $after->getPermissions()); + } + + public function testADefinitionReadInOneTransactionIsNotReusedByTheNext(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + $database->withTransaction(function () use ($database): void { + $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])); + $database->getCollection(self::COLLECTION); + }); + + $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + $collection = $database->withTransaction(function () use ($database): Collection { + $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 3])); + + return $database->getCollection(self::COLLECTION); + }); + + $this->assertTrue($collection->getAttribute('documentSecurity')); + } + + public function testChangingADefinitionReadInATransactionDoesNotChangeTheNextRead(): void + { + $adapter = new CountingMemory(); + $database = $this->database($adapter); + + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + $second = $database->withTransaction(function () use ($database): Collection { + $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])); + $first = $database->getCollection(self::COLLECTION); + $first->setAttribute('name', 'changed'); + /** @var list $attributes */ + $attributes = $first->getAttribute('attributes'); + $attributes[0]->setAttribute('size', 1); + + return $database->getCollection(self::COLLECTION); + }); + + $this->assertSame(self::COLLECTION, $second->getAttribute('name')); + $this->assertSame(0, $second->attributes()[0]->toDocument()->getAttribute('size')); + } + + /** + * @return array + */ + public static function adapters(): array + { + return [ + 'memory' => [new CountingMemory()], + 'sqlite' => [new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + #[DataProvider('adapters')] + public function testARawDefinitionReadInATransactionLeavesLaterWritesWorking(Adapter $adapter): void + { + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + [$raw, $created] = $database->withTransaction(fn (): array => [ + $database->skipFilters(fn (): Document => $database->getDocument(Database::METADATA, self::COLLECTION)), + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'grace', 'balance' => 5])), + ]); + + $this->assertIsString($raw->getAttribute('attributes')); + $this->assertSame(5, $created->getAttribute('balance')); + $this->assertSame(5, $database->getDocument(self::COLLECTION, 'grace')->getAttribute('balance')); + } + + #[DataProvider('adapters')] + public function testAFilteredDefinitionReadInATransactionLeavesLaterRawReadsRaw(Adapter $adapter): void + { + $database = $this->database($adapter); + $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); + + [$collection, $raw] = $database->withTransaction(fn (): array => [ + $database->getCollection(self::COLLECTION), + $database->skipFilters(fn (): Document => $database->getDocument(Database::METADATA, self::COLLECTION)), + ]); + + $this->assertSame('balance', $collection->attributes()[0]->key); + $encoded = $raw->getAttribute('attributes'); + $this->assertIsString($encoded); + $attributes = \json_decode($encoded, true); + $this->assertIsArray($attributes); + $this->assertIsArray($attributes[0]); + $this->assertSame('balance', $attributes[0]['key']); + } + + /** + * @param callable(): mixed $callback + */ + private function transactionReads(Database $database, CountingMemory $adapter, callable $callback): int + { + return $this->readsOf($adapter, fn (): mixed => $database->withTransaction($callback)); + } + + /** + * @param callable(): mixed $callback + */ + private function readsOf(CountingMemory $adapter, callable $callback): int + { + $adapter->reset(); + $callback(); + + return $adapter->metadataReads; + } + + /** + * @return UtopiaPool + */ + private function connections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + return $connections; + } + + private function database(Adapter $adapter, ?Cache $cache = null, ?string $namespace = null): Database + { + $database = new Database($adapter, $cache ?? new Cache(new RedisLeasableCache())); + $database->setDatabase('transactions')->setNamespace($namespace ?? 'transactions_'.\uniqid()); + $database->create(); + $this->createAccounts($database); + $database->getDocument(self::COLLECTION, 'ada'); + + return $database; + } + + private function createLedgers(Database $database): void + { + $database->createCollection(Collection::create( + id: self::LEDGERS, + attributes: [Attribute::integer(key: 'balance')], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + )); + } + + private function createAccounts(Database $database): void + { + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::integer(key: 'balance')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'ada', 'balance' => 1])); + } +} diff --git a/tests/unit/Documents/TypedReadersTest.php b/tests/unit/Documents/TypedReadersTest.php new file mode 100644 index 0000000000..b3b2d87299 --- /dev/null +++ b/tests/unit/Documents/TypedReadersTest.php @@ -0,0 +1,206 @@ +database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune', 'pages' => 412])); + + $document = $database->getDocument(self::COLLECTION, 'dune'); + + $this->assertSame('Dune', $document->getAttribute('title')); + $this->assertSame('paperback', $document->getAttribute('format')); + $this->assertSame(412, $document->getAttribute('pages')); + $this->assertFalse($document->getAttribute('signed')); + } + + public function testCastingFollowsTheDeclaredTypes(): void + { + $database = $this->database(); + $collection = $database->getCollection(self::COLLECTION); + + $document = $database->casting($collection, new Document(['pages' => '12', 'signed' => 1, 'tags' => '["a","b"]', 'title' => 7])); + + $this->assertSame(12, $document->getAttribute('pages')); + $this->assertTrue($document->getAttribute('signed')); + $this->assertSame(['a', 'b'], $document->getAttribute('tags')); + $this->assertSame(7, $document->getAttribute('title')); + } + + public function testAPlainCollectionDocumentDecodesLikeTheCollection(): void + { + $database = $this->database(); + $collection = $database->getCollection(self::COLLECTION); + $stored = new Document(['title' => 'Dune', 'tags' => ['a'], 'unknown' => 'kept']); + + $fromCollection = $database->decode($collection, clone $stored); + $fromDocument = $database->decode($collection->toDocument(), clone $stored); + + $this->assertSame($fromCollection->getArrayCopy(), $fromDocument->getArrayCopy()); + } + + public function testFindFiltersAndOrdersByDeclaredAttributes(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'a', 'title' => 'A', 'pages' => 30])); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'b', 'title' => 'B', 'pages' => 10])); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'c', 'title' => 'C', 'pages' => 20])); + + $found = $database->find(self::COLLECTION, [Query::greaterThan('pages', 15), Query::orderAsc('pages')]); + + $this->assertSame(['c', 'a'], \array_map(static fn (Document $document): string => $document->getId(), $found)); + $this->assertSame(2, $database->count(self::COLLECTION, [Query::greaterThan('pages', 15)])); + $this->assertSame(60, $database->sum(self::COLLECTION, 'pages')); + } + + public function testAFilterOnAnUndeclaredAttributeIsRefused(): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Attribute not found in schema: missing'); + + $database->find(self::COLLECTION, [Query::equal('missing', ['x'])]); + } + + public function testAFilterValueOfTheWrongTypeIsRefused(): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Query value is invalid for attribute "pages"'); + + $database->find(self::COLLECTION, [Query::equal('pages', ['many'])]); + } + + public function testSelectingAnUndeclaredAttributeIsRefused(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune'])); + + $this->expectException(QueryException::class); + + $database->getDocument(self::COLLECTION, 'dune', [Query::select(['title', 'missing'])]); + } + + public function testSelectingDeclaredAttributesReturnsOnlyThem(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune', 'pages' => 412])); + + $document = $database->getDocument(self::COLLECTION, 'dune', [Query::select(['title'])]); + + $this->assertSame('Dune', $document->getAttribute('title')); + $this->assertFalse($document->offsetExists('pages')); + } + + public function testIncreaseChangesADeclaredInteger(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune', 'pages' => 412])); + + $document = $database->increaseDocumentAttribute(self::COLLECTION, 'dune', 'pages', 8); + + $this->assertSame(420, $document->getAttribute('pages')); + } + + public function testIncreaseOfAStringAttributeIsRefused(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune'])); + + $this->expectException(TypeException::class); + + $database->increaseDocumentAttribute(self::COLLECTION, 'dune', 'title'); + } + + public function testDecreaseOfAnUndeclaredAttributeIsRefused(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune'])); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Attribute not found'); + + $database->decreaseDocumentAttribute(self::COLLECTION, 'dune', 'missing'); + } + + public function testAJoinOfAMissingCollectionIsAQueryError(): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage("Joined collection 'missing' not found"); + + $database->count(self::COLLECTION, [Query::join('missing', 'm', [Query::equal('m.title', ['x'])])]); + } + + public function testUpdatingDocumentsEncodesTheDeclaredFilters(): void + { + $database = $this->database(); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'dune', 'title' => 'Dune', 'tags' => ['a']])); + + $updated = $database->updateDocuments(self::COLLECTION, new Document(['tags' => ['b', 'c']])); + + $this->assertSame(1, $updated); + $this->assertSame(['b', 'c'], $database->getDocument(self::COLLECTION, 'dune')->getAttribute('tags')); + } + + private function database(): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('typed_readers') + ->setNamespace('typed_readers_'.\uniqid()); + $database->create(); + $database->addHook(new Permissions()); + + $database->createCollection(Collection::create( + self::COLLECTION, + attributes: [ + Attribute::string('title', 64), + Attribute::string('format', 32, default: 'paperback'), + Attribute::integer('pages'), + Attribute::boolean('signed', default: false), + Attribute::string('tags', 32, array: true), + ], + indexes: [Index::key('by_pages', ['pages'])], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + + return $database; + } +} diff --git a/tests/unit/Documents/UpdateDocumentLogicTest.php b/tests/unit/Documents/UpdateDocumentLogicTest.php new file mode 100644 index 0000000000..242c036908 --- /dev/null +++ b/tests/unit/Documents/UpdateDocumentLogicTest.php @@ -0,0 +1,345 @@ +getAuthorization()->addRole(Role::any()->toString()); + + return $db; + } + + private function makeAdapter(): Adapter&Stub + { + $adapter = self::createStub(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $adapter->method('startTransaction')->willReturn(true); + $adapter->method('commitTransaction')->willReturn(true); + $adapter->method('rollbackTransaction')->willReturn(true); + $adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $adapter->method('updateDocument')->willReturnArgument(2); + + return $adapter; + } + + /** + * @param array $attributes + * @param array $collectionPermissions + */ + private function setupCollectionAndDocument( + Adapter&Stub $adapter, + string $collectionId, + Document $existingDoc, + array $attributes = [], + array $collectionPermissions = [] + ): void { + if (empty($collectionPermissions)) { + $collectionPermissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + $collection = new Document([ + '$id' => $collectionId, + '$collection' => Database::METADATA, + '$permissions' => $collectionPermissions, + 'name' => $collectionId, + 'attributes' => $attributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collectionId, $collection, $existingDoc) { + if ($col->getId() === Database::METADATA && $docId === $collectionId) { + return $collection; + } + if ($col->getId() === $collectionId && $docId === $existingDoc->getId()) { + return $existingDoc; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + + return new Document(); + } + ); + } + + public function testUpdateDocumentSetsUpdatedAt(): void + { + $adapter = $this->makeAdapter(); + $existing = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'old', + ]); + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollectionAndDocument($adapter, 'testCol', $existing, $attributes); + $db = $this->buildDatabase($adapter); + + $updated = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'new', + ]); + + $result = $db->updateDocument('testCol', 'doc1', $updated); + $this->assertNotSame('2024-01-01T00:00:00.000+00:00', $result->getUpdatedAt()); + } + + public function testUpdateDocumentChecksUpdatePermission(): void + { + $adapter = $this->makeAdapter(); + $existing = new Document([ + '$id' => 'doc1', + '$collection' => 'restricted', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::update(Role::user('admin'))], + 'name' => 'old', + ]); + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollectionAndDocument($adapter, 'restricted', $existing, $attributes, [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::user('admin')), + ]); + + $db = new Database($adapter, new Cache(new None())); + + $this->expectException(AuthorizationException::class); + $db->updateDocument('restricted', 'doc1', new Document([ + '$id' => 'doc1', + '$collection' => 'restricted', + 'name' => 'new', + ])); + } + + public function testUpdateDocumentValidatesStructure(): void + { + $adapter = $this->makeAdapter(); + $attributes = [ + new Document(['$id' => 'title', 'key' => 'title', 'type' => 'string', 'size' => 5, 'required' => true, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + + $existing = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'ok', + ]); + + $this->setupCollectionAndDocument($adapter, 'testCol', $existing, $attributes); + $db = $this->buildDatabase($adapter); + $db->setValidation(true); + + $updated = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'this string is way too long for size 5', + ]); + + $this->expectException(StructureException::class); + $db->updateDocument('testCol', 'doc1', $updated); + } + + public function testUpdateDocumentDetectsNoChangesAndPreservesUpdatedAt(): void + { + $adapter = $this->makeAdapter(); + $existing = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'same', + ]); + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollectionAndDocument($adapter, 'testCol', $existing, $attributes); + $db = $this->buildDatabase($adapter); + + $noChange = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'same', + ]); + + $result = $db->updateDocument('testCol', 'doc1', $noChange); + $this->assertSame('2024-01-01T00:00:00.000+00:00', $result->getUpdatedAt()); + } + + public function testUpdateDocumentRequiresId(): void + { + $adapter = $this->makeAdapter(); + $adapter->method('getDocument')->willReturn(new Document()); + $db = $this->buildDatabase($adapter); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Must define $id attribute'); + $db->updateDocument('testCol', '', new Document([])); + } + + public function testUpdateDocumentReturnsEmptyForMissingDocument(): void + { + $adapter = $this->makeAdapter(); + $collection = new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'testCol', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'testCol') { + return $collection; + } + + return new Document(); + } + ); + + $db = $this->buildDatabase($adapter); + + $result = $db->updateDocument('testCol', 'nonexistent', new Document([ + '$id' => 'nonexistent', + ])); + $this->assertTrue($result->isEmpty()); + } + + public function testUpdateDocumentPreservesCreatedAt(): void + { + $adapter = $this->makeAdapter(); + $existing = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$createdAt' => '2020-06-15T12:00:00.000+00:00', + '$updatedAt' => '2020-06-15T12:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'old', + ]); + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollectionAndDocument($adapter, 'testCol', $existing, $attributes); + $db = $this->buildDatabase($adapter); + + $updated = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'new', + ]); + + $result = $db->updateDocument('testCol', 'doc1', $updated); + $this->assertSame('2020-06-15T12:00:00.000+00:00', $result->getCreatedAt()); + } + + public function testUpdateDocumentPermissionChangeIsHandled(): void + { + $adapter = $this->makeAdapter(); + $existing = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'same', + ]); + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollectionAndDocument($adapter, 'testCol', $existing, $attributes); + $db = $this->buildDatabase($adapter); + + $updated = new Document([ + '$id' => 'doc1', + '$collection' => 'testCol', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => 'same', + ]); + + $result = $db->updateDocument('testCol', 'doc1', $updated); + $this->assertNotEmpty($result->getId()); + } +} diff --git a/tests/unit/Documents/UpdateDocumentsCastingTest.php b/tests/unit/Documents/UpdateDocumentsCastingTest.php new file mode 100644 index 0000000000..de3b6d23d9 --- /dev/null +++ b/tests/unit/Documents/UpdateDocumentsCastingTest.php @@ -0,0 +1,107 @@ + */ + private array $casted = []; + + /** @var array */ + public array $receivedCastedUpdates = []; + + /** @var array */ + public array $receivedUpdatedAtTypes = []; + + /** @var array */ + public array $receivedUpdatedAtValues = []; + + /** @var array */ + public array $receivedUpdateIds = []; + + #[\Override] + public function castBefore(Document $collection, Document $document): Document + { + $this->casted[\spl_object_id($document)] = true; + + $value = $document->getAttribute('value'); + if ($value instanceof Operator) { + $value->setValues([2]); + if ($document->getId() === '') { + $document->setAttribute('$updatedAt', '2000-01-01 00:00:00.000'); + } + } + + return $document; + } + + #[\Override] + public function castAfter(Document $collection, array $documents): array + { + return $documents; + } + + #[\Override] + public function castDatetime(string $value): mixed + { + return DateTime::setTimezone($value); + } + + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int + { + $this->receivedCastedUpdates[] = isset($this->casted[\spl_object_id($updates)]); + $this->receivedUpdatedAtTypes[] = \get_debug_type($updates->getUpdatedAt()); + $this->receivedUpdatedAtValues[] = $updates->getUpdatedAt(); + $this->receivedUpdateIds[] = \spl_object_id($updates); + + return parent::updateDocuments($collection, $updates, $documents, $skipPermissions); + } + }; + $database = new Database($adapter, new Cache(new None())); + $database->getAuthorization()->disable(); + $database->setNamespace('casting'); + $this->assertTrue($database->create()); + $database->createCollection(Collection::create(id: 'counters', attributes: [ + Attribute::integer(key: 'value'), + ])); + $database->createDocuments('counters', [ + new Document(['$id' => 'first', 'value' => 1]), + new Document(['$id' => 'second', 'value' => 2]), + ]); + + $operator = Operator::increment('1'); + $modified = $database->updateDocuments( + 'counters', + new Document(['value' => $operator]), + batchSize: 1, + ); + + $this->assertSame(2, $modified); + $this->assertSame([true, true], $adapter->receivedCastedUpdates); + $this->assertSame(['1'], $operator->getValues()); + $this->assertSame(['string', 'string'], $adapter->receivedUpdatedAtTypes); + $this->assertSame( + ['2000-01-01 00:00:00.000', '2000-01-01 00:00:00.000'], + $adapter->receivedUpdatedAtValues, + ); + $this->assertCount(1, \array_unique($adapter->receivedUpdateIds)); + $this->assertSame(3, $database->getDocument('counters', 'first')->getAttribute('value')); + $this->assertSame(4, $database->getDocument('counters', 'second')->getAttribute('value')); + } +} diff --git a/tests/unit/Documents/UpdateDocumentsOperatorDecodeTest.php b/tests/unit/Documents/UpdateDocumentsOperatorDecodeTest.php new file mode 100644 index 0000000000..f26371495f --- /dev/null +++ b/tests/unit/Documents/UpdateDocumentsOperatorDecodeTest.php @@ -0,0 +1,135 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None()), [ + new Callback( + 'wrapped', + static fn (mixed $value): ?string => $value === null ? null : \json_encode(['value' => $value], JSON_THROW_ON_ERROR), + static function (mixed $value): mixed { + if ($value === null) { + return null; + } + + $decoded = \is_string($value) ? \json_decode($value, true) : null; + if (! \is_array($decoded) || ! \array_key_exists('value', $decoded)) { + throw new RuntimeException('Decoded a value that was never encoded: '.\var_export($value, true)); + } + + return $decoded['value']; + }, + ), + ]); + $this->database + ->setDatabase('operator_decode') + ->setNamespace('operator_decode_'.\uniqid()); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer('counter', required: false), + Attribute::string('secret', size: 128, required: false, filters: ['wrapped']), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + $this->database->createDocuments(self::COLLECTION, [ + new Document(['$id' => 'first', 'counter' => 1, 'secret' => 'alpha']), + new Document(['$id' => 'second', 'counter' => 10, 'secret' => 'beta']), + ]); + } + + public function testOperatorUpdateHandsEachDocumentToOnNextDecodedOnce(): void + { + $updated = []; + + $modified = $this->database->updateDocuments( + self::COLLECTION, + new Document(['counter' => Operator::increment(1)]), + [Query::orderAsc('counter')], + onNext: function (Document $document) use (&$updated): void { + $updated[$document->getId()] = [$document->getAttribute('counter'), $document->getAttribute('secret')]; + }, + ); + + $this->assertSame(2, $modified); + $this->assertSame([ + 'first' => [2, 'alpha'], + 'second' => [11, 'beta'], + ], $updated); + } + + public function testOperatorUpdateWithSelectionsDecodesTheSelectedAttributesOnce(): void + { + $updated = []; + + $this->database->updateDocuments( + self::COLLECTION, + new Document(['counter' => Operator::increment(5)]), + [Query::select(['counter', 'secret']), Query::equal('$id', ['first'])], + onNext: function (Document $document) use (&$updated): void { + $updated[] = [$document->getAttribute('counter'), $document->getAttribute('secret')]; + }, + ); + + $this->assertSame([[6, 'alpha']], $updated); + } + + public function testPlainUpdateStillDecodesTheWrittenDocuments(): void + { + $updated = []; + + $this->database->updateDocuments( + self::COLLECTION, + new Document(['counter' => 7]), + [Query::orderAsc('counter')], + onNext: function (Document $document) use (&$updated): void { + $updated[$document->getId()] = [$document->getAttribute('counter'), $document->getAttribute('secret')]; + }, + ); + + $this->assertSame([ + 'first' => [7, 'alpha'], + 'second' => [7, 'beta'], + ], $updated); + } + + public function testOperatorUpdatePersistsTheEncodedValueUntouched(): void + { + $this->database->updateDocuments(self::COLLECTION, new Document(['counter' => Operator::increment(1)])); + + $document = $this->database->getDocument(self::COLLECTION, 'first'); + + $this->assertSame(2, $document->getAttribute('counter')); + $this->assertSame('alpha', $document->getAttribute('secret')); + } +} diff --git a/tests/unit/Documents/WithCacheTest.php b/tests/unit/Documents/WithCacheTest.php new file mode 100644 index 0000000000..a9bc9de10f --- /dev/null +++ b/tests/unit/Documents/WithCacheTest.php @@ -0,0 +1,247 @@ +addRole(Role::user('reader')->toString()); + $database = $this->database(new Memory(), new Cache(new MemoryCache()), $authorization, [Permission::read(Role::user('reader'))]); + $database->getAuthorization()->skip(fn (): Document => $database->createDocument(self::COLLECTION, new Document([Document::ID => 'q3', 'title' => 'Q3']))); + + $this->assertSame('Q3', $this->cached($database, fn (): Document => $database->getDocument(self::COLLECTION, 'q3'))->getAttribute('title')); + $this->assertSame('Q3', $this->cached($database, fn (): Document => $database->getDocument(self::COLLECTION, 'q3'))->getAttribute('title')); + $this->assertSame(1, $this->calls); + + $authorization->removeRole(Role::user('reader')->toString()); + $this->expectException(AuthorizationException::class); + $this->cached($database, fn (): Document => $database->getDocument(self::COLLECTION, 'q3')); + } + + public function testACachedDocumentTheCallerCannotReadIsRecomputed(): void + { + $authorization = new Authorization(); + $authorization->addRole(Role::user('owner')->toString()); + $database = $this->database(new Memory(), new Cache(new MemoryCache()), $authorization, [], documentSecurity: true); + $database->getAuthorization()->skip(fn (): Document => $database->createDocument(self::COLLECTION, new Document([ + Document::ID => 'q3', + Document::PERMISSIONS => [Permission::read(Role::user('owner'))], + 'title' => 'Q3', + ]))); + + $this->assertSame('Q3', $this->cached($database, fn (): Document => $database->getDocument(self::COLLECTION, 'q3'))->getAttribute('title')); + $authorization->removeRole(Role::user('owner')->toString()); + $authorization->addRole(Role::user('stranger')->toString()); + + $this->assertTrue($this->cached($database, fn (): Document => $database->getDocument(self::COLLECTION, 'q3'))->isEmpty()); + $this->assertSame(2, $this->calls, 'the stranger\'s read runs the callback instead of serving the owner\'s copy'); + } + + public function testACachedValueOfADeletedCollectionIsRecomputed(): void + { + $database = $this->database(new Memory(), new Cache(new MemoryCache())); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'q3', 'title' => 'Q3'])); + + $this->cached($database, fn (): array => $database->find(self::COLLECTION)); + $database->deleteCollection(self::COLLECTION); + + $recomputed = \uniqid('recomputed', true); + $this->assertSame($recomputed, $this->cached($database, fn (): string => $recomputed)); + $this->assertSame(2, $this->calls); + } + + public function testACachedDocumentPastItsTimeToLiveIsRecomputed(): void + { + $adapter = new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::IndexTtl]; + } + }; + $database = $this->database($adapter, new Cache(new MemoryCache())); + $database->createIndex(self::COLLECTION, Index::ttl(key: 'expiry', attribute: 'publishedAt', ttl: 1)); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'q3', 'title' => 'Q3', 'publishedAt' => DateTime::now()])); + + $this->cached($database, fn (): array => $database->find(self::COLLECTION)); + $this->cached($database, fn (): array => $database->find(self::COLLECTION)); + $this->assertSame(1, $this->calls); + \sleep(2); + + $this->cached($database, fn (): array => $database->find(self::COLLECTION)); + $this->assertSame(2, $this->calls, 'a cached document past its time to live is not served'); + } + + /** + * @return array + */ + public static function cacheFailures(): array + { + return [ + 'the epoch cannot be read' => ['loadEpoch', 'Warning: Failed to load cache epoch: the cache refused loadEpoch'], + 'the epoch cannot be written' => ['saveEpoch', ''], + 'the epoch is not a string' => ['listEpoch', ''], + 'the value cannot be read' => ['loadValue', 'Warning: Failed to load cache value: the cache refused loadValue'], + 'a rejected value cannot be purged' => ['purge', 'Warning: Failed to purge rejected cache value: the cache refused purge'], + 'the generation cannot be read' => ['getGeneration', 'Warning: Failed to get cache generation: the cache refused getGeneration'], + 'the value cannot be written' => ['saveWithLease', 'Warning: Failed to save cache value: the cache refused saveWithLease'], + ]; + } + + #[DataProvider('cacheFailures')] + public function testACacheFailureFallsBackToTheCallback(string $failure, string $warning): void + { + $cache = $this->failingCache($failure); + $database = $this->database(new Memory(), new Cache($cache)); + if ($failure === 'listEpoch') { + $cache->save(self::KEY.'#epoch', ['not', 'an', 'epoch']); + } + if ($failure === 'purge') { + $cache->save(self::KEY.'#epoch', 'fixed'); + $cache->save(self::KEY.'#fixed:', 'not a cache entry'); + } + + $value = null; + $log = StderrCapture::during(function () use ($database, &$value): void { + $value = $this->cached($database, fn (): string => 'computed'); + }); + + $this->assertSame('computed', $value); + $this->assertSame(1, $this->calls); + if ($warning !== '') { + $this->assertStringContainsString($warning, $log); + } + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T + */ + private function cached(Database $database, callable $callback): mixed + { + return $database->withCache(self::KEY, function () use ($callback) { + $this->calls++; + + return $callback(); + }); + } + + private function failingCache(string $failure): MemoryCache + { + return new class ($failure) extends MemoryCache implements Leasable { + public function __construct(private readonly string $failure) + { + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $epoch = \str_ends_with($key, '#epoch'); + if (\str_starts_with($key, 'reports:summary') && (($epoch && $this->failure === 'loadEpoch') || (! $epoch && $this->failure === 'loadValue'))) { + throw new RuntimeException("the cache refused {$this->failure}"); + } + + return parent::load($key, $ttl, $hash); + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ($this->failure === 'saveEpoch' && $key === 'reports:summary#epoch') { + return false; + } + + return parent::save($key, $data, $hash); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->failure === 'purge' && \str_starts_with($key, 'reports:summary#')) { + throw new RuntimeException('the cache refused purge'); + } + + return parent::purge($key, $hash); + } + + #[\Override] + public function getGeneration(string $key): string + { + if ($this->failure === 'getGeneration' && \str_starts_with($key, 'reports:summary#')) { + throw new RuntimeException('the cache refused getGeneration'); + } + + return '0'; + } + + /** + * @param array|string $data + * @return bool|string|array + */ + #[\Override] + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if ($this->failure === 'saveWithLease' && \str_starts_with($key, 'reports:summary#')) { + throw new RuntimeException('the cache refused saveWithLease'); + } + + return parent::save($key, $data, $hash); + } + }; + } + + /** + * @param list|null $permissions + */ + private function database(Memory $adapter, Cache $cache, ?Authorization $authorization = null, ?array $permissions = null, bool $documentSecurity = false): Database + { + $database = new Database($adapter, $cache); + if ($authorization !== null) { + $database->setAuthorization($authorization); + } + $database->setDatabase('with_cache')->setNamespace('with_cache_'.\uniqid()); + $database->create(); + $database->getAuthorization()->skip(fn (): mixed => $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 32), Attribute::datetime(key: 'publishedAt')], + permissions: $permissions ?? [Permission::create(Role::any()), Permission::read(Role::any()), Permission::delete(Role::any())], + documentSecurity: $documentSecurity, + ))); + + return $database; + } +} diff --git a/tests/unit/DottedAttributeKeyTest.php b/tests/unit/DottedAttributeKeyTest.php new file mode 100644 index 0000000000..c961282d72 --- /dev/null +++ b/tests/unit/DottedAttributeKeyTest.php @@ -0,0 +1,194 @@ +database(); + $queries = [Query::equal('dots.name', ['v'])]; + + $this->assertSame(2, \count($database->find(self::PEOPLE, $queries))); + $this->assertSame(2, $database->count(self::PEOPLE, $queries)); + $this->assertSame(2, $database->count(self::PEOPLE, $queries, 10)); + $this->assertSame(1, $database->count(self::PEOPLE, $queries, 1)); + $this->assertSame(0, $database->count(self::PEOPLE, [Query::equal('dots.name', ['missing'])])); + } + + public function testCountReadsADottedKeyInsideALogicalGroup(): void + { + $database = $this->database(); + $queries = [Query::or([Query::equal('dots.name', ['w']), Query::greaterThan('dots.score', 2)])]; + + $this->assertSame(['b', 'c'], $this->ids($database->find(self::PEOPLE, $queries))); + $this->assertSame(2, $database->count(self::PEOPLE, $queries)); + } + + public function testCountWithAnOrderOnADottedKey(): void + { + $database = $this->database(); + + $this->assertSame(3, $database->count(self::PEOPLE, [ + Query::isNotNull('dots.name'), + Query::orderDesc('dots.score'), + ])); + } + + public function testSumFiltersOnADottedKey(): void + { + $database = $this->database(); + $queries = [Query::equal('dots.name', ['v'])]; + + $this->assertSame(5, $database->sum(self::PEOPLE, 'dots.score', $queries)); + $this->assertSame(5, $database->sum(self::PEOPLE, 'dots.score', $queries, 10)); + $this->assertSame(10, $database->sum(self::PEOPLE, 'dots.score')); + $this->assertSame(0, $database->sum(self::PEOPLE, 'dots.score', [Query::equal('dots.name', ['missing'])])); + } + + public function testExistsAndNotExistsReadADottedKey(): void + { + $database = $this->database(); + + $this->assertSame(['a', 'b', 'c'], $this->ids($database->find(self::PEOPLE, [Query::exists(['dots.name'])]))); + $this->assertSame(['d'], $this->ids($database->find(self::PEOPLE, [Query::notExists(['dots.name'])]))); + $this->assertSame(3, $database->count(self::PEOPLE, [Query::exists(['dots.name'])])); + $this->assertSame(1, $database->count(self::PEOPLE, [Query::notExists(['dots.name'])])); + $this->assertSame(10, $database->sum(self::PEOPLE, 'dots.score', [Query::exists(['dots.score'])])); + } + + public function testJoinAliasesStillQualifyAJoinedColumn(): void + { + $database = $this->database(); + $join = Query::join(self::ORDERS, 'ord', [Query::on('$id', 'personId')]); + + $this->assertSame(['a'], $this->ids($database->find(self::PEOPLE, [$join, Query::equal('dots.name', ['v'])]))); + $this->assertSame(1, $database->count(self::PEOPLE, [$join, Query::equal('dots.name', ['v'])])); + $this->assertSame(7, $database->sum(self::PEOPLE, 'ord.total', [$join, Query::equal('dots.name', ['v'])])); + $this->assertSame(1, $database->count(self::PEOPLE, [$join, Query::greaterThan('ord.total', 5)])); + $this->assertSame(0, $database->count(self::PEOPLE, [$join, Query::greaterThan('ord.total', 7)])); + } + + public function testExistsQualifiesAJoinedInternalAttribute(): void + { + $database = $this->database(); + $join = Query::join(self::ORDERS, 'ord', [Query::on('$id', 'personId')]); + $leftJoin = Query::leftJoin(self::ORDERS, 'ord', [Query::on('$id', 'personId')]); + + $this->assertSame(['a'], $this->ids($database->find(self::PEOPLE, [$join, Query::exists(['ord.$id'])]))); + $this->assertSame(['b', 'c', 'd'], $this->ids($database->find(self::PEOPLE, [$leftJoin, Query::notExists(['ord.$id'])]))); + $this->assertSame(3, $database->count(self::PEOPLE, [$leftJoin, Query::notExists(['ord.$createdAt'])])); + $this->assertSame(1, $database->count(self::PEOPLE, [$join, Query::exists(['dots.name'])])); + } + + public function testGroupsAndHavingReadADottedKey(): void + { + $database = $this->database(); + + $groups = $database->aggregate(self::PEOPLE, [ + Query::count('*', 'people'), + Query::sum('dots.score', 'score'), + Query::groupBy(['dots.name']), + Query::exists(['dots.name']), + Query::orderDesc('people'), + ]); + + $this->assertSame([[2, 5], [1, 5]], \array_map( + static fn (array $group): array => [$group['people'], $group['score']], + $groups, + )); + + $having = $database->aggregate(self::PEOPLE, [ + Query::count('*', 'people'), + Query::groupBy(['dots.name']), + Query::having([Query::greaterThan('people', 1)]), + ]); + + $this->assertCount(1, $having); + $this->assertSame(2, $having[0]['people']); + } + + public function testSearchReadsADottedKey(): void + { + $database = $this->database(); + $database->createIndex(self::PEOPLE, Index::fulltext(key: 'names', attributes: ['dots.name'])); + + $this->assertSame(['a', 'b'], $this->ids($database->find(self::PEOPLE, [Query::search('dots.name', 'v')]))); + $this->assertSame(2, $database->count(self::PEOPLE, [Query::search('dots.name', 'v')])); + $this->assertSame(1, $database->count(self::PEOPLE, [ + Query::join(self::ORDERS, 'ord', [Query::on('$id', 'personId')]), + Query::search('dots.name', 'v'), + ])); + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + $ids = \array_map(static fn (Document $document): string => $document->getId(), $documents); + \sort($ids); + + return $ids; + } + + private function database(): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database + ->setAuthorization($authorization) + ->setDatabase('dotted') + ->setNamespace('dotted_'.\uniqid()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: self::PEOPLE, + attributes: [ + Attribute::string(key: 'dots.name', size: 64), + Attribute::integer(key: 'dots.score'), + ], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: self::ORDERS, + attributes: [ + Attribute::string(key: 'personId', size: 64, required: true), + Attribute::integer(key: 'total', required: true), + ], + permissions: $permissions, + documentSecurity: false, + )); + + $database->createDocument(self::PEOPLE, new Document(['$id' => 'a', 'dots.name' => 'v', 'dots.score' => 2])); + $database->createDocument(self::PEOPLE, new Document(['$id' => 'b', 'dots.name' => 'v', 'dots.score' => 3])); + $database->createDocument(self::PEOPLE, new Document(['$id' => 'c', 'dots.name' => 'w', 'dots.score' => 5])); + $database->createDocument(self::PEOPLE, new Document(['$id' => 'd'])); + $database->createDocument(self::ORDERS, new Document(['$id' => 'o1', 'personId' => 'a', 'total' => 7])); + + return $database; + } +} diff --git a/tests/unit/EmptyDocumentTypeTest.php b/tests/unit/EmptyDocumentTypeTest.php index 787c0981f5..ded1a7482b 100644 --- a/tests/unit/EmptyDocumentTypeTest.php +++ b/tests/unit/EmptyDocumentTypeTest.php @@ -3,9 +3,11 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; +use Tests\Unit\Support\CountingMemory; use Utopia\Cache\Adapter\Memory as CacheMemory; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -28,7 +30,7 @@ public function testEmptyIdReturnsTheMappedDocumentType(): void ->setDatabase('utopiaTests') ->setNamespace('empty_type_' . \uniqid()); $database->create(); - $database->createCollection('users'); + $database->createCollection(Collection::create(id: 'users')); $database->setDocumentType('users', TypedUser::class); $empty = $database->getDocument('users', ''); @@ -51,4 +53,23 @@ public function testEmptyIdOnAnUnmappedCollectionStaysAPlainDocument(): void $this->assertSame(Document::class, $empty::class); $this->assertTrue($empty->isEmpty()); } + + public function testAZeroIdReadsNothing(): void + { + $adapter = new CountingMemory(); + $database = new Database($adapter, new Cache(new CacheMemory())); + $database + ->setDatabase('utopiaTests') + ->setNamespace('empty_type_' . \uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: 'users')); + $database->setDocumentType('users', TypedUser::class); + $adapter->reset(); + + $empty = $database->getDocument('users', '0'); + + $this->assertInstanceOf(TypedUser::class, $empty); + $this->assertTrue($empty->isEmpty()); + $this->assertSame(0, $adapter->metadataReads + $adapter->documentReads); + } } diff --git a/tests/unit/EmptySetAggregateContractTest.php b/tests/unit/EmptySetAggregateContractTest.php new file mode 100644 index 0000000000..a3ebc61b95 --- /dev/null +++ b/tests/unit/EmptySetAggregateContractTest.php @@ -0,0 +1,169 @@ +}> + */ + public static function adapters(): iterable + { + yield 'mysql' => [MySQL::class]; + yield 'mariadb' => [MariaDB::class]; + yield 'postgres' => [Postgres::class]; + } + + /** + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testBitwiseAggregatesOverNoInputValuesAreNull(string $adapter): void + { + $rows = $this->find($adapter, inputs: 0, queries: [ + Query::count('*', 'rows'), + Query::bitAnd('flags', 'all_bits'), + Query::bitOr('flags', 'any_bits'), + Query::bitXor('flags', 'odd_bits'), + Query::sum('flags', 'total'), + ]); + + $this->assertSame([[ + 'rows' => '0', + 'all_bits' => null, + 'any_bits' => null, + 'odd_bits' => null, + 'total' => null, + ]], $rows); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testBitwiseAggregatesOverInputValuesKeepTheirResult(string $adapter): void + { + $rows = $this->find($adapter, inputs: 2, queries: [ + Query::bitAnd('flags', 'all_bits'), + Query::bitOr('flags', 'any_bits'), + Query::bitXor('flags', 'odd_bits'), + Query::groupBy(['kind']), + ]); + + $this->assertSame([[ + 'all_bits' => self::EVERY_BIT, + 'any_bits' => '0', + 'odd_bits' => '0', + ]], $rows); + } + + /** + * MySQL and MariaDB aggregate an emulated full outer join once, over the union of its halves, and + * that outer statement answers the same way; PostgreSQL joins natively. + * + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testBitwiseAggregatesOverAFullOuterJoinWithNoInputValuesAreNull(string $adapter): void + { + $rows = $this->find($adapter, inputs: 0, queries: [ + Query::fullOuterJoin('other', 'joined', [Query::on('$id', 'collectionId')]), + Query::count('*', 'rows'), + Query::bitAnd('joined.flags', 'all_bits'), + Query::bitOr('joined.flags', 'any_bits'), + Query::bitXor('joined.flags', 'odd_bits'), + Query::sum('joined.flags', 'total'), + ]); + + $this->assertSame([[ + 'rows' => '0', + 'all_bits' => null, + 'any_bits' => null, + 'odd_bits' => null, + 'total' => null, + ]], $rows); + } + + /** + * Run a find whose statement is answered as if `$inputs` rows fed every aggregate. + * + * @param class-string $adapter + * @param list $queries + * @return list> + */ + private function find(string $adapter, int $inputs, array $queries): array + { + $sql = ''; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('closeCursor')->willReturn(true); + $statement->method('fetchAll')->willReturnCallback(function () use (&$sql, $inputs): array { + return [$this->answer($sql, $inputs)]; + }); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): PDOStatement { + $sql = $query; + + return $statement; + }); + + $instance = new $adapter($pdo); + $instance->setDatabase('database'); + $instance->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $instance->setAuthorization($authorization); + + $rows = []; + foreach ($instance->find(new Document(['$id' => 'collection']), $queries, limit: 25) as $document) { + $rows[] = $document->getArrayCopy(); + } + + return $rows; + } + + /** + * The row MySQL returns for each aggregate of the statement, with the bitwise aggregates + * answering a neutral value when there are no input values. + * + * @return array + */ + private function answer(string $sql, int $inputs): array + { + \preg_match_all('/([A-Z_]+)\((?:DISTINCT )?[^()]*\) AS [`"]([^`"]+)[`"]/', $sql, $matches, PREG_SET_ORDER); + $this->assertNotSame([], $matches, 'no aggregate in: '.$sql); + + $row = []; + foreach ($matches as [, $function, $alias]) { + $row[$alias] = match ($function) { + 'COUNT' => (string) $inputs, + 'BIT_AND' => self::EVERY_BIT, + 'BIT_OR', 'BIT_XOR' => '0', + default => $inputs === 0 ? null : '1', + }; + } + + return $row; + } +} diff --git a/tests/unit/EncodeTest.php b/tests/unit/EncodeTest.php new file mode 100644 index 0000000000..7a1cf7c7d8 --- /dev/null +++ b/tests/unit/EncodeTest.php @@ -0,0 +1,28 @@ +encode($collection, new Document(['status' => null])); + + $this->assertSame('pending', $encoded->getAttribute('status')); + } +} diff --git a/tests/unit/EngineErrorMappingTest.php b/tests/unit/EngineErrorMappingTest.php new file mode 100644 index 0000000000..4f7f0144ef --- /dev/null +++ b/tests/unit/EngineErrorMappingTest.php @@ -0,0 +1,511 @@ +, 3: string}> + */ + public static function lockConflictProvider(): array + { + $deadlock = self::engineError('40001', 1213, 'SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction'); + $lockWait = self::engineError('HY000', 1205, 'SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction'); + $missingTable = self::engineError('42S02', 1146, "SQLSTATE[42S02]: Base table or view not found: 1146 Table 'utopiaTests.engine_orders' doesn't exist"); + + return [ + 'MariaDB deadlock' => [self::mariaDB(), $deadlock, ContentionException::class, 'Deadlock detected'], + 'MySQL deadlock' => [self::mySQL(), $deadlock, ContentionException::class, 'Deadlock detected'], + 'MariaDB lock wait timeout' => [self::mariaDB(), $lockWait, ContentionException::class, 'Lock wait timeout exceeded'], + 'MySQL lock wait timeout' => [self::mySQL(), $lockWait, ContentionException::class, 'Lock wait timeout exceeded'], + 'MariaDB statement on a missing table' => [self::mariaDB(), $missingTable, NotFoundException::class, 'Collection not found'], + 'MySQL statement on a missing table' => [self::mySQL(), $missingTable, NotFoundException::class, 'Collection not found'], + 'Postgres deadlock' => [ + self::postgres(), + self::engineError('40P01', 7, "SQLSTATE[40P01]: Deadlock detected: 7 ERROR: deadlock detected\nDETAIL: Process 81 waits for ShareLock on transaction 740; blocked by process 82."), + ContentionException::class, + 'Deadlock detected', + ], + 'Postgres serialization failure' => [ + self::postgres(), + self::engineError('40001', 7, 'SQLSTATE[40001]: Serialization failure: 7 ERROR: could not serialize access due to concurrent update'), + ContentionException::class, + 'Could not serialize access due to a concurrent update', + ], + 'Postgres lock not available' => [ + self::postgres(), + self::engineError('55P03', 7, 'SQLSTATE[55P03]: Lock not available: 7 ERROR: canceling statement due to lock timeout'), + ContentionException::class, + 'Lock not available', + ], + 'SQLite busy database' => [ + self::sqlite(), + self::engineError('HY000', 5, 'SQLSTATE[HY000]: General error: 5 database is locked'), + ContentionException::class, + 'Database is locked', + ], + 'Postgres invalid UTF-8' => [ + self::postgres(), + self::engineError('22021', 7, 'SQLSTATE[22021]: Character not in repertoire: 7 ERROR: invalid byte sequence for encoding "UTF8": 0xc3 0x28'), + CharacterException::class, + 'Invalid character', + ], + ]; + } + + /** + * @param Closure(PDOException): Throwable $map + * @param class-string $expected + */ + #[DataProvider('lockConflictProvider')] + public function testLockConflictsMissingTablesAndBadCharactersAreMapped(Closure $map, PDOException $error, string $expected, string $message): void + { + $this->assertMapped($map, $error, $expected, $message); + } + + /** + * @return array, 2: string}> + */ + public static function undefinedTableProvider(): array + { + $line = "\nLINE 1: SELECT \"main\".\"_uid\" FROM \"utopiaTests\".\"engine_orders\" AS \"main\" WHERE \"mian\".\"_uid\" = \$1\n ^"; + $hashed = \md5('engine_'.\str_repeat('a', 70)); + + return [ + 'a statement on a missing table' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "utopiaTests.engine_orders" does not exist'.$line, NotFoundException::class, 'Collection not found'], + 'a statement on a missing permissions table' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "utopiaTests.engine_orders_perms" does not exist'.$line, NotFoundException::class, 'Collection not found'], + 'a statement on a missing table with a hashed name' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "utopiaTests.'.$hashed.'_perms" does not exist'.$line, NotFoundException::class, 'Collection not found'], + 'a DDL statement on a missing table' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "utopiaTests.engine_orders" does not exist', NotFoundException::class, 'Collection not found'], + 'a DROP of a missing table' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: table "engine_orders" does not exist', NotFoundException::class, 'Collection not found'], + 'a DROP of a missing table with a hashed name' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: table "'.$hashed.'" does not exist', NotFoundException::class, 'Collection not found'], + 'a missing table in German' => ["SQLSTATE[42P01]: Undefined table: 7 FEHLER: Relation \u{BB}utopiaTests.engine_orders\u{AB} existiert nicht".$line, NotFoundException::class, 'Collection not found'], + 'a missing table in French' => ["SQLSTATE[42P01]: Undefined table: 7 ERREUR: la relation \u{AB}\u{A0}utopiaTests.engine_orders\u{A0}\u{BB} n'existe pas".$line, NotFoundException::class, 'Collection not found'], + 'an undeclared alias' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: missing FROM-clause entry for table "mian"'.$line, QueryException::class, 'Query references an undefined table or alias'], + 'an undeclared alias in German' => ["SQLSTATE[42P01]: Undefined table: 7 FEHLER: fehlender Eintrag in FROM-Klausel f\u{FC}r Tabelle \u{BB}mian\u{AB}".$line, QueryException::class, 'Query references an undefined table or alias'], + 'a table referenced by name instead of its alias' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: invalid reference to FROM-clause entry for table "engine_orders"'.$line."\nHINT: Perhaps you meant to reference the table alias \"main\".", QueryException::class, 'Query references an undefined table or alias'], + 'a relation outside the namespace' => ['SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "utopiaTests.other_orders" does not exist'.$line, QueryException::class, 'Query references an undefined table or alias'], + 'a message that names nothing' => ['SQLSTATE[42P01]: Undefined table: 7', NotFoundException::class, 'Collection not found'], + ]; + } + + /** + * @param class-string $expected + */ + #[DataProvider('undefinedTableProvider')] + public function testPostgresUndefinedTableIsNotFoundOnlyForACollectionTable(string $message, string $expected, string $mapped): void + { + $this->assertMapped(self::postgres(), self::engineError('42P01', 7, $message), $expected, $mapped); + } + + /** + * @return array + */ + public static function mariaDBFamilyProvider(): array + { + return [ + 'MariaDB' => [self::mariaDB()], + 'MySQL' => [self::mySQL()], + ]; + } + + /** + * @param Closure(PDOException): Throwable $map + */ + #[DataProvider('mariaDBFamilyProvider')] + public function testIndexOnAColumnTheTableLacksIsAttributeNotFound(Closure $map): void + { + $error = self::engineError('42000', 1072, "SQLSTATE[42000]: Syntax error or access violation: 1072 Key column 'name' doesn't exist in table"); + + $this->assertMapped($map, $error, NotFoundException::class, 'Attribute not found'); + } + + /** + * @param Closure(PDOException): Throwable $map + */ + #[DataProvider('mariaDBFamilyProvider')] + public function testIndexKeyTooLongIsAnIndexError(Closure $map): void + { + $error = self::engineError('42000', 1071, 'SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 3072 bytes'); + + $this->assertMapped($map, $error, IndexException::class, 'Index key length exceeds the maximum'); + } + + public function testPostgresIndexRowTooLargeIsALimit(): void + { + $error = self::engineError('54000', 7, 'SQLSTATE[54000]: Program limit exceeded: 7 ERROR: index row size 8016 exceeds btree version 4 maximum 2704 for index "engine_orders_by_note"'); + + $this->assertMapped(self::postgres(), $error, LimitException::class, 'Index row size exceeds the maximum'); + } + + public function testPostgresProgramLimitOtherThanAnIndexRowStaysRaw(): void + { + $error = self::engineError('54000', 7, 'SQLSTATE[54000]: Program limit exceeded: 7 ERROR: tables can have at most 1600 columns'); + + $this->assertSame($error, self::postgres()($error)); + } + + public function testSQLiteUnknownColumnIsAttributeNotFound(): void + { + $error = self::engineError('HY000', 1, 'SQLSTATE[HY000]: General error: 1 no such column: items'); + + $this->assertMapped(self::sqlite(), $error, NotFoundException::class, 'Attribute not found'); + } + + public function testSQLiteReadOnAnUnknownColumnIsAttributeNotFound(): void + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $database + ->setDatabase('engine_errors') + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create( + id: 'orders', + attributes: [Attribute::string(key: 'category', size: 20)], + permissions: [Permission::read(Role::any())], + )); + + $error = null; + try { + $database->skipValidation(fn () => $database->find('orders', [Query::equal('no_such_attribute', ['x'])])); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(NotFoundException::class, $error); + $this->assertSame('Attribute not found', $error->getMessage()); + } + + /** + * @return array + */ + public static function undefinedFunctionProvider(): array + { + return [ + 'max over a boolean' => ["SQLSTATE[42883]: Undefined function: 7 ERROR: function max(boolean) does not exist\nLINE 1: SELECT MAX(\"main\".\"active\") AS \"most\" FROM ...\nHINT: No function matches the given name and argument types. You might need to add explicit type casts."], + 'min over JSONB' => ['SQLSTATE[42883]: Undefined function: 7 ERROR: function min(jsonb) does not exist'], + 'an operator on mismatched types' => ['SQLSTATE[42883]: Undefined function: 7 ERROR: operator does not exist: character varying + integer'], + ]; + } + + #[DataProvider('undefinedFunctionProvider')] + public function testPostgresUndefinedFunctionIsAQueryError(string $message): void + { + $this->assertMapped(self::postgres(), self::engineError('42883', 7, $message), QueryException::class, 'Query applies a function or operator the attribute type does not support'); + } + + public function testPostgresDistinctReadOrderedByAnUnselectedAttributeIsAQueryErrorInAnyLanguage(): void + { + $error = self::engineError('42P10', 7, "SQLSTATE[42P10]: Invalid column reference: 7 FEHLER: bei SELECT DISTINCT m\u{FC}ssen ORDER-BY-Ausdr\u{FC}cke in der Select-Liste erscheinen\nLINE 1: ...\"main\" ORDER BY \"main\".\"price\" ASC"); + + $failure = $this->postgresFindFailure($error, [Query::distinct(), Query::select(['category'])]); + + $this->assertInstanceOf(QueryException::class, $failure); + $this->assertSame(self::DISTINCT_ORDER, $failure->getMessage()); + $this->assertSame($error, $failure->getPrevious()); + } + + public function testPostgresReadWithoutDistinctLeavesAnUnnamedInvalidColumnReferenceRaw(): void + { + $error = self::engineError('42P10', 7, 'SQLSTATE[42P10]: Invalid column reference: 7 FEHLER: ORDER BY Position 3 ist nicht in der Select-Liste'); + + $this->assertSame($error, $this->postgresFindFailure($error, [Query::select(['category'])])); + } + + public function testPostgresConflictTargetWithoutAConstraintIsNotADistinctError(): void + { + $error = self::engineError('42P10', 7, 'SQLSTATE[42P10]: Invalid column reference: 7 ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification'); + + $this->assertSame($error, self::postgres()($error)); + } + + public function testMongoTypeMismatchIsAnInvalidOperation(): void + { + $adapter = new class (new class () extends Client { + public function __construct() + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + }) extends Mongo { + public function map(Throwable $error): Throwable + { + return $this->processException($error); + } + }; + $error = new MongoException('Cannot apply $inc to a value of non-numeric type. {_id: ObjectId(\'66f9\')} has the field \'name\' of non-numeric type string', 14); + + $mapped = $adapter->map($error); + + $this->assertInstanceOf(TypeException::class, $mapped); + $this->assertSame('Invalid operation', $mapped->getMessage()); + $this->assertSame($error, $mapped->getPrevious()); + } + + public function testSQLiteDoesNotTreatTheMySQLTimeoutCodeAsATimeout(): void + { + $error = self::engineError('HY000', 3024, 'SQLSTATE[HY000]: General error: 3024 Query execution was interrupted'); + + $this->assertSame($error, self::sqlite()($error)); + } + + public function testPostgresDeleteCollectionWithoutItsTableIsNotFoundAndStillDropsThePermissionsTable(): void + { + $statements = []; + $adapter = $this->postgresRecording($statements, self::engineError('42P01', 7, 'SQLSTATE[42P01]: Undefined table: 7 ERROR: table "engine_orders" does not exist')); + + $error = null; + try { + $adapter->deleteCollection('orders'); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(NotFoundException::class, $error); + $this->assertSame('Collection not found', $error->getMessage()); + $this->assertSame([ + 'DROP TABLE "utopiaTests"."engine_orders"; DROP TABLE IF EXISTS "utopiaTests"."engine_orders_perms"', + 'DROP TABLE IF EXISTS "utopiaTests"."engine_orders_perms"', + ], $statements); + } + + public function testPostgresUniqueIndexOverDuplicateObjectPathValuesIsUnique(): void + { + $statements = []; + $duplicates = self::engineError('23505', 7, "SQLSTATE[23505]: Unique violation: 7 ERROR: could not create unique index \"engine_orders_unique_country\"\nDETAIL: Key ((data ->> 'country'::text))=(au) is duplicated."); + $adapter = $this->postgresRecording($statements, $duplicates); + + $error = null; + try { + $adapter->createIndex('orders', Index::unique(key: 'unique_country', attributes: ['data.country']), ['data.country' => ColumnType::Object->value]); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(UniqueException::class, $error); + $this->assertSame(UniqueException::MESSAGE, $error->getMessage()); + $this->assertSame($duplicates, $error->getPrevious()); + } + + public function testPostgresDeleteCollectionDropsBothTablesInOneStatement(): void + { + $statements = []; + $adapter = $this->postgresRecording($statements); + + $this->assertTrue($adapter->deleteCollection('orders')); + $this->assertSame([ + 'DROP TABLE "utopiaTests"."engine_orders"; DROP TABLE IF EXISTS "utopiaTests"."engine_orders_perms"', + ], $statements); + } + + public function testPostgresDeleteCollectionPassesOtherErrorsThrough(): void + { + $statements = []; + $lockTimeout = self::engineError('55P03', 7, 'SQLSTATE[55P03]: Lock not available: 7 ERROR: canceling statement due to lock timeout'); + $adapter = $this->postgresRecording($statements, $lockTimeout); + + $error = null; + try { + $adapter->deleteCollection('orders'); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(TransactionException::class, $error); + $this->assertSame($lockTimeout, $error->getPrevious()); + $this->assertCount(1, $statements); + } + + /** + * @param Closure(PDOException): Throwable $map + * @param class-string $expected + */ + private function assertMapped(Closure $map, PDOException $error, string $expected, string $message): void + { + $mapped = $map($error); + + $this->assertInstanceOf($expected, $mapped); + $this->assertSame($message, $mapped->getMessage()); + $this->assertSame($error, $mapped->getPrevious()); + } + + /** + * @param list $queries + */ + private function postgresFindFailure(PDOException $error, array $queries): Throwable + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willThrowException($error); + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturn($statement); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('utopiaTests'); + $adapter->setNamespace(self::NAMESPACE); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + try { + $adapter->find(new Document(['$id' => 'orders']), $queries, orderAttributes: ['price'], orderTypes: [OrderDirection::Asc]); + } catch (Throwable $failure) { + return $failure; + } + + $this->fail('The read succeeded'); + } + + /** + * @param list $statements + */ + private function postgresRecording(array &$statements, ?PDOException $firstError = null): Postgres + { + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $sql) use (&$statements, $firstError): PDOStatement { + $statements[] = $sql; + $statement = $this->createStub(PDOStatement::class); + if ($firstError !== null && \count($statements) === 1) { + $statement->method('execute')->willThrowException($firstError); + } else { + $statement->method('execute')->willReturn(true); + } + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('utopiaTests'); + $adapter->setNamespace(self::NAMESPACE); + + return $adapter; + } + + private static function engineError(string $state, int $code, string $message): PDOException + { + $error = new class ($message, $state) extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $error->errorInfo = [$state, $code, $message]; + + return $error; + } + + /** + * @return Closure(PDOException): Throwable + */ + private static function mariaDB(): Closure + { + $adapter = new class (new stdClass()) extends MariaDB { + public function map(PDOException $error): Throwable + { + return $this->processException($error); + } + }; + $adapter->setNamespace(self::NAMESPACE); + + return $adapter->map(...); + } + + /** + * @return Closure(PDOException): Throwable + */ + private static function mySQL(): Closure + { + $adapter = new class (new stdClass()) extends MySQL { + public function map(PDOException $error): Throwable + { + return $this->processException($error); + } + }; + $adapter->setNamespace(self::NAMESPACE); + + return $adapter->map(...); + } + + /** + * @return Closure(PDOException): Throwable + */ + private static function postgres(): Closure + { + $adapter = new class (new stdClass()) extends Postgres { + public function map(PDOException $error): Throwable + { + return $this->processException($error); + } + }; + $adapter->setNamespace(self::NAMESPACE); + + return $adapter->map(...); + } + + /** + * @return Closure(PDOException): Throwable + */ + private static function sqlite(): Closure + { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public function map(PDOException $error): Throwable + { + return $this->processException($error); + } + }; + $adapter->setNamespace(self::NAMESPACE); + + return $adapter->map(...); + } +} diff --git a/tests/unit/Event/AggregateEventTest.php b/tests/unit/Event/AggregateEventTest.php new file mode 100644 index 0000000000..c49532e6c7 --- /dev/null +++ b/tests/unit/Event/AggregateEventTest.php @@ -0,0 +1,43 @@ +addHook($recorder); + + $rows = $database->aggregate(HookFixture::COLLECTION, [Query::count('*', 'total'), Query::sum('views', 'views')]); + + $this->assertSame([Event::DocumentAggregate], $recorder->getEvents()); + $aggregated = $recorder->received(Event::DocumentAggregate)[0]; + $this->assertInstanceOf(Event\Document\Aggregated::class, $aggregated); + $this->assertSame(HookFixture::COLLECTION, $aggregated->collection); + $this->assertSame([['total' => 3, 'views' => 6]], $aggregated->rows); + $this->assertSame($rows, $aggregated->rows); + } + + public function testAnUnheardAggregateFiresNothing(): void + { + $database = HookFixture::sqlite(); + HookFixture::seed($database, ['first']); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $database->silent(fn (): array => $database->aggregate(HookFixture::COLLECTION, [Query::count()])); + + $this->assertSame([], $recorder->getEvents()); + } +} diff --git a/tests/unit/Event/AttributeCreateTest.php b/tests/unit/Event/AttributeCreateTest.php new file mode 100644 index 0000000000..b0542d46d7 --- /dev/null +++ b/tests/unit/Event/AttributeCreateTest.php @@ -0,0 +1,58 @@ +addHook($recorder); + + $database->createAttributes(HookFixture::COLLECTION, [ + Attribute::string(key: 'summary', size: 64), + Attribute::integer(key: 'likes'), + ]); + + $this->assertSame([ + Event::DocumentPurge, + Event::AttributeCreate, + Event::AttributeCreate, + Event::AttributesCreate, + ], $recorder->getEvents()); + + $created = $recorder->received(Event::AttributeCreate); + $this->assertSame(['posts/summary', 'posts/likes'], \array_map($this->describe(...), $created)); + + $batches = $recorder->received(Event::AttributesCreate); + $this->assertCount(1, $batches); + $this->assertInstanceOf(Event\Attribute\BatchCreated::class, $batches[0]); + $this->assertSame('posts', $batches[0]->collection); + $this->assertSame(['summary', 'likes'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $batches[0]->attributes)); + } + + public function testCreateAttributeFiresAttributeCreateOnce(): void + { + $database = HookFixture::memory(); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $database->createAttribute(HookFixture::COLLECTION, Attribute::string(key: 'summary', size: 64)); + + $this->assertSame([Event::DocumentPurge, Event::AttributeCreate], $recorder->getEvents()); + $this->assertSame(['posts/summary'], \array_map($this->describe(...), $recorder->received(Event::AttributeCreate))); + } + + private function describe(Domain $event): string + { + $this->assertInstanceOf(Event\Attribute\Created::class, $event); + + return $event->collection.'/'.$event->attribute->key; + } +} diff --git a/tests/unit/Event/DispatcherHookRecording.php b/tests/unit/Event/DispatcherHookRecording.php new file mode 100644 index 0000000000..feeb298dec --- /dev/null +++ b/tests/unit/Event/DispatcherHookRecording.php @@ -0,0 +1,24 @@ + */ + public array $events = []; + + public function add(Domain $event): void + { + $this->events[] = $event; + } + + /** + * @return list> + */ + public function classes(): array + { + return \array_map(static fn (Domain $event): string => $event::class, $this->events); + } +} diff --git a/tests/unit/Event/DispatcherHookTest.php b/tests/unit/Event/DispatcherHookTest.php new file mode 100644 index 0000000000..1493b51b77 --- /dev/null +++ b/tests/unit/Event/DispatcherHookTest.php @@ -0,0 +1,256 @@ +hook = new DispatcherHook(); + } + + public function testTheHookHandlesOnlyEventsItHasAListenerOrADispatcherFor(): void + { + $this->assertFalse($this->hook->handles(Event::DocumentUpdate)); + + $this->hook->on(DocumentUpdated::class, static function (): void { + }); + + $this->assertTrue($this->hook->handles(Event::DocumentUpdate)); + $this->assertFalse($this->hook->handles(Event::DocumentDelete)); + $this->assertFalse($this->hook->handles(Event::All), 'All selects timeouts and is never an event'); + + $dispatching = new DispatcherHook(new class () { + public function dispatch(object $event): object + { + return $event; + } + }); + $this->assertTrue($dispatching->handles(Event::DocumentDelete)); + $this->assertTrue($dispatching->handles(Event::DocumentRead)); + $this->assertFalse($dispatching->handles(Event::All)); + } + + public function testTheListenersOfTheEventClassReceiveTheEvent(): void + { + $received = []; + $this->hook->on(DocumentCreated::class, static function (DocumentCreated $event) use (&$received): void { + $received[] = $event; + }); + $this->hook->on(DocumentUpdated::class, static function () { + throw new RuntimeException('Not the event class'); + }); + + $event = new DocumentCreated('users', new Document(['$id' => 'doc-1'])); + $this->hook->handle($event); + + $this->assertSame([$event], $received); + } + + public function testHandleRunsEveryListenerThenRethrowsTheFirstException(): void + { + $first = new RuntimeException('first'); + $calls = []; + + $this->hook->on(DocumentCreated::class, function () use ($first, &$calls): void { + $calls[] = 'first'; + + throw $first; + }); + $this->hook->on(DocumentCreated::class, function () use (&$calls): void { + $calls[] = 'second'; + + throw new RuntimeException('second'); + }); + $this->hook->on(DocumentCreated::class, function () use (&$calls): void { + $calls[] = 'third'; + }); + + $caught = null; + try { + $this->hook->handle(new DocumentCreated('test', new Document(['$id' => 'doc-5']))); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($first, $caught, 'The first listener exception must reach the database'); + $this->assertSame(['first', 'second', 'third'], $calls); + } + + public function testDeleteCollectionDeliversCollectionDeletedOnce(): void + { + $database = HookFixture::memory(); + $database->addHook($this->hook); + $received = $this->record(); + + $database->deleteCollection(HookFixture::COLLECTION); + + $this->assertSame([CollectionDeleted::class], $received->classes()); + $this->assertInstanceOf(CollectionDeleted::class, $received->events[0]); + $this->assertSame(HookFixture::COLLECTION, $received->events[0]->collection); + $this->assertSame(HookFixture::COLLECTION, $received->events[0]->definition->getId()); + } + + public function testBulkWritesDeliverBulkEventsWithTheirCounts(): void + { + $database = HookFixture::memory(); + $database->addHook($this->hook); + $received = $this->record(); + + $database->createDocuments(HookFixture::COLLECTION, [ + new Document([Document::ID => 'first', 'title' => 'first', 'views' => 1]), + new Document([Document::ID => 'second', 'title' => 'second', 'views' => 2]), + new Document([Document::ID => 'third', 'title' => 'third', 'views' => 3]), + ]); + $database->updateDocuments(HookFixture::COLLECTION, new Document(['views' => 10]), [ + Query::notEqual('title', 'third'), + ]); + $database->deleteDocuments(HookFixture::COLLECTION); + + $this->assertSame([BatchCreated::class, BatchUpdated::class, BatchDeleted::class], $received->classes()); + [$created, $updated, $deleted] = $received->events; + $this->assertInstanceOf(BatchCreated::class, $created); + $this->assertInstanceOf(BatchUpdated::class, $updated); + $this->assertInstanceOf(BatchDeleted::class, $deleted); + $this->assertSame([HookFixture::COLLECTION, 3], [$created->collection, $created->count]); + $this->assertSame([HookFixture::COLLECTION, 2], [$updated->collection, $updated->count]); + $this->assertSame([HookFixture::COLLECTION, 3], [$deleted->collection, $deleted->count]); + } + + public function testAListenerErrorReachesTheCaller(): void + { + $database = HookFixture::memory(); + $database->addHook($this->hook); + $this->hook->on(CollectionCreated::class, static function (): void { + throw new Error('listener bug'); + }); + + $this->expectException(Error::class); + $this->expectExceptionMessage('listener bug'); + + $database->createCollection(Collection::create(id: 'comments')); + } + + public function testAListenerExceptionAtAnIsolatedEventIsSwallowedAndTheOtherListenersRun(): void + { + $database = HookFixture::memory(); + $database->addHook($this->hook); + $ran = false; + $this->hook->on(CollectionCreated::class, static function (): void { + throw new RuntimeException('listener failure'); + }); + $this->hook->on(CollectionCreated::class, static function () use (&$ran): void { + $ran = true; + }); + + $database->createCollection(Collection::create(id: 'comments')); + + $this->assertTrue($ran); + $this->assertNotNull($database->findCollection('comments')); + } + + public function testAListenerExceptionAtADocumentEventReachesTheCaller(): void + { + $database = HookFixture::memory(); + $database->addHook($this->hook); + $ran = false; + $this->hook->on(DocumentCreated::class, static function (): void { + throw new RuntimeException('listener failure'); + }); + $this->hook->on(DocumentCreated::class, static function () use (&$ran): void { + $ran = true; + }); + + $caught = null; + try { + HookFixture::seed($database, ['first']); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame('listener failure', $caught?->getMessage(), 'A listener exception at document_create must reach the caller'); + $this->assertTrue($ran); + } + + public function testADispatcherErrorReachesTheCaller(): void + { + $database = HookFixture::memory(); + $database->addHook(new DispatcherHook(new class () { + public function dispatch(object $event): object + { + throw new Error('dispatcher bug'); + } + })); + + $this->expectException(Error::class); + $this->expectExceptionMessage('dispatcher bug'); + + $database->deleteCollection(HookFixture::COLLECTION); + } + + public function testADispatcherExceptionFollowsTheHookFailurePolicy(): void + { + $database = HookFixture::memory(); + $dispatcher = new class () { + /** @var list */ + public array $events = []; + + public function dispatch(object $event): object + { + $this->events[] = $event; + + throw new RuntimeException('dispatcher failure'); + } + }; + $database->addHook(new DispatcherHook($dispatcher)); + + $database->createCollection(Collection::create(id: 'comments')); + $this->assertInstanceOf(CollectionCreated::class, $dispatcher->events[0]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('dispatcher failure'); + + HookFixture::seed($database, ['first']); + } + + private function record(): DispatcherHookRecording + { + $recording = new DispatcherHookRecording(); + foreach (self::DOMAIN_EVENTS as $class) { + $this->hook->on($class, $recording->add(...)); + } + + return $recording; + } +} diff --git a/tests/unit/Event/DocumentPurgeTest.php b/tests/unit/Event/DocumentPurgeTest.php new file mode 100644 index 0000000000..68fc8a5a5d --- /dev/null +++ b/tests/unit/Event/DocumentPurgeTest.php @@ -0,0 +1,976 @@ + + */ + public static function databases(): iterable + { + yield 'memory' => [HookFixture::memory(...)]; + yield 'sqlite' => [HookFixture::sqlite(...)]; + } + + /** + * @return iterable + */ + public static function purgingCalls(): iterable + { + yield 'updateDocument' => [static fn (Database $database): mixed => $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed']))]; + yield 'updateDocuments' => [static fn (Database $database): mixed => $database->updateDocuments(HookFixture::COLLECTION, new Document(['views' => 10]))]; + yield 'upsertDocuments' => [static fn (Database $database): mixed => $database->upsertDocuments(HookFixture::COLLECTION, [new Document([Document::ID => 'first', 'title' => 'upserted', 'views' => 5])])]; + yield 'increaseDocumentAttribute' => [static fn (Database $database): mixed => $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views')]; + yield 'decreaseDocumentAttribute' => [static fn (Database $database): mixed => $database->decreaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views')]; + yield 'deleteDocument' => [static fn (Database $database): mixed => $database->deleteDocument(HookFixture::COLLECTION, 'first')]; + yield 'deleteDocuments' => [static fn (Database $database): mixed => $database->deleteDocuments(HookFixture::COLLECTION)]; + yield 'purgeCachedDocument' => [static function (Database $database): mixed { + $database->purgeCachedDocument(HookFixture::COLLECTION, 'first'); + + return null; + }]; + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testUpdateDocumentPurgesTheDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second']); + + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + + $this->assertSame([Event::DocumentPurge, Event::DocumentUpdate], $recorder->getEvents()); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testUpdateDocumentPurgesBothIdentifiersOfARenamedDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first']); + + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document([Document::ID => 'renamed'])); + + $this->assertSame([Event::DocumentPurge, Event::DocumentPurge, Event::DocumentUpdate], $recorder->getEvents()); + $this->assertSame(['posts/first', 'posts/renamed'], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testUpdateDocumentsPurgesEveryUpdatedDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second', 'third']); + + $database->updateDocuments(HookFixture::COLLECTION, new Document(['views' => 10]), [Query::notEqual('title', 'third')]); + + $this->assertSame([Event::DocumentPurge, Event::DocumentPurge, Event::DocumentsUpdate], $recorder->getEvents()); + $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); + } + + public function testUpsertDocumentsPurgesEveryUpsertedDocument(): void + { + [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first', 'second']); + + $database->upsertDocuments(HookFixture::COLLECTION, [ + new Document([Document::ID => 'first', 'title' => 'upserted', 'views' => 5]), + new Document([Document::ID => 'fourth', 'title' => 'fourth', 'views' => 4]), + ]); + + $this->assertSame([Event::DocumentPurge, Event::DocumentPurge, Event::DocumentsUpsert], $recorder->getEvents()); + $this->assertSame(['posts/first', 'posts/fourth'], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testIncreaseDocumentAttributePurgesTheDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second']); + + $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', 2); + + $this->assertSame([Event::DocumentPurge, Event::DocumentIncrease], $recorder->getEvents()); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testDecreaseDocumentAttributePurgesTheDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second']); + + $database->decreaseDocumentAttribute(HookFixture::COLLECTION, 'second', 'views'); + + $this->assertSame([Event::DocumentPurge, Event::DocumentDecrease], $recorder->getEvents()); + $this->assertSame(['posts/second'], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testDeleteDocumentPurgesTheDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second']); + + $database->deleteDocument(HookFixture::COLLECTION, 'first'); + + $this->assertSame([Event::DocumentPurge, Event::DocumentDelete], $recorder->getEvents()); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testDeleteDocumentsPurgesEveryDeletedDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second', 'third']); + + $database->deleteDocuments(HookFixture::COLLECTION, [Query::notEqual('title', 'second')]); + + $this->assertSame([Event::DocumentPurge, Event::DocumentPurge, Event::DocumentsDelete], $recorder->getEvents()); + $this->assertSame(['posts/first', 'posts/third'], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testBatchWritesPurgeEveryBatch(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second', 'third']); + + $database->updateDocuments(HookFixture::COLLECTION, new Document(['views' => 10]), batchSize: 2); + $database->deleteDocuments(HookFixture::COLLECTION, batchSize: 2); + + $this->assertSame([ + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentsUpdate, + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentsDelete, + ], $recorder->getEvents()); + $this->assertSame([ + 'posts/first', + 'posts/second', + 'posts/third', + 'posts/first', + 'posts/second', + 'posts/third', + ], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testWritesThatChangeNothingPurgeNothing(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first']); + + $database->updateDocument(HookFixture::COLLECTION, 'missing', new Document(['title' => 'renamed'])); + $database->deleteDocument(HookFixture::COLLECTION, 'missing'); + $database->updateDocuments(HookFixture::COLLECTION, new Document(['views' => 10]), [Query::equal('title', ['missing'])]); + $database->deleteDocuments(HookFixture::COLLECTION, [Query::equal('title', ['missing'])]); + + $this->assertSame([Event::DocumentsUpdate, Event::DocumentsDelete], $recorder->getEvents()); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testPurgeCachedDocumentPurgesTheDocument(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first']); + + $database->purgeCachedDocument(HookFixture::COLLECTION, 'first'); + + $this->assertSame([Event::DocumentPurge], $recorder->getEvents()); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + public function testDocumentPurgeFiresOnceWhenTheTransactionIsRetried(): void + { + $adapter = new class () extends Memory { + public int $commitFailures = 0; + + #[\Override] + public function commitTransaction(): bool + { + if ($this->commitFailures > 0) { + $this->commitFailures--; + + throw new TransactionException('Failed to commit transaction: commit lost'); + } + + return parent::commitTransaction(); + } + }; + [$database, $recorder] = $this->seeded(HookFixture::database($adapter), ['first', 'second']); + + $adapter->commitFailures = 1; + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + $adapter->commitFailures = 1; + $database->deleteDocuments(HookFixture::COLLECTION); + + $this->assertSame([ + Event::DocumentPurge, + Event::DocumentUpdate, + Event::DocumentPurge, + Event::DocumentPurge, + Event::DocumentsDelete, + ], $recorder->getEvents()); + $this->assertSame(['posts/first', 'posts/first', 'posts/second'], $this->purged($recorder)); + } + + /** + * @param Closure(Database): mixed $call + */ + #[DataProvider('purgingCalls')] + public function testDocumentPurgeHookFailureReachesTheCaller(Closure $call): void + { + foreach ([new RuntimeException('region broadcast failed'), new TypeError('broken hook')] as $failure) { + $database = HookFixture::sqlite(); + HookFixture::seed($database, ['first']); + $later = new RecordingLifecycle(); + $database + ->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)) + ->addHook($later); + + $this->assertSame($failure, $this->failureOf(static fn () => $call($database))); + $this->assertNotContains(Event::DocumentPurge, $later->getEvents()); + } + } + + public function testDocumentPurgeThroughMirrorReachesTheCaller(): void + { + $source = HookFixture::sqlite(); + HookFixture::seed($source, ['first']); + $mirror = new Mirror($source); + $failure = new RuntimeException('region broadcast failed'); + $mirror->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)); + + $this->assertSame($failure, $this->failureOf(static fn () => $mirror->purgeCachedDocument(HookFixture::COLLECTION, 'first'))); + $this->assertSame($failure, $this->failureOf(static fn () => $mirror->updateDocument( + HookFixture::COLLECTION, + 'first', + new Document(['title' => 'renamed']), + ))); + } + + /** + * @param list $ids + * @return array{Database, RecordingLifecycle} + */ + private function seeded(Database $database, array $ids): array + { + HookFixture::seed($database, $ids); + + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + return [$database, $recorder]; + } + + /** + * @return list + */ + private function purged(RecordingLifecycle $recorder): array + { + $purged = []; + foreach ($recorder->received(Event::DocumentPurge) as $event) { + $this->assertInstanceOf(Event\Document\Purged::class, $event); + $purged[] = $event->collection.'/'.$event->id; + } + + return $purged; + } + + /** + * @param callable(): mixed $call + */ + private function failureOf(callable $call): ?Throwable + { + try { + $call(); + } catch (Throwable $failure) { + return $failure; + } + + return null; + } + + /** + * @return iterable + */ + public static function writes(): iterable + { + foreach (self::purgingCalls() as $name => $call) { + if ($name !== 'purgeCachedDocument') { + yield $name => $call; + } + } + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testPurgeEventsInsideACallerTransactionFireAfterTheCommit(Closure $write): void + { + [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first']); + $inTransaction = $this->observePurges($database, static fn (): bool => $database->getAdapter()->inTransaction()); + + $database->withTransaction(static fn (): mixed => $write($database)); + + $this->assertSame([false], $inTransaction->getArrayCopy()); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testPurgeEventsAreDroppedWhenTheCallerTransactionRollsBack(Closure $write): void + { + [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first']); + $abandoned = new RuntimeException('abandoned'); + + $this->assertSame($abandoned, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $write, $abandoned): never { + $write($database); + + throw $abandoned; + }, + ))); + + $this->assertSame([], $this->purged($recorder)); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testPurgeEventsOfARolledBackNestedTransactionAreDropped(Closure $database): void + { + [$database, $recorder] = $this->seeded($database(), ['first', 'second']); + $abandoned = new RuntimeException('abandoned'); + + $database->withTransaction(function () use ($database, $abandoned): void { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + + $this->assertSame($abandoned, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $abandoned): never { + $database->updateDocument(HookFixture::COLLECTION, 'second', new Document(['title' => 'renamed'])); + + throw $abandoned; + }, + ))); + }); + + $this->assertSame(['posts/first'], $this->purged($recorder)); + $this->assertSame('second', $database->getDocument(HookFixture::COLLECTION, 'second')->getAttribute('title')); + } + + /** + * Without savepoints a nested call runs inside the caller's transaction and nothing + * rolls its writes back when it fails: once the caller catches the failure, those + * writes commit with it, so their purge events must fire after that commit. + */ + public function testPurgeEventsOfAFailedNestedCallWithoutSavepointsFireAfterTheOuterCommit(): void + { + $adapter = new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + static fn (Capability $capability): bool => $capability !== Capability::TransactionNested, + )); + } + + #[\Override] + public function withTransaction(callable $callback): mixed + { + if ($this->inTransaction()) { + return $callback(); + } + + return parent::withTransaction($callback); + } + }; + [$database, $recorder] = $this->seeded(HookFixture::database($adapter), ['first', 'second']); + $abandoned = new RuntimeException('abandoned'); + + $database->withTransaction(function () use ($database, $abandoned): void { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + + $this->assertSame($abandoned, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $abandoned): never { + $database->updateDocument(HookFixture::COLLECTION, 'second', new Document(['title' => 'renamed'])); + + throw $abandoned; + }, + ))); + }); + + $this->assertSame('renamed', $database->getDocument(HookFixture::COLLECTION, 'second')->getAttribute('title'), 'Nothing rolls the nested write back'); + $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testPurgeEventsOfARetriedCallerTransactionFireOnce(Closure $write): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public int $commitFailures = 0; + + #[\Override] + public function commitTransaction(): bool + { + if ($this->inTransaction === 1 && $this->commitFailures > 0) { + $this->commitFailures--; + + throw new TransactionException('Failed to commit transaction: commit lost'); + } + + return parent::commitTransaction(); + } + }; + [$database, $recorder] = $this->seeded(HookFixture::database($adapter), ['first']); + + $adapter->commitFailures = 1; + $database->withTransaction(static fn (): mixed => $write($database)); + + $this->assertSame(0, $adapter->commitFailures); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + public function testPurgeEventsOfWritesWithoutAnAdapterTransactionFireAtOnce(): void + { + $adapter = new class () extends Memory { + #[\Override] + public function withTransaction(callable $callback): mixed + { + return $callback(); + } + }; + [$database, $recorder] = $this->seeded(HookFixture::database($adapter), ['first']); + $abandoned = new RuntimeException('abandoned'); + + $this->assertSame($abandoned, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $abandoned): never { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + + throw $abandoned; + }, + ))); + + $this->assertSame('renamed', $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('title')); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testPurgeEventFiresWhenThePostCommitInvalidationFails(Closure $write): void + { + $failure = new RuntimeException('cache unavailable'); + $cache = new class ($failure) extends MemoryCache { + public bool $failing = false; + + public function __construct(private readonly RuntimeException $failure) + { + } + + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + if ($this->failing) { + throw $this->failure; + } + + return parent::save($key, $data, $hash); + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->failing) { + throw $this->failure; + } + + return parent::purge($key, $hash); + } + }; + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public ?Closure $afterCommit = null; + + #[\Override] + public function commitTransaction(): bool + { + $committed = parent::commitTransaction(); + if (! $this->inTransaction()) { + $this->afterCommit?->__invoke(); + } + + return $committed; + } + }; + $database = HookFixture::database($adapter)->setCache(new Cache($cache)); + [$database, $recorder] = $this->seeded($database, ['first']); + + $adapter->afterCommit = static function () use ($cache): void { + $cache->failing = true; + }; + + $this->assertSame($failure, $this->failureOf(static fn (): mixed => $write($database))); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + public function testQueuedPurgeEventsKeepTheirTenant(): void + { + $database = (new Database(new Memory(), new Cache(new None()))) + ->setAuthorization(new Authorization()) + ->setDatabase('hooks') + ->setNamespace('hooks_'.\uniqid()) + ->setSharedTables(true) + ->setTenant(null) + ->setTenantPerDocument(true); + $database->create(); + $database->createCollection(Collection::create( + id: HookFixture::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + foreach ([7, 8] as $tenant) { + $database->createDocument(HookFixture::COLLECTION, new Document([ + Document::ID => 'first', + Document::TENANT => $tenant, + 'title' => 'first', + ])); + } + $tenants = $this->observePurges($database, static fn (): int|string|null => $database->getTenant()); + + $database->withTransaction(static function () use ($database): void { + $database->withTenant(7, static fn (): Document => $database->updateDocument( + HookFixture::COLLECTION, + 'first', + new Document(['title' => 'renamed']), + )); + $database->withTenant(8, static fn (): int => $database->updateDocuments( + HookFixture::COLLECTION, + new Document(['title' => 'renamed']), + [Query::equal('$id', ['first'])], + )); + }); + + $this->assertSame([7, 8], $tenants->getArrayCopy()); + $this->assertNull($database->getTenant()); + } + + public function testQueuedPurgeEventsStaySilenced(): void + { + [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first', 'second']); + $named = new NamedRecordingLifecycle('audit'); + $database->addHook($named); + + $database->withTransaction(static function () use ($database): void { + $database->silent(static fn (): Document => $database->updateDocument( + HookFixture::COLLECTION, + 'first', + new Document(['title' => 'renamed']), + )); + $database->silent(static fn (): Document => $database->updateDocument( + HookFixture::COLLECTION, + 'second', + new Document(['title' => 'renamed']), + ), ['audit']); + }); + + $this->assertSame(['posts/second'], $this->purged($recorder)); + $this->assertSame([], $named->received(Event::DocumentPurge)); + } + + public function testEveryQueuedPurgeEventIsDeliveredWhenAListenerFails(): void + { + [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first', 'second']); + $failure = new RuntimeException('region broadcast failed'); + $database->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)); + + $this->assertSame($failure, $this->failureOf(static function () use ($database): void { + $database->withTransaction(static function () use ($database): void { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + $database->updateDocument(HookFixture::COLLECTION, 'second', new Document(['title' => 'renamed'])); + }); + })); + + $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); + } + + /** + * @return iterable + */ + public static function savepoints(): iterable + { + yield 'with savepoints' => [true]; + yield 'without savepoints' => [false]; + } + + /** + * The writes on an adapter with and without savepoints. The adapter without them (Memory) has no upserts. + * + * @return iterable + */ + public static function unconfirmedWrites(): iterable + { + foreach (self::writes() as $name => [$write]) { + yield $name.' with savepoints' => [$write, true]; + + if ($name !== 'upsertDocuments') { + yield $name.' without savepoints' => [$write, false]; + } + } + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('unconfirmedWrites')] + public function testPurgeEventsOfAnUnconfirmedCommitFire(Closure $write, bool $savepoints): void + { + foreach ([ + 'own transaction' => static fn (Database $database): mixed => $write($database), + 'caller transaction' => static fn (Database $database): mixed => $database->withTransaction(static fn (): mixed => $write($database)), + ] as $scope => $call) { + [$database, $recorder] = $this->unconfirmed($savepoints, ['first']); + + $thrown = $this->failureOf(static fn (): mixed => $call($database)); + + $this->assertInstanceOf(UnconfirmedException::class, $thrown, $scope); + $this->assertSame(['posts/first'], $this->purged($recorder), $scope.': the write may be stored, so its purge must be announced'); + } + } + + #[DataProvider('savepoints')] + public function testOnlyTheUnconfirmedAttemptOfARetriedTransactionAnnouncesItsPurgeEvents(bool $savepoints): void + { + [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second'], commitFailures: 1); + $attempts = 0; + $attempt = static function () use ($database, &$attempts): Document { + $attempts++; + + return $database->updateDocument(HookFixture::COLLECTION, $attempts === 1 ? 'first' : 'second', new Document(['title' => 'renamed'])); + }; + + $thrown = $this->failureOf(static fn (): mixed => $database->withTransaction($attempt)); + + $this->assertSame(2, $attempts); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + $this->assertSame(['posts/second'], $this->purged($recorder)); + } + + #[DataProvider('savepoints')] + public function testPurgeEventsAreDroppedWhenATransactionThatWouldBeUnconfirmedRollsBack(bool $savepoints): void + { + [$database, $recorder] = $this->unconfirmed($savepoints, ['first']); + $abandoned = new RuntimeException('abandoned'); + + $this->assertSame($abandoned, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $abandoned): never { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + + throw $abandoned; + }, + ))); + + $this->assertSame([], $this->purged($recorder)); + $this->assertSame('first', $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('title')); + } + + #[DataProvider('savepoints')] + public function testAnUnconfirmedCommitStaysTheFailureWhenAPurgeListenerFails(bool $savepoints): void + { + [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second']); + $database->addHook(new FailingLifecycle(Event::DocumentPurge, new RuntimeException('region broadcast failed'))); + + $thrown = $this->failureOf(static function () use ($database): void { + $database->withTransaction(static function () use ($database): void { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + $database->updateDocument(HookFixture::COLLECTION, 'second', new Document(['title' => 'renamed'])); + }); + }); + + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); + } + + /** + * An Exception\Unconfirmed the callback throws, as one from another database's commit does, says nothing about + * this transaction: it rolls back, so its purge events are dropped. + */ + #[DataProvider('savepoints')] + public function testPurgeEventsAreDroppedWhenTheCallbackThrowsAnotherCommitsUnconfirmed(bool $savepoints): void + { + [$database, $recorder] = $this->unconfirmed($savepoints, ['first'], confirmed: true); + $foreign = new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); + + $this->assertSame($foreign, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $foreign): never { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + + throw $foreign; + }, + ))); + + $this->assertSame([], $this->purged($recorder)); + $this->assertSame('first', $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('title')); + } + + #[DataProvider('savepoints')] + public function testPurgeEventsAreDroppedWhenARetriedCallbackThrowsAnotherCommitsUnconfirmed(bool $savepoints): void + { + [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second'], commitFailures: 1, confirmed: true); + $foreign = new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); + $attempts = 0; + $attempt = static function () use ($database, $foreign, &$attempts): Document { + $attempts++; + $updated = $database->updateDocument(HookFixture::COLLECTION, $attempts === 1 ? 'first' : 'second', new Document(['title' => 'renamed'])); + + if ($attempts > 1) { + throw $foreign; + } + + return $updated; + }; + + $this->assertSame($foreign, $this->failureOf(static fn (): mixed => $database->withTransaction($attempt))); + + $this->assertSame(2, $attempts); + $this->assertSame([], $this->purged($recorder)); + } + + #[DataProvider('savepoints')] + public function testAnUnconfirmedCommitRethrownByALaterTransactionDropsThatTransactionsPurgeEvents(bool $savepoints): void + { + [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second']); + $unconfirmed = $this->failureOf(static fn (): mixed => $database->updateDocument( + HookFixture::COLLECTION, + 'first', + new Document(['title' => 'renamed']), + )); + $this->assertInstanceOf(UnconfirmedException::class, $unconfirmed); + + $this->assertSame($unconfirmed, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $unconfirmed): never { + $database->updateDocument(HookFixture::COLLECTION, 'second', new Document(['title' => 'renamed'])); + + throw $unconfirmed; + }, + ))); + + $this->assertSame(['posts/first'], $this->purged($recorder)); + $this->assertSame('second', $database->getDocument(HookFixture::COLLECTION, 'second')->getAttribute('title')); + } + + public function testPurgeEventsOfASavepointRolledBackOnAnotherCommitsUnconfirmedAreDropped(): void + { + [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first', 'second']); + $foreign = new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); + + $database->withTransaction(function () use ($database, $foreign): void { + $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])); + + $this->assertSame($foreign, $this->failureOf(static fn (): mixed => $database->withTransaction( + static function () use ($database, $foreign): never { + $database->updateDocument(HookFixture::COLLECTION, 'second', new Document(['title' => 'renamed'])); + + throw $foreign; + }, + ))); + }); + + $this->assertSame('second', $database->getDocument(HookFixture::COLLECTION, 'second')->getAttribute('title')); + $this->assertSame(['posts/first'], $this->purged($recorder)); + } + + /** + * A seeded database whose outermost transactions from now on commit and then throw Exception\Unconfirmed, as a + * MongoDB commit whose result could not be confirmed does, unless $confirmed. The next $commitFailures outermost + * commits fail with Exception\Transaction instead, so the transaction runs again. Without $savepoints its adapter + * runs a nested call in the open transaction, as the MongoDB adapter does. + * + * @param list $ids + * @return array{Database, RecordingLifecycle} + */ + private function unconfirmed(bool $savepoints, array $ids, int $commitFailures = 0, bool $confirmed = false): array + { + if ($savepoints) { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public bool $unconfirmed = false; + + public int $commitFailures = 0; + + #[\Override] + public function withTransaction(callable $callback): mixed + { + if ($this->inTransaction()) { + return parent::withTransaction($callback); + } + + $result = parent::withTransaction($callback); + + if ($this->unconfirmed) { + throw new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); + } + + return $result; + } + + #[\Override] + public function commitTransaction(): bool + { + if ($this->inTransaction === 1 && $this->commitFailures > 0) { + $this->commitFailures--; + + throw new TransactionException('Failed to commit transaction: commit lost'); + } + + return parent::commitTransaction(); + } + }; + } else { + $adapter = new class () extends Memory { + public bool $unconfirmed = false; + + public int $commitFailures = 0; + + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + static fn (Capability $capability): bool => $capability !== Capability::TransactionNested, + )); + } + + #[\Override] + public function withTransaction(callable $callback): mixed + { + if ($this->inTransaction()) { + return $callback(); + } + + $result = parent::withTransaction($callback); + + if ($this->unconfirmed) { + throw new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); + } + + return $result; + } + + #[\Override] + public function commitTransaction(): bool + { + if ($this->inTransaction === 1 && $this->commitFailures > 0) { + $this->commitFailures--; + + throw new TransactionException('Failed to commit transaction: commit lost'); + } + + return parent::commitTransaction(); + } + }; + } + + $seeded = $this->seeded(HookFixture::database($adapter), $ids); + + $adapter->unconfirmed = ! $confirmed; + $adapter->commitFailures = $commitFailures; + + return $seeded; + } + + /** + * @param Closure(): mixed $observe + * @return ArrayObject + */ + private function observePurges(Database $database, Closure $observe): ArrayObject + { + /** @var ArrayObject $observed */ + $observed = new ArrayObject(); + $database->addHook(new class ($observe, $observed) implements Lifecycle { + /** + * @param Closure(): mixed $observe + * @param ArrayObject $observed + */ + public function __construct( + private readonly Closure $observe, + private readonly ArrayObject $observed, + ) { + } + + #[\Override] + public function handle(Domain $event): void + { + if ($event->event === Event::DocumentPurge) { + $this->observed->append(($this->observe)()); + } + } + }); + + return $observed; + } +} diff --git a/tests/unit/Event/FailingLifecycle.php b/tests/unit/Event/FailingLifecycle.php new file mode 100644 index 0000000000..0cd0c610b7 --- /dev/null +++ b/tests/unit/Event/FailingLifecycle.php @@ -0,0 +1,25 @@ +event === $this->event) { + throw $this->failure; + } + } +} diff --git a/tests/unit/Event/FindOneMissTest.php b/tests/unit/Event/FindOneMissTest.php new file mode 100644 index 0000000000..aed047372d --- /dev/null +++ b/tests/unit/Event/FindOneMissTest.php @@ -0,0 +1,51 @@ +addHook($recorder); + + $found = $database->findOne(HookFixture::COLLECTION, [Query::equal(Document::ID, ['missing'])]); + + $this->assertTrue($found->isEmpty()); + $this->assertSame([], $recorder->received(Event::DocumentFind)); + } + + public function testAMissOnAnEmptyCollectionFiresNoFindEvent(): void + { + $database = HookFixture::memory(); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $this->assertTrue($database->findOne(HookFixture::COLLECTION)->isEmpty()); + $this->assertSame([], $recorder->received(Event::DocumentFind)); + } + + public function testAHitFiresOneFindEventWithTheDocument(): void + { + $database = HookFixture::memory(); + HookFixture::seed($database, ['first', 'second']); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $found = $database->findOne(HookFixture::COLLECTION, [Query::equal(Document::ID, ['second'])]); + + $events = $recorder->received(Event::DocumentFind); + $this->assertSame('second', $found->getId()); + $this->assertCount(1, $events); + $this->assertInstanceOf(Event\Document\Found::class, $events[0]); + $this->assertSame(HookFixture::COLLECTION, $events[0]->collection); + $this->assertSame([$found], $events[0]->documents); + } +} diff --git a/tests/unit/Event/HookErrorCleanupTest.php b/tests/unit/Event/HookErrorCleanupTest.php new file mode 100644 index 0000000000..7ae2cc9365 --- /dev/null +++ b/tests/unit/Event/HookErrorCleanupTest.php @@ -0,0 +1,144 @@ +database(); + $this->createNotes($database); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'one', 'name' => 'old'])); + $database->getDocument(self::COLLECTION, 'one'); + $database->addHook(new FailingLifecycle(Event::CollectionDelete, new TypeError('broken hook'))); + + $this->assertInstanceOf(TypeError::class, $this->failureOf(fn () => $database->deleteCollection(self::COLLECTION))); + + $this->createNotes($database); + + $this->assertTrue( + $database->getDocument(self::COLLECTION, 'one')->isEmpty(), + 'The re-created collection served a document cached before the delete', + ); + } + + public function testDatabaseDeleteFlushesTheCacheWhenAHookFails(): void + { + $database = $this->database(new Memory()); + $this->createNotes($database); + $database->getCollection(self::COLLECTION); + $database->addHook(new FailingLifecycle(Event::DatabaseDelete, new TypeError('broken hook'))); + + $this->assertInstanceOf(TypeError::class, $this->failureOf(fn () => $database->delete())); + + $database->create(); + + $this->assertNull( + $database->findCollection(self::COLLECTION), + 'The re-created database served collection metadata cached before the delete', + ); + } + + public function testRelationshipRenameKeepsMetadataAndColumnsTogetherWhenAHookFails(): void + { + $database = $this->database(); + foreach (['parent', 'child'] as $id) { + $database->createCollection(Collection::create(id: $id, permissions: $this->permissions(), documentSecurity: false)); + } + $database->createRelationship('parent', Relationship::oneToMany( + relatedCollection: 'child', + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: RelationshipDeleteAction::SetNull, + )); + $database->createDocument('parent', new Document([Document::ID => 'owner'])); + $database->createDocument('child', new Document([Document::ID => 'member', 'parent' => 'owner'])); + $database->addHook(new FailingLifecycle(Event::AttributeUpdate, new TypeError('broken hook'))); + + $this->assertInstanceOf( + TypeError::class, + $this->failureOf(fn () => $database->updateRelationship('parent', 'children', new RelationshipUpdate(twoWayKey: 'owner'))), + ); + + $owner = $database->getDocument('child', 'member')->getAttribute('owner'); + + $this->assertSame( + 'owner', + $owner instanceof Document ? $owner->getId() : $owner, + 'The metadata names the renamed key while the column kept its old name', + ); + } + + private function database(?Adapter $adapter = null): Database + { + $database = new Database($adapter ?? new SQLite(new PDO('sqlite::memory:')), new Cache(new MemoryCache())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('hooks') + ->setNamespace('hooks_'.\uniqid()); + $database->create(); + + return $database; + } + + private function createNotes(Database $database): void + { + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: $this->permissions(), + documentSecurity: false, + )); + } + + /** + * @return list + */ + private function permissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @param callable(): mixed $operation + */ + private function failureOf(callable $operation): ?Throwable + { + try { + $operation(); + } catch (Throwable $failure) { + return $failure; + } + + return null; + } +} diff --git a/tests/unit/Event/HookFailureTest.php b/tests/unit/Event/HookFailureTest.php new file mode 100644 index 0000000000..ff1d8c3bb0 --- /dev/null +++ b/tests/unit/Event/HookFailureTest.php @@ -0,0 +1,162 @@ + + */ + public static function isolatedOperations(): iterable + { + yield 'database list' => [Event::DatabaseList, static fn (Database $database): mixed => $database->list()]; + yield 'collection create' => [Event::CollectionCreate, static fn (Database $database): mixed => $database->createCollection(Collection::create(id: 'comments'))]; + yield 'collection update' => [Event::CollectionUpdate, static fn (Database $database): mixed => $database->updateCollection(HookFixture::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true))]; + yield 'collection read' => [Event::CollectionRead, static fn (Database $database): mixed => $database->getCollection(HookFixture::COLLECTION)]; + yield 'collection list' => [Event::CollectionList, static fn (Database $database): mixed => $database->listCollections()]; + yield 'document purge from createAttribute' => [Event::DocumentPurge, static fn (Database $database): mixed => $database->createAttribute(HookFixture::COLLECTION, Attribute::string(key: 'summary', size: 64))]; + yield 'attribute create' => [Event::AttributeCreate, static fn (Database $database): mixed => $database->createAttribute(HookFixture::COLLECTION, Attribute::string(key: 'summary', size: 64))]; + yield 'attributes create' => [Event::AttributesCreate, static fn (Database $database): mixed => $database->createAttributes(HookFixture::COLLECTION, [Attribute::string(key: 'summary', size: 64)])]; + yield 'attribute update' => [Event::AttributeUpdate, static fn (Database $database): mixed => $database->updateAttribute(HookFixture::COLLECTION, 'title', new AttributeUpdate(required: true))]; + yield 'attribute delete' => [Event::AttributeDelete, static function (Database $database): void { + $database->deleteAttribute(HookFixture::COLLECTION, 'views'); + }]; + yield 'index rename' => [Event::IndexRename, static function (Database $database): void { + $database->createIndex(HookFixture::COLLECTION, Index::key(key: 'by_title', attributes: ['title'])); + $database->renameIndex(HookFixture::COLLECTION, 'by_title', 'by_heading'); + }]; + yield 'index delete' => [Event::IndexDelete, static function (Database $database): void { + $database->createIndex(HookFixture::COLLECTION, Index::key(key: 'by_title', attributes: ['title'])); + $database->deleteIndex(HookFixture::COLLECTION, 'by_title'); + }]; + yield 'collection delete' => [Event::CollectionDelete, static function (Database $database): void { + $database->deleteCollection(HookFixture::COLLECTION); + }]; + } + + /** + * Events 7.x dispatched without a try/catch, so a listener failure failed the call. + * + * @return iterable + */ + public static function propagatingOperations(): iterable + { + yield 'index create' => [Event::IndexCreate, static fn (Database $database): mixed => $database->createIndex(HookFixture::COLLECTION, Index::key(key: 'by_title', attributes: ['title']))]; + yield 'document read' => [Event::DocumentRead, static fn (Database $database): mixed => $database->getDocument(HookFixture::COLLECTION, 'first')]; + yield 'document create' => [Event::DocumentCreate, static fn (Database $database): mixed => $database->createDocument(HookFixture::COLLECTION, new Document([Document::ID => 'second', 'title' => 'second', 'views' => 2]))]; + yield 'documents create' => [Event::DocumentsCreate, static fn (Database $database): mixed => $database->createDocuments(HookFixture::COLLECTION, [new Document([Document::ID => 'second', 'title' => 'second', 'views' => 2])])]; + yield 'document update' => [Event::DocumentUpdate, static fn (Database $database): mixed => $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed']))]; + yield 'documents update' => [Event::DocumentsUpdate, static fn (Database $database): mixed => $database->updateDocuments(HookFixture::COLLECTION, new Document(['views' => 10]))]; + yield 'documents upsert' => [Event::DocumentsUpsert, static fn (Database $database): mixed => $database->upsertDocuments(HookFixture::COLLECTION, [new Document([Document::ID => 'first', 'title' => 'upserted', 'views' => 5])])]; + yield 'document increase' => [Event::DocumentIncrease, static fn (Database $database): mixed => $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views')]; + yield 'document decrease' => [Event::DocumentDecrease, static fn (Database $database): mixed => $database->decreaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views')]; + yield 'document delete' => [Event::DocumentDelete, static fn (Database $database): mixed => $database->deleteDocument(HookFixture::COLLECTION, 'first')]; + yield 'documents delete' => [Event::DocumentsDelete, static fn (Database $database): mixed => $database->deleteDocuments(HookFixture::COLLECTION)]; + yield 'document find' => [Event::DocumentFind, static fn (Database $database): mixed => $database->find(HookFixture::COLLECTION)]; + yield 'document find one' => [Event::DocumentFind, static fn (Database $database): mixed => $database->findOne(HookFixture::COLLECTION)]; + yield 'document count' => [Event::DocumentCount, static fn (Database $database): mixed => $database->count(HookFixture::COLLECTION)]; + yield 'document sum' => [Event::DocumentSum, static fn (Database $database): mixed => $database->sum(HookFixture::COLLECTION, 'views')]; + } + + /** + * @param Closure(Database): mixed $operation + */ + #[DataProvider('isolatedOperations')] + public function testIsolatedEventSwallowsHookExceptionAndRunsLaterHooks(Event $event, Closure $operation): void + { + $database = $this->database(); + $later = new RecordingLifecycle(); + $database + ->addHook(new FailingLifecycle($event, new RuntimeException('isolated'))) + ->addHook($later); + + $operation($database); + + $this->assertContains($event, $later->getEvents()); + } + + /** + * @param Closure(Database): mixed $operation + */ + #[DataProvider('propagatingOperations')] + public function testPropagatingEventSurfacesFirstHookException(Event $event, Closure $operation): void + { + $database = $this->database(); + $failure = new RuntimeException('propagated'); + $later = new RecordingLifecycle(); + $database + ->addHook(new FailingLifecycle($event, $failure)) + ->addHook($later); + + $this->assertSame($failure, $this->failureOf(static fn () => $operation($database))); + $this->assertNotContains($event, $later->getEvents()); + } + + /** + * @param Closure(Database): mixed $operation + */ + #[DataProvider('isolatedOperations')] + #[DataProvider('propagatingOperations')] + public function testErrorAlwaysSurfaces(Event $event, Closure $operation): void + { + $database = $this->database(); + $error = new TypeError('broken hook'); + $database->addHook(new FailingLifecycle($event, $error)); + + $this->assertSame($error, $this->failureOf(static fn () => $operation($database))); + } + + public function testAssertionFailureRaisedInsideHookReachesCaller(): void + { + $database = $this->database(); + $failure = new AssertionFailedError('expected another event'); + $database->addHook(new FailingLifecycle(Event::DocumentCreate, $failure)); + + $this->assertSame($failure, $this->failureOf(static fn () => $database->createDocument( + HookFixture::COLLECTION, + new Document([Document::ID => 'second', 'title' => 'second', 'views' => 2]), + ))); + } + + private function database(): Database + { + $database = HookFixture::sqlite(); + HookFixture::seed($database, ['first']); + + return $database; + } + + /** + * @param callable(): mixed $operation + */ + private function failureOf(callable $operation): ?Throwable + { + try { + $operation(); + } catch (Throwable $failure) { + return $failure; + } + + return null; + } +} diff --git a/tests/unit/Event/HookFixture.php b/tests/unit/Event/HookFixture.php new file mode 100644 index 0000000000..64aef088bc --- /dev/null +++ b/tests/unit/Event/HookFixture.php @@ -0,0 +1,72 @@ + $ids + */ + public static function seed(Database $database, array $ids): void + { + foreach ($ids as $index => $id) { + $database->createDocument(self::COLLECTION, new Document([ + Document::ID => $id, + 'title' => $id, + 'views' => $index + 1, + ])); + } + } + + public static function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('hooks') + ->setNamespace('hooks_'.\uniqid()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::integer(key: 'views'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + )); + + return $database; + } +} diff --git a/tests/unit/Event/NamedRecordingLifecycle.php b/tests/unit/Event/NamedRecordingLifecycle.php new file mode 100644 index 0000000000..4e79795ca0 --- /dev/null +++ b/tests/unit/Event/NamedRecordingLifecycle.php @@ -0,0 +1,19 @@ +name; + } +} diff --git a/tests/unit/Event/RecordingLifecycle.php b/tests/unit/Event/RecordingLifecycle.php new file mode 100644 index 0000000000..a962cfa97e --- /dev/null +++ b/tests/unit/Event/RecordingLifecycle.php @@ -0,0 +1,40 @@ + */ + private array $events = []; + + #[\Override] + public function handle(Domain $event): void + { + $this->events[] = $event; + } + + /** + * @return list + */ + public function getEvents(): array + { + return \array_map(static fn (Domain $event): Event => $event->event, $this->events); + } + + /** + * The typed events recorded for $event, in the order they fired. + * + * @return list + */ + public function received(Event $event): array + { + return \array_values(\array_filter( + $this->events, + static fn (Domain $recorded): bool => $recorded->event === $event, + )); + } +} diff --git a/tests/unit/Event/TypedEventTest.php b/tests/unit/Event/TypedEventTest.php new file mode 100644 index 0000000000..06fb3b6f9a --- /dev/null +++ b/tests/unit/Event/TypedEventTest.php @@ -0,0 +1,256 @@ +database = HookFixture::sqlite(); + $this->recorder = new RecordingLifecycle(); + $this->database->addHook($this->recorder); + } + + public function testEveryCaseButAllNamesAFinalEventClassThatCarriesIt(): void + { + foreach (Event::cases() as $case) { + $class = $case->domain(); + if ($case === Event::All) { + $this->assertNull($class); + + continue; + } + + $this->assertNotNull($class, $case->value.' has no event class'); + $this->assertTrue(\is_subclass_of($class, Domain::class), $class.' is not a Domain event'); + } + } + + public function testDatabaseEvents(): void + { + $database = HookFixture::memory(); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $database->list(); + $database->update('hooks', 'renamed'); + $database->delete('renamed'); + + $this->assertSame([Event::DatabaseList, Event::DatabaseUpdate, Event::DatabaseDelete], $recorder->getEvents()); + [$listed, $updated, $deleted] = [ + $recorder->received(Event::DatabaseList)[0], + $recorder->received(Event::DatabaseUpdate)[0], + $recorder->received(Event::DatabaseDelete)[0], + ]; + $this->assertInstanceOf(Event\Database\Listed::class, $listed); + $this->assertContainsOnlyInstancesOf(Document::class, $listed->databases); + $this->assertInstanceOf(Event\Database\Updated::class, $updated); + $this->assertSame(['hooks', 'renamed'], [$updated->database, $updated->new]); + $this->assertInstanceOf(Event\Database\Deleted::class, $deleted); + $this->assertSame('renamed', $deleted->database); + } + + public function testCollectionEvents(): void + { + $this->database->createCollection(Collection::create(id: 'comments')); + $this->database->getCollection('comments'); + $this->database->updateCollection('comments', new CollectionUpdate(documentSecurity: true)); + $collections = $this->database->listCollections(); + $this->database->deleteCollection('comments'); + + $created = $this->only(Event::CollectionCreate, Event\Collection\Created::class); + $this->assertSame('comments', $created->collection); + $this->assertSame('comments', $created->definition->getId()); + + $read = $this->only(Event::CollectionRead, Event\Collection\Read::class); + $this->assertSame('comments', $read->definition->getId()); + + $updated = $this->only(Event::CollectionUpdate, Event\Collection\Updated::class); + $this->assertTrue($updated->definition->documentSecurity()); + + $this->assertSame($collections, $this->only(Event::CollectionList, Event\Collection\Listed::class)->collections); + + $deleted = $this->only(Event::CollectionDelete, Event\Collection\Deleted::class); + $this->assertSame('comments', $deleted->collection); + } + + public function testAttributeEvents(): void + { + $this->database->createAttribute(HookFixture::COLLECTION, Attribute::string(key: 'summary', size: 64)); + $this->database->createAttributes(HookFixture::COLLECTION, [Attribute::integer(key: 'likes')]); + $this->database->updateAttribute(HookFixture::COLLECTION, 'likes', new AttributeUpdate(required: true)); + $this->database->renameAttribute(HookFixture::COLLECTION, 'summary', 'abstract'); + $this->database->deleteAttribute(HookFixture::COLLECTION, 'abstract'); + + $this->assertSame(['summary', 'likes'], \array_map( + static fn (Domain $event): string => $event instanceof Event\Attribute\Created ? $event->attribute->key : '', + $this->recorder->received(Event::AttributeCreate), + )); + + $batch = $this->only(Event::AttributesCreate, Event\Attribute\BatchCreated::class); + $this->assertSame(HookFixture::COLLECTION, $batch->collection); + $this->assertSame(['likes'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $batch->attributes)); + + $updated = $this->only(Event::AttributeUpdate, Event\Attribute\Updated::class); + $this->assertSame(['likes', true], [$updated->attribute->key, $updated->attribute->required]); + + $renamed = $this->only(Event::AttributeRename, Event\Attribute\Renamed::class); + $this->assertSame([HookFixture::COLLECTION, 'summary', 'abstract'], [$renamed->collection, $renamed->old, $renamed->attribute->key]); + + $deleted = $this->only(Event::AttributeDelete, Event\Attribute\Deleted::class); + $this->assertSame('abstract', $deleted->attribute->key); + } + + public function testIndexEvents(): void + { + $this->database->createIndex(HookFixture::COLLECTION, Index::key(key: 'byTitle', attributes: ['title'])); + $this->database->createIndexes(HookFixture::COLLECTION, [ + Index::key(key: 'byViews', attributes: ['views']), + Index::key(key: 'byBoth', attributes: ['title', 'views']), + ]); + $this->database->renameIndex(HookFixture::COLLECTION, 'byTitle', 'titles'); + $this->database->deleteIndex(HookFixture::COLLECTION, 'titles'); + + $this->assertSame(['byTitle', 'byViews', 'byBoth'], \array_map( + static fn (Domain $event): string => $event instanceof Event\Index\Created ? $event->index->key : '', + $this->recorder->received(Event::IndexCreate), + )); + + $batch = $this->only(Event::IndexesCreate, Event\Index\BatchCreated::class); + $this->assertSame(['byViews', 'byBoth'], \array_map(static fn (Index $index): string => $index->key, $batch->indexes)); + + $renamed = $this->only(Event::IndexRename, Event\Index\Renamed::class); + $this->assertSame(['byTitle', 'titles'], [$renamed->old, $renamed->index->key]); + + $deleted = $this->only(Event::IndexDelete, Event\Index\Deleted::class); + $this->assertSame([HookFixture::COLLECTION, 'titles'], [$deleted->collection, $deleted->index->key]); + } + + public function testDocumentEvents(): void + { + $collection = HookFixture::COLLECTION; + $this->database->createDocument($collection, new Document([Document::ID => 'first', 'title' => 'first', 'views' => 1])); + $this->database->getDocument($collection, 'first'); + $this->database->updateDocument($collection, 'first', new Document(['title' => 'renamed'])); + $this->database->upsertDocument($collection, new Document([Document::ID => 'second', 'title' => 'second', 'views' => 2])); + $this->database->increaseDocumentAttribute($collection, 'first', 'views', 2); + $this->database->decreaseDocumentAttribute($collection, 'first', 'views'); + $this->database->find($collection, [Query::orderAsc('title')]); + $this->database->count($collection); + $this->database->sum($collection, 'views'); + $this->database->deleteDocument($collection, 'second'); + + $this->assertSame('first', $this->only(Event::DocumentCreate, Event\Document\Created::class)->document->getId()); + $this->assertSame('first', $this->only(Event::DocumentRead, Event\Document\Read::class)->document->getId()); + $this->assertSame('renamed', $this->only(Event::DocumentUpdate, Event\Document\Updated::class)->document->getAttribute('title')); + + $upserted = $this->only(Event::DocumentUpsert, Event\Document\Upserted::class); + $this->assertSame(['second', true], [$upserted->document->getId(), $upserted->created]); + $this->assertSame([], $this->recorder->received(Event::DocumentsUpsert), 'A single upsert fires no batch event'); + + $increased = $this->only(Event::DocumentIncrease, Event\Document\Increased::class); + $this->assertSame(['views', 3], [$increased->attribute, $increased->document->getAttribute('views')]); + $decreased = $this->only(Event::DocumentDecrease, Event\Document\Decreased::class); + $this->assertSame(['views', 2], [$decreased->attribute, $decreased->document->getAttribute('views')]); + + $found = $this->only(Event::DocumentFind, Event\Document\Found::class); + $this->assertSame(['first', 'second'], \array_map(static fn (Document $document): string => $document->getId(), $found->documents)); + $counted = $this->only(Event::DocumentCount, Event\Document\Counted::class); + $this->assertSame([$collection, 2], [$counted->collection, $counted->count]); + + $summed = $this->only(Event::DocumentSum, Event\Document\Summed::class); + $this->assertSame(['views', 4], [$summed->attribute, $summed->sum]); + + $deleted = $this->only(Event::DocumentDelete, Event\Document\Deleted::class); + $this->assertSame([$collection, 'second', 2], [$deleted->collection, $deleted->document->getId(), $deleted->document->getAttribute('views')]); + + $this->assertContains($collection.'/second', \array_map( + static fn (Domain $event): string => $event instanceof Event\Document\Purged ? $event->collection.'/'.$event->id : '', + $this->recorder->received(Event::DocumentPurge), + )); + } + + public function testBatchUpsertCountsCreatedAndUpdatedDocuments(): void + { + HookFixture::seed($this->database, ['first']); + + $this->database->upsertDocuments(HookFixture::COLLECTION, [ + new Document([Document::ID => 'first', 'title' => 'renamed', 'views' => 1]), + new Document([Document::ID => 'second', 'title' => 'second', 'views' => 2]), + new Document([Document::ID => 'third', 'title' => 'third', 'views' => 3]), + ]); + + $batch = $this->only(Event::DocumentsUpsert, Event\Document\BatchUpserted::class); + $this->assertSame([HookFixture::COLLECTION, 2, 1, 3], [$batch->collection, $batch->created, $batch->updated, $batch->count]); + $this->assertSame([], $this->recorder->received(Event::DocumentUpsert)); + } + + public function testPermissionEventsCarryTheirCase(): void + { + $created = new Event\Permission\Created('posts', 'first', ['read("any")']); + $read = new Event\Permission\Read('posts', 'first', ['read("any")']); + $deleted = new Event\Permission\Deleted('posts', 'first', []); + + $this->assertSame( + [Event::PermissionsCreate, Event::PermissionsRead, Event::PermissionsDelete], + [$created->event, $read->event, $deleted->event], + ); + $this->assertSame(['posts', 'first', ['read("any")']], [$created->collection, $created->document, $created->permissions]); + } + + public function testNoEventIsBuiltForAnEventNoHookHandles(): void + { + $database = HookFixture::sqlite(); + $selective = new class () extends RecordingLifecycle implements Selective { + #[\Override] + public function handles(Event $event): bool + { + return $event === Event::DocumentCreate; + } + }; + $database->addHook($selective); + + HookFixture::seed($database, ['first']); + $database->getDocument(HookFixture::COLLECTION, 'first'); + $database->find(HookFixture::COLLECTION); + + $this->assertSame([Event::DocumentCreate], $selective->getEvents()); + } + + /** + * @template T of Domain + * + * @param class-string $class + * @return T + */ + private function only(Event $event, string $class): Domain + { + $received = $this->recorder->received($event); + $this->assertCount(1, $received, $event->value.' fired '.\count($received).' times'); + $this->assertInstanceOf($class, $received[0]); + + return $received[0]; + } +} diff --git a/tests/unit/Exception/HierarchyTest.php b/tests/unit/Exception/HierarchyTest.php new file mode 100644 index 0000000000..63e22b5fdf --- /dev/null +++ b/tests/unit/Exception/HierarchyTest.php @@ -0,0 +1,129 @@ +, class-string}> + */ + public static function parents(): array + { + return [ + 'schema' => [Schema::class, Exception::class], + 'structure' => [Structure::class, Schema::class], + 'type' => [Type::class, Schema::class], + 'character' => [Character::class, Schema::class], + 'truncate' => [Truncate::class, Schema::class], + 'index' => [Index::class, Schema::class], + 'dependency' => [Dependency::class, Schema::class], + 'limit' => [Limit::class, Schema::class], + 'relationship' => [Relationship::class, Schema::class], + 'query' => [Query::class, Exception::class], + 'order' => [Order::class, Query::class], + 'operator' => [Operator::class, Query::class], + 'transaction' => [Transaction::class, Exception::class], + 'contention' => [Contention::class, Transaction::class], + 'timeout' => [Timeout::class, Exception::class], + 'unconfirmed' => [Unconfirmed::class, Exception::class], + 'duplicate' => [Duplicate::class, Exception::class], + 'unique' => [Unique::class, Duplicate::class], + 'mismatch' => [Mismatch::class, Duplicate::class], + 'authorization' => [Authorization::class, Exception::class], + 'conflict' => [Conflict::class, Exception::class], + 'not found' => [NotFound::class, Exception::class], + 'restricted' => [Restricted::class, Exception::class], + 'refused' => [Refused::class, Exception::class], + ]; + } + + /** + * @param class-string $exception + * @param class-string $parent + */ + #[DataProvider('parents')] + public function testParent(string $exception, string $parent): void + { + $this->assertSame($parent, \get_parent_class($exception)); + } + + /** + * @return array}> + */ + public static function neverRetried(): array + { + return [ + 'timeout' => [Timeout::class], + 'unconfirmed' => [Unconfirmed::class], + 'refused' => [Refused::class], + ]; + } + + /** + * @param class-string $exception + */ + #[DataProvider('neverRetried')] + public function testOutsideTheTransactionSubtree(string $exception): void + { + $this->assertFalse(\is_subclass_of($exception, Transaction::class)); + } + + /** + * @return array + */ + public static function outsideSchema(): array + { + return [ + 'query' => [new Query('Invalid query')], + 'order' => [new Order('Invalid order')], + 'not found' => [new NotFound('Collection not found')], + 'duplicate' => [new Duplicate('Document already exists')], + 'transaction' => [new Transaction('Failed to start transaction')], + ]; + } + + #[DataProvider('outsideSchema')] + public function testOtherFailuresAreNotSchemaFailures(Exception $exception): void + { + $this->assertNotInstanceOf(Schema::class, $exception); + } + + public function testACatchOfQueryCatchesOrderAndOperator(): void + { + foreach ([new Order('Invalid order', 'name'), new Operator('Invalid operator')] as $failure) { + try { + throw $failure; + } catch (Query $caught) { + $this->assertSame($failure, $caught); + } + } + } +} diff --git a/tests/unit/Exception/StateTest.php b/tests/unit/Exception/StateTest.php new file mode 100644 index 0000000000..caa934beb8 --- /dev/null +++ b/tests/unit/Exception/StateTest.php @@ -0,0 +1,96 @@ + + */ + public static function codes(): array + { + return [ + 'integer' => [1062, 1062, null], + 'zero' => [0, 0, null], + 'alphanumeric sqlstate' => ['HY000', 0, 'HY000'], + 'numeric sqlstate' => ['23000', 23000, '23000'], + 'sqlstate with a leading zero' => ['08S01', 0, '08S01'], + 'empty string' => ['', 0, ''], + ]; + } + + #[DataProvider('codes')] + public function testTheCodeAndTheState(int|string $code, int $expectedCode, ?string $expectedState): void + { + $exception = new Exception('failed', $code); + + $this->assertSame($expectedCode, $exception->getCode()); + $this->assertSame($expectedState, $exception->state); + } + + public function testEveryArgumentIsOptional(): void + { + $exception = new Exception(); + + $this->assertSame('', $exception->getMessage()); + $this->assertSame(0, $exception->getCode()); + $this->assertNull($exception->state); + $this->assertNull($exception->getPrevious()); + } + + public function testAMappedDriverErrorKeepsItsSqlstate(): void + { + $driver = new class () extends PDOException { + public function __construct() + { + parent::__construct('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'); + $this->code = 'HY000'; + } + }; + + $exception = new Duplicate('Document already exists', $driver->getCode(), $driver); + + $this->assertSame('HY000', $exception->state); + $this->assertSame(0, $exception->getCode()); + $this->assertSame($driver, $exception->getPrevious()); + } + + public function testSubclassesKeepTheState(): void + { + $previous = new RuntimeException('previous'); + $exception = new Structure('Invalid document structure', '42S22', $previous); + + $this->assertSame('42S22', $exception->state); + $this->assertSame($previous, $exception->getPrevious()); + } + + public function testOrderTakesTheAttributeBeforeTheCode(): void + { + $previous = new RuntimeException('previous'); + $exception = new Order('Order attribute is empty', 'name', 'HY000', $previous); + + $this->assertSame('Order attribute is empty', $exception->getMessage()); + $this->assertSame('name', $exception->getAttribute()); + $this->assertSame(0, $exception->getCode()); + $this->assertSame('HY000', $exception->state); + $this->assertSame($previous, $exception->getPrevious()); + } + + public function testOrderWithoutAnAttribute(): void + { + $exception = new Order('Invalid order'); + + $this->assertNull($exception->getAttribute()); + $this->assertSame(0, $exception->getCode()); + $this->assertNull($exception->state); + } +} diff --git a/tests/unit/Exception/UniqueTest.php b/tests/unit/Exception/UniqueTest.php new file mode 100644 index 0000000000..7f111de6db --- /dev/null +++ b/tests/unit/Exception/UniqueTest.php @@ -0,0 +1,27 @@ +assertSame('Unique index violation', $exception->getMessage()); + $this->assertSame(42, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } + + public function testSpecificMessageIsPreserved(): void + { + $exception = new Unique('Custom unique conflict'); + + $this->assertSame('Custom unique conflict', $exception->getMessage()); + } +} diff --git a/tests/unit/Filter/CodecTest.php b/tests/unit/Filter/CodecTest.php new file mode 100644 index 0000000000..28c6a2d283 --- /dev/null +++ b/tests/unit/Filter/CodecTest.php @@ -0,0 +1,209 @@ +database($adapter)->setFilters($registry); + $registry->register(new Reversed()); + + $this->createNote($database, 'hello', ['reversed']); + + $this->assertSame('olleh', $adapter->getDocument($database->getCollection('notes'), 'note')->getAttribute('body')); + $this->assertSame('hello', $database->getDocument('notes', 'note')->getAttribute('body')); + } + + public function testHandlesWithoutTheRegistryCannotEncodeItsTypes(): void + { + $this->database()->setFilters($this->registry(new Reversed())); + + $this->expectException(NotFoundException::class); + $this->database()->encode($this->notes(), new Document(['body' => 'hello'])); + } + + public function testHandlesWithoutTheRegistryCannotDecodeItsTypes(): void + { + $this->database()->setFilters($this->registry(new Reversed())); + + $this->expectException(NotFoundException::class); + $this->database()->decode($this->notes(), new Document(['body' => 'olleh'])); + } + + public function testABuiltInNameCannotBreakOtherHandles(): void + { + $adapter = new Memory(); + $this->createNote($this->database($adapter), 'hello'); + + $registry = new Registry(); + $this->database()->setFilters($registry); + + try { + $registry->register(new Reversed('json')); + } catch (DuplicateException) { + } + + $this->assertSame('hello', $this->database($adapter)->getDocument('notes', 'note')->getAttribute('body')); + } + + public function testARegisteredTypeShadowsAGlobalFilterOnlyOnItsHandle(): void + { + $database = $this->database()->setFilters($this->registry(new Reversed())); + $other = $this->database(); + + $previous = FilterRegistry::filters(); + + try { + Database::addFilter( + 'reversed', + static fn (mixed $value): mixed => $value, + static fn (mixed $value): string => 'global', + ); + + $this->assertSame('hello', $database->decode($this->notes(), new Document(['body' => 'olleh']))->getAttribute('body')); + $this->assertSame('global', $other->decode($this->notes(), new Document(['body' => 'olleh']))->getAttribute('body')); + } finally { + FilterRegistry::restore($previous, FilterRegistry::defaultsRegistered()); + } + } + + public function testRegisteringATypeChangesOnlyItsHandlesCacheKeys(): void + { + $plain = $this->database(); + $before = $this->documentHash($plain); + + $typed = $this->database()->setFilters($this->registry(new Reversed())); + + $this->assertNotSame($before, $this->documentHash($typed)); + $this->assertSame($before, $this->documentHash($plain)); + } + + public function testCacheKeysFollowTheTypeClass(): void + { + $reversed = $this->database()->setFilters($this->registry(new Reversed('text'))); + $rot13 = $this->database()->setFilters($this->registry(new Rot13('text'))); + $sameClass = $this->database()->setFilters($this->registry(new Reversed('text'))); + + $this->assertNotSame($this->documentHash($reversed), $this->documentHash($rot13)); + $this->assertNotSame( + $reversed->getQueryCacheField(null, [Query::limit(1)]), + $rot13->getQueryCacheField(null, [Query::limit(1)]), + ); + $this->assertSame($this->documentHash($reversed), $this->documentHash($sameClass)); + } + + public function testCacheKeysFollowTheSignatureOfASignedCodec(): void + { + $first = $this->database()->setFilters($this->registry(new CodecTestPrefixed('a:'))); + $second = $this->database()->setFilters($this->registry(new CodecTestPrefixed('b:'))); + $sameSignature = $this->database()->setFilters($this->registry(new CodecTestPrefixed('a:'))); + + $this->assertNotSame($this->documentHash($first), $this->documentHash($second)); + $this->assertSame($this->documentHash($first), $this->documentHash($sameSignature)); + } + + public function testConstructorFiltersEncodeAndDecodeOnTheirHandle(): void + { + $adapter = new Memory(); + $database = $this->database($adapter, [new Reversed()]); + + $this->createNote($database, 'hello', ['reversed']); + + $this->assertSame('olleh', $adapter->getDocument($database->getCollection('notes'), 'note')->getAttribute('body')); + $this->assertSame('hello', $database->getDocument('notes', 'note')->getAttribute('body')); + $this->assertTrue($database->getFilters()->has('reversed')); + } + + public function testSetFiltersReplacesTheConstructorFilters(): void + { + $identity = static fn (mixed $value): mixed => $value; + $database = $this->database(filters: [new Callback('reversed', $identity, $identity)]) + ->setFilters($this->registry(new Reversed())); + $registryOnly = $this->database()->setFilters($this->registry(new Reversed())); + + $this->assertSame('hello', $database->decode($this->notes(), new Document(['body' => 'olleh']))->getAttribute('body')); + $this->assertSame($this->documentHash($registryOnly), $this->documentHash($database)); + } + + public function testAConstructorFilterNamedAfterABuiltInIsRejected(): void + { + $this->expectException(DuplicateException::class); + + $this->database(filters: [new Reversed('json')]); + } + + /** + * @param list $filters + */ + private function database(?Memory $adapter = null, array $filters = []): Database + { + return (new Database($adapter ?? new Memory(), new Cache(new None()), $filters)) + ->setDatabase('types') + ->setNamespace('types'); + } + + private function registry(Codec $type): Registry + { + $registry = new Registry(); + $registry->register($type); + + return $registry; + } + + private function notes(): Document + { + return new Document([ + '$id' => 'notes', + 'attributes' => [ + new Document([ + '$id' => 'body', + 'type' => ColumnType::String->value, + 'array' => false, + 'filters' => ['reversed'], + ]), + ], + ]); + } + + /** + * @param list $filters + */ + private function createNote(Database $database, string $body, array $filters = []): void + { + $database->create(); + $database->createCollection(Collection::create(id: 'notes')); + $database->createAttribute('notes', Attribute::string(key: 'body', filters: $filters)); + $database->createDocument('notes', new Document([ + '$id' => 'note', + '$permissions' => [Permission::read(Role::any())], + 'body' => $body, + ])); + } + + private function documentHash(Database $database): string + { + return $database->getCacheKeys('notes', 'note')[2]; + } +} diff --git a/tests/unit/Filter/CodecTestPrefixed.php b/tests/unit/Filter/CodecTestPrefixed.php new file mode 100644 index 0000000000..6b946bed9c --- /dev/null +++ b/tests/unit/Filter/CodecTestPrefixed.php @@ -0,0 +1,39 @@ +prefix.$value : $value; + } + + #[\Override] + public function decode(mixed $value): mixed + { + return \is_string($value) && \str_starts_with($value, $this->prefix) ? \substr($value, \strlen($this->prefix)) : $value; + } + + #[\Override] + public function signature(): string + { + return self::class.':'.$this->prefix; + } +} diff --git a/tests/unit/Filter/Reversed.php b/tests/unit/Filter/Reversed.php new file mode 100644 index 0000000000..131603475e --- /dev/null +++ b/tests/unit/Filter/Reversed.php @@ -0,0 +1,30 @@ +name; + } + + #[\Override] + public function encode(mixed $value): mixed + { + return \is_string($value) ? \strrev($value) : $value; + } + + #[\Override] + public function decode(mixed $value): mixed + { + return \is_string($value) ? \strrev($value) : $value; + } +} diff --git a/tests/unit/Filter/Rot13.php b/tests/unit/Filter/Rot13.php new file mode 100644 index 0000000000..321f36d3e7 --- /dev/null +++ b/tests/unit/Filter/Rot13.php @@ -0,0 +1,30 @@ +name; + } + + #[\Override] + public function encode(mixed $value): mixed + { + return \is_string($value) ? \str_rot13($value) : $value; + } + + #[\Override] + public function decode(mixed $value): mixed + { + return \is_string($value) ? \str_rot13($value) : $value; + } +} diff --git a/tests/unit/FilterRegistry.php b/tests/unit/FilterRegistry.php new file mode 100644 index 0000000000..5e2c5a29d2 --- /dev/null +++ b/tests/unit/FilterRegistry.php @@ -0,0 +1,35 @@ + + */ + public static function filters(): array + { + return self::$filters; + } + + public static function defaultsRegistered(): bool + { + return self::$defaultFiltersRegistered; + } + + /** + * @param array $filters + */ + public static function restore(array $filters, bool $defaultsRegistered): void + { + self::$filters = $filters; + self::$defaultFiltersRegistered = $defaultsRegistered; + } + + public static function clear(): void + { + self::restore([], false); + } +} diff --git a/tests/unit/FilterRegistryTest.php b/tests/unit/FilterRegistryTest.php index f817efce79..6d0c261efb 100644 --- a/tests/unit/FilterRegistryTest.php +++ b/tests/unit/FilterRegistryTest.php @@ -2,13 +2,26 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Tests\Unit\Filter\Reversed; +use Tests\Unit\Filter\Rot13; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Filter; +use Utopia\Database\Filter\Callback; +use Utopia\Database\Filter\Codec; +use Utopia\Database\Filter\Registry; +use Utopia\Database\Permission; +use Utopia\Database\Role; +use Utopia\Query\Schema\ColumnType; class FilterRegistryTest extends TestCase { @@ -23,24 +36,25 @@ class FilterRegistryTest extends TestCase /** * @var array */ - private array $registry; + private array $registry = []; + #[\Override] protected function setUp(): void { $this->adapter = new DatabaseMemory(); $this->cache = new Cache(new HashAwareMemoryCache()); - $this->namespace = 'filter_registry_' . \uniqid(); + $this->namespace = 'filter_registry_'.\uniqid(); $this->database = $this->createDatabase(); // Snapshot once the constructor has registered the built-ins, so the // restore in tearDown puts back a populated registry rather than an // empty one. - $this->registry = (new \ReflectionProperty(Database::class, 'filters'))->getValue(); + $this->registry = FilterRegistry::filters(); $this->database->create(); - $this->database->createCollection('projects'); - $this->database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $this->database->createCollection(Collection::create(id: 'projects')); + $this->database->createAttribute('projects', Attribute::string(key: 'name', size: 255)); $this->database->createDocument('projects', new Document([ '$id' => 'project', '$permissions' => [Permission::read(Role::any())], @@ -48,42 +62,102 @@ protected function setUp(): void ])); } + #[\Override] protected function tearDown(): void { // addFilter() writes to a static registry with no removal API, so a test // registering one would otherwise leak into every later test. - (new \ReflectionProperty(Database::class, 'filters'))->setValue(null, $this->registry); - (new \ReflectionProperty(Database::class, 'defaultFiltersRegistered'))->setValue(null, true); + FilterRegistry::restore($this->registry, true); } - private function createDatabase(): Database + public function testRegisteredCodecsAreFoundByName(): void { - $database = new Database($this->adapter, $this->cache); + $registry = new Registry(); + $reversed = new Reversed(); + $rot13 = new Rot13(); + + $this->assertSame($registry, $registry->register($reversed)->register($rot13)); + $this->assertSame($reversed, $registry->get('reversed')); + $this->assertSame($rot13, $registry->get('rot13')); + $this->assertTrue($registry->has('reversed')); + } - return $database - ->setDatabase('utopiaTests') - ->setNamespace($this->namespace); + public function testAnUnknownNameIsNeitherFoundNorHeld(): void + { + $registry = (new Registry())->register(new Reversed()); + + $this->assertNull($registry->get('rot13')); + $this->assertFalse($registry->has('rot13')); + } + + public function testRegisteringANameAgainReplacesTheCodec(): void + { + $replacement = new Rot13('reversed'); + $registry = (new Registry())->register(new Reversed())->register($replacement); + + $this->assertSame($replacement, $registry->get('reversed')); } /** - * Write through the adapter, bypassing Database and therefore the cache - * purge, so the cache holds a copy the source no longer agrees with. A read - * returning 'cached' was served from the cache; one returning 'fresh' missed - * and went to the adapter. + * @return iterable */ - private function writeBehindTheCache(string $value): void + public static function builtInFilters(): iterable { - $collection = $this->database->getCollection('projects'); - $document = $this->adapter->getDocument($collection, 'project'); - $document->setAttribute('name', $value); - $this->adapter->updateDocument($collection, 'project', $document, true); + foreach (Filter::cases() as $filter) { + yield $filter->value => [$filter->value]; + } } - private function read(?Database $database = null): string + #[DataProvider('builtInFilters')] + public function testBuiltInFilterNamesAreRejected(string $name): void { - return ($database ?? $this->database) - ->getDocument('projects', 'project') - ->getAttribute('name'); + $registry = new Registry(); + + try { + $registry->register(new Reversed($name)); + $this->fail("registering a codec named \"{$name}\" must be rejected"); + } catch (DuplicateException $exception) { + $this->assertStringContainsString("\"{$name}\"", $exception->getMessage()); + } + + $this->assertFalse($registry->has($name)); + } + + public function testDefaultFiltersNameEveryBuiltInFilter(): void + { + FilterRegistry::clear(); + new Database(new DatabaseMemory(), new Cache(new HashAwareMemoryCache())); + + $names = \array_keys(FilterRegistry::filters()); + $expected = Database::DEFAULT_FILTERS; + $cases = \array_map(static fn (Filter $filter): string => $filter->value, Filter::cases()); + \sort($names); + \sort($expected); + \sort($cases); + + $this->assertSame($expected, $names); + $this->assertSame($expected, $cases); + } + + public function testCallbackRunsItsClosures(): void + { + $callback = new Callback( + 'wrapped', + static fn (mixed $value): mixed => \is_string($value) ? '['.$value.']' : $value, + static fn (mixed $value): mixed => \is_string($value) ? \trim($value, '[]') : $value, + ); + + $this->assertSame('wrapped', $callback->name()); + $this->assertSame('[value]', $callback->encode('value')); + $this->assertSame('value', $callback->decode('[value]')); + } + + public function testRegisteringOnARegistryLeavesTheGlobalFiltersUntouched(): void + { + (new Registry())->register(new Reversed()); + + $this->expectException(NotFoundException::class); + $this->createDatabase()->decode($this->notes('reversed'), new Document(['body' => 'olleh'])); } public function testRegisteringAGlobalFilterStopsStaleEntriesBeingServed(): void @@ -103,31 +177,22 @@ public function testRegisteringAGlobalFilterStopsStaleEntriesBeingServed(): void ); } - public function testChangingInstanceFiltersStopsStaleEntriesBeingServed(): void + public function testRegisteringOnASharedRegistryStopsStaleEntriesBeingServed(): void { - $database = new class ($this->adapter, $this->cache) extends Database { - public function swapInstanceFilter(string $signature): void - { - $noop = fn (mixed $value) => $value; - - $this->instanceFilters = [ - 'probe' => ['encode' => $noop, 'decode' => $noop, 'signature' => $signature], - ]; - } - }; - $database->setDatabase('utopiaTests')->setNamespace($this->namespace); + $registry = new Registry(); + $database = $this->createDatabase()->setFilters($registry); $this->assertSame('cached', $this->read($database)); $this->writeBehindTheCache('fresh'); $this->assertSame('cached', $this->read($database), 'read should still be served from cache'); - $database->swapInstanceFilter('v2'); + $registry->register(new Reversed('probe')); $this->assertSame( 'fresh', $this->read($database), - 'a subclass replacing its instance filters must not keep serving the previous entry', + 'a codec registered after the handle cached a document must not keep the previous entry served', ); } @@ -135,8 +200,7 @@ public function testOverridingABuiltInFilterBeforeTheFirstInstanceStillWins(): v { // A fresh process: nothing has constructed a Database yet, so the // built-ins are not in the registry. - (new \ReflectionProperty(Database::class, 'filters'))->setValue(null, []); - (new \ReflectionProperty(Database::class, 'defaultFiltersRegistered'))->setValue(null, false); + FilterRegistry::clear(); $identity = fn (mixed $value) => $value; Database::addFilter('datetime', $identity, $identity); @@ -147,7 +211,7 @@ public function testOverridingABuiltInFilterBeforeTheFirstInstanceStillWins(): v 'attributes' => [ new Document([ '$id' => 'occurredAt', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'array' => false, 'filters' => ['datetime'], ]), @@ -176,4 +240,169 @@ public function testInstancesSharingAConfigShareCachedDocuments(): void 'a later instance with the same config must hit the entry the first one cached', ); } + + public function testFilterEncodeFailureIsADatabaseExceptionWithTheOriginalAsPrevious(): void + { + $failure = new \InvalidArgumentException('cannot encode the probe', 7); + Database::addFilter( + 'failingEncode', + static fn (mixed $value) => throw $failure, + static fn (mixed $value) => $value, + ); + + $this->assertEncodeFailureWrapped($this->database, 'failingEncode', $failure); + } + + public function testCodecEncodeFailureIsADatabaseExceptionWithTheOriginalAsPrevious(): void + { + $failure = new \DomainException('cannot encode the custom probe', 11); + $codec = new class ($failure) implements Codec { + public function __construct(private readonly \DomainException $failure) + { + } + + #[\Override] + public function name(): string + { + return 'failingCodec'; + } + + #[\Override] + public function encode(mixed $value): mixed + { + throw $this->failure; + } + + #[\Override] + public function decode(mixed $value): mixed + { + return $value; + } + }; + + $database = new Database($this->adapter, $this->cache, [$codec]); + + $this->assertEncodeFailureWrapped($database, 'failingCodec', $failure); + } + + /** + * @return array + */ + public static function nonClosureCallables(): array + { + $first = new class () { + public function transform(mixed $value): mixed + { + return $value; + } + }; + $second = new class () { + public function transform(mixed $value): mixed + { + return $value; + } + }; + + return [ + 'string callables' => ['trim', 'strtolower'], + 'static array callables' => [[self::class, 'identity'], [self::class, 'passthrough']], + 'instance array callables' => [[$first, 'transform'], [$second, 'transform']], + ]; + } + + #[DataProvider('nonClosureCallables')] + public function testReplacingANonClosureFilterStopsStaleEntriesBeingServed(callable $original, callable $replacement): void + { + Database::addFilter('replaceable', $original, $original); + $this->assertSame('cached', $this->read()); + + $this->writeBehindTheCache('fresh'); + $this->assertSame('cached', $this->read(), 'read should still be served from cache'); + + Database::addFilter('replaceable', $replacement, $replacement); + + $this->assertSame( + 'fresh', + $this->read(), + 'a filter replaced by another callable under the same name must not keep serving the previous entry', + ); + } + + public static function identity(mixed $value): mixed + { + return $value; + } + + public static function passthrough(mixed $value): mixed + { + return $value; + } + + private function createDatabase(): Database + { + $database = new Database($this->adapter, $this->cache); + + return $database + ->setDatabase('utopiaTests') + ->setNamespace($this->namespace); + } + + /** + * Write through the adapter, bypassing Database and therefore the cache + * purge, so the cache holds a copy the source no longer agrees with. A read + * returning 'cached' was served from the cache; one returning 'fresh' missed + * and went to the adapter. + */ + private function writeBehindTheCache(string $value): void + { + $collection = $this->database->getCollection('projects'); + $document = $this->adapter->getDocument($collection, 'project'); + $document->setAttribute('name', $value); + $this->adapter->updateDocument($collection, 'project', $document, true); + } + + private function read(?Database $database = null): mixed + { + return ($database ?? $this->database) + ->getDocument('projects', 'project') + ->getAttribute('name'); + } + + private function notes(string $filter): Document + { + return new Document([ + '$id' => 'notes', + 'attributes' => [ + new Document([ + '$id' => 'body', + 'type' => ColumnType::String->value, + 'array' => false, + 'filters' => [$filter], + ]), + ], + ]); + } + + private function assertEncodeFailureWrapped(Database $database, string $filter, \Throwable $failure): void + { + $collection = new Document([ + '$id' => 'probes', + 'attributes' => [new Document([ + '$id' => 'probe', + 'type' => ColumnType::String->value, + 'array' => false, + 'filters' => [$filter], + ])], + ]); + + try { + $database->encode($collection, new Document(['$id' => 'probe', 'probe' => 'value'])); + $this->fail('encode() must rethrow the failure of '.$filter); + } catch (DatabaseException $error) { + $this->assertSame(DatabaseException::class, $error::class); + $this->assertSame($failure->getMessage(), $error->getMessage()); + $this->assertSame($failure->getCode(), $error->getCode()); + $this->assertSame($failure, $error->getPrevious()); + } + } } diff --git a/tests/unit/FlatAggregateTest.php b/tests/unit/FlatAggregateTest.php new file mode 100644 index 0000000000..64ac6be384 --- /dev/null +++ b/tests/unit/FlatAggregateTest.php @@ -0,0 +1,288 @@ +pdo = new PDO('sqlite::memory:'); + $this->database = new Database(new SQLite($this->pdo), new Cache(new NoCache())); + $this->database + ->setDatabase('flat_aggregate') + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + + foreach (['items', 'labels'] as $collection) { + $this->database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'category', size: 16), + Attribute::integer(key: 'price'), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + } + + foreach ([ + ['i1', 'a', 10, Role::any()], + ['i2', 'a', 20, Role::any()], + ['i3', 'a', 30, Role::user('other')], + ['i4', 'b', 40, Role::any()], + ['i5', 'a', 50, Role::user('other')], + ] as [$id, $category, $price, $reader]) { + $this->database->createDocument('items', new Document([ + '$id' => $id, + '$permissions' => [Permission::read($reader)], + 'category' => $category, + 'price' => $price, + ])); + } + + $this->database->createDocument('labels', new Document([ + '$id' => 'l1', + '$permissions' => [Permission::read(Role::any())], + 'category' => 'a', + 'price' => 0, + ])); + } + + public function testFilteredCountRunsOneFlatStatement(): void + { + [$count, $statements] = $this->profile(fn (): int => $this->database->count('items', [Query::equal('category', ['a'])])); + + $this->assertSame(2, $count); + $this->assertCount(1, $statements, \implode("\n", $statements)); + $this->assertFlat($statements[0]); + $this->assertStringContainsString('COUNT(1)', $statements[0]); + $this->assertStringContainsString('_perms', $statements[0], 'the permission subquery is part of the statement'); + } + + public function testFilteredSumRunsOneFlatStatement(): void + { + [$sum, $statements] = $this->profile(fn (): int|float => $this->database->sum('items', 'price', [Query::equal('category', ['a'])])); + + $this->assertSame(30, $sum); + $this->assertCount(1, $statements, \implode("\n", $statements)); + $this->assertFlat($statements[0]); + $this->assertStringContainsString('SUM(', $statements[0]); + $this->assertStringContainsString('_perms', $statements[0], 'the permission subquery is part of the statement'); + } + + public function testNestedFiltersStayFlat(): void + { + [$count, $statements] = $this->profile(fn (): int => $this->database->count('items', [ + Query::or([Query::equal('category', ['b']), Query::lessThan('price', 15)]), + ])); + + $this->assertSame(2, $count); + $this->assertCount(1, $statements); + $this->assertFlat($statements[0]); + } + + public function testFilteredCountWithoutAuthorizationStaysFlat(): void + { + [$count, $statements] = $this->database->getAuthorization()->skip(fn (): array => $this->profile( + fn (): int => $this->database->count('items', [Query::equal('category', ['a'])]), + )); + + $this->assertSame(4, $count); + $this->assertCount(1, $statements); + $this->assertFlat($statements[0]); + } + + public function testBoundedCountKeepsItsLimit(): void + { + [$count, $statements] = $this->database->getAuthorization()->skip(fn (): array => $this->profile( + fn (): int => $this->database->count('items', [Query::equal('category', ['a'])], 2), + )); + + $this->assertSame(2, $count); + $this->assertCount(1, $statements); + $this->assertStringContainsString('table_count', $statements[0], 'a bound on the rows keeps the derived table'); + $this->assertStringContainsString('LIMIT', $statements[0]); + } + + public function testBoundedSumKeepsItsLimit(): void + { + [$sum, $statements] = $this->database->getAuthorization()->skip(fn (): array => $this->profile( + fn (): int|float => $this->database->sum('items', 'price', [Query::equal('category', ['a'])], 2), + )); + + $this->assertContains($sum, [30, 40, 50, 60, 70, 80], 'the sum of two of the four matching prices'); + $this->assertCount(1, $statements); + $this->assertStringContainsString('table_count', $statements[0]); + $this->assertStringContainsString('LIMIT', $statements[0]); + } + + public function testJoinedCountKeepsTheDerivedTable(): void + { + $join = Query::join('labels', 'label', [Query::on('category', 'category')]); + + [$count, $statements] = $this->profile(fn (): int => $this->database->count('items', [$join])); + $this->assertSame(2, $count); + $this->assertCount(1, $statements); + $this->assertStringContainsString('table_count', $statements[0], 'a join keeps the derived table'); + + [$sum] = $this->profile(fn (): int|float => $this->database->sum('items', 'price', [$join])); + $this->assertSame(30, $sum); + } + + public function testBoundedJoinedCountKeepsItsLimit(): void + { + $this->assertSame(1, $this->database->count('items', [Query::join('labels', 'label', [Query::on('category', 'category')])], 1)); + $this->assertSame(3, $this->database->count('items', [Query::fullOuterJoin('labels', 'label', [Query::on('category', 'category')])])); + $this->assertSame(2, $this->database->count('items', [Query::fullOuterJoin('labels', 'label', [Query::on('category', 'category')])], 2)); + } + + public function testANonNumericAggregateCountsAsZero(): void + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('closeCursor')->willReturn(true); + $statement->method('fetch')->willReturn(['sum' => 'not a number']); + $statement->method('fetchAll')->willReturn([['sum' => 'not a number']]); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturn($statement); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $collection = new Document(['$id' => 'items']); + $filter = [Query::equal('category', ['a'])]; + + $this->assertSame(0, $adapter->sum($collection, 'price'), 'unfiltered'); + $this->assertSame(0, $adapter->sum($collection, 'price', $filter), 'filtered'); + $this->assertSame(0, $adapter->sum($collection, 'price', $filter, 2), 'bounded'); + $this->assertSame(0, $adapter->count($collection), 'unfiltered'); + $this->assertSame(0, $adapter->count($collection, $filter), 'filtered'); + $this->assertSame(0, $adapter->count($collection, $filter, 2), 'bounded'); + } + + public function testPermissionFilteredCountIsUnchanged(): void + { + $this->assertSame(3, $this->database->count('items')); + $this->assertSame(70, $this->database->sum('items', 'price')); + $this->assertSame(0, $this->database->count('items', [Query::equal('category', ['c'])])); + $this->assertSame(0, $this->database->sum('items', 'price', [Query::equal('category', ['c'])])); + + $this->database->getAuthorization()->addRole(Role::user('other')->toString()); + $this->assertSame(5, $this->database->count('items')); + $this->assertSame(4, $this->database->count('items', [Query::equal('category', ['a'])])); + $this->assertSame(110, $this->database->sum('items', 'price', [Query::equal('category', ['a'])])); + } + + public function testABuilderRefusalIsAQueryException(): void + { + $join = new Query(Method::Join, 'labels', [Query::on('category', 'category'), Query::limit(1)], 'label'); + $this->database->setValidation(false); + + foreach ([ + 'count()' => fn (): int => $this->database->count('items', [$join]), + 'sum()' => fn (): int|float => $this->database->sum('items', 'price', [$join]), + ] as $method => $read) { + try { + $read(); + $this->fail($method.': the builder\'s refusal was not raised'); + } catch (QueryException $error) { + $this->assertSame('Unsupported join ON condition: limit', $error->getMessage(), $method); + } + } + } + + public function testAnEngineErrorWhilePreparingIsMapped(): void + { + $this->pdo->exec('DROP TABLE `'.self::NAMESPACE.'_items`'); + + foreach ([ + 'filtered count()' => fn (): int => $this->database->count('items', [Query::equal('category', ['a'])]), + 'filtered sum()' => fn (): int|float => $this->database->sum('items', 'price', [Query::equal('category', ['a'])]), + 'bounded count()' => fn (): int => $this->database->count('items', [], 2), + 'unfiltered count()' => fn (): int => $this->database->getAuthorization()->skip(fn (): int => $this->database->count('items')), + 'unfiltered sum()' => fn (): int|float => $this->database->getAuthorization()->skip(fn (): int|float => $this->database->sum('items', 'price')), + ] as $method => $read) { + try { + $read(); + $this->fail($method.': the missing table was not reported'); + } catch (NotFoundException $error) { + $this->assertSame('Collection not found', $error->getMessage(), $method); + } + } + } + + /** + * Run $read with the profiler on and return its result with the statements it ran on the + * collection's own table. + * + * @template T + * + * @param callable(): T $read + * @return array{T, list} + */ + private function profile(callable $read): array + { + $profiler = $this->database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + + try { + $profiler->reset(); + $result = $read(); + } finally { + $this->database->setProfiling(false); + } + + $statements = []; + foreach ($profiler->getLogs() as $log) { + if (\str_contains($log->query, self::NAMESPACE.'_items`')) { + $statements[] = $log->query; + } + } + + return [$result, $statements]; + } + + private function assertFlat(string $statement): void + { + $this->assertStringNotContainsString('table_count', $statement, 'no derived table'); + $this->assertStringNotContainsString('FROM (SELECT', $statement, 'no derived table'); + } +} diff --git a/tests/unit/ForUpdateCacheTest.php b/tests/unit/ForUpdateCacheTest.php index c1297da86c..d35e552418 100644 --- a/tests/unit/ForUpdateCacheTest.php +++ b/tests/unit/ForUpdateCacheTest.php @@ -6,10 +6,12 @@ use Utopia\Cache\Adapter\Memory as CacheMemory; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Permission; +use Utopia\Database\Role; class ForUpdateCacheTest extends TestCase { @@ -17,6 +19,7 @@ class ForUpdateCacheTest extends TestCase private Database $database; + #[\Override] protected function setUp(): void { $this->adapter = new DatabaseMemory(); @@ -26,9 +29,9 @@ protected function setUp(): void ->setNamespace('for_update_' . \uniqid()); $this->database->create(); - $this->database->createCollection('projects'); - $this->database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); - $this->database->createAttribute('projects', 'description', Database::VAR_STRING, 255, false); + $this->database->createCollection(Collection::create(id: 'projects')); + $this->database->createAttribute('projects', Attribute::string(key: 'name')); + $this->database->createAttribute('projects', Attribute::string(key: 'description')); $this->database->createDocument('projects', new Document([ '$id' => 'project', '$permissions' => [ diff --git a/tests/unit/Format.php b/tests/unit/Format.php index f4f4a4a0f5..ed968d80e1 100644 --- a/tests/unit/Format.php +++ b/tests/unit/Format.php @@ -8,8 +8,6 @@ * Format Test for Email * * Validate that an variable is a valid email address - * - * @package Utopia\Validator */ class Format extends Text { @@ -17,9 +15,8 @@ class Format extends Text * Get Description * * Returns validator description - * - * @return string */ + #[\Override] public function getDescription(): string { return 'Value must be a valid email address'; @@ -30,12 +27,12 @@ public function getDescription(): string * * Validation will pass when $value is valid email address. * - * @param mixed $value - * @return bool + * @param mixed $value */ + #[\Override] public function isValid($value): bool { - if (!\filter_var($value, FILTER_VALIDATE_EMAIL)) { + if (! \filter_var($value, FILTER_VALIDATE_EMAIL)) { return false; } diff --git a/tests/unit/FullOuterJoinChainTest.php b/tests/unit/FullOuterJoinChainTest.php new file mode 100644 index 0000000000..0d56bdd78c --- /dev/null +++ b/tests/unit/FullOuterJoinChainTest.php @@ -0,0 +1,379 @@ +>> + */ + private const array REPORTED = [ + 'main' => ['m1' => ['name' => 'm1']], + 'b' => ['b1' => ['mainId' => 'm1', 'score' => 1]], + 'c' => [ + 'c1' => ['mainId' => 'm1', 'score' => 10], + 'c3' => ['mainId' => 'zz', 'score' => 30], + ], + ]; + + /** + * Every table has rows only it holds, and b and c each hold a row that only an unmatched row + * of an earlier table matches, which is where the two halves of the emulation meet. + * + * @var array> + */ + private const array LINKS = [ + 'main' => ['m1' => '1', 'm2' => '2', 'm3' => '5'], + 'a' => ['a1' => '1', 'a2' => '3', 'a3' => '4'], + 'b' => ['b1' => '1', 'b2' => '3', 'b3' => '6', 'b4' => '1'], + 'c' => ['c1' => '1', 'c2' => '4', 'c3' => '6', 'c4' => '7', 'c5' => '3'], + ]; + + /** + * @param list $joins + */ + #[DataProvider('reportedChains')] + public function testUnmatchedRowOfALaterRightJoinIsReturnedOnce(array $joins): void + { + $database = $this->reportedDatabase(); + + $rows = $database->find('main', [...$joins, Query::select(['name', 'b.score', 'c.score'])]); + $values = \array_map( + static fn (Document $row): string => (string) \json_encode([$row->getAttribute('name'), $row->getAttribute('b.score'), $row->getAttribute('c.score')]), + $rows, + ); + \sort($values); + + $this->assertSame(['["m1",1,10]', '[null,null,30]'], $values); + $this->assertSame(2, $database->count('main', $joins)); + $this->assertSame(40, $database->sum('main', 'c.score', $joins)); + } + + /** + * @return iterable}> + */ + public static function reportedChains(): iterable + { + yield 'right join on the main collection' => [[ + Query::fullOuterJoin('b', 'b', [Query::on('$id', 'mainId')]), + Query::rightJoin('c', 'c', [Query::on('$id', 'mainId')]), + ]]; + yield 'right join on the full outer joined collection' => [[ + Query::fullOuterJoin('b', 'b', [Query::on('$id', 'mainId')]), + Query::rightJoin('c', 'c', [Query::on('b.mainId', 'mainId')]), + ]]; + } + + /** + * Chains of up to three joins with at least one full outer join, every join type in every + * position and every earlier table in every ON. A chain with one full outer join returns exactly + * the native rows and count unless a table cross joined after the full outer join decides which + * rows a later right join matches; a chain with two full outer joins is rejected. + */ + #[DataProvider('mainDocumentSecurity')] + public function testEveryChainMatchesANativeFullOuterJoin(bool $documentSecurity): void + { + $emulated = $this->linkedDatabase(native: false, documentSecurity: $documentSecurity); + $native = $this->linkedDatabase(native: true, documentSecurity: $documentSecurity); + + $checked = 0; + $mismatches = []; + foreach ($this->chains() as $label => [$joins, $fullOuterJoins, $crossJoinAfterFullOuterJoin]) { + $expected = $this->rows($native, $joins); + $expectedCount = $native->count('main', $joins); + $this->assertSame(\count($expected), $expectedCount, $label); + + try { + $actual = $this->rows($emulated, $joins); + $actualCount = $emulated->count('main', $joins); + } catch (QueryException $exception) { + if ($fullOuterJoins === 1 && ! $crossJoinAfterFullOuterJoin) { + $mismatches[] = "{$label}: rejected ({$exception->getMessage()})"; + } + + continue; + } + + if ($fullOuterJoins > 1) { + $mismatches[] = "{$label}: two full outer joins were not rejected"; + + continue; + } + + $checked++; + if ($actual !== $expected || $actualCount !== $expectedCount) { + $mismatches[] = "{$label}: expected {$expectedCount} ".\json_encode($expected).", got {$actualCount} ".\json_encode($actual); + } + } + + $this->assertSame([], $mismatches); + $this->assertGreaterThan(150, $checked); + } + + /** + * @return iterable + */ + public static function mainDocumentSecurity(): iterable + { + yield 'main rows readable through the collection' => [false]; + yield 'main rows readable through their own permissions' => [true]; + } + + public function testTwoFullOuterJoinsAreRejectedWhenEmulated(): void + { + $database = $this->linkedDatabase(native: false, documentSecurity: false); + $joins = [ + Query::fullOuterJoin('a', 'a', [Query::on(self::LINK, self::LINK)]), + Query::fullOuterJoin('b', 'b', [Query::on('a.'.self::LINK, self::LINK)]), + ]; + + try { + $database->find('main', $joins); + $this->fail('Two emulated full outer joins must be rejected'); + } catch (QueryException $exception) { + $this->assertSame('A query can hold only one full outer join on this database', $exception->getMessage()); + } + + $this->expectException(QueryException::class); + $database->count('main', $joins); + } + + public function testTwoFullOuterJoinsRunNatively(): void + { + $database = $this->linkedDatabase(native: true, documentSecurity: false); + + $rows = $this->rows($database, [ + Query::fullOuterJoin('a', 'a', [Query::on(self::LINK, self::LINK)]), + Query::fullOuterJoin('b', 'b', [Query::on('a.'.self::LINK, self::LINK)]), + ]); + + $this->assertSame([ + ['m1', 'a1', 'b1', null], + ['m1', 'a1', 'b4', null], + ['m2', null, null, null], + ['m3', null, null, null], + [null, 'a2', 'b2', null], + [null, 'a3', null, null], + [null, null, 'b3', null], + ], $rows); + } + + public function testRightJoinOnATableCrossJoinedAfterTheFullOuterJoinIsRejectedWhenEmulated(): void + { + $database = $this->linkedDatabase(native: false, documentSecurity: false); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('A right join after a full outer join has to join on a table joined before it, or on the full outer joined table'); + + $database->find('main', [ + Query::fullOuterJoin('a', 'a', [Query::on(self::LINK, self::LINK)]), + Query::crossJoin('b', 'b'), + Query::rightJoin('c', 'c', [Query::on('b.'.self::LINK, self::LINK)]), + ]); + } + + /** + * @return iterable, int, bool}> + */ + private function chains(): iterable + { + $collections = ['a', 'b', 'c']; + $options = []; + foreach ($collections as $position => $collection) { + $options[$position] = [['cross', $collection, null]]; + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin] as $method) { + foreach (['main', ...\array_slice($collections, 0, $position)] as $reference) { + $options[$position][] = [$method->value, $collection, $reference]; + } + } + } + + $prefixes = [[]]; + foreach ($options as $position => $choices) { + $next = []; + foreach ($prefixes as $prefix) { + foreach ($choices as $choice) { + $chain = [...$prefix, $choice]; + $next[] = $chain; + yield from $this->chain($chain); + } + } + $prefixes = $next; + } + } + + /** + * @param list $specification + * @return iterable, int, bool}> + */ + private function chain(array $specification): iterable + { + $joins = []; + $labels = []; + $fullOuterJoins = 0; + $crossJoinAfterFullOuterJoin = false; + foreach ($specification as [$method, $collection, $reference]) { + if ($method === 'cross') { + $joins[] = Query::crossJoin($collection, $collection); + $labels[] = "cross {$collection}"; + $crossJoinAfterFullOuterJoin = $crossJoinAfterFullOuterJoin || $fullOuterJoins > 0; + + continue; + } + + $left = $reference === 'main' ? self::LINK : $reference.'.'.self::LINK; + $joins[] = match (Method::from($method)) { + Method::Join => Query::join($collection, $collection, [Query::on($left, self::LINK)]), + Method::LeftJoin => Query::leftJoin($collection, $collection, [Query::on($left, self::LINK)]), + Method::RightJoin => Query::rightJoin($collection, $collection, [Query::on($left, self::LINK)]), + default => Query::fullOuterJoin($collection, $collection, [Query::on($left, self::LINK)]), + }; + $labels[] = "{$method} {$collection} on {$reference}"; + if ($method === Method::FullOuterJoin->value) { + $fullOuterJoins++; + } + } + + if ($fullOuterJoins === 0) { + return; + } + + yield \implode(', ', $labels) => [$joins, $fullOuterJoins, $crossJoinAfterFullOuterJoin]; + } + + /** + * @param list $joins + * @return list> + */ + private function rows(Database $database, array $joins): array + { + $aliases = \array_map(static fn (Query $join): string => $join->getAlias(), $joins); + $documents = $database->find('main', [ + ...$joins, + Query::select(['$id', ...\array_map(static fn (string $alias): string => $alias.'.$id', $aliases)]), + Query::limit(5000), + ]); + + $rows = []; + foreach ($documents as $document) { + $row = []; + foreach (['', 'a.', 'b.', 'c.'] as $prefix) { + $id = $document->getAttribute($prefix.'$id'); + $row[] = \is_string($id) && $id !== '' ? $id : null; + } + $rows[] = $row; + } + \usort($rows, static fn (array $left, array $right): int => \strcmp((string) \json_encode($left), (string) \json_encode($right))); + + return $rows; + } + + private function linkedDatabase(bool $native, bool $documentSecurity): Database + { + $database = $this->database($native); + + $collectionPermissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + foreach (\array_keys(self::LINKS) as $collection) { + $main = $collection === 'main'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: self::LINK, size: 16, required: true)], + permissions: $main && $documentSecurity ? [Permission::create(Role::any())] : $collectionPermissions, + documentSecurity: $main && $documentSecurity, + )); + } + + foreach (self::LINKS as $collection => $links) { + foreach ($links as $id => $link) { + $database->createDocument($collection, new Document([ + '$id' => $id, + self::LINK => $link, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + + return $database; + } + + private function reportedDatabase(): Database + { + $database = $this->database(native: false); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'main', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + foreach (['b', 'c'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'mainId', size: 64, required: true), + Attribute::integer(key: 'score', required: true), + ], + permissions: $permissions, + documentSecurity: false, + )); + } + + foreach (self::REPORTED as $collection => $documents) { + foreach ($documents as $id => $attributes) { + $database->createDocument($collection, new Document(['$id' => $id, ...$attributes])); + } + } + + return $database; + } + + private function database(bool $native): Database + { + $pdo = new PDO('sqlite::memory:'); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database( + $native ? new NativeJoinChainSQLite($pdo) : new SQLite($pdo), + new Cache(new Memory()), + ); + $database + ->setAuthorization($authorization) + ->setDatabase('chains') + ->setNamespace('chains_'.\uniqid()); + $database->addHook(new Permissions()); + $database->create(); + + return $database; + } +} diff --git a/tests/unit/HashAwareMemoryCache.php b/tests/unit/HashAwareMemoryCache.php index 7a72186045..104f03f17c 100644 --- a/tests/unit/HashAwareMemoryCache.php +++ b/tests/unit/HashAwareMemoryCache.php @@ -12,6 +12,7 @@ */ class HashAwareMemoryCache extends Memory { + #[\Override] public function load(string $key, int $ttl, string $hash = ''): mixed { return parent::load($this->field($key, $hash), $ttl); @@ -21,16 +22,19 @@ public function load(string $key, int $ttl, string $hash = ''): mixed * @param array|string $data * @return bool|string|array */ - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { return parent::save($this->field($key, $hash), $data); } + #[\Override] public function touch(string $key, string $hash = ''): bool { return parent::touch($this->field($key, $hash)); } + #[\Override] public function purge(string $key, string $hash = ''): bool { if ($hash !== '') { diff --git a/tests/unit/Hook/AddUnknownHookTest.php b/tests/unit/Hook/AddUnknownHookTest.php new file mode 100644 index 0000000000..c82c7c05da --- /dev/null +++ b/tests/unit/Hook/AddUnknownHookTest.php @@ -0,0 +1,55 @@ +addHook($hook); + $this->fail('An unknown hook was accepted'); + } catch (DatabaseException $error) { + $this->assertSame('Unknown hook: '.$hook::class, $error->getMessage()); + } + } + + public function testAnUnknownHookIsRefusedThroughAMirror(): void + { + $mirror = new Mirror(HookFixture::memory(), new Database(new Memory(), new Cache(new None()))); + $hook = new class () implements Hook {}; + + try { + $mirror->addHook($hook); + $this->fail('An unknown hook was accepted'); + } catch (DatabaseException $error) { + $this->assertSame('Unknown hook: '.$hook::class, $error->getMessage()); + } + } + + public function testAKnownHookIsRegistered(): void + { + $database = HookFixture::memory(); + $recorder = new RecordingLifecycle(); + + $this->assertSame($database, $database->addHook($recorder)); + + $database->getCollection(HookFixture::COLLECTION); + $this->assertSame([Event::CollectionRead], $recorder->getEvents()); + } +} diff --git a/tests/unit/Hook/AttachTest.php b/tests/unit/Hook/AttachTest.php new file mode 100644 index 0000000000..7a9a50e5ad --- /dev/null +++ b/tests/unit/Hook/AttachTest.php @@ -0,0 +1,157 @@ +addHook($hook); + $database->createDocument(HookFixture::COLLECTION, new Document(['$id' => 'first', 'title' => 'first', 'views' => 1])); + + $this->assertSame([$database], $hook->attached); + $this->assertSame(['first'], $hook->created); + } + + public function testAHookThatFailsToAttachIsNotRegistered(): void + { + $database = HookFixture::sqlite(); + $hook = new AttachTestHook(new RuntimeException('Not this database')); + + try { + $database->addHook($hook); + $this->fail('A hook that failed to attach was added'); + } catch (RuntimeException $error) { + $this->assertSame('Not this database', $error->getMessage()); + } + $database->createDocument(HookFixture::COLLECTION, new Document(['$id' => 'first', 'title' => 'first', 'views' => 1])); + + $this->assertSame([], $hook->created); + } + + public function testAnUnknownHookIsRefusedBeforeItIsAttached(): void + { + $database = HookFixture::memory(); + $hook = new class () implements Attachable, Hook { + /** @var list */ + public array $attached = []; + + #[\Override] + public function attach(Database $database): void + { + $this->attached[] = $database; + } + }; + + try { + $database->addHook($hook); + $this->fail('An unknown hook was accepted'); + } catch (DatabaseException $error) { + $this->assertSame('Unknown hook: '.$hook::class, $error->getMessage()); + } + + $this->assertSame([], $hook->attached); + } + + public function testAMirrorAttachesTheHookToItself(): void + { + $mirror = new Mirror(HookFixture::sqlite(), new Database(new Memory(), new Cache(new None()))); + $hook = new AttachTestHook(); + + $mirror->addHook($hook); + + $this->assertSame([$mirror], $hook->attached); + } + + public function testTheRelationshipsHookRelatesThroughTheDatabaseItIsAddedTo(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database->setDatabase('attach')->setNamespace('attach'); + $database->create(); + foreach (['parents', 'children'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + } + $database->createRelationship('parents', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, key: 'children', twoWayKey: 'parent')); + + $database->addHook(new Relationships()); + $database->createDocument('parents', new Document([ + '$id' => 'p1', + 'name' => 'p1', + 'children' => [new Document(['$id' => 'c1', 'name' => 'c1'])], + ])); + + $children = $database->getDocument('parents', 'p1')->getAttribute('children'); + $this->assertIsArray($children); + $this->assertCount(1, $children); + $this->assertInstanceOf(Document::class, $children[0]); + $this->assertSame('c1', $children[0]->getId()); + } + public function testTheRelationshipsHookRefusesToMoveToAnotherDatabase(): void + { + $hook = new Relationships(); + $first = new Database(new Memory(), new Cache(new None())); + $second = new Database(new Memory(), new Cache(new None())); + $first->addHook($hook); + + try { + $second->addHook($hook); + $this->fail('A relationships hook moved to another database'); + } catch (DatabaseException $error) { + $this->assertSame('This relationships hook is already attached to another database; attach a clone of it instead', $error->getMessage()); + } + + $this->assertSame($hook, $first->getRelationshipHook()); + $this->assertNull($second->getRelationshipHook()); + } + + public function testTheRelationshipsHookCanBeAddedAgainToItsOwnDatabase(): void + { + $hook = new Relationships(); + $database = new Database(new Memory(), new Cache(new None())); + + $database->addHook($hook)->addHook($hook); + + $this->assertSame($hook, $database->getRelationshipHook()); + } + + public function testACloneOfAnAttachedRelationshipsHookAttachesToAnotherDatabase(): void + { + $hook = new Relationships(); + $first = new Database(new Memory(), new Cache(new None())); + $second = new Database(new Memory(), new Cache(new None())); + $first->addHook($hook); + + $clone = clone $hook; + $second->addHook($clone); + + $this->assertSame($hook, $first->getRelationshipHook()); + $this->assertSame($clone, $second->getRelationshipHook()); + } + +} diff --git a/tests/unit/Hook/AttachTestHook.php b/tests/unit/Hook/AttachTestHook.php new file mode 100644 index 0000000000..a5190e4040 --- /dev/null +++ b/tests/unit/Hook/AttachTestHook.php @@ -0,0 +1,44 @@ + */ + public array $attached = []; + + /** @var list */ + public array $created = []; + + public function __construct( + private readonly ?RuntimeException $failure = null, + ) { + } + + #[\Override] + public function attach(Database $database): void + { + if ($this->failure !== null) { + throw $this->failure; + } + + $this->attached[] = $database; + } + + #[\Override] + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + foreach ($documents as $document) { + $this->created[] = $document->getId(); + } + } +} diff --git a/tests/unit/Hook/ColumnNameTest.php b/tests/unit/Hook/ColumnNameTest.php new file mode 100644 index 0000000000..3ddf73d90b --- /dev/null +++ b/tests/unit/Hook/ColumnNameTest.php @@ -0,0 +1,139 @@ +assertSame('(x = ? OR `alias`.`_uid` IS NULL)', $condition->expression); + $this->assertSame([1], $condition->bindings); + } + + #[DataProvider('invalidColumns')] + public function testWrapRejectsAColumnOutsideTheIdentifierPattern(string $column): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Invalid column name: '.$column); + + AllowNull::wrap(new Condition('x = 1'), $column); + } + + /** + * @return iterable + */ + public static function invalidColumns(): iterable + { + yield 'a space' => ['a b']; + yield 'a statement separator' => ['x;y']; + yield 'a quote character' => ['x`y']; + yield 'an empty name' => ['']; + } + + /** + * @param Closure(string): Filter $construct + */ + #[DataProvider('permissionFilterColumns')] + public function testPermissionFilterRejectsAColumnOutsideTheIdentifierPattern(Closure $construct, string $column): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name: '.$column); + + $construct($column); + } + + /** + * @return iterable + */ + public static function permissionFilterColumns(): iterable + { + $permissionsTable = static fn (string $table): string => $table.'_perms'; + $constructors = [ + 'documentColumn' => static fn (string $column): Filter => new Filter(['any'], $permissionsTable, documentColumn: $column), + 'permissionDocumentColumn' => static fn (string $column): Filter => new Filter(['any'], $permissionsTable, permissionDocumentColumn: $column), + 'permissionRoleColumn' => static fn (string $column): Filter => new Filter(['any'], $permissionsTable, permissionRoleColumn: $column), + 'permissionTypeColumn' => static fn (string $column): Filter => new Filter(['any'], $permissionsTable, permissionTypeColumn: $column), + 'scopeColumn' => static fn (string $column): Filter => new Filter(['any'], $permissionsTable, scopeColumn: $column), + ]; + foreach ($constructors as $parameter => $construct) { + foreach (self::invalidColumns() as $label => [$column]) { + yield $parameter.' with '.$label => [$construct, $column]; + } + } + } + + public function testPermissionFilterWithoutRolesMatchesNothing(): void + { + $filter = new Filter([], static fn (string $table): string => $table.'_perms'); + + $condition = $filter->filter('posts'); + + $this->assertSame('1 = 0', $condition->expression); + $this->assertSame([], $condition->bindings); + } + + public function testPermissionFilterRejectsAPermissionsTableOutsideTheIdentifierPattern(): void + { + $filter = new Filter(['any'], static fn (string $table): string => $table.' perms'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Invalid permissions table name: posts perms'); + + $filter->filter('posts'); + } + + public function testPermissionFilterWithNoColumnsMatchesOnlyCollectionWidePermissions(): void + { + $filter = new Filter(['any'], static fn (string $table): string => $table.'_perms', columns: []); + + $condition = $filter->filter('posts'); + + $this->assertStringEndsWith(' AND type = ? AND column IS NULL)', $condition->expression); + $this->assertSame(['any', 'read'], $condition->bindings); + } + + public function testPermissionFilterWithColumnsMatchesThemOrCollectionWidePermissions(): void + { + $filter = new Filter(['any', 'users'], static fn (string $table): string => $table.'_perms', columns: ['title', 'body']); + + $condition = $filter->filter('posts'); + + $this->assertStringEndsWith(' AND type = ? AND (column IS NULL OR column IN (?, ?)))', $condition->expression); + $this->assertSame(['any', 'users', 'read', 'title', 'body'], $condition->bindings); + } + + public function testWrapAcceptsADigitOrHyphenLeadingColumnAndQuotesIt(): void + { + $this->assertSame('(x = 1 OR `1db`.`_uid` IS NULL)', AllowNull::wrap(new Condition('x = 1'), '1db._uid')->expression); + $this->assertSame('(x = 1 OR `-ns`.`_uid` IS NULL)', AllowNull::wrap(new Condition('x = 1'), '-ns._uid')->expression); + } + + public function testPermissionFilterAcceptsADigitLeadingPermissionsTableAndQuotesIt(): void + { + $filter = new Filter(['any'], static fn (string $table): string => '1db.ns_'.$table.'_perms'); + + $condition = $filter->filter('posts'); + + $this->assertStringContainsString(' FROM `1db`.`ns_posts_perms` WHERE ', $condition->expression); + $this->assertSame(['any', 'read'], $condition->bindings); + } + + public function testPermissionFilterStillRefusesADigitLeadingUnquotedColumn(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name: 1role'); + + new Filter(['any'], static fn (string $table): string => $table.'_perms', permissionRoleColumn: '1role'); + } +} diff --git a/tests/unit/Hook/DecoratorBatchTest.php b/tests/unit/Hook/DecoratorBatchTest.php new file mode 100644 index 0000000000..74d0920b27 --- /dev/null +++ b/tests/unit/Hook/DecoratorBatchTest.php @@ -0,0 +1,121 @@ +addHook($this->decorator()); + + $documents = $database->find(HookFixture::COLLECTION, [Query::orderAsc('views')]); + + $this->assertSame(['a', 'b', 'c'], \array_map(static fn (Document $document): string => $document->getId(), $documents)); + foreach ($documents as $document) { + $this->assertSame(Event::DocumentFind->value, $document->getAttribute(self::MARK), "{$document->getId()} must be decorated by find()"); + } + } + + public function testSilencedFindIsNotDecorated(): void + { + $database = HookFixture::memory(); + HookFixture::seed($database, ['a', 'b']); + $database->addHook($this->decorator()); + + $documents = $database->silent(fn (): array => $database->find(HookFixture::COLLECTION)); + + $this->assertCount(2, $documents); + foreach ($documents as $document) { + $this->assertNull($document->getAttribute(self::MARK)); + } + } + + public function testCreateDocumentsHandsDecoratedDocumentsToItsCallback(): void + { + $database = HookFixture::memory(); + $database->addHook($this->decorator()); + + $marks = $this->collect(fn (callable $onNext): int => $database->createDocuments( + HookFixture::COLLECTION, + [ + new Document([Document::ID => 'a', 'title' => 'a', 'views' => 1]), + new Document([Document::ID => 'b', 'title' => 'b', 'views' => 2]), + ], + onNext: $onNext, + )); + + $this->assertSame(['a' => Event::DocumentsCreate->value, 'b' => Event::DocumentsCreate->value], $marks); + } + + public function testUpdateDocumentsHandsDecoratedDocumentsToItsCallback(): void + { + $database = HookFixture::memory(); + HookFixture::seed($database, ['a', 'b']); + $database->addHook($this->decorator()); + + $marks = $this->collect(fn (callable $onNext): int => $database->updateDocuments( + HookFixture::COLLECTION, + new Document(['views' => 10]), + onNext: $onNext, + )); + + $this->assertSame(['a' => Event::DocumentsUpdate->value, 'b' => Event::DocumentsUpdate->value], $marks); + } + + public function testUpsertDocumentsHandsDecoratedDocumentsToItsCallback(): void + { + $database = HookFixture::sqlite(); + HookFixture::seed($database, ['a']); + $database->addHook($this->decorator()); + + $marks = $this->collect(fn (callable $onNext): int => $database->upsertDocuments( + HookFixture::COLLECTION, + [ + new Document([Document::ID => 'a', 'title' => 'a', 'views' => 5]), + new Document([Document::ID => 'b', 'title' => 'b', 'views' => 6]), + ], + onNext: $onNext, + )); + + $this->assertSame(['a' => Event::DocumentsUpsert->value, 'b' => Event::DocumentsUpsert->value], $marks); + } + + /** + * @param callable(callable(Document): void): int $write + * @return array + */ + private function collect(callable $write): array + { + $marks = []; + $count = $write(static function (Document $document) use (&$marks): void { + $marks[$document->getId()] = $document->getAttribute(self::MARK); + }); + + $this->assertSame(\count($marks), $count); + \ksort($marks); + + return $marks; + } + + private function decorator(): Decorator + { + return new class () implements Decorator { + #[\Override] + public function decorate(Event $event, Document $collection, Document $document): Document + { + return new Document([...$document->getArrayCopy(), DecoratorBatchTest::MARK => $event->value]); + } + }; + } +} diff --git a/tests/unit/Hook/JoinAliasQuotingTest.php b/tests/unit/Hook/JoinAliasQuotingTest.php new file mode 100644 index 0000000000..c420b5a525 --- /dev/null +++ b/tests/unit/Hook/JoinAliasQuotingTest.php @@ -0,0 +1,203 @@ + + */ + public static function quoteCharacters(): iterable + { + yield 'PostgreSQL' => ['"']; + yield 'MariaDB and MySQL' => ['`']; + } + + /** + * @return iterable + */ + public static function joins(): iterable + { + foreach (self::quoteCharacters() as $engine => [$quote]) { + foreach ([JoinType::Inner, JoinType::Left, JoinType::Right, JoinType::FullOuter, JoinType::Cross] as $joinType) { + yield "{$joinType->value}, {$engine}" => [$joinType, $quote]; + } + } + } + + #[DataProvider('quoteCharacters')] + public function testTenantFilterQuotesTheTableItQualifies(string $quote): void + { + $filter = new Tenant\Filter(7, Database::METADATA, 'authors', quoteCharacter: $quote); + + $this->assertSame($this->quoted('"Main"._tenant IN (?)', $quote), $filter->filter(self::SOURCE)->expression); + $this->assertSame($this->quoted('"Book"._tenant IN (?)', $quote), $filter->joined(self::ALIAS)->expression); + $this->assertSame([7], $filter->joined(self::ALIAS)->bindings); + } + + #[DataProvider('quoteCharacters')] + public function testTenantFilterQuotesTheTableOfATenantlessMetadataRow(string $quote): void + { + $filter = new Tenant\Filter(7, Database::METADATA, Database::METADATA, quoteCharacter: $quote); + + $this->assertSame( + $this->quoted('("Main"._tenant IN (?) OR "Main"._tenant IS NULL)', $quote), + $filter->filter(self::SOURCE)->expression, + ); + } + + /** + * A table named with its database, or quoted already, is a raw table name rather than an alias. + */ + #[DataProvider('quoteCharacters')] + public function testTenantFilterDoesNotQualifyWithARawTableName(string $quote): void + { + $filter = new Tenant\Filter(7, Database::METADATA, 'authors', quoteCharacter: $quote); + + $this->assertSame('_tenant IN (?)', $filter->filter('database.namespace_authors')->expression); + $this->assertSame('_tenant IN (?)', $filter->filter($quote.'namespace_authors'.$quote)->expression); + } + + #[DataProvider('joins')] + public function testTenantFilterQuotesTheAliasOfEveryJoin(JoinType $joinType, string $quote): void + { + $filter = new Tenant\Filter(7, allowNullColumn: self::SOURCE.'.'.Storage::UID, quoteCharacter: $quote); + + $result = $filter->filterJoin(self::ALIAS, $joinType); + + $this->assertSame( + $this->quoted(match ($joinType) { + JoinType::Inner, JoinType::Left => '"Book"._tenant IN (?)', + default => '("Book"._tenant IN (?) OR "Book"."_uid" IS NULL)', + }, $quote), + $result->condition->expression, + ); + } + + #[DataProvider('quoteCharacters')] + public function testOuterJoinTenantFilterQuotesBothTables(string $quote): void + { + $filter = new Tenant\Filter(7, Database::METADATA, 'authors', self::SOURCE.'.'.Storage::UID, $quote); + + $result = (new Tenant\OuterJoin($filter, self::SOURCE))->filterJoin(self::ALIAS, JoinType::Right); + + $this->assertNotNull($result); + $this->assertSame( + $this->quoted('("Main"._tenant IN (?) OR "Main"."_uid" IS NULL) AND "Book"._tenant IN (?)', $quote), + $result->condition->expression, + ); + } + + #[DataProvider('quoteCharacters')] + public function testOuterJoinChainFilterQuotesEveryEarlierTable(string $quote): void + { + $chain = new Join\Chain([self::EARLIER => JoinType::Cross, self::ALIAS => JoinType::Right]); + $tenants = new Tenant\Filter(7, quoteCharacter: $quote); + $permission = $this->permission(self::EARLIER, $quote); + + $tenant = (new Join\OuterChain($chain, [self::EARLIER => $tenants->joined(self::EARLIER)], $quote)) + ->filterJoin(self::ALIAS, JoinType::Right); + $permitted = (new Join\OuterChain($chain, [self::EARLIER => $permission->filter(self::EARLIER)], $quote)) + ->filterJoin(self::ALIAS, JoinType::Right); + + $this->assertNotNull($tenant); + $this->assertSame($this->quoted('("Extra"._tenant IN (?) OR "Extra"."_uid" IS NULL)', $quote), $tenant->condition->expression); + $this->assertNotNull($permitted); + $this->assertOnlyQuoted(self::EARLIER, $permitted->condition->expression, $quote); + } + + #[DataProvider('quoteCharacters')] + public function testPermissionFilterQuotesItsDocumentColumn(string $quote): void + { + $this->assertSame( + $this->quoted('"Book"."_uid" IN (SELECT _document FROM "database"."namespace_books_perms" WHERE _permission IN (?) AND _type = ?)', $quote), + $this->permission(self::ALIAS, $quote)->filter(self::ALIAS)->expression, + ); + } + + #[DataProvider('joins')] + public function testPermissionJoinFilterQuotesTheAliasOfEveryJoin(JoinType $joinType, string $quote): void + { + $hook = new Permission\Join($this->permission(self::ALIAS, $quote), self::ALIAS, $quote, preservingOuterJoin: true); + + $result = $hook->filterJoin(self::ALIAS, $joinType); + + $this->assertNotNull($result); + $this->assertOnlyQuoted(self::ALIAS, $result->condition->expression, $quote); + } + + #[DataProvider('quoteCharacters')] + public function testPermissionAllowNullUidQuotesTheMainTableOnBothSides(string $quote): void + { + $hook = new Permission\AllowNullUid($this->permission(self::SOURCE, $quote), self::SOURCE.'.'.Storage::UID, $quote); + + $this->assertSame( + $this->quoted('("Main"."_uid" IN (SELECT _document FROM "database"."namespace_books_perms" WHERE _permission IN (?) AND _type = ?) OR "Main"."_uid" IS NULL)', $quote), + $hook->filter(self::SOURCE)->expression, + ); + } + + #[DataProvider('quoteCharacters')] + public function testOuterJoinPermissionFilterQuotesBothTables(string $quote): void + { + $hook = new Permission\OuterJoin(self::SOURCE, [ + self::SOURCE => $this->permission(self::SOURCE, $quote)->filter(self::SOURCE), + self::ALIAS => $this->permission(self::ALIAS, $quote)->filter(self::ALIAS), + ], $quote); + + $result = $hook->filterJoin(self::ALIAS, JoinType::FullOuter); + + $this->assertNotNull($result); + $this->assertOnlyQuoted(self::SOURCE, $result->condition->expression, $quote); + $this->assertOnlyQuoted(self::ALIAS, $result->condition->expression, $quote); + } + + private function permission(string $alias, string $quote): Permission\Filter + { + return new Permission\Filter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'database.namespace_books_perms', + documentColumn: $alias.'.'.Storage::UID, + permissionDocumentColumn: Storage::PERMISSIONS_DOCUMENT, + permissionRoleColumn: Storage::PERMISSIONS_PERMISSION, + permissionTypeColumn: Storage::PERMISSIONS_TYPE, + quoteCharacter: $quote, + ); + } + + private function quoted(string $expression, string $quote): string + { + return \strtr($expression, ['"' => $quote]); + } + + private function assertOnlyQuoted(string $alias, string $expression, string $quote): void + { + $this->assertStringContainsString($quote.$alias.$quote.'.', $expression, "{$alias} must be named quoted"); + $this->assertDoesNotMatchRegularExpression( + '/(?addHook($replaced)->addHook($replacement); + $database->getCollection(HookFixture::COLLECTION); + + $this->assertSame([], $replaced->getEvents()); + $this->assertSame([Event::CollectionRead], $replacement->getEvents()); + } + + public function testReplacementKeepsTheRegistrationPosition(): void + { + $database = HookFixture::memory(); + $journal = []; + $record = static function (string $label) use (&$journal): void { + $journal[] = $label; + }; + + $database + ->addHook($this->journalingHook('audits', 'replaced', $record)) + ->addHook($this->journalingHook(null, 'unnamed', $record)) + ->addHook($this->journalingHook('audits', 'replacement', $record)); + $database->getCollection(HookFixture::COLLECTION); + + $this->assertSame(['replacement', 'unnamed'], $journal); + } + + public function testUnnamedHooksKeepAppending(): void + { + $database = HookFixture::memory(); + $first = new RecordingLifecycle(); + $second = new RecordingLifecycle(); + + $database->addHook($first)->addHook($second)->addHook($first); + $database->getCollection(HookFixture::COLLECTION); + + $this->assertSame([Event::CollectionRead, Event::CollectionRead], $first->getEvents()); + $this->assertSame([Event::CollectionRead], $second->getEvents()); + } + + public function testSilentWithoutListenersSilencesEveryHook(): void + { + $database = HookFixture::memory(); + $named = new NamedRecordingLifecycle('audits'); + $unnamed = new RecordingLifecycle(); + $database->addHook($named)->addHook($unnamed); + + $database->silent(fn () => $database->getCollection(HookFixture::COLLECTION)); + + $this->assertSame([], $named->getEvents()); + $this->assertSame([], $unnamed->getEvents()); + } + + public function testSilentWithListenersSilencesOnlyTheNamedHooks(): void + { + $database = HookFixture::memory(); + $audits = new NamedRecordingLifecycle('audits'); + $usage = new NamedRecordingLifecycle('usage'); + $unnamed = new RecordingLifecycle(); + $database->addHook($audits)->addHook($usage)->addHook($unnamed); + + $database->silent(fn () => $database->getCollection(HookFixture::COLLECTION), ['audits']); + + $this->assertSame([], $audits->getEvents()); + $this->assertSame([Event::CollectionRead], $usage->getEvents()); + $this->assertSame([Event::CollectionRead], $unnamed->getEvents()); + } + + public function testSilentWithListenersKeepsUnnamedHooksFiring(): void + { + $database = HookFixture::memory(); + $unnamed = new RecordingLifecycle(); + $database->addHook($unnamed); + + $database->silent(fn () => $database->getCollection(HookFixture::COLLECTION), ['audits']); + + $this->assertSame([Event::CollectionRead], $unnamed->getEvents()); + } + + public function testNestedSilenceNeverNarrowsAnOuterSilence(): void + { + $database = HookFixture::memory(); + $audits = new NamedRecordingLifecycle('audits'); + $usage = new NamedRecordingLifecycle('usage'); + $unnamed = new RecordingLifecycle(); + $database->addHook($audits)->addHook($usage)->addHook($unnamed); + + $database->silent(fn () => $database->silent(fn () => $database->getCollection(HookFixture::COLLECTION), ['audits'])); + $this->assertSame([], $unnamed->getEvents()); + + $database->silent(function () use ($database): void { + $database->silent(fn () => $database->listCollections(), ['usage']); + $database->getCollection(HookFixture::COLLECTION); + }, ['audits']); + + $this->assertSame([], $audits->getEvents()); + $this->assertSame([Event::CollectionRead], $usage->getEvents()); + $this->assertSame([Event::CollectionList, Event::CollectionRead], $unnamed->getEvents()); + } + + public function testSilenceEndsWhenTheCallbackThrows(): void + { + $database = HookFixture::memory(); + $audits = new NamedRecordingLifecycle('audits'); + $unnamed = new RecordingLifecycle(); + $database->addHook($audits)->addHook($unnamed); + + foreach ([null, ['audits']] as $listeners) { + try { + $database->silent(static fn () => throw new RuntimeException('callback failed'), $listeners); + } catch (RuntimeException) { + } + } + $database->getCollection(HookFixture::COLLECTION); + + $this->assertSame([Event::CollectionRead], $audits->getEvents()); + $this->assertSame([Event::CollectionRead], $unnamed->getEvents()); + } + + public function testNamedSilenceAlsoSilencesDocumentPurge(): void + { + $database = HookFixture::memory(); + HookFixture::seed($database, ['first']); + $database->addHook($this->failingNamedHook('regions', Event::DocumentPurge, new RuntimeException('region broadcast failed'))); + + $database->silent(fn () => $database->updateDocument(HookFixture::COLLECTION, 'first', new Document(['title' => 'renamed'])), ['regions']); + $database->silent(fn () => $database->purgeCachedDocument(HookFixture::COLLECTION, 'first'), ['regions']); + + $this->assertSame('renamed', $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('title')); + } + + public function testDecoratorsKeepRunningDuringANamedSilence(): void + { + $database = HookFixture::memory(); + HookFixture::seed($database, ['first']); + $database->addHook(new class () implements Decorator { + #[\Override] + public function decorate(Event $event, Document $collection, Document $document): Document + { + return $document->setAttribute('decorated', true); + } + }); + + $named = $database->silent(fn () => $database->getDocument(HookFixture::COLLECTION, 'first'), ['audits']); + $silenced = $database->silent(fn () => $database->getDocument(HookFixture::COLLECTION, 'first')); + + $this->assertTrue($named->getAttribute('decorated')); + $this->assertNull($silenced->getAttribute('decorated')); + } + + public function testNamedSilenceIsScopedToTheCoroutine(): void + { + $database = HookFixture::memory(); + $audits = new NamedRecordingLifecycle('audits'); + $database->addHook($audits); + + $hookFlags = Runtime::getHookFlags(); + + try { + run(static function () use ($database): void { + $entered = new Channel(1); + $released = new Channel(1); + + Coroutine::create(static function () use ($database, $entered, $released): void { + $database->silent(static function () use ($database, $entered, $released): void { + $database->getCollection(HookFixture::COLLECTION); + $entered->push(true); + $released->pop(); + }, ['audits']); + }); + + Coroutine::create(static function () use ($database, $entered, $released): void { + $entered->pop(); + $database->listCollections(); + $released->push(true); + }); + }); + } finally { + Runtime::setHookFlags($hookFlags); + } + + $this->assertSame([Event::CollectionList], $audits->getEvents()); + } + + public function testMirrorReplacesNamedHooksOnItsSource(): void + { + $source = HookFixture::memory(); + $mirror = new Mirror($source); + $replaced = new NamedRecordingLifecycle('audits'); + $replacement = new NamedRecordingLifecycle('audits'); + + $mirror->addHook($replaced)->addHook($replacement); + $mirror->getCollection(HookFixture::COLLECTION); + + $this->assertSame([], $replaced->getEvents()); + $this->assertSame([Event::CollectionRead], $replacement->getEvents()); + } + + public function testMirrorForwardsNamedSilenceToItsSource(): void + { + $source = HookFixture::memory(); + $mirror = new Mirror($source); + $audits = new NamedRecordingLifecycle('audits'); + $unnamed = new RecordingLifecycle(); + $mirror->addHook($audits)->addHook($unnamed); + + $mirror->silent(fn () => $mirror->getCollection(HookFixture::COLLECTION), ['audits']); + + $this->assertSame([], $audits->getEvents()); + $this->assertSame([Event::CollectionRead], $unnamed->getEvents()); + } + + public function testMirrorSilenceAlsoSilencesItsOwnDecorators(): void + { + $source = HookFixture::memory(); + HookFixture::seed($source, ['first']); + $mirror = new Mirror($source); + $mirror->addHook(new class () implements Decorator { + #[\Override] + public function decorate(Event $event, Document $collection, Document $document): Document + { + return $document->setAttribute('decorated', true); + } + }); + + $silenced = $mirror->silent(fn () => $mirror->getDocument(HookFixture::COLLECTION, 'first')); + + $this->assertNull($silenced->getAttribute('decorated')); + $this->assertTrue($mirror->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('decorated')); + } + + /** + * @param Closure(string): void $record + */ + private function journalingHook(?string $name, string $label, Closure $record): Lifecycle + { + $journaling = new class ($label, $record) implements Lifecycle { + /** + * @param Closure(string): void $record + */ + public function __construct( + private readonly string $label, + private readonly Closure $record, + ) { + } + + #[\Override] + public function handle(Domain $event): void + { + ($this->record)($this->label); + } + }; + + if ($name === null) { + return $journaling; + } + + return new class ($name, $journaling) implements Lifecycle, Named { + public function __construct( + private readonly string $name, + private readonly Lifecycle $hook, + ) { + } + + #[\Override] + public function getName(): string + { + return $this->name; + } + + #[\Override] + public function handle(Domain $event): void + { + $this->hook->handle($event); + } + }; + } + + private function failingNamedHook(string $name, Event $event, RuntimeException $failure): Lifecycle&Named + { + return new class ($name, new FailingLifecycle($event, $failure)) implements Lifecycle, Named { + public function __construct( + private readonly string $name, + private readonly FailingLifecycle $failing, + ) { + } + + #[\Override] + public function getName(): string + { + return $this->name; + } + + #[\Override] + public function handle(Domain $event): void + { + $this->failing->handle($event); + } + }; + } +} diff --git a/tests/unit/Hook/PermissionsBatchSkipTest.php b/tests/unit/Hook/PermissionsBatchSkipTest.php new file mode 100644 index 0000000000..ff4992992b --- /dev/null +++ b/tests/unit/Hook/PermissionsBatchSkipTest.php @@ -0,0 +1,191 @@ + + */ + private array $statements = []; + + private Database $database; + + #[\Override] + protected function setUp(): void + { + $this->pdo = new class ('sqlite::memory:', $this->record(...)) extends PDO { + public function __construct(string $dsn, private readonly \Closure $record) + { + parent::__construct($dsn); + } + + /** + * @param array $options + */ + #[\Override] + public function prepare(string $query, array $options = []): \PDOStatement|false + { + ($this->record)($query); + + return parent::prepare($query, $options); + } + }; + + $this->database = new Database(new SQLite($this->pdo), new Cache(new None())); + $this->database + ->setAuthorization(new Authorization()) + ->setDatabase('permissions') + ->setNamespace('batch_skip_'.\uniqid()); + $this->database->addHook(new Permissions()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: true, + )); + + foreach (['first', 'second'] as $id) { + $this->database->createDocument(self::COLLECTION, new Document([ + Document::ID => $id, + Document::PERMISSIONS => self::stored(), + 'title' => $id, + ])); + } + } + + public function testABulkUpdateKeepingEveryDocumentsPermissionsTouchesNoPermissionRows(): void + { + $statements = $this->statementsDuring(fn (): int => $this->database->updateDocuments( + self::COLLECTION, + new Document(['title' => 'renamed', Document::PERMISSIONS => \array_reverse(self::stored())]), + )); + + $this->assertSame([], $this->permissionStatements($statements)); + foreach (['first', 'second'] as $id) { + $document = $this->database->getDocument(self::COLLECTION, $id); + $this->assertSame('renamed', $document->getAttribute('title')); + $this->assertEqualsCanonicalizing(self::stored(), $document->getPermissions()); + } + } + + public function testABulkUpdateChangingThePermissionsRewritesTheirRows(): void + { + $changed = [Permission::read(Role::user('reader')), Permission::update(Role::any())]; + + $statements = $this->statementsDuring(fn (): int => $this->database->updateDocuments( + self::COLLECTION, + new Document([Document::PERMISSIONS => $changed]), + )); + + $this->assertNotSame([], $this->permissionStatements($statements)); + $this->assertEqualsCanonicalizing($changed, $this->database->getAuthorization()->skip( + fn (): array => $this->database->getDocument(self::COLLECTION, 'first')->getPermissions(), + )); + } + + public function testABulkUpdateRewritesThePermissionsOfOnlyTheDocumentsThatChangeThem(): void + { + $reader = [Permission::read(Role::user('reader')), Permission::update(Role::any())]; + $this->database->updateDocument(self::COLLECTION, 'second', new Document([Document::PERMISSIONS => $reader])); + + $statements = $this->statementsDuring(fn (): int => $this->database->updateDocuments( + self::COLLECTION, + new Document(['title' => 'renamed', Document::PERMISSIONS => self::stored()]), + )); + + $this->assertNotSame([], $this->permissionStatements($statements)); + foreach (['first', 'second'] as $id) { + $document = $this->database->getAuthorization()->skip(fn (): Document => $this->database->getDocument(self::COLLECTION, $id)); + $this->assertSame('renamed', $document->getAttribute('title')); + $this->assertEqualsCanonicalizing(self::stored(), $document->getPermissions()); + } + } + + public function testTheHookReadsNoPermissionsWhenEveryDocumentKeepsItsOwn(): void + { + $adapter = new SQLite($this->pdo); + $adapter->setNamespace('batch_skip_adapter_'.\uniqid()); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $adapter->addWriteHook(new Permissions()); + $this->assertTrue($adapter->createCollection(self::COLLECTION)); + $collection = new Document([Document::ID => self::COLLECTION]); + $documents = $adapter->createDocuments($collection, [ + new Document([Document::ID => 'first', Document::PERMISSIONS => self::stored()]), + new Document([Document::ID => 'second', Document::PERMISSIONS => self::stored()]), + ]); + $updates = new class ([Document::PERMISSIONS => self::stored()]) extends Document { + public int $calls = 0; + + #[\Override] + public function getPermissionsByType(PermissionType|string $type): array + { + $this->calls++; + + return parent::getPermissionsByType($type); + } + }; + + $before = \count($this->statements); + $adapter->updateDocuments($collection, $updates, $documents, ['first' => true, 'second' => true]); + + $this->assertSame(0, $updates->calls, 'no document is eligible, so no permission type is read from the update'); + $this->assertSame([], $this->permissionStatements(\array_slice($this->statements, $before))); + } + + /** + * @return list + */ + private static function stored(): array + { + return [Permission::read(Role::any()), Permission::update(Role::any())]; + } + + /** + * @param callable(): int $write + * @return list + */ + private function statementsDuring(callable $write): array + { + $before = \count($this->statements); + $this->assertSame(2, $write()); + + return \array_slice($this->statements, $before); + } + + private function record(string $statement): void + { + $this->statements[] = $statement; + } + + /** + * @param list $statements + * @return list + */ + private function permissionStatements(array $statements): array + { + return \array_values(\array_filter($statements, static fn (string $statement): bool => \str_contains($statement, '_perms'))); + } +} diff --git a/tests/unit/Hook/PermissionsTest.php b/tests/unit/Hook/PermissionsTest.php new file mode 100644 index 0000000000..fe58695de5 --- /dev/null +++ b/tests/unit/Hook/PermissionsTest.php @@ -0,0 +1,261 @@ +adapter(); + + $this->assertTrue($adapter->createCollection('movies')); + $collection = new Document(['$id' => 'movies']); + $documents = $adapter->createDocuments($collection, [ + new Document(['$id' => 'first', '$permissions' => [Permission::read(Role::any())]]), + new Document(['$id' => 'second', '$permissions' => [Permission::read(Role::any())]]), + ]); + $updates = new class ([ + '$permissions' => [ + Permission::read(Role::user('reader')), + Permission::update(Role::user('editor')), + ], + ]) extends Document { + public int $calls = 0; + + #[\Override] + public function getPermissionsByType(PermissionType|string $type): array + { + $this->calls++; + + return parent::getPermissionsByType($type); + } + }; + + $adapter->updateDocuments($collection, $updates, $documents); + $this->assertSame(4, $updates->calls); + } + + public function testUpdateKeepsPermissionRowsUnderTheStoredDocumentIdCasing(): void + { + $pdo = new PDO('sqlite::memory:', null, null); + $adapter = $this->adapterWithLegacyCasedPermissions($pdo); + $collection = new Document(['$id' => 'movies']); + + $adapter->updateDocument($collection, 'CaseSensitive', new Document([ + '$id' => 'CaseSensitive', + '$permissions' => [ + Permission::create(Role::any()), + Permission::create(Role::guests()), + Permission::create(Role::guests()), + Permission::read(Role::guests()), + ], + ]), false); + + $this->assertSame([ + ['_document' => 'caseSensitive', '_type' => 'create', '_permission' => 'any'], + ['_document' => 'caseSensitive', '_type' => 'create', '_permission' => 'guests'], + ['_document' => 'caseSensitive', '_type' => 'read', '_permission' => 'guests'], + ], $this->permissionRows($pdo)); + } + + public function testBatchUpdateKeepsPermissionRowsUnderTheStoredDocumentIdCasing(): void + { + $pdo = new PDO('sqlite::memory:', null, null); + $adapter = $this->adapterWithLegacyCasedPermissions($pdo); + $collection = new Document(['$id' => 'movies']); + + $adapter->updateDocuments($collection, new Document([ + '$permissions' => [ + Permission::create(Role::any()), + Permission::create(Role::users()), + Permission::create(Role::users()), + Permission::read(Role::guests()), + ], + ]), $adapter->find($collection)); + + $this->assertSame([ + ['_document' => 'caseSensitive', '_type' => 'create', '_permission' => 'any'], + ['_document' => 'caseSensitive', '_type' => 'create', '_permission' => 'users'], + ['_document' => 'caseSensitive', '_type' => 'read', '_permission' => 'guests'], + ], $this->permissionRows($pdo)); + } + + public function testUpdateDoesNotInsertDuplicatePermissionRows(): void + { + $adapter = $this->adapter(); + $this->assertTrue($adapter->createCollection('movies')); + $collection = new Document(['$id' => 'movies']); + $adapter->createDocuments($collection, [ + new Document(['$id' => 'dupes', '$permissions' => [Permission::create(Role::any())]]), + ]); + + $update = new class ([ + '$id' => 'dupes', + '$permissions' => [ + Permission::create(Role::any()), + Permission::create(Role::guests()), + ], + ]) extends Document { + #[\Override] + public function getPermissionsByType(PermissionType|string $type): array + { + if ($type === PermissionType::Create) { + return ['any', 'guests', 'guests']; + } + + return parent::getPermissionsByType($type); + } + }; + + $adapter->updateDocument($collection, 'dupes', $update, false); + + $document = $adapter->getDocument($collection, 'dupes'); + $this->assertSame(['any', 'guests'], $document->getPermissionsByType(PermissionType::Create)); + } + + public function testUpdateDoesNotDuplicatePermissionsWhenDocumentIdCasingDiffers(): void + { + $adapter = $this->adapter(); + $this->assertTrue($adapter->createCollection('movies')); + $collection = new Document(['$id' => 'movies']); + $adapter->createDocuments($collection, [ + new Document([ + '$id' => 'caseSensitive', + '$permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]), + ]); + + $update = new Document([ + '$id' => 'CaseSensitive', + '$permissions' => [ + Permission::create(Role::any()), + Permission::create(Role::guests()), + Permission::create(Role::guests()), + Permission::read(Role::any()), + Permission::read(Role::guests()), + Permission::read(Role::guests()), + ], + ]); + + $adapter->updateDocument($collection, 'caseSensitive', $update, false); + + $document = $adapter->getDocument($collection, 'CaseSensitive'); + $this->assertSame('CaseSensitive', $document->getId()); + $this->assertSame(['any', 'guests'], $document->getPermissionsByType(PermissionType::Create)); + $this->assertSame(['any', 'guests'], $document->getPermissionsByType(PermissionType::Read)); + } + + public function testBatchUpdateDeduplicatesPermissionAdditions(): void + { + $this->expectNotToPerformAssertions(); + + $adapter = $this->adapter(); + $adapter->createCollection('movies'); + $collection = new Document(['$id' => 'movies']); + $documents = $adapter->createDocuments($collection, [ + new Document(['$id' => 'batch', '$permissions' => [Permission::create(Role::any())]]), + ]); + + $updates = new class ([ + '$permissions' => [ + Permission::create(Role::any()), + Permission::create(Role::guests()), + ], + ]) extends Document { + #[\Override] + public function getPermissionsByType(PermissionType|string $type): array + { + if ($type === PermissionType::Create) { + return ['any', 'guests', 'guests']; + } + + return parent::getPermissionsByType($type); + } + }; + + $adapter->updateDocuments($collection, $updates, $documents); + } + + public function testUpdateMovesPermissionRowsToTheRenamedDocument(): void + { + $pdo = new PDO('sqlite::memory:', null, null); + $adapter = $this->adapter($pdo); + $this->assertTrue($adapter->createCollection('movies')); + $collection = new Document(['$id' => 'movies']); + $permissions = [ + Permission::read(Role::user('alice')), + Permission::update(Role::user('alice')), + ]; + [$created] = $adapter->createDocuments($collection, [ + new Document(['$id' => 'before', '$permissions' => $permissions]), + ]); + + $adapter->updateDocument($collection, 'before', new Document([ + '$id' => 'after', + '$sequence' => $created->getSequence(), + '$permissions' => $permissions, + ]), false); + + $rows = $pdo->prepare('SELECT _document, _type, _permission FROM permissions_movies_perms ORDER BY _type'); + $rows->execute(); + + $this->assertSame([ + ['_document' => 'after', '_type' => 'read', '_permission' => 'user:alice'], + ['_document' => 'after', '_type' => 'update', '_permission' => 'user:alice'], + ], $rows->fetchAll(\PDO::FETCH_ASSOC), 'The rows keyed by the old id are unreadable and must follow the document to its new id'); + } + + private function adapterWithLegacyCasedPermissions(PDO $pdo): SQLite + { + $adapter = $this->adapter($pdo); + $this->assertTrue($adapter->createCollection('movies')); + $adapter->createDocuments(new Document(['$id' => 'movies']), [ + new Document([ + '$id' => 'CaseSensitive', + '$permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]), + ]); + $pdo->exec("UPDATE permissions_movies_perms SET _document = 'caseSensitive'"); + + return $adapter; + } + + /** + * @return array + */ + private function permissionRows(PDO $pdo): array + { + $rows = $pdo->prepare('SELECT _document, _type, _permission FROM permissions_movies_perms ORDER BY _type, _permission'); + $rows->execute(); + + return $rows->fetchAll(\PDO::FETCH_ASSOC); + } + + private function adapter(?PDO $pdo = null): SQLite + { + $adapter = new SQLite($pdo ?? new PDO('sqlite::memory:', null, null)); + $adapter->setNamespace('permissions'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $adapter->addWriteHook(new Permissions()); + + return $adapter; + } +} diff --git a/tests/unit/Hook/RelationshipCoroutineTest.php b/tests/unit/Hook/RelationshipCoroutineTest.php new file mode 100644 index 0000000000..3330f1e527 --- /dev/null +++ b/tests/unit/Hook/RelationshipCoroutineTest.php @@ -0,0 +1,228 @@ +markTestSkipped('ext-swoole is required for coroutines sharing a handle'); + } + } + + public function testANestedWriteInOneCoroutineLeavesAnotherCoroutinesNestedWritesWhole(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $database = $this->database(); + foreach (['levelOne', 'levelTwo', 'levelThree', 'shelves', 'books'] as $collection) { + $database->createCollection($this->collection($collection)); + } + $this->relate($database, 'levelOne', 'levelTwo', RelationshipType::OneToMany, 'children', 'parent'); + $this->relate($database, 'levelTwo', 'levelThree', RelationshipType::OneToMany, 'children', 'parent'); + $this->relate($database, 'shelves', 'books', RelationshipType::OneToMany, 'books', 'shelf'); + $this->armed = true; + + $done = new Channel(1); + Coroutine::create(function () use ($database, $done): void { + $database->createDocument('levelOne', new Document([ + '$id' => 'one', + 'name' => 'one', + 'children' => [new Document([ + '$id' => 'two', + 'name' => 'two', + 'children' => [new Document(['$id' => 'three', 'name' => self::PAUSE])], + ])], + ])); + $done->push(true); + }); + + $this->assertTrue($this->pausedChannel()->pop(5), 'The first coroutine never paused'); + $database->createDocument('shelves', new Document([ + '$id' => 'fiction', + 'name' => 'fiction', + 'books' => [new Document(['$id' => 'dune', 'name' => 'dune'])], + ])); + $this->resumedChannel()->push(true); + $done->pop(); + + $seen['book'] = $database->getDocument('books', 'dune')->getId(); + $books = $database->getDocument('shelves', 'fiction')->getAttribute('books', []); + $this->assertIsArray($books); + $seen['shelf'] = \array_map(static function (mixed $book): string { + self::assertInstanceOf(Document::class, $book); + + return $book->getId(); + }, $books); + $seen['three'] = $database->getDocument('levelThree', 'three')->getId(); + }); + + $this->assertSame(['book' => 'dune', 'shelf' => ['dune'], 'three' => 'three'], $seen); + } + + public function testACascadeInOneCoroutineLeavesAnotherCoroutinesCascadeWhole(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $database = $this->database(); + foreach (['people', 'pets', 'passports'] as $collection) { + $database->createCollection($this->collection($collection)); + } + $this->relate($database, 'people', 'pets', RelationshipType::OneToMany, 'pets', 'owner'); + $this->relate($database, 'people', 'passports', RelationshipType::OneToOne, 'passport', 'person', RelationshipDeleteAction::Cascade); + + $database->createDocument('people', new Document([ + '$id' => 'paused', + 'name' => 'paused', + 'pets' => [new Document(['$id' => 'rex', 'name' => self::PAUSE])], + 'passport' => new Document(['$id' => 'pausedPassport', 'name' => 'pausedPassport']), + ])); + $database->createDocument('people', new Document([ + '$id' => 'other', + 'name' => 'other', + 'passport' => new Document(['$id' => 'otherPassport', 'name' => 'otherPassport']), + ])); + $this->armed = true; + + $done = new Channel(1); + Coroutine::create(function () use ($database, $done): void { + $database->deleteDocument('passports', 'pausedPassport'); + $done->push(true); + }); + + $this->assertTrue($this->pausedChannel()->pop(5), 'The first coroutine never paused'); + $database->deleteDocument('people', 'other'); + $this->resumedChannel()->push(true); + $done->pop(); + + $seen['otherPassportDeleted'] = $database->getDocument('passports', 'otherPassport')->isEmpty(); + $seen['pausedDeleted'] = $database->getDocument('people', 'paused')->isEmpty(); + $seen['rexOwner'] = $database->getDocument('pets', 'rex')->getAttribute('owner'); + }); + + $this->assertSame(['otherPassportDeleted' => true, 'pausedDeleted' => true, 'rexOwner' => null], $seen); + } + + private function database(): Database + { + $this->paused = new Channel(1); + $this->resumed = new Channel(1); + $this->armed = false; + $pause = function (mixed $value): mixed { + if ($value === self::PAUSE && $this->armed) { + $this->armed = false; + $this->pausedChannel()->push(true); + $this->resumedChannel()->pop(); + } + + return $value; + }; + + $database = new Database( + new SQLite(new PDO('sqlite::memory:')), + new Cache(new None()), + [new Callback(self::FILTER, $pause, static fn (mixed $value): mixed => $value)], + ); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('relationships') + ->setNamespace('relationships_'.\uniqid()); + $database->addHook(new Relationships()); + $database->create(); + + return $database; + } + + private function collection(string $id): Collection + { + return Collection::create( + id: $id, + attributes: [Attribute::string(key: 'name', size: 64, filters: [self::FILTER])], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + ); + } + + private function relate( + Database $database, + string $collection, + string $relatedCollection, + RelationshipType $type, + string $key, + string $twoWayKey, + RelationshipDeleteAction $onDelete = RelationshipDeleteAction::SetNull, + ): void { + $database->createRelationship($collection, Relationship::fromArray([ + 'relatedCollection' => $relatedCollection, + 'relationType' => $type, + 'twoWay' => true, + 'key' => $key, + 'twoWayKey' => $twoWayKey, + 'onDelete' => $onDelete, + ])); + } + + private function pausedChannel(): Channel + { + return $this->paused ?? throw new \LogicException('The database is not built'); + } + + private function resumedChannel(): Channel + { + return $this->resumed ?? throw new \LogicException('The database is not built'); + } + + private function inCoroutine(Closure $test): void + { + $hookFlags = Runtime::getHookFlags(); + + try { + run($test); + } finally { + Runtime::setHookFlags($hookFlags); + } + } +} diff --git a/tests/unit/Hook/RemoveHookTest.php b/tests/unit/Hook/RemoveHookTest.php new file mode 100644 index 0000000000..9e915d61ed --- /dev/null +++ b/tests/unit/Hook/RemoveHookTest.php @@ -0,0 +1,188 @@ +addHook($removed)->addHook($kept); + + $this->assertSame($database, $database->removeHook($removed)); + $database->getCollection(HookFixture::COLLECTION); + + $this->assertSame([], $removed->getEvents()); + $this->assertSame([Event::CollectionRead], $kept->getEvents()); + } + + public function testRemovingAClassRemovesEveryHookOfIt(): void + { + $database = HookFixture::memory(); + $first = new RecordingLifecycle(); + $second = new RecordingLifecycle(); + $database->addHook($first)->addHook($second); + + $database->removeHook(RecordingLifecycle::class); + $database->getCollection(HookFixture::COLLECTION); + + $this->assertSame([], $first->getEvents()); + $this->assertSame([], $second->getEvents()); + } + + public function testRemovingADecoratorLeavesDocumentsUndecorated(): void + { + $database = HookFixture::memory(); + $decorator = new class () implements Decorator { + #[\Override] + public function decorate(Event $event, Document $collection, Document $document): Document + { + return $document->setAttribute('decorated', true); + } + }; + $database->addHook($decorator); + HookFixture::seed($database, ['first']); + + $database->removeHook($decorator); + + $this->assertNull($database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('decorated')); + } + + public function testRemovingAWriteHookStopsItInterceptingWrites(): void + { + $database = HookFixture::sqlite(); + $hook = new RecordingWrite(); + $database->addHook($hook); + HookFixture::seed($database, ['first']); + + $database->removeHook($hook); + HookFixture::seed($database, ['second']); + + $this->assertSame([['create', HookFixture::COLLECTION, ['first']]], $hook->writes); + } + + public function testRemovingAWriteHookByAnInterfaceRemovesEveryWriteHook(): void + { + $database = HookFixture::sqlite(); + $hook = new RecordingWrite(); + $database->addHook($hook); + + $database->removeHook(Write::class); + HookFixture::seed($database, ['first']); + + $this->assertSame([], $hook->writes); + } + + public function testRemovingATransformStopsItRewritingStatements(): void + { + $database = HookFixture::sqlite(); + $transform = new class () implements Transform { + public int $transformed = 0; + + #[\Override] + public function transform(Event $event, string $query): string + { + $this->transformed++; + + return $query; + } + }; + $database->addHook($transform); + HookFixture::seed($database, ['first']); + $this->assertGreaterThan(0, $transform->transformed); + + $database->removeHook($transform); + $transformed = $transform->transformed; + HookFixture::seed($database, ['second']); + + $this->assertSame($transformed, $transform->transformed); + } + + public function testRemovingTheRelationshipsHookStopsRelatingDocuments(): void + { + $database = $this->family(); + $database->addHook(new Relationships()); + + $database->removeHook(Relationships::class); + $database->createDocument('parents', $this->parent()); + + $this->assertTrue($database->getDocument('children', 'c1')->isEmpty()); + } + + public function testRemovingTheRelationshipsHookFromAMirrorRemovesItFromBothSides(): void + { + $source = $this->family(); + $destination = $this->family(); + $mirror = new Mirror($source, $destination); + $hook = new Relationships(); + $mirror->addHook($hook); + + $mirror->removeHook($hook); + $source->createDocument('parents', $this->parent()); + $destination->createDocument('parents', $this->parent()); + + $this->assertTrue($source->getDocument('children', 'c1')->isEmpty()); + $this->assertTrue($destination->getDocument('children', 'c1')->isEmpty()); + } + + public function testRemovingALifecycleHookFromAMirrorStopsItsEvents(): void + { + $mirror = new Mirror(HookFixture::memory()); + $recorder = new RecordingLifecycle(); + $mirror->addHook($recorder); + + $mirror->removeHook($recorder); + $mirror->getCollection(HookFixture::COLLECTION); + + $this->assertSame([], $recorder->getEvents()); + } + + private function family(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database->setDatabase('remove_hook')->setNamespace('remove_hook'); + $database->create(); + foreach (['parents', 'children'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + } + $database->createRelationship('parents', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, key: 'children', twoWayKey: 'parent')); + + return $database; + } + + private function parent(): Document + { + return new Document([ + '$id' => 'p1', + 'name' => 'p1', + 'children' => [new Document(['$id' => 'c1', 'name' => 'c1'])], + ]); + } +} diff --git a/tests/unit/Hook/TenancyHookTest.php b/tests/unit/Hook/TenancyHookTest.php new file mode 100644 index 0000000000..ac51e4e117 --- /dev/null +++ b/tests/unit/Hook/TenancyHookTest.php @@ -0,0 +1,43 @@ + + */ + public static function tenants(): array + { + return [ + 'integer' => [5], + 'string' => ['tenant-a'], + 'per document' => [null], + ]; + } + + #[DataProvider('tenants')] + public function testDecorateRowStoresTheRowsTenant(int|string|null $tenant): void + { + $this->assertSame( + ['title' => 'x', Storage::TENANT => $tenant], + (new Tenancy())->decorateRow(['title' => 'x'], new RowMetadata($tenant)), + ); + } + + public function testDecorateRowStoresTheTenantInTheGivenColumn(): void + { + $this->assertSame(['title' => 'x', 'owner' => 5], (new Tenancy('owner'))->decorateRow(['title' => 'x'], new RowMetadata(5))); + } + + public function testDecorateRowReplacesATenantTheRowAlreadyHolds(): void + { + $this->assertSame([Storage::TENANT => 7], (new Tenancy())->decorateRow([Storage::TENANT => 5], new RowMetadata(7))); + } +} diff --git a/tests/unit/Hook/WriteContextTest.php b/tests/unit/Hook/WriteContextTest.php new file mode 100644 index 0000000000..cbb5e69261 --- /dev/null +++ b/tests/unit/Hook/WriteContextTest.php @@ -0,0 +1,157 @@ +database(); + $recorder = new WriteContextTestRecorder(); + $database->addHook($recorder); + $database->createDocument(self::COLLECTION, $this->note('first', 'any')); + + $database->updateDocument(self::COLLECTION, 'first', new Document(['body' => 'kept'])); + $database->updateDocument(self::COLLECTION, 'first', new Document([ + 'body' => 'changed', + '$permissions' => [Permission::read(Role::users())], + ])); + + $this->assertSame([true, false], $recorder->skipPermissions); + } + + public function testUpdateHandsTheHookTheIdTheDocumentWasStoredUnder(): void + { + $database = $this->database(); + $recorder = new WriteContextTestRecorder(); + $database->addHook($recorder); + $database->createDocument(self::COLLECTION, $this->note('first', 'any')); + + $database->updateDocument(self::COLLECTION, 'first', new Document(['body' => 'kept'])); + + $this->assertSame([['first', 'first']], $recorder->updates); + } + + public function testBuilderReadsOnlyTheAdaptersTenantWhileRawBuilderReadsEveryTenant(): void + { + $database = $this->database(tenant: 1); + $database->createDocument(self::COLLECTION, $this->note('first', 'any')); + + $recorder = new WriteContextTestRecorder(); + $database->addHook($recorder); + $database->setTenant(2); + $database->createCollection($this->notes()); + $database->createDocument(self::COLLECTION, $this->note('second', 'users')); + + $this->assertSame( + [ + 'scoped' => [['second', 'users', 2]], + 'raw' => [['first', 'any', 1], ['second', 'users', 2]], + ], + $recorder->permissionRows, + ); + } + + public function testDecorateRowStoresTheTenantTheRowIsWrittenFor(): void + { + $database = $this->database(tenant: 3); + $recorder = new WriteContextTestRecorder(); + $database->addHook($recorder); + + $database->createDocument(self::COLLECTION, $this->note('first', 'any')); + + $this->assertSame([[Storage::TENANT => 3]], $recorder->decorated); + } + + public function testDecorateRowLeavesTheRowAsItIsWithoutSharedTables(): void + { + $database = $this->database(); + $recorder = new WriteContextTestRecorder(); + $database->addHook($recorder); + + $database->createDocument(self::COLLECTION, $this->note('first', 'any')); + + $this->assertSame([[]], $recorder->decorated); + } + + public function testRunRemovesTheRowsItsStatementDeletes(): void + { + $database = $this->database(); + $database->addHook(new class () extends Interceptor { + #[\Override] + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + $builder = $context->builder()->from(Storage::permissionsTable($collection)); + $builder->filter([Query::equal(Storage::PERMISSIONS_DOCUMENT, ['first'])]); + $context->run($builder->delete(), Event::PermissionsDelete); + } + }); + + $database->createDocument(self::COLLECTION, $this->note('first', 'any')); + + $this->assertSame([], $database->find(self::COLLECTION)); + $this->assertSame(['first'], \array_map( + static fn (Document $document): string => $document->getId(), + $database->getAuthorization()->skip(static fn (): array => $database->find(self::COLLECTION)), + )); + } + + private function database(int|string|null $tenant = null): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('write_context') + ->setNamespace('write_context'); + if ($tenant !== null) { + $database->setSharedTables(true)->setTenant($tenant); + } + $database->create(); + $database->addHook(new Permissions()); + $database->createCollection($this->notes()); + + return $database; + } + + private function notes(): Collection + { + return Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'body', size: 64)], + permissions: [Permission::create(Role::any()), Permission::update(Role::any())], + documentSecurity: true, + ); + } + + private function note(string $id, string $reader): Document + { + return new Document([ + '$id' => $id, + 'body' => $id, + '$permissions' => [Permission::read($reader === 'any' ? Role::any() : Role::users())], + ]); + } +} diff --git a/tests/unit/Hook/WriteContextTestRecorder.php b/tests/unit/Hook/WriteContextTestRecorder.php new file mode 100644 index 0000000000..9ff2220bb6 --- /dev/null +++ b/tests/unit/Hook/WriteContextTestRecorder.php @@ -0,0 +1,70 @@ + */ + public array $skipPermissions = []; + + /** @var list */ + public array $updates = []; + + /** @var array> */ + public array $permissionRows = []; + + /** @var list> */ + public array $decorated = []; + + #[\Override] + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + foreach ($documents as $document) { + $this->decorated[] = $context->decorateRow([], $document); + } + + $table = Storage::permissionsTable($collection); + $scoped = $context->builder()->from($table)->sortAsc(Storage::PERMISSIONS_DOCUMENT)->build(); + $raw = $context->builder()->fromTable($context->rawTable($table))->sortAsc(Storage::PERMISSIONS_DOCUMENT)->build(); + + $this->permissionRows = [ + 'scoped' => $this->rows($context->fetch($scoped, Event::PermissionsRead)), + 'raw' => $this->rows($context->fetch($raw, Event::PermissionsRead)), + ]; + } + + #[\Override] + public function afterDocumentUpdate(string $collection, string $id, Document $document, WriteContext $context): void + { + $this->skipPermissions[] = $context->skipPermissions($document); + $this->updates[] = [$id, $document->getId()]; + } + + /** + * @param list> $rows + * @return list + */ + private function rows(array $rows): array + { + return \array_map(static function (array $row): array { + $document = $row[Storage::PERMISSIONS_DOCUMENT] ?? null; + $permission = $row[Storage::PERMISSIONS_PERMISSION] ?? null; + $tenant = $row[Storage::TENANT] ?? null; + + return [ + \is_string($document) ? $document : '', + \is_string($permission) ? $permission : '', + \is_numeric($tenant) ? (int) $tenant : null, + ]; + }, $rows); + } +} diff --git a/tests/unit/IDTest.php b/tests/unit/IDTest.php deleted file mode 100644 index 8953097560..0000000000 --- a/tests/unit/IDTest.php +++ /dev/null @@ -1,22 +0,0 @@ -assertEquals('test', $id); - } - - public function testUniqueID(): void - { - $id = ID::unique(); - $this->assertNotEmpty($id); - $this->assertIsString($id); - } -} diff --git a/tests/unit/IdTest.php b/tests/unit/IdTest.php new file mode 100644 index 0000000000..6f515932c4 --- /dev/null +++ b/tests/unit/IdTest.php @@ -0,0 +1,22 @@ +assertEquals('test', $id); + } + + public function test_unique_id(): void + { + $id = Id::unique(); + $this->assertNotEmpty($id); + $this->assertIsString($id); // @phpstan-ignore method.alreadyNarrowedType + } +} diff --git a/tests/unit/Indexes/CreateIndexesTest.php b/tests/unit/Indexes/CreateIndexesTest.php new file mode 100644 index 0000000000..642c653d2a --- /dev/null +++ b/tests/unit/Indexes/CreateIndexesTest.php @@ -0,0 +1,164 @@ +database(new Memory()); + + $created = $database->createIndexes('users', [ + Index::unique('by_email', ['email']), + Index::key('by_name', ['name'], [64]), + ]); + + $this->assertSame(['by_email', 'by_name'], \array_map(static fn (Index $index): string => $index->key, $created)); + $this->assertSame([null], $created[1]->lengths); + $this->assertSame( + \array_map(static fn (Index $index): array => $index->toDocument()->getArrayCopy(), $created), + \array_map(static fn (Index $index): array => $index->toDocument()->getArrayCopy(), $database->getCollection('users')->indexes()), + ); + + $this->createUser($database, 'one', 'same@example.com'); + $this->expectException(DuplicateException::class); + $this->createUser($database, 'two', 'same@example.com'); + } + + public function testAnEmptyListCreatesNothing(): void + { + $database = $this->database(new Memory()); + + $this->assertSame([], $database->createIndexes('users', [])); + $this->assertSame([], $database->getCollection('users')->indexes()); + } + + public function testAnInvalidIndexCreatesNone(): void + { + $database = $this->database(new Memory()); + + try { + $database->createIndexes('users', [ + Index::unique('by_email', ['email']), + Index::key('by_missing', ['missing']), + ]); + $this->fail('An index on a missing attribute was accepted'); + } catch (IndexException $error) { + $this->assertSame('Invalid index attribute "missing" not found', $error->getMessage()); + } + + $this->assertNoIndexes($database); + } + + public function testKeysRepeatedInTheBatchCreateNone(): void + { + $database = $this->database(new Memory()); + + try { + $database->createIndexes('users', [ + Index::unique('by_email', ['email']), + Index::key('BY_EMAIL', ['name']), + ]); + $this->fail('A key repeated in the batch was accepted'); + } catch (DuplicateException) { + } + + $this->assertNoIndexes($database); + } + + public function testABatchOverTheIndexLimitCreatesNone(): void + { + $database = $this->database(new Memory()); + $indexes = []; + for ($position = 0; $position < 65; $position++) { + $indexes[] = Index::key('by_name_'.$position, ['name']); + } + + try { + $database->createIndexes('users', $indexes); + $this->fail('A batch over the index limit was accepted'); + } catch (LimitException) { + } + + $this->assertNoIndexes($database); + } + + public function testAnEngineFailurePartWayDropsTheIndexesAlreadyCreated(): void + { + $adapter = new class () extends Memory { + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool + { + if ($index->key === 'broken') { + throw new DatabaseException('Engine refused the index'); + } + + return parent::createIndex($collection, $index, $indexAttributeTypes, $collation); + } + }; + $database = $this->database($adapter); + + try { + $database->createIndexes('users', [ + Index::unique('by_email', ['email']), + Index::key('broken', ['name']), + ]); + $this->fail('The engine failure was swallowed'); + } catch (DatabaseException $error) { + $this->assertSame('Engine refused the index', $error->getMessage()); + } + + $this->assertNoIndexes($database); + } + + private function assertNoIndexes(Database $database): void + { + $this->assertSame([], $database->getCollection('users')->indexes()); + + $this->createUser($database, 'one', 'same@example.com'); + $this->createUser($database, 'two', 'same@example.com'); + $this->assertSame(2, $database->count('users')); + } + + private function createUser(Database $database, string $id, string $email): void + { + $database->createDocument('users', new Document([ + '$id' => $id, + 'email' => $email, + 'name' => $id, + ])); + } + + private function database(Memory $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('create_indexes') + ->setNamespace('create_indexes_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create('users', attributes: [ + Attribute::string('email', 128), + Attribute::string('name', 64), + ], permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + + return $database; + } +} diff --git a/tests/unit/Indexes/CreateReturnsStoredTest.php b/tests/unit/Indexes/CreateReturnsStoredTest.php new file mode 100644 index 0000000000..1d5c879b1f --- /dev/null +++ b/tests/unit/Indexes/CreateReturnsStoredTest.php @@ -0,0 +1,124 @@ +database(); + + $created = $database->createIndex('books', Index::key('by_title', ['title'], [64], [OrderDirection::Desc])); + + $this->assertSame([null], $created->lengths); + $this->assertSame([OrderDirection::Desc], $created->orders); + $this->assertSame($created->toDocument()->getArrayCopy(), $this->stored($database, 'by_title')->toDocument()->getArrayCopy()); + } + + public function testCreateIndexRefusesAKeyThatDiffersOnlyInCase(): void + { + $database = $this->database(); + $database->createIndex('books', Index::key('by_title', ['title'])); + + $this->expectException(DuplicateException::class); + + $database->createIndex('books', Index::key('BY_TITLE', ['title'])); + } + + public function testCreateIndexRefusesAnIndexWithoutAttributes(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Missing attributes'); + + $this->database()->createIndex('books', Index::key('empty', [])); + } + + public function testRenameIndexStoresTheIndexUnderTheNewKey(): void + { + $database = $this->database(); + $database->createIndex('books', Index::key('by_title', ['title'])); + + $database->renameIndex('books', 'by_title', 'title_lookup'); + + $this->assertSame(['title_lookup'], $this->keys($database)); + $this->assertSame(['title'], $this->stored($database, 'title_lookup')->attributes); + } + + public function testRenameIndexRefusesAKeyInUse(): void + { + $database = $this->database(); + $database->createIndex('books', Index::key('by_title', ['title'])); + $database->createIndex('books', Index::unique('by_isbn', ['isbn'])); + + $this->expectException(DuplicateException::class); + + $database->renameIndex('books', 'by_title', 'by_isbn'); + } + + public function testDeleteIndexRemovesItFromTheMetadata(): void + { + $database = $this->database(); + $database->createIndex('books', Index::key('by_title', ['title'])); + $database->createIndex('books', Index::unique('by_isbn', ['isbn'])); + + $database->deleteIndex('books', 'by_title'); + + $this->assertSame(['by_isbn'], $this->keys($database)); + } + + public function testDeleteIndexOfAMissingKeyIsNotFound(): void + { + $this->expectException(NotFoundException::class); + + $this->database()->deleteIndex('books', 'missing'); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('index_returns_stored') + ->setNamespace('index_returns_stored_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create('books', attributes: [ + Attribute::string('title', 64), + Attribute::string('isbn', 32), + ])); + + return $database; + } + + /** + * @return list + */ + private function keys(Database $database): array + { + return \array_map(static fn (Index $index): string => $index->key, $database->getCollection('books')->indexes()); + } + + private function stored(Database $database, string $key): Index + { + foreach ($database->getCollection('books')->indexes() as $index) { + if ($index->key === $key) { + return $index; + } + } + + $this->fail('Index '.$key.' is missing from the collection metadata'); + } +} diff --git a/tests/unit/Indexes/IndexSchemaReconciliationTest.php b/tests/unit/Indexes/IndexSchemaReconciliationTest.php new file mode 100644 index 0000000000..013ef49073 --- /dev/null +++ b/tests/unit/Indexes/IndexSchemaReconciliationTest.php @@ -0,0 +1,330 @@ + + */ + public array $renames = []; + + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + $this->renames[] = "{$old}->{$new}"; + + return false; + } + }; + $database = $this->database($adapter); + + try { + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->fail('an adapter that renames nothing must refuse the rename'); + } catch (RefusedException $error) { + $this->assertSame("Failed to rename index 'existing' to 'renamed'", $error->getMessage()); + $this->assertNull($error->getPrevious()); + } + + $this->assertSame(['existing->renamed'], $adapter->renames, 'a refusal is not a prior rename to complete'); + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + public function testAnAdapterThatDoesNotCreateTheIndexFailsTheCreate(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool + { + return $index->key === 'byName' ? false : parent::createIndex($collection, $index, $indexAttributeTypes, $collation); + } + }); + + $this->assertRefused('Failed to create index', fn (): Index => $database->createIndex(self::COLLECTION, $this->byName())); + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + public function testAnIndexOnlyInTheSchemaIsAdopted(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool + { + if ($index->key === 'byName') { + throw new DuplicateException('Index already exists in the schema'); + } + + return parent::createIndex($collection, $index, $indexAttributeTypes, $collation); + } + }); + + $this->assertSame('byName', $database->createIndex(self::COLLECTION, $this->byName())->key); + $this->assertSame(['existing', 'byName'], $this->indexKeys($database)); + } + + public function testRenamingAnUnknownIndexIsNotFound(): void + { + $database = $this->database(new Memory()); + + try { + $database->renameIndex(self::COLLECTION, 'missing', 'renamed'); + $this->fail('an unknown index cannot be renamed'); + } catch (NotFoundException $error) { + $this->assertSame('Index not found', $error->getMessage()); + } + + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + public function testRenamingAnIndexTheSchemaNoLongerHasFails(): void + { + $adapter = new Memory(); + $database = $this->database($adapter); + $adapter->deleteIndex(self::COLLECTION, 'existing'); + + try { + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->fail('a rename of an index the schema does not have must fail'); + } catch (RefusedException $error) { + $this->assertSame("Failed to rename index 'existing' to 'renamed'", $error->getMessage()); + } + + $this->assertSame(['existing'], $this->indexKeys($database)); + $this->assertFalse($adapter->renameIndex(self::COLLECTION, 'existing', 'renamed')); + } + + public function testRenamingAnIndexTheSchemaAlreadyRenamedCompletes(): void + { + $adapter = new Memory(); + $database = $this->database($adapter); + $this->assertTrue($adapter->renameIndex(self::COLLECTION, 'existing', 'renamed')); + + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->assertSame(['renamed'], $this->indexKeys($database)); + $this->assertTrue($adapter->renameIndex(self::COLLECTION, 'existing', 'renamed'), 'the index already carries the new name'); + } + + public function testARenameTheSchemaAlreadyAppliedIsCompleted(): void + { + $adapter = new class () extends Memory { + /** + * @var list + */ + public array $renames = []; + + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + $this->renames[] = "{$old}->{$new}"; + if (\count($this->renames) === 1) { + throw new NotFoundException('Index not found in the schema'); + } + + return parent::renameIndex($collection, $old, $new); + } + }; + $database = $this->database($adapter); + + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->assertSame(['existing->renamed', 'renamed->existing', 'existing->renamed'], $adapter->renames); + $this->assertSame(['renamed'], $this->indexKeys($database)); + } + + public function testARenameThatFailsBothWaysIsReportedWithItsCause(): void + { + $cause = new RuntimeException('the engine refused the rename'); + $database = $this->database(new class ($cause) extends Memory { + public function __construct(private readonly RuntimeException $cause) + { + parent::__construct(); + } + + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + throw $this->cause; + } + }); + + try { + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->fail('a rename that fails both ways must be reported'); + } catch (DatabaseException $error) { + $this->assertNotInstanceOf(RefusedException::class, $error); + $this->assertSame("Failed to rename index 'existing' to 'renamed': the engine refused the rename", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + } + + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + public function testARenameTheAdapterDoesNotRedoAfterReversingItIsReportedWithItsCause(): void + { + $cause = new RuntimeException('the engine lost the index'); + $adapter = new class ($cause) extends Memory { + /** + * @var list + */ + public array $renames = []; + + public function __construct(private readonly RuntimeException $cause) + { + parent::__construct(); + } + + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + $this->renames[] = "{$old}->{$new}"; + + return match (\count($this->renames)) { + 1 => throw $this->cause, + 2 => true, + default => false, + }; + } + }; + $database = $this->database($adapter); + + try { + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->fail('a rename the adapter does not redo must fail'); + } catch (DatabaseException $error) { + $this->assertNotInstanceOf(RefusedException::class, $error); + $this->assertSame("Failed to rename index 'existing' to 'renamed': the engine lost the index", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + } + + $this->assertSame(['existing->renamed', 'renamed->existing', 'existing->renamed'], $adapter->renames); + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + /** + * @return array + */ + public static function typedRenameFailures(): array + { + return [ + 'not found' => [new NotFoundException('Collection not found')], + 'duplicate' => [new DuplicateException('Index already exists')], + ]; + } + + #[DataProvider('typedRenameFailures')] + public function testATypedFailureTheRenameCannotCompleteIsTheAdaptersOwn(\Throwable $failure): void + { + $database = $this->database(new class ($failure) extends Memory { + public function __construct(private readonly \Throwable $failure) + { + parent::__construct(); + } + + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + throw $this->failure; + } + }); + + try { + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->fail('a rename that fails both ways must be reported'); + } catch (\Throwable $error) { + $this->assertSame($failure, $error); + } + + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + public function testDeletingAnIndexTheSchemaNoLongerHasSucceeds(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function deleteIndex(string $collection, string $id): bool + { + throw new NotFoundException('Index not found in the schema'); + } + }); + + $database->deleteIndex(self::COLLECTION, 'existing'); + $this->assertSame([], $this->indexKeys($database)); + } + + public function testAnAdapterThatDoesNotDeleteTheIndexFailsTheDelete(): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function deleteIndex(string $collection, string $id): bool + { + return false; + } + }); + + $this->assertRefused('Failed to delete index', fn () => $database->deleteIndex(self::COLLECTION, 'existing')); + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + /** + * @return list + */ + private function indexKeys(Database $database): array + { + return \array_map(static fn (Index $index): string => $index->key, $database->getCollection(self::COLLECTION)->indexes()); + } + + private function database(Memory $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('indexes')->setNamespace('reconcile_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 32), Attribute::string(key: 'sku', size: 32)], + indexes: [Index::key(key: 'existing', attributes: ['sku'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } + + private function byName(): Index + { + return Index::key(key: 'byName', attributes: ['name']); + } + + /** + * @param callable(): mixed $operation + */ + private function assertRefused(string $message, callable $operation): void + { + try { + $operation(); + $this->fail('the operation must be refused'); + } catch (RefusedException $error) { + $this->assertSame($message, $error->getMessage()); + } + } +} diff --git a/tests/unit/Indexes/IndexValidationTest.php b/tests/unit/Indexes/IndexValidationTest.php new file mode 100644 index 0000000000..0037dcf437 --- /dev/null +++ b/tests/unit/Indexes/IndexValidationTest.php @@ -0,0 +1,325 @@ + */ + private array $metadataWrites = []; + + #[\Override] + protected function setUp(): void + { + $this->adapter = self::createStub(Adapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::IndexTtl, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createIndex')->willReturn(true); + $this->adapter->method('deleteIndex')->willReturn(true); + $this->adapter->method('renameIndex')->willReturn(true); + $this->adapter->method('createDocument')->willReturnArgument(1); + $this->adapter->method('updateDocument')->willReturnArgument(2); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + private function metaCollection(): Document + { + return new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $attributes + * @param array $indexes + */ + private function setupCollection(string $id, array $attributes = [], array $indexes = []): void + { + $collection = new Document([ + '$id' => $id, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true, + ]); + $meta = $this->metaCollection(); + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($id, $collection, $meta) { + if ($col->getId() === Database::METADATA && $docId === $id) { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + + return new Document(); + } + ); + $this->adapter->method('updateDocument')->willReturnCallback(function (Document $collection, string $id, Document $document): Document { + $this->metadataWrites[] = $document; + + return $document; + }); + } + + private function lastMetadataWrite(): Collection + { + $write = \end($this->metadataWrites); + if ($write === false) { + $this->fail('no metadata was written'); + } + + return Collection::fromDocument($write); + } + + public function testCreateIndexValidatesAttributeExists(): void + { + $this->setupCollection('testCol'); + + $this->expectException(IndexException::class); + $this->database->createIndex('testCol', Index::key(key: 'idx1', attributes: ['nonexistent'])); + } + + public function testCreateIndexEnforcesIndexCountLimit(): void + { + $adapter = self::createStub(Adapter::class); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 1, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(5); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [Capability::IndexKey, Capability::IndexArray, Capability::IndexUnique, Capability::DefinedAttributes]); + }); + $adapter->method('createIndex')->willReturn(true); + + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $collection = new Document([ + '$id' => 'testCol', + '$collection' => Database::METADATA, + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], + 'name' => 'testCol', + 'attributes' => $attributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($collection) { + if ($col->getId() === Database::METADATA && $docId === 'testCol') { + return $collection; + } + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return Database::collectionDefinition(); + } + + return new Document(); + } + ); + $adapter->method('updateDocument')->willReturnArgument(2); + + $db = new Database($adapter, new Cache(new None())); + $db->getAuthorization()->addRole(Role::any()->toString()); + + $this->expectException(LimitException::class); + $this->expectExceptionMessage('Index limit'); + $db->createIndex('testCol', Index::key(key: 'idx_name', attributes: ['name'])); + } + + public function testCreateIndexRejectsDuplicateKey(): void + { + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $indexes = [ + new Document(['$id' => 'idx_name', 'key' => 'idx_name', 'type' => 'key', 'attributes' => ['name'], 'lengths' => [], 'orders' => []]), + ]; + $this->setupCollection('testCol', $attributes, $indexes); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Index already exists'); + $this->database->createIndex('testCol', Index::key(key: 'idx_name', attributes: ['name'])); + } + + public function testCreateIndexMissingAttributesThrows(): void + { + $this->setupCollection('testCol'); + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Missing attributes'); + $this->database->createIndex('testCol', Index::fromArray(['key' => 'idx_empty', 'type' => IndexType::Key])); + } + + public function testCreateIndexSucceedsWithValidConfig(): void + { + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $this->setupCollection('testCol', $attributes); + + $created = $this->database->createIndex('testCol', Index::key(key: 'idx_name', attributes: ['name'])); + + $this->assertSame('idx_name', $created->key); + $this->assertSame(['name'], $created->attributes); + } + + public function testDeleteIndexThrowsOnNotFound(): void + { + $this->setupCollection('testCol'); + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Index not found'); + $this->database->deleteIndex('testCol', 'nonexistent'); + } + + public function testRenameIndexThrowsOnNotFound(): void + { + $this->setupCollection('testCol'); + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Index not found'); + $this->database->renameIndex('testCol', 'nonexistent', 'newname'); + } + + public function testRenameIndexThrowsOnExistingName(): void + { + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + new Document(['$id' => 'title', 'key' => 'title', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $indexes = [ + new Document(['$id' => 'idx_name', 'key' => 'idx_name', 'type' => 'key', 'attributes' => ['name'], 'lengths' => [], 'orders' => []]), + new Document(['$id' => 'idx_title', 'key' => 'idx_title', 'type' => 'key', 'attributes' => ['title'], 'lengths' => [], 'orders' => []]), + ]; + $this->setupCollection('testCol', $attributes, $indexes); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Index name already used'); + $this->database->renameIndex('testCol', 'idx_name', 'idx_title'); + } + + public function testRenameIndexSucceeds(): void + { + $attributes = [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 128, 'required' => false, 'array' => false, 'signed' => true, 'filters' => []]), + ]; + $indexes = [ + new Document(['$id' => 'idx_name', 'key' => 'idx_name', 'type' => 'key', 'attributes' => ['name'], 'lengths' => [], 'orders' => []]), + ]; + $this->setupCollection('testCol', $attributes, $indexes); + + $this->database->renameIndex('testCol', 'idx_name', 'idx_new_name'); + + $indexes = $this->lastMetadataWrite()->indexes(); + $this->assertSame(['idx_new_name'], \array_map(static fn (Index $index): string => $index->key, $indexes)); + $this->assertSame(['name'], $indexes[0]->attributes); + } +} diff --git a/tests/unit/Indexes/OrphanIndexTest.php b/tests/unit/Indexes/OrphanIndexTest.php new file mode 100644 index 0000000000..7a4e536142 --- /dev/null +++ b/tests/unit/Indexes/OrphanIndexTest.php @@ -0,0 +1,213 @@ +assertIsString($path); + $this->path = $path; + $this->namespace = 'orphan_index_'.\uniqid(); + } + + #[\Override] + protected function tearDown(): void + { + if (\is_file($this->path)) { + \unlink($this->path); + } + } + + public function testCreateIndexReplacesAMismatchedOrphanIndex(): void + { + $database = $this->database(); + $database->getAdapter()->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name'])); + + $this->assertSame(self::INDEX, $database->createIndex(self::COLLECTION, Index::unique(key: self::INDEX, attributes: ['email']))->key); + + $this->assertSame([['email'], IndexType::Unique], $this->schemaIndex($database)); + $database->createDocument(self::COLLECTION, new Document(['email' => 'user@example.com'])); + $this->expectException(DuplicateException::class); + $database->createDocument(self::COLLECTION, new Document(['email' => 'user@example.com'])); + } + + public function testCreateIndexReusesAMatchingOrphanIndex(): void + { + $database = $this->database(); + $database->getAdapter()->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name'])); + + $this->assertSame(self::INDEX, $database->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name']))->key); + + $this->assertSame([['name'], IndexType::Key], $this->schemaIndex($database)); + $this->assertSame([self::INDEX], $this->indexKeys($database)); + } + + public function testSharedTablesRefuseAnotherTenantsIndexOfAnotherDefinition(): void + { + $first = $this->database(tenant: 1); + $first->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name'])); + $second = $this->database(tenant: 2); + + try { + $second->createIndex(self::COLLECTION, Index::unique(key: self::INDEX, attributes: ['email'])); + $this->fail('An index another tenant uses with another definition must be refused'); + } catch (DuplicateException $error) { + $this->assertSame('Index exists in the shared table with another definition', $error->getMessage()); + } + + $this->assertSame([['_tenant', 'name'], IndexType::Key], $this->schemaIndex($first)); + $this->assertSame([], $this->indexKeys($second)); + } + + public function testSharedTablesReuseAnotherTenantsIndexOfTheSameDefinition(): void + { + $first = $this->database(tenant: 1); + $first->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name'])); + $second = $this->database(tenant: 2); + + $this->assertSame(self::INDEX, $second->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name']))->key); + + $this->assertSame([['_tenant', 'name'], IndexType::Key], $this->schemaIndex($first)); + $this->assertSame([self::INDEX], $this->indexKeys($second)); + } + + public function testSharedTablesReuseAnIndexTheEngineStoresAsAKeyForAFulltextIndex(): void + { + $first = $this->database(tenant: 1); + $first->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name'])); + $second = $this->database(tenant: 2); + + $this->assertSame(IndexType::Fulltext, $second->createIndex(self::COLLECTION, Index::fulltext(key: self::INDEX, attributes: ['name']))->type); + + $this->assertSame([['_tenant', 'name'], IndexType::Key], $this->schemaIndex($first)); + $this->assertSame([self::INDEX], $this->indexKeys($second)); + } + + public function testSharedTablesReuseAnIndexWhoseEngineReportsNoPrefixLengths(): void + { + $first = $this->database(tenant: 1); + $first->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name'])); + $second = $this->database(tenant: 2); + + $this->assertSame([32], $second->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name'], lengths: [32]))->lengths); + + $this->assertSame([self::INDEX], $this->indexKeys($second)); + } + + private function database(?int $tenant = null): Database + { + $database = new Database($this->adapter(), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('orphan_index') + ->setNamespace($this->namespace) + ->setSharedTables($tenant !== null) + ->setTenant($tenant); + $database->getAuthorization()->addRole(Role::any()->toString()); + + if (! $database->exists()) { + $database->create(); + } + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'email', size: 64), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + return $database; + } + + private function adapter(): SQLite + { + return new class (new PDO('sqlite:'.$this->path)) extends SQLite { + #[\Override] + public function getSchemaIndexType(IndexType $type): IndexType + { + return $type === IndexType::Fulltext ? IndexType::Key : $type; + } + + #[\Override] + public function getSchemaIndexes(string $collection): array + { + $prefix = '/^'.\preg_quote($this->getNamespace(), '/').'_[^_]*_'.\preg_quote($this->filter($collection), '/').'_/'; + + return \array_map( + static fn (SchemaIndex $index): SchemaIndex => new SchemaIndex( + \preg_replace($prefix, '', $index->name) ?? $index->name, + $index->type, + $index->columns, + $index->lengths, + ), + parent::getSchemaIndexes($collection), + ); + } + }; + } + + /** + * @return array{list, IndexType} + */ + private function schemaIndex(Database $database): array + { + foreach ($database->getSchemaIndexes(self::COLLECTION) as $index) { + if ($index->name === self::INDEX) { + return [$index->columns, $index->type]; + } + } + + $this->fail('The index is not in the schema'); + } + + /** + * @return list + */ + private function indexKeys(Database $database): array + { + return \array_map( + static fn (Index $index): string => $index->key, + $database->getCollection(self::COLLECTION)->indexes(), + ); + } +} diff --git a/tests/unit/Indexes/UniqueIndexOverDuplicatesTest.php b/tests/unit/Indexes/UniqueIndexOverDuplicatesTest.php new file mode 100644 index 0000000000..7290d333d6 --- /dev/null +++ b/tests/unit/Indexes/UniqueIndexOverDuplicatesTest.php @@ -0,0 +1,93 @@ + + */ + public static function adapters(): array + { + return [ + 'Memory' => [static fn (): Adapter => new Memory()], + 'SQLite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUniqueIndexOverDuplicateValuesIsRefusedWithoutMetadata(Closure $adapter): void + { + $database = $this->database($adapter()); + + foreach (['name', 'age'] as $attribute) { + $error = null; + try { + $database->createIndex(self::COLLECTION, Index::unique(key: 'unique_'.$attribute, attributes: [$attribute])); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(UniqueException::class, $error, 'A unique index on '.$attribute.' over duplicate values must be refused as Unique'); + $this->assertSame([], $database->getCollection(self::COLLECTION)->indexes(), 'A refused unique index on '.$attribute.' must leave no metadata behind'); + } + + $database->createDocument(self::COLLECTION, new Document(['name' => 'chester', 'age' => 7])); + $this->assertSame(3, $database->count(self::COLLECTION), 'No unique index may be left in the schema to refuse a third duplicate'); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('unique_over_duplicates') + ->setNamespace('unique_over_duplicates_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'age'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + + foreach (['first', 'second'] as $id) { + $database->createDocument(self::COLLECTION, new Document(['$id' => $id, 'name' => 'chester', 'age' => 7])); + } + + return $database; + } +} diff --git a/tests/unit/JoinAliasSpellingTest.php b/tests/unit/JoinAliasSpellingTest.php new file mode 100644 index 0000000000..4aaa99d8a6 --- /dev/null +++ b/tests/unit/JoinAliasSpellingTest.php @@ -0,0 +1,341 @@ + 'pages', + self::REVIEWS => 'stars', + self::EXTRAS => 'weight', + ]; + + private const array LOWER_CASE = [self::BOOKS => 'book', self::REVIEWS => 'review', self::EXTRAS => 'extra']; + + private const array MIXED_CASE = [self::BOOKS => 'Book', self::REVIEWS => 'Review', self::EXTRAS => 'Extra']; + + private const array RESERVED_WORDS = [self::BOOKS => 'order', self::REVIEWS => 'group', self::EXTRAS => 'select']; + + /** + * Every join type and the chains whose later right join repeats the conditions of the tables + * before it, as [method, joined collection, collection whose alias the ON names or null for + * the main one]. + * + * @var array> + */ + private const array CHAINS = [ + 'an inner join' => [[Method::Join, self::BOOKS, null]], + 'a left join' => [[Method::LeftJoin, self::BOOKS, null]], + 'a right join' => [[Method::RightJoin, self::BOOKS, null]], + 'a full outer join' => [[Method::FullOuterJoin, self::BOOKS, null]], + 'a cross join' => [[Method::CrossJoin, self::BOOKS, null]], + 'an inner join, then a right join' => [[Method::Join, self::BOOKS, null], [Method::RightJoin, self::REVIEWS, null]], + 'a cross join, then a right join' => [[Method::CrossJoin, self::EXTRAS, null], [Method::RightJoin, self::REVIEWS, null]], + 'a right join, then a right join on it' => [[Method::RightJoin, self::BOOKS, null], [Method::RightJoin, self::REVIEWS, self::BOOKS]], + ]; + + /** + * Every document and whether the caller holds read on it; the unreadable ones share keys with + * readable ones, so the permission conditions decide what each join pairs. + * + * @var array, bool}>> + */ + private const array DOCUMENTS = [ + self::AUTHORS => [ + 'a1' => [['name' => 'a1'], true], + 'a2' => [['name' => 'a2'], true], + 'hidden' => [['name' => 'hidden'], false], + ], + self::BOOKS => [ + 'b1' => [['authorId' => 'a1', 'pages' => 1], true], + 'b2' => [['authorId' => 'a2', 'pages' => 2], false], + 'b3' => [['authorId' => 'hidden', 'pages' => 3], true], + 'b4' => [['authorId' => 'ghost', 'pages' => 4], true], + ], + self::REVIEWS => [ + 'r1' => [['authorId' => 'a1', 'stars' => 10], true], + 'r2' => [['authorId' => 'a2', 'stars' => 20], true], + 'r3' => [['authorId' => 'ghost', 'stars' => 30], true], + 'r4' => [['authorId' => 'a2', 'stars' => 40], false], + ], + self::EXTRAS => [ + 'x1' => [['authorId' => 'a1', 'weight' => 100], true], + 'x2' => [['authorId' => 'a2', 'weight' => 200], false], + ], + ]; + + /** + * @return iterable + */ + public static function adapters(): iterable + { + yield 'PostgreSQL' => [static fn (PDO $pdo): SQL => new Postgres($pdo), '"']; + yield 'MariaDB' => [static fn (PDO $pdo): SQL => new MariaDB($pdo), '`']; + yield 'MySQL' => [static fn (PDO $pdo): SQL => new MySQL($pdo), '`']; + } + + /** + * @return iterable, bool, bool}> + */ + public static function readers(): iterable + { + foreach (['mixed-case aliases' => self::MIXED_CASE, 'reserved-word aliases' => self::RESERVED_WORDS] as $spelling => $aliases) { + foreach (['dedicated tables' => false, 'shared tables' => true] as $tables => $sharedTables) { + foreach (['emulated full outer join' => false, 'native full outer join' => true] as $mode => $nativeFullOuterJoin) { + yield "{$spelling}, {$tables}, {$mode}" => [$aliases, $sharedTables, $nativeFullOuterJoin]; + } + } + } + } + + /** + * Every statement a join read sends names each mixed-case alias only quoted, as the builder + * declares it, under shared tables with every collection read per document. + * + * @param Closure(PDO): SQL $adapter + */ + #[DataProvider('adapters')] + public function testEveryStatementNamesAMixedCaseAliasQuoted(Closure $adapter, string $quote): void + { + $statements = []; + $sql = $adapter($this->capturingPdo($statements)); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $sql->setAuthorization($authorization); + $sql->setDatabase('database'); + $sql->setNamespace('namespace'); + $sql->setSharedTables(true); + $sql->setTenant(self::TENANT); + $authors = new Document(['$id' => self::AUTHORS, 'attributes' => [], 'documentSecurity' => true]); + + foreach (self::CHAINS as $chain) { + [$joins, $numbers] = $this->joins($chain, self::MIXED_CASE); + + $sql->find($authors, [...$joins, Query::select(['name', ...$numbers])]); + $sql->count($authors, $joins); + $sql->sum($authors, $numbers[\count($numbers) - 1], $joins); + $sql->getDocument($authors, 'a1', $joins); + } + + $this->assertCount(\count(self::CHAINS) * 4, $statements); + $sent = \implode("\n", $statements); + $quoted = \preg_quote($quote, '/'); + foreach (self::MIXED_CASE as $alias) { + $this->assertStringContainsString($quote.$alias.$quote.'.', $sent, "{$alias} must be named quoted"); + foreach ($statements as $statement) { + $this->assertDoesNotMatchRegularExpression("/(? $aliases + */ + #[DataProvider('readers')] + public function testJoinsReadWhatTheyReadWithLowerCaseAliases(array $aliases, bool $sharedTables, bool $nativeFullOuterJoin): void + { + $database = $this->database($sharedTables, $nativeFullOuterJoin); + + foreach (self::CHAINS as $label => $chain) { + $expected = $this->read($database, $chain, self::LOWER_CASE); + + $this->assertNotSame([], $expected['rows'], "{$label} must return rows for the comparison to mean anything"); + $this->assertSame( + $expected, + $this->read($database, $chain, $aliases), + "{$label} aliased ".\implode(', ', $aliases).' must read what it reads with lower-case aliases', + ); + } + } + + /** + * @param list $statements + */ + private function capturingPdo(array &$statements): PDO + { + $statement = self::createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('bindValue')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('fetch')->willReturn(false); + $statement->method('closeCursor')->willReturn(true); + + $pdo = self::createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$statements, $statement): PDOStatement { + $statements[] = $query; + + return $statement; + }); + + return $pdo; + } + + /** + * Every collection read per document; under shared tables another tenant holds the same + * documents with larger numbers, so a condition that lost its tenant would change every read. + */ + private function database(bool $sharedTables, bool $nativeFullOuterJoin): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $pdo = new PDO('sqlite::memory:'); + $database = new Database( + $nativeFullOuterJoin ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), + new Cache(new None()), + ); + $database + ->setAuthorization($authorization) + ->setDatabase('joins') + ->setNamespace('spelling') + ->setSharedTables($sharedTables) + ->setTenant(null); + $database->addHook(new Permissions()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + foreach (self::NUMBERS as $collection => $number) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'authorId', size: 64, required: true), + Attribute::integer(key: $number, required: true), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + } + + $owners = $sharedTables ? [[self::OTHER_TENANT, 1000], [self::TENANT, 1]] : [[null, 1]]; + foreach ($owners as [$tenant, $scale]) { + $database->setTenant($tenant); + foreach (self::DOCUMENTS as $collection => $documents) { + foreach ($documents as $id => [$attributes, $readable]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [$readable ? Permission::read(Role::any()) : Permission::read(Role::user('someone-else'))], + ...\array_map(static fn (string|int $value): string|int => \is_int($value) ? $value * $scale : $value, $attributes), + ])); + } + } + } + + return $database; + } + + /** + * @param list $chain + * @param array $aliases + * @return array{rows: list>, count: int, sum: int|float, document: list>} + */ + private function read(Database $database, array $chain, array $aliases): array + { + [$joins, $numbers] = $this->joins($chain, $aliases); + $selection = Query::select(['name', ...$numbers]); + + return [ + 'rows' => $this->rows($database->find(self::AUTHORS, [...$joins, $selection, Query::limit(100)]), $numbers), + 'count' => $database->count(self::AUTHORS, $joins), + 'sum' => $database->sum(self::AUTHORS, $numbers[\count($numbers) - 1], $joins), + 'document' => $this->rows([$database->getDocument(self::AUTHORS, 'a1', [...$joins, $selection])], $numbers), + ]; + } + + /** + * @param list $chain + * @param array $aliases + * @return array{list, list} The joins and each joined collection's number, alias-qualified + */ + private function joins(array $chain, array $aliases): array + { + $joins = []; + $numbers = []; + foreach ($chain as [$method, $collection, $on]) { + $alias = $aliases[$collection]; + $left = $on === null ? '$id' : $aliases[$on].'.authorId'; + $numbers[] = $alias.'.'.self::NUMBERS[$collection]; + $joins[] = match ($method) { + Method::Join => Query::join($collection, $alias, [Query::on($left, 'authorId')]), + Method::LeftJoin => Query::leftJoin($collection, $alias, [Query::on($left, 'authorId')]), + Method::RightJoin => Query::rightJoin($collection, $alias, [Query::on($left, 'authorId')]), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $alias, [Query::on($left, 'authorId')]), + Method::CrossJoin => Query::crossJoin($collection, $alias), + default => throw new \InvalidArgumentException("{$method->value} is not a join"), + }; + } + + return [$joins, $numbers]; + } + + /** + * @param array $documents + * @param list $numbers + * @return list> + */ + private function rows(array $documents, array $numbers): array + { + $rows = \array_map(static function (Document $document) use ($numbers): array { + $name = $document->getAttribute('name'); + $row = [\is_string($name) && $name !== '' ? $name : null]; + foreach ($numbers as $number) { + $value = $document->getAttribute($number); + $row[] = \is_numeric($value) ? (int) $value : null; + } + + return $row; + }, $documents); + \usort($rows, static fn (array $left, array $right): int => \json_encode($left) <=> \json_encode($right)); + + return $rows; + } +} diff --git a/tests/unit/JoinAliasTest.php b/tests/unit/JoinAliasTest.php new file mode 100644 index 0000000000..0a671780e3 --- /dev/null +++ b/tests/unit/JoinAliasTest.php @@ -0,0 +1,189 @@ +database(); + + $rows = $database->find('main', [ + Query::join('b', 'j1', [Query::on('$id', 'mainId')]), + Query::join('c', 'j2', [Query::on('$id', 'mainId')]), + Query::select(['name', 'j1.score']), + ]); + + $this->assertCount(1, $rows); + $this->assertSame('m1', $rows[0]->getAttribute('name')); + $this->assertSame(1, $rows[0]->getAttribute('j1.score')); + } + + public function testAnAliasSortingBeforeAnEarlierOneStillReadsItsOwnJoin(): void + { + $database = $this->database(); + + $rows = $database->find('main', [ + Query::join('b', 'j1', [Query::on('$id', 'mainId')]), + Query::join('c', 'j0', [Query::on('$id', 'mainId')]), + Query::select(['name', 'j0.score']), + ]); + + $this->assertCount(1, $rows); + $this->assertSame(10, $rows[0]->getAttribute('j0.score')); + $this->assertSame(1, $database->count('main', [ + Query::join('b', 'j1', [Query::on('$id', 'mainId')]), + Query::join('c', 'j0', [Query::on('$id', 'mainId')]), + ])); + } + + public function testAJoinWithoutAnAliasIsRejectedWithoutQueryValidation(): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join alias is required'); + + $database->skipValidation(fn (): array => $database->find('main', [ + new Query(Method::Join, 'b', [Query::on('$id', 'mainId')], ''), + ])); + } + + /** + * @param list $joins + */ + #[DataProvider('collidingAliases')] + public function testCollidingAliasIsRejectedByFind(array $joins): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + + $database->find('main', $joins); + } + + /** + * @param list $joins + */ + #[DataProvider('collidingAliases')] + public function testCollidingAliasIsRejectedByCount(array $joins): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + + $database->count('main', $joins); + } + + /** + * @param list $joins + */ + #[DataProvider('collidingAliases')] + public function testCollidingAliasIsRejectedWithoutQueryValidation(array $joins): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + + $database->skipValidation(fn () => $database->find('main', $joins)); + } + + /** + * @param list $joins + */ + #[DataProvider('collidingAliases')] + public function testCollidingAliasIsRejectedByGetDocument(array $joins): void + { + $database = $this->database(); + + $this->expectException(QueryException::class); + + $database->skipValidation(fn () => $database->getDocument('main', 'm1', $joins)); + } + + /** + * @return iterable}> + */ + public static function collidingAliases(): iterable + { + yield 'the same alias twice' => [[ + Query::join('b', 'x', [Query::on('$id', 'mainId')]), + Query::join('c', 'x', [Query::on('$id', 'mainId')]), + ]]; + yield 'aliases that differ only in case' => [[ + Query::join('b', 'x', [Query::on('$id', 'mainId')]), + Query::leftJoin('c', 'X', [Query::on('$id', 'mainId')]), + ]]; + yield 'the main collection alias' => [[ + Query::join('b', Query::DEFAULT_ALIAS, [Query::on('$id', 'mainId')]), + ]]; + yield 'the main collection alias in another case' => [[ + Query::join('b', \strtoupper(Query::DEFAULT_ALIAS), [Query::on('$id', 'mainId')]), + ]]; + yield 'a cross join alias that repeats an earlier one' => [[ + Query::join('b', 'x', [Query::on('$id', 'mainId')]), + Query::crossJoin('c', 'x'), + ]]; + yield 'an alias that is not an identifier' => [[ + Query::join('b', 'my-alias', [Query::on('$id', 'mainId')]), + ]]; + yield 'a nested join alias that repeats an earlier one' => [[ + Query::join('b', 'x', [Query::on('$id', 'mainId')]), + Query::leftJoin('c', 'x', [Query::on('$id', 'mainId')]), + ]]; + } + + private function database(): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database + ->setAuthorization($authorization) + ->setDatabase('aliases') + ->setNamespace('aliases_'.\uniqid()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'main', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + foreach (['b', 'c'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'mainId', size: 64, required: true), + Attribute::integer(key: 'score', required: true), + ], + permissions: $permissions, + documentSecurity: false, + )); + } + + $database->createDocument('main', new Document(['$id' => 'm1', 'name' => 'm1'])); + $database->createDocument('b', new Document(['$id' => 'b1', 'mainId' => 'm1', 'score' => 1])); + $database->createDocument('c', new Document(['$id' => 'c1', 'mainId' => 'm1', 'score' => 10])); + + return $database; + } +} diff --git a/tests/unit/JoinImplicitProjectionTest.php b/tests/unit/JoinImplicitProjectionTest.php new file mode 100644 index 0000000000..4281fef15c --- /dev/null +++ b/tests/unit/JoinImplicitProjectionTest.php @@ -0,0 +1,228 @@ +database(sharedTables: false); + + $rows = $database->find('customers', [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + ]); + + $this->assertCount(1, $rows); + $this->assertSame($this->keys('ord'), $this->sortedKeys($rows[0])); + $this->assertSame('c1', $rows[0]->getId()); + $this->assertSame('Alice', $rows[0]->getAttribute('name')); + $this->assertSame('o1', $rows[0]->getAttribute('ord.$id')); + $this->assertSame('c1', $rows[0]->getAttribute('ord.customerId')); + $this->assertSame(10, $rows[0]->getAttribute('ord.total')); + $this->assertSame('first-secret', $rows[0]->getAttribute('ord.secret')); + } + + public function testGeneratedAliasCarriesTheJoinedAttributes(): void + { + $database = $this->database(sharedTables: false); + + $rows = $database->find('customers', [ + Query::leftJoin('orders', 'j0', [Query::on('$id', 'customerId')]), + ]); + + $this->assertCount(2, $rows); + foreach ($rows as $row) { + $this->assertSame($this->keys('j0'), $this->sortedKeys($row)); + } + $totals = \array_map(static fn (Document $row): mixed => $row->getAttribute('j0.total'), $rows); + \sort($totals); + $this->assertSame([null, 10], $totals); + } + + public function testEachAliasOfOneCollectionCarriesItsOwnRow(): void + { + $database = $this->database(sharedTables: false); + + $rows = $database->find('customers', [ + Query::join('orders', 'first', [Query::on('$id', 'customerId')]), + Query::leftJoin('orders', 'second', [Query::on('$id', 'customerId')]), + ]); + + $this->assertCount(1, $rows); + $this->assertSame( + $this->keys('first', 'second.$id', 'second.customerId', 'second.secret', 'second.total'), + $this->sortedKeys($rows[0]), + ); + $this->assertSame('o1', $rows[0]->getAttribute('first.$id')); + $this->assertSame('o1', $rows[0]->getAttribute('second.$id')); + } + + public function testSharedTablesNeverReturnTheJoinedTenant(): void + { + $database = $this->database(sharedTables: true); + + $rows = $database->find('customers', [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + ]); + + $this->assertCount(1, $rows); + $this->assertSame($this->keys('ord', '$tenant'), $this->sortedKeys($rows[0]), 'Only the main document carries a tenant'); + $this->assertArrayNotHasKey('ord.$tenant', $rows[0]->getArrayCopy()); + } + + public function testEmulatedFullOuterJoinReturnsTheSameKeysFromBothHalves(): void + { + $database = $this->database(sharedTables: false); + + $rows = $database->find('customers', [ + Query::fullOuterJoin('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::orderAsc('ord.total'), + ]); + + $this->assertCount(3, $rows); + foreach ($rows as $row) { + $keys = $this->sortedKeys($row); + foreach (['ord.$id', 'ord.customerId', 'ord.secret', 'ord.total'] as $joined) { + $this->assertContains($joined, $keys); + } + $this->assertSame([], \array_values(\array_diff($keys, $this->keys('ord'))), 'Unexpected keys: '.\implode(', ', $keys)); + } + $orders = \array_map(static fn (Document $row): mixed => $row->getAttribute('ord.$id'), $rows); + $this->assertSame([null, 'o1', 'o2'], $orders); + } + + public function testGetDocumentWithJoinReturnsJoinedAttributesUnderTheAlias(): void + { + $database = $this->database(sharedTables: false); + + $document = $database->getDocument('customers', 'c1', [ + Query::leftJoin('orders', 'ord', [Query::on('$id', 'customerId')]), + ]); + + $this->assertSame($this->keys('ord'), $this->sortedKeys($document)); + $this->assertSame(10, $document->getAttribute('ord.total')); + } + + public function testExplicitSelectStillReturnsJoinedInternals(): void + { + $database = $this->database(sharedTables: false); + + $rows = $database->find('customers', [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::select(['name', 'ord.$id', 'ord.$permissions', 'ord.$createdAt']), + ]); + + $this->assertCount(1, $rows); + $this->assertSame('o1', $rows[0]->getAttribute('ord.$id')); + $this->assertSame([Permission::read(Role::any())], $rows[0]->getAttribute('ord.$permissions')); + $this->assertIsString($rows[0]->getAttribute('ord.$createdAt')); + $this->assertArrayNotHasKey('ord.total', $rows[0]->getArrayCopy()); + } + + /** + * @return list + */ + private function keys(string $alias, string ...$extra): array + { + $keys = [ + ...self::MAIN_KEYS, + $alias.'.$id', + $alias.'.customerId', + $alias.'.secret', + $alias.'.total', + ...$extra, + ]; + \sort($keys); + + return $keys; + } + + /** + * @return list + */ + private function sortedKeys(Document $document): array + { + $keys = \array_map(\strval(...), \array_keys($document->getArrayCopy())); + \sort($keys); + + return $keys; + } + + private function database(bool $sharedTables): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('projection') + ->setNamespace('projection_'.\uniqid()) + ->setSharedTables($sharedTables) + ->setTenant(null); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'customers', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: 'orders', + attributes: [ + Attribute::string(key: 'customerId', size: 64, required: true), + Attribute::integer(key: 'total', required: true), + Attribute::string(key: 'secret', size: 64, required: true), + ], + permissions: $permissions, + documentSecurity: true, + )); + + if ($sharedTables) { + $database->setTenant(1); + } + $database->createDocument('customers', new Document(['$id' => 'c1', 'name' => 'Alice'])); + $database->createDocument('customers', new Document(['$id' => 'c2', 'name' => 'Bob'])); + $database->createDocument('orders', new Document([ + '$id' => 'o1', + 'customerId' => 'c1', + 'total' => 10, + 'secret' => 'first-secret', + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createDocument('orders', new Document([ + '$id' => 'o2', + 'customerId' => 'nobody', + 'total' => 20, + 'secret' => 'second-secret', + '$permissions' => [Permission::read(Role::any())], + ])); + + return $database; + } +} diff --git a/tests/unit/JoinIndexedTest.php b/tests/unit/JoinIndexedTest.php new file mode 100644 index 0000000000..a94b9cac16 --- /dev/null +++ b/tests/unit/JoinIndexedTest.php @@ -0,0 +1,199 @@ + + */ + private ArrayObject $collections; + + #[\Override] + protected function setUp(): void + { + $this->collections = new ArrayObject(); + } + + public function testFindHandsTheAdapterTheLeadingAttributesOfEachJoinedCollectionsIndexes(): void + { + $database = $this->database(); + + $database->find(self::CUSTOMERS, [$this->join()]); + + $this->assertSame([self::LABELS => ['code', 'pair']], $this->indexed()); + } + + public function testCountAndSumHandTheAdapterTheLeadingAttributes(): void + { + $database = $this->database(); + + $database->count(self::CUSTOMERS, [$this->join()]); + $this->assertSame([self::LABELS => ['code', 'pair']], $this->indexed()); + + $database->sum(self::CUSTOMERS, 'label.score', [$this->join()]); + $this->assertSame([self::LABELS => ['code', 'pair']], $this->indexed()); + } + + public function testGetDocumentHandsTheAdapterTheLeadingAttributes(): void + { + $database = $this->database(); + + $database->getDocument(self::CUSTOMERS, 'c1', [$this->join()]); + + $this->assertSame([self::LABELS => ['code', 'pair']], $this->indexed()); + } + + public function testReadWithoutJoinsHandsTheAdapterNoLeadingAttributes(): void + { + $database = $this->database(); + + $database->find(self::CUSTOMERS, [Query::equal('name', ['one'])]); + + $this->assertNull($this->last()->getAttribute(Database::JOIN_INDEXED)); + } + + public function testJoinReadOfPlainTablesHandsTheAdapterNoLeadingAttributes(): void + { + $database = $this->database(shared: false); + + $database->find(self::CUSTOMERS, [$this->join()]); + $this->assertNull($this->indexed()); + + $database->count(self::CUSTOMERS, [$this->join()]); + $this->assertNull($this->indexed()); + + $database->getDocument(self::CUSTOMERS, 'c1', [$this->join()]); + $this->assertNull($this->indexed()); + } + + private function join(): Query + { + return Query::join(self::LABELS, 'label', [Query::on('name', 'name')]); + } + + /** + * @return mixed The JOIN_INDEXED attribute of the collection the adapter read last + */ + private function indexed(): mixed + { + return $this->last()->getAttribute(Database::JOIN_INDEXED); + } + + private function last(): Document + { + $collections = $this->collections->getArrayCopy(); + $this->assertNotSame([], $collections, 'The adapter must have read'); + + return $collections[\array_key_last($collections)]; + } + + private function database(bool $shared = true): Database + { + $adapter = new class (new PDO('sqlite::memory:'), $this->collections) extends SQLite { + /** + * @param ArrayObject $collections + */ + public function __construct(PDO $pdo, private readonly ArrayObject $collections) + { + parent::__construct($pdo); + } + + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array + { + $this->collections->append($collection); + + return parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); + } + + #[\Override] + public function count(Document $collection, array $queries = [], ?int $max = null): int + { + $this->collections->append($collection); + + return parent::count($collection, $queries, $max); + } + + #[\Override] + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float + { + $this->collections->append($collection); + + return parent::sum($collection, $attribute, $queries, $max); + } + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $this->collections->append($collection); + + return parent::getDocument($collection, $id, $queries, $forUpdate); + } + }; + + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('indexed')->setNamespace('indexed')->setAuthorization($authorization); + if ($shared) { + $database->setSharedTables(true)->setTenant(1); + } + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: self::CUSTOMERS, + attributes: [Attribute::string('name', 64)], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: self::LABELS, + attributes: [ + Attribute::string('name', 64), + Attribute::string('code', 64), + Attribute::string('pair', 64), + Attribute::string('text', 256), + Attribute::integer('score'), + ], + indexes: [ + Index::key('code_key', ['code']), + Index::unique('pair_unique', ['pair', 'name']), + Index::key('code_name', ['code', 'name']), + Index::fulltext('text_fulltext', ['text']), + ], + permissions: $permissions, + )); + $database->createDocument(self::CUSTOMERS, new Document(['$id' => 'c1', 'name' => 'one'])); + $this->collections->exchangeArray([]); + + return $database; + } +} diff --git a/tests/unit/JoinTenancyTest.php b/tests/unit/JoinTenancyTest.php new file mode 100644 index 0000000000..1179f484db --- /dev/null +++ b/tests/unit/JoinTenancyTest.php @@ -0,0 +1,736 @@ + 'pages', + self::REVIEWS => 'stars', + self::EXTRAS => 'weight', + ]; + + /** + * Only tenant two and the tenantless rows have extras, so tenant one's cross join with them + * is empty and every review it right-joins afterwards must come back unmatched. + * + * @var array>> + */ + private const array EXTRA_ROWS = [ + self::SECOND => ['x1' => ['authorId' => 'a1', 'weight' => 5]], + self::TENANTLESS => ['x9' => ['authorId' => 'a2', 'weight' => 9]], + ]; + + /** + * Tenant one's book "b2" belongs to an author only tenant two has and its "b3" to the + * tenantless author; its author "a2" has books only in tenant two; its review "r4" names an + * author only tenant two has. The tenantless book and review name authors both tenants have. + * + * @var array>>> + */ + private const array ROWS = [ + self::FIRST => [ + self::AUTHORS => [ + 'a1' => ['name' => 'one-a1'], + 'a2' => ['name' => 'one-a2'], + ], + self::BOOKS => [ + 'b1' => ['authorId' => 'a1', 'pages' => 11], + 'b2' => ['authorId' => 'shared', 'pages' => 12], + 'b3' => ['authorId' => 'legacy', 'pages' => 13], + ], + self::REVIEWS => [ + 'r1' => ['authorId' => 'a1', 'stars' => 5], + 'r2' => ['authorId' => 'a2', 'stars' => 4], + 'r3' => ['authorId' => 'ghost', 'stars' => 3], + 'r4' => ['authorId' => 'shared', 'stars' => 2], + ], + ], + self::SECOND => [ + self::AUTHORS => [ + 'a1' => ['name' => 'two-a1'], + 'a2' => ['name' => 'two-a2'], + 'shared' => ['name' => 'two-shared'], + ], + self::BOOKS => [ + 'b1' => ['authorId' => 'a1', 'pages' => 21], + 'b2' => ['authorId' => 'a2', 'pages' => 22], + ], + self::REVIEWS => [ + 'r1' => ['authorId' => 'shared', 'stars' => 1], + ], + ], + self::TENANTLESS => [ + self::AUTHORS => [ + 'legacy' => ['name' => 'no-tenant'], + ], + self::BOOKS => [ + 'orphan' => ['authorId' => 'a1', 'pages' => 99], + ], + self::REVIEWS => [ + 'stale' => ['authorId' => 'a2', 'stars' => 9], + ], + ], + ]; + + /** + * Every row a tenant must read when authors join books, as [author name, book pages], with + * null where the join found no match. + * + * @var array>> + */ + private const array EXPECTED = [ + Method::Join->value => [ + self::FIRST => [['one-a1', 11]], + self::SECOND => [['two-a1', 21], ['two-a2', 22]], + ], + Method::LeftJoin->value => [ + self::FIRST => [['one-a1', 11], ['one-a2', null]], + self::SECOND => [['two-a1', 21], ['two-a2', 22], ['two-shared', null]], + ], + Method::RightJoin->value => [ + self::FIRST => [['one-a1', 11], [null, 12], [null, 13]], + self::SECOND => [['two-a1', 21], ['two-a2', 22]], + ], + Method::FullOuterJoin->value => [ + self::FIRST => [['one-a1', 11], ['one-a2', null], [null, 12], [null, 13]], + self::SECOND => [['two-a1', 21], ['two-a2', 22], ['two-shared', null]], + ], + Method::CrossJoin->value => [ + self::FIRST => [ + ['one-a1', 11], ['one-a1', 12], ['one-a1', 13], + ['one-a2', 11], ['one-a2', 12], ['one-a2', 13], + ], + self::SECOND => [ + ['two-a1', 21], ['two-a1', 22], + ['two-a2', 21], ['two-a2', 22], + ['two-shared', 21], ['two-shared', 22], + ], + ], + ]; + + /** + * @return iterable, 2: list}, bool, bool}> + */ + public static function joins(): iterable + { + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin, Method::CrossJoin] as $join) { + foreach (self::configurations() as $configuration => [$nativeFullOuterJoin, $documentSecurity]) { + yield "{$join->value}, {$configuration}" => [$join, self::EXPECTED[$join->value], $nativeFullOuterJoin, $documentSecurity]; + } + } + } + + /** + * @return iterable + */ + public static function joinTypes(): iterable + { + foreach (self::joins() as $case => [$join, , $nativeFullOuterJoin, $documentSecurity]) { + yield $case => [$join, $nativeFullOuterJoin, $documentSecurity]; + } + } + + /** + * Chains whose later join keeps rows the earlier ones did not match, the shape where a + * tenant condition placed after every join drops rows a dedicated database returns. + * + * @return iterable + */ + public static function chains(): iterable + { + $chains = [ + [Method::Join, Method::RightJoin], + [Method::Join, Method::FullOuterJoin], + [Method::LeftJoin, Method::RightJoin], + [Method::LeftJoin, Method::FullOuterJoin], + [Method::RightJoin, Method::RightJoin], + [Method::RightJoin, Method::Join], + [Method::CrossJoin, Method::RightJoin], + ]; + + foreach ($chains as [$books, $reviews]) { + foreach (self::configurations() as $configuration => [$nativeFullOuterJoin, $documentSecurity]) { + yield "{$books->value} books, {$reviews->value} reviews, {$configuration}" => [[$books, $reviews], $nativeFullOuterJoin, $documentSecurity]; + } + } + } + + /** + * @param array{1: list, 2: list} $expected + */ + #[DataProvider('joins')] + public function testFindReturnsExactlyTheTenantsOwnRows(Method $join, array $expected, bool $nativeFullOuterJoin, bool $documentSecurity): void + { + $database = $this->shared($nativeFullOuterJoin, $documentSecurity); + + foreach ([self::FIRST, self::SECOND] as $tenant) { + $database->setTenant($tenant); + + $documents = $database->find(self::AUTHORS, [ + $this->book($join), + Query::select(['name', self::PAGES]), + ]); + + $this->assertSame( + $this->sorted($expected[$tenant]), + $this->sorted(\array_map($this->pages(...), $documents)), + "Tenant {$tenant} must read exactly its own rows through a {$join->value}", + ); + } + } + + /** + * @param array{1: list, 2: list} $expected + */ + #[DataProvider('joins')] + public function testCountCountsExactlyTheTenantsOwnRows(Method $join, array $expected, bool $nativeFullOuterJoin, bool $documentSecurity): void + { + $database = $this->shared($nativeFullOuterJoin, $documentSecurity); + + foreach ([self::FIRST, self::SECOND] as $tenant) { + $database->setTenant($tenant); + + $this->assertSame( + \count($expected[$tenant]), + $database->count(self::AUTHORS, [$this->book($join)]), + "Tenant {$tenant} must count exactly its own rows through a {$join->value}", + ); + } + } + + /** + * @param array{1: list, 2: list} $expected + */ + #[DataProvider('joins')] + public function testSumAddsExactlyTheTenantsOwnRows(Method $join, array $expected, bool $nativeFullOuterJoin, bool $documentSecurity): void + { + $database = $this->shared($nativeFullOuterJoin, $documentSecurity); + + foreach ([self::FIRST, self::SECOND] as $tenant) { + $database->setTenant($tenant); + + $pages = \array_sum(\array_map( + static fn (array $row): int => $row[1] ?? 0, + $expected[$tenant], + )); + + $this->assertSame( + $pages, + $database->sum(self::AUTHORS, self::PAGES, [$this->book($join)]), + "Tenant {$tenant} must sum exactly its own rows through a {$join->value}", + ); + } + } + + #[DataProvider('joinTypes')] + public function testGetDocumentReadsOnlyTheTenantsOwnDocument(Method $join, bool $nativeFullOuterJoin, bool $documentSecurity): void + { + $database = $this->shared($nativeFullOuterJoin, $documentSecurity); + $queries = fn (): array => [$this->book($join), Query::select(['name', self::PAGES])]; + + $database->setTenant(self::FIRST); + + $this->assertSame( + 'one-a1', + $database->getDocument(self::AUTHORS, 'a1', $queries())->getAttribute('name'), + "Tenant one must read its own a1 through a {$join->value}, not tenant two's", + ); + $this->assertTrue( + $database->getDocument(self::AUTHORS, 'shared', $queries())->isEmpty(), + "Tenant one must not read tenant two's document through a {$join->value}", + ); + $this->assertTrue( + $database->getDocument(self::AUTHORS, 'legacy', $queries())->isEmpty(), + "Tenant one must not read a tenantless document through a {$join->value}", + ); + + $database->setTenant(self::SECOND); + + $this->assertSame( + 'two-a1', + $database->getDocument(self::AUTHORS, 'a1', $queries())->getAttribute('name'), + "Tenant two must read its own a1 through a {$join->value}, not tenant one's", + ); + $this->assertTrue( + $database->getDocument(self::AUTHORS, 'legacy', $queries())->isEmpty(), + "Tenant two must not read a tenantless document through a {$join->value}", + ); + } + + /** + * @param array{Method, Method} $chain + */ + #[DataProvider('chains')] + public function testChainedJoinsReadWhatADedicatedDatabaseReads(array $chain, bool $nativeFullOuterJoin, bool $documentSecurity): void + { + [$books, $reviews] = $chain; + $shared = $this->shared($nativeFullOuterJoin, $documentSecurity); + $joins = fn (): array => [$this->book($books), $this->review($reviews)]; + $queries = fn (): array => [...$joins(), Query::select(['name', self::PAGES, self::STARS])]; + $label = "{$books->value} books then {$reviews->value} reviews"; + + foreach ([self::FIRST, self::SECOND] as $tenant) { + $dedicated = $this->dedicated($nativeFullOuterJoin, $documentSecurity, $tenant); + $shared->setTenant($tenant); + + $this->assertSame( + $this->sorted(\array_map($this->pagesAndStars(...), $dedicated->find(self::AUTHORS, $queries()))), + $this->sorted(\array_map($this->pagesAndStars(...), $shared->find(self::AUTHORS, $queries()))), + "Tenant {$tenant} must read through {$label} what its own database would return", + ); + $this->assertSame( + $dedicated->count(self::AUTHORS, $joins()), + $shared->count(self::AUTHORS, $joins()), + "Tenant {$tenant} must count through {$label} what its own database would count", + ); + } + } + + /** + * @return iterable + */ + public static function fullOuterJoinModes(): iterable + { + yield 'emulated full outer join' => [false]; + yield 'native full outer join' => [true]; + } + + /** + * Every chain of two joins, the second joined on the main table or on the first join: a right + * or full outer join that follows a right, full outer or cross join must not pair its rows with + * another tenant's rows of the earlier table, or they vanish instead of coming back unmatched. + */ + #[DataProvider('fullOuterJoinModes')] + public function testEveryChainOfTwoJoinsReadsWhatADedicatedDatabaseReads(bool $nativeFullOuterJoin): void + { + $shared = $this->sharedWithExtras($nativeFullOuterJoin); + + foreach ([self::FIRST, self::SECOND] as $tenant) { + $dedicated = $this->dedicated($nativeFullOuterJoin, documentSecurity: false, tenant: $tenant); + $this->extras($dedicated, self::EXTRA_ROWS[$tenant] ?? []); + $shared->setTenant($tenant); + + $expected = []; + $actual = []; + foreach (self::twoJoinChains() as $label => $joins) { + $expected[$label] = $this->joinedChain($dedicated, $joins); + $actual[$label] = $this->readChain($shared, $joins); + } + + $this->assertSame($expected, $actual, "Tenant {$tenant} must read through every chain what its own database would return"); + } + } + + /** + * A full outer join combined with a right join reads through find(), count() and sum() what the + * tenant's own database reads, emulated and native. + */ + #[DataProvider('fullOuterJoinModes')] + public function testAFullOuterJoinCombinedWithARightJoinReadsWhatADedicatedDatabaseReads(bool $nativeFullOuterJoin): void + { + $shared = $this->shared($nativeFullOuterJoin, documentSecurity: false); + + foreach ([self::FIRST, self::SECOND] as $tenant) { + $dedicated = $this->dedicated($nativeFullOuterJoin, documentSecurity: false, tenant: $tenant); + $shared->setTenant($tenant); + + foreach ([ + 'a right join after a full outer join' => [$this->book(Method::FullOuterJoin), $this->review(Method::RightJoin)], + 'a full outer join after a right join' => [$this->book(Method::RightJoin), $this->review(Method::FullOuterJoin)], + ] as $label => $joins) { + $expected = $this->joinedChain($dedicated, $joins); + + $this->assertNotSame(self::REJECTED, $expected, "A dedicated database must read {$label}"); + $this->assertSame($expected, $this->readChain($shared, $joins), "Tenant {$tenant} must read {$label} as its own database would"); + } + } + } + + /** + * @return array + */ + private static function configurations(): array + { + return [ + 'emulated full outer join, document security off' => [false, false], + 'emulated full outer join, document security on' => [false, true], + 'native full outer join, document security off' => [true, false], + 'native full outer join, document security on' => [true, true], + ]; + } + + /** + * Both tenants' rows and the tenantless ones, in one set of shared tables. + */ + private function shared(bool $nativeFullOuterJoin, bool $documentSecurity): Database + { + $pdo = new PDO('sqlite::memory:'); + $database = $this->database($pdo, $nativeFullOuterJoin, $documentSecurity, sharedTables: true); + + foreach (self::ROWS as $tenant => $collections) { + $database->setTenant($tenant); + $this->write($database, $collections); + } + + foreach ([self::AUTHORS, self::BOOKS, self::REVIEWS] as $collection) { + foreach ([$collection, Storage::permissionsTable($collection)] as $table) { + $pdo->exec('UPDATE '.self::NAMESPACE.'_'.$table.' SET '.Storage::TENANT.' = NULL WHERE '.Storage::TENANT.' = '.self::TENANTLESS); + } + } + + return $database; + } + + /** + * One tenant's rows alone, in tables of their own: what that tenant must read. + */ + private function dedicated(bool $nativeFullOuterJoin, bool $documentSecurity, int $tenant): Database + { + $database = $this->database(new PDO('sqlite::memory:'), $nativeFullOuterJoin, $documentSecurity, sharedTables: false); + $this->write($database, self::ROWS[$tenant]); + + return $database; + } + + private function database(PDO $pdo, bool $nativeFullOuterJoin, bool $documentSecurity, bool $sharedTables): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database( + $nativeFullOuterJoin ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), + new Cache(new None()), + ); + $database + ->setAuthorization($authorization) + ->setDatabase('joins') + ->setNamespace(self::NAMESPACE) + ->setSharedTables($sharedTables) + ->setTenant(null); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: $documentSecurity, + )); + $database->createCollection(Collection::create( + id: self::BOOKS, + attributes: [ + Attribute::string(key: 'authorId', size: 64, required: true), + Attribute::integer(key: 'pages', required: true), + ], + permissions: $permissions, + documentSecurity: $documentSecurity, + )); + $database->createCollection(Collection::create( + id: self::REVIEWS, + attributes: [ + Attribute::string(key: 'authorId', size: 64, required: true), + Attribute::integer(key: 'stars', required: true), + ], + permissions: $permissions, + documentSecurity: $documentSecurity, + )); + + return $database; + } + + /** + * @param array>> $collections + */ + private function write(Database $database, array $collections): void + { + foreach ($collections as $collection => $documents) { + foreach ($documents as $id => $attributes) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } + } + } + + private function book(Method $method): Query + { + return $this->join($method, self::BOOKS, self::BOOK); + } + + private function review(Method $method): Query + { + return $this->join($method, self::REVIEWS, self::REVIEW); + } + + private function join(Method $method, string $collection, string $alias): Query + { + return match ($method) { + Method::Join => Query::join($collection, $alias, [Query::on('$id', 'authorId')]), + Method::LeftJoin => Query::leftJoin($collection, $alias, [Query::on('$id', 'authorId')]), + Method::RightJoin => Query::rightJoin($collection, $alias, [Query::on('$id', 'authorId')]), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $alias, [Query::on('$id', 'authorId')]), + Method::CrossJoin => Query::crossJoin($collection, $alias), + default => throw new \InvalidArgumentException("{$method->value} is not a join this test covers"), + }; + } + + /** + * @return array{?string, ?int} + */ + private function pages(Document $document): array + { + return [$this->author($document), $this->integer($document, self::PAGES)]; + } + + /** + * @return array{?string, ?int, ?int} + */ + private function pagesAndStars(Document $document): array + { + return [$this->author($document), $this->integer($document, self::PAGES), $this->integer($document, self::STARS)]; + } + + private function author(Document $document): ?string + { + $name = $document->getAttribute('name'); + + return \is_string($name) && $name !== '' ? $name : null; + } + + private function integer(Document $document, string $attribute): ?int + { + $value = $document->getAttribute($attribute); + + return \is_numeric($value) ? (int) $value : null; + } + + /** + * @template T of array + * @param array $rows + * @return list + */ + private function sorted(array $rows): array + { + \usort($rows, static fn (array $left, array $right): int => \json_encode($left) <=> \json_encode($right)); + + return $rows; + } + + /** + * The shared tables of shared(), plus extras only tenant two and the tenantless rows have. + */ + private function sharedWithExtras(bool $nativeFullOuterJoin): Database + { + $pdo = new PDO('sqlite::memory:'); + $database = $this->database($pdo, $nativeFullOuterJoin, documentSecurity: false, sharedTables: true); + $this->extras($database, []); + + foreach (self::ROWS as $tenant => $collections) { + $database->setTenant($tenant); + $this->write($database, [...$collections, self::EXTRAS => self::EXTRA_ROWS[$tenant] ?? []]); + } + + foreach ([self::AUTHORS, self::BOOKS, self::REVIEWS, self::EXTRAS] as $collection) { + foreach ([$collection, Storage::permissionsTable($collection)] as $table) { + $pdo->exec('UPDATE '.self::NAMESPACE.'_'.$table.' SET '.Storage::TENANT.' = NULL WHERE '.Storage::TENANT.' = '.self::TENANTLESS); + } + } + + return $database; + } + + /** + * @param array> $rows + */ + private function extras(Database $database, array $rows): void + { + $database->createCollection(Collection::create( + id: self::EXTRAS, + attributes: [ + Attribute::string(key: 'authorId', size: 64, required: true), + Attribute::integer(key: 'weight', required: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + $this->write($database, [self::EXTRAS => $rows]); + } + + /** + * @return array> + */ + private static function twoJoinChains(): array + { + $chains = []; + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin, Method::CrossJoin] as $first) { + [$collection, $alias] = $first === Method::CrossJoin ? [self::EXTRAS, self::EXTRA] : [self::BOOKS, self::BOOK]; + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin] as $second) { + foreach (['$id' => self::AUTHORS, $alias.'.authorId' => $alias] as $on => $target) { + $chains["{$first->value} {$collection}, {$second->value} reviews on {$target}"] = [ + self::joinOn($first, $collection, $alias, '$id'), + self::joinOn($second, self::REVIEWS, self::REVIEW, $on), + ]; + } + } + } + + return $chains; + } + + private static function joinOn(Method $method, string $collection, string $alias, string $on): Query + { + return match ($method) { + Method::Join => Query::join($collection, $alias, [Query::on($on, 'authorId')]), + Method::LeftJoin => Query::leftJoin($collection, $alias, [Query::on($on, 'authorId')]), + Method::RightJoin => Query::rightJoin($collection, $alias, [Query::on($on, 'authorId')]), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $alias, [Query::on($on, 'authorId')]), + Method::CrossJoin => Query::crossJoin($collection, $alias), + default => throw new \InvalidArgumentException("{$method->value} is not a join this test covers"), + }; + } + + /** + * The rows as [author name, then each join's number], their count and the sum of the last + * join's number, as find(), count() and sum() return them, or REJECTED when refused. + * + * @param list $joins + * @return array{rows: list>, count: int, sum: int|float}|string + */ + private function readChain(Database $database, array $joins): array|string + { + $copies = static fn (): array => \array_map(static fn (Query $join): Query => clone $join, $joins); + $numbers = $this->numbers($joins); + + try { + return [ + 'rows' => $this->chainRows($database, $copies(), $numbers), + 'count' => $database->count(self::AUTHORS, $copies()), + 'sum' => $database->sum(self::AUTHORS, $numbers[\count($numbers) - 1], $copies()), + ]; + } catch (QueryException) { + return self::REJECTED; + } + } + + /** + * What readChain() must return, taken from a dedicated database's rows alone: nothing + * filters its joins, so its count and sum follow from its rows. A chain the adapter refuses + * outright is refused here too. + * + * @param list $joins + * @return array{rows: list>, count: int, sum: int}|string + */ + private function joinedChain(Database $dedicated, array $joins): array|string + { + try { + $rows = $this->chainRows( + $dedicated, + \array_map(static fn (Query $join): Query => clone $join, $joins), + $this->numbers($joins), + ); + } catch (QueryException) { + return self::REJECTED; + } + + return [ + 'rows' => $rows, + 'count' => \count($rows), + 'sum' => \array_sum(\array_map(static fn (array $row): int => (int) $row[\count($row) - 1], $rows)), + ]; + } + + /** + * @param list $joins + * @return list + */ + private function numbers(array $joins): array + { + return \array_map( + static fn (Query $join): string => $join->getAlias().'.'.self::NUMBERS[$join->getAttribute()], + $joins, + ); + } + + /** + * @param list $joins + * @param list $numbers + * @return list> + */ + private function chainRows(Database $database, array $joins, array $numbers): array + { + return $this->sorted(\array_map(function (Document $document) use ($numbers): array { + $row = [$this->author($document)]; + foreach ($numbers as $number) { + $row[] = $this->integer($document, $number); + } + + return $row; + }, $database->find(self::AUTHORS, [...$joins, Query::select(['name', ...$numbers]), Query::limit(100)]))); + } +} diff --git a/tests/unit/JoinVectorCursorTest.php b/tests/unit/JoinVectorCursorTest.php new file mode 100644 index 0000000000..1c9487d9e1 --- /dev/null +++ b/tests/unit/JoinVectorCursorTest.php @@ -0,0 +1,144 @@ +captureFindSql( + orderAttributes: ['meta.score', 'meta.$id', '$sequence'], + cursor: [ + 'meta.score' => 10, + 'meta.$id' => 'meta-1', + '$sequence' => '5', + '$distance' => 0.25, + ], + direction: $direction, + ); + + $where = $this->whereClause($sql); + $this->assertStringNotContainsString('"table_main"."meta.', $where); + $this->assertStringNotContainsString('"meta.score"', $where); + $this->assertStringNotContainsString('"meta._uid"', $where); + $this->assertSame(3, \substr_count($where, '"meta"."score"'), $sql); + $this->assertSame(2, \substr_count($where, '"meta"."_uid"'), $sql); + $this->assertSame(1, \substr_count($where, '"table_main"."_id"'), $sql); + } + + public function testCursorConditionKeepsMainColumnsOnTheMainAlias(): void + { + $sql = $this->captureFindSql( + orderAttributes: ['title', '$sequence'], + cursor: [ + 'title' => 'alpha', + '$sequence' => '5', + '$distance' => 0.25, + ], + direction: CursorDirection::After, + ); + + $where = $this->whereClause($sql); + $this->assertSame(2, \substr_count($where, '"table_main"."title"'), $sql); + $this->assertSame(1, \substr_count($where, '"table_main"."_id" >'), $sql); + } + + public function testANullJoinedCursorValueIsMatchedByNullnessNotByComparison(): void + { + $cursor = [ + 'meta.score' => null, + 'meta.$id' => 'meta-1', + '$sequence' => '5', + '$distance' => 0.25, + ]; + + $after = $this->whereClause($this->captureFindSql(['meta.score', 'meta.$id', '$sequence'], $cursor, CursorDirection::After)); + $before = $this->whereClause($this->captureFindSql(['meta.score', 'meta.$id', '$sequence'], $cursor, CursorDirection::Before)); + + $this->assertSame(2, \substr_count($after, '"meta"."score" IS NULL'), $after); + $this->assertStringNotContainsString('"meta"."score" IS NOT NULL', $after, 'after a null, in a direction that puts nulls last, only nulls follow'); + $this->assertStringNotContainsString('"meta"."score" =', $after); + $this->assertStringNotContainsString('"meta"."score" >', $after); + + $this->assertSame(1, \substr_count($before, '"meta"."score" IS NOT NULL'), $before); + $this->assertSame(2, \substr_count($before, '"meta"."score" IS NULL'), $before); + $this->assertStringNotContainsString('"meta"."score" =', $before); + $this->assertStringNotContainsString('"meta"."score" <', $before); + } + + /** + * @return iterable + */ + public static function directions(): iterable + { + yield 'after' => [CursorDirection::After]; + yield 'before' => [CursorDirection::Before]; + } + + /** + * @param list $orderAttributes + * @param array $cursor + */ + private function captureFindSql(array $orderAttributes, array $cursor, CursorDirection $direction): string + { + $statement = self::createStub(PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = self::createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document(['$id' => 'collection']), + [ + Query::vectorCosine('embedding', [1.0, 0.0, 0.0]), + Query::join('meta', 'meta', [Query::on('$id', 'mainId')]), + ], + limit: 5, + orderAttributes: $orderAttributes, + orderTypes: \array_fill(0, \count($orderAttributes), OrderDirection::Asc), + cursor: $cursor, + cursorDirection: $direction, + ); + + $this->assertNotSame('', $sql); + + return $sql; + } + + private function whereClause(string $sql): string + { + $where = \strpos($sql, ' WHERE '); + $order = \strrpos($sql, ' ORDER BY '); + $this->assertNotFalse($where, $sql); + $this->assertNotFalse($order, $sql); + + return \substr($sql, $where, $order - $where); + } +} diff --git a/tests/unit/JoinedInternalAttributesTest.php b/tests/unit/JoinedInternalAttributesTest.php new file mode 100644 index 0000000000..4cd63ce719 --- /dev/null +++ b/tests/unit/JoinedInternalAttributesTest.php @@ -0,0 +1,218 @@ +}> + */ + public static function joins(): iterable + { + $paired = ['a1/b1', 'a1/b2', 'a2/b3']; + + foreach (['emulated' => false, 'native' => true] as $label => $native) { + yield "inner join, {$label} full outer join" => [Method::Join, $native, $paired]; + yield "left join, {$label} full outer join" => [Method::LeftJoin, $native, [...$paired, 'a3/-']]; + yield "right join, {$label} full outer join" => [Method::RightJoin, $native, [...$paired, '-/b4']]; + yield "full outer join, {$label}" => [Method::FullOuterJoin, $native, [...$paired, 'a3/-', '-/b4']]; + } + } + + /** + * @param list $rows + */ + #[DataProvider('joins')] + public function testAliasWildcardReturnsTheJoinedRowAsADirectReadReturnsIt(Method $join, bool $native, array $rows): void + { + $database = $this->database($native); + + $direct = []; + foreach ($database->find(self::BOOKS) as $book) { + $direct[$book->getId()] = $book; + } + + $found = $database->find(self::AUTHORS, [$this->join($join), Query::select(['name', self::BOOK.'.*']), Query::limit(100)]); + \sort($rows); + $this->assertSame($rows, $this->sorted($found)); + + foreach ($found as $row) { + $this->assertFalse($row->offsetExists(self::BOOK.'.'.Document::TENANT)); + + $id = $row->getAttribute(self::BOOK.'.$id'); + if ($id !== null) { + $this->assertIsString($id); + } + foreach (self::INTERNALS as $internal) { + $this->assertSame( + $id === null ? null : $direct[$id]->getAttribute($internal), + $row->getAttribute(self::BOOK.'.'.$internal), + self::BOOK.".{$internal} of ".($id ?? 'an unmatched row'), + ); + } + } + } + + /** + * @param list $rows + */ + #[DataProvider('joins')] + public function testReadOrderedByAJoinedInternalAttributePagesEveryRowOnce(Method $join, bool $native, array $rows): void + { + $database = $this->database($native); + \sort($rows); + + foreach ([ + Query::orderAsc(self::BOOK.'.$sequence'), + Query::orderDesc(self::BOOK.'.$sequence'), + Query::orderAsc(self::BOOK.'.$createdAt'), + Query::orderDesc(self::BOOK.'.$updatedAt'), + ] as $order) { + foreach ([[], [Query::select(['*'])]] as $select) { + $label = $order->getMethod()->value.' '.$order->getAttribute().($select === [] ? '' : ' with *'); + $queries = [$this->join($join), ...$select, $order]; + + $all = $database->find(self::AUTHORS, [...$queries, Query::limit(100)]); + $keys = \array_map($this->key(...), $all); + $this->assertSame($rows, $this->sorted($all), $label); + + $forward = []; + $cursor = null; + while (\count($forward) <= \count($all)) { + $page = $database->find(self::AUTHORS, [...$queries, Query::limit(1), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + if ($page === []) { + break; + } + $forward[] = $this->key($page[0]); + $cursor = $page[0]; + } + $this->assertSame($keys, $forward, "{$label}: forward"); + + $backward = []; + $cursor = $all[\count($all) - 1]; + while (\count($backward) <= \count($all)) { + $page = $database->find(self::AUTHORS, [...$queries, Query::limit(1), Query::cursorBefore($cursor)]); + if ($page === []) { + break; + } + \array_unshift($backward, $this->key($page[0])); + $cursor = $page[0]; + } + $this->assertSame(\array_slice($keys, 0, -1), $backward, "{$label}: backward"); + } + } + } + + private function database(bool $native): Database + { + $pdo = new PDO('sqlite::memory:'); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($native ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('joins') + ->setNamespace('internals') + ->setSharedTables(true) + ->setTenant(null); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: self::BOOKS, + attributes: [Attribute::string(key: 'authorId', size: 64, required: true)], + permissions: $permissions, + )); + + $authorsByBook = [ + self::OTHER_TENANT => ['b1' => 'a3', 'b2' => 'a3', 'b3' => 'a3', 'b4' => 'a1'], + self::TENANT => ['b1' => 'a1', 'b2' => 'a1', 'b3' => 'a2', 'b4' => 'zz'], + ]; + foreach ($authorsByBook as $tenant => $books) { + $database->setTenant($tenant); + foreach (['a1', 'a2', 'a3'] as $author) { + $database->createDocument(self::AUTHORS, new Document(['$id' => $author, 'name' => $author, '$permissions' => [Permission::read(Role::any())]])); + } + foreach ($books as $book => $author) { + $database->createDocument(self::BOOKS, new Document([ + '$id' => $book, + 'authorId' => $author, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::user($book.'-'.$tenant))], + ])); + } + } + + return $database; + } + + private function join(Method $method): Query + { + return new Query($method, self::BOOKS, [Query::on('$id', 'authorId')], self::BOOK); + } + + private function key(Document $row): string + { + $book = $row->getAttribute(self::BOOK.'.$id'); + + return ($row->getId() === '' ? '-' : $row->getId()).'/'.(\is_string($book) ? $book : '-'); + } + + /** + * @param array $rows + * @return list + */ + private function sorted(array $rows): array + { + $keys = \array_values(\array_map($this->key(...), $rows)); + \sort($keys); + + return $keys; + } +} diff --git a/tests/unit/Joins/AggregateMinorsTest.php b/tests/unit/Joins/AggregateMinorsTest.php new file mode 100644 index 0000000000..29233cca33 --- /dev/null +++ b/tests/unit/Joins/AggregateMinorsTest.php @@ -0,0 +1,278 @@ +mariaDBFind( + [Query::bitAnd('flags'), Query::bitOr('flags'), Query::bitXor('mask'), Query::bitAnd('flags', 'all_bits')], + ['flags' => 0, 'mask' => 0], + ); + + $this->assertStringContainsString('COUNT(`flags`) AS `$inputs:0`', $statement); + $this->assertStringContainsString('COUNT(`flags`) AS `$inputs:1`', $statement); + $this->assertStringContainsString('COUNT(`mask`) AS `$inputs:2`', $statement); + $this->assertStringContainsString('COUNT(`flags`) AS `$inputs:3`', $statement); + $this->assertSame( + [['BIT_AND(`flags`)' => null, 'BIT_OR(`flags`)' => null, 'BIT_XOR(`mask`)' => null, 'all_bits' => null]], + $rows, + ); + } + + public function testUnaliasedBitwiseAggregateOfValuesKeepsItsValue(): void + { + [$rows] = $this->mariaDBFind( + [Query::bitAnd('flags'), Query::bitOr('mask'), Query::bitXor('$sequence')], + ['flags' => 2, 'mask' => 0, '_id' => 3], + ); + + $this->assertSame( + [['BIT_AND(`flags`)' => '18446744073709551615', 'BIT_OR(`mask`)' => null, 'BIT_XOR(`_id`)' => '0']], + $rows, + ); + } + + public function testSumResolvesAJoinDeclaredAttribute(): void + { + $database = $this->database(); + $item = Query::join('items', 'it', [Query::on('item', 'code')]); + + $this->assertSame(40, $database->sum('orders', 'price', [$item]), 'a name only the join declares'); + $this->assertSame( + [['total' => 40]], + $database->aggregate('orders', [$item, Query::sum('price', 'total')]), + 'aggregate() reads the same attribute', + ); + $this->assertSame(40, $database->sum('orders', 'it.price', [$item]), 'the qualified name'); + $this->assertSame(20, $database->sum('orders', 'price', [$item, Query::equal('it.code', ['a'])])); + $this->assertSame(6, $database->sum('orders', 'quantity', [$item]), 'a name the main collection declares reads the main table'); + $this->assertSame(0, $database->sum('orders', 'price', [$item, Query::equal('it.code', ['z'])])); + } + + public function testSumReadsEachJoinedCollectionDefinitionOnce(): void + { + $database = $this->database(); + $database->setProfiling(true); + + foreach (['validated' => true, 'unvalidated' => false] as $case => $validate) { + $validate ? $database->setValidation(true) : $database->setValidation(false); + $database->getProfiler()?->reset(); + + $this->assertSame(40, $database->sum('orders', 'price', [Query::join('items', 'it', [Query::on('item', 'code')])]), $case); + + $reads = \array_filter( + $database->getProfiler()?->getLogs() ?? [], + static fn (Log $log): bool => \str_contains($log->query, '_metadata') && \in_array('items', $log->bindings, true), + ); + $this->assertCount(1, $reads, $case.': the definition resolved for the join serves the bare name too'); + } + } + + public function testSumRefusesABareNameNoCollectionOrSeveralJoinsDeclare(): void + { + $database = $this->database(); + $item = Query::join('items', 'it', [Query::on('item', 'code')]); + $extra = Query::join('extras', 'ex', [Query::on('item', 'code')]); + + foreach ([ + 'two joins declare it' => [fn (): int|float => $database->sum('orders', 'price', [$item, $extra]), 'Invalid query: Attribute "price" is ambiguous across joins; qualify it with a join alias'], + 'no join' => [fn (): int|float => $database->sum('orders', 'price'), 'Invalid query: Attribute not found in schema: price'], + 'no collection declares it' => [fn (): int|float => $database->sum('orders', 'weight', [$item]), 'Invalid query: Attribute not found in schema: weight'], + ] as $case => [$sum, $message]) { + try { + $sum(); + $this->fail($case.': the sum ran'); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage(), $case); + } + } + + $this->assertSame(200, $database->sum('orders', 'ex.price', [$item, $extra]), 'qualified, the ambiguous name reads its join'); + } + + public function testJoinedGroupKeepsItsQualifiedName(): void + { + foreach (['join' => [false, 'join'], 'emulated full outer join' => [false, 'fullOuterJoin'], 'native full outer join' => [true, 'fullOuterJoin']] as $case => [$native, $method]) { + $database = $this->database($native); + $item = Query::$method('items', 'it', [Query::on('item', 'code')]); + $extra = Query::join('extras', 'ex', [Query::on('item', 'code')]); + + $this->assertSame( + [['orders' => 2, 'name' => 'x', 'it.name' => 'apple'], ['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['name', 'it.name']), Query::orderAsc('name')]), + $case.': the main group keeps the bare name', + ); + $this->assertSame( + [['orders' => 2, 'name' => 'x', 'it.name' => 'apple'], ['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['it.name', 'name']), Query::orderAsc('it.name')]), + $case.': in either order', + ); + $this->assertSame( + [['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['name', 'it.name']), Query::having([Query::equal('it.name', ['banana'])])]), + $case.': a having on the qualified group', + ); + $this->assertSame( + [['orders' => 2, 'name' => 'apple'], ['orders' => 1, 'name' => 'banana']], + $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['it.name']), Query::orderAsc('it.name')]), + $case.': a joined group alone keeps its bare name', + ); + $this->assertSame( + [['orders' => 2, 'code' => 'a'], ['orders' => 1, 'code' => 'b']], + $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['code']), Query::orderAsc('it.code')]), + $case.': a bare name only the join declares', + ); + } + + $database = $this->database(); + $this->assertSame( + [['orders' => 2, 'it.code' => 'a', 'ex.code' => 'a']], + $database->aggregate('orders', [ + Query::join('items', 'it', [Query::on('item', 'code')]), + Query::join('extras', 'ex', [Query::on('item', 'code')]), + Query::count('*', 'orders'), + Query::groupBy(['it.code', 'ex.code']), + ]), + 'two joined groups of one name are both qualified', + ); + } + + /** + * Run a find on MariaDB, answered as MariaDB answers: an unaliased aggregate is named by its + * expression, a count is the number of values $inputs gives its column, BIT_AND is every bit set + * and BIT_OR/BIT_XOR are 0. + * + * @param list $queries + * @param array $inputs The number of values each column holds + * @return array{list>, string} + */ + private function mariaDBFind(array $queries, array $inputs): array + { + $sql = ''; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('closeCursor')->willReturn(true); + $statement->method('fetchAll')->willReturnCallback(function () use (&$sql, $inputs): array { + \preg_match_all('/([A-Z_]+)\(`([^`]+)`\)(?: AS `([^`]+)`)?/', $sql, $matches, PREG_SET_ORDER); + $this->assertNotSame([], $matches, 'no aggregate in: '.$sql); + + $row = []; + foreach ($matches as $match) { + [$expression, $function, $column] = $match; + $name = $match[3] ?? $expression; + $row[$name] = match ($function) { + 'COUNT' => (string) ($inputs[$column] ?? 0), + 'BIT_AND' => '18446744073709551615', + default => '0', + }; + } + + return [$row]; + }); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new MariaDB($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $rows = \array_values(\array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $adapter->find(new Document(['$id' => 'collection']), $queries, limit: 25), + )); + + return [$rows, $sql]; + } + + /** + * Orders of items: o1 and o3 order a (price 10), o2 orders b (price 20); extras prices a at 100. + */ + private function database(bool $nativeFullOuterJoin = false): Database + { + $pdo = new PDO('sqlite::memory:'); + $database = new Database($nativeFullOuterJoin ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), new Cache(new NoCache())); + $database + ->setDatabase('aggregate_minors') + ->setNamespace('aggregate_minors_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'orders', + attributes: [ + Attribute::string(key: 'item', size: 16), + Attribute::integer(key: 'quantity'), + Attribute::string(key: 'name', size: 16), + ], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: 'items', + attributes: [ + Attribute::string(key: 'code', size: 16), + Attribute::integer(key: 'price'), + Attribute::integer(key: 'quantity'), + Attribute::string(key: 'name', size: 16), + ], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: 'extras', + attributes: [ + Attribute::string(key: 'code', size: 16), + Attribute::integer(key: 'price'), + ], + permissions: $permissions, + )); + + foreach ([['o1', 'a', 1, 'x'], ['o2', 'b', 2, 'y'], ['o3', 'a', 3, 'x']] as [$id, $item, $quantity, $name]) { + $database->createDocument('orders', new Document(['$id' => $id, '$permissions' => [], 'item' => $item, 'quantity' => $quantity, 'name' => $name])); + } + foreach ([['a', 10, 'apple'], ['b', 20, 'banana']] as [$code, $price, $name]) { + $database->createDocument('items', new Document(['$id' => $code, '$permissions' => [], 'code' => $code, 'price' => $price, 'quantity' => 100, 'name' => $name])); + } + $database->createDocument('extras', new Document(['$id' => 'a', '$permissions' => [], 'code' => 'a', 'price' => 100])); + + return $database; + } +} diff --git a/tests/unit/Joins/AggregateResultNamesTest.php b/tests/unit/Joins/AggregateResultNamesTest.php new file mode 100644 index 0000000000..54d8845d18 --- /dev/null +++ b/tests/unit/Joins/AggregateResultNamesTest.php @@ -0,0 +1,243 @@ + + */ + public static function joins(): iterable + { + yield 'inner join' => [Method::Join, false, 10, 1, 1]; + yield 'left join' => [Method::LeftJoin, false, 30, 1, 2]; + yield 'right join' => [Method::RightJoin, false, 10, 4, 2]; + yield 'native full outer join' => [Method::FullOuterJoin, true, 30, 4, 3]; + yield 'emulated full outer join' => [Method::FullOuterJoin, false, 30, 4, 3]; + } + + #[DataProvider('joins')] + public function testMainAttributeAggregatedUnderItsOwnNameIsReadFromTheMainTable(Method $join, bool $native, int $total, int $joinedTotal, int $rows): void + { + $database = $this->database($native); + $joined = $this->join($join); + + $this->assertSame([['total' => $total]], $database->aggregate('main', [$joined, Query::sum('score', 'total')])); + $this->assertSame([['score' => $total]], $database->aggregate('main', [$joined, Query::sum('score', 'score')])); + $this->assertSame([['total' => $total, 'score' => $rows]], $database->aggregate('main', [$joined, Query::sum('score', 'total'), Query::count('*', 'score')]), 'another aggregate named like the attribute'); + $this->assertSame([['score' => $joinedTotal]], $database->aggregate('main', [$joined, Query::sum('a.score', 'score')]), 'a joined attribute keeps its alias'); + } + + #[DataProvider('joins')] + public function testUnaliasedAggregatesComeBackUnderTheirDefaultAlias(Method $join, bool $native, int $total, int $joinedTotal, int $rows): void + { + $database = $this->database($native); + + $this->assertSame( + [['count' => $rows, 'sum_score' => $total, 'sum_a_score' => $joinedTotal]], + $database->aggregate('main', [$this->join($join), Query::count(), Query::sum('score'), Query::sum('a.score')]), + ); + } + + /** + * @return iterable, string}> + */ + public static function collidingAliases(): iterable + { + $grouped = static fn (string $alias, string $attribute): string => 'Invalid query: Aggregate alias "'.$alias.'" is the name the groupBy attribute "'.$attribute.'" is returned under'; + + yield 'a grouped main attribute' => [static fn (): array => [Query::count('*', 'link'), Query::groupBy(['link'])], $grouped('link', 'link')]; + yield 'a grouped main attribute over a join' => [static fn (): array => [Query::join('a', 'a', [Query::on('link', 'link')]), Query::count('*', 'label'), Query::groupBy(['label'])], $grouped('label', 'label')]; + yield 'a grouped main attribute over a full outer join' => [static fn (): array => [Query::fullOuterJoin('a', 'a', [Query::on('link', 'link')]), Query::count('*', 'label'), Query::groupBy(['label'])], $grouped('label', 'label')]; + yield 'a grouped joined attribute' => [static fn (): array => [Query::join('a', 'a', [Query::on('link', 'link')]), Query::sum('score', 'score'), Query::groupBy(['a.score'])], $grouped('score', 'a.score')]; + yield 'a grouped internal attribute' => [static fn (): array => [Query::count('*', '_uid'), Query::groupBy(['$id'])], $grouped('_uid', '$id')]; + yield 'another aggregate' => [static fn (): array => [Query::count('*', 'rows'), Query::sum('score', 'rows')], 'Invalid query: Aggregate alias "rows" is given to more than one aggregate']; + } + + /** + * @param Closure(): list $queries + */ + #[DataProvider('collidingAliases')] + public function testAliasNamingAnotherColumnOfTheResultIsAnInvalidQuery(Closure $queries, string $message): void + { + foreach ([false, true] as $native) { + try { + $rows = $this->database($native)->aggregate('main', $queries()); + $this->fail(($native ? 'native' : 'emulated').': the shape was accepted and returned '.\json_encode($rows)); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage()); + } + } + } + + public function testAliasesNamingNoOtherColumnKeepEveryValue(): void + { + $database = $this->database(false); + + $this->assertSame( + [['links' => 1, 'total' => 10, 'link' => '1'], ['links' => 1, 'total' => 20, 'link' => '2']], + $database->aggregate('main', [Query::count('*', 'links'), Query::sum('score', 'total'), Query::groupBy(['link']), Query::orderAsc('link')]), + ); + $this->assertSame( + [['score' => 10, 'label' => 'first']], + $database->aggregate('main', [Query::join('a', 'a', [Query::on('link', 'link')]), Query::sum('score', 'score'), Query::groupBy(['a.label'])]), + ); + } + + /** + * A bitwise aggregate over no input values is null because of an input count the adapter adds + * next to it. The count's name has to survive PostgreSQL's 63-byte identifiers: a truncated name + * nulled whichever aggregate carried the truncated alias. + */ + public function testBitwiseInputCountsHaveNamesPostgresDoesNotTruncate(): void + { + $alias = \str_repeat('b', 60); + $prefix = \substr($alias, 0, 55); + + [$rows, $statement] = $this->postgresFind([ + Query::bitAnd('flags', $alias), + Query::count('*', $prefix), + Query::bitOr('flags', 'any_bits'), + ]); + + $this->assertStringContainsString('COUNT("flags") AS "$inputs:0"', $statement); + $this->assertStringContainsString('COUNT("flags") AS "$inputs:1"', $statement); + $this->assertSame([[$alias => null, $prefix => '0', 'any_bits' => null]], $rows); + } + + /** + * Run a find on PostgreSQL over no input values, answered as PostgreSQL answers: a count is zero, + * every other aggregate is null, and each name is kept to its first 63 bytes. + * + * @param list $queries + * @return array{list>, string} + */ + private function postgresFind(array $queries): array + { + $sql = ''; + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('closeCursor')->willReturn(true); + $statement->method('fetchAll')->willReturnCallback(function () use (&$sql): array { + \preg_match_all('/([A-Z_]+)\((?:DISTINCT )?[^()]*\) AS "([^"]+)"/', $sql, $matches, PREG_SET_ORDER); + $this->assertNotSame([], $matches, 'no aggregate in: '.$sql); + + $row = []; + foreach ($matches as [, $function, $name]) { + $row[\substr($name, 0, self::POSTGRES_IDENTIFIER_BYTES)] = $function === 'COUNT' ? '0' : null; + } + + return [$row]; + }); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $rows = $this->rows($adapter->find(new Document(['$id' => 'collection']), $queries, limit: 25)); + + return [$rows, $sql]; + } + + private function join(Method $method): Query + { + return new Query($method, 'a', [Query::on('link', 'link')], 'a'); + } + + /** + * @param array $documents + * @return list> + */ + private function rows(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): array => $document->getArrayCopy(), $documents)); + } + + private function database(bool $native): Database + { + $pdo = new PDO('sqlite::memory:'); + $database = new Database($native ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), new Cache(new NoCache())); + $database + ->setDatabase('aggregate_result_names') + ->setNamespace('aggregate_result_names_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + foreach (['main', 'a'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'link', size: 64), + Attribute::string(key: 'label', size: 64), + Attribute::integer(key: 'score'), + ], + permissions: $permissions, + )); + } + + foreach ([ + ['main', 'm1', '1', 'one', 10], + ['main', 'm2', '2', 'two', 20], + ['a', 'a1', '1', 'first', 1], + ['a', 'a3', '3', 'third', 3], + ] as [$collection, $id, $link, $label, $score]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'link' => $link, + 'label' => $label, + 'score' => $score, + ])); + } + + return $database; + } +} diff --git a/tests/unit/Joins/BareJoinedOrderCursorTest.php b/tests/unit/Joins/BareJoinedOrderCursorTest.php new file mode 100644 index 0000000000..6856f5702e --- /dev/null +++ b/tests/unit/Joins/BareJoinedOrderCursorTest.php @@ -0,0 +1,241 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase('bare_order') + ->setNamespace('bare_order_'.\uniqid()) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + + $this->createCollection(self::MAIN, [Attribute::string(key: 'name', size: 16)]); + $this->createCollection(self::JOINED, [ + Attribute::string(key: 'store', size: 16), + Attribute::string(key: 'name', size: 16), + Attribute::integer(key: 'price', required: false), + ]); + $this->createCollection(self::OTHER, [ + Attribute::string(key: 'store', size: 16), + Attribute::integer(key: 'price', required: false), + ]); + + foreach (['s1', 's2', 's3'] as $id) { + $this->createDocument(self::MAIN, $id, ['name' => 'store '.$id]); + } + + foreach ([ + 'i1' => ['s1', 10], + 'i2' => ['s1', 10], + 'i3' => ['s2', 20], + 'i4' => ['s2', null], + 'i5' => ['s1', 5], + 'i6' => ['zz', 30], + ] as $id => [$store, $price]) { + $this->createDocument(self::JOINED, $id, ['store' => $store, 'name' => 'item '.$id, 'price' => $price]); + } + + $this->createDocument(self::OTHER, 'o1', ['store' => 's1', 'price' => 1]); + } + + /** + * @return iterable + */ + public static function reads(): iterable + { + foreach (['inner join' => Method::Join, 'left join' => Method::LeftJoin] as $joinName => $join) { + foreach (['ascending' => true, 'descending' => false] as $directionName => $ascending) { + yield "{$joinName}, {$directionName}" => [$join, $ascending]; + } + } + } + + #[DataProvider('reads')] + public function testCursorPagesAlongABareJoinedOrderAsAlongTheQualifiedOne(Method $join, bool $ascending): void + { + $bare = [$this->join($join), $ascending ? Query::orderAsc('price') : Query::orderDesc('price')]; + $qualified = [$this->join($join), $ascending ? Query::orderAsc(self::ALIAS.'.price') : Query::orderDesc(self::ALIAS.'.price')]; + + $expected = $this->keys($qualified); + $rows = $join === Method::LeftJoin ? 6 : 5; + $this->assertCount($rows, $expected, 'every joined row, the tied i1/i2, the null i4 and for a left join s3 without an item'); + $this->assertSame($expected, $this->keys($bare), 'the bare name orders by the joined attribute'); + + $this->assertPagesEveryRowOnce($bare, $expected); + } + + public function testCursorKeyedByTheBareNameFollowsTheQualifiedOrder(): void + { + $queries = [$this->join(Method::Join), Query::orderAsc('price')]; + $expected = $this->keys($queries); + + $cursor = $this->database->find(self::MAIN, [...$queries, Query::limit(1)])[0]; + $cursor->setAttribute('price', $cursor->getAttribute(self::ALIAS.'.price')); + $cursor->removeAttribute(self::ALIAS.'.price'); + + $this->assertSame(\array_slice($expected, 1), $this->keys([...$queries, Query::cursorAfter($cursor)])); + } + + public function testNameTheMainCollectionDeclaresKeepsOrderingTheMainTable(): void + { + $queries = [$this->join(Method::Join), Query::orderDesc('name')]; + $rows = \array_values($this->database->find(self::MAIN, [...$queries, Query::limit(100)])); + + $this->assertSame(['s2', 's2', 's1', 's1', 's1'], \array_map(static fn (Document $row): string => $row->getId(), $rows)); + $this->assertPagesEveryRowOnce($queries, \array_map($this->key(...), $rows)); + } + + /** + * @return iterable + */ + public static function validation(): iterable + { + yield 'validation on' => [true]; + yield 'validation off' => [false]; + } + + #[DataProvider('validation')] + public function testBareNameSeveralJoinsDeclareIsRefusedForACursorRead(bool $validate): void + { + $joins = [$this->join(Method::LeftJoin), Query::leftJoin(self::OTHER, 'of', [Query::on('$id', 'store')])]; + $cursor = $this->database->find(self::MAIN, [...$joins, Query::orderAsc(self::ALIAS.'.price'), Query::limit(1)])[0]; + + if (! $validate) { + $this->database->setValidation(false); + } + + try { + $this->database->find(self::MAIN, [...$joins, Query::orderAsc('price'), Query::cursorAfter($cursor)]); + $this->fail('A bare name two joins declare must be refused, not read from one of them'); + } catch (QueryException $exception) { + $this->assertStringContainsString('Attribute "price" is ambiguous across joins; qualify it with a join alias', $exception->getMessage()); + } + } + + private function join(Method $method): Query + { + return new Query($method, self::JOINED, [Query::on('$id', 'store')], self::ALIAS); + } + + /** + * After and before each row the rest of the read in order, then a walk in pages of two in both + * directions that returns each row exactly once. + * + * @param list $queries + * @param list $expected + */ + private function assertPagesEveryRowOnce(array $queries, array $expected): void + { + $all = \array_values($this->database->find(self::MAIN, [...$queries, Query::limit(100)])); + $this->assertSame($expected, \array_map($this->key(...), $all)); + + foreach ($all as $index => $row) { + $this->assertSame(\array_slice($expected, $index + 1), $this->keys([...$queries, Query::cursorAfter($row)]), "after {$expected[$index]}"); + $this->assertSame(\array_slice($expected, 0, $index), $this->keys([...$queries, Query::cursorBefore($row)]), "before {$expected[$index]}"); + } + + $forward = []; + $cursor = null; + for ($page = 0; $page <= \count($all); $page++) { + $rows = \array_values($this->database->find(self::MAIN, [...$queries, Query::limit(2), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])])); + if ($rows === []) { + break; + } + \array_push($forward, ...\array_map($this->key(...), $rows)); + $cursor = $rows[\count($rows) - 1]; + } + $this->assertSame($expected, $forward, 'pages of two forward'); + + $backward = []; + $cursor = $all[\count($all) - 1]; + $backward[] = $this->key($cursor); + for ($page = 0; $page <= \count($all); $page++) { + $rows = \array_values($this->database->find(self::MAIN, [...$queries, Query::limit(2), Query::cursorBefore($cursor)])); + if ($rows === []) { + break; + } + \array_unshift($backward, ...\array_map($this->key(...), $rows)); + $cursor = $rows[0]; + } + $this->assertSame($expected, $backward, 'pages of two backward'); + } + + /** + * @param list $queries + * @return list + */ + private function keys(array $queries): array + { + return \array_values(\array_map($this->key(...), $this->database->find(self::MAIN, [...$queries, Query::limit(100)]))); + } + + private function key(Document $row): string + { + $joined = $row->getAttribute(self::ALIAS.'.$id'); + + return $row->getId().'/'.(\is_string($joined) && $joined !== '' ? $joined : '-'); + } + + /** + * @param list $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + + /** + * @param array $attributes + */ + private function createDocument(string $collection, string $id, array $attributes): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Joins/BoundedJoinSortTest.php b/tests/unit/Joins/BoundedJoinSortTest.php new file mode 100644 index 0000000000..09d062a4ee --- /dev/null +++ b/tests/unit/Joins/BoundedJoinSortTest.php @@ -0,0 +1,477 @@ + ['amber', 2, true], + 'a02' => ['basalt', 1, true], + 'a03' => ['cedar', null, true], + 'a04' => ['delta', 2, false], + 'a05' => ['ember', 3, true], + 'a06' => ['fjord', 1, true], + 'a07' => ['granite', null, false], + 'a08' => ['harbor', 2, true], + 'a09' => ['island', 4, true], + 'a10' => ['juniper', 1, true], + ]; + + /** + * author, rank, readable: authors with no note, with hidden notes only, with ties on the rank, a note without an + * author, and a note naming its author in another case, which SQLite matches in the collation of the id index. + * The second tenant's n15 belongs to a05, whose own notes are hidden: read from the first tenant, it must not + * count as a05's note. + */ + private const array NOTES = [ + 'n01' => ['a01', 1, true], + 'n02' => ['a01', 2, true], + 'n03' => ['a01', 1, true], + 'n04' => ['a02', 5, true], + 'n05' => ['a03', null, true], + 'n06' => ['a03', 2, false], + 'n07' => ['a04', 1, true], + 'n08' => ['a05', 1, false], + 'n09' => ['a06', 3, true], + 'n10' => ['a06', 3, true], + 'n11' => ['a08', 2, true], + 'n12' => ['a09', 1, true], + 'n13' => ['a09', 1, true], + 'n14' => ['a09', 4, true], + 'n15' => ['zz', 1, true], + 'n16' => ['A10', 2, true], + ]; + + /** + * author, note, readable. + */ + private const array TAGS = [ + 't01' => ['a01', 'n01', true], + 't02' => ['a01', 'n01', true], + 't03' => ['a01', 'n03', true], + 't04' => ['a06', 'n09', true], + 't05' => ['a09', 'n14', true], + 't06' => ['a02', 'none', true], + 't07' => ['a10', 'none', true], + 't08' => ['a03', 'n05', false], + ]; + + /** + * @var array + */ + private array $databases = []; + + /** + * @return iterable, bool}> + */ + public static function reads(): iterable + { + $notes = Query::leftJoin('notes', 'n', [Query::on('$id', 'author')]); + $innerNotes = Query::join('notes', 'n', [Query::on('$id', 'author')]); + $joins = [ + 'notes' => [$notes], + 'notes and their tags' => [$notes, Query::leftJoin('tags', 't', [Query::on('n.$id', 'note')])], + 'notes and the author\'s tags' => [$notes, Query::leftJoin('tags', 't', [Query::on('$id', 'author')])], + 'inner notes' => [$innerNotes], + 'inner notes and their inner tags' => [$innerNotes, Query::join('tags', 't', [Query::on('n.$id', 'note')])], + 'notes and their inner tags' => [$notes, Query::join('tags', 't', [Query::on('n.$id', 'note')])], + 'notes and the author\'s inner tags' => [$notes, Query::join('tags', 't', [Query::on('$id', 'author')])], + ]; + $orders = [ + 'default order' => [], + 'rank' => [Query::orderAsc('rank')], + 'rank descending' => [Query::orderDesc('rank')], + 'name' => [Query::orderAsc('name')], + '$id descending' => [Query::orderDesc('$id')], + 'rank, $sequence, then the joined rank' => [Query::orderAsc('rank'), Query::orderAsc('$sequence'), Query::orderDesc('n.rank')], + ]; + $joinedConditions = [ + 'a joined attribute that is set' => [Query::isNotNull('n.rank')], + 'a joined attribute equal to a value' => [Query::equal('n.rank', [1])], + 'a joined attribute in a range' => [Query::between('n.rank', 1, 2)], + 'a joined attribute above a value, grouped with one below' => [Query::or([Query::greaterThan('n.rank', 2), Query::lessThan('n.rank', 2)])], + 'a main and a joined condition grouped with and' => [Query::and([Query::lessThan('rank', 4), Query::greaterThanEqual('n.rank', 1)])], + 'a condition on the tags of the notes' => [Query::leftJoin('tags', 't', [Query::on('n.$id', 'note')]), Query::isNotNull('t.$id')], + 'a joined condition grouped with and inside or' => [Query::or([Query::and([Query::lessThan('n.rank', 2), Query::or([Query::isNull('n.rank'), Query::equal('n.rank', [1])])]), Query::greaterThan('n.rank', 3)])], + 'a condition on the notes and on the author\'s tags' => [Query::leftJoin('tags', 't', [Query::on('$id', 'author')]), Query::lessThanEqual('n.rank', 3), Query::startsWith('t.note', 'n0')], + ]; + + foreach ([self::PLAIN, self::GRANTED, self::SHARED] as $mode) { + foreach ($joins as $joinName => $join) { + foreach ($orders as $orderName => $order) { + $bounded = \array_filter($join, static fn (Query $query): bool => $query->getMethod() === Method::Join) === []; + yield "{$mode}: {$joinName}, {$orderName}" => [$mode, [...$join, ...$order], $bounded]; + } + } + + foreach ($joinedConditions as $conditionName => $conditions) { + foreach (['default order' => [], 'rank descending' => [Query::orderDesc('rank')]] as $orderName => $order) { + yield "{$mode}: notes, {$conditionName}, {$orderName}" => [$mode, [$notes, ...$conditions, ...$order], false]; + yield "{$mode}: inner notes, {$conditionName}, {$orderName}" => [$mode, [$innerNotes, ...$conditions, ...$order], false]; + } + } + + yield "{$mode}: notes, joined attributes selected" => [$mode, [$notes, Query::orderAsc('rank'), Query::select(['name', 'rank', 'n.rank', 'n.$id'])], true]; + yield "{$mode}: inner notes, joined attributes selected" => [$mode, [$innerNotes, Query::orderAsc('rank'), Query::select(['name', 'rank', 'n.rank', 'n.$id'])], false]; + yield "{$mode}: notes, every attribute selected" => [$mode, [$notes, Query::orderDesc('rank'), Query::select(['*'])], true]; + yield "{$mode}: notes, a main condition" => [$mode, [$notes, Query::notEqual('name', 'cedar'), Query::orderAsc('rank')], true]; + yield "{$mode}: notes, a condition on a main attribute whose name holds a dot" => [$mode, [$notes, Query::equal('rev.score', [1, 2])], true]; + yield "{$mode}: notes, ordered by a main attribute whose name holds a dot" => [$mode, [$notes, Query::orderDesc('rev.score'), Query::greaterThan('rev.score', 0)], true]; + yield "{$mode}: notes, main conditions grouped" => [$mode, [$notes, Query::or([Query::lessThan('rank', 2), Query::isNull('rank')])], true]; + yield "{$mode}: inner notes, a main and a joined condition" => [$mode, [$innerNotes, Query::notEqual('name', 'cedar'), Query::lessThan('n.rank', 3), Query::orderAsc('rank')], false]; + yield "{$mode}: inner notes, a joined attribute that is not set" => [$mode, [$innerNotes, Query::isNull('n.rank')], false]; + yield "{$mode}: notes and their inner tags, a joined condition that keeps notes without a rank" => [$mode, [$notes, Query::join('tags', 't', [Query::on('n.$id', 'note')]), Query::or([Query::isNull('n.rank'), Query::lessThan('n.rank', 4)])], false]; + yield "{$mode}: notes, a search on a main attribute" => [$mode, [$notes, Query::search('name', 'amber')], true]; + yield "{$mode}: notes, a search matching every author" => [$mode, [$notes, Query::search('name', 'one'), Query::orderAsc('rank')], true]; + yield "{$mode}: inner notes, a search and a joined condition" => [$mode, [$innerNotes, Query::search('name', 'one'), Query::isNotNull('n.rank'), Query::orderDesc('rank')], false]; + yield "{$mode}: notes, a search that excludes authors" => [$mode, [$notes, Query::notSearch('name', 'amber')], true]; + yield "{$mode}: notes, a search on a joined attribute" => [$mode, [$notes, Query::search('n.author', 'a01')], false]; + yield "{$mode}: notes, a joined attribute that is not set" => [$mode, [$notes, Query::isNull('n.rank'), Query::orderAsc('rank')], false]; + yield "{$mode}: notes, a joined attribute other than a value" => [$mode, [$notes, Query::notEqual('n.rank', 1)], false]; + yield "{$mode}: notes, a joined attribute that is not set or below a value" => [$mode, [$notes, Query::or([Query::isNull('n.rank'), Query::lessThan('n.rank', 2)])], false]; + yield "{$mode}: notes, a grouped condition naming a joined and a main attribute" => [$mode, [$notes, Query::or([Query::equal('n.rank', [1]), Query::isNull('rank')])], false]; + yield "{$mode}: inner notes, a grouped condition naming a joined and a main attribute" => [$mode, [$innerNotes, Query::or([Query::equal('n.rank', [1]), Query::isNull('rank')])], false]; + yield "{$mode}: notes and their tags, a grouped condition naming both" => [$mode, [$notes, Query::leftJoin('tags', 't', [Query::on('n.$id', 'note')]), Query::or([Query::equal('n.rank', [1]), Query::isNotNull('t.$id')])], false]; + yield "{$mode}: notes, ordered by a joined attribute first" => [$mode, [$notes, Query::orderAsc('n.rank')], false]; + yield "{$mode}: inner notes, ordered by a joined attribute first" => [$mode, [$innerNotes, Query::orderAsc('n.rank')], false]; + yield "{$mode}: notes, ordered by a main attribute that is not unique, then a joined one" => [$mode, [$notes, Query::orderAsc('rank'), Query::orderAsc('n.rank')], false]; + yield "{$mode}: notes, right join" => [$mode, [Query::rightJoin('notes', 'n', [Query::on('$id', 'author')]), Query::orderAsc('rank')], false]; + yield "{$mode}: notes, right join behind an inner join" => [$mode, [$innerNotes, Query::rightJoin('tags', 't', [Query::on('$id', 'author')])], false]; + yield "{$mode}: notes, full outer join" => [$mode, [Query::fullOuterJoin('notes', 'n', [Query::on('$id', 'author')])], false]; + yield "{$mode}: notes, main attributes selected" => [$mode, [$notes, Query::orderAsc('rank'), Query::select(['name', 'rank'])], false]; + } + } + + /** + * @param list $queries + */ + #[DataProvider('reads')] + public function testEveryWindowOfTheReadMatchesTheReadThatSortsTheWholeJoin(string $mode, array $queries, bool $bounded): void + { + [$sorted, $bounding] = $this->databases($mode); + $all = $this->rows($sorted, $queries, [Query::limit(100)]); + $this->assertNotSame([], $all); + $this->assertSame($all, $this->rows($bounding, $queries, [Query::limit(100)])); + + foreach ([1, 2, 3] as $limit) { + for ($offset = 0; $offset <= \count($all); $offset++) { + $page = [Query::limit($limit), Query::offset($offset)]; + $expected = \array_slice($all, $offset, $limit); + $this->assertSame($expected, $this->rows($sorted, $queries, $page), "limit {$limit}, offset {$offset}: the read that sorts the whole join"); + $this->assertSame($expected, $this->rows($bounding, $queries, $page), "limit {$limit}, offset {$offset}"); + $this->assertSame($bounded ? $offset + $limit : null, $this->boundedMainRows($bounding), "limit {$limit}, offset {$offset}: main rows the join sees"); + } + } + } + + /** + * @param list $queries + */ + #[DataProvider('reads')] + public function testCursorPagesMatchTheReadThatSortsTheWholeJoin(string $mode, array $queries, bool $bounded): void + { + [$sorted, $bounding] = $this->databases($mode); + $all = $this->documents($sorted, $queries, [Query::limit(100)]); + $keys = \array_map($this->key(...), $all); + if (! $this->pageable($all, $queries)) { + $this->expectNotToPerformAssertions(); + + return; + } + + foreach ($all as $index => $row) { + foreach ([1, 2] as $limit) { + $after = \array_slice($keys, $index + 1, $limit); + $before = \array_slice($keys, \max(0, $index - $limit), \min($limit, $index)); + foreach ([$sorted, $bounding] as $database) { + $this->assertSame($after, $this->keys($database, $queries, [Query::cursorAfter($row), Query::limit($limit)]), "{$limit} after {$keys[$index]}"); + $this->assertSame($bounded && $database === $bounding ? $limit + 1 : null, $this->boundedMainRows($database), "{$limit} after {$keys[$index]}: main rows the join sees"); + $this->assertSame($before, $this->keys($database, $queries, [Query::cursorBefore($row), Query::limit($limit)]), "{$limit} before {$keys[$index]}"); + $this->assertSame($bounded && $database === $bounding ? $limit + 1 : null, $this->boundedMainRows($database), "{$limit} before {$keys[$index]}: main rows the join sees"); + } + } + + foreach ([$sorted, $bounding] as $database) { + $this->assertSame(\array_slice($keys, $index + 2, 2), $this->keys($database, $queries, [Query::cursorAfter($row), Query::offset(1), Query::limit(2)]), "offset 1 after {$keys[$index]}"); + } + } + } + + /** + * @param list $queries + */ + #[DataProvider('reads')] + public function testIterationMatchesTheReadThatSortsTheWholeJoin(string $mode, array $queries, bool $bounded): void + { + [$sorted, $bounding] = $this->databases($mode); + $all = $this->documents($sorted, $queries, [Query::limit(100)]); + if (! $this->pageable($all, $queries)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $keys = \array_map($this->key(...), $all); + foreach ([[2, []], [3, [Query::offset(1)]]] as [$pageSize, $offset]) { + $expected = $offset === [] ? $keys : \array_slice($keys, 1); + foreach ([$sorted, $bounding] as $database) { + foreach ([$pageSize, 2] as $batchSize) { + $batched = []; + foreach ($database->cursor('authors', [...$queries, ...$offset], $batchSize) as $document) { + $batched[] = $this->key($document); + } + $this->assertSame($expected, $batched, "batches of {$batchSize}"); + } + } + } + } + + public function testAnotherTenantsMainRowsTakeNoPlaceInTheBoundedPage(): void + { + [$sorted, $bounding] = $this->databases(self::SHARED); + $queries = [Query::leftJoin('notes', 'n', [Query::on('$id', 'author')]), Query::limit(4)]; + + foreach ([1, 2] as $tenant) { + $sorted->setTenant($tenant); + $bounding->setTenant($tenant); + $expected = $this->rows($sorted, $queries, []); + $this->assertCount(4, $expected); + $this->assertSame($expected, $this->rows($bounding, $queries, [])); + foreach ($expected as $row) { + $this->assertIsString($row['name']); + $this->assertStringStartsWith($tenant === 1 ? 'one ' : 'two ', $row['name']); + } + } + } + + /** + * A read whose rows show no joined attribute orders by the main sequence alone (fix 60): there is no joined sort + * to bound. + */ + public function testAReadThatShowsNoJoinedAttributeIsNotBounded(): void + { + [$sorted, $bounding] = $this->databases(self::PLAIN); + $queries = [Query::leftJoin('notes', 'n', [Query::on('$id', 'author')]), Query::select(['name'])]; + + $this->assertSame($this->rows($sorted, $queries, [Query::limit(5)]), $this->rows($bounding, $queries, [Query::limit(5)])); + $this->assertNull($this->boundedMainRows($bounding)); + } + + /** + * @param list $rows + * @param list $queries + */ + private function pageable(array $rows, array $queries): bool + { + foreach ($queries as $query) { + if ($query->getMethod() === Method::Select && ! \in_array('*', $query->getValues(), true) && ! \in_array('n.$id', $query->getValues(), true)) { + return false; + } + } + + return $rows !== []; + } + + /** + * @param list $queries + * @param list $page + * @return list> + */ + private function rows(Database $database, array $queries, array $page): array + { + return \array_map( + static function (Document $row): array { + $copy = $row->getArrayCopy(); + unset($copy['$createdAt'], $copy['$updatedAt']); + foreach (\array_keys($copy) as $key) { + if (\str_ends_with($key, '.$createdAt') || \str_ends_with($key, '.$updatedAt')) { + unset($copy[$key]); + } + } + + return $copy; + }, + $this->documents($database, $queries, $page), + ); + } + + /** + * @param list $queries + * @param list $page + * @return list + */ + private function documents(Database $database, array $queries, array $page): array + { + $database->getProfiler()?->reset(); + + return \array_values($database->find('authors', [...$page, ...$queries])); + } + + /** + * @param list $queries + * @param list $page + * @return list + */ + private function keys(Database $database, array $queries, array $page): array + { + return \array_map($this->key(...), $this->documents($database, $queries, $page)); + } + + private function key(Document $row): string + { + return ($row->getId() ?: '-').'/'.$this->joinedId($row, 'n').'/'.$this->joinedId($row, 't'); + } + + private function joinedId(Document $row, string $alias): string + { + $id = $row->getAttribute($alias.'.$id'); + + return \is_string($id) ? $id : '-'; + } + + /** + * How many main rows the last read let its join see: the limit of the subquery that picks them, or null when the + * read joins every main row it matches. + */ + private function boundedMainRows(Database $database): ?int + { + $reads = \array_values(\array_filter( + $database->getProfiler()?->getLogs() ?? [], + static fn (Log $log): bool => \str_starts_with($log->query, 'SELECT') && \str_contains($log->query, 'JOIN'), + )); + $this->assertNotSame([], $reads); + $read = $reads[\count($reads) - 1]; + + if (\preg_match('/^SELECT .+? FROM \(SELECT .+? LIMIT \?\) AS [`"]?table_main[`"]? /', $read->query, $match) !== 1) { + return null; + } + + $placeholder = \substr_count($match[0], '?') - 1; + $bound = $read->bindings[$placeholder] ?? null; + $this->assertIsInt($bound); + + return $bound; + } + + /** + * The same documents in a database that sorts the whole join and one that bounds it. + * + * @return array{Database, Database} + */ + private function databases(string $mode): array + { + return $this->databases[$mode] ??= [ + $this->database(new SQLite(new PDO('sqlite::memory:')), $mode), + $this->database(new BoundedJoinSortSQLite(new PDO('sqlite::memory:')), $mode), + ]; + } + + private function database(SQLite $adapter, string $mode): Database + { + $authorization = new Authorization(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::user('reader')->toString()); + + $shared = $mode === self::SHARED; + $database = new Database($adapter, new Cache(new NoCache())); + $database + ->setAuthorization($authorization) + ->setDatabase('bounded_join_sort') + ->setNamespace('bounded_join_sort') + ->setSharedTables($shared) + ->setTenant(null); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = $mode === self::GRANTED + ? [Permission::create(Role::any()), Permission::read(Role::user('reader'))] + : [Permission::create(Role::any())]; + foreach ([ + 'authors' => [Attribute::string(key: 'name', size: 32), Attribute::integer(key: 'rank', required: false), Attribute::integer(key: 'rev.score', required: false)], + 'notes' => [Attribute::string(key: 'author', size: 16), Attribute::integer(key: 'rank', required: false)], + 'tags' => [Attribute::string(key: 'author', size: 16), Attribute::string(key: 'note', size: 16)], + ] as $id => $attributes) { + $database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + indexes: match ($id) { + 'authors' => [Index::fulltext(key: 'name_search', attributes: ['name'])], + 'notes' => [Index::fulltext(key: 'author_search', attributes: ['author'])], + default => [], + }, + permissions: $permissions, + documentSecurity: true, + )); + } + + $tenants = $shared ? [1 => 'one', 2 => 'two'] : [0 => 'one']; + foreach ([ + 'authors' => self::AUTHORS, + 'notes' => self::NOTES, + 'tags' => self::TAGS, + ] as $collection => $documents) { + foreach ($documents as $id => $values) { + foreach ($tenants as $tenant => $prefix) { + if ($shared) { + $database->setTenant($tenant); + } + $attributes = match ($collection) { + 'authors' => ['name' => $prefix.' '.$values[0], 'rank' => $values[1], 'rev.score' => $values[1]], + 'notes' => ['author' => $tenant === 2 && $id === 'n15' ? 'a05' : $values[0], 'rank' => $values[1]], + default => ['author' => $values[0], 'note' => $values[1]], + }; + $readable = $values[2] && ! ($tenant === 2 && $id === 'a06'); + $database->getAuthorization()->skip(fn () => $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::user($readable ? 'reader' : 'other'))], + ...$attributes, + ]))); + } + } + } + + if ($shared) { + $database->setTenant(1); + } + $database->setProfiling(true); + + return $database; + } +} diff --git a/tests/unit/Joins/DistinctJoinedSelectTest.php b/tests/unit/Joins/DistinctJoinedSelectTest.php new file mode 100644 index 0000000000..dd72cd0a1a --- /dev/null +++ b/tests/unit/Joins/DistinctJoinedSelectTest.php @@ -0,0 +1,119 @@ +useDatabase(new SQLite(new PDO('sqlite::memory:'))); + } + + /** + * @return iterable}> + */ + public static function joins(): iterable + { + yield 'inner join' => [Method::Join, false, ['side-one']]; + yield 'left join' => [Method::LeftJoin, false, [null, 'side-one']]; + yield 'emulated full outer join' => [Method::FullOuterJoin, false, [null, 'side-one', 'side-two']]; + yield 'native full outer join' => [Method::FullOuterJoin, true, [null, 'side-one', 'side-two']]; + } + + /** + * @param list $labels + */ + #[DataProvider('joins')] + public function testDistinctSelectOfOnlyJoinedColumnsKeepsThemUnderTheirAlias(Method $join, bool $native, array $labels): void + { + if ($native) { + $this->useDatabase(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + + $rows = $this->database->find('main', [ + new Query($join, 'side', [Query::on('code', 'code')], 's'), + Query::distinct(), + Query::select(['s.label']), + Query::orderAsc('s.label'), + ]); + + $this->assertSame($labels, \array_map(static fn (Document $row): mixed => $row->getAttribute('s.label'), $rows)); + foreach ($rows as $row) { + $this->assertArrayNotHasKey('label', $row->getArrayCopy(), 'a joined value never lands on the main document\'s bare key'); + } + } + + public function testDistinctSelectOfAJoinedInternalAttributeIsProjectedUnderItsAlias(): void + { + $rows = $this->database->find('main', [ + Query::join('side', 's', [Query::on('code', 'code')]), + Query::distinct(), + Query::select(['s.$id']), + ]); + + $this->assertSame(['s1'], \array_map(static fn (Document $row): mixed => $row->getAttribute('s.$id'), $rows)); + } + + private function useDatabase(SQLite $adapter): void + { + $this->database = new Database($adapter, new Cache(new NoCache())); + $this->database + ->setDatabase('distinct_joined_select') + ->setNamespace('distinct_joined_select_'.\uniqid()) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + + foreach (['main', 'side'] as $collection) { + $this->database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::string(key: 'label', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + + $this->createDocument('main', 'm1', ['code' => 'one', 'label' => 'main-one']); + $this->createDocument('main', 'm2', ['code' => 'two', 'label' => 'main-two']); + $this->createDocument('side', 's1', ['code' => 'one', 'label' => 'side-one']); + $this->createDocument('side', 's2', ['code' => 'three', 'label' => 'side-two']); + } + + /** + * @param array $attributes + */ + private function createDocument(string $collection, string $id, array $attributes): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Joins/FullOuterJoinAggregateTest.php b/tests/unit/Joins/FullOuterJoinAggregateTest.php new file mode 100644 index 0000000000..b1feecbedf --- /dev/null +++ b/tests/unit/Joins/FullOuterJoinAggregateTest.php @@ -0,0 +1,468 @@ +> + */ + private const array ROWS = [ + 'main' => [ + 'm1' => ['1', 'p', 10], + 'm2' => ['2', 'q', 20], + 'm3' => ['5', 'p', 30], + ], + 'a' => [ + 'a1' => ['1', 'p', 1], + 'a2' => ['3', 'q', 2], + 'a3' => ['4', null, 3], + ], + 'b' => [ + 'b1' => ['1', 'p', 4], + 'b2' => ['3', 'p', 5], + 'b3' => ['6', null, 6], + 'b4' => ['1', 'q', 7], + ], + 'c' => [ + 'c1' => ['1', 'q', 8], + 'c2' => ['4', 'p', 9], + 'c3' => ['6', null, 10], + 'c4' => ['7', 'p', 11], + ], + ]; + + public function testCountAndSumSeeBothHalvesOfTheJoin(): void + { + $database = $this->database(native: false, documentSecurity: false, sharedTables: false); + + $rows = $database->aggregate('main', [ + Query::fullOuterJoin('b', 'b', [Query::on(self::LINK, self::LINK)]), + Query::count('*', 'rows'), + Query::sum('b.score', 'total'), + ]); + + $this->assertSame([['rows' => 6, 'total' => 22]], $rows); + } + + public function testDistinctReturnsAValueBothHalvesHoldOnce(): void + { + $database = $this->database(native: false, documentSecurity: false, sharedTables: false); + + $rows = $database->find('main', [ + Query::fullOuterJoin('b', 'b', [Query::on(self::LINK, self::LINK)]), + Query::distinct(), + Query::select(['b.category']), + ]); + + $categories = \array_map(static fn (Document $row): mixed => $row->getAttribute('b.category'), $rows); + \sort($categories); + + $this->assertSame([null, 'p', 'q'], $categories); + } + + /** + * Every chain of one or two joins holding one full outer join, with the main rows readable + * through the collection and through their own permissions, answers every aggregate, group, + * having, distinct() and page shape exactly as the native full outer join does. + */ + #[DataProvider('mainDocumentSecurity')] + public function testEveryShapeMatchesANativeFullOuterJoin(bool $documentSecurity): void + { + $emulated = $this->database(native: false, documentSecurity: $documentSecurity, sharedTables: false); + $native = $this->database(native: true, documentSecurity: $documentSecurity, sharedTables: false); + + $this->assertShapesMatch($emulated, $native, $this->chains(), 200); + } + + /** + * @return iterable + */ + public static function mainDocumentSecurity(): iterable + { + yield 'main rows readable through the collection' => [false]; + yield 'main rows readable through their own permissions' => [true]; + } + + public function testEveryShapeMatchesANativeFullOuterJoinUnderSharedTables(): void + { + $emulated = $this->database(native: false, documentSecurity: true, sharedTables: true); + $native = $this->database(native: true, documentSecurity: true, sharedTables: true); + + $totals = $emulated->aggregate('main', [ + Query::fullOuterJoin('b', 'b', [Query::on(self::LINK, self::LINK)]), + Query::count('*', 'rows'), + Query::sum('b.score', 'total'), + ]); + $this->assertSame([['rows' => 6, 'total' => 22]], $totals); + + $chains = []; + foreach ($this->chains() as $label => $chain) { + if (! \str_contains($label, Method::RightJoin->value)) { + $chains[$label] = $chain; + } + } + + $this->assertShapesMatch($emulated, $native, $chains, 150); + } + + public function testUnaliasedAggregatesComeBackUnderTheSameDefaultAliasOnBothJoins(): void + { + $emulated = $this->database(native: false, documentSecurity: false, sharedTables: false); + $native = $this->database(native: true, documentSecurity: false, sharedTables: false); + $queries = [ + Query::fullOuterJoin('b', 'b', [Query::on(self::LINK, self::LINK)]), + Query::count(), + Query::sum('b.score'), + Query::max('score'), + Query::groupBy(['b.category']), + ]; + + $expected = $this->rows($native, $queries, ordered: false); + + $this->assertCount(3, $expected); + $this->assertSame($expected, $this->rows($emulated, $queries, ordered: false)); + } + + /** + * The halves' UNION compares every projected column, an order column among them, where the single + * statement compares the selected columns only, so this order has no emulation. + */ + public function testDistinctOrderedByAnUnselectedAttributeIsRejectedWhenEmulated(): void + { + $queries = [ + Query::fullOuterJoin('b', 'b', [Query::on(self::LINK, self::LINK)]), + Query::distinct(), + Query::select(['b.category']), + Query::orderAsc(self::SCORE), + ]; + + $this->assertCount(3, $this->database(native: true, documentSecurity: false, sharedTables: false)->find('main', $queries)); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('A distinct() query over a full outer join can only be ordered by a selected attribute on this database, and score is not selected'); + + $this->database(native: false, documentSecurity: false, sharedTables: false)->find('main', $queries); + } + + /** + * @param iterable, string}> $chains + */ + private function assertShapesMatch(Database $emulated, Database $native, iterable $chains, int $minimum): void + { + $checked = 0; + $mismatches = []; + foreach ($chains as $chainLabel => [$joins, $fullOuterJoined]) { + foreach ($this->shapes($fullOuterJoined) as $shapeLabel => [$shape, $ordered]) { + $label = "{$chainLabel} / {$shapeLabel}"; + $queries = [...$joins, ...$shape]; + $expected = $this->rows($native, $queries, $ordered); + + try { + $actual = $this->rows($emulated, $queries, $ordered); + } catch (Throwable $throwable) { + $mismatches[] = "{$label}: ".$throwable::class." ({$throwable->getMessage()})"; + + continue; + } + + $checked++; + if ($actual !== $expected) { + $mismatches[] = "{$label}: expected ".\json_encode($expected).', got '.\json_encode($actual); + } + } + } + + $this->assertSame([], $mismatches); + $this->assertGreaterThan($minimum, $checked); + } + + /** + * The shapes every chain runs, over the main collection and the full outer joined alias. + * + * @return iterable, bool}> + */ + private function shapes(string $alias): iterable + { + $category = "{$alias}.".self::CATEGORY; + $score = "{$alias}.".self::SCORE; + + yield 'a row count' => [[ + Query::count('*', 'rows'), + ], false]; + yield 'every aggregate' => [[ + Query::count('*', 'rows'), + Query::count("{$alias}.\$id", 'joined'), + Query::count('$id', 'main'), + Query::sum($score, 'total'), + Query::avg($score, 'mean'), + Query::min($score, 'low'), + Query::max($score, 'high'), + Query::sum(self::SCORE, 'mainTotal'), + Query::min(self::CATEGORY, 'firstCategory'), + ], false]; + yield 'distinct counts' => [[ + Query::countDistinct($category, 'categories'), + Query::countDistinct(self::CATEGORY, 'mainCategories'), + ], false]; + yield 'grouped by the joined collection' => [[ + Query::groupBy([$category]), + Query::count('*', 'rows'), + Query::sum($score, 'total'), + Query::sum(self::SCORE, 'mainTotal'), + ], false]; + yield 'grouped by the main collection' => [[ + Query::groupBy([self::CATEGORY]), + Query::count('*', 'rows'), + Query::max($score, 'high'), + ], false]; + yield 'grouped by both collections' => [[ + Query::groupBy([self::CATEGORY, $category]), + Query::count('*', 'rows'), + Query::avg($score, 'mean'), + ], false]; + yield 'having on a count' => [[ + Query::groupBy([$category]), + Query::count('*', 'rows'), + Query::having([Query::greaterThan('rows', 1)]), + ], false]; + yield 'having on a sum and a group' => [[ + Query::groupBy([self::CATEGORY]), + Query::sum($score, 'total'), + Query::having([Query::lessThan('total', 12), Query::isNotNull(self::CATEGORY)]), + ], false]; + yield 'ordered and paged groups' => [[ + Query::groupBy([$category]), + Query::count('*', 'rows'), + Query::orderDesc('rows'), + Query::orderAsc($category), + Query::limit(2), + Query::offset(1), + ], true]; + yield 'distinct aggregated rows' => [[ + Query::distinct(), + Query::groupBy([$category]), + Query::count('*', 'rows'), + ], false]; + yield 'aggregates filtered on the main collection' => [[ + Query::equal(self::CATEGORY, ['p']), + Query::count('*', 'rows'), + Query::sum($score, 'total'), + ], false]; + yield 'aggregates filtered on the joined collection' => [[ + Query::isNotNull($category), + Query::count('*', 'rows'), + Query::max(self::SCORE, 'high'), + ], false]; + yield 'aggregates over no rows' => [[ + Query::equal(self::CATEGORY, ['none']), + Query::count('*', 'rows'), + Query::sum($score, 'total'), + Query::avg($score, 'mean'), + Query::max($score, 'high'), + ], false]; + yield 'distinct joined values' => [[ + Query::distinct(), + Query::select([$category]), + ], false]; + yield 'distinct main and joined values' => [[ + Query::distinct(), + Query::select([self::CATEGORY, $category]), + ], false]; + yield 'distinct values ordered and paged' => [[ + Query::distinct(), + Query::select([$category]), + Query::orderAsc($category), + Query::limit(2), + Query::offset(1), + ], true]; + } + + /** + * Every chain of one or two joins holding exactly one full outer join — every join type before + * and after it, every earlier table in every ON — and three chains of three joins. + * + * @return iterable, string}> + */ + private function chains(): iterable + { + $methods = [Method::CrossJoin, Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin]; + + yield 'fullOuterJoin a on main' => [[$this->join(Method::FullOuterJoin, 'a', 'main')], 'a']; + + foreach ($methods as $first) { + foreach ($methods as $second) { + if (($first === Method::FullOuterJoin) === ($second === Method::FullOuterJoin)) { + continue; + } + + foreach ($second === Method::CrossJoin ? ['main'] : ['main', 'a'] as $reference) { + yield $this->label([[$first, 'a', 'main'], [$second, 'b', $reference]]) => [ + [$this->join($first, 'a', 'main'), $this->join($second, 'b', $reference)], + $first === Method::FullOuterJoin ? 'a' : 'b', + ]; + } + } + } + + foreach ([ + [[Method::LeftJoin, 'a', 'main'], [Method::FullOuterJoin, 'b', 'a'], [Method::LeftJoin, 'c', 'b']], + [[Method::FullOuterJoin, 'b', 'main'], [Method::RightJoin, 'c', 'b'], [Method::Join, 'a', 'c']], + [[Method::Join, 'a', 'main'], [Method::FullOuterJoin, 'b', 'main'], [Method::RightJoin, 'c', 'a']], + ] as $specification) { + yield $this->label($specification) => [ + \array_map(fn (array $join): Query => $this->join(...$join), $specification), + 'b', + ]; + } + } + + /** + * @param list $specification + */ + private function label(array $specification): string + { + return \implode(', ', \array_map( + static fn (array $join): string => $join[0] === Method::CrossJoin ? "cross {$join[1]}" : "{$join[0]->value} {$join[1]} on {$join[2]}", + $specification, + )); + } + + private function join(Method $method, string $collection, string $reference): Query + { + $left = $reference === 'main' ? self::LINK : "{$reference}.".self::LINK; + + return match ($method) { + Method::CrossJoin => Query::crossJoin($collection, $collection), + Method::Join => Query::join($collection, $collection, [Query::on($left, self::LINK)]), + Method::LeftJoin => Query::leftJoin($collection, $collection, [Query::on($left, self::LINK)]), + Method::RightJoin => Query::rightJoin($collection, $collection, [Query::on($left, self::LINK)]), + default => Query::fullOuterJoin($collection, $collection, [Query::on($left, self::LINK)]), + }; + } + + /** + * @param list $queries + * @return list> + */ + private function rows(Database $database, array $queries, bool $ordered): array + { + $aggregates = \array_filter( + $queries, + static fn (Query $query): bool => $query->getMethod()->isAggregate() || $query->getMethod() === Method::GroupBy, + ); + $rows = $aggregates !== [] + ? $database->aggregate('main', $queries) + : \array_values(\array_map(static fn (Document $document): array => $document->getArrayCopy(), $database->find('main', $queries))); + + if (! $ordered) { + \usort($rows, static fn (array $left, array $right): int => \strcmp((string) \json_encode($left), (string) \json_encode($right))); + } + + return $rows; + } + + private function database(bool $native, bool $documentSecurity, bool $sharedTables): Database + { + $pdo = new PDO('sqlite::memory:'); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database( + $native ? new NativeJoinChainSQLite($pdo) : new SQLite($pdo), + new Cache(new Memory()), + ); + $database + ->setAuthorization($authorization) + ->setDatabase('aggregates') + ->setNamespace('aggregates_'.\uniqid()); + if ($sharedTables) { + $database->setSharedTables(true)->setTenant(null); + } + $database->addHook(new Permissions()); + $database->create(); + + $collectionPermissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + foreach (\array_keys(self::ROWS) as $collection) { + $main = $collection === 'main'; + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: self::LINK, size: 16, required: true), + Attribute::string(key: self::CATEGORY, size: 16, required: false), + Attribute::integer(key: self::SCORE, required: true), + ], + permissions: $main && $documentSecurity ? [Permission::create(Role::any())] : $collectionPermissions, + documentSecurity: $main && $documentSecurity, + )); + } + + if ($sharedTables) { + $database->setTenant(self::OTHER_TENANT); + $this->createRows($database, self::OTHER_TENANT_SCORE_OFFSET); + $database->setTenant(self::TENANT); + } + $this->createRows($database, 0); + + return $database; + } + + private function createRows(Database $database, int $scoreOffset): void + { + foreach (self::ROWS as $collection => $rows) { + foreach ($rows as $id => [$link, $category, $score]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + self::LINK => $link, + self::CATEGORY => $category, + self::SCORE => $score + $scoreOffset, + '$permissions' => [Permission::read(Role::any())], + ])); + } + } + } +} diff --git a/tests/unit/Joins/FullOuterJoinOrderColumnTest.php b/tests/unit/Joins/FullOuterJoinOrderColumnTest.php new file mode 100644 index 0000000000..6dfbd13746 --- /dev/null +++ b/tests/unit/Joins/FullOuterJoinOrderColumnTest.php @@ -0,0 +1,240 @@ + + */ + private const array MAIN_ROWS = ['m1' => ['1', 'first'], 'm2' => ['2', 'second']]; + + /** + * @var array + */ + private const array JOINED_ROWS = ['j1' => ['1', 1], 'j2' => ['3', 3]]; + + /** + * @return iterable + */ + public static function tables(): iterable + { + yield 'dedicated tables' => [false]; + yield 'shared tables' => [true]; + } + + #[DataProvider('tables')] + public function testAttributeNamedLikeAnOrderColumnIsRead(bool $sharedTables): void + { + $database = $this->database(native: false, sharedTables: $sharedTables); + + $this->assertSame('first', $database->getDocument(self::MAIN, 'm1')->getAttribute(self::NOTE), 'getDocument'); + $this->assertSame('first', $database->getDocument(self::MAIN, 'm1', [Query::select([self::NOTE])])->getAttribute(self::NOTE), 'getDocument with a select'); + $this->assertSame(['first', 'second'], $this->notes($database->find(self::MAIN)), 'find'); + $this->assertSame(['first', 'second'], $this->notes($database->getAuthorization()->skip(fn (): array => $database->find(self::MAIN))), 'find without authorization'); + $this->assertSame(['second'], $this->notes($database->find(self::MAIN, [Query::equal(self::NOTE, ['second'])])), 'find filtered by the attribute'); + $this->assertSame(['second', 'first'], $this->notes($database->find(self::MAIN, [Query::orderDesc(self::NOTE)])), 'find ordered by the attribute'); + } + + #[DataProvider('tables')] + public function testFullOuterJoinOrderedByAnAttributeNamedLikeAnOrderColumnReturnsIt(bool $sharedTables): void + { + $emulated = $this->database(native: false, sharedTables: $sharedTables); + $native = $this->database(native: true, sharedTables: $sharedTables); + $join = Query::fullOuterJoin(self::JOINED, 'j', [Query::on(self::LINK, self::LINK)]); + + $this->assertReadsMatch($emulated, $native, 'j', [ + 'ordered by the attribute' => [ + [$join, Query::orderAsc(self::NOTE)], + [['', null, 'j2', 3], ['m1', 'first', 'j1', 1], ['m2', 'second', null, null]], + ], + 'selecting and ordered by the attribute' => [ + [$join, Query::select(['$id', self::NOTE, 'j.$id', 'j.score']), Query::orderDesc(self::NOTE)], + [['m2', 'second', null, null], ['m1', 'first', 'j1', 1], ['', null, 'j2', 3]], + ], + 'ordered by a joined attribute and then the attribute, paged' => [ + [$join, Query::orderAsc('j.score'), Query::orderAsc(self::NOTE), Query::limit(2), Query::offset(1)], + [['m1', 'first', 'j1', 1], ['', null, 'j2', 3]], + ], + ]); + } + + #[DataProvider('tables')] + public function testJoinAliasNamedLikeAnOrderColumnReturnsItsColumns(bool $sharedTables): void + { + $emulated = $this->database(native: false, sharedTables: $sharedTables); + $native = $this->database(native: true, sharedTables: $sharedTables); + $alias = self::ALIAS; + + $this->assertSame( + [['m1', 'first', 'j1', 1]], + $this->summaries([$emulated->getDocument(self::MAIN, 'm1', [Query::leftJoin(self::JOINED, $alias, [Query::on(self::LINK, self::LINK)])])], $alias), + 'getDocument', + ); + + $this->assertReadsMatch($emulated, $native, $alias, [ + 'joined' => [ + [Query::join(self::JOINED, $alias, [Query::on(self::LINK, self::LINK)])], + [['m1', 'first', 'j1', 1]], + ], + 'left joined, selecting its columns' => [ + [Query::leftJoin(self::JOINED, $alias, [Query::on(self::LINK, self::LINK)]), Query::select(['$id', self::NOTE, "{$alias}.\$id", "{$alias}.score"])], + [['m1', 'first', 'j1', 1], ['m2', 'second', null, null]], + ], + 'full outer joined, ordered by its column' => [ + [Query::fullOuterJoin(self::JOINED, $alias, [Query::on(self::LINK, self::LINK)]), Query::orderDesc("{$alias}.score")], + [['', null, 'j2', 3], ['m1', 'first', 'j1', 1], ['m2', 'second', null, null]], + ], + ]); + } + + /** + * Each read returns the expected rows, emulated and native, and the emulated rows carry exactly + * the columns of the native ones: none of the columns the emulation ordered by, and nothing less. + * + * @param array, list}> $reads + */ + private function assertReadsMatch(Database $emulated, Database $native, string $alias, array $reads): void + { + foreach ($reads as $label => [$queries, $expected]) { + $emulatedRows = $emulated->find(self::MAIN, $queries); + $nativeRows = $native->find(self::MAIN, $queries); + + $this->assertSame($expected, $this->summaries($emulatedRows, $alias), "{$label}, emulated"); + $this->assertSame($expected, $this->summaries($nativeRows, $alias), "{$label}, native"); + $this->assertSame($this->columns($nativeRows), $this->columns($emulatedRows), "{$label}, columns"); + } + } + + /** + * @param array $rows + * @return list + */ + private function notes(array $rows): array + { + return \array_values(\array_map(static fn (Document $row): mixed => $row->getAttribute(self::NOTE), $rows)); + } + + /** + * @param array $rows + * @return list + */ + private function summaries(array $rows, string $alias): array + { + return \array_values(\array_map( + static fn (Document $row): array => [ + $row->getId(), + $row->getAttribute(self::NOTE), + $row->getAttribute("{$alias}.\$id"), + $row->getAttribute("{$alias}.".self::SCORE), + ], + $rows, + )); + } + + /** + * @param array $rows + * @return list> + */ + private function columns(array $rows): array + { + return \array_values(\array_map(static function (Document $row): array { + $columns = \array_map(\strval(...), \array_keys($row->getArrayCopy())); + \sort($columns); + + return $columns; + }, $rows)); + } + + private function database(bool $native, bool $sharedTables): Database + { + $pdo = new PDO('sqlite::memory:'); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database( + $native ? new NativeJoinChainSQLite($pdo) : new SQLite($pdo), + new Cache(new None()), + ); + $database + ->setAuthorization($authorization) + ->setDatabase('order_columns') + ->setNamespace('order_columns_'.\uniqid()); + if ($sharedTables) { + $database->setSharedTables(true)->setTenant(null); + } + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: self::MAIN, + attributes: [ + Attribute::string(key: self::LINK, size: 16, required: true), + Attribute::string(key: self::NOTE, size: 64, required: false), + ], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: self::JOINED, + attributes: [ + Attribute::string(key: self::LINK, size: 16, required: true), + Attribute::integer(key: self::SCORE, required: true), + ], + permissions: $permissions, + documentSecurity: false, + )); + + if ($sharedTables) { + $database->setTenant(self::TENANT); + } + foreach (self::MAIN_ROWS as $id => [$link, $note]) { + $database->createDocument(self::MAIN, new Document(['$id' => $id, self::LINK => $link, self::NOTE => $note])); + } + foreach (self::JOINED_ROWS as $id => [$link, $score]) { + $database->createDocument(self::JOINED, new Document(['$id' => $id, self::LINK => $link, self::SCORE => $score])); + } + + return $database; + } +} diff --git a/tests/unit/Joins/JoinCursorTest.php b/tests/unit/Joins/JoinCursorTest.php new file mode 100644 index 0000000000..4a410d4e5e --- /dev/null +++ b/tests/unit/Joins/JoinCursorTest.php @@ -0,0 +1,644 @@ +useDatabase(new SQLite(new PDO('sqlite::memory:'))); + } + + public function testCursorWithoutItsJoinedOrderValueIsRefusedByName(): void + { + $queries = [Query::join('notes', 'n', [Query::on('$id', 'author')]), Query::orderAsc('n.rank')]; + $cursor = $this->database->find('authors', [...$queries, Query::limit(1)])[0]; + $cursor->removeAttribute('n.rank'); + $this->assertNotNull($cursor->getAttribute('rank'), 'the main document\'s attribute of the same name is there to fall back to'); + + $this->expectException(OrderException::class); + $this->expectExceptionMessage("Cursor has no value for order attribute 'n.rank'"); + + $this->database->find('authors', [...$queries, Query::cursorAfter($cursor)]); + } + + /** + * @return iterable, list}> + */ + public static function matchedJoins(): iterable + { + $inner = ['a1/n1', 'a1/n2', 'a1/n3', 'a2/n4', 'a2/n6']; + $orders = [ + 'joined ascending' => [Query::orderAsc('n.rank')], + 'joined descending' => [Query::orderDesc('n.rank')], + 'main attribute' => [Query::orderAsc('rank')], + 'main $id' => [Query::orderDesc('$id')], + 'default order' => [], + ]; + + foreach ([ + 'inner join' => [Method::Join, $inner], + 'left join' => [Method::LeftJoin, [...$inner, 'a3/-']], + ] as $joinName => [$join, $rows]) { + foreach ($orders as $orderName => $order) { + yield "{$joinName}, {$orderName}" => [$join, $order, $rows]; + } + } + } + + /** + * @param list $order + * @param list $rows + */ + #[DataProvider('matchedJoins')] + public function testCursorPagingOverAOneToManyJoinReturnsEveryJoinedRowOnce(Method $join, array $order, array $rows): void + { + $this->assertPagesEveryRowOnce([new Query($join, 'notes', [Query::on('$id', 'author')], 'n'), ...$order], $rows); + } + + public function testCursorFromAnotherJoinShapeIsRefusedByTheJoinedIdItLacks(): void + { + $read = [Query::join('notes', 'n', [Query::on('$id', 'author')]), Query::orderAsc('rank')]; + $foreign = $this->database->find('authors', [Query::join('notes', 'other', [Query::on('$id', 'author')]), Query::orderAsc('rank'), Query::limit(1)])[0]; + + foreach ([$foreign, $this->database->getDocument('authors', 'a1')] as $cursor) { + try { + $this->database->find('authors', [...$read, Query::cursorAfter($cursor)]); + $this->fail('A cursor that does not name a row of this join is refused'); + } catch (OrderException $exception) { + $this->assertStringContainsString("Cursor has no value for order attribute 'n.\$id'", $exception->getMessage()); + $this->assertSame('n.$id', $exception->getAttribute()); + } + } + } + + public function testJoinedCursorOnTheLastRowReturnsNothing(): void + { + $queries = [Query::join('notes', 'n', [Query::on('$id', 'author')]), Query::orderAsc('n.rank')]; + $rows = $this->database->find('authors', $queries); + + $this->assertSame([], $this->database->find('authors', [...$queries, Query::cursorAfter($rows[\count($rows) - 1])])); + $this->assertSame([], $this->database->find('authors', [...$queries, Query::cursorBefore($rows[0])])); + } + + /** + * Every row of the unpaged read, then: after and before each row the rest of the read in order, and a walk in + * pages of two in both directions that returns each row exactly once. + * + * @param list $queries + * @param list $expected + */ + private function assertPagesEveryRowOnce(array $queries, array $expected): void + { + $all = \array_values($this->database->find('authors', [...$queries, Query::limit(100)])); + $keys = \array_map($this->key(...), $all); + $sorted = $keys; + \sort($sorted); + \sort($expected); + $this->assertSame($expected, $sorted, 'the unpaged read returns each joined row once'); + + foreach ($all as $index => $row) { + $this->assertSame(\array_slice($keys, $index + 1), $this->keys([...$queries, Query::cursorAfter($row)]), "after {$keys[$index]}"); + $this->assertSame(\array_slice($keys, 0, $index), $this->keys([...$queries, Query::cursorBefore($row)]), "before {$keys[$index]}"); + } + + $forward = []; + $cursor = null; + for ($page = 0; $page <= \count($all); $page++) { + $rows = $this->database->find('authors', [...$queries, Query::limit(2), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + \array_push($forward, ...\array_map($this->key(...), $rows)); + if (\count($rows) < 2) { + break; + } + $cursor = $rows[1]; + } + $this->assertSame($keys, $forward, 'paging forward in pages of two'); + + $backward = []; + $cursor = $all[\count($all) - 1]; + for ($page = 0; $page <= \count($all); $page++) { + $rows = $this->database->find('authors', [...$queries, Query::limit(2), Query::cursorBefore($cursor)]); + $backward = [...\array_map($this->key(...), $rows), ...$backward]; + if (\count($rows) < 2) { + break; + } + $cursor = $rows[0]; + } + $this->assertSame(\array_slice($keys, 0, -1), $backward, 'paging backward in pages of two from the last row'); + } + + /** + * @param list $queries + * @return list + */ + private function keys(array $queries): array + { + return \array_values(\array_map($this->key(...), $this->database->find('authors', [...$queries, Query::limit(100)]))); + } + + private function key(Document $row, string $alias = 'n'): string + { + $joined = $row->getAttribute($alias.'.$id'); + + return ($row->getId() === '' ? '-' : $row->getId()).'/'.(\is_string($joined) ? $joined : '-'); + } + + /** + * @return iterable, list}> + */ + public static function outerJoins(): iterable + { + $inner = ['a1/n1', 'a1/n2', 'a1/n3', 'a2/n4', 'a2/n6']; + $orders = [ + 'joined ascending' => [Query::orderAsc('n.rank')], + 'joined descending' => [Query::orderDesc('n.rank')], + 'main attribute' => [Query::orderAsc('rank')], + 'default order' => [], + ]; + + foreach ([ + 'right join' => [Method::RightJoin, false, [...$inner, '-/n5']], + 'emulated full outer join' => [Method::FullOuterJoin, false, [...$inner, 'a3/-', '-/n5']], + 'native full outer join' => [Method::FullOuterJoin, true, [...$inner, 'a3/-', '-/n5']], + ] as $joinName => [$join, $native, $rows]) { + foreach ($orders as $orderName => $order) { + yield "{$joinName}, {$orderName}" => [$join, $native, $order, $rows]; + } + } + } + + /** + * @param list $order + * @param list $rows + */ + #[DataProvider('outerJoins')] + public function testCursorPagingOverAnOuterJoinPassesRowsWithoutAMainDocument(Method $join, bool $native, array $order, array $rows): void + { + if ($native) { + $this->useDatabase(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + + $this->assertPagesEveryRowOnce([new Query($join, 'notes', [Query::on('$id', 'author')], 'n'), ...$order], $rows); + } + + public function testPlainReadRefusesACursorWithoutAnIdAsBefore(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Invalid cursor: UID must contain at most'); + + $this->database->find('authors', [Query::cursorAfter(new Document(['$collection' => 'authors', 'name' => 'a1', 'rank' => 1]))]); + } + + /** + * @return iterable, string, list}> + */ + public static function distinctReads(): iterable + { + yield 'distinct read' => ['notes', [Query::distinct(), Query::select(['label']), Query::orderAsc('label')], 'label', ['x', 'y', 'z']]; + yield 'distinct read, descending' => ['notes', [Query::distinct(), Query::select(['label']), Query::orderDesc('label')], 'label', ['z', 'y', 'x']]; + yield 'distinct read over a join' => [ + 'authors', + [Query::join('notes', 'n', [Query::on('$id', 'author')]), Query::distinct(), Query::select(['n.label']), Query::orderAsc('n.label')], + 'n.label', + ['x', 'y'], + ]; + yield 'distinct read over a left join, nulls included' => [ + 'authors', + [Query::leftJoin('notes', 'n', [Query::on('$id', 'author')]), Query::distinct(), Query::select(['n.rank']), Query::orderAsc('n.rank')], + 'n.rank', + [null, 1, 2], + ]; + } + + /** + * @param list $queries + * @param list $values + */ + #[DataProvider('distinctReads')] + public function testCursorPagingOverADistinctReadReachesTheEnd(string $collection, array $queries, string $attribute, array $values): void + { + $paged = []; + $cursor = null; + for ($page = 0; $page <= \count($values); $page++) { + $rows = $this->database->find($collection, [...$queries, Query::limit(1), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + if ($rows === []) { + break; + } + $paged[] = $rows[0]->getAttribute($attribute); + $cursor = $rows[0]; + } + + $this->assertSame($values, $paged); + $this->assertNotNull($cursor); + $this->assertSame(\array_slice($values, 0, -1), \array_map( + static fn (Document $row): mixed => $row->getAttribute($attribute), + $this->database->find($collection, [...$queries, Query::cursorBefore($cursor)]), + )); + } + + public function testIterateOverADistinctReadReachesTheEnd(): void + { + $labels = []; + foreach ($this->database->cursor('notes', [Query::distinct(), Query::select(['label']), Query::orderAsc('label')], batchSize: 1) as $row) { + $labels[] = $row->getAttribute('label'); + if (\count($labels) > 3) { + break; + } + } + + $this->assertSame(['x', 'y', 'z'], $labels); + } + + public function testDistinctCursorNeedsAnOrderOnEverySelectedAttribute(): void + { + $queries = [Query::distinct(), Query::select(['label', 'rank']), Query::orderAsc('label')]; + $cursor = $this->database->find('notes', [...$queries, Query::limit(1)])[0]; + + try { + $this->database->find('notes', [...$queries, Query::cursorAfter($cursor)]); + $this->fail('A distinct read whose order does not name every selected attribute cannot be paged'); + } catch (QueryException $exception) { + $this->assertStringContainsString("'rank'", $exception->getMessage()); + } + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('A cursor on a distinct() read pages along its orders'); + + $this->database->find('notes', [Query::distinct(), Query::select(['label']), Query::cursorAfter($this->database->getDocument('notes', 'n1'))]); + } + + /** + * @return iterable, ?string, list}> + */ + public static function cursorBatches(): iterable + { + yield 'no caller page' => [[], null, ['i01', 'i02', 'i03', 'i04', 'i05', 'i06', 'i07', 'i08', 'i09', 'i10']]; + yield 'an offset applies once' => [[Query::offset(2)], null, ['i03', 'i04', 'i05', 'i06', 'i07', 'i08', 'i09', 'i10']]; + yield 'a cursor starts the iteration, which then ends' => [[], 'i04', ['i05', 'i06', 'i07', 'i08', 'i09', 'i10']]; + yield 'a limit caps the iteration' => [[Query::limit(4)], null, ['i01', 'i02', 'i03', 'i04']]; + yield 'a limit and an offset' => [[Query::offset(5), Query::limit(4)], null, ['i06', 'i07', 'i08', 'i09']]; + yield 'a limit beyond the matches' => [[Query::limit(40)], null, ['i01', 'i02', 'i03', 'i04', 'i05', 'i06', 'i07', 'i08', 'i09', 'i10']]; + yield 'a descending order and an offset' => [[Query::orderDesc('$id'), Query::offset(1)], null, ['i09', 'i08', 'i07', 'i06', 'i05', 'i04', 'i03', 'i02', 'i01']]; + } + + /** + * @param list $queries + * @param list $expected + */ + #[DataProvider('cursorBatches')] + public function testCursorBuildsEachBatchFromTheCallerQueries(array $queries, ?string $after, array $expected): void + { + $this->createItems(); + if ($after !== null) { + $queries[] = Query::cursorAfter($this->database->getDocument('items', $after)); + } + + foreach ([1, 3, 4, 100] as $batchSize) { + $ids = []; + foreach ($this->database->cursor('items', $queries, $batchSize) as $item) { + $ids[] = $item->getId(); + if (\count($ids) > 20) { + break; + } + } + + $this->assertSame($expected, $ids, "batches of {$batchSize}"); + } + } + + /** + * @return iterable, string}> + */ + public static function unpageableJoinedReads(): iterable + { + foreach (['inner join' => Method::Join, 'left join' => Method::LeftJoin] as $joinName => $join) { + yield "{$joinName}, joined id not selected" => [$join, [Query::select(['name', 'n.rank'])], 'n.$id']; + yield "{$joinName}, joined order not selected" => [$join, [Query::select(['name', 'n.$id']), Query::orderAsc('n.rank')], 'n.rank']; + } + } + + /** + * @param list $queries + */ + #[DataProvider('unpageableJoinedReads')] + public function testPagingAJoinedReadItCannotPageFailsBeforeYieldingARow(Method $join, array $queries, string $missing): void + { + $queries = [new Query($join, 'notes', [Query::on('$id', 'author')], 'n'), ...$queries]; + $rows = $this->database->cursor('authors', $queries, 2); + /** @var int $yielded */ + $yielded = 0; + + try { + foreach ($rows as $row) { + $yielded++; + } + $this->fail('A read whose rows lack a value its next page orders by cannot be paged'); + } catch (OrderException $exception) { + $this->assertStringContainsString("Cursor has no value for order attribute '{$missing}'", $exception->getMessage()); + } + + $this->assertSame(0, $yielded, 'The read must be refused before the caller acts on any of its rows'); + } + + public function testPagingAJoinedReadThatFitsOnePageNeedsNoPagingValue(): void + { + $rows = \iterator_to_array($this->database->cursor('authors', [Query::join('notes', 'n', [Query::on('$id', 'author')]), Query::select(['name', 'n.rank'])], 10), false); + + $this->assertCount(5, $rows); + } + + public function testCursorRefusesCursorBefore(): void + { + $this->createItems(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cursor before not supported in this method.'); + + \iterator_to_array($this->database->cursor('items', [Query::cursorBefore($this->database->getDocument('items', 'i04'))])); + } + + private function createItems(): void + { + $this->database->createCollection(Collection::create( + id: 'items', + attributes: [Attribute::string(key: 'name', size: 16)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + for ($number = 1; $number <= 10; $number++) { + $id = \sprintf('i%02d', $number); + $this->createDocument('items', $id, ['name' => $id]); + } + } + + /** + * @return iterable + */ + public static function getDocumentJoins(): iterable + { + yield 'inner join' => [Method::Join]; + yield 'left join' => [Method::LeftJoin]; + yield 'right join' => [Method::RightJoin]; + yield 'full outer join' => [Method::FullOuterJoin]; + } + + #[DataProvider('getDocumentJoins')] + public function testJoinedGetDocumentPairsTheLowestSequenceJoinedRow(Method $join): void + { + $this->database->createCollection(Collection::create( + id: 'drafts', + attributes: [Attribute::string(key: 'author', size: 16), Attribute::string(key: 'label', size: 16)], + indexes: [Index::key('author_label', ['author', 'label'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + foreach (['d-first' => 'z', 'd-second' => 'm', 'd-third' => 'a'] as $id => $label) { + $this->createDocument('drafts', $id, ['author' => 'a1', 'label' => $label]); + } + + $document = $this->database->getDocument('authors', 'a1', [new Query($join, 'drafts', [Query::on('$id', 'author')], 'd')]); + + $this->assertSame('d-first', $document->getAttribute('d.$id')); + $this->assertSame('z', $document->getAttribute('d.label')); + } + + /** + * @return iterable, OrderDirection, list}> + */ + public static function unlimitedOffsets(): iterable + { + yield 'fast path' => [[], OrderDirection::Asc, ['i08', 'i09', 'i10']]; + yield 'builder path' => [[Query::notEqual('name', 'none')], OrderDirection::Asc, ['i08', 'i09', 'i10']]; + yield 'builder path, descending' => [[], OrderDirection::Desc, ['i03', 'i02', 'i01']]; + } + + /** + * @param list $queries + * @param list $expected + */ + #[DataProvider('unlimitedOffsets')] + public function testAdapterFindWithAnOffsetAndNoLimitReturnsTheRowsAfterIt(array $queries, OrderDirection $direction, array $expected): void + { + $this->createItems(); + $collection = $this->database->getCollection('items'); + + /** @var list $rows */ + $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->getAdapter()->find( + $collection, + $queries, + limit: null, + offset: 7, + orderAttributes: ['$sequence'], + orderTypes: [$direction], + )); + + $this->assertSame($expected, \array_map(static fn (Document $row): string => $row->getId(), $rows)); + } + + /** + * @return iterable + */ + public static function joinTieKeys(): iterable + { + yield 'join on the joined $id' => [Query::join('authors', 'a', [Query::on('author', '$id')]), false]; + yield 'left join on the joined $id, qualified' => [Query::leftJoin('authors', 'a', [Query::on('author', 'a.$id')]), false]; + yield 'join on another joined attribute' => [Query::join('authors', 'a', [Query::on('author', 'name')]), true]; + yield 'right join on the joined $id' => [Query::rightJoin('authors', 'a', [Query::on('author', '$id')]), true]; + yield 'full outer join on the joined $id' => [Query::fullOuterJoin('authors', 'a', [Query::on('author', '$id')]), true]; + yield 'join on the joined $id with another operator' => [Query::join('authors', 'a', [Query::on('author', '$id', '!=')]), true]; + } + + #[DataProvider('joinTieKeys')] + public function testJoinedIdBreaksTiesOnlyWhenAJoinCanPairSeveralRows(Query $join, bool $ordersByJoinedId): void + { + $this->database->setProfiling(true); + $this->database->getProfiler()?->reset(); + + $rows = $this->database->find('notes', [$join, Query::orderAsc('label')]); + + $selects = \array_values(\array_filter( + $this->database->getProfiler()?->getLogs() ?? [], + static fn (Log $log): bool => \str_starts_with($log->query, 'SELECT') && \str_contains($log->query, 'ORDER BY'), + )); + $this->assertNotSame([], $selects); + $query = $selects[\count($selects) - 1]->query; + $order = \substr($query, (int) \strrpos($query, 'ORDER BY')); + $this->assertCount($ordersByJoinedId ? 3 : 2, \explode(',', $order), 'label, the main $sequence and, only when the join can pair several rows, the joined $id: '.$order); + + $keys = \array_map(fn (Document $row): string => $this->key($row, 'a'), $rows); + $paged = []; + $cursor = null; + for ($page = 0; $page <= \count($rows); $page++) { + $batch = $this->database->find('notes', [$join, Query::orderAsc('label'), Query::limit(1), ...($cursor === null ? [] : [Query::cursorAfter($cursor)])]); + if ($batch === []) { + break; + } + $paged[] = $this->key($batch[0], 'a'); + $cursor = $batch[0]; + } + $this->assertSame($keys, $paged); + } + + /** + * @return iterable, list}> + */ + public static function joinedTieKeysBySelection(): iterable + { + foreach (['inner join' => Method::Join, 'left join' => Method::LeftJoin] as $joinName => $join) { + yield "{$joinName}, no select" => [$join, [], ['_id', 'n._uid']]; + yield "{$joinName}, select of everything" => [$join, [Query::select(['*'])], ['_id', 'n._uid']]; + yield "{$joinName}, select of a joined attribute" => [$join, [Query::select(['name', 'n.label'])], ['_id', 'n._uid']]; + yield "{$joinName}, select of every joined attribute" => [$join, [Query::select(['name', 'n.*'])], ['_id', 'n._uid']]; + yield "{$joinName}, select of main attributes" => [$join, [Query::select(['name', '$id', '$sequence'])], ['_id']]; + yield "{$joinName}, select of main attributes, ordered by a joined one" => [$join, [Query::select(['name']), Query::orderAsc('n.rank')], ['n.rank', '_id']]; + } + } + + /** + * @param list $queries + * @param list $columns + */ + #[DataProvider('joinedTieKeysBySelection')] + public function testJoinedIdBreaksTiesOnlyWhereTheRowsShowTheJoin(Method $join, array $queries, array $columns): void + { + $queries = [new Query($join, 'notes', [Query::on('$id', 'author')], 'n'), ...$queries]; + + $this->database->setProfiling(true); + $this->database->getProfiler()?->reset(); + $rows = $this->database->find('authors', $queries); + + $this->assertSame($columns, $this->orderedColumns()); + + $paged = []; + for ($offset = 0; $offset < \count($rows); $offset++) { + \array_push($paged, ...$this->database->find('authors', [...$queries, Query::limit(1), Query::offset($offset)])); + } + $this->assertSame( + \array_map(static fn (Document $row): array => $row->getArrayCopy(), $rows), + \array_map(static fn (Document $row): array => $row->getArrayCopy(), $paged), + 'Paging by offset returns the rows of the read at once, in its order', + ); + } + + public function testCursorFromARowThatShowsNoJoinedIdIsRefusedAsBefore(): void + { + $queries = [Query::leftJoin('notes', 'n', [Query::on('$id', 'author')]), Query::select(['name'])]; + $cursor = $this->database->find('authors', [...$queries, Query::limit(1)])[0]; + + $this->database->setProfiling(true); + $this->database->getProfiler()?->reset(); + + try { + $this->database->find('authors', [...$queries, Query::cursorAfter($cursor)]); + $this->fail('A row without the joined $id cannot name a joined row'); + } catch (OrderException $exception) { + $this->assertSame('n.$id', $exception->getAttribute()); + } + } + + /** + * The columns of the last read's ORDER BY, unquoted, without their direction. + * + * @return list + */ + private function orderedColumns(): array + { + $selects = \array_values(\array_filter( + $this->database->getProfiler()?->getLogs() ?? [], + static fn (Log $log): bool => \str_starts_with($log->query, 'SELECT') && \str_contains($log->query, 'ORDER BY'), + )); + $this->assertNotSame([], $selects); + $query = $selects[\count($selects) - 1]->query; + $order = \substr($query, (int) \strrpos($query, 'ORDER BY') + \strlen('ORDER BY ')); + $order = \explode(' LIMIT ', $order)[0]; + + return \array_map( + static fn (string $column): string => \preg_replace('/^table_main\\.| (ASC|DESC)$/', '', \str_replace(['`', '"'], '', \trim($column))) ?? $column, + \explode(',', $order), + ); + } + + private function useDatabase(SQLite $adapter): void + { + $this->database = new Database($adapter, new Cache(new NoCache())); + $this->database + ->setDatabase('join_cursor') + ->setNamespace('join_cursor_'.\uniqid()) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + + $this->database->createCollection(Collection::create( + id: 'authors', + attributes: [Attribute::string(key: 'name', size: 16), Attribute::integer(key: 'rank', required: false)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $this->database->createCollection(Collection::create( + id: 'notes', + attributes: [ + Attribute::string(key: 'author', size: 16), + Attribute::integer(key: 'rank', required: false), + Attribute::string(key: 'label', size: 16), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + foreach (['a1' => 1, 'a2' => 2, 'a3' => 3] as $id => $rank) { + $this->createDocument('authors', $id, ['name' => $id, 'rank' => $rank]); + } + + foreach ([ + 'n1' => ['a1', 1, 'x'], + 'n2' => ['a1', 1, 'x'], + 'n3' => ['a1', 2, 'y'], + 'n4' => ['a2', 1, 'y'], + 'n5' => ['zz', 9, 'z'], + 'n6' => ['a2', null, 'x'], + ] as $id => [$author, $rank, $label]) { + $this->createDocument('notes', $id, ['author' => $author, 'rank' => $rank, 'label' => $label]); + } + } + + /** + * @param array $attributes + */ + private function createDocument(string $collection, string $id, array $attributes): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Joins/JoinInternalColumnsTest.php b/tests/unit/Joins/JoinInternalColumnsTest.php new file mode 100644 index 0000000000..df277e73df --- /dev/null +++ b/tests/unit/Joins/JoinInternalColumnsTest.php @@ -0,0 +1,432 @@ +useDatabase(new SQLite(new PDO('sqlite::memory:'))); + } + + /** + * Each join of the customers to their notes, with the number of rows it returns: c3 has no note + * and n4 no customer. + * + * @return iterable + */ + public static function joins(): iterable + { + yield 'inner join' => [Method::Join, false, 3]; + yield 'left join' => [Method::LeftJoin, false, 4]; + yield 'right join' => [Method::RightJoin, false, 4]; + yield 'emulated full outer join' => [Method::FullOuterJoin, false, 5]; + yield 'native full outer join' => [Method::FullOuterJoin, true, 5]; + } + + /** + * @return iterable + */ + public static function internalAttributesUnderEveryJoin(): iterable + { + foreach (self::joins() as $label => [$join, $native, $rows]) { + foreach ([Document::ID, Document::SEQUENCE, Document::CREATED_AT, Document::UPDATED_AT, Document::PERMISSIONS] as $attribute) { + yield $attribute.' over a '.$label => [$join, $native, $rows, $attribute]; + } + } + } + + #[DataProvider('internalAttributesUnderEveryJoin')] + public function testInternalAttributeUnderAnAliasGroupsTheJoinedRows(Method $join, bool $native, int $rows, string $attribute): void + { + if ($native) { + $this->useDatabase(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + + $groups = $this->database->aggregate('customers', [ + $this->join($join), + Query::count('*', 'rows'), + Query::groupBy(['note.'.$attribute]), + ]); + + $total = 0; + foreach ($groups as $group) { + $this->assertArrayHasKey(Storage::column($attribute), $group, 'a group comes back under its column, as on the main collection'); + $count = $group['rows']; + $this->assertIsInt($count); + $total += $count; + } + $this->assertSame($rows, $total); + } + + /** + * @return iterable}> + */ + public static function joinedIdGroups(): iterable + { + yield 'inner join' => [Method::Join, false, ['n1', 'n2', 'n3']]; + yield 'left join' => [Method::LeftJoin, false, [null, 'n1', 'n2', 'n3']]; + yield 'right join' => [Method::RightJoin, false, ['n1', 'n2', 'n3', 'n4']]; + yield 'emulated full outer join' => [Method::FullOuterJoin, false, [null, 'n1', 'n2', 'n3', 'n4']]; + yield 'native full outer join' => [Method::FullOuterJoin, true, [null, 'n1', 'n2', 'n3', 'n4']]; + } + + /** + * @param list $expected + */ + #[DataProvider('joinedIdGroups')] + public function testJoinedIdGroupsOneRowPerJoinedDocument(Method $join, bool $native, array $expected): void + { + if ($native) { + $this->useDatabase(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + + $groups = $this->database->aggregate('customers', [ + $this->join($join), + Query::count('*', 'rows'), + Query::groupBy(['note.$id']), + Query::orderAsc('note.$id'), + ]); + + $this->assertSame($expected, \array_map(static fn (array $group): mixed => $group[Storage::UID], $groups)); + } + + public function testCollectionIsNeitherAggregatedNorGrouped(): void + { + foreach ([ + 'count' => [Query::count('$collection', 'total')], + 'countDistinct' => [Query::countDistinct('$collection', 'total')], + 'groupBy' => [Query::count('*', 'rows'), Query::groupBy(['$collection'])], + 'groupBy over a join' => [$this->join(Method::Join), Query::count('*', 'rows'), Query::groupBy(['$collection'])], + ] as $label => $queries) { + $this->assertInvalidQuery('Invalid query: Attribute not found in schema: $collection', fn (): mixed => $this->database->find('customers', $queries), $label); + } + + $customers = $this->database->find('customers', [Query::select(['name', '$collection']), Query::orderAsc('name')]); + $this->assertSame(['customers', 'customers', 'customers'], \array_map(static fn (Document $customer): string => $customer->getCollection(), $customers), 'a read still derives $collection'); + } + + public function testTenantIsRejectedWithoutSharedTables(): void + { + $note = $this->join(Method::Join); + + foreach ([ + 'count' => [[Query::count('$tenant', 'total')], '$tenant'], + 'groupBy' => [[Query::count('*', 'rows'), Query::groupBy(['$tenant'])], '$tenant'], + 'select' => [[Query::select(['name', '$tenant'])], '$tenant'], + 'joined count' => [[$note, Query::count('note.$tenant', 'total')], 'note.$tenant'], + 'joined groupBy' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.$tenant'])], 'note.$tenant'], + 'joined select' => [[$note, Query::select(['name', 'note.$tenant'])], 'note.$tenant'], + ] as $label => [$queries, $attribute]) { + $this->assertInvalidQuery('Invalid query: Attribute not found in schema: '.$attribute, fn (): mixed => $this->database->find('customers', $queries), $label); + } + + $this->assertInvalidQuery( + 'Invalid query: Attribute not found in schema: note.$tenant', + fn (): mixed => $this->database->getDocument('customers', 'c1', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::select(['name', 'note.$tenant']), + ]), + ); + } + + public function testTenantIsReadUnderSharedTables(): void + { + $this->useDatabase(new SQLite(new PDO('sqlite::memory:')), sharedTables: true); + $note = $this->join(Method::Join); + + $this->assertSame([['total' => 3]], $this->database->aggregate('customers', [Query::count('$tenant', 'total')])); + $this->assertSame([['rows' => 3, Storage::TENANT => 1]], $this->database->aggregate('customers', [Query::count('*', 'rows'), Query::groupBy(['$tenant'])])); + $this->assertSame([['total' => 3]], $this->database->aggregate('customers', [$note, Query::count('note.$tenant', 'total')])); + $this->assertSame([['rows' => 3, Storage::TENANT => 1]], $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::groupBy(['note.$tenant'])])); + + $customers = $this->database->find('customers', [$note, Query::select(['name', 'note.$tenant']), Query::orderAsc('note.$id')]); + $this->assertSame([self::TENANT, self::TENANT, self::TENANT], \array_map(static fn (Document $customer): mixed => $customer->getAttribute('note.$tenant'), $customers)); + } + + /** + * @return iterable, string}> + */ + public static function joinConditionsNamingNoColumn(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + $notFound = 'Invalid query: Attribute not found in schema: '; + + yield 'an unknown right column' => [[Query::join('notes', 'note', [Query::on('$id', 'nothing')])], $notFound.'nothing']; + yield 'an unknown left column' => [[Query::join('notes', 'note', [Query::on('nothing', 'customerId')])], $notFound.'nothing']; + yield 'an unknown right column of an on condition' => [[Query::leftJoin('notes', 'note', [Query::on('$id', 'nothing')])], $notFound.'nothing']; + yield 'an unknown left column of an on condition' => [[Query::leftJoin('notes', 'note', [Query::on('nothing', 'customerId')])], $notFound.'nothing']; + yield 'an unknown right column under the join alias' => [[Query::leftJoin('notes', 'note', [Query::on('$id', 'note.nothing')])], $notFound.'note.nothing']; + yield 'an unknown column of an earlier join' => [[$note, Query::join('replies', 'reply', [Query::on('note.nothing', 'noteId')])], $notFound.'note.nothing']; + yield 'a main attribute under the join alias' => [[Query::rightJoin('notes', 'note', [Query::on('$id', 'note.name')])], $notFound.'note.name']; + yield 'a join declared after it' => [ + [Query::join('replies', 'reply', [Query::on('note.$id', 'noteId')]), $note], + 'Invalid query: The left column of a join condition must belong to the main collection or to a join declared before it: note.$id', + ]; + } + + /** + * @param list $joins + */ + #[DataProvider('joinConditionsNamingNoColumn')] + public function testJoinConditionNamingNoColumnIsAnInvalidQuery(array $joins, string $message): void + { + $this->assertInvalidQuery($message, fn (): mixed => $this->database->find('customers', $joins), 'find()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->count('customers', $joins), 'count()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->sum('customers', 'score', $joins), 'sum()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->getDocument('customers', 'c1', $joins), 'getDocument()'); + } + + public function testJoinConditionOverColumnsTheTablesHaveRuns(): void + { + $note = $this->join(Method::Join); + + $this->assertSame(3, $this->database->count('customers', [Query::leftJoin('notes', 'note', [Query::on('$id', 'note.customerId')]), Query::isNotNull('note.$id')])); + $this->assertSame(1, $this->database->count('customers', [$note, Query::join('replies', 'reply', [Query::on('note.$id', 'noteId')])]), 'a chained join reads the join before it'); + $this->assertSame(1, $this->database->count('customers', [Query::crossJoin('replies', 'reply'), Query::join('notes', 'note', [Query::on('reply.noteId', '$id')]), Query::equal('note.customerId', ['c1']), Query::equal('$id', ['c1'])]), 'a cross join declares its alias for the joins after it'); + $this->assertSame(3, $this->database->count('customers', [Query::join('notes', 'note', [Query::on('$sequence', '$sequence', '<')]), Query::equal('note.$id', ['n4'])]), 'internal columns are compared on both sides'); + } + + public function testJoinConditionOverARelationshipNamesItsColumn(): void + { + $this->useRelationships(); + + $persons = $this->database->find('persons', [Query::join('libraries', 'lib', [Query::on('library', '$id')]), Query::select(['name', 'lib.name'])]); + $this->assertSame(['Central'], \array_map(static fn (Document $person): mixed => $person->getAttribute('lib.name'), $persons), 'the parent side of a one-to-one relationship holds a column'); + + $this->assertSame([['rows' => 2]], $this->database->aggregate('persons', [Query::join('books', 'book', [Query::on('$id', 'owner')]), Query::count('*', 'rows')]), 'the child side of a one-to-many relationship holds a column'); + $this->assertSame(2, $this->database->count('books', [Query::join('persons', 'person', [Query::on('owner', '$id')])])); + + $this->assertInvalidQuery( + 'Invalid query: Cannot join on virtual relationship attribute: books', + fn (): mixed => $this->database->find('persons', [Query::join('books', 'book', [Query::on('books', '$id')])]), + ); + $this->assertInvalidQuery( + 'Invalid query: Cannot join on virtual relationship attribute: person', + fn (): mixed => $this->database->find('persons', [Query::join('libraries', 'lib', [Query::on('$id', 'person')])]), + ); + } + + /** + * @return iterable + */ + public static function attributesSumCannotAddUp(): iterable + { + $numeric = 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: '; + $notFound = 'Invalid query: Attribute not found in schema: '; + + yield 'an unknown attribute' => ['nothing', false, $notFound.'nothing']; + yield 'a string' => ['name', false, $numeric.'name']; + yield 'an array' => ['tags', false, $numeric.'tags']; + yield 'an internal attribute' => ['$sequence', false, $numeric.'$sequence']; + yield 'a joined string' => ['note.body', true, $numeric.'note.body']; + yield 'a joined array' => ['note.tags', true, $numeric.'note.tags']; + yield 'an unknown joined attribute' => ['note.nothing', true, $notFound.'note.nothing']; + yield 'a string only a join declares, unqualified' => ['body', true, $numeric.'body']; + yield 'an alias no join declares' => ['other.score', true, $notFound.'other.score']; + } + + #[DataProvider('attributesSumCannotAddUp')] + public function testSumRejectsAnAttributeASumAggregateRejects(string $attribute, bool $joined, string $message): void + { + $queries = $joined ? [$this->join(Method::Join)] : []; + + $this->assertInvalidQuery($message, fn (): mixed => $this->database->sum('customers', $attribute, $queries)); + } + + public function testSumAddsUpANumericAttributeOfTheMainOrAJoinedCollection(): void + { + $note = $this->join(Method::Join); + + $this->assertSame(60, $this->database->sum('customers', 'score')); + $this->assertSame(40, $this->database->sum('customers', 'score', [$note]), 'a bare name the join declares too stays on the main collection'); + $this->assertSame(6, $this->database->sum('customers', 'note.score', [$note])); + $this->assertSame(0, $this->database->sum('customers', 'note.score', [$note, Query::equal('note.body', ['none'])])); + } + + public function testEncryptedJoinedAttributeCannotBeFiltered(): void + { + $vault = Query::join('secrets', 'vault', [Query::on('$id', 'holderId')]); + + $this->assertInvalidQuery('Invalid query: Cannot query encrypted attribute: secret', fn (): mixed => $this->database->find('secrets', [Query::equal('secret', ['x'])])); + $this->assertInvalidQuery('Invalid query: Cannot query encrypted attribute: vault.secret', fn (): mixed => $this->database->find('customers', [$vault, Query::equal('vault.secret', ['x'])])); + $this->assertInvalidQuery('Invalid query: Cannot query encrypted attribute: vault.secret', fn (): mixed => $this->database->count('customers', [$vault, Query::isNull('vault.secret')])); + $this->assertInvalidQuery('Invalid query: Cannot query encrypted attribute: vault.secret', fn (): mixed => $this->database->find('customers', [$vault, Query::or([Query::equal('name', ['One']), Query::equal('vault.secret', ['x'])])])); + + $this->assertSame([], $this->database->find('customers', [$vault, Query::equal('vault.holderId', ['c1'])])); + } + + /** + * What a join could already read stays readable: internal attributes under an alias where they + * are valid on the main collection, and every declared joined attribute. + */ + public function testWhatJoinsAlreadyReadStaysReadable(): void + { + $note = $this->join(Method::Join); + + $customers = $this->database->find('customers', [ + $note, + Query::equal('note.$id', ['n1', 'n3']), + Query::between('note.$createdAt', '1970-01-01', '2099-12-31'), + Query::between('note.score', 0, 10), + Query::equal('note.score', [1, 3]), + Query::select(['name', 'note.$id', 'note.$permissions', 'note.$createdAt', 'note.$sequence', 'note.body']), + Query::orderAsc('note.score'), + ]); + $this->assertSame(['n1', 'n3'], \array_map(static fn (Document $customer): mixed => $customer->getAttribute('note.$id'), $customers)); + + $this->assertSame([['notes' => 3, 'total' => 6]], $this->database->aggregate('customers', [$note, Query::count('note.$id', 'notes'), Query::sum('note.score', 'total')])); + $this->assertCount(3, $this->database->aggregate('customers', [$note, Query::count('*', 'rows'), Query::groupBy(['note.body'])])); + } + + private function join(Method $method): Query + { + return new Query($method, 'notes', [Query::on('$id', 'customerId')], 'note'); + } + + /** + * @param Closure(): mixed $read + */ + private function assertInvalidQuery(string $message, Closure $read, string $label = ''): void + { + $prefix = $label === '' ? '' : $label.': '; + + try { + $read(); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage(), $prefix.'the rejection names the shape'); + + return; + } + + $this->fail($prefix.'the shape was accepted: '.$message); + } + + private function useDatabase(SQLite $adapter, bool $sharedTables = false): void + { + $this->database = new Database($adapter, new Cache(new NoCache())); + $this->database + ->setDatabase('join_internal_columns') + ->setNamespace('join_internal_columns_'.\uniqid()) + ->setAuthorization(new Authorization()); + if ($sharedTables) { + $this->database->setSharedTables(true)->setTenant(null); + } + $this->database->addHook(new Permissions()); + $this->database->create(); + + $this->createCollection('customers', [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'score'), + Attribute::string(key: 'tags', size: 16, array: true), + ]); + $this->createCollection('notes', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::string(key: 'body', size: 256), + Attribute::integer(key: 'score'), + Attribute::string(key: 'tags', size: 16, array: true), + ]); + $this->createCollection('replies', [ + Attribute::string(key: 'noteId', size: 64), + Attribute::string(key: 'text', size: 256), + ]); + $this->createCollection('secrets', [ + Attribute::string(key: 'holderId', size: 64), + Attribute::string(key: 'secret', size: 64, filters: ['encrypt']), + ]); + + if ($sharedTables) { + $this->database->setTenant(self::TENANT); + } + + $this->createDocument('customers', 'c1', ['name' => 'One', 'score' => 10, 'tags' => ['a']]); + $this->createDocument('customers', 'c2', ['name' => 'Two', 'score' => 20, 'tags' => ['b']]); + $this->createDocument('customers', 'c3', ['name' => 'Three', 'score' => 30, 'tags' => []]); + $this->createDocument('notes', 'n1', ['customerId' => 'c1', 'body' => 'first', 'score' => 1, 'tags' => ['x']]); + $this->createDocument('notes', 'n2', ['customerId' => 'c1', 'body' => 'second', 'score' => 2, 'tags' => []]); + $this->createDocument('notes', 'n3', ['customerId' => 'c2', 'body' => 'third', 'score' => 3, 'tags' => []]); + $this->createDocument('notes', 'n4', ['customerId' => 'ghost', 'body' => 'stray', 'score' => 4, 'tags' => []]); + $this->createDocument('replies', 'r1', ['noteId' => 'n1', 'text' => 'thanks']); + } + + /** + * persons.library is the parent side of a one-to-one relationship, so it holds a column and + * libraries.person does not; books.owner is the child side of a one-to-many relationship, so it + * holds a column and persons.books does not. + */ + private function useRelationships(): void + { + $this->database->addHook(new Relationships()); + + $this->createCollection('libraries', [Attribute::string(key: 'name', size: 64)]); + $this->createCollection('persons', [Attribute::string(key: 'name', size: 64)]); + $this->createCollection('books', [Attribute::string(key: 'title', size: 64)]); + $this->database->createRelationship('persons', Relationship::oneToOne(relatedCollection: 'libraries', key: 'library', twoWayKey: 'person')); + $this->database->createRelationship('persons', Relationship::oneToMany(relatedCollection: 'books', key: 'books', twoWayKey: 'owner')); + + $this->createDocument('libraries', 'central', ['name' => 'Central']); + $this->createDocument('persons', 'ada', ['name' => 'Ada', 'library' => 'central']); + $this->createDocument('persons', 'bob', ['name' => 'Bob']); + $this->createDocument('books', 'b1', ['title' => 'One', 'owner' => 'ada']); + $this->createDocument('books', 'b2', ['title' => 'Two', 'owner' => 'ada']); + } + + /** + * @param list $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + } + + /** + * @param array $attributes + */ + private function createDocument(string $collection, string $id, array $attributes): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Joins/JoinResolutionTest.php b/tests/unit/Joins/JoinResolutionTest.php new file mode 100644 index 0000000000..eb9306f8c6 --- /dev/null +++ b/tests/unit/Joins/JoinResolutionTest.php @@ -0,0 +1,398 @@ +database = $this->database(new Cache(new None())); + } + + /** + * @return iterable + */ + public static function joinedFilters(): iterable + { + yield 'containsAny on a joined array' => [Query::containsAny('th.tags', ['a']), Query::containsAny('tags', ['a'])]; + yield 'contains on a joined array' => [Query::contains('th.tags', ['a']), Query::contains('tags', ['a'])]; + yield 'containsAll on a joined array' => [Query::containsAll('th.tags', ['a', 'b']), Query::containsAll('tags', ['a', 'b'])]; + yield 'notContains on a joined array' => [Query::notContains('th.tags', ['a']), Query::notContains('tags', ['a'])]; + yield 'greaterThan on a joined datetime with an offset' => [Query::greaterThan('th.when', self::LATER_THAN_EIGHT_UTC), Query::greaterThan('when', self::LATER_THAN_EIGHT_UTC)]; + yield 'equal on a joined datetime in UTC' => [Query::equal('th.when', ['2024-01-01T09:00:00.000+00:00']), Query::equal('when', ['2024-01-01T09:00:00.000+00:00'])]; + yield 'equal on a joined datetime with an offset' => [Query::equal('th.when', ['2024-01-01T11:00:00.000+02:00']), Query::equal('when', ['2024-01-01T11:00:00.000+02:00'])]; + yield 'a joined filter inside or()' => [ + Query::or([Query::containsAny('th.tags', ['c']), Query::lessThan('th.when', '2024-01-01T09:30:00.000+02:00')]), + Query::or([Query::containsAny('tags', ['c']), Query::lessThan('when', '2024-01-01T09:30:00.000+02:00')]), + ]; + } + + /** + * The joined filters a join's ON list holds: every one but containsAll. + * + * @return iterable + */ + public static function joinConditionFilters(): iterable + { + foreach (self::joinedFilters() as $name => $filters) { + if ($filters[0]->getMethod() !== Method::ContainsAll) { + yield $name => $filters; + } + } + } + + #[DataProvider('joinedFilters')] + public function testAJoinedFilterMatchesWhatTheSameFilterMatchesOnTheJoinedCollection(Query $joined, Query $direct): void + { + $expected = $this->ticketsOfThemes($this->database->find('themes', [$direct])); + $this->assertNotSame([], $expected, 'the fixture has to match some rows'); + $this->assertNotSame(['k1', 'k2', 'k3', 'k4'], $expected, 'the fixture has to leave some rows out'); + + $join = Query::join('themes', 'th', [Query::on('theme', '$id')]); + + $this->assertSame($expected, $this->ids($this->database->find('tickets', [$join, $joined])), 'find()'); + $this->assertSame(\count($expected), $this->database->count('tickets', [$join, $joined]), 'count()'); + $this->assertSame($this->amounts($expected), $this->database->sum('tickets', 'amount', [$join, $joined]), 'sum()'); + } + + #[DataProvider('joinConditionFilters')] + public function testAJoinedFilterInAJoinConditionMatchesWhatItMatchesOnTheJoinedCollection(Query $joined, Query $direct): void + { + $expected = $this->ticketsOfThemes($this->database->find('themes', [$direct])); + $join = Query::join('themes', 'th', [Query::on('theme', '$id'), $joined]); + + $this->assertSame($expected, $this->ids($this->database->find('tickets', [$join])), 'find()'); + $this->assertSame(\count($expected), $this->database->count('tickets', [$join]), 'count()'); + $this->assertSame($this->amounts($expected), $this->database->sum('tickets', 'amount', [$join]), 'sum()'); + + foreach (['k1', 'k2', 'k3', 'k4'] as $ticket) { + $this->assertSame( + \in_array($ticket, $expected, true), + ! $this->database->getDocument('tickets', $ticket, [$join])->isEmpty(), + 'getDocument('.$ticket.')', + ); + } + } + + public function testAJoinedCreatedAtWithAnOffsetMatchesWhatItMatchesOnTheJoinedCollection(): void + { + $created = $this->database->getDocument('themes', 't2')->getCreatedAt(); + $this->assertIsString($created); + $sameInstantElsewhere = (new \DateTimeImmutable($created)) + ->setTimezone(new \DateTimeZone('+14:00')) + ->format('Y-m-d\TH:i:s.vP'); + + $expected = $this->ticketsOfThemes($this->database->find('themes', [Query::equal('$createdAt', [$sameInstantElsewhere])])); + $this->assertContains('k2', $expected); + + $this->assertSame($expected, $this->ids($this->database->find('tickets', [ + Query::join('themes', 'th', [Query::on('theme', '$id')]), + Query::equal('th.$createdAt', [$sameInstantElsewhere]), + ]))); + } + + public function testAHavingConditionOnAGroupedDatetimeIsComparedAsAFilterComparesIt(): void + { + $expected = []; + foreach ($this->database->find('themes', [Query::greaterThan('when', self::LATER_THAN_EIGHT_UTC)]) as $theme) { + $when = $theme->getAttribute('when'); + $this->assertIsString($when); + $expected[] = $when; + } + \sort($expected); + $this->assertSame(['2024-01-01T09:00:00.000+00:00', '2024-01-01T11:00:00.000+00:00'], $expected); + + $joined = $this->database->aggregate('tickets', [ + Query::join('themes', 'th', [Query::on('theme', '$id')]), + Query::count('*', 'total'), + Query::groupBy(['th.when']), + Query::having([Query::greaterThan('th.when', self::LATER_THAN_EIGHT_UTC)]), + ]); + $this->assertCount(2, $joined, 'having on a joined grouped datetime'); + + $main = $this->database->aggregate('tickets', [ + Query::count('*', 'total'), + Query::groupBy(['when']), + Query::having([Query::greaterThan('when', self::LATER_THAN_EIGHT_UTC)]), + ]); + $this->assertCount(2, $main, 'having on a grouped datetime of the main collection'); + } + + public function testAMaximumOfADatetimeIsComparedInHavingAsTheDatetimeIs(): void + { + $rows = $this->database->aggregate('tickets', [ + Query::join('themes', 'th', [Query::on('theme', '$id')]), + Query::max('th.when', 'latest'), + Query::groupBy(['name']), + Query::having([Query::greaterThan('latest', self::LATER_THAN_EIGHT_UTC)]), + ]); + + $names = \array_map(static fn (array $row): mixed => $row['name'], $rows); + \sort($names); + $this->assertSame(['first', 'third'], $names); + } + + public function testAJoinReadResolvesEachJoinedCollectionOnce(): void + { + $cache = new class () extends HashAwareMemoryCache { + /** + * @var list + */ + public array $loads = []; + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $this->loads[] = $key; + + return parent::load($key, $ttl, $hash); + } + + public function forgetLoads(): void + { + $this->loads = []; + } + }; + $this->database = $this->database(new Cache($cache)); + + $join = Query::join('themes', 'th', [Query::on('theme', '$id')]); + $selfJoin = Query::join('themes', 'tx', [Query::on('th.$id', '$id')]); + $reads = [ + 'find()' => fn (): mixed => $this->database->find('tickets', [$join, Query::containsAny('th.tags', ['a'])]), + 'find() of an aggregate' => fn (): mixed => $this->database->aggregate('tickets', [$join, Query::sum('th.score', 'total')]), + 'find() of a self-join' => fn (): mixed => $this->database->find('tickets', [$join, $selfJoin]), + 'count()' => fn (): mixed => $this->database->count('tickets', [$join, $selfJoin]), + 'sum() of a joined attribute' => fn (): mixed => $this->database->sum('tickets', 'th.score', [$join, $selfJoin]), + 'getDocument()' => fn (): mixed => $this->database->getDocument('tickets', 'k1', [$join, $selfJoin]), + ]; + + $lookups = []; + foreach ($reads as $name => $read) { + $read(); + $cache->forgetLoads(); + $read(); + + $lookups[$name] = \count(\array_filter( + $cache->loads, + static fn (string $key): bool => \str_ends_with($key, ':'.Database::METADATA.':themes'), + )); + } + + $this->assertSame(\array_fill_keys(\array_keys($reads), 1), $lookups); + } + + public function testMoreJoinsThanTheCapAreRefusedWithoutValidation(): void + { + $joins = static fn (int $count): array => \array_map( + static fn (int $index): Query => Query::join('themes', 'th'.$index, [Query::on('theme', '$id')]), + \range(1, $count), + ); + + $reads = [ + 'find()' => fn (Query ...$queries): mixed => $this->database->find('tickets', $queries), + 'count()' => fn (Query ...$queries): mixed => $this->database->count('tickets', $queries), + 'sum()' => fn (Query ...$queries): mixed => $this->database->sum('tickets', 'amount', $queries), + 'getDocument()' => fn (Query ...$queries): mixed => $this->database->getDocument('tickets', 'k1', $queries), + ]; + + foreach ($reads as $name => $read) { + $this->database->skipValidation(fn (): mixed => $read(...$joins(JoinValidator::MAX_PER_QUERY))); + + try { + $this->database->skipValidation(fn (): mixed => $read(...$joins(JoinValidator::MAX_PER_QUERY + 1))); + $this->fail($name.': '.(JoinValidator::MAX_PER_QUERY + 1).' joins ran without validation'); + } catch (QueryException $error) { + $this->assertSame('Too many joins: at most '.JoinValidator::MAX_PER_QUERY.' are allowed', $error->getMessage(), $name); + } + } + } + + public function testAnUnmatchedOuterRowWithoutASelectedIdIsDropped(): void + { + $join = Query::leftJoin('themes', 'th', [Query::on('theme', '$id')]); + $select = Query::select(['name', 'th.tags', 'th.when']); + + $rows = []; + foreach ($this->database->find('tickets', [$join, $select]) as $row) { + $rows[$row->getId()] = $row; + } + $rows['k4 read by id'] = $this->database->getDocument('tickets', 'k4', [$join, $select]); + $rows['k2 read by id'] = $this->database->getDocument('tickets', 'k2', [$join, $select]); + + foreach ($rows as $name => $row) { + $this->assertFalse($row->offsetExists('th.$id'), $name.': the joined $id was not selected'); + } + foreach (['k4', 'k4 read by id'] as $name) { + $this->assertNull($rows[$name]->getAttribute('th.tags'), $name); + $this->assertNull($rows[$name]->getAttribute('th.when'), $name); + } + foreach (['k2', 'k2 read by id'] as $name) { + $this->assertSame(['a', 'b'], $rows[$name]->getAttribute('th.tags'), $name); + $this->assertSame('2024-01-01T07:00:00.000+00:00', $rows[$name]->getAttribute('th.when'), $name); + } + + $selected = $this->database->find('tickets', [$join, Query::select(['name', 'th.$id', 'th.tags']), Query::equal('$id', ['k2', 'k4'])]); + $this->assertSame( + [['k2', 't2', ['a', 'b']], ['k4', null, null]], + \array_map(static fn (Document $row): array => [$row->getId(), $row->getAttribute('th.$id'), $row->getAttribute('th.tags')], $selected), + ); + } + + public function testADistinctOuterJoinReadSelectsNoJoinedIdOfItsOwn(): void + { + $rows = $this->database->find('tickets', [ + Query::leftJoin('themes', 'th', [Query::on('theme', '$id')]), + Query::select(['th.score']), + Query::distinct(), + ]); + + $scores = \array_map(static fn (Document $row): mixed => $row->getAttribute('th.score'), $rows); + \sort($scores); + $this->assertSame([null, 5], $scores); + } + + public function testAJoinedSelectLeavesUnselectedAttributesOut(): void + { + $join = Query::join('themes', 'th', [Query::on('theme', '$id')]); + $select = Query::select(['name', 'th.name']); + + $rows = $this->database->find('tickets', [$join, $select, Query::equal('$id', ['k1'])]); + $rows[] = $this->database->getDocument('tickets', 'k1', [$join, $select]); + + foreach ($rows as $row) { + $this->assertSame('first', $row->getAttribute('name')); + $this->assertSame('banana theme', $row->getAttribute('th.name')); + foreach (['tags', 'amount', 'when', 'theme'] as $unselected) { + $this->assertFalse($row->offsetExists($unselected), $unselected.' was not selected'); + } + } + } + + /** + * @param array $themes + * @return list + */ + private function ticketsOfThemes(array $themes): array + { + $themeIds = \array_map(static fn (Document $theme): string => $theme->getId(), $themes); + + return $this->ids($this->database->find('tickets', [Query::equal('theme', $themeIds === [] ? ['none'] : $themeIds)])); + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + $ids = \array_map(static fn (Document $document): string => $document->getId(), $documents); + \sort($ids); + + return $ids; + } + + /** + * @param list $tickets + */ + private function amounts(array $tickets): int + { + $amounts = ['k1' => 1, 'k2' => 10, 'k3' => 100, 'k4' => 1000]; + + return \array_sum(\array_map(static fn (string $ticket): int => $amounts[$ticket], $tickets)); + } + + private function database(Cache $cache): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $connection = new PDO('sqlite::memory:', null, null, [PDO::ATTR_PERSISTENT => false, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true, PDO::ATTR_STRINGIFY_FETCHES => true]); + $database = new Database(new SQLite($connection), $cache); + $database + ->setAuthorization($authorization) + ->setDatabase('join_resolution') + ->setNamespace('join_resolution_'.\uniqid()); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'themes', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'tags', size: 32, array: true), + Attribute::datetime(key: 'when'), + Attribute::integer(key: 'score'), + ], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: 'tickets', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'theme', size: 64), + Attribute::string(key: 'tags', size: 32, array: true), + Attribute::integer(key: 'amount'), + Attribute::datetime(key: 'when'), + ], + permissions: $permissions, + documentSecurity: false, + )); + + foreach ([ + ['t1', 'banana theme', ['banana'], '2024-01-01T09:00:00.000+00:00'], + ['t2', 'ab theme', ['a', 'b'], '2024-01-01T07:00:00.000+00:00'], + ['t3', 'c theme', ['b', 'c'], '2024-01-01T11:00:00.000+00:00'], + ] as [$id, $name, $tags, $when]) { + $database->createDocument('themes', new Document(['$id' => $id, 'name' => $name, 'tags' => $tags, 'when' => $when, 'score' => 5])); + } + + foreach ([ + ['k1', 'first', 't1', 1, '2024-01-01T09:00:00.000+00:00'], + ['k2', 'second', 't2', 10, '2024-01-01T07:00:00.000+00:00'], + ['k3', 'third', 't3', 100, '2024-01-01T11:00:00.000+00:00'], + ['k4', 'fourth', 'missing', 1000, '2024-01-01T06:00:00.000+00:00'], + ] as [$id, $name, $theme, $amount, $when]) { + $database->createDocument('tickets', new Document([ + '$id' => $id, + 'name' => $name, + 'theme' => $theme, + 'tags' => ['x', 'y'], + 'amount' => $amount, + 'when' => $when, + ])); + } + + return $database; + } +} diff --git a/tests/unit/Joins/JoinedAttributeResolutionTest.php b/tests/unit/Joins/JoinedAttributeResolutionTest.php new file mode 100644 index 0000000000..4b98bbf779 --- /dev/null +++ b/tests/unit/Joins/JoinedAttributeResolutionTest.php @@ -0,0 +1,389 @@ +useDatabase(new SQLite(new PDO('sqlite::memory:'))); + } + + /** + * @return iterable + */ + public static function fullOuterJoinModes(): iterable + { + yield 'emulated full outer join' => [false]; + yield 'native full outer join' => [true]; + } + + /** + * The same rule holds over a full outer join, whose aggregation runs once over both halves of the + * emulation on engines without one: an order without a customer counts towards the joined + * attribute and a customer attribute stays on the main table. + */ + #[DataProvider('fullOuterJoinModes')] + public function testBareAttributesResolveTheSameWayOverAFullOuterJoin(bool $native): void + { + if ($native) { + $this->useDatabase(new NativeFullOuterJoinSQLite(new PDO('sqlite::memory:'))); + } + $this->createDocument('orders', 'stray', ['customerId' => 'ghost', 'amount' => 9, 'status' => 'lost', 'memo' => 'no customer']); + $purchases = Query::fullOuterJoin('orders', 'purchase', [Query::on('$id', 'customerId')]); + + foreach ([true, false] as $validate) { + $mode = $validate ? 'validated' : 'unvalidated'; + + $totals = $this->aggregateCustomers($validate, [$purchases, Query::sum('amount', 'total'), Query::sum('visits', 'visits'), Query::count('$id', 'customers'), Query::count('*', 'rows')]); + $this->assertCount(1, $totals, $mode); + $this->assertSame(166, $totals[0]['total'], $mode.': a bare joined attribute sums every order, the one without a customer included'); + $this->assertSame(4, $totals[0]['visits'], $mode.': a bare main attribute stays on the main table, even aggregated under its own name'); + $this->assertSame(3, $totals[0]['customers'], $mode.': $id counts the rows that have a customer'); + $this->assertSame(4, $totals[0]['rows'], $mode); + + $byStatus = []; + foreach ($this->aggregateCustomers($validate, [$purchases, Query::sum('amount', 'total'), Query::groupBy(['status'])]) as $group) { + $status = $group['status']; + $this->assertIsString($status, $mode); + $byStatus[$status] = $group['total']; + } + \ksort($byStatus); + $this->assertSame(['lost' => 9, 'open' => 50, 'paid' => 107], $byStatus, $mode.': a bare groupBy attribute groups by the join that declares it'); + + foreach ([ + 'Attribute "amount" is ambiguous across joins; qualify it with a join alias' => [ + Query::fullOuterJoin('orders', 'alpha', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'beta', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ], + 'Attribute not found in schema: also_anything' => [ + $purchases, + Query::sum('also_anything', 'total'), + ], + ] as $message => $queries) { + try { + $this->aggregateCustomers($validate, $queries); + $this->fail("{$mode}: a bare attribute that cannot be resolved was bound: {$message}"); + } catch (QueryException $error) { + $this->assertStringEndsWith($message, $error->getMessage(), $mode); + } + } + } + } + + /** + * @param array $queries + * @return list> + */ + private function aggregateCustomers(bool $validate, array $queries): array + { + return $validate + ? $this->database->aggregate('customers', $queries) + : $this->database->skipValidation(fn (): array => $this->database->aggregate('customers', $queries)); + } + + private function useDatabase(SQLite $adapter): void + { + $this->database = new Database($adapter, new Cache(new NoCache())); + $this->database + ->setDatabase('joined_attributes') + ->setNamespace('joined_attributes_'.\uniqid()) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + + $this->createCollection('customers', [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'visits'), + ]); + $this->createCollection('orders', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::integer(key: 'amount'), + Attribute::string(key: 'status', size: 32), + Attribute::string(key: 'memo', size: 256), + ]); + $this->createCollection('refunds', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::integer(key: 'amount'), + ]); + $this->createCollection('notes', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::string(key: 'body', size: 256), + ], [ + Index::fulltext(key: 'body_fulltext', attributes: ['body']), + ]); + $this->createCollection('profiles', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::integer(key: 'visits'), + ]); + + $this->createDocument('customers', 'first', ['name' => 'First', 'visits' => 1]); + $this->createDocument('customers', 'second', ['name' => 'Second', 'visits' => 2]); + $this->createDocument('orders', 'paid', ['customerId' => 'first', 'amount' => 100, 'status' => 'paid', 'memo' => 'gift wrapped']); + $this->createDocument('orders', 'open', ['customerId' => 'first', 'amount' => 50, 'status' => 'open', 'memo' => 'pending']); + $this->createDocument('orders', 'other', ['customerId' => 'second', 'amount' => 7, 'status' => 'paid', 'memo' => 'plain']); + $this->createDocument('refunds', 'refund', ['customerId' => 'first', 'amount' => 5]); + $this->createDocument('notes', 'note', ['customerId' => 'first', 'body' => 'a needle in a haystack']); + $this->createDocument('profiles', 'profile', ['customerId' => 'first', 'visits' => 1000]); + } + + public function testBareAttributesNoCollectionDeclaresAreRejected(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute not found in schema: anything_at_all'); + + $this->database->aggregate('customers', [ + Query::leftJoin('orders', 'j', [Query::on('$id', 'customerId')]), + Query::groupBy(['anything_at_all']), + Query::sum('also_anything', 'total'), + ]); + } + + public function testBareAggregateAttributeSeveralJoinsDeclareIsRejectedAsAmbiguous(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute "amount" is ambiguous across joins; qualify it with a join alias'); + + $this->database->aggregate('customers', [ + Query::join('orders', 'alpha', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'beta', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ]); + } + + public function testBareGroupByAttributeSeveralJoinsDeclareIsRejectedAsAmbiguous(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute "amount" is ambiguous across joins; qualify it with a join alias'); + + $this->database->aggregate('customers', [ + Query::join('orders', 'alpha', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'beta', [Query::on('$id', 'customerId')]), + Query::count('*', 'rows'), + Query::groupBy(['amount']), + ]); + } + + public function testQualifiedAttributesStillPickTheirJoin(): void + { + $results = $this->database->aggregate('customers', [ + Query::join('orders', 'alpha', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'beta', [Query::on('$id', 'customerId')]), + Query::sum('alpha.amount', 'ordered'), + Query::sum('beta.amount', 'refunded'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(150, $results[0]['ordered']); + $this->assertSame(10, $results[0]['refunded']); + } + + public function testBareAggregateAttributeResolvesToTheOneJoinThatDeclaresIt(): void + { + $results = $this->database->aggregate('customers', [ + Query::join('notes', 'note', [Query::on('$id', 'customerId')]), + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(150, $results[0]['total']); + } + + public function testBareGroupByAttributeResolvesToTheOneJoinThatDeclaresIt(): void + { + $results = $this->database->aggregate('customers', [ + Query::join('notes', 'note', [Query::on('$id', 'customerId')]), + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + Query::groupBy(['status']), + ]); + + $totals = []; + foreach ($results as $result) { + $status = $result['status']; + $this->assertIsString($status); + $totals[$status] = $result['total']; + } + \ksort($totals); + + $this->assertSame(['open' => 50, 'paid' => 100], $totals); + } + + public function testBareAttributeResolvesThroughJoinsWithoutAliases(): void + { + $results = $this->database->aggregate('customers', [ + Query::join('notes', 'j0', [Query::on('$id', 'customerId')]), + Query::join('orders', 'j1', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(150, $results[0]['total']); + } + + public function testBareAttributeOfTheMainCollectionIsNotReboundToAJoin(): void + { + $results = $this->database->aggregate('customers', [ + Query::join('profiles', 'profile', [Query::on('$id', 'customerId')]), + Query::sum('visits', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(1, $results[0]['total']); + } + + public function testBareInternalAttributeResolvesToTheMainCollection(): void + { + $results = $this->database->aggregate('customers', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::count('$id', 'customers'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(2, $results[0]['customers']); + } + + public function testAdapterResolvesBareAttributesTheSameWayWithoutValidation(): void + { + $results = $this->database->skipValidation(fn () => $this->database->aggregate('customers', [ + Query::join('notes', 'note', [Query::on('$id', 'customerId')]), + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ])); + $this->assertSame(150, $results[0]['total']); + + foreach ([ + 'Attribute "amount" is ambiguous across joins; qualify it with a join alias' => [ + Query::join('orders', 'alpha', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'beta', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ], + 'Attribute not found in schema: also_anything' => [ + Query::leftJoin('orders', 'j', [Query::on('$id', 'customerId')]), + Query::sum('also_anything', 'total'), + ], + ] as $message => $queries) { + try { + $this->database->skipValidation(fn () => $this->database->aggregate('customers', $queries)); + $this->fail('The adapter bound a bare attribute it could not resolve: '.$message); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage()); + } + } + } + + public function testSearchOnAJoinedAttributeWithoutAFulltextIndexIsRejected(): void + { + $queries = [ + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::search('purchase.memo', 'gift'), + ]; + + foreach ([ + 'find' => fn () => $this->database->find('customers', $queries), + 'count' => fn () => $this->database->count('customers', $queries), + 'sum' => fn () => $this->database->sum('customers', 'visits', $queries), + ] as $method => $read) { + try { + $read(); + $this->fail($method.'() accepted a search on a joined attribute without a fulltext index'); + } catch (QueryException $error) { + $this->assertSame('Searching by attribute "purchase.memo" requires a fulltext index.', $error->getMessage(), $method); + } + } + } + + public function testSearchOnAJoinedAttributeWithAFulltextIndexIsAccepted(): void + { + $queries = [ + Query::join('notes', 'note', [Query::on('$id', 'customerId')]), + Query::search('note.body', 'needle'), + ]; + + $results = $this->database->find('customers', [...$queries, Query::select(['name'])]); + + $this->assertSame(['first'], \array_map(static fn (Document $document): string => $document->getId(), $results)); + $this->assertSame(1, $this->database->count('customers', $queries)); + } + + public function testUnknownJoinedCollectionIsReportedAsNotFound(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage("Joined collection 'missing' not found"); + + $this->database->count('customers', [ + Query::join('missing', 'gone', [Query::on('$id', 'customerId')]), + Query::search('gone.body', 'needle'), + ]); + } + + public function testJoinedResolutionDoesNotCarryOverToAFindWithoutJoins(): void + { + $joined = $this->database->aggregate('customers', [ + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ]); + $this->assertSame(157, $joined[0]['total']); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute not found in schema: amount'); + + $this->database->aggregate('customers', [Query::sum('amount', 'total')]); + } + + /** + * @param list $attributes + * @param list $indexes + */ + private function createCollection(string $id, array $attributes, array $indexes = []): void + { + $this->database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + indexes: $indexes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + } + + /** + * @param array $attributes + */ + private function createDocument(string $collection, string $id, array $attributes): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Joins/JoinedColumnValidationTest.php b/tests/unit/Joins/JoinedColumnValidationTest.php new file mode 100644 index 0000000000..ef6646b27a --- /dev/null +++ b/tests/unit/Joins/JoinedColumnValidationTest.php @@ -0,0 +1,422 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase('joined_columns') + ->setNamespace('joined_columns_'.\uniqid()) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + + $this->createCollection('customers', [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'visits'), + ]); + $this->createCollection('notes', [ + Attribute::string(key: 'customerId', size: 64), + Attribute::string(key: 'body', size: 256), + Attribute::integer(key: 'score'), + Attribute::double(key: 'ratio'), + Attribute::string(key: 'tags', size: 32, array: true), + Attribute::integer(key: 'points', array: true), + ]); + $this->createCollection('replies', [ + Attribute::string(key: 'noteId', size: 64), + Attribute::string(key: 'text', size: 256), + ]); + + $this->createDocument('customers', 'first', ['name' => 'First', 'visits' => 1]); + $this->createDocument('notes', 'note', [ + 'customerId' => 'first', + 'body' => 'needle', + 'score' => 3, + 'ratio' => 0.5, + 'tags' => ['a'], + 'points' => [1], + ]); + $this->createDocument('replies', 'reply', ['noteId' => 'note', 'text' => 'thanks']); + } + + /** + * @return array + */ + public static function unknownJoinedColumnProvider(): array + { + return [ + 'filter' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.nothing', ['x'])])], + 'select' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::select(['name', 'note.nothing'])])], + 'order' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::orderAsc('note.nothing')])], + 'count aggregate' => [static fn (Database $database): mixed => $database->aggregate('customers', [self::join(), Query::count('note.nothing', 'total')])], + 'sum aggregate' => [static fn (Database $database): mixed => $database->aggregate('customers', [self::join(), Query::sum('note.nothing', 'total')])], + 'groupBy' => [static fn (Database $database): mixed => $database->aggregate('customers', [self::join(), Query::count('*', 'rows'), Query::groupBy(['note.nothing'])])], + 'count() filter' => [static fn (Database $database): mixed => $database->count('customers', [self::join(), Query::equal('note.nothing', ['x'])])], + 'sum() filter' => [static fn (Database $database): mixed => $database->sum('customers', 'visits', [self::join(), Query::equal('note.nothing', ['x'])])], + 'getDocument() join condition' => [static fn (Database $database): mixed => $database->getDocument('customers', 'first', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.nothing', ['x'])]), + ])], + 'getDocument() select' => [static fn (Database $database): mixed => $database->getDocument('customers', 'first', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::select(['name', 'note.nothing']), + ])], + ]; + } + + /** + * @param Closure(Database): mixed $read + */ + #[DataProvider('unknownJoinedColumnProvider')] + public function testUnknownJoinedColumnIsRejectedBeforeTheEngine(Closure $read): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute not found in schema: note.nothing'); + + $read($this->database); + } + + /** + * @return array + */ + public static function internalAttributeOutsideItsQueryTypeProvider(): array + { + return [ + '$permissions in a filter' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.$permissions', ['x'])]), + 'Invalid query: Attribute not found in schema: note.$permissions', + ], + '$permissions in an order' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::orderAsc('note.$permissions')]), + 'Invalid query: Attribute not found in schema: note.$permissions', + ], + '$tenant in a filter' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::isNotNull('note.$tenant')]), + 'Invalid query: Attribute not found in schema: note.$tenant', + ], + '$collection in a select' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::select(['name', 'note.$collection'])]), + 'Invalid query: Attribute not found in schema: note.$collection', + ], + '$permissions in a getDocument() join condition' => [ + static fn (Database $database): mixed => $database->getDocument('customers', 'first', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.$permissions', ['x'])]), + ]), + 'Invalid query: Attribute not found in schema: note.$permissions', + ], + ]; + } + + /** + * @param Closure(Database): mixed $read + */ + #[DataProvider('internalAttributeOutsideItsQueryTypeProvider')] + public function testJoinedInternalAttributeIsRejectedWhereTheMainCollectionRejectsIt(Closure $read, string $message): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage($message); + + $read($this->database); + } + + public function testGetDocumentChecksJoinConditionValuesLikeFind(): void + { + $queries = [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('name', [5])]), + ]; + + foreach ([ + 'find' => fn (): mixed => $this->database->find('customers', $queries), + 'getDocument' => fn (): mixed => $this->database->getDocument('customers', 'first', $queries), + ] as $method => $read) { + try { + $read(); + $this->fail($method.'() accepted a join condition whose value does not fit the attribute'); + } catch (QueryException $error) { + $this->assertSame('Invalid query: Query value is invalid for attribute "name"', $error->getMessage(), $method); + } + } + } + + /** + * @return array + */ + public static function validJoinedColumnProvider(): array + { + return [ + 'known column in a filter' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.body', ['needle'])])], + 'known column in a select' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::select(['name', 'note.body'])])], + 'known column in an order' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::orderAsc('note.body')])], + 'known column in a count' => [static fn (Database $database): mixed => $database->aggregate('customers', [self::join(), Query::count('note.body', 'total')])], + 'known column in a sum' => [static fn (Database $database): mixed => $database->aggregate('customers', [self::join(), Query::sum('note.score', 'total')])], + 'known column in a groupBy' => [static fn (Database $database): mixed => $database->aggregate('customers', [self::join(), Query::count('*', 'rows'), Query::groupBy(['note.body'])])], + 'known column in a between' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::between('note.score', 1, 5)])], + '$id in a filter' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.$id', ['note'])])], + '$id in a select' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::select(['name', 'note.$id'])])], + '$id in a count' => [static fn (Database $database): mixed => $database->aggregate('customers', [self::join(), Query::count('note.$id', 'notes')])], + '$createdAt in a between' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::between('note.$createdAt', '1970-01-01', '2099-12-31')])], + '$permissions, $createdAt, $updatedAt and $sequence in a select' => [static fn (Database $database): mixed => $database->find('customers', [ + self::join(), + Query::select(['name', 'note.$permissions', 'note.$createdAt', 'note.$updatedAt', 'note.$sequence']), + ])], + 'join chained on $id' => [static fn (Database $database): mixed => $database->find('customers', [ + self::join(), + Query::join('replies', 'reply', [Query::on('note.$id', 'noteId')]), + Query::select(['name', 'reply.text']), + ])], + 'count() with a known column' => [static fn (Database $database): mixed => $database->count('customers', [self::join(), Query::equal('note.body', ['needle'])])], + 'sum() with a known column' => [static fn (Database $database): mixed => $database->sum('customers', 'visits', [self::join(), Query::equal('note.body', ['needle'])])], + 'getDocument() join condition on a known column' => [static fn (Database $database): mixed => $database->getDocument('customers', 'first', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.body', ['needle'])]), + ])], + 'getDocument() select of joined columns' => [static fn (Database $database): mixed => $database->getDocument('customers', 'first', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::select(['name', 'note.body', 'note.$id', 'note.$permissions']), + ])], + ]; + } + + /** + * @param Closure(Database): mixed $read + */ + #[DataProvider('validJoinedColumnProvider')] + public function testJoinedColumnValidOnTheJoinedCollectionStaysValid(Closure $read): void + { + $result = $read($this->database); + + if ($result instanceof Document) { + $this->assertSame('first', $result->getId()); + } elseif (\is_array($result)) { + $this->assertCount(1, $result); + } else { + $this->assertSame(1, $result); + } + } + + public function testTopLevelFilterInGetDocumentStaysAnInvalidMethod(): void + { + foreach ([ + 'without a join' => [Query::equal('name', ['First'])], + 'with a join' => [Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), Query::equal('name', ['First'])], + ] as $label => $queries) { + try { + $this->database->getDocument('customers', 'first', $queries); + $this->fail('getDocument() accepted a top-level filter '.$label); + } catch (QueryException $error) { + $this->assertSame('Invalid query method: equal', $error->getMessage(), $label); + } + } + } + + /** + * @return array + */ + public static function nonNumericJoinedAttributeProvider(): array + { + $numeric = static fn (string $method, string $attribute): string => 'Invalid query: Aggregate '.$method.' requires a numeric attribute that is not an array: '.$attribute; + $integer = static fn (string $method, string $attribute): string => 'Invalid query: Aggregate '.$method.' requires an integer attribute that is not an array: '.$attribute; + + return [ + 'sum of a joined string' => [Query::sum('note.body', 'result'), $numeric('sum', 'note.body')], + 'sum of a bare name resolved to a joined string' => [Query::sum('body', 'result'), $numeric('sum', 'body')], + 'avg of a joined string' => [Query::avg('note.body', 'result'), $numeric('avg', 'note.body')], + 'stddev of a joined string' => [Query::stddev('note.body', 'result'), $numeric('stddev', 'note.body')], + 'variance of a bare name resolved to a joined string' => [Query::variance('body', 'result'), $numeric('variance', 'body')], + 'bitAnd of a joined string' => [Query::bitAnd('note.body', 'result'), $numeric('bitAnd', 'note.body')], + 'sum of a joined string array' => [Query::sum('note.tags', 'result'), $numeric('sum', 'note.tags')], + 'avg of a bare name resolved to a joined string array' => [Query::avg('tags', 'result'), $numeric('avg', 'tags')], + 'sum of a joined integer array' => [Query::sum('note.points', 'result'), $numeric('sum', 'note.points')], + 'bitOr of a bare name resolved to a joined integer array' => [Query::bitOr('points', 'result'), $numeric('bitOr', 'points')], + 'bitXor of a joined double' => [Query::bitXor('note.ratio', 'result'), $integer('bitXor', 'note.ratio')], + 'sum of a joined internal attribute' => [Query::sum('note.$createdAt', 'result'), $numeric('sum', 'note.$createdAt')], + ]; + } + + #[DataProvider('nonNumericJoinedAttributeProvider')] + public function testArithmeticAndBitwiseAggregatesTypeAJoinedAttributeByItsCollection(Query $aggregate, string $message): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage($message); + + $this->database->aggregate('customers', [self::join(), $aggregate]); + } + + public function testArithmeticAggregatesOfNumericJoinedAttributesStayValid(): void + { + $results = $this->database->aggregate('customers', [ + self::join(), + Query::sum('note.score', 'total'), + Query::avg('note.ratio', 'average'), + Query::sum('score', 'bare'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(3, $results[0]['total']); + $this->assertSame(0.5, $results[0]['average']); + $this->assertSame(3, $results[0]['bare']); + } + + /** + * @return array + */ + public static function invalidJoinedValueProvider(): array + { + return [ + 'a word for an integer' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.score', ['abc'])]), + 'Invalid query: Query value is invalid for attribute "note.score"', + ], + 'a word for a joined internal datetime' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::greaterThan('note.$createdAt', 'not a date')]), + 'Invalid query: Query value is invalid for attribute "note.$createdAt"', + ], + 'a list for a string' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.body', [['x']])]), + 'Invalid query: Query value is invalid for attribute "note.body"', + ], + 'a prefix match on an integer' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::startsWith('note.score', '1')]), + 'Invalid query: Query value is invalid for attribute "note.score"', + ], + 'equal on an array attribute' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.tags', ['a'])]), + 'Invalid query: Cannot query equal on attribute "note.tags" because it is an array.', + ], + 'contains on a number' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), new Query(Method::Contains, 'note.score', [1])]), + 'Invalid query: Cannot query contains on attribute "note.score" because it is not an array, string, or object.', + ], + 'a vector query on a joined attribute' => [ + static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::vectorCosine('note.score', [1.0])]), + 'Invalid query: Vector queries cannot be used on a joined attribute: note.score', + ], + 'count() with a word for an integer' => [ + static fn (Database $database): mixed => $database->count('customers', [self::join(), Query::equal('note.score', ['abc'])]), + 'Invalid query: Query value is invalid for attribute "note.score"', + ], + 'getDocument() join condition with a word for an integer' => [ + static fn (Database $database): mixed => $database->getDocument('customers', 'first', [ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.score', ['abc'])]), + ]), + 'Invalid query: Query value is invalid for attribute "note.score"', + ], + ]; + } + + /** + * A filter on a joined column is checked against the joined collection's attribute, as a filter + * on the main collection is checked against its own, so an invalid value never reaches the engine. + * + * @param Closure(Database): mixed $read + */ + #[DataProvider('invalidJoinedValueProvider')] + public function testInvalidValueForAJoinedColumnIsRejectedBeforeTheEngine(Closure $read, string $message): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage($message); + + $read($this->database); + } + + /** + * @return array + */ + public static function validJoinedValueProvider(): array + { + return [ + 'an integer' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.score', [3])])], + 'a float' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::lessThan('note.ratio', 0.75)])], + 'a string' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::startsWith('note.body', 'need')])], + 'a joined internal datetime' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::greaterThan('note.$createdAt', '2000-01-01T00:00:00.000+00:00')])], + 'a joined id' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::equal('note.$id', ['note'])])], + 'containsAny on an array attribute' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::containsAny('note.tags', ['a'])])], + 'count() with an integer' => [static fn (Database $database): mixed => $database->count('customers', [self::join(), Query::equal('note.score', [3])])], + ]; + } + + /** + * @param Closure(Database): mixed $read + */ + #[DataProvider('validJoinedValueProvider')] + public function testValidValueForAJoinedColumnStaysValid(Closure $read): void + { + $result = $read($this->database); + + if (\is_array($result)) { + $this->assertCount(1, $result); + } else { + $this->assertSame(1, $result); + } + } + + public function testAJoinedColumnWithoutASchemaIsNotTypeChecked(): void + { + $filter = new Filter([], ColumnType::String->value, supportForAttributes: false); + $filter->allowJoinAliases(['note']); + + $this->assertTrue($filter->isValid(Query::equal('note.anything', ['x'])), $filter->getDescription()); + $this->assertTrue($filter->isValid(Query::equal('note.anything', [['x']])), $filter->getDescription()); + } + + private static function join(): Query + { + return Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + } + + /** + * @param list $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + } + + /** + * @param array $attributes + */ + private function createDocument(string $collection, string $id, array $attributes): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Joins/JoinedDecodeTest.php b/tests/unit/Joins/JoinedDecodeTest.php new file mode 100644 index 0000000000..ce95cf01a4 --- /dev/null +++ b/tests/unit/Joins/JoinedDecodeTest.php @@ -0,0 +1,412 @@ + + */ + private ArrayObject $witnessed; + + /** + * @var array + */ + private array $registered = []; + + private bool $defaultsRegistered = false; + + #[\Override] + protected function setUp(): void + { + $this->witnessed = new ArrayObject(); + $this->registered = FilterRegistry::filters(); + $this->defaultsRegistered = FilterRegistry::defaultsRegistered(); + + Database::addFilter( + 'witness', + static fn (mixed $value): mixed => \is_string($value) ? self::WITNESS_PREFIX.$value : $value, + function (mixed $value, Document $document, Database $database): mixed { + $this->witnessed->append(['document' => clone $document, 'database' => $database]); + + return \is_string($value) && \str_starts_with($value, self::WITNESS_PREFIX) + ? \substr($value, \strlen(self::WITNESS_PREFIX)) + : $value; + }, + ); + } + + #[\Override] + protected function tearDown(): void + { + FilterRegistry::restore($this->registered, $this->defaultsRegistered); + } + + public function testImplicitProjectionDecodesLikeADirectRead(): void + { + $database = $this->database(); + + $rows = $database->find('customers', [Query::join('orders', 'ord', [Query::on('$id', 'customerId')])]); + + $this->assertCount(1, $rows); + $this->assertDecodedOrder($rows[0], 'ord'); + $this->assertLikeDirectRead($database, $rows[0], 'ord', 'orders', 'o1', ['$id', ...self::ORDER_ATTRIBUTES]); + } + + public function testExplicitSelectDecodesLikeADirectRead(): void + { + $database = $this->database(); + + $rows = $database->find('customers', [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::select(['name', ...\array_map(static fn (string $attribute): string => 'ord.'.$attribute, self::ORDER_ATTRIBUTES)]), + ]); + + $this->assertCount(1, $rows); + $this->assertDecodedOrder($rows[0], 'ord'); + $this->assertLikeDirectRead($database, $rows[0], 'ord', 'orders', 'o1', self::ORDER_ATTRIBUTES); + } + + public function testSelectingOneJoinedAttributeDecodesOnlyThatAttribute(): void + { + $database = $this->database(); + + $rows = $database->find('customers', [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::select(['name', 'ord.secret']), + ]); + + $this->assertCount(1, $rows); + $this->assertSame('plain-secret', $rows[0]->getAttribute('ord.secret')); + $joined = \array_values(\array_filter( + \array_map(\strval(...), \array_keys($rows[0]->getArrayCopy())), + static fn (string $key): bool => \str_starts_with($key, 'ord.'), + )); + $this->assertSame(['ord.secret'], $joined); + } + + public function testGetDocumentDecodesLikeADirectRead(): void + { + $database = $this->database(); + $join = Query::join('orders', 'ord', [Query::on('$id', 'customerId')]); + + $implicit = $database->getDocument('customers', 'c1', [$join]); + $this->assertDecodedOrder($implicit, 'ord'); + $this->assertLikeDirectRead($database, $implicit, 'ord', 'orders', 'o1', ['$id', ...self::ORDER_ATTRIBUTES]); + + $selected = $database->getDocument('customers', 'c1', [ + $join, + Query::select(['name', ...\array_map(static fn (string $attribute): string => 'ord.'.$attribute, self::ORDER_ATTRIBUTES)]), + ]); + $this->assertDecodedOrder($selected, 'ord'); + $this->assertLikeDirectRead($database, $selected, 'ord', 'orders', 'o1', self::ORDER_ATTRIBUTES); + } + + public function testEachAliasDecodesWithItsOwnCollection(): void + { + $database = $this->database(); + + $rows = $database->find('customers', [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'ref', [Query::on('$id', 'customerId')]), + ]); + + $this->assertCount(1, $rows); + $this->assertLikeDirectRead($database, $rows[0], 'ord', 'orders', 'o1', ['$id', ...self::ORDER_ATTRIBUTES]); + $this->assertLikeDirectRead($database, $rows[0], 'ref', 'refunds', 'r1', ['$id', ...self::REFUND_ATTRIBUTES]); + $this->assertSame(7.5, $rows[0]->getAttribute('ref.total')); + $this->assertSame('refund-note', $rows[0]->getAttribute('ref.secret')); + } + + public function testGeneratedAliasesDecodeWithTheirJoinedCollection(): void + { + $database = $this->database(); + + $generated = $database->find('customers', [Query::join('orders', 'j0', [Query::on('$id', 'customerId')])]); + $this->assertCount(1, $generated); + $this->assertDecodedOrder($generated[0], 'j0'); + $this->assertLikeDirectRead($database, $generated[0], 'j0', 'orders', 'o1', ['$id', ...self::ORDER_ATTRIBUTES]); + + $skipping = $database->find('customers', [ + Query::join('orders', 'j1', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'j0', [Query::on('$id', 'customerId')]), + ]); + $this->assertCount(1, $skipping); + $this->assertDecodedOrder($skipping[0], 'j1'); + $this->assertLikeDirectRead($database, $skipping[0], 'j1', 'orders', 'o1', ['$id', ...self::ORDER_ATTRIBUTES]); + $this->assertLikeDirectRead($database, $skipping[0], 'j0', 'refunds', 'r1', ['$id', ...self::REFUND_ATTRIBUTES]); + } + + public function testJoinedInternalAttributesDecodeLikeADirectRead(): void + { + $database = $this->database(); + + $rows = $database->find('customers', [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::select(['name', 'ord.$id', 'ord.$sequence', 'ord.$createdAt', 'ord.$updatedAt', 'ord.$permissions']), + ]); + + $this->assertCount(1, $rows); + $this->assertLikeDirectRead($database, $rows[0], 'ord', 'orders', 'o1', ['$id', '$sequence', '$createdAt', '$updatedAt', '$permissions']); + } + + public function testDecodeFiltersReceiveTheJoinedDocument(): void + { + $database = $this->database(); + $join = Query::join('orders', 'ord', [Query::on('$id', 'customerId')]); + + $this->witnessed->exchangeArray([]); + $database->find('customers', [$join]); + $database->getDocument('customers', 'c1', [$join]); + + $this->assertCount(2, $this->witnessed); + foreach ($this->witnessed as ['document' => $document, 'database' => $witness]) { + $this->assertSame('o1', $document->getId()); + $this->assertSame('orders', $document->getCollection()); + $this->assertSame('c1', $document->getAttribute('customerId')); + $this->assertSame(10, $document->getAttribute('total')); + $this->assertSame($database, $witness); + } + } + + public function testUnmatchedJoinReturnsNullValues(): void + { + $database = $this->database(); + + $this->witnessed->exchangeArray([]); + $rows = $database->find('customers', [ + Query::leftJoin('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::orderAsc('name'), + ]); + + $this->assertSame(['Alice', 'Bob'], \array_map(static fn (Document $row): mixed => $row->getAttribute('name'), $rows)); + $this->assertDecodedOrder($rows[0], 'ord'); + $this->assertNull($rows[1]->getAttribute('ord.$id')); + foreach (self::ORDER_ATTRIBUTES as $attribute) { + $this->assertArrayHasKey('ord.'.$attribute, $rows[1]->getArrayCopy()); + $this->assertNull($rows[1]->getAttribute('ord.'.$attribute), 'ord.'.$attribute); + } + $this->assertCount(1, $this->witnessed, 'A decode filter only runs for a joined row that exists'); + } + + public function testCursorAfterARowPagesByAJoinedDatetime(): void + { + $database = $this->database(); + foreach (['c3' => '2024-05-06T08:00:00.000+00:00', 'c4' => '2024-05-06T09:00:00.000+00:00'] as $customer => $time) { + $database->createDocument('customers', new Document(['$id' => $customer, 'name' => $customer])); + $database->createDocument('orders', $this->order('o'.$customer, $customer, $time)); + } + + $queries = [ + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + Query::orderAsc('ord.placedAt'), + Query::limit(1), + ]; + + $customers = []; + $cursor = null; + $placedAt = null; + for ($page = 0; $page < 4; $page++) { + $rows = $database->find('customers', $cursor === null ? $queries : [...$queries, Query::cursorAfter($cursor)]); + if ($rows === []) { + break; + } + + $customers[] = $rows[0]->getId(); + if ($cursor !== null) { + $this->assertSame($placedAt, $cursor->getAttribute('ord.placedAt'), 'The cursor document keeps its decoded values'); + } + $cursor = $rows[0]; + $placedAt = $cursor->getAttribute('ord.placedAt'); + } + + $this->assertSame(['c1', 'c3', 'c4'], $customers); + } + + private function assertDecodedOrder(Document $row, string $alias): void + { + $this->assertSame('c1', $row->getAttribute($alias.'.customerId')); + $this->assertSame(10, $row->getAttribute($alias.'.total')); + $this->assertSame(2.5, $row->getAttribute($alias.'.price')); + $this->assertTrue($row->getAttribute($alias.'.paid')); + $this->assertSame(['a', 'b'], $row->getAttribute($alias.'.tags')); + $this->assertSame(['color' => 'red'], $row->getAttribute($alias.'.meta')); + $this->assertSame('plain-secret', $row->getAttribute($alias.'.secret')); + $this->assertSame(['tier' => 'gold'], $row->getAttribute($alias.'.profile')); + $this->assertSame('handwritten', $row->getAttribute($alias.'.note')); + } + + /** + * @param list $attributes + */ + private function assertLikeDirectRead(Database $database, Document $row, string $alias, string $collection, string $id, array $attributes): void + { + $direct = $database->getDocument($collection, $id); + $this->assertFalse($direct->isEmpty()); + + foreach ($attributes as $attribute) { + $this->assertSame($direct->getAttribute($attribute), $row->getAttribute($alias.'.'.$attribute), $alias.'.'.$attribute); + } + } + + private function database(): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $connection = new PDO('sqlite::memory:', null, null, [PDO::ATTR_PERSISTENT => false, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true, PDO::ATTR_STRINGIFY_FETCHES => true]); + $database = new Database(new SQLite($connection), new Cache(new None()), $this->filters()); + $database + ->setAuthorization($authorization) + ->setDatabase('joined_decode') + ->setNamespace('joined_decode_'.\uniqid()); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'customers', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: 'orders', + attributes: [ + Attribute::string(key: 'customerId', size: 64, required: true), + Attribute::integer(key: 'total', required: true), + Attribute::float(key: 'price', required: true), + Attribute::boolean(key: 'paid', required: true), + Attribute::datetime(key: 'placedAt', required: true), + Attribute::string(key: 'tags', size: 32, array: true), + Attribute::string(key: 'meta', size: 1024, filters: [Filter::Json]), + Attribute::string(key: 'secret', size: 1024, filters: ['sealed']), + Attribute::string(key: 'profile', size: 1024, filters: [Filter::Json, 'sealed']), + Attribute::string(key: 'note', size: 256, filters: ['witness']), + ], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: 'refunds', + attributes: [ + Attribute::string(key: 'customerId', size: 64, required: true), + Attribute::float(key: 'total', required: true), + Attribute::string(key: 'secret', size: 64, required: true), + ], + permissions: $permissions, + documentSecurity: false, + )); + + $database->createDocument('customers', new Document(['$id' => 'c1', 'name' => 'Alice'])); + $database->createDocument('customers', new Document(['$id' => 'c2', 'name' => 'Bob'])); + $database->createDocument('orders', $this->order('o1', 'c1', '2024-05-06T07:08:09.123+00:00')); + $database->createDocument('refunds', new Document([ + '$id' => 'r1', + 'customerId' => 'c1', + 'total' => 7.5, + 'secret' => 'refund-note', + ])); + + return $database; + } + + private function order(string $id, string $customerId, string $placedAt): Document + { + return new Document([ + '$id' => $id, + 'customerId' => $customerId, + 'total' => 10, + 'price' => 2.5, + 'paid' => true, + 'placedAt' => $placedAt, + 'tags' => ['a', 'b'], + 'meta' => ['color' => 'red'], + 'secret' => 'plain-secret', + 'profile' => ['tier' => 'gold'], + 'note' => 'handwritten', + ]); + } + + /** + * @return list + */ + private function filters(): array + { + return [ + new Callback( + 'sealed', + static function (mixed $value): mixed { + if (! \is_string($value)) { + return $value; + } + + $iv = \random_bytes(self::IV_LENGTH); + $tag = ''; + $data = \openssl_encrypt($value, self::CIPHER, self::KEY, 0, $iv, $tag); + + return \json_encode([ + 'data' => $data, + 'method' => self::CIPHER, + 'iv' => \bin2hex($iv), + 'tag' => \bin2hex($tag), + 'version' => '1', + ]); + }, + static function (mixed $value): mixed { + if ($value === null) { + return null; + } + + $payload = \is_string($value) ? \json_decode($value, true) : null; + if ( + ! \is_array($payload) + || ! \is_string($payload['data'] ?? null) + || ! \is_string($payload['iv'] ?? null) + || ! \is_string($payload['tag'] ?? null) + ) { + throw new RuntimeException('Not a sealed value: '.\var_export($value, true)); + } + + return \openssl_decrypt($payload['data'], self::CIPHER, self::KEY, 0, (string) \hex2bin($payload['iv']), (string) \hex2bin($payload['tag'])); + }, + ), + ]; + } +} diff --git a/tests/unit/Joins/JoinedShapesTest.php b/tests/unit/Joins/JoinedShapesTest.php new file mode 100644 index 0000000000..a621d3caa3 --- /dev/null +++ b/tests/unit/Joins/JoinedShapesTest.php @@ -0,0 +1,248 @@ +database(); + $item = Query::join('items', 'it', [Query::on('item', 'code')]); + + $rows = $database->find('orders', [$item, Query::select(['name', 'it.*']), Query::orderAsc('$id')]); + $this->assertSame(['x', 'y', 'x'], \array_map(static fn (Document $row): mixed => $row->getAttribute('name'), $rows)); + $this->assertSame(['apple', 'banana', 'apple'], \array_map(static fn (Document $row): mixed => $row->getAttribute('it.name'), $rows)); + $this->assertSame(self::JOINED_KEYS, $this->joinedKeys($rows[0])); + $this->assertNull($rows[0]->getAttribute('quantity'), 'an attribute of the main collection the select leaves out'); + + $alone = $database->find('orders', [$item, Query::select(['it.*']), Query::orderAsc('$id')]); + $this->assertSame(self::JOINED_KEYS, $this->joinedKeys($alone[0])); + $this->assertNull($alone[0]->getAttribute('name')); + $this->assertSame('o1', $alone[0]->getId()); + + $withWildcard = $database->find('orders', [$item, Query::select(['*', 'it.*']), Query::orderAsc('$id')]); + $this->assertSame( + $this->mainAttributes($database->find('orders', [$item, Query::orderAsc('$id')])), + $this->mainAttributes($withWildcard), + 'next to * the main document is returned as * alone returns it', + ); + $this->assertSame(self::JOINED_KEYS, $this->joinedKeys($withWildcard[0]), 'next to * it adds the whole joined row'); + $this->assertSame( + $this->arrays($database->find('orders', [$item, Query::select(['name', 'it.*']), Query::orderAsc('$id')])), + $this->arrays($database->find('orders', [$item, Query::select(['name', 'it.*', 'it.name']), Query::orderAsc('$id')])), + 'a joined attribute named as well is selected once', + ); + + $both = $database->find('orders', [$item, Query::join('extras', 'ex', [Query::on('item', 'code')]), Query::select(['quantity', 'ex.*'])]); + $this->assertCount(2, $both); + $this->assertSame(['ex.$createdAt', 'ex.$id', 'ex.$permissions', 'ex.$sequence', 'ex.$updatedAt', 'ex.code', 'ex.price'], $this->joinedKeys($both[0])); + + $document = $database->getDocument('orders', 'o2', [$item, Query::select(['name', 'it.*'])]); + $this->assertSame('y', $document->getAttribute('name')); + $this->assertSame('banana', $document->getAttribute('it.name')); + $this->assertSame(20, $document->getAttribute('it.price')); + } + + public function testAJoinWildcardIsRefusedWhereItNamesNoJoinOrInAnAggregation(): void + { + $database = $this->database(); + $item = Query::join('items', 'it', [Query::on('item', 'code')]); + + $this->assertRefused( + 'Invalid query: Cannot select "it.*": an aggregation query can only select the attributes it groups by', + fn (): mixed => $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['it.name']), Query::select(['it.*'])]), + ); + $this->assertRefused( + 'Invalid query: Attribute not found in schema: zz', + fn (): mixed => $database->find('orders', [$item, Query::select(['name', 'zz.*'])]), + ); + $this->assertRefused( + 'Invalid query: Attribute not found in schema: it', + fn (): mixed => $database->find('orders', [Query::select(['name', 'it.*'])]), + 'without the join', + ); + } + + public function testAnOrderOnABareJoinedNameReadsTheJoin(): void + { + foreach (['join' => [false, 'join'], 'emulated full outer join' => [false, 'fullOuterJoin'], 'native full outer join' => [true, 'fullOuterJoin']] as $case => [$native, $method]) { + $database = $this->database($native); + $item = Query::$method('items', 'it', [Query::on('item', 'code')]); + + $this->assertSame( + $this->ids($database->find('orders', [$item, Query::orderDesc('it.price'), Query::orderAsc('$id')])), + $this->ids($database->find('orders', [$item, Query::orderDesc('price'), Query::orderAsc('$id')])), + $case, + ); + $this->assertSame(['o2', 'o1', 'o3'], $this->ids($database->find('orders', [$item, Query::orderDesc('price'), Query::orderAsc('$id')])), $case); + $this->assertSame( + [['orders' => 1, 'code' => 'b'], ['orders' => 2, 'code' => 'a']], + $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['code']), Query::orderDesc('code')]), + $case.': a group only the join declares, named bare', + ); + $this->assertSame( + [['orders' => 1, 'code' => 'b'], ['orders' => 2, 'code' => 'a']], + $database->aggregate('orders', [$item, Query::count('*', 'orders'), Query::groupBy(['it.code']), Query::orderDesc('code')]), + $case.': a group named by its alias', + ); + } + } + + public function testAnOrderOnANameTheMainCollectionDeclaresReadsTheMainTable(): void + { + $database = $this->database(); + $item = Query::join('items', 'it', [Query::on('item', 'code')]); + + $this->assertSame(['o2', 'o1', 'o3'], $this->ids($database->find('orders', [$item, Query::orderDesc('name'), Query::orderAsc('$id')])), 'orders are named y, x, x; their items apple, banana'); + } + + public function testAnOrderOnABareNameSeveralOrNoCollectionsDeclareIsRefused(): void + { + $database = $this->database(); + $joins = [Query::join('items', 'it', [Query::on('item', 'code')]), Query::join('extras', 'ex', [Query::on('item', 'code')])]; + $ambiguous = 'Attribute "price" is ambiguous across joins; qualify it with a join alias'; + + $this->assertRefused('Invalid query: '.$ambiguous, fn (): mixed => $database->find('orders', [...$joins, Query::orderAsc('price')])); + $this->assertRefused('Invalid query: Attribute not found in schema: weight', fn (): mixed => $database->find('orders', [...$joins, Query::orderAsc('weight')])); + $this->assertSame(['o1', 'o3'], $this->ids($database->find('orders', [...$joins, Query::orderAsc('ex.price'), Query::orderAsc('$id')]))); + + $database->setValidation(false); + $this->assertRefused($ambiguous, fn (): mixed => $database->find('orders', [...$joins, Query::orderAsc('price')]), 'the adapter without validation'); + } + + private function assertRefused(string $message, Closure $read, string $label = ''): void + { + try { + $read(); + } catch (QueryException $error) { + $this->assertSame($message, $error->getMessage(), $label); + + return; + } + + $this->fail(($label === '' ? '' : $label.': ').'the shape was accepted'); + } + + /** + * @return list + */ + private function joinedKeys(Document $row): array + { + $keys = \array_values(\array_filter(\array_keys($row->getArrayCopy()), static fn (string $key): bool => \str_contains($key, '.'))); + \sort($keys); + + return $keys; + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): string => $document->getId(), $documents)); + } + + /** + * @param array $documents + * @return list> + */ + private function arrays(array $documents): array + { + return \array_values(\array_map(static function (Document $document): array { + $row = $document->getArrayCopy(); + unset($row['$createdAt'], $row['$updatedAt']); + + return $row; + }, $documents)); + } + + /** + * @param array $documents + * @return list> + */ + private function mainAttributes(array $documents): array + { + return \array_map( + static fn (array $row): array => \array_filter($row, static fn (string $key): bool => ! \str_contains($key, '.'), ARRAY_FILTER_USE_KEY), + $this->arrays($documents), + ); + } + + /** + * Orders of items: o1 and o3 order a (price 10), o2 orders b (price 20); extras list a at 100. + */ + private function database(bool $nativeFullOuterJoin = false): Database + { + $pdo = new PDO('sqlite::memory:'); + $database = new Database($nativeFullOuterJoin ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), new Cache(new NoCache())); + $database + ->setDatabase('joined_shapes') + ->setNamespace('joined_shapes_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'orders', + attributes: [ + Attribute::string(key: 'item', size: 16), + Attribute::integer(key: 'quantity'), + Attribute::string(key: 'name', size: 16), + ], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: 'items', + attributes: [ + Attribute::string(key: 'code', size: 16), + Attribute::integer(key: 'price'), + Attribute::string(key: 'name', size: 16), + ], + permissions: $permissions, + )); + $database->createCollection(Collection::create( + id: 'extras', + attributes: [ + Attribute::string(key: 'code', size: 16), + Attribute::integer(key: 'price'), + ], + permissions: $permissions, + )); + + foreach ([['o1', 'a', 1, 'x'], ['o2', 'b', 2, 'y'], ['o3', 'a', 3, 'x']] as [$id, $item, $quantity, $name]) { + $database->createDocument('orders', new Document(['$id' => $id, '$permissions' => [], 'item' => $item, 'quantity' => $quantity, 'name' => $name])); + } + foreach ([['a', 10, 'apple'], ['b', 20, 'banana']] as [$code, $price, $name]) { + $database->createDocument('items', new Document(['$id' => $code, '$permissions' => [], 'code' => $code, 'price' => $price, 'name' => $name])); + } + $database->createDocument('extras', new Document(['$id' => 'a', '$permissions' => [], 'code' => 'a', 'price' => 100])); + + return $database; + } +} diff --git a/tests/unit/LegacyAttributeFormatTest.php b/tests/unit/LegacyAttributeFormatTest.php new file mode 100644 index 0000000000..15d81f9e4e --- /dev/null +++ b/tests/unit/LegacyAttributeFormatTest.php @@ -0,0 +1,65 @@ + + */ + public static function adapters(): array + { + return [ + 'SQLite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + 'Memory' => [static fn (): Adapter => new Memory()], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testStoredEmptyFormatReadsBackLikeTheDefinition(Closure $adapter): void + { + $definition = Attribute::string(key: 'resourceInternalId', size: Database::LENGTH_KEY); + + $database = new Database($adapter(), new Cache(new None())); + $database + ->setDatabase('legacy') + ->setNamespace('legacy_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create(id: 'migrations', attributes: [$definition])); + + $legacy = $definition->toDocument()->getArrayCopy(); + $legacy['format'] = ''; + $database->getAuthorization()->skip(fn () => $database->updateDocument( + Database::METADATA, + 'migrations', + new Document(['attributes' => [$legacy]]), + )); + + $stored = $database->getCollection('migrations')->attributes()[0]->toDocument(); + $expected = $definition->toDocument(); + + $this->assertSame($expected->getAttribute('type'), $stored->getAttribute('type')); + foreach (['size', 'required', 'default', 'signed', 'array', 'format', 'formatOptions', 'filters'] as $key) { + $this->assertSame($expected->getAttribute($key), $stored->getAttribute($key), "Stored '{$key}' differs from the definition"); + } + } +} diff --git a/tests/unit/LenientReadTest.php b/tests/unit/LenientReadTest.php new file mode 100644 index 0000000000..5a5692a02d --- /dev/null +++ b/tests/unit/LenientReadTest.php @@ -0,0 +1,172 @@ + + */ + public static function databases(): array + { + return [ + 'Memory' => [self::memory(...)], + 'SQLite' => [self::sqlite(...)], + ]; + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testGetDocumentDropsAStoredNonStringPermission(Closure $database): void + { + $document = $database()->getDocument(self::COLLECTION, self::ID); + + $this->assertSame('stored', $document->getAttribute('title')); + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testFindDropsAStoredNonStringPermission(Closure $database): void + { + $documents = $database()->find(self::COLLECTION); + + $this->assertCount(1, $documents); + $this->assertSame([Permission::read(Role::any())], $documents[0]->getPermissions()); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testUpdateDocumentOverAStoredNonStringPermission(Closure $database): void + { + $database = $database(); + + $updated = $database->updateDocument(self::COLLECTION, self::ID, new Document(['title' => 'updated'])); + + $this->assertSame('updated', $updated->getAttribute('title')); + $this->assertSame([Permission::read(Role::any())], $updated->getPermissions()); + $this->assertSame('updated', $database->getDocument(self::COLLECTION, self::ID)->getAttribute('title')); + } + + /** + * @param Closure(): Database $database + */ + #[DataProvider('databases')] + public function testUpdateDocumentsOverAStoredNonStringPermission(Closure $database): void + { + $database = $database(); + + $this->assertSame(1, $database->updateDocuments(self::COLLECTION, new Document(['title' => 'updated']))); + + $document = $database->getDocument(self::COLLECTION, self::ID); + $this->assertSame('updated', $document->getAttribute('title')); + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + } + + private static function memory(): Database + { + $adapter = new class () extends Memory { + /** + * @param list $permissions + */ + public function storePermissions(string $collection, string $id, array $permissions): void + { + $this->data[$this->key($collection)]['documents'][$this->documentKey($id)][Storage::PERMISSIONS] = $permissions; + } + }; + + $database = self::seed(self::database($adapter)); + $adapter->storePermissions(self::COLLECTION, self::ID, self::storedPermissions()); + + return $database; + } + + private static function sqlite(): Database + { + $pdo = new PDO('sqlite::memory:'); + $database = self::seed(self::database(new SQLite($pdo))->addHook(new Permissions())); + + $table = self::NAMESPACE.'_'.self::COLLECTION; + $statement = $pdo->prepare('UPDATE `'.$table.'` SET `'.Storage::PERMISSIONS.'` = :permissions WHERE `'.Storage::UID.'` = :id'); + self::assertNotFalse($statement); + $statement->execute([ + 'permissions' => \json_encode(self::storedPermissions(), JSON_THROW_ON_ERROR), + 'id' => self::ID, + ]); + self::assertSame(1, $statement->rowCount()); + + return $database; + } + + private static function database(Adapter $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + return (new Database($adapter, new Cache(new None()))) + ->setAuthorization($authorization) + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE); + } + + private static function seed(Database $database): Database + { + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + $database->createDocument(self::COLLECTION, new Document([ + Document::ID => self::ID, + Document::PERMISSIONS => [Permission::read(Role::any())], + 'title' => 'stored', + ])); + + return $database; + } + + /** + * @return list + */ + private static function storedPermissions(): array + { + return [Permission::read(Role::any()), 42, null, Permission::read(Role::any())]; + } +} diff --git a/tests/unit/LostTransactionConnectionTest.php b/tests/unit/LostTransactionConnectionTest.php new file mode 100644 index 0000000000..39b3438793 --- /dev/null +++ b/tests/unit/LostTransactionConnectionTest.php @@ -0,0 +1,313 @@ + + */ + public static function adapters(): array + { + return [ + 'MariaDB' => [static fn (PDO $pdo): Adapter => new MariaDB($pdo)], + 'Postgres' => [static fn (PDO $pdo): Adapter => new Postgres($pdo)], + 'SQLite' => [static fn (PDO $pdo): Adapter => new SQLite($pdo)], + ]; + } + + /** + * @return array + */ + public static function nestedLosses(): array + { + $prepared = static function (PDO $pdo, int $value): void { + $pdo->prepare("INSERT INTO items VALUES ({$value})")->execute(); + }; + $executed = static function (PDO $pdo, int $value): void { + $pdo->exec("INSERT INTO items VALUES ({$value})"); + }; + + $losses = []; + foreach (self::adapters() as $name => [$create]) { + $losses["{$name}, prepared statement"] = [$create, $prepared]; + $losses["{$name}, executed statement"] = [$create, $executed]; + } + + return $losses; + } + + /** + * @param Closure(PDO): Adapter $create + * @param Closure(PDO, int): void $write + */ + #[DataProvider('nestedLosses')] + public function testAConnectionLostInANestedTransactionLeavesTheConnectionUsable(Closure $create, Closure $write): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $adapter = $create($pdo); + $attempts = 0; + + $error = $this->capture(function () use ($adapter, $pdo, $endSession, $write, &$attempts): void { + $adapter->withTransaction(function () use ($adapter, $pdo, $endSession, $write, &$attempts): void { + $attempts++; + $write($pdo, 1); + + $adapter->withTransaction(function () use ($pdo, $endSession, $write): void { + $endSession(); + $write($pdo, 2); + }); + }); + }); + + $this->assertInstanceOf(TransactionException::class, $error, 'A transaction lost under a nested call fails the outermost call'); + $this->assertSame(1, $attempts, 'The work of a lost transaction must not run again'); + $this->assertSame([], $this->values($path), 'Nothing written in the lost transaction may be stored'); + $this->assertFalse($adapter->inTransaction()); + $this->assertConnectionUsable($pdo, $path); + } + + /** + * The outer callback catches the nested failure and carries on: its statements must not + * run in autocommit while it still runs, and the outer call must not report the lost + * work as committed. + * + * @param Closure(PDO): Adapter $create + */ + #[DataProvider('adapters')] + public function testAnOuterCallbackThatCarriesOnAfterALostNestedTransactionFails(Closure $create): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $adapter = $create($pdo); + $nested = null; + $carriedOn = null; + + $error = $this->capture(function () use ($adapter, $pdo, $endSession, &$nested, &$carriedOn): void { + $adapter->withTransaction(function () use ($adapter, $pdo, $endSession, &$nested, &$carriedOn): void { + $pdo->exec('INSERT INTO items VALUES (1)'); + + $nested = $this->capture(function () use ($adapter, $pdo, $endSession): void { + $adapter->withTransaction(function () use ($pdo, $endSession): void { + $endSession(); + $pdo->prepare('INSERT INTO items VALUES (2)')->execute(); + }); + }); + + $carriedOn = $this->capture(fn (): int|false => $pdo->exec('INSERT INTO items VALUES (5)')); + }); + }); + + $this->assertInstanceOf(TransactionException::class, $nested); + $this->assertInstanceOf(PDOException::class, $carriedOn, 'A statement after the lost transaction must not run while the outer call runs'); + $this->assertInstanceOf(TransactionException::class, $error, 'The outer call must not return as if its lost work committed'); + $this->assertSame([], $this->values($path), 'Nothing written in or after the lost transaction may be stored'); + $this->assertFalse($adapter->inTransaction()); + $this->assertConnectionUsable($pdo, $path); + } + + /** + * @param Closure(PDO): Adapter $create + */ + #[DataProvider('adapters')] + public function testATransactionThatLosesTheConnectionOnEveryAttemptLeavesTheConnectionUsable(Closure $create): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $adapter = $create($pdo); + $attempts = 0; + + $error = $this->capture(function () use ($adapter, $pdo, $endSession, &$attempts): void { + $adapter->withTransaction(function () use ($pdo, $endSession, &$attempts): void { + $attempts++; + $pdo->prepare('INSERT INTO items VALUES (1)')->execute(); + $endSession(); + $pdo->prepare('INSERT INTO items VALUES (2)')->execute(); + }); + }); + + $this->assertInstanceOf(Throwable::class, $error, 'Every attempt lost its connection'); + $this->assertSame(3, $attempts, 'A top-level transaction that lost its connection runs again up to its retries'); + $this->assertSame([], $this->values($path), 'Nothing written in the lost transactions may be stored'); + $this->assertFalse($adapter->inTransaction()); + $this->assertConnectionUsable($pdo, $path); + } + + public function testAPooledConnectionLostInANestedTransactionServesTheNextCheckout(): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $connection = new MariaDB($pdo); + $pool = new Pool(new UtopiaPool(new Stack(), 'lost-transaction', 1, static fn (): MariaDB => $connection, timeout: 0.0)); + $pool->setAuthorization(new Authorization()); + + $error = $this->capture(function () use ($pool, $pdo, $endSession): void { + $pool->withTransaction(function () use ($pool, $pdo, $endSession): void { + $pdo->exec('INSERT INTO items VALUES (1)'); + + $pool->withTransaction(function () use ($pdo, $endSession): void { + $endSession(); + $pdo->prepare('INSERT INTO items VALUES (2)')->execute(); + }); + }); + }); + + $this->assertInstanceOf(TransactionException::class, $error); + $this->assertFalse($pool->inTransaction()); + $this->assertTrue($pool->ping(), 'The next checkout of the connection must run its statement'); + $this->assertSame([], $this->values($path), 'Nothing written in the lost transaction may be stored'); + } + + private function assertConnectionUsable(PDO $pdo, string $path): void + { + $pdo->prepare('INSERT INTO items VALUES (3)')->execute(); + $pdo->exec('INSERT INTO items VALUES (4)'); + + $statement = $pdo->query('SELECT COUNT(*) FROM items'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + $this->assertSame(2, $statement->fetchColumn(), 'A read on the connection must see its own writes'); + $this->assertSame([3, 4], $this->values($path), 'Writes after the lost transaction must be stored'); + $this->assertFalse($pdo->inTransaction(), 'The connection must not keep the lost transaction'); + } + + /** + * @param callable(): mixed $callback + */ + private function capture(callable $callback): ?Throwable + { + try { + $callback(); + } catch (Throwable $error) { + return $error; + } + + return null; + } + + private function createDatabaseFile(): string + { + $path = \tempnam(\sys_get_temp_dir(), 'lost-transaction-'); + $this->assertIsString($path); + \register_shutdown_function(static fn (): bool => @\unlink($path)); + + $database = new \PDO("sqlite:{$path}"); + $database->exec('CREATE TABLE items (value INTEGER)'); + + return $path; + } + + /** + * A connection whose session the server can end: the server rolls the session's + * transaction back, and every call on the old handle then fails as a dropped MySQL + * connection does while the handle still reports its transaction. A reconnect opens + * a new session on the same database. + * + * @return array{PDO, Closure(): void} + */ + private function createLosableConnection(string $path): array + { + $pdo = new class ("sqlite:{$path}", null, null) extends PDO { + public function endSession(): void + { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + + $this->pdo = new class () extends \PDO { + public function __construct() + { + } + + #[\Override] + public function inTransaction(): bool + { + return true; + } + + #[\Override] + public function beginTransaction(): bool + { + throw self::lost(); + } + + #[\Override] + public function commit(): bool + { + throw self::lost(); + } + + #[\Override] + public function rollBack(): bool + { + throw self::lost(); + } + + #[\Override] + public function exec(string $statement): int|false + { + throw self::lost(); + } + + /** + * @param array $options + */ + #[\Override] + public function prepare(string $query, array $options = []): \PDOStatement|false + { + throw self::lost(); + } + + #[\Override] + public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): \PDOStatement|false + { + throw self::lost(); + } + + private static function lost(): PDOException + { + return EngineError::create('HY000', 2006, 'SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'); + } + }; + } + }; + + return [$pdo, $pdo->endSession(...)]; + } + + /** + * @return array + */ + private function values(string $path): array + { + $statement = (new \PDO("sqlite:{$path}"))->query('SELECT value FROM items ORDER BY value'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + return \array_map(static function (mixed $value): int { + self::assertIsNumeric($value); + + return (int) $value; + }, $statement->fetchAll(\PDO::FETCH_COLUMN)); + } +} diff --git a/tests/unit/MariaDBMinorsTest.php b/tests/unit/MariaDBMinorsTest.php new file mode 100644 index 0000000000..93a63bc02e --- /dev/null +++ b/tests/unit/MariaDBMinorsTest.php @@ -0,0 +1,115 @@ +}> + */ + public static function adapters(): iterable + { + yield 'MariaDB' => [MariaDB::class]; + yield 'MySQL' => [MySQL::class]; + } + + /** + * @return iterable, string, string}> + */ + public static function engines(): iterable + { + yield 'MariaDB' => [MariaDB::class, 'SET max_statement_time = 1.000000', 'SET max_statement_time = 0.000000']; + yield 'MySQL' => [MySQL::class, 'SET SESSION MAX_EXECUTION_TIME = 1000', 'SET SESSION MAX_EXECUTION_TIME = 0']; + } + + /** + * An object that is no driver fails on the first access, so any read of the + * driver while no timeout is requested shows up as an exception. + * + * @param class-string $class + */ + #[DataProvider('adapters')] + public function testNoTimeoutNeverTouchesTheDriver(string $class): void + { + $adapter = new $class(new \stdClass()); + + $adapter->clearTimeout(); + $adapter->clearTimeout(Event::DocumentFind); + $adapter->setTimeout(1000, Event::DocumentFind); + $adapter->clearTimeout(Event::DocumentFind); + $adapter->clearTimeout(); + + $this->assertSame(0, $adapter->getTimeout()); + $this->assertSame(0, $adapter->getTimeout(Event::DocumentFind)); + } + + /** + * @param class-string $class + */ + #[DataProvider('adapters')] + public function testPooledBorrowWithoutATimeoutNeverTouchesTheDriver(string $class): void + { + $connection = new $class(new \stdClass()); + $adapter = new Pool(new UtopiaPool(new Stack(), 'no-driver', 1, fn (): MariaDB => $connection, timeout: 0.0)); + $adapter->setAuthorization(new Authorization()); + + $this->assertSame($connection->limits()->varchar, $adapter->limits()->varchar); + $this->assertSame($connection->limits()->varchar, $adapter->limits()->varchar); + } + + /** + * Swoole's PDOProxy counts each reconnect as a round, and the session it opens + * starts at the server default: a timeout set before the reconnect is written + * again, and clearing it there needs no statement. + * + * @param class-string $class + */ + #[DataProvider('engines')] + public function testTimeoutIsReappliedAfterAReconnect(string $class, string $set, string $clear): void + { + if (! \class_exists(PDOProxy::class)) { + $this->markTestSkipped('Swoole\'s library is not loaded'); + } + + $round = 0; + $statements = []; + $proxy = $this->createStub(PDOProxy::class); + $proxy->method('getRound')->willReturnCallback(function () use (&$round): int { + return $round; + }); + $proxy->method('__call')->willReturnCallback(function (string $name, array $arguments) use (&$statements): int { + $statements[] = [$name, ...$arguments]; + + return 0; + }); + + $adapter = new $class($proxy); + $adapter->setTimeout(1000); + $adapter->clearTimeout(); + $adapter->setTimeout(1000); + $round = 1; + $adapter->setTimeout(1000); + $round = 2; + $adapter->clearTimeout(); + $adapter->setTimeout(1000); + + $this->assertSame([ + ['exec', $set], + ['exec', $clear], + ['exec', $set], + ['exec', $set], + ['exec', $set], + ], $statements); + } +} diff --git a/tests/unit/MariaDBTimeoutTest.php b/tests/unit/MariaDBTimeoutTest.php new file mode 100644 index 0000000000..91227d2cf6 --- /dev/null +++ b/tests/unit/MariaDBTimeoutTest.php @@ -0,0 +1,127 @@ +getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2)) + ->method('exec') + ->willReturnCallback(function (string $sql) use (&$statements): int { + $statements[] = $sql; + + return 0; + }); + + $adapter = new MariaDB($pdo); + $adapter->setTimeout(1000); + $adapter->setTimeout(1000); + $adapter->clearTimeout(); + $adapter->clearTimeout(); + + $this->assertSame([ + 'SET max_statement_time = 1.000000', + 'SET max_statement_time = 0.000000', + ], $statements); + } + + public function testTimeoutUsesFixedPointFormatting(): void + { + $milliseconds = PHP_INT_MAX; + $expected = 'SET max_statement_time = '.\sprintf('%.6F', $milliseconds / 1000.0); + + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('exec') + ->with($expected) + ->willReturn(0); + + $adapter = new MariaDB($pdo); + $adapter->setTimeout($milliseconds); + } + + /** + * @return iterable, string, string}> + */ + public static function engines(): iterable + { + yield 'MariaDB' => [MariaDB::class, 'SET max_statement_time = 1.000000', 'SET max_statement_time = 0.000000']; + yield 'MySQL' => [MySQL::class, 'SET SESSION MAX_EXECUTION_TIME = 1000', 'SET SESSION MAX_EXECUTION_TIME = 0']; + } + + /** + * The wrapper runs its configured session again on the connection a transparent + * reconnect opens, before it retries the call that found the old one gone. + * + * @param class-string $class + */ + #[DataProvider('engines')] + public function testTimeoutIsConfiguredAsSessionStateOfTheDatabasePDO(string $class, string $set, string $clear): void + { + $configured = []; + $pdo = $this->createMock(DatabasePDO::class); + $pdo->expects($this->exactly(2)) + ->method('configure') + ->willReturnCallback(function (string $setting, string $statement) use (&$configured): void { + $configured[] = [$setting, $statement]; + }); + + $adapter = new $class($pdo); + $adapter->setTimeout(1000); + $adapter->setTimeout(1000); + $adapter->clearTimeout(); + + $this->assertSame([$set, $clear], \array_column($configured, 1)); + $this->assertCount(1, \array_unique(\array_column($configured, 0)), 'Clearing must replace the setting, not add another'); + } + + /** + * Swoole's PDOProxy reconnects on its own, counting each reconnect as a round, + * and its new session starts from the server default. + * + * @param class-string $class + */ + #[DataProvider('engines')] + public function testTimeoutIsSetAgainAfterSwooleProxyReconnects(string $class, string $set, string $clear): void + { + if (! \class_exists(PDOProxy::class)) { + $this->markTestSkipped('Swoole\'s library is not loaded'); + } + + $round = 0; + $statements = []; + $proxy = $this->createStub(PDOProxy::class); + $proxy->method('getRound')->willReturnCallback(function () use (&$round): int { + return $round; + }); + $proxy->method('__call')->willReturnCallback(function (string $name, array $arguments) use (&$statements): int { + $statements[] = [$name, ...$arguments]; + + return 0; + }); + + $adapter = new $class($proxy); + $adapter->setTimeout(1000); + $round = 1; + $adapter->setTimeout(1000); + $adapter->clearTimeout(); + $round = 2; + $adapter->clearTimeout(); + + $this->assertSame([['exec', $set], ['exec', $set], ['exec', $clear]], $statements); + } +} diff --git a/tests/unit/MemoryPermissionsTenantPerDocumentTest.php b/tests/unit/MemoryPermissionsTenantPerDocumentTest.php new file mode 100644 index 0000000000..d049320a77 --- /dev/null +++ b/tests/unit/MemoryPermissionsTenantPerDocumentTest.php @@ -0,0 +1,231 @@ +authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + + $this->database = (new Database(new Memory(), new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('memory_permissions_tenant_per_document') + ->setNamespace('memory_permissions_tenant_per_document') + ->setSharedTables(true) + ->setTenant(null) + ->setTenantPerDocument(true); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: true, + )); + + foreach ([self::TENANT, self::OTHER_TENANT] as $tenant) { + $this->database->createDocument( + self::COLLECTION, + $this->readers([self::ALICE, self::BOB]) + ->setAttribute('$id', self::DOCUMENT) + ->setAttribute('$tenant', $tenant) + ->setAttribute('title', 'first'), + ); + } + } + + public function testCreatingWithNoTenantSelectedGrantsReadUnderEachDocumentsTenant(): void + { + $this->assertNothingRevoked(); + } + + public function testAnUpdateUnderTheDocumentsTenantRevokesOnlyThatTenantsGrant(): void + { + $this->database->withTenant( + self::TENANT, + fn (): Document => $this->database->updateDocument(self::COLLECTION, self::DOCUMENT, $this->readers([self::ALICE])), + ); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testABatchUpdateUnderTheDocumentsTenantRevokesOnlyThatTenantsGrant(): void + { + $this->database->withTenant( + self::TENANT, + fn (): int => $this->database->updateDocuments(self::COLLECTION, $this->readers([self::ALICE]), [Query::equal('$id', [self::DOCUMENT])]), + ); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testARenameUnderTheDocumentsTenantMovesOnlyThatTenantsGrants(): void + { + $this->database->withTenant( + self::TENANT, + fn (): Document => $this->database->updateDocument( + self::COLLECTION, + self::DOCUMENT, + $this->readers([self::ALICE])->setAttribute('$id', self::RENAMED), + ), + ); + + $this->assertSame([self::TENANT => [self::RENAMED], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::ALICE)); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + } + + public function testADeleteUnderTheDocumentsTenantRemovesOnlyThatTenantsGrants(): void + { + $this->database->withTenant( + self::TENANT, + fn (): bool => $this->database->deleteDocument(self::COLLECTION, self::DOCUMENT), + ); + + $this->assertOnlyTheOtherTenantReadable(); + } + + public function testABatchDeleteUnderTheDocumentsTenantRemovesOnlyThatTenantsGrants(): void + { + $this->database->withTenant( + self::TENANT, + fn (): int => $this->database->deleteDocuments(self::COLLECTION, [Query::equal('$id', [self::DOCUMENT])]), + ); + + $this->assertOnlyTheOtherTenantReadable(); + } + + public function testAnUpdateWithNoTenantSelectedChangesNoGrant(): void + { + $updated = $this->database->updateDocument(self::COLLECTION, self::DOCUMENT, $this->readers([self::ALICE])); + + $this->assertTrue($updated->isEmpty(), 'With no tenant selected no tenant\'s document is found to update'); + $this->assertNothingRevoked(); + } + + public function testABatchUpdateWithNoTenantSelectedChangesNoGrant(): void + { + $this->assertSame(0, $this->database->updateDocuments(self::COLLECTION, $this->readers([self::ALICE]), [Query::equal('$id', [self::DOCUMENT])])); + $this->assertNothingRevoked(); + } + + public function testADeleteWithNoTenantSelectedChangesNoGrant(): void + { + $this->assertFalse($this->database->deleteDocument(self::COLLECTION, self::DOCUMENT)); + $this->assertNothingRevoked(); + } + + public function testABatchDeleteWithNoTenantSelectedIsRejected(): void + { + try { + $this->database->deleteDocuments(self::COLLECTION, [Query::equal('$id', [self::DOCUMENT])]); + $this->fail('A batch delete with no tenant selected must be rejected'); + } catch (DatabaseException $exception) { + $this->assertStringStartsWith('Missing tenant', $exception->getMessage()); + } + + $this->assertNothingRevoked(); + } + + private function assertNothingRevoked(): void + { + $everywhere = [self::TENANT => [self::DOCUMENT], self::OTHER_TENANT => [self::DOCUMENT]]; + + $this->assertSame($everywhere, $this->readableBy(self::ALICE)); + $this->assertSame($everywhere, $this->readableBy(self::BOB)); + } + + private function assertBobRevokedOnlyUnderTheTenant(): void + { + $this->assertSame([self::TENANT => [self::DOCUMENT], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::ALICE)); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + } + + private function assertOnlyTheOtherTenantReadable(): void + { + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::ALICE)); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + } + + /** + * @param list $readers + */ + private function readers(array $readers): Document + { + return new Document([ + '$permissions' => \array_map( + static fn (string $reader): string => Permission::read(Role::user($reader)), + $readers, + ), + ]); + } + + /** + * @return array> + */ + private function readableBy(string $reader): array + { + $roles = $this->authorization->getRoles(); + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::user($reader)->toString()); + + try { + $readable = []; + foreach ([self::TENANT, self::OTHER_TENANT] as $tenant) { + $readable[$tenant] = \array_values(\array_map( + static fn (Document $document): string => $document->getId(), + $this->database->withTenant($tenant, fn (): array => $this->database->find(self::COLLECTION)), + )); + } + + return $readable; + } finally { + $this->authorization->cleanRoles(); + foreach ($roles as $role) { + $this->authorization->addRole($role); + } + } + } +} diff --git a/tests/unit/Mirror/ClearDocumentTypesPost.php b/tests/unit/Mirror/ClearDocumentTypesPost.php new file mode 100644 index 0000000000..7834643595 --- /dev/null +++ b/tests/unit/Mirror/ClearDocumentTypesPost.php @@ -0,0 +1,9 @@ +clearDocumentTypes(); + + $this->assertSame(Collection::class, $mirror->getDocumentType(Database::METADATA)); + $this->assertSame(Collection::class, $source->getDocumentType(Database::METADATA)); + $this->assertSame(Collection::class, $destination->getDocumentType(Database::METADATA)); + $this->assertInstanceOf(Collection::class, $mirror->getDocument(Database::METADATA, HookFixture::COLLECTION)); + } + + public function testClearingEveryTypeDropsTheCustomTypes(): void + { + $source = HookFixture::memory(); + $mirror = new Mirror($source, new Database(new Memory(), new Cache(new None()))); + $mirror->setDocumentType(HookFixture::COLLECTION, ClearDocumentTypesPost::class); + + $mirror->clearDocumentTypes(); + + $this->assertNull($mirror->getDocumentType(HookFixture::COLLECTION)); + $this->assertNull($source->getDocumentType(HookFixture::COLLECTION)); + } +} diff --git a/tests/unit/Mirror/FailureTest.php b/tests/unit/Mirror/FailureTest.php new file mode 100644 index 0000000000..b059d34270 --- /dev/null +++ b/tests/unit/Mirror/FailureTest.php @@ -0,0 +1,189 @@ +database(new Memory()); + $mirror = new Mirror($this->database(new Memory()), $destination); + $failures = $this->failures($mirror); + $destination->createCollection($this->notes()); + + $created = $mirror->createCollection($this->notes()); + + $failure = $this->only($failures); + $this->assertSame('notes', $created->getId()); + $this->assertSame('createCollection', $failure->method); + $this->assertSame(Event::CollectionCreate, $failure->event); + $this->assertInstanceOf(DuplicateException::class, $failure->error); + } + + public function testAnIndexBatchTheDestinationRejectsIsReportedAsAnIndexesCreate(): void + { + $destination = $this->database(new Memory()); + $mirror = new Mirror($this->database(new Memory()), $destination); + $mirror->createCollection($this->notes()); + $destination->createIndex('notes', Index::key(key: 'title', attributes: ['title'])); + $failures = $this->failures($mirror); + + $mirror->createIndexes('notes', [Index::key(key: 'title', attributes: ['title'])]); + + $failure = $this->only($failures); + $this->assertSame('createIndexes', $failure->method); + $this->assertSame(Event::IndexesCreate, $failure->event); + $this->assertInstanceOf(DuplicateException::class, $failure->error); + } + + public function testAnUpsertTheDestinationRejectsIsReportedAsADocumentUpsert(): void + { + $destination = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $mirror = new Mirror($this->database(new SQLite(new PDO('sqlite::memory:'))), $destination); + $mirror->getAuthorization()->addRole(Role::any()->toString()); + $mirror->createCollection(Collection::create( + id: 'notes', + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $destination->deleteCollection('notes'); + $failures = $this->failures($mirror); + + $mirror->upsertDocument('notes', new Document([Document::ID => 'first', 'title' => 'first'])); + + $failure = $this->only($failures); + $this->assertSame('upsertDocument', $failure->method); + $this->assertSame(Event::DocumentUpsert, $failure->event); + $this->assertInstanceOf(NotFoundException::class, $failure->error); + } + + public function testASettingTheDestinationRejectsIsReportedWithoutAnEvent(): void + { + $refusal = new RuntimeException('destination unreachable'); + $destination = $this->database(new class ($refusal) extends Memory { + public function __construct(private readonly RuntimeException $refusal) + { + parent::__construct(); + } + + /** + * @return array + */ + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::AlterLock]; + } + + #[\Override] + public function setLocks(bool $locks): static + { + throw $this->refusal; + } + }); + $mirror = new Mirror($this->database(new Memory()), $destination); + $failures = $this->failures($mirror); + + $mirror->setLocks(true); + + $failure = $this->only($failures); + $this->assertSame('setLocks', $failure->method); + $this->assertNull($failure->event); + $this->assertSame($refusal, $failure->error); + } + + public function testEveryCallbackReceivesTheSameFailure(): void + { + $destination = $this->database(new Memory()); + $mirror = new Mirror($this->database(new Memory()), $destination); + $destination->createCollection($this->notes()); + $first = []; + $second = []; + + $chained = $mirror + ->onError(static function (Failure $failure) use (&$first): void { + $first[] = $failure; + }) + ->onError(static function (Failure $failure) use (&$second): void { + $second[] = $failure; + }); + $mirror->createCollection($this->notes()); + + $this->assertSame($mirror, $chained); + $this->assertCount(1, $first); + $this->assertSame($first, $second); + } + + public function testAMirrorWithoutAFailingDestinationReportsNothing(): void + { + $mirror = new Mirror($this->database(new Memory()), $this->database(new Memory())); + $failures = $this->failures($mirror); + + $mirror->createCollection($this->notes()); + + $this->assertSame([], $failures->getArrayCopy()); + } + + /** + * @return \ArrayObject + */ + private function failures(Mirror $mirror): \ArrayObject + { + /** @var \ArrayObject $failures */ + $failures = new \ArrayObject(); + $mirror->onError(static function (Failure $failure) use ($failures): void { + $failures->append($failure); + }); + + return $failures; + } + + /** + * @param \ArrayObject $failures + */ + private function only(\ArrayObject $failures): Failure + { + $this->assertCount(1, $failures); + $failure = $failures[0] ?? null; + $this->assertInstanceOf(Failure::class, $failure); + + return $failure; + } + + private function database(Adapter $adapter): Database + { + $database = (new Database($adapter, new Cache(new None()))) + ->setDatabase('mirror') + ->setNamespace('failure_'.\uniqid()); + $database->create(); + + return $database; + } + + private function notes(): Collection + { + return Collection::create(id: 'notes', attributes: [Attribute::string(key: 'title', size: 64)]); + } +} diff --git a/tests/unit/Mirror/RelationshipsHookTest.php b/tests/unit/Mirror/RelationshipsHookTest.php new file mode 100644 index 0000000000..5faf799845 --- /dev/null +++ b/tests/unit/Mirror/RelationshipsHookTest.php @@ -0,0 +1,131 @@ + + */ + public static function preparing(): iterable + { + yield 'preparing' => [true]; + yield 'not preparing' => [false]; + } + + public function testTheMirrorKeepsTheGivenHook(): void + { + $mirror = new Mirror($this->database(new Memory()), $this->database(new Memory())); + $hook = new Relationships(); + + $mirror->addHook($hook); + + $this->assertSame($hook, $mirror->getRelationshipHook()); + } + + public function testBothSidesRelateDocumentsWithACopyOfTheGivenHook(): void + { + $source = $this->database(new Memory()); + $destination = $this->database(new Memory()); + $mirror = new Mirror($source, $destination); + $hook = new class (prepare: false) extends Relationships { + }; + + $mirror->addHook($hook); + + $this->assertInstanceOf($hook::class, $source->getRelationshipHook()); + $this->assertInstanceOf($hook::class, $destination->getRelationshipHook()); + $this->assertNotSame($hook, $source->getRelationshipHook()); + $this->assertNotSame($source->getRelationshipHook(), $destination->getRelationshipHook()); + } + + #[DataProvider('preparing')] + public function testBothSidesHonourPrepare(bool $prepare): void + { + $source = $this->family(new CountingMemory()); + $destination = $this->family(new CountingMemory()); + $mirror = new Mirror($source, $destination); + + $mirror->addHook(new Relationships(prepare: $prepare)); + + foreach (['source' => $source, 'destination' => $destination] as $side => $database) { + $adapter = $database->getAdapter(); + $this->assertInstanceOf(CountingMemory::class, $adapter); + $adapter->reset(); + + $database->createDocument('parents', new Document([ + '$id' => 'p1', + 'name' => 'p1', + 'children' => [new Document(['$id' => 'c1', 'name' => 'c1'])], + ])); + + if ($prepare) { + $this->assertSame(0, $adapter->documentReads, 'The '.$side.' read a related document it prepares'); + } else { + $this->assertGreaterThan(0, $adapter->documentReads, 'The '.$side.' prepared a related document it relates one by one'); + } + } + } + + public function testTheSourceRelatesDocumentsWithoutADestination(): void + { + $source = $this->family(new Memory()); + $mirror = new Mirror($source); + + $mirror->addHook(new Relationships(prepare: false)); + $mirror->createDocument('parents', new Document([ + '$id' => 'p1', + 'name' => 'p1', + 'children' => [new Document(['$id' => 'c1', 'name' => 'c1'])], + ])); + + $child = $source->skipRelationships(static fn (): Document => $source->getDocument('children', 'c1')); + $this->assertSame('p1', $child->getAttribute('parent')); + } + + private function database(Memory $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('mirror_relationships')->setNamespace('mirror'); + $database->create(); + + return $database; + } + + private function family(Memory $adapter): Database + { + $database = $this->database($adapter); + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ]; + foreach (['parents', 'children'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: $permissions, + documentSecurity: false, + )); + } + $database->createRelationship('parents', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, key: 'children', twoWayKey: 'parent')); + + return $database; + } +} diff --git a/tests/unit/Mirror/UpdateAttributeFilterTest.php b/tests/unit/Mirror/UpdateAttributeFilterTest.php new file mode 100644 index 0000000000..1fb2729bc3 --- /dev/null +++ b/tests/unit/Mirror/UpdateAttributeFilterTest.php @@ -0,0 +1,125 @@ +mirror($destination, static function (Document $attribute): Document { + $attribute->setAttribute('default', 'mirrored'); + + return $attribute; + }); + $errors = $this->errors($mirror); + + $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new AttributeUpdate(default: 'source')); + + $this->assertSame([], $errors->getArrayCopy()); + $this->assertSame('source', $this->stored($mirror->getSource(), 'title')->default); + $this->assertSame('mirrored', $this->stored($destination, 'title')->default); + } + + public function testAFilteredUpdateDoesNotRewriteAnUnchangedColumn(): void + { + $destination = HookFixture::database(new class () extends Memory { + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool + { + throw new DatabaseException('Column rewrite refused'); + } + }); + $mirror = $this->mirror($destination, static function (Document $attribute): Document { + $filtered = clone $attribute; + $filtered->setAttribute('default', 'mirrored'); + + return $filtered; + }); + $errors = $this->errors($mirror); + + $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new AttributeUpdate(default: 'source', required: false)); + + $this->assertSame([], $errors->getArrayCopy()); + $this->assertSame('mirrored', $this->stored($destination, 'title')->default); + $this->assertSame(64, $this->stored($destination, 'title')->size); + } + + public function testAFilterThatChangesAFieldTheUpdateLeftAloneReplicatesIt(): void + { + $destination = HookFixture::memory(); + $mirror = $this->mirror($destination, static function (Document $attribute): Document { + $attribute->setAttribute('size', 128); + + return $attribute; + }); + $errors = $this->errors($mirror); + + $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new AttributeUpdate(default: 'source')); + + $this->assertSame([], $errors->getArrayCopy()); + $this->assertSame(64, $this->stored($mirror->getSource(), 'title')->size); + $this->assertSame(128, $this->stored($destination, 'title')->size); + $this->assertSame('source', $this->stored($destination, 'title')->default); + } + + /** + * @param Closure(Document): Document $transform + */ + private function mirror(Database $destination, Closure $transform): Mirror + { + return new Mirror(HookFixture::memory(), $destination, [new class ($transform) extends Filter { + /** + * @param Closure(Document): Document $transform + */ + public function __construct(private readonly Closure $transform) + { + } + + #[\Override] + public function beforeUpdateAttribute(Database $source, Database $destination, string $collectionId, string $attributeId, ?Document $attribute = null): ?Document + { + return $attribute === null ? null : ($this->transform)($attribute); + } + }]); + } + + /** + * @return ArrayObject + */ + private function errors(Mirror $mirror): ArrayObject + { + /** @var ArrayObject $errors */ + $errors = new ArrayObject(); + $mirror->onError(static function (Failure $failure) use ($errors): void { + $errors[] = $failure->method.': '.$failure->error->getMessage(); + }); + + return $errors; + } + + private function stored(Database $database, string $key): Attribute + { + foreach ($database->getCollection(HookFixture::COLLECTION)->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + $this->fail('Attribute '.$key.' is missing from the collection metadata'); + } +} diff --git a/tests/unit/MirrorReplicationTest.php b/tests/unit/MirrorReplicationTest.php new file mode 100644 index 0000000000..d71e5268ed --- /dev/null +++ b/tests/unit/MirrorReplicationTest.php @@ -0,0 +1,940 @@ + + */ + private array $delays = []; + + /** + * Destination writes in the order they completed, as [document id, title written, coroutine id]. + * + * @var list + */ + private array $writes = []; + + /** + * @var list + */ + private array $errors = []; + + /** + * Destination adapter calls in progress, and the most that were ever in progress at once. + */ + private int $busy = 0; + + private int $peak = 0; + + #[\Override] + protected function setUp(): void + { + if (! \extension_loaded('swoole')) { + $this->markTestSkipped('ext-swoole is required for asynchronous replication'); + } + + $this->authorization = new Authorization(); + $this->destination = new Database($this->yieldingAdapter(), new Cache(new None())); + $this->mirror = new Mirror(new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())), $this->destination); + $this->mirror + ->setAuthorization($this->authorization) + ->setDatabase('mirror') + ->setNamespace('replication_'.\uniqid()) + ->create(); + $this->mirror->onError(function (Failure $failure): void { + $this->errors[] = [$failure->method, $failure->error->getMessage()]; + }); + + $this->authorization->skip(function (): void { + $this->mirror->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'views')], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + $this->mirror->createCollection(Collection::create( + id: self::SECRETS, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $this->mirror->createDocument(self::NOTES, new Document([Document::ID => 'public', 'title' => 'public'])); + $this->mirror->createDocument(self::SECRETS, new Document([ + Document::ID => 'secret', + 'title' => 'secret', + Document::PERMISSIONS => [Permission::read(Role::user('alice'))], + ])); + }); + + $this->writes = []; + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::any()->toString()); + } + + public function testAReplicatedWriteLeavesTheCallersAuthorizationUnchanged(): void + { + $seen = null; + $status = null; + + $this->inCoroutine(function () use (&$seen, &$status): void { + $this->mirror->deleteDocument(self::NOTES, 'public'); + $seen = $this->mirror->find(self::SECRETS); + $status = $this->authorization->getStatus(); + }); + + $this->assertSame([], $seen, 'A guest must not read a document only alice may read'); + $this->assertTrue($status); + $this->assertTrue($this->authorization->getStatus()); + $this->assertSame([], $this->errors); + $this->assertSame([['public', self::DELETED]], $this->titlesWritten()); + } + + public function testReplicationRunsUnderTheCallersStateAfterTheCallerLeftItsScope(): void + { + $this->inCoroutine(function (): void { + $this->authorization->skip(fn (): bool => $this->mirror->deleteDocument(self::SECRETS, 'secret')); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['secret', self::DELETED]], $this->titlesWritten()); + $this->assertTrue($this->authorization->skip(fn (): bool => $this->destination->getDocument(self::SECRETS, 'secret')->isEmpty())); + } + + public function testReplicationOutsideACoroutineIsSynchronous(): void + { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'v0'])]); + $this->mirror->deleteDocument(self::NOTES, 'public'); + + $this->assertSame([['first', 'v0'], ['public', self::DELETED]], $this->titlesWritten()); + $this->assertSame([-1], \array_values(\array_unique(\array_column($this->writes, 2))), 'Every destination call runs in the caller'); + $this->assertSame('v0', $this->destination->getDocument(self::NOTES, 'first')->getAttribute('title')); + $this->assertTrue($this->destination->getDocument(self::NOTES, 'public')->isEmpty()); + } + + /** + * Delays that make later writes finish first when nothing orders them. + * + * @return iterable}> + */ + public static function interleavings(): iterable + { + yield 'latest first' => [['v0' => 0.05, 'v1' => 0.04, 'v2' => 0.03, 'v3' => 0.02, self::DELETED => 0.01]]; + yield 'mixed' => [['v0' => 0.02, 'v1' => 0.05, 'v2' => 0.01, 'v3' => 0.04, self::DELETED => 0.03]]; + yield 'create last' => [['v0' => 0.04, 'v1' => 0.01, 'v2' => 0.05, 'v3' => 0.02, self::DELETED => 0.03]]; + } + + /** + * @param array $delays + */ + #[DataProvider('interleavings')] + public function testWritesToOneDocumentReachTheDestinationInOrder(array $delays): void + { + $this->delays = $delays; + + foreach (['kept' => false, 'removed' => true] as $id => $delete) { + $this->inCoroutine(function () use ($id, $delete): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => $id, 'title' => 'v0'])]); + $this->mirror->updateDocuments(self::NOTES, new Document(['title' => 'v1']), [Query::equal(Document::ID, [$id])]); + $this->mirror->updateDocument(self::NOTES, $id, new Document(['title' => 'v2'])); + $this->mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => $id, 'title' => 'v3'])]); + if ($delete) { + $this->mirror->deleteDocument(self::NOTES, $id); + } + }); + } + + $this->assertSame([], $this->errors); + $this->assertSame([ + ['kept', 'v0'], ['kept', 'v1'], ['kept', 'v2'], ['kept', 'v3'], + ['removed', 'v0'], ['removed', 'v1'], ['removed', 'v2'], ['removed', 'v3'], ['removed', self::DELETED], + ], $this->titlesWritten()); + $this->assertSame('v3', $this->destination->getDocument(self::NOTES, 'kept')->getAttribute('title')); + $this->assertTrue($this->destination->getDocument(self::NOTES, 'removed')->isEmpty()); + } + + public function testAFailedReplicationIsReportedAndDoesNotHoldBackLaterWritesToTheDocument(): void + { + $this->delays = ['v0' => 0.03, 'broken' => 0.02, 'v2' => 0.01]; + + $this->inCoroutine(function (): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'v0'])]); + $this->mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'broken'])]); + $this->mirror->updateDocuments(self::NOTES, new Document(['title' => 'v2']), [Query::equal(Document::ID, ['first'])]); + }); + + $this->assertSame([['upsertDocuments', 'destination rejected broken']], $this->errors); + $this->assertSame([['first', 'v0'], ['first', 'v2']], $this->titlesWritten()); + $this->assertSame('v2', $this->destination->getDocument(self::NOTES, 'first')->getAttribute('title')); + } + + public function testARecreatedDocumentWaitsForItsEarlierDeletion(): void + { + $this->delays = [self::DELETED => 0.03]; + + $this->inCoroutine(function (): void { + $this->mirror->deleteDocument(self::NOTES, 'public'); + $this->mirror->createDocument(self::NOTES, new Document([Document::ID => 'public', 'title' => 'v1'])); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['public', self::DELETED], ['public', 'v1']], $this->titlesWritten()); + $this->assertSame('v1', $this->destination->getDocument(self::NOTES, 'public')->getAttribute('title')); + } + + public function testACounterUpdateWaitsForEarlierReplicationsOfTheDocument(): void + { + $this->delays = ['v0' => 0.03]; + + $this->inCoroutine(function (): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'v0', 'views' => 1])]); + $this->mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'v1', 'views' => 5])]); + $this->mirror->increaseDocumentAttribute(self::NOTES, 'first', 'views', 2); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['first', 'v0'], ['first', 'v1'], ['first', self::LOCKED], ['first', self::INCREASED]], $this->titlesWrittenAndLocked()); + $this->assertSame(7, $this->destination->getDocument(self::NOTES, 'first')->getAttribute('views')); + } + + public function testReplicationsUseTheDestinationOneAtATimeInTheOrderTheyWereMade(): void + { + $this->delays = ['slow' => 0.05, 'fast' => 0.01]; + $this->peak = 0; + + $this->inCoroutine(function (): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'slow'])]); + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'second', 'title' => 'fast'])]); + $this->mirror->updateDocument(self::NOTES, 'public', new Document(['title' => 'synchronous'])); + $this->mirror->deleteDocument(self::NOTES, 'first'); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['first', 'slow'], ['second', 'fast'], ['public', 'synchronous'], ['first', self::DELETED]], $this->titlesWritten()); + $this->assertSame(1, $this->peak, 'Replications sharing one destination connection must not overlap'); + } + + public function testAwaitReplicationsReturnsOnceEveryQueuedReplicationReachedTheDestination(): void + { + $this->delays = ['queued' => 0.03, 'broken' => 0.01]; + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'queued'])]); + $this->mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'broken'])]); + $seen['queued'] = [$this->titlesWritten(), $this->errors]; + $this->mirror->awaitReplications(); + $seen['awaited'] = [$this->titlesWritten(), $this->errors]; + $this->mirror->awaitReplications(); + }); + + $this->assertSame([[], []], $seen['queued']); + $this->assertSame([[['first', 'queued']], [['upsertDocuments', 'destination rejected broken']]], $seen['awaited']); + } + + public function testAwaitReplicationsReturnsAfterItsTimeoutWhileAReplicationIsStillRunning(): void + { + $this->delays = ['slow' => 0.2]; + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'slow'])]); + $this->mirror->awaitReplications(20); + $seen['timedOut'] = $this->titlesWritten(); + $this->mirror->awaitReplications(); + $seen['awaited'] = $this->titlesWritten(); + }); + + $this->assertSame([], $seen['timedOut']); + $this->assertSame([['first', 'slow']], $seen['awaited']); + } + + public function testAwaitReplicationsWithAZeroTimeoutDoesNotWait(): void + { + $this->delays = ['slow' => 0.05]; + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'slow'])]); + $this->mirror->awaitReplications(0); + $seen['notWaited'] = $this->titlesWritten(); + $this->mirror->awaitReplications(); + $seen['awaited'] = $this->titlesWritten(); + }); + + $this->assertSame([], $seen['notWaited']); + $this->assertSame([['first', 'slow']], $seen['awaited']); + } + + public function testAwaitReplicationsRefusesANegativeTimeout(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('A replication timeout cannot be negative, -1 given'); + + $this->mirror->awaitReplications(-1); + } + + public function testAQueuedReplicationFailureCarriesItsEvent(): void + { + /** @var list $failures */ + $failures = []; + $this->mirror->onError(static function (Failure $failure) use (&$failures): void { + $failures[] = $failure; + }); + + $this->inCoroutine(function (): void { + $this->mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'broken'])]); + $this->mirror->awaitReplications(); + }); + + $this->assertCount(1, $failures); + $this->assertSame('upsertDocuments', $failures[0]->method); + $this->assertSame(Event::DocumentsUpsert, $failures[0]->event); + $this->assertSame('destination rejected broken', $failures[0]->error->getMessage()); + } + + public function testAwaitReplicationsOutsideACoroutineReturnsAtOnce(): void + { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'v0'])]); + $this->mirror->awaitReplications(); + + $this->assertSame([['first', 'v0']], $this->titlesWritten()); + } + + public function testAWriteThroughTheMirrorFromOnErrorDoesNotWaitForTheReplicationThatReportedIt(): void + { + $this->mirror->onError(function (Failure $failure): void { + $this->mirror->createDocument(self::NOTES, new Document([Document::ID => 'reported', 'title' => $failure->method])); + $this->mirror->awaitReplications(); + }); + + $this->inCoroutine(function (): void { + $this->mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'broken'])]); + $this->mirror->updateDocument(self::NOTES, 'public', new Document(['title' => 'after'])); + }); + + $this->assertSame([['upsertDocuments', 'destination rejected broken']], $this->errors); + $this->assertSame([['reported', 'upsertDocuments'], ['public', 'after']], $this->titlesWritten()); + } + + public function testADocumentWrittenThroughARelationshipIsNotOvertakenByALaterWriteToIt(): void + { + $this->relate(); + $this->delays = ['parent' => 0.03, 'nested' => 0.03]; + + $this->inCoroutine(function (): void { + $this->mirror->createDocuments(self::PARENTS, [new Document([ + Document::ID => 'parent', + 'title' => 'parent', + 'children' => [new Document([Document::ID => 'child', 'title' => 'nested'])], + ])]); + $this->mirror->updateDocument(self::CHILDREN, 'child', new Document(['title' => 'renamed'])); + }); + + $this->assertSame([], $this->errors); + $this->assertSame('renamed', $this->mirror->getSource()->getDocument(self::CHILDREN, 'child')->getAttribute('title')); + $this->assertSame('renamed', $this->destination->getDocument(self::CHILDREN, 'child')->getAttribute('title')); + $this->assertFalse($this->destination->getDocument(self::PARENTS, 'parent')->isEmpty()); + } + + /** + * @return iterable + */ + public static function relationshipChanges(): iterable + { + yield 'deleteRelationship' => [static fn (Mirror $mirror) => $mirror->deleteRelationship(self::PARENTS, 'children')]; + yield 'updateRelationship' => [static fn (Mirror $mirror): Relationship => $mirror->updateRelationship(self::PARENTS, 'children', new RelationshipUpdate(twoWayKey: 'owner'))]; + yield 'deleteCollection' => [static fn (Mirror $mirror) => $mirror->deleteCollection(self::PARENTS)]; + } + + /** + * @param Closure(Mirror): mixed $change + */ + #[DataProvider('relationshipChanges')] + public function testARelationshipChangeWaitsForTheQueuedReplicationsOfTheRelatedCollection(Closure $change): void + { + $this->relate(); + $this->authorization->skip(fn (): Document => $this->mirror->createDocument(self::PARENTS, new Document([Document::ID => 'parent', 'title' => 'parent']))); + $this->writes = []; + $this->delays = ['queued' => 0.03]; + + $this->inCoroutine(function () use ($change): void { + $this->mirror->createDocuments(self::CHILDREN, [new Document([Document::ID => 'child', 'title' => 'queued', 'parent' => 'parent'])]); + $change($this->mirror); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['child', 'queued']], $this->writesOf('child')); + $this->assertSame('queued', $this->destination->getDocument(self::CHILDREN, 'child')->getAttribute('title')); + } + + /** + * A write the mirror replicates before returning, then a write to the same document from another coroutine. + * + * @return iterable}> + */ + public static function overtakingWrites(): iterable + { + yield 'createDocument, then deleteDocument' => [ + static fn (Mirror $mirror): Document => $mirror->createDocument(self::NOTES, new Document([Document::ID => 'raced', 'title' => 'slow'])), + static fn (Mirror $mirror): bool => $mirror->deleteDocument(self::NOTES, 'raced'), + ['slow' => 0.03], + ]; + yield 'updateDocument, then upsertDocuments' => [ + static fn (Mirror $mirror): Document => $mirror->updateDocument(self::NOTES, 'public', new Document(['title' => 'slow'])), + static fn (Mirror $mirror): int => $mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => 'public', 'title' => 'latest'])]), + ['slow' => 0.03], + ]; + yield 'increaseDocumentAttribute, then updateDocuments' => [ + static fn (Mirror $mirror): Document => $mirror->increaseDocumentAttribute(self::NOTES, 'public', 'views', 5), + static fn (Mirror $mirror): int => $mirror->updateDocuments(self::NOTES, new Document(['views' => 1]), [Query::equal(Document::ID, ['public'])]), + [self::LOCKED => 0.03], + ]; + } + + /** + * @param Closure(Mirror): mixed $synchronous + * @param Closure(Mirror): mixed $later + * @param array $delays + */ + #[DataProvider('overtakingWrites')] + public function testALaterReplicationFromAnotherCoroutineDoesNotOvertakeASynchronousOne(Closure $synchronous, Closure $later, array $delays): void + { + $this->delays = $delays; + + $this->inCoroutine(function () use ($synchronous, $later): void { + $writers = new Channel(2); + Coroutine::create(function () use ($synchronous, $writers): void { + $synchronous($this->mirror); + $writers->push(true); + }); + Coroutine::create(function () use ($later, $writers): void { + Coroutine::sleep(0.01); + $later($this->mirror); + $writers->push(true); + }); + $writers->pop(); + $writers->pop(); + }); + + $this->assertSame([], $this->errors); + foreach (['raced', 'public'] as $id) { + $this->assertSame($this->stored($this->mirror->getSource(), $id), $this->stored($this->destination, $id), "The destination's {$id} matches the source's"); + } + } + + public function testReplicationRunsUnderTheCallersRolesAfterTheCallerChangedThem(): void + { + $this->authorization->skip(fn (): Document => $this->mirror->createDocument(self::SECRETS, new Document([ + Document::ID => 'owned', + 'title' => 'owned', + Document::PERMISSIONS => [Permission::read(Role::user('alice')), Permission::delete(Role::user('alice'))], + ]))); + $this->writes = []; + $this->delays = [self::DELETED => 0.02]; + + $this->inCoroutine(function (): void { + $this->authorization->addRole(Role::user('alice')->toString()); + $this->mirror->deleteDocument(self::SECRETS, 'owned'); + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::any()->toString()); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['owned', self::DELETED]], $this->titlesWritten()); + $this->assertSame([Role::any()->toString()], $this->authorization->getRoles()); + } + + public function testAReplicationDoesNotChangeTheCallersRoles(): void + { + $this->delays = [self::DELETED => 0.01]; + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->addRole(Role::user('bob')->toString()); + $this->mirror->deleteDocument(self::NOTES, 'public'); + $seen['whileQueued'] = $this->authorization->getRoles(); + Coroutine::sleep(0.03); + $seen['afterReplication'] = $this->authorization->getRoles(); + }); + + $roles = [Role::any()->toString(), Role::user('bob')->toString()]; + $this->assertSame([], $this->errors); + $this->assertSame(['whileQueued' => $roles, 'afterReplication' => $roles], $seen); + } + + public function testConcurrentReplicationsDoNotShareTheDestinationsSkipDuplicates(): void + { + $this->authorization->skip(fn (): Document => $this->destination->createDocument(self::NOTES, new Document([ + Document::ID => 'second', + 'title' => 'only on the destination', + ]))); + $this->writes = []; + $this->delays = ['skipping' => 0.03]; + + $this->inCoroutine(function (): void { + $this->mirror->ignoreDuplicates(fn (): int => $this->mirror->createDocuments(self::NOTES, [ + new Document([Document::ID => 'first', 'title' => 'skipping']), + ])); + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'second', 'title' => 'duplicate'])]); + }); + + $this->assertCount(1, $this->errors); + $this->assertSame('createDocuments', $this->errors[0][0]); + $this->assertStringContainsString('already exists', $this->errors[0][1]); + $this->assertSame([['first', 'skipping']], $this->titlesWritten()); + $this->assertSame('only on the destination', $this->destination->getDocument(self::NOTES, 'second')->getAttribute('title')); + } + + public function testOverlappingReplicationsLeaveTheDestinationsPreserveDatesSetting(): void + { + $this->delays = ['early' => 0.01, 'late' => 0.03]; + + $this->inCoroutine(function (): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'early'])]); + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'second', 'title' => 'late'])]); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['first', 'early'], ['second', 'late']], $this->titlesWritten()); + $this->assertFalse($this->destination->isPreservingDates()); + } + + public function testAReplicationDoesNotRecheckTheCallersRequestTimestampOnTheDestination(): void + { + \usleep(5000); + $requestTimestamp = new DateTime(); + \usleep(5000); + $this->destination->updateDocument(self::NOTES, 'public', new Document(['views' => 2])); + $this->writes = []; + + $this->inCoroutine(function () use ($requestTimestamp): void { + $this->mirror->withRequestTimestamp( + $requestTimestamp, + fn (): int => $this->mirror->upsertDocuments(self::NOTES, [new Document([Document::ID => 'public', 'title' => 'v1'])]), + ); + }); + + $this->assertSame([], $this->errors); + $this->assertSame([['public', 'v1']], $this->titlesWritten()); + } + + public function testSynchronousAndAsynchronousReplicationsUseTheCallersTenant(): void + { + $destination = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $mirror = new Mirror(new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())), $destination); + $mirror + ->setAuthorization(new Authorization()) + ->setDatabase('mirror') + ->setNamespace('tenants_'.\uniqid()) + ->setSharedTables(true) + ->setTenant(1) + ->create(); + $mirror->onError(function (Failure $failure): void { + $this->errors[] = [$failure->method, $failure->error->getMessage()]; + }); + foreach ([1, 2] as $tenant) { + $mirror->setTenant($tenant); + $mirror->createCollection(Collection::create( + id: self::NOTES, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + $mirror->setTenant(1); + + $mirror->withTenant(2, fn (): Document => $mirror->createDocument(self::NOTES, new Document([Document::ID => 'synchronous', 'title' => 'synchronous']))); + $this->inCoroutine(function () use ($mirror): void { + $mirror->withTenant(2, fn (): int => $mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'asynchronous', 'title' => 'asynchronous'])])); + }); + + $idsUnder = static fn (int $tenant): array => \array_map( + static fn (Document $document): string => $document->getId(), + $destination->withTenant($tenant, static fn (): array => $destination->find(self::NOTES, [Query::orderAsc(Document::ID)])), + ); + $this->assertSame([], $this->errors); + $this->assertSame([1 => [], 2 => ['asynchronous', 'synchronous']], [1 => $idsUnder(1), 2 => $idsUnder(2)]); + $this->assertSame(1, $destination->getTenant()); + } + + /** + * @return iterable + */ + public static function schemaChanges(): iterable + { + yield 'deleteAttribute' => [static fn (Mirror $mirror) => $mirror->deleteAttribute(self::NOTES, 'views')]; + yield 'renameAttribute' => [static fn (Mirror $mirror) => $mirror->renameAttribute(self::NOTES, 'views', 'count')]; + yield 'deleteCollection' => [static fn (Mirror $mirror) => $mirror->deleteCollection(self::NOTES)]; + yield 'updateCollection' => [static fn (Mirror $mirror): Document => $mirror->updateCollection(self::NOTES, new CollectionUpdate(permissions: [Permission::create(Role::any())], documentSecurity: false))]; + yield 'createIndex' => [static fn (Mirror $mirror): Index => $mirror->createIndex(self::NOTES, Index::key(key: 'views_index', attributes: ['views']))]; + yield 'updateAttribute' => [static fn (Mirror $mirror): Attribute => $mirror->updateAttribute(self::NOTES, 'title', new AttributeUpdate(required: false))]; + yield 'createRelationship' => [static fn (Mirror $mirror): Relationship => $mirror->createRelationship(self::SECRETS, Relationship::manyToOne( + relatedCollection: self::NOTES, + key: 'note', + ))]; + } + + /** + * @param Closure(Mirror): mixed $change + */ + #[DataProvider('schemaChanges')] + public function testASchemaChangeWaitsForTheQueuedReplicationsOfItsCollection(Closure $change): void + { + $this->delays = ['v0' => 0.03]; + + $this->inCoroutine(function () use ($change): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'v0', 'views' => 1])]); + $change($this->mirror); + }); + + $this->assertSame([], $this->errors); + $this->assertSame(['first', 'v0'], $this->titlesWritten()[0] ?? null, 'The queued write reaches the destination before the schema change'); + } + + public function testASchemaChangeWaitsForTheQueuedReplicationsOfEveryCollection(): void + { + $this->authorization->skip(fn (): Attribute => $this->mirror->createAttribute(self::SECRETS, Attribute::integer(key: 'extra'))); + $this->delays = ['slow' => 0.05]; + $writesBeforeTheChangeReturned = null; + + $this->inCoroutine(function () use (&$writesBeforeTheChangeReturned): void { + $this->mirror->createDocuments(self::NOTES, [new Document([Document::ID => 'first', 'title' => 'slow'])]); + $this->mirror->deleteAttribute(self::SECRETS, 'extra'); + $writesBeforeTheChangeReturned = $this->writesOf('first'); + }); + + $this->assertSame([['first', 'slow']], $writesBeforeTheChangeReturned); + $this->assertSame([], $this->errors); + } + + /** + * Parents with a two-way one-to-many relationship to children, created through the mirror. + */ + private function relate(): void + { + $this->mirror->addHook(new Relationships()); + $this->authorization->skip(function (): void { + foreach ([self::PARENTS, self::CHILDREN] as $collection) { + $this->mirror->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, + )); + } + $this->mirror->createRelationship(self::PARENTS, Relationship::oneToMany( + relatedCollection: self::CHILDREN, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + )); + }); + } + + /** + * @return array{mixed, mixed}|null The title and views a database stores for the note, or null when it has none + */ + private function stored(Database $database, string $id): ?array + { + $note = $this->authorization->skip(static fn (): Document => $database->getDocument(self::NOTES, $id)); + + return $note->isEmpty() ? null : [$note->getAttribute('title'), $note->getAttribute('views')]; + } + + /** + * @return list + */ + private function titlesWritten(): array + { + return \array_values(\array_filter( + $this->titlesWrittenAndLocked(), + static fn (array $write): bool => $write[1] !== self::LOCKED, + )); + } + + /** + * @return list + */ + private function writesOf(string $id): array + { + return \array_values(\array_filter( + $this->titlesWritten(), + static fn (array $write): bool => $write[0] === $id, + )); + } + + /** + * @return list + */ + private function titlesWrittenAndLocked(): array + { + return \array_map(static fn (array $write): array => [$write[0], $write[1]], $this->writes); + } + + /** + * @param Closure(): void $callback + */ + private function inCoroutine(Closure $callback): void + { + $failure = null; + $hookFlags = Runtime::getHookFlags(); + + try { + run(static function () use ($callback, &$failure): void { + try { + $callback(); + } catch (Throwable $error) { + $failure = $error; + } + }); + } finally { + Runtime::setHookFlags($hookFlags); + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * A destination whose reads yield once and whose writes wait for their delay first, so replications interleave + * with their caller and with each other. Each write, and each read that locks a document, is recorded when it + * completes; a write of the title 'broken' fails. A read that locks a document waits for the delay of LOCKED. + */ + private function yieldingAdapter(): SQLite + { + $delay = fn (string $title): float => $this->delays[$title] ?? 0.0; + $record = function (string $id, string $title): void { + $coroutine = Coroutine::getCid(); + $this->assertIsInt($coroutine); + $this->writes[] = [$id, $title, $coroutine]; + }; + $busy = function (int $change): void { + $this->busy += $change; + $this->peak = \max($this->peak, $this->busy); + }; + + return new class (new PDO('sqlite::memory:'), $delay, $record, $busy) extends SQLite { + /** + * @param Closure(string): float $delay + * @param Closure(string, string): void $record + * @param Closure(int): void $busy + */ + public function __construct(PDO $pdo, private readonly Closure $delay, private readonly Closure $record, private readonly Closure $busy) + { + parent::__construct($pdo); + } + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + return $this->busy(function () use ($collection, $id, $queries, $forUpdate): Document { + if (Coroutine::getCid() > 0) { + Coroutine::sleep(0.001); + } + if ($forUpdate) { + $this->wait(MirrorReplicationTest::LOCKED); + $this->written($id, MirrorReplicationTest::LOCKED); + } + + return parent::getDocument($collection, $id, $queries, $forUpdate); + }); + } + + #[\Override] + public function increaseDocumentAttribute(Document $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool + { + return $this->busy(function () use ($collection, $id, $attribute, $value, $updatedAt, $min, $max): bool { + $increased = parent::increaseDocumentAttribute($collection, $id, $attribute, $value, $updatedAt, $min, $max); + $this->written($id, MirrorReplicationTest::INCREASED); + + return $increased; + }); + } + + #[\Override] + public function createDocuments(Document $collection, array $documents): array + { + return $this->busy(function () use ($collection, $documents): array { + $this->wait($documents[0]->getAttribute('title', '')); + $created = parent::createDocuments($collection, $documents); + foreach ($documents as $document) { + $this->written($document->getId(), $document->getAttribute('title', '')); + } + + return $created; + }); + } + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + return $this->busy(function () use ($collection, $document): Document { + $this->wait($document->getAttribute('title', '')); + $created = parent::createDocument($collection, $document); + $this->written($document->getId(), $document->getAttribute('title', '')); + + return $created; + }); + } + + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + return $this->busy(function () use ($collection, $id, $document, $skipPermissions): Document { + $this->wait($document->getAttribute('title', '')); + $updated = parent::updateDocument($collection, $id, $document, $skipPermissions); + $this->written($id, $document->getAttribute('title', '')); + + return $updated; + }); + } + + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int + { + return $this->busy(function () use ($collection, $updates, $documents, $skipPermissions): int { + $this->wait($updates->getAttribute('title', '')); + $modified = parent::updateDocuments($collection, $updates, $documents, $skipPermissions); + foreach ($documents as $document) { + $this->written($document->getId(), $updates->getAttribute('title', '')); + } + + return $modified; + }); + } + + /** + * @param array $changes + * @return array + */ + #[\Override] + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array + { + return $this->busy(function () use ($collection, $increase, $changes): array { + $title = $changes[0]->new->getAttribute('title', ''); + $this->wait($title); + if ($title === 'broken') { + throw new RuntimeException('destination rejected broken'); + } + $upserted = parent::upsertDocuments($collection, $changes, $increase); + foreach ($changes as $change) { + $this->written($change->new->getId(), $change->new->getAttribute('title', '')); + } + + return $upserted; + }); + } + + #[\Override] + public function deleteDocument(Document $collection, string $id): bool + { + return $this->busy(function () use ($collection, $id): bool { + $this->wait(MirrorReplicationTest::DELETED); + $deleted = parent::deleteDocument($collection, $id); + $this->written($id, MirrorReplicationTest::DELETED); + + return $deleted; + }); + } + + /** + * @template T + * + * @param Closure(): T $call + * @return T + */ + private function busy(Closure $call): mixed + { + ($this->busy)(1); + + try { + return $call(); + } finally { + ($this->busy)(-1); + } + } + + private function wait(mixed $title): void + { + $seconds = ($this->delay)(\is_string($title) ? $title : ''); + if ($seconds > 0 && Coroutine::getCid() > 0) { + Coroutine::sleep($seconds); + } + } + + private function written(string $id, mixed $title): void + { + ($this->record)($id, \is_string($title) ? $title : ''); + } + }; + } +} diff --git a/tests/unit/MirrorTest.php b/tests/unit/MirrorTest.php new file mode 100644 index 0000000000..fa29f00ac0 --- /dev/null +++ b/tests/unit/MirrorTest.php @@ -0,0 +1,2022 @@ +pair(); + + $mirror->setDatabase('utopiaTests'); + + $this->assertSame('utopiaTests', $mirror->getDatabase()); + $this->assertSame('utopiaTests', $source->getDatabase()); + $this->assertSame('utopiaTests', $destination->getDatabase()); + } + + public function testSetNamespaceUpdatesMirrorAndChildren(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror->setNamespace('myapp'); + + $this->assertSame('myapp', $mirror->getNamespace()); + $this->assertSame('myapp', $source->getNamespace()); + $this->assertSame('myapp', $destination->getNamespace()); + } + + public function testSetTenantUpdatesMirrorAndChildren(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror->setTenant(7); + + $this->assertSame(7, $mirror->getTenant()); + $this->assertSame(7, $source->getTenant()); + $this->assertSame(7, $destination->getTenant()); + } + + public function testSetSharedTablesUpdatesMirrorAndChildren(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror->setSharedTables(true); + + $this->assertTrue($mirror->hasSharedTables()); + $this->assertTrue($source->hasSharedTables()); + $this->assertTrue($destination->hasSharedTables()); + } + + public function testCreateCreatesMetadataOnDestination(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror + ->setDatabase('utopiaTests') + ->setNamespace('myapp') + ->create(); + + $this->assertTrue($source->exists('utopiaTests')); + $this->assertTrue($source->collectionExists(Database::METADATA, 'utopiaTests')); + $this->assertTrue($destination->exists('utopiaTests')); + $this->assertTrue($destination->collectionExists(Database::METADATA, 'utopiaTests')); + } + + public function testListCollectionsHidesSourceOnlyUpgrades(): void + { + [$mirror, $source] = $this->pair(); + + $mirror + ->setDatabase('utopiaTests') + ->setNamespace('myapp') + ->create(); + + $mirror->createCollection(Collection::create(id: 'actors', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + + $listed = $mirror->listCollections(); + $ids = \array_map(static fn ($collection): string => $collection->getId(), $listed); + + $this->assertSame(['actors'], $ids); + $this->assertNotNull($source->findCollection('upgrades')); + } + + public function testSkipValidationRestoresSourceAndDestination(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $this->assertTrue($mirror->isValidating()); + $this->assertTrue($source->isValidating()); + $this->assertTrue($destination->isValidating()); + + $mirror->skipValidation(function () use ($mirror, $source, $destination) { + $this->assertFalse($mirror->isValidating()); + $this->assertFalse($source->isValidating()); + $this->assertFalse($destination->isValidating()); + }); + + $this->assertTrue($mirror->isValidating()); + $this->assertTrue($source->isValidating()); + $this->assertTrue($destination->isValidating()); + } + + public function testDisableValidationDelegatesToSourceAndDestination(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror->setValidation(false); + + $this->assertFalse($mirror->isValidating()); + $this->assertFalse($source->isValidating()); + $this->assertFalse($destination->isValidating()); + + $mirror->setValidation(true); + + $this->assertTrue($mirror->isValidating()); + $this->assertTrue($source->isValidating()); + $this->assertTrue($destination->isValidating()); + } + + public function testWrappingKeepsTheAuthorizationOfTheSourceAndDestination(): void + { + $sourceAuthorization = new Authorization(); + $destinationAuthorization = new Authorization(); + $source = self::sqlite()->setAuthorization($sourceAuthorization); + $destination = self::sqlite()->setAuthorization($destinationAuthorization); + + $mirror = new Mirror($source, $destination); + + $this->assertSame($sourceAuthorization, $source->getAuthorization()); + $this->assertSame($destinationAuthorization, $destination->getAuthorization()); + $this->assertSame($sourceAuthorization, $mirror->getAuthorization()); + } + + public function testARoleGrantedOnTheSourcesAuthorizationAfterWrappingAppliesToTheMirror(): void + { + $authorization = new Authorization(); + $source = self::sqlite()->setAuthorization($authorization); + $mirror = new Mirror($source, self::sqlite()); + $mirror->setDatabase('utopiaTests')->setNamespace('wrapped_'.\uniqid())->create(); + $authorization->skip(function () use ($mirror): void { + $mirror->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $mirror->createDocument(self::COLLECTION, new Document([ + Document::ID => 'owned', + 'title' => 'owned', + Document::PERMISSIONS => [Permission::read(Role::user('alice'))], + ])); + }); + + $hidden = $mirror->getDocument(self::COLLECTION, 'owned'); + $authorization->addRole(Role::user('alice')->toString()); + $granted = $mirror->getDocument(self::COLLECTION, 'owned'); + $authorization->skip(fn (): bool => $mirror->deleteDocument(self::COLLECTION, 'owned')); + + $this->assertTrue($hidden->isEmpty()); + $this->assertSame('owned', $granted->getAttribute('title')); + $this->assertTrue($source->getDocument(self::COLLECTION, 'owned')->isEmpty()); + } + + public function testCreateThrowsWhenDestinationCreateFails(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $destination = new Database(new class () extends Memory { + #[\Override] + public function create(string $name): bool + { + throw new RuntimeException('destination create failed'); + } + }, new Cache(new None())); + $mirror = new Mirror($source, $destination); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('destination create failed'); + + $mirror->setDatabase('utopiaTests')->setNamespace('myapp')->create(); + } + + public function testUpdateThroughMirrorInvalidatesItsQueryCache(): void + { + $adapter = new CountingMemory(); + $mirror = $this->seed(new Mirror( + new Database($adapter, new Cache(new None())), + new Database(new Memory(), new Cache(new None())), + )); + $mirror->setQueryCache(new ResultCache(new Cache(new MemoryCache()))); + + $this->assertSame('first', $this->title($mirror)); + $finds = $adapter->finds; + $this->assertSame('first', $this->title($mirror)); + $this->assertSame($finds, $adapter->finds, 'The repeated read must be served from the query cache'); + + $mirror->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'updated'])); + + $this->assertSame('updated', $this->title($mirror)); + } + + public function testAnEventThroughTheMirrorInvalidatesTheMirrorQueryCacheAndIsDispatchedOnce(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $mirror = new class ($source) extends Mirror { + public function fire(Document $document): void + { + $this->invalidate(Event::DocumentUpdate, $document); + $listeners = $this->listens(Event::DocumentUpdate); + if ($listeners !== []) { + $this->dispatch(new Event\Document\Updated($document->getCollection(), $document), $listeners); + } + } + }; + $this->seed($mirror); + $mirror->setQueryCache(new ResultCache(new Cache(new MemoryCache()))); + $source->setQueryCache(null); + + $this->assertSame('first', $this->title($mirror)); + $source->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'updated'])); + $this->assertSame('first', $this->title($mirror), 'Only the mirror holds the query cache, so the source write must not reach it'); + + $updated = $source->getDocument(self::COLLECTION, 'first'); + $recorder = new RecordingLifecycle(); + $mirror->addHook($recorder); + $mirror->fire($updated); + + $this->assertSame([Event::DocumentUpdate], $recorder->getEvents()); + $this->assertSame('updated', $this->title($mirror)); + } + + /** + * @return iterable + */ + public static function invalidatorRegistrations(): iterable + { + yield 'addHook' => [ + static fn (Mirror $mirror, Invalidator $invalidator): mixed => $mirror->addHook($invalidator), + ]; + } + + /** + * @param Closure(Mirror, Invalidator): mixed $register + */ + #[DataProvider('invalidatorRegistrations')] + public function testInvalidatorAddedThroughMirrorInvalidatesOnPurge(Closure $register): void + { + $mirror = $this->seed(new Mirror(new Database(new Memory(), new Cache(new None())))); + $queryCache = new ResultCache(new Cache(new MemoryCache())); + $register($mirror, new Invalidator($queryCache)); + + $this->assertStaleUntilPurgedThroughMirror($mirror, $this->sibling($mirror)->setQueryCache($queryCache)); + } + + public function testPurgeThroughMirrorInvalidatesTheSourceQueryCache(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $mirror = $this->seed(new Mirror($source)); + $source->setQueryCache(new ResultCache(new Cache(new MemoryCache()))); + + $this->assertStaleUntilPurgedThroughMirror($mirror, $source); + } + + public function testUpdateThroughMirrorPurgesDocumentsCachedUnderItsCacheName(): void + { + $mirror = $this->seed(new Mirror( + new Database(new Memory(), new Cache(new MemoryCache())), + new Database(new Memory(), new Cache(new None())), + )); + $mirror->setCacheName('mirrored'); + + $this->assertSame('first', $mirror->getDocument(self::COLLECTION, 'first')->getAttribute('title')); + + $mirror->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'updated'])); + + $this->assertSame('updated', $mirror->getDocument(self::COLLECTION, 'first')->getAttribute('title')); + } + + /** + * @return iterable + */ + public static function forwardedSetters(): iterable + { + $queryCache = new ResultCache(new Cache(new None())); + $filters = new Registry(); + $meta = self::meta(...); + + yield 'setQueryCache' => [ + static fn (Mirror $mirror): mixed => $mirror->setQueryCache($queryCache), + static fn (Database $database): mixed => $database->getQueryCache(), + $queryCache, + ]; + yield 'setCacheName' => [ + static fn (Mirror $mirror): mixed => $mirror->setCacheName('mirrored'), + static fn (Database $database): mixed => $database->getCacheName(), + 'mirrored', + ]; + yield 'setGlobalCollections' => [ + static fn (Mirror $mirror): mixed => $mirror->setGlobalCollections(['projects']), + static fn (Database $database): mixed => $database->getGlobalCollections(), + ['projects'], + ]; + yield 'resetGlobalCollections' => [ + static function (Mirror $mirror): void { + self::onEach($mirror, static fn (Database $database): mixed => $database->setGlobalCollections(['projects']))->resetGlobalCollections(); + }, + static fn (Database $database): mixed => $database->getGlobalCollections(), + [], + ]; + yield 'setTenantPerDocument' => [ + static fn (Mirror $mirror): mixed => $mirror->setTenantPerDocument(true), + static fn (Database $database): mixed => $database->isTenantPerDocument(), + true, + ]; + yield 'setTimeout' => [ + static fn (Mirror $mirror): mixed => $mirror->setTimeout(500), + static fn (Database $database): mixed => self::timeout($database), + 500, + ]; + yield 'clearTimeout' => [ + static function (Mirror $mirror): void { + self::onEach($mirror, static fn (Database $database): mixed => $database->setTimeout(500))->clearTimeout(); + }, + static fn (Database $database): mixed => self::timeout($database), + 0, + ]; + yield 'setMetadata' => [ + static fn (Mirror $mirror): mixed => $mirror->setMetadata('request', 'mirrored'), + static fn (Database $database): mixed => $database->getMetadata(), + ['request' => 'mirrored'], + ]; + yield 'resetMetadata' => [ + static function (Mirror $mirror): void { + self::onEach($mirror, static fn (Database $database): mixed => $database->setMetadata('request', 'mirrored'))->resetMetadata(); + }, + static fn (Database $database): mixed => $database->getMetadata(), + [], + ]; + yield 'setFiltering(false)' => [ + static fn (Mirror $mirror): mixed => $mirror->setFiltering(false), + $meta, + '{"filtered":true}', + ]; + yield 'setFiltering(true)' => [ + static fn (Mirror $mirror): mixed => self::onEach($mirror, static fn (Database $database): mixed => $database->setFiltering(false))->setFiltering(true), + $meta, + ['filtered' => true], + ]; + yield 'setLocks' => [ + static fn (Mirror $mirror): mixed => $mirror->setLocks(true), + static fn (Database $database): mixed => self::locking($database), + true, + ]; + yield 'setProfiling(true)' => [ + static fn (Mirror $mirror): mixed => $mirror->setProfiling(true), + static fn (Database $database): mixed => $database->getProfiler()?->isEnabled(), + true, + ]; + yield 'setProfiling(false)' => [ + static fn (Mirror $mirror): mixed => self::onEach($mirror, static fn (Database $database): mixed => $database->setProfiling(true))->setProfiling(false), + static fn (Database $database): mixed => $database->getProfiler()?->isEnabled(), + false, + ]; + yield 'setMigrating' => [ + static fn (Mirror $mirror): mixed => $mirror->setMigrating(true), + static fn (Database $database): mixed => $database->isMigrating(), + true, + ]; + yield 'setFilters' => [ + static fn (Mirror $mirror): mixed => $mirror->setFilters($filters), + static fn (Database $database): mixed => $database->getFilters(), + $filters, + ]; + } + + /** + * @param Closure(Mirror): mixed $configure + * @param Closure(Database): mixed $read + */ + #[DataProvider('forwardedSetters')] + public function testSetterReachesSourceAndDestination(Closure $configure, Closure $read, mixed $expected): void + { + $source = new Database(self::configurableAdapter(), new Cache(new None())); + $destination = new Database(self::configurableAdapter(), new Cache(new None())); + $mirror = new Mirror($source, $destination); + + $configure($mirror); + + $this->assertSame($expected, $read($mirror), 'mirror'); + $this->assertSame($expected, $read($source), 'source'); + $this->assertSame($expected, $read($destination), 'destination'); + } + + /** + * @return iterable|null}> + */ + public static function skippedFilters(): iterable + { + yield 'every filter' => [null]; + yield 'named filters' => [['json']]; + } + + /** + * @param array|null $filters + */ + #[DataProvider('skippedFilters')] + public function testSkipFiltersRestoresSourceAndDestination(?array $filters): void + { + [$mirror, $source, $destination] = $this->pair(); + $databases = [$mirror, $source, $destination]; + + $skipped = $mirror->skipFilters( + static fn (): array => \array_map(self::meta(...), $databases), + $filters, + ); + + $this->assertSame(\array_fill(0, 3, '{"filtered":true}'), $skipped); + $this->assertSame(\array_fill(0, 3, ['filtered' => true]), \array_map(self::meta(...), $databases)); + } + + /** + * @return iterable + */ + public static function scopedSetters(): iterable + { + yield 'withPreserveDates' => [ + static fn (Mirror $mirror, Closure $callback): mixed => $mirror->withPreserveDates(true, $callback), + static fn (Database $database): mixed => $database->isPreservingDates(), + true, + false, + true, + ]; + yield 'withPreserveSequence' => [ + static fn (Mirror $mirror, Closure $callback): mixed => $mirror->withPreserveSequence(true, $callback), + static fn (Database $database): mixed => $database->isPreservingSequence(), + true, + false, + true, + ]; + yield 'withTenant' => [ + static fn (Mirror $mirror, Closure $callback): mixed => $mirror->withTenant(7, $callback), + static fn (Database $database): mixed => $database->getTenant(), + 7, + null, + 7, + ]; + yield 'skipRelationships' => [ + static fn (Mirror $mirror, Closure $callback): mixed => $mirror->skipRelationships($callback), + static fn (Database $database): mixed => $database->getRelationshipHook()?->isEnabled(), + false, + true, + true, + ]; + yield 'skipRelationshipsExistCheck' => [ + static fn (Mirror $mirror, Closure $callback): mixed => $mirror->skipRelationshipsExistCheck($callback), + static fn (Database $database): mixed => $database->getRelationshipHook()?->shouldCheckExist(), + false, + true, + true, + ]; + } + + /** + * @param Closure(Mirror, Closure(): mixed): mixed $scope + * @param Closure(Database): mixed $read + */ + #[DataProvider('scopedSetters')] + public function testScopedSetterAppliesToTheSourceOnce(Closure $scope, Closure $read, mixed $inside, mixed $outside, mixed $destinationInside): void + { + [$mirror, $source, $destination] = $this->pair(); + $mirror->addHook(new Relationships()); + $databases = [$mirror, $source, $destination]; + $observed = []; + + $scope($mirror, static function () use (&$observed, $databases, $read): void { + $observed[] = \array_map($read, $databases); + }); + + $this->assertSame([[$inside, $inside, $destinationInside]], $observed); + $this->assertSame(\array_fill(0, 3, $outside), \array_map($read, $databases)); + } + + public function testRequestTimestampThroughMirrorRunsTheCallbackOnce(): void + { + [$mirror] = $this->pair(); + $runs = 0; + + $mirror->withRequestTimestamp(new DateTime(), static function () use (&$runs): void { + $runs++; + }); + + $this->assertSame(1, $runs); + } + + public function testSingleUpsertThroughMirrorHonoursTheRequestTimestamp(): void + { + $mirror = $this->seed(new Mirror(self::sqlite(), self::sqlite())); + + $this->expectException(ConflictException::class); + + self::inCoroutine(static fn (): mixed => $mirror->withRequestTimestamp( + new DateTime('-1 hour'), + static fn (): mixed => $mirror->upsertDocument(self::COLLECTION, new Document([Document::ID => 'first', 'title' => 'late'])), + )); + } + + public function testSingleUpsertThroughMirrorKeepsScopedPreservedDates(): void + { + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->seed(new Mirror($source, $destination)); + $createdAt = '2001-02-03T04:05:06.000+00:00'; + + self::inCoroutine(static fn (): mixed => $mirror->withPreserveDates( + true, + static fn (): mixed => $mirror->upsertDocument(self::COLLECTION, new Document([ + Document::ID => 'dated', + '$createdAt' => $createdAt, + '$updatedAt' => $createdAt, + 'title' => 'dated', + 'views' => 1, + ])), + )); + + $expected = (new DateTime($createdAt))->getTimestamp(); + foreach (['source' => $source, 'destination' => $destination] as $name => $database) { + $stored = $database->getDocument(self::COLLECTION, 'dated')->getCreatedAt(); + $this->assertNotNull($stored, $name); + $this->assertSame($expected, (new DateTime($stored))->getTimestamp(), $name); + } + } + + public function testProfilingThroughMirrorRecordsIntoTheProfilerItReturns(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror->setProfiling(true); + + $this->assertNotNull($mirror->getProfiler()); + $this->assertSame($source->getProfiler(), $mirror->getProfiler()); + $this->assertSame($mirror->getProfiler(), $mirror->getAdapter()->getProfiler()); + $this->assertNotNull($destination->getProfiler()); + $this->assertSame($destination->getProfiler(), $destination->getAdapter()->getProfiler()); + + $mirror->setProfiling(false); + + $this->assertNull($mirror->getAdapter()->getProfiler()); + $this->assertNull($destination->getAdapter()->getProfiler()); + } + + /** + * @return iterable + */ + public static function timeoutCalls(): iterable + { + yield 'setTimeout' => [ + 'setTimeout', + static fn (Mirror $mirror): mixed => $mirror->setTimeout(500), + static fn (Database $database): mixed => self::timeout($database), + 500, + ]; + yield 'clearTimeout' => [ + 'clearTimeout', + static function (Mirror $mirror): void { + $mirror->clearTimeout(); + }, + static fn (Database $database): mixed => self::timeout($database), + 0, + ]; + } + + /** + * @param Closure(Mirror): mixed $call + * @param Closure(Database): mixed $read + */ + #[DataProvider('timeoutCalls')] + public function testDestinationTimeoutFailureIsReportedNotThrown(string $action, Closure $call, Closure $read, mixed $expected): void + { + $source = new Database(self::configurableAdapter(), new Cache(new None())); + $destination = new Database(new class () extends Memory implements Feature\Timeouts { + use Timeout; + + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + throw new RuntimeException('destination unreachable'); + } + + #[\Override] + public function clearTimeout(Event $event = Event::All): void + { + throw new RuntimeException('destination unreachable'); + } + }, new Cache(new None())); + $mirror = new Mirror($source, $destination); + $errors = []; + $mirror->onError(static function (Failure $failure) use (&$errors): void { + $errors[] = [$failure->method, $failure->error->getMessage()]; + }); + + $call($mirror); + + $this->assertSame($expected, $read($source)); + $this->assertSame([[$action, 'destination unreachable']], $errors); + } + + /** + * @return iterable + */ + public static function upserts(): iterable + { + yield 'upsertDocument' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocument(self::COLLECTION, $document), + ]; + yield 'upsertDocuments' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocuments(self::COLLECTION, [$document]), + ]; + yield 'upsertDocuments with an increase' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocuments(self::COLLECTION, [$document], increase: 'views'), + ]; + } + + /** + * @return iterable + */ + public static function upsertEvents(): iterable + { + $events = [ + 'upsertDocument' => Event::DocumentUpsert, + 'upsertDocuments' => Event::DocumentsUpsert, + 'upsertDocuments with an increase' => Event::DocumentsUpsert, + ]; + + foreach (self::upserts() as $label => [$upsert]) { + yield $label => [$upsert, $events[$label]]; + } + } + + /** + * @param Closure(Mirror, Document): mixed $upsert + */ + #[DataProvider('upsertEvents')] + public function testUpsertThroughMirrorFiresEachEventOnce(Closure $upsert, Event $event): void + { + $mirror = $this->seed(new Mirror(self::sqlite(), self::sqlite())); + $recorder = new RecordingLifecycle(); + $mirror->addHook($recorder); + + self::inCoroutine(static fn (): mixed => $upsert($mirror, new Document([Document::ID => 'upserted', 'title' => 'upserted', 'views' => 2]))); + + $this->assertSame([Event::DocumentPurge, $event], $recorder->getEvents()); + } + + /** + * @param Closure(Mirror, Document): mixed $upsert + */ + #[DataProvider('upserts')] + public function testUpsertThroughMirrorReachesTheDestination(Closure $upsert): void + { + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->seed(new Mirror($source, $destination)); + $errors = []; + $mirror->onError(static function (Failure $failure) use (&$errors): void { + $errors[] = [$failure->method, $failure->error->getMessage()]; + }); + + self::inCoroutine(static fn (): mixed => $upsert($mirror, new Document([Document::ID => 'first', 'title' => 'upserted', 'views' => 2]))); + + $this->assertSame([], $errors); + $upserted = $source->getDocument(self::COLLECTION, 'first'); + $mirrored = $destination->getDocument(self::COLLECTION, 'first'); + $this->assertNotSame(1, $upserted->getAttribute('views')); + $this->assertSame( + [$upserted->getAttribute('title'), $upserted->getAttribute('views')], + [$mirrored->getAttribute('title'), $mirrored->getAttribute('views')], + ); + } + + /** + * @return iterable + */ + public static function upsertActions(): iterable + { + yield 'upsertDocument' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocument(self::COLLECTION, $document), + 'upsertDocument', + ]; + yield 'upsertDocuments' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocuments(self::COLLECTION, [$document]), + 'upsertDocuments', + ]; + yield 'upsertDocuments with an increase' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocuments(self::COLLECTION, [$document], increase: 'views'), + 'upsertDocuments', + ]; + } + + /** + * @param Closure(Mirror, Document): mixed $upsert + */ + #[DataProvider('upsertActions')] + public function testUpsertReplicationFailureIsReportedUnderItsAction(Closure $upsert, string $action): void + { + $destination = new Database(new class (new PDO('sqlite::memory:')) extends SQLite { + /** + * @param array $changes + * @return array + */ + #[\Override] + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array + { + throw new RuntimeException('destination unreachable'); + } + }, new Cache(new None())); + $mirror = $this->seed(new Mirror(self::sqlite(), $destination)); + $errors = []; + $mirror->onError(static function (Failure $failure) use (&$errors): void { + $errors[] = [$failure->method, $failure->error->getMessage()]; + }); + + self::inCoroutine(static fn (): mixed => $upsert($mirror, new Document([Document::ID => 'first', 'title' => 'upserted', 'views' => 2]))); + + $this->assertSame([[$action, 'destination unreachable']], $errors); + } + + private function assertStaleUntilPurgedThroughMirror(Mirror $mirror, Database $reader): void + { + $this->assertSame('first', $this->title($reader)); + $this->sibling($mirror)->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'updated'])); + $this->assertSame('first', $this->title($reader), 'A write that invalidates nothing must leave the cached read in place'); + + $mirror->purgeCachedDocument(self::COLLECTION, 'first'); + + $this->assertSame('updated', $this->title($reader)); + } + + /** + * A database on the mirror's adapter and authorization that shares none of its caches or hooks. + */ + private function sibling(Mirror $mirror): Database + { + return (new Database($mirror->getAdapter(), new Cache(new None())))->setAuthorization($mirror->getAuthorization()); + } + + private function title(Database $database): mixed + { + return $database->findOne(self::COLLECTION, [Query::equal(Document::ID, ['first'])])->getAttribute('title'); + } + + /** + * Applies $apply to the wrapped databases directly, then to the mirror, so an undo through + * the mirror has state to clear on each of them. + * + * @param Closure(Database): mixed $apply + */ + private static function onEach(Mirror $mirror, Closure $apply): Mirror + { + $apply($mirror->getSource()); + $destination = $mirror->getDestination(); + if ($destination !== null) { + $apply($destination); + } + $apply($mirror); + + return $mirror; + } + + private static function meta(Database $database): mixed + { + $collection = Collection::create(id: self::COLLECTION, attributes: [ + Attribute::string(key: 'meta', size: 64, filters: [\Utopia\Database\Filter::Json]), + ]); + + return $database->decode($collection, new Document(['meta' => '{"filtered":true}']))->getAttribute('meta'); + } + + private function seed(Mirror $mirror): Mirror + { + $mirror + ->setDatabase('mirror') + ->setNamespace('mirror_'.\uniqid()) + ->create(); + + $mirror->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::integer(key: 'views'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + )); + + $mirror->createDocument(self::COLLECTION, new Document([ + Document::ID => 'first', + 'title' => 'first', + 'views' => 1, + ])); + + return $mirror; + } + + /** + * Runs $callback in a coroutine scheduler, as a Swoole server does, so the mirror's + * asynchronous replication finishes inside it; a failure is rethrown outside, where + * PHPUnit can report it. + * + * @param Closure(): mixed $callback + */ + private static function inCoroutine(Closure $callback): void + { + $failure = null; + $hookFlags = Runtime::getHookFlags(); + + try { + run(static function () use ($callback, &$failure): void { + try { + $callback(); + } catch (Throwable $error) { + $failure = $error; + } + }); + } finally { + Runtime::setHookFlags($hookFlags); + } + + if ($failure !== null) { + throw $failure; + } + } + + private static function sqlite(): Database + { + return new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + } + + private static function configurableAdapter(): Memory + { + return new class () extends Memory implements Feature\Timeouts { + use Timeout; + + /** + * @return array + */ + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::AlterLock]; + } + + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + $this->setTimeoutState($milliseconds, $event); + } + + #[\Override] + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); + } + + public function isLocking(): bool + { + return $this->locks; + } + }; + } + + private static function timeout(Database $database): int + { + $adapter = $database->getAdapter(); + self::assertInstanceOf(Feature\Timeouts::class, $adapter); + + return $adapter->getTimeout(); + } + + private static function locking(Database $database): bool + { + $adapter = $database->getAdapter(); + self::assertTrue(\method_exists($adapter, 'isLocking')); + $locking = $adapter->isLocking(); + self::assertIsBool($locking); + + return $locking; + } + + /** + * @return array{0: Mirror, 1: Database, 2: Database} + */ + private function pair(): array + { + $source = new Database(new Memory(), new Cache(new None())); + $destination = new Database(new Memory(), new Cache(new None())); + + return [new Mirror($source, $destination), $source, $destination]; + } + + public function testSetLocksFailureOnTheDestinationReachesOnError(): void + { + $source = new Database(self::configurableAdapter(), new Cache(new None())); + $destination = new Database(new class () extends Memory { + /** + * @return array + */ + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::AlterLock]; + } + + #[\Override] + public function setLocks(bool $locks): static + { + throw new RuntimeException('destination unreachable'); + } + }, new Cache(new None())); + $mirror = new Mirror($source, $destination); + $errors = []; + $mirror->onError(static function (Failure $failure) use (&$errors): void { + $errors[] = [$failure->method, $failure->error->getMessage()]; + }); + + $mirror->setLocks(true); + + $this->assertTrue(self::locking($source)); + $this->assertSame([['setLocks', 'destination unreachable']], $errors); + } + + public function testCacheWriterTimeoutReachesSourceAndDestination(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror->setCacheWriterTimeout(30); + + $this->assertSame( + [30, 30, 30], + [$mirror->getCacheWriterTimeout(), $source->getCacheWriterTimeout(), $destination->getCacheWriterTimeout()], + ); + } + + /** + * @return iterable}> + */ + public static function writesReturningDocuments(): iterable + { + yield 'createDocument' => [ + static fn (Mirror $mirror): array => [$mirror->createDocument(self::COLLECTION, new Document([Document::ID => 'written', 'title' => 'written']))], + ]; + yield 'updateDocument' => [ + static fn (Mirror $mirror): array => [$mirror->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'written']))], + ]; + yield 'upsertDocument' => [ + static fn (Mirror $mirror): array => [$mirror->upsertDocument(self::COLLECTION, new Document([Document::ID => 'first', 'title' => 'written']))], + ]; + yield 'createDocuments' => [ + static function (Mirror $mirror): array { + $returned = []; + $mirror->createDocuments( + self::COLLECTION, + [new Document([Document::ID => 'written', 'title' => 'written'])], + onNext: static function (Document $document) use (&$returned): void { + $returned[] = $document; + }, + ); + + return $returned; + }, + ]; + yield 'updateDocuments' => [ + static function (Mirror $mirror): array { + $returned = []; + $mirror->updateDocuments( + self::COLLECTION, + new Document(['title' => 'written']), + [Query::equal(Document::ID, ['first'])], + onNext: static function (Document $document) use (&$returned): void { + $returned[] = $document; + }, + ); + + return $returned; + }, + ]; + yield 'upsertDocuments' => [ + static function (Mirror $mirror): array { + $returned = []; + $mirror->upsertDocuments( + self::COLLECTION, + [new Document([Document::ID => 'first', 'title' => 'written'])], + onNext: static function (Document $document) use (&$returned): void { + $returned[] = $document; + }, + ); + + return $returned; + }, + ]; + } + + /** + * @param Closure(Mirror): array $write + */ + #[DataProvider('writesReturningDocuments')] + public function testDecoratorsApplyToDocumentsReturnedByWrites(Closure $write): void + { + $destination = self::sqlite(); + $mirror = $this->seed(new Mirror(self::sqlite(), $destination)); + $mirror->addHook(new class () implements Decorator { + #[\Override] + public function decorate(Event $event, Document $collection, Document $document): Document + { + return $document->setAttribute('decoratedFor', $collection->getId()); + } + }); + $errors = []; + $mirror->onError(static function (Failure $failure) use (&$errors): void { + $errors[] = [$failure->method, $failure->error->getMessage()]; + }); + /** @var ArrayObject $returned */ + $returned = new ArrayObject(); + + self::inCoroutine(static function () use ($mirror, $write, $returned): void { + $returned->exchangeArray($write($mirror)); + }); + + $this->assertCount(1, $returned); + $first = $returned[0] ?? null; + $this->assertInstanceOf(Document::class, $first); + $this->assertSame(self::COLLECTION, $first->getAttribute('decoratedFor')); + $this->assertSame([], $errors, 'A decorated document must not reach the destination'); + $replicated = $destination->getDocument(self::COLLECTION, $first->getId()); + $this->assertSame('written', $replicated->getAttribute('title')); + $this->assertNull($replicated->getAttribute('decoratedFor')); + } + + /** + * @return iterable + */ + public static function destinationlessCalls(): iterable + { + yield 'setTimeout' => [ + static function (Mirror $mirror): mixed { + $mirror->setTimeout(500); + + return self::timeout($mirror->getSource()); + }, + 500, + ]; + yield 'setValidation(false)' => [ + static function (Mirror $mirror): mixed { + $mirror->setValidation(false); + + return [$mirror->isValidating(), $mirror->getSource()->isValidating()]; + }, + [false, false], + ]; + yield 'collectionExists' => [ + static fn (Mirror $mirror): mixed => $mirror->collectionExists(self::COLLECTION, 'mirror'), + true, + ]; + yield 'increaseDocumentAttribute' => [ + static fn (Mirror $mirror): mixed => [ + $mirror->increaseDocumentAttribute(self::COLLECTION, 'first', 'views', 2)->getAttribute('views'), + $mirror->getSource()->getDocument(self::COLLECTION, 'first')->getAttribute('views'), + ], + [3, 3], + ]; + } + + /** + * @param Closure(Mirror): mixed $call + */ + #[DataProvider('destinationlessCalls')] + public function testMirrorWithoutDestinationDelegatesToTheSource(Closure $call, mixed $expected): void + { + $mirror = $this->seed(new Mirror(new Database(self::configurableAdapter(), new Cache(new None())))); + $errors = []; + $mirror->onError(static function (Failure $failure) use (&$errors): void { + $errors[] = [$failure->method, $failure->error->getMessage()]; + }); + + $this->assertSame($expected, $call($mirror)); + $this->assertSame([], $errors); + } + + public function testSkipValidationWithoutDestinationRunsOnTheSource(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $mirror = new Mirror($source); + + $inside = $mirror->skipValidation(static fn (): array => [$mirror->isValidating(), $source->isValidating()]); + + $this->assertSame([false, false], $inside); + $this->assertSame([true, true], [$mirror->isValidating(), $source->isValidating()]); + } + + public function testCreateCollectionRunsWriteFilters(): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $destination = self::sqlite(); + $mirror = $this->filtered( + [self::recordingFilter($calls, static fn (string $hook, ?Document $collection): ?Document => $collection === null ? null : (clone $collection)->setAttribute('filtered', true))], + self::sqlite(), + $destination, + ); + + $created = $mirror->createCollection(Collection::create(id: 'filtered', attributes: [Attribute::string(key: 'title', size: 64)])); + + $this->assertSame([['beforeCreateCollection', 'filtered', 'filtered']], $calls->getArrayCopy()); + $this->assertTrue($created->getAttribute('filtered'), 'The filtered collection is what the caller receives'); + $this->assertNotNull($destination->findCollection('filtered')); + $this->assertSame('upgraded', self::upgradeStatus($mirror, 'filtered')); + } + + public function testCreateCollectionFilterReturningNullSkipsTheDestination(): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter($calls, static fn (): ?Document => null)], $source, $destination); + $errors = self::errors($mirror); + + $created = $mirror->createCollection(Collection::create(id: 'skipped', attributes: [Attribute::string(key: 'title', size: 64)])); + + $this->assertSame([['beforeCreateCollection', 'skipped', 'skipped']], $calls->getArrayCopy()); + $this->assertSame('skipped', $created->getId()); + $this->assertNotNull($source->findCollection('skipped')); + $this->assertNull($destination->findCollection('skipped')); + $this->assertNull(self::upgradeStatus($mirror, 'skipped'), 'Documents of a collection the destination lacks must not be replicated'); + $this->assertSame([], $errors->getArrayCopy()); + } + + public function testUpdateCollectionRunsWriteFilters(): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $destination = self::sqlite(); + $mirror = $this->filtered( + [self::recordingFilter($calls, static fn (string $hook, ?Document $collection): ?Document => $collection === null ? null : (clone $collection)->setAttribute('filtered', true))], + self::sqlite(), + $destination, + ); + + $updated = $mirror->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::users())], documentSecurity: false)); + + $this->assertSame([['beforeUpdateCollection', self::COLLECTION, self::COLLECTION]], $calls->getArrayCopy()); + $this->assertNull($updated->getAttribute('filtered'), 'The caller receives the collection the source stored'); + $this->assertSame([Permission::read(Role::users())], $destination->getCollection(self::COLLECTION)->getPermissions()); + $this->assertFalse($destination->getCollection(self::COLLECTION)->getAttribute('documentSecurity')); + } + + public function testUpdateCollectionFilterReturningNullSkipsTheDestination(): void + { + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter(new ArrayObject(), static fn (): ?Document => null)], $source, $destination); + $permissions = $destination->getCollection(self::COLLECTION)->getPermissions(); + + $updated = $mirror->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::users())], documentSecurity: false)); + + $this->assertFalse($updated->getAttribute('documentSecurity')); + $this->assertSame([Permission::read(Role::users())], $source->getCollection(self::COLLECTION)->getPermissions()); + $this->assertSame($permissions, $destination->getCollection(self::COLLECTION)->getPermissions()); + $this->assertTrue($destination->getCollection(self::COLLECTION)->getAttribute('documentSecurity')); + } + + public function testUpdateCollectionReplicationFailureIsReportedNotThrown(): void + { + $source = self::sqlite(); + $mirror = $this->filtered([], $source, self::sqlite()); + $errors = self::errors($mirror); + $source->createCollection(Collection::create(id: 'sourceOnly', attributes: [Attribute::string(key: 'title', size: 64)])); + + $updated = $mirror->updateCollection('sourceOnly', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: false)); + + $this->assertFalse($updated->getAttribute('documentSecurity')); + $this->assertSame([Permission::read(Role::any())], $source->getCollection('sourceOnly')->getPermissions()); + $this->assertSame([['updateCollection', 'Collection not found']], $errors->getArrayCopy()); + } + + /** + * The mirror seeded over $source and $destination, as a mirror with $filters over the same two databases. + * + * @param array $filters + */ + private function filtered(array $filters, Database $source, Database $destination): Mirror + { + $seeded = $this->seed(new Mirror($source, $destination)); + + return (new Mirror($source, $destination, $filters)) + ->setDatabase($seeded->getDatabase()) + ->setNamespace($seeded->getNamespace()); + } + + private static function upgradeStatus(Mirror $mirror, string $collection): mixed + { + $source = $mirror->getSource(); + if ($source->findCollection('upgrades') === null) { + return null; + } + + return $source->getAuthorization()->skip( + static fn (): mixed => $source->getDocument('upgrades', $collection)->getAttribute('status'), + ); + } + + /** + * @return ArrayObject + */ + private static function errors(Mirror $mirror): ArrayObject + { + /** @var ArrayObject $errors */ + $errors = new ArrayObject(); + $mirror->onError(static function (Failure $failure) use ($errors): void { + $errors[] = [$failure->method, $failure->error->getMessage()]; + }); + + return $errors; + } + + /** + * A write filter that records every hook it runs as [hook, collection id, subject] and returns what $transform + * makes of the document the hook receives; hooks without a document pass null and ignore the result. + * + * @param ArrayObject $calls + * @param Closure(string, ?Document): ?Document $transform + */ + private static function recordingFilter(ArrayObject $calls, Closure $transform): Filter + { + return new class ($calls, $transform) extends Filter { + /** + * @param ArrayObject $calls + * @param Closure(string, ?Document): ?Document $transform + */ + public function __construct( + private readonly ArrayObject $calls, + private readonly Closure $transform, + ) { + } + + #[\Override] + public function beforeCreateCollection(Database $source, Database $destination, string $collectionId, ?Document $collection = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $collection?->getId(), $collection); + } + + #[\Override] + public function beforeUpdateCollection(Database $source, Database $destination, string $collectionId, ?Document $collection = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $collection?->getId(), $collection); + } + + #[\Override] + public function beforeDeleteCollection(Database $source, Database $destination, string $collectionId): void + { + $this->run(__FUNCTION__, $collectionId, $collectionId); + } + + #[\Override] + public function beforeCreateAttribute(Database $source, Database $destination, string $collectionId, string $attributeId, ?Document $attribute = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $attributeId, $attribute); + } + + #[\Override] + public function beforeUpdateAttribute(Database $source, Database $destination, string $collectionId, string $attributeId, ?Document $attribute = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $attributeId, $attribute); + } + + #[\Override] + public function beforeDeleteAttribute(Database $source, Database $destination, string $collectionId, string $attributeId): void + { + $this->run(__FUNCTION__, $collectionId, $attributeId); + } + + #[\Override] + public function beforeCreateIndex(Database $source, Database $destination, string $collectionId, string $indexId, ?Document $index = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $indexId, $index); + } + + #[\Override] + public function beforeDeleteIndex(Database $source, Database $destination, string $collectionId, string $indexId): void + { + $this->run(__FUNCTION__, $collectionId, $indexId); + } + + #[\Override] + public function beforeCreateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + #[\Override] + public function afterCreateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + #[\Override] + public function beforeUpdateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + #[\Override] + public function afterUpdateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + #[\Override] + public function beforeUpdateDocuments(Database $source, Database $destination, string $collectionId, Document $updates, array $queries): Document + { + return $this->run(__FUNCTION__, $collectionId, $updates->getAttribute('title'), $updates) ?? $updates; + } + + #[\Override] + public function afterUpdateDocuments(Database $source, Database $destination, string $collectionId, Document $updates, array $queries): void + { + $this->run(__FUNCTION__, $collectionId, $updates->getAttribute('title')); + } + + #[\Override] + public function beforeDeleteDocument(Database $source, Database $destination, string $collectionId, string $documentId): void + { + $this->run(__FUNCTION__, $collectionId, $documentId); + } + + #[\Override] + public function afterDeleteDocument(Database $source, Database $destination, string $collectionId, string $documentId): void + { + $this->run(__FUNCTION__, $collectionId, $documentId); + } + + #[\Override] + public function beforeDeleteDocuments(Database $source, Database $destination, string $collectionId, array $queries): void + { + $this->run(__FUNCTION__, $collectionId, \count($queries)); + } + + #[\Override] + public function afterDeleteDocuments(Database $source, Database $destination, string $collectionId, array $queries): void + { + $this->run(__FUNCTION__, $collectionId, \count($queries)); + } + + #[\Override] + public function beforeCreateOrUpdateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + #[\Override] + public function afterCreateOrUpdateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + private function run(string $hook, string $collectionId, mixed $subject, ?Document $document = null): ?Document + { + $this->calls[] = [$hook, $collectionId, $subject]; + + return ($this->transform)($hook, $document); + } + }; + } + + /** + * @return iterable + */ + public static function attributeFilters(): iterable + { + yield 'resized' => [ + static fn (string $hook, ?Document $attribute): ?Document => $attribute === null ? null : (clone $attribute)->setAttribute('size', 128), + 128, + ]; + yield 'skipped' => [static fn (): ?Document => null, null]; + } + + /** + * @param Closure(string, ?Document): ?Document $transform + */ + #[DataProvider('attributeFilters')] + public function testCreateAttributeRunsWriteFilters(Closure $transform, ?int $size): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter($calls, $transform)], $source, $destination); + $errors = self::errors($mirror); + + $mirror->createAttribute(self::COLLECTION, Attribute::string(key: 'summary', size: 64)); + + $this->assertSame([['beforeCreateAttribute', self::COLLECTION, 'summary']], $calls->getArrayCopy()); + $this->assertSame(64, self::attribute($source, 'summary')?->size); + $this->assertSame($size, self::attribute($destination, 'summary')?->size); + $this->assertSame([], $errors->getArrayCopy()); + } + + public function testCreateAttributesRunsWriteFiltersPerAttribute(): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter( + $calls, + static fn (string $hook, ?Document $attribute): ?Document => $attribute === null || $attribute->getAttribute('key') === 'dropped' + ? null + : (clone $attribute)->setAttribute('size', 128), + )], $source, $destination); + $errors = self::errors($mirror); + + $mirror->createAttributes(self::COLLECTION, [ + Attribute::string(key: 'dropped', size: 64), + Attribute::string(key: 'resized', size: 64), + ]); + + $this->assertSame([ + ['beforeCreateAttribute', self::COLLECTION, 'dropped'], + ['beforeCreateAttribute', self::COLLECTION, 'resized'], + ], $calls->getArrayCopy()); + $this->assertSame([64, 64], [self::attribute($source, 'dropped')?->size, self::attribute($source, 'resized')?->size]); + $this->assertSame([null, 128], [self::attribute($destination, 'dropped')?->size, self::attribute($destination, 'resized')?->size]); + $this->assertSame([], $errors->getArrayCopy()); + } + + /** + * @param Closure(string, ?Document): ?Document $transform + */ + #[DataProvider('attributeFilters')] + public function testUpdateAttributeRunsWriteFilters(Closure $transform, ?int $size): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter($calls, $transform)], $source, $destination); + $errors = self::errors($mirror); + + $updated = $mirror->updateAttribute(self::COLLECTION, 'title', new AttributeUpdate(size: 100)); + + $this->assertSame([['beforeUpdateAttribute', self::COLLECTION, 'title']], $calls->getArrayCopy()); + $this->assertSame(100, $updated->size, 'The caller receives the definition the source stored'); + $this->assertSame(100, self::attribute($source, 'title')?->size); + $this->assertSame($size ?? 64, self::attribute($destination, 'title')?->size); + $this->assertSame([], $errors->getArrayCopy()); + } + + /** + * @return iterable|null}> + */ + public static function indexFilters(): iterable + { + yield 'retargeted' => [ + static fn (string $hook, ?Document $index): ?Document => $index === null ? null : (clone $index)->setAttribute('attributes', ['views']), + ['views'], + ]; + yield 'skipped' => [static fn (): ?Document => null, null]; + } + + /** + * @param Closure(string, ?Document): ?Document $transform + * @param array|null $attributes + */ + #[DataProvider('indexFilters')] + public function testCreateIndexRunsWriteFilters(Closure $transform, ?array $attributes): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter($calls, $transform)], $source, $destination); + $errors = self::errors($mirror); + + $mirror->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title'])); + + $this->assertSame([['beforeCreateIndex', self::COLLECTION, 'titles']], $calls->getArrayCopy()); + $this->assertSame(['title'], self::index($source, 'titles')?->attributes); + $this->assertSame($attributes, self::index($destination, 'titles')?->attributes); + $this->assertSame([], $errors->getArrayCopy()); + } + + /** + * @return iterable + */ + public static function schemaReplicationFailures(): iterable + { + yield 'createAttribute' => [ + 'createAttribute', + static fn (Mirror $mirror): Attribute => $mirror->createAttribute(self::COLLECTION, Attribute::string(key: 'summary', size: 64)), + ]; + yield 'createAttributes' => [ + 'createAttributes', + static fn (Mirror $mirror): array => $mirror->createAttributes(self::COLLECTION, [Attribute::string(key: 'summary', size: 64)]), + ]; + yield 'deleteAttribute' => [ + 'deleteAttribute', + static function (Mirror $mirror, Database $source): void { + $source->createAttribute(self::COLLECTION, Attribute::string(key: 'summary', size: 64)); + $mirror->deleteAttribute(self::COLLECTION, 'summary'); + }, + ]; + yield 'createIndex' => [ + 'createIndex', + static function (Mirror $mirror, Database $source, Database $destination): Index { + $destination->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title'])); + + return $mirror->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title'])); + }, + ]; + yield 'deleteIndex' => [ + 'deleteIndex', + static function (Mirror $mirror, Database $source): void { + $source->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title'])); + $mirror->deleteIndex(self::COLLECTION, 'titles'); + }, + ]; + } + + /** + * @param Closure(Mirror, Database, Database): mixed $change + */ + #[DataProvider('schemaReplicationFailures')] + public function testSchemaReplicationFailureIsReportedNotThrown(string $action, Closure $change): void + { + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([], $source, $destination); + $errors = self::errors($mirror); + if ($action !== 'createIndex') { + $destination->deleteCollection(self::COLLECTION); + } + + $change($mirror, $source, $destination); + + $this->assertCount(1, $errors); + $this->assertSame([$action], \array_column($errors->getArrayCopy(), 0)); + } + + /** + * Each case: the write, its onError action, the document it touches, the filter hooks it runs, then the title the + * source and the destination hold afterwards, and the one the destination keeps when a filter fails. + * + * @return iterable, ?string, ?string, ?string}> + */ + public static function documentWrites(): iterable + { + yield 'createDocument' => [ + static fn (Mirror $mirror): mixed => $mirror->createDocument(self::COLLECTION, new Document([Document::ID => 'second', 'title' => 'second'])), + 'createDocument', + 'second', + [['beforeCreateDocument', self::COLLECTION, 'second'], ['afterCreateDocument', self::COLLECTION, 'second']], + 'second', + 'filtered', + null, + ]; + yield 'createDocuments' => [ + static fn (Mirror $mirror): mixed => $mirror->createDocuments(self::COLLECTION, [new Document([Document::ID => 'second', 'title' => 'second'])]), + 'createDocuments', + 'second', + [['beforeCreateDocument', self::COLLECTION, 'second'], ['afterCreateDocument', self::COLLECTION, 'second']], + 'second', + 'filtered', + null, + ]; + yield 'updateDocument' => [ + static fn (Mirror $mirror): mixed => $mirror->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'updated'])), + 'updateDocument', + 'first', + [['beforeUpdateDocument', self::COLLECTION, 'first'], ['afterUpdateDocument', self::COLLECTION, 'first']], + 'updated', + 'filtered', + 'first', + ]; + yield 'updateDocuments' => [ + static fn (Mirror $mirror): mixed => $mirror->updateDocuments(self::COLLECTION, new Document(['title' => 'updated']), [Query::equal(Document::ID, ['first'])]), + 'updateDocuments', + 'first', + [['beforeUpdateDocuments', self::COLLECTION, 'updated'], ['afterUpdateDocuments', self::COLLECTION, 'filtered']], + 'updated', + 'filtered', + 'first', + ]; + yield 'upsertDocuments' => [ + static fn (Mirror $mirror): mixed => $mirror->upsertDocuments(self::COLLECTION, [new Document([Document::ID => 'first', 'title' => 'upserted'])]), + 'upsertDocuments', + 'first', + [['beforeCreateOrUpdateDocument', self::COLLECTION, 'first'], ['afterCreateOrUpdateDocument', self::COLLECTION, 'first']], + 'upserted', + 'filtered', + 'first', + ]; + yield 'upsertDocuments with an increase' => [ + static fn (Mirror $mirror): mixed => $mirror->upsertDocuments(self::COLLECTION, [new Document([Document::ID => 'second', 'title' => 'upserted', 'views' => 1])], increase: 'views'), + 'upsertDocuments', + 'second', + [['beforeCreateOrUpdateDocument', self::COLLECTION, 'second'], ['afterCreateOrUpdateDocument', self::COLLECTION, 'second']], + 'upserted', + 'filtered', + null, + ]; + yield 'deleteDocument' => [ + static fn (Mirror $mirror): mixed => $mirror->deleteDocument(self::COLLECTION, 'first'), + 'deleteDocument', + 'first', + [['beforeDeleteDocument', self::COLLECTION, 'first'], ['afterDeleteDocument', self::COLLECTION, 'first']], + null, + null, + 'first', + ]; + yield 'deleteDocuments' => [ + static fn (Mirror $mirror): mixed => $mirror->deleteDocuments(self::COLLECTION, [Query::equal(Document::ID, ['first'])]), + 'deleteDocuments', + 'first', + [['beforeDeleteDocuments', self::COLLECTION, 1], ['afterDeleteDocuments', self::COLLECTION, 1]], + null, + null, + 'first', + ]; + } + + /** + * @param Closure(Mirror): mixed $write + * @param list $hooks + */ + #[DataProvider('documentWrites')] + public function testDocumentWritesRunWriteFilters(Closure $write, string $action, string $id, array $hooks, ?string $sourceTitle, ?string $destinationTitle, ?string $unchangedTitle): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter( + $calls, + static fn (string $hook, ?Document $document): ?Document => $document !== null && \str_starts_with($hook, 'before') + ? (clone $document)->setAttribute('title', 'filtered') + : $document, + )], $source, $destination); + $errors = self::errors($mirror); + + self::inCoroutine(static fn (): mixed => $write($mirror)); + + $this->assertSame([], $errors->getArrayCopy()); + $this->assertSame($hooks, $calls->getArrayCopy()); + $this->assertSame([$sourceTitle, $destinationTitle], [self::storedTitle($source, $id), self::storedTitle($destination, $id)]); + } + + /** + * @param Closure(Mirror): mixed $write + * @param list $hooks + */ + #[DataProvider('documentWrites')] + public function testDocumentWriteFilterFailureIsReportedNotThrown(Closure $write, string $action, string $id, array $hooks, ?string $sourceTitle, ?string $destinationTitle, ?string $unchangedTitle): void + { + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter( + new ArrayObject(), + static fn (string $hook): ?Document => throw new RuntimeException('filter failed in '.$hook), + )], $source, $destination); + $errors = self::errors($mirror); + + self::inCoroutine(static fn (): mixed => $write($mirror)); + + $this->assertSame([[$action, 'filter failed in '.$hooks[0][0]]], $errors->getArrayCopy()); + $this->assertSame([$sourceTitle, $unchangedTitle], [self::storedTitle($source, $id), self::storedTitle($destination, $id)]); + } + + /** + * @param Closure(Mirror): mixed $write + * @param list $hooks + */ + #[DataProvider('documentWrites')] + public function testDocumentReplicationFailureIsReportedNotThrown(Closure $write, string $action, string $id, array $hooks, ?string $sourceTitle, ?string $destinationTitle, ?string $unchangedTitle): void + { + $source = self::sqlite(); + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + public bool $unreachable = false; + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + $this->reach(); + + return parent::createDocument($collection, $document); + } + + #[\Override] + public function createDocuments(Document $collection, array $documents): array + { + $this->reach(); + + return parent::createDocuments($collection, $documents); + } + + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + $this->reach(); + + return parent::updateDocument($collection, $id, $document, $skipPermissions); + } + + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int + { + $this->reach(); + + return parent::updateDocuments($collection, $updates, $documents, $skipPermissions); + } + + /** + * @param array $changes + * @return array + */ + #[\Override] + public function upsertDocuments(Document $collection, array $changes, ?string $increase = null): array + { + $this->reach(); + + return parent::upsertDocuments($collection, $changes, $increase); + } + + #[\Override] + public function deleteDocument(Document $collection, string $id): bool + { + $this->reach(); + + return parent::deleteDocument($collection, $id); + } + + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + { + $this->reach(); + + return parent::deleteDocuments($collection, $sequences, $permissionIds); + } + + private function reach(): void + { + if ($this->unreachable) { + throw new RuntimeException('destination unreachable'); + } + } + }; + $destination = new Database($adapter, new Cache(new None())); + $mirror = $this->filtered([], $source, $destination); + $errors = self::errors($mirror); + $adapter->unreachable = true; + + self::inCoroutine(static fn (): mixed => $write($mirror)); + + $this->assertSame([[$action, 'destination unreachable']], $errors->getArrayCopy()); + $this->assertSame($sourceTitle, self::storedTitle($source, $id)); + $this->assertSame($unchangedTitle, self::storedTitle($destination, $id)); + $this->assertFalse($destination->isPreservingDates(), 'A failed replication must not leave the destination preserving dates'); + } + + public function testReplicationKeepsTheDestinationsPreserveDatesSetting(): void + { + $destination = self::sqlite(); + $mirror = $this->filtered([], self::sqlite(), $destination); + $mirror->setPreserveDates(true); + + $mirror->createDocument(self::COLLECTION, new Document([Document::ID => 'second', 'title' => 'second'])); + $mirror->updateDocument(self::COLLECTION, 'second', new Document(['title' => 'updated'])); + + $this->assertTrue($destination->isPreservingDates()); + } + + public function testWritesThroughAMirrorWhoseSourceHasNoUpgradesCollectionAreNotReplicated(): void + { + $source = self::sqlite(); + $destination = self::sqlite(); + $namespace = 'mirror_'.\uniqid(); + foreach ([$source, $destination] as $database) { + $database->setDatabase('mirror')->setNamespace($namespace)->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64), Attribute::integer(key: 'views')], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'first', 'title' => 'first'])); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'third', 'title' => 'third'])); + } + $mirror = (new Mirror($source, $destination))->setDatabase('mirror')->setNamespace($namespace); + $errors = self::errors($mirror); + + self::inCoroutine(static function () use ($mirror): void { + $mirror->createDocument(self::COLLECTION, new Document([Document::ID => 'second', 'title' => 'second'])); + $mirror->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'updated'])); + $mirror->deleteDocument(self::COLLECTION, 'third'); + $mirror->createDocuments(self::COLLECTION, [new Document([Document::ID => 'fourth', 'title' => 'fourth'])]); + }); + + $this->assertNull($source->findCollection('upgrades')); + $this->assertSame(['updated', 'second', null, 'fourth'], \array_map(static fn (string $id): ?string => self::storedTitle($source, $id), ['first', 'second', 'third', 'fourth'])); + $this->assertSame(['first', null, 'third', null], \array_map(static fn (string $id): ?string => self::storedTitle($destination, $id), ['first', 'second', 'third', 'fourth'])); + $this->assertSame([], $errors->getArrayCopy()); + } + + public function testUpdateCollectionWithoutDestinationReturnsTheSourceCollection(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $mirror = $this->seed(new Mirror($source)); + $errors = self::errors($mirror); + + $updated = $mirror->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::users())], documentSecurity: false)); + + $this->assertSame([Permission::read(Role::users())], $updated->getPermissions()); + $this->assertFalse($updated->getAttribute('documentSecurity')); + $this->assertSame([Permission::read(Role::users())], $source->getCollection(self::COLLECTION)->getPermissions()); + $this->assertSame([], $errors->getArrayCopy()); + } + + /** + * @return iterable + */ + public static function destinationlessReplicatedWrites(): iterable + { + yield 'createAttribute' => [ + static fn (Mirror $mirror, Database $source): array => [ + $mirror->createAttribute(self::COLLECTION, Attribute::string(key: 'summary', size: 64))->key, + self::attribute($source, 'summary')?->size, + ], + ['summary', 64], + ]; + yield 'createAttributes' => [ + static fn (Mirror $mirror, Database $source): array => [ + \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $mirror->createAttributes(self::COLLECTION, [Attribute::string(key: 'summary', size: 64), Attribute::string(key: 'subtitle', size: 32)]), + ), + [self::attribute($source, 'summary')?->size, self::attribute($source, 'subtitle')?->size], + ], + [['summary', 'subtitle'], [64, 32]], + ]; + yield 'updateAttribute' => [ + static fn (Mirror $mirror, Database $source): array => [ + $mirror->updateAttribute(self::COLLECTION, 'title', new AttributeUpdate(size: 100))->size, + self::attribute($source, 'title')?->size, + ], + [100, 100], + ]; + yield 'createIndex' => [ + static fn (Mirror $mirror, Database $source): array => [ + $mirror->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title']))->key, + self::index($source, 'titles')?->attributes, + ], + ['titles', ['title']], + ]; + yield 'upsertDocuments without an increase' => [ + static fn (Mirror $mirror, Database $source): array => [ + $mirror->upsertDocuments(self::COLLECTION, [new Document([Document::ID => 'first', 'title' => 'upserted', 'views' => 1])]), + self::storedTitle($source, 'first'), + ], + [1, 'upserted'], + ]; + yield 'deleteAttribute' => [ + static function (Mirror $mirror, Database $source): array { + $mirror->deleteAttribute(self::COLLECTION, 'views'); + + return [self::attribute($source, 'views'), self::attribute($source, 'title')?->key]; + }, + [null, 'title'], + ]; + yield 'deleteIndex' => [ + static function (Mirror $mirror, Database $source): array { + $source->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title'])); + + $mirror->deleteIndex(self::COLLECTION, 'titles'); + + return [self::index($source, 'titles'), self::attribute($source, 'title')?->key]; + }, + [null, 'title'], + ]; + yield 'updateDocument' => [ + static fn (Mirror $mirror, Database $source): array => [ + $mirror->updateDocument(self::COLLECTION, 'first', new Document(['title' => 'updated']))->getAttribute('title'), + self::storedTitle($source, 'first'), + ], + ['updated', 'updated'], + ]; + } + + /** + * A mirror built without a destination over a source whose collection an earlier mirror upgraded writes the + * source only: its write filters do not run and nothing is reported. + * + * @param Closure(Mirror, Database): array{mixed, mixed} $write + * @param array{mixed, mixed} $expected + */ + #[DataProvider('destinationlessReplicatedWrites')] + public function testWriteWithoutDestinationOverAnUpgradedCollectionStaysOnTheSource(Closure $write, array $expected): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $seeded = $this->seed(new Mirror($source, self::sqlite())); + $mirror = (new Mirror($source, null, [self::recordingFilter($calls, static fn (string $hook, ?Document $document): ?Document => $document)])) + ->setDatabase($seeded->getDatabase()) + ->setNamespace($seeded->getNamespace()); + $errors = self::errors($mirror); + + $this->assertSame('upgraded', self::upgradeStatus($mirror, self::COLLECTION)); + $this->assertSame($expected, $write($mirror, $source)); + $this->assertSame([], $calls->getArrayCopy()); + $this->assertSame([], $errors->getArrayCopy()); + } + + public function testDeleteAttributeAndIndexRunWriteFilters(): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $source = self::sqlite(); + $destination = self::sqlite(); + $mirror = $this->filtered([self::recordingFilter($calls, static fn (string $hook, ?Document $document): ?Document => $document)], $source, $destination); + $errors = self::errors($mirror); + $mirror->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title'])); + $calls->exchangeArray([]); + + $mirror->deleteIndex(self::COLLECTION, 'titles'); + $mirror->deleteAttribute(self::COLLECTION, 'views'); + + $this->assertSame([ + ['beforeDeleteIndex', self::COLLECTION, 'titles'], + ['beforeDeleteAttribute', self::COLLECTION, 'views'], + ], $calls->getArrayCopy()); + $this->assertSame([null, null], [self::index($destination, 'titles'), self::attribute($destination, 'views')]); + $this->assertSame([], $errors->getArrayCopy()); + } + + private static function storedTitle(Database $database, string $id): ?string + { + $title = $database->getDocument(self::COLLECTION, $id)->getAttribute('title'); + + return \is_string($title) ? $title : null; + } + + private static function attribute(Database $database, string $key): ?Attribute + { + foreach ($database->getCollection(self::COLLECTION)->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + return null; + } + + private static function index(Database $database, string $key): ?Index + { + foreach ($database->getCollection(self::COLLECTION)->indexes() as $index) { + if ($index->key === $key) { + return $index; + } + } + + return null; + } +} diff --git a/tests/unit/Model/AttributeBits64BoundsTest.php b/tests/unit/Model/AttributeBits64BoundsTest.php new file mode 100644 index 0000000000..f7a8d9bef6 --- /dev/null +++ b/tests/unit/Model/AttributeBits64BoundsTest.php @@ -0,0 +1,104 @@ + + */ + public static function bounds(): array + { + return [ + 'signed 64-bit' => [Attribute::integer('a', width: IntegerWidth::Bits64), \PHP_INT_MIN, Database::MAX_BIG_INT], + 'unsigned 64-bit' => [Attribute::integer('a', signed: false, width: IntegerWidth::Bits64), 0, Database::MAX_BIG_INT], + 'signed 32-bit' => [Attribute::integer('a'), Database::MIN_INT, Database::MAX_INT], + 'unsigned 32-bit' => [Attribute::integer('a', signed: false), 0, Database::MAX_INT], + 'stored size 8' => [Attribute::fromArray(['key' => 'a', 'type' => 'integer', 'size' => 8, 'signed' => true]), \PHP_INT_MIN, Database::MAX_BIG_INT], + ]; + } + + #[DataProvider('bounds')] + public function testBounds(Attribute $attribute, int $min, int $max): void + { + $bounds = $attribute->bounds(); + + $this->assertNotNull($bounds); + $this->assertSame($min, $bounds->min); + $this->assertSame($max, $bounds->max); + } + + public function testIncreasingPastThe32BitMaximumSucceeds(): void + { + $database = $this->database(signed: true); + + $document = $database->increaseDocumentAttribute(self::COLLECTION, 'counter', self::COUNTER, 1); + + $this->assertSame(Database::MAX_INT + 1, $document->getAttribute(self::COUNTER)); + $this->assertSame(Database::MAX_INT + 1, $database->getDocument(self::COLLECTION, 'counter')->getAttribute(self::COUNTER)); + } + + public function testIncreasingPastThe64BitMaximumFails(): void + { + $database = $this->database(signed: false); + $database->updateDocument(self::COLLECTION, 'counter', new Document([self::COUNTER => Database::MAX_BIG_INT])); + + $this->expectException(Limit::class); + + $database->increaseDocumentAttribute(self::COLLECTION, 'counter', self::COUNTER, 1); + } + + public function testDecreasingAnUnsignedCounterBelowZeroFails(): void + { + $database = $this->database(signed: false); + $database->updateDocument(self::COLLECTION, 'counter', new Document([self::COUNTER => 0])); + + $this->expectException(Limit::class); + + $database->decreaseDocumentAttribute(self::COLLECTION, 'counter', self::COUNTER, 1); + } + + private function database(bool $signed): Database + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('bounds') + ->setNamespace('bounds_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::integer(key: self::COUNTER, signed: $signed, width: IntegerWidth::Bits64)], + permissions: [Permission::read(Role::any()), Permission::update(Role::any()), Permission::create(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document([ + Document::ID => 'counter', + self::COUNTER => Database::MAX_INT, + ])); + + return $database; + } +} diff --git a/tests/unit/Model/AttributeFactoryTest.php b/tests/unit/Model/AttributeFactoryTest.php new file mode 100644 index 0000000000..49e088e641 --- /dev/null +++ b/tests/unit/Model/AttributeFactoryTest.php @@ -0,0 +1,284 @@ +toDocument(); + + $this->assertSame('datetime', $stored->getAttribute('type')); + $this->assertFalse($stored->getAttribute('signed')); + $this->assertSame(['datetime'], $stored->getAttribute('filters')); + $this->assertSame(0, $stored->getAttribute('size')); + $this->assertTrue($stored->getAttribute('required')); + } + + public function testDatetimeArrayKeepsTheDatetimeFilter(): void + { + $attribute = Attribute::datetime('history', array: true, default: ['2024-01-01T00:00:00.000+00:00']); + + $this->assertTrue($attribute->array); + $this->assertSame([Filter::Datetime->value], $attribute->filters); + $this->assertSame(['2024-01-01T00:00:00.000+00:00'], $attribute->default); + } + + /** + * @return array + */ + public static function spatialFactories(): array + { + return [ + 'point' => [static fn (): Attribute => Attribute::point('location'), 'point'], + 'lineString' => [static fn (): Attribute => Attribute::lineString('location'), 'linestring'], + 'polygon' => [static fn (): Attribute => Attribute::polygon('location'), 'polygon'], + ]; + } + + /** + * @param \Closure(): Attribute $factory + */ + #[DataProvider('spatialFactories')] + public function testSpatialPersistsScalarWithItsTypeFilter(\Closure $factory, string $type): void + { + $stored = $factory()->toDocument(); + + $this->assertSame($type, $stored->getAttribute('type')); + $this->assertFalse($stored->getAttribute('array')); + $this->assertSame([$type], $stored->getAttribute('filters')); + $this->assertSame(0, $stored->getAttribute('size')); + $this->assertTrue($stored->getAttribute('signed')); + } + + public function testSpatialKeepsItsDefault(): void + { + $attribute = Attribute::point('location', required: true, default: [1.5, 2.5]); + + $this->assertSame([1.5, 2.5], $attribute->default); + $this->assertTrue($attribute->required); + } + + public function testVectorPersistsDimensionsAsSize(): void + { + $stored = Attribute::vector('embedding', 1536, default: [0.0, 1.0])->toDocument(); + + $this->assertSame('vector', $stored->getAttribute('type')); + $this->assertSame(1536, $stored->getAttribute('size')); + $this->assertSame(['vector'], $stored->getAttribute('filters')); + $this->assertFalse($stored->getAttribute('array')); + $this->assertSame([0.0, 1.0], $stored->getAttribute('default')); + } + + public function testObjectPersistsScalarWithObjectFilter(): void + { + $stored = Attribute::object('metadata', default: ['a' => 1])->toDocument(); + + $this->assertSame('object', $stored->getAttribute('type')); + $this->assertSame(['object'], $stored->getAttribute('filters')); + $this->assertFalse($stored->getAttribute('array')); + $this->assertSame(0, $stored->getAttribute('size')); + $this->assertSame(['a' => 1], $stored->getAttribute('default')); + } + + public function testIntegerDefaultsToThirtyTwoBitsPersistedAsSizeZero(): void + { + $attribute = Attribute::integer('count'); + + $this->assertNull($attribute->size); + $this->assertSame(IntegerWidth::Bits32, $attribute->width()); + $this->assertSame(0, $attribute->toDocument()->getAttribute('size')); + $this->assertTrue($attribute->signed); + } + + public function testIntegerSixtyFourBitsPersistsAsSizeEight(): void + { + $attribute = Attribute::integer('count', width: IntegerWidth::Bits64, signed: false, default: 5); + + $this->assertSame(8, $attribute->size); + $this->assertSame(IntegerWidth::Bits64, $attribute->width()); + $this->assertSame(8, $attribute->toDocument()->getAttribute('size')); + $this->assertFalse($attribute->signed); + $this->assertSame(5, $attribute->default); + } + + /** + * @return array + */ + public static function sizelessFactories(): array + { + return [ + 'boolean' => [static fn (): Attribute => Attribute::boolean('flag'), 'boolean'], + 'double' => [static fn (): Attribute => Attribute::double('score'), 'double'], + 'float' => [static fn (): Attribute => Attribute::float('score'), 'float'], + 'id' => [static fn (): Attribute => Attribute::id('reference'), 'id'], + 'bigInteger' => [static fn (): Attribute => Attribute::bigInteger('total'), 'bigint'], + ]; + } + + /** + * @param \Closure(): Attribute $factory + */ + #[DataProvider('sizelessFactories')] + public function testSizelessTypesPersistSizeZero(\Closure $factory, string $type): void + { + $attribute = $factory(); + $stored = $attribute->toDocument(); + + $this->assertNull($attribute->size); + $this->assertSame($type, $stored->getAttribute('type')); + $this->assertSame(0, $stored->getAttribute('size')); + $this->assertSame([], $stored->getAttribute('filters')); + $this->assertTrue($stored->getAttribute('signed')); + } + + /** + * @return array + */ + public static function stringFactories(): array + { + return [ + 'string' => [static fn (): Attribute => Attribute::string('name'), 'string', Database::LENGTH_KEY], + 'varchar' => [static fn (): Attribute => Attribute::varchar('name', 64), 'varchar', 64], + 'text' => [static fn (): Attribute => Attribute::text('name'), 'text', 0], + 'mediumText' => [static fn (): Attribute => Attribute::mediumText('name'), 'mediumtext', 0], + 'longText' => [static fn (): Attribute => Attribute::longText('name', 1024), 'longtext', 1024], + ]; + } + + /** + * @param \Closure(): Attribute $factory + */ + #[DataProvider('stringFactories')] + public function testStringFamilyPersistsSigned(\Closure $factory, string $type, int $size): void + { + $stored = $factory()->toDocument(); + + $this->assertSame($type, $stored->getAttribute('type')); + $this->assertTrue($stored->getAttribute('signed')); + $this->assertSame($size, $stored->getAttribute('size')); + $this->assertFalse($stored->getAttribute('array')); + $this->assertSame([], $stored->getAttribute('filters')); + } + + public function testExplicitZeroSizeMeansNoSize(): void + { + $attribute = Attribute::text('body', 0); + + $this->assertNull($attribute->size); + $this->assertSame(0, $attribute->toDocument()->getAttribute('size')); + } + + public function testStringCarriesEveryArgument(): void + { + $attribute = Attribute::string( + 'email', + size: 320, + required: true, + default: 'user@example.com', + array: true, + format: new Format('email', ['allowPlus' => true]), + filters: [Filter::Json, 'lowercase'], + ); + + $this->assertSame('email', $attribute->key); + $this->assertSame(ColumnType::String, $attribute->type); + $this->assertSame(320, $attribute->size); + $this->assertTrue($attribute->required); + $this->assertSame('user@example.com', $attribute->default); + $this->assertTrue($attribute->array); + $this->assertSame('email', $attribute->format?->name); + $this->assertSame(['allowPlus' => true], $attribute->format->options); + $this->assertSame(['json', 'lowercase'], $attribute->filters); + $this->assertNull($attribute->relationship); + $this->assertNull($attribute->side); + } + + public function testEveryFactoryProducesItsColumnType(): void + { + $this->assertSame(ColumnType::String, Attribute::string('a')->type); + $this->assertSame(ColumnType::Varchar, Attribute::varchar('a')->type); + $this->assertSame(ColumnType::Text, Attribute::text('a')->type); + $this->assertSame(ColumnType::MediumText, Attribute::mediumText('a')->type); + $this->assertSame(ColumnType::LongText, Attribute::longText('a')->type); + $this->assertSame(ColumnType::Integer, Attribute::integer('a')->type); + $this->assertSame(ColumnType::BigInteger, Attribute::bigInteger('a')->type); + $this->assertSame(ColumnType::Float, Attribute::float('a')->type); + $this->assertSame(ColumnType::Double, Attribute::double('a')->type); + $this->assertSame(ColumnType::Boolean, Attribute::boolean('a')->type); + $this->assertSame(ColumnType::Datetime, Attribute::datetime('a')->type); + $this->assertSame(ColumnType::Point, Attribute::point('a')->type); + $this->assertSame(ColumnType::Linestring, Attribute::lineString('a')->type); + $this->assertSame(ColumnType::Polygon, Attribute::polygon('a')->type); + $this->assertSame(ColumnType::Vector, Attribute::vector('a', 3)->type); + $this->assertSame(ColumnType::Object, Attribute::object('a')->type); + $this->assertSame(ColumnType::Id, Attribute::id('a')->type); + } + + public function testNumericFactoriesKeepSignedAndDefault(): void + { + $double = Attribute::double('score', required: true, default: 1.5, signed: false, array: true); + + $this->assertFalse($double->signed); + $this->assertSame(1.5, $double->default); + $this->assertTrue($double->required); + $this->assertTrue($double->array); + + $big = Attribute::bigInteger('total', default: '9223372036854775808', signed: false); + + $this->assertSame('9223372036854775808', $big->default); + $this->assertFalse($big->signed); + } + + public function testRelationshipPersistsSevenXOptionsShapePlusSide(): void + { + $relationship = Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade); + $stored = Attribute::relationship('comments', $relationship, RelationshipSide::Parent)->toDocument(); + + $this->assertSame('relationship', $stored->getAttribute('type')); + $this->assertSame('comments', $stored->getAttribute('key')); + $this->assertFalse($stored->getAttribute('required')); + $this->assertNull($stored->getAttribute('default')); + $this->assertSame([ + 'relatedCollection' => 'comments', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => 'cascade', + 'side' => 'parent', + ], $stored->getAttribute('options')); + } + + public function testRelationshipAdoptsTheAttributeKeyWhenTheRelationshipHasNone(): void + { + $attribute = Attribute::relationship('author', Relationship::manyToOne('users'), RelationshipSide::Child); + + $this->assertSame(ColumnType::Relationship, $attribute->type); + $this->assertSame('author', $attribute->relationship?->key); + $this->assertSame(RelationshipType::ManyToOne, $attribute->relationship->type); + $this->assertSame(RelationshipSide::Child, $attribute->side); + $this->assertNull($attribute->size); + $this->assertSame([], $attribute->filters); + } + + public function testRelationshipRejectsAMismatchedKey(): void + { + $this->expectException(Structure::class); + + Attribute::relationship('author', Relationship::manyToOne('users', key: 'writer'), RelationshipSide::Parent); + } +} diff --git a/tests/unit/Model/AttributeLegacyHydrationTest.php b/tests/unit/Model/AttributeLegacyHydrationTest.php new file mode 100644 index 0000000000..e253f83834 --- /dev/null +++ b/tests/unit/Model/AttributeLegacyHydrationTest.php @@ -0,0 +1,164 @@ + $stored + * @return array + */ + private static function sevenFour(array $stored): array + { + return [ + '$id' => 'field', + 'key' => 'field', + 'size' => 0, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'format' => '', + 'formatOptions' => [], + 'filters' => [], + ...$stored, + ]; + } + + /** + * @return array, Attribute}> + */ + public static function legacyShapes(): array + { + return [ + 'signed datetime' => [self::sevenFour(['type' => 'datetime', 'filters' => ['datetime']]), Attribute::datetime('field')], + 'signed datetime array' => [self::sevenFour(['type' => 'datetime', 'array' => true, 'filters' => ['datetime']]), Attribute::datetime('field', array: true)], + 'datetime without its filter' => [self::sevenFour(['type' => 'datetime']), Attribute::datetime('field')], + 'sizeless signed integer' => [self::sevenFour(['type' => 'integer']), Attribute::integer('field')], + '32-bit signed integer' => [self::sevenFour(['type' => 'integer', 'size' => 4]), Attribute::integer('field')], + '32-bit unsigned integer' => [self::sevenFour(['type' => 'integer', 'size' => 4, 'signed' => false]), Attribute::integer('field', signed: false)], + '64-bit signed integer' => [self::sevenFour(['type' => 'integer', 'size' => 8]), Attribute::integer('field', width: IntegerWidth::Bits64)], + '64-bit unsigned integer' => [self::sevenFour(['type' => 'integer', 'size' => 8, 'signed' => false]), Attribute::integer('field', signed: false, width: IntegerWidth::Bits64)], + 'oversized integer' => [self::sevenFour(['type' => 'integer', 'size' => 16]), Attribute::integer('field', width: IntegerWidth::Bits64)], + 'unsigned bigint' => [self::sevenFour(['type' => 'bigint', 'signed' => false]), Attribute::bigInteger('field', signed: false)], + 'string' => [self::sevenFour(['type' => 'string', 'size' => 255]), Attribute::string('field', 255)], + 'unsigned string' => [self::sevenFour(['type' => 'string', 'size' => 255, 'signed' => false]), Attribute::string('field', 255)], + 'string array' => [self::sevenFour(['type' => 'string', 'size' => 64, 'array' => true]), Attribute::string('field', 64, array: true)], + 'sizeless text' => [self::sevenFour(['type' => 'text']), Attribute::text('field')], + 'varchar' => [self::sevenFour(['type' => 'varchar', 'size' => 128]), Attribute::varchar('field', 128)], + 'signed double' => [self::sevenFour(['type' => 'double']), Attribute::double('field')], + 'unsigned double' => [self::sevenFour(['type' => 'double', 'signed' => false]), Attribute::double('field', signed: false)], + 'unsigned float' => [self::sevenFour(['type' => 'float', 'signed' => false]), Attribute::float('field', signed: false)], + 'boolean' => [self::sevenFour(['type' => 'boolean']), Attribute::boolean('field')], + 'unsigned boolean' => [self::sevenFour(['type' => 'boolean', 'signed' => false]), Attribute::boolean('field')], + 'id' => [self::sevenFour(['type' => 'id', 'signed' => false]), Attribute::id('field')], + 'point' => [self::sevenFour(['type' => 'point', 'filters' => ['point']]), Attribute::point('field')], + 'unsigned point' => [self::sevenFour(['type' => 'point', 'signed' => false, 'filters' => ['point']]), Attribute::point('field')], + 'linestring' => [self::sevenFour(['type' => 'linestring', 'filters' => ['linestring']]), Attribute::lineString('field')], + 'polygon' => [self::sevenFour(['type' => 'polygon', 'filters' => ['polygon']]), Attribute::polygon('field')], + 'vector' => [self::sevenFour(['type' => 'vector', 'size' => 3, 'filters' => ['vector']]), Attribute::vector('field', 3)], + 'object' => [self::sevenFour(['type' => 'object', 'filters' => ['object']]), Attribute::object('field')], + 'format' => [ + self::sevenFour(['type' => 'string', 'size' => 64, 'format' => 'enum', 'formatOptions' => ['elements' => ['a', 'b']]]), + Attribute::string('field', 64, format: new Format('enum', ['elements' => ['a', 'b']])), + ], + 'relationship' => [ + [ + '$id' => 'field', + 'key' => 'field', + 'type' => 'relationship', + 'required' => false, + 'default' => null, + 'options' => [ + 'relatedCollection' => 'comments', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => 'cascade', + 'side' => 'parent', + ], + ], + Attribute::relationship( + 'field', + Relationship::oneToMany('comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade), + RelationshipSide::Parent, + ), + ], + 'unsigned relationship' => [ + [ + '$id' => 'field', + 'key' => 'field', + 'type' => 'relationship', + 'size' => 0, + 'signed' => false, + 'array' => false, + 'filters' => [], + 'options' => [ + 'relatedCollection' => 'users', + 'relationType' => 'manyToOne', + 'twoWay' => false, + 'twoWayKey' => 'posts', + 'onDelete' => 'restrict', + 'side' => 'child', + ], + ], + Attribute::relationship('field', Relationship::manyToOne('users', twoWayKey: 'posts'), RelationshipSide::Child), + ], + ]; + } + + /** + * @param array $stored + */ + #[DataProvider('legacyShapes')] + public function testLegacyStoredShapeHydratesAsTheEightZeroAttribute(array $stored, Attribute $created): void + { + $hydrated = Attribute::fromDocument(new Document($stored)); + + $this->assertEquals($created, $hydrated); + $this->assertSame($created->toDocument()->getArrayCopy(), $hydrated->toDocument()->getArrayCopy()); + } + + /** + * @param array $stored + */ + #[DataProvider('legacyShapes')] + public function testLegacyStoredShapeHydratesAlreadyNormalised(array $stored, Attribute $created): void + { + $hydrated = Attribute::fromArray($stored); + + $this->assertEquals($hydrated->apply(new AttributeUpdate()), $hydrated); + $this->assertEquals($created, $hydrated); + } + + public function testHydrationKeepsATypeFilterInItsStoredPosition(): void + { + $attribute = Attribute::fromDocument(new Document(self::sevenFour([ + 'type' => 'datetime', + 'filters' => ['custom', 'datetime'], + ]))); + + $this->assertSame(['custom', 'datetime'], $attribute->filters); + } + + public function testHydrationPrependsAMissingTypeFilterAheadOfCustomFilters(): void + { + $attribute = Attribute::fromDocument(new Document(self::sevenFour([ + 'type' => 'point', + 'filters' => ['custom'], + ]))); + + $this->assertSame(['point', 'custom'], $attribute->filters); + } +} diff --git a/tests/unit/Model/AttributeStorageTest.php b/tests/unit/Model/AttributeStorageTest.php new file mode 100644 index 0000000000..8289a5e6d9 --- /dev/null +++ b/tests/unit/Model/AttributeStorageTest.php @@ -0,0 +1,394 @@ + + */ + public static function storedTypesOfSevenFour(): array + { + return [ + 'VAR_STRING' => ['string', ColumnType::String], + 'VAR_INTEGER' => ['integer', ColumnType::Integer], + 'VAR_BIGINT' => ['bigint', ColumnType::BigInteger], + 'VAR_FLOAT' => ['double', ColumnType::Double], + 'VAR_BOOLEAN' => ['boolean', ColumnType::Boolean], + 'VAR_DATETIME' => ['datetime', ColumnType::Datetime], + 'VAR_VARCHAR' => ['varchar', ColumnType::Varchar], + 'VAR_TEXT' => ['text', ColumnType::Text], + 'VAR_MEDIUMTEXT' => ['mediumtext', ColumnType::MediumText], + 'VAR_LONGTEXT' => ['longtext', ColumnType::LongText], + 'VAR_ID' => ['id', ColumnType::Id], + 'VAR_OBJECT' => ['object', ColumnType::Object], + 'VAR_VECTOR' => ['vector', ColumnType::Vector], + 'VAR_POINT' => ['point', ColumnType::Point], + 'VAR_LINESTRING' => ['linestring', ColumnType::Linestring], + 'VAR_POLYGON' => ['polygon', ColumnType::Polygon], + ]; + } + + #[DataProvider('storedTypesOfSevenFour')] + public function testEverySevenFourStoredTypeHydrates(string $stored, ColumnType $type): void + { + $attribute = Attribute::fromDocument(new Document([ + '$id' => 'field', + 'key' => 'field', + 'type' => $stored, + 'size' => 0, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'format' => '', + 'formatOptions' => [], + 'filters' => [], + ])); + + $this->assertSame($type, $attribute->type); + $this->assertSame('field', $attribute->key); + $this->assertSame($stored, $attribute->toDocument()->getAttribute('type')); + } + + public function testFloatStoredByEightZeroHydratesAsFloat(): void + { + $this->assertSame(ColumnType::Float, Attribute::fromArray(['key' => 'score', 'type' => 'float'])->type); + } + + public function testCanonicalBigIntegerSpellingHydratesAndPersistsAsLegacySpelling(): void + { + $attribute = Attribute::fromArray(['key' => 'total', 'type' => 'biginteger']); + + $this->assertSame(ColumnType::BigInteger, $attribute->type); + $this->assertSame('bigint', $attribute->toDocument()->getAttribute('type')); + } + + public function testSevenFourRelationshipShapeHydrates(): void + { + $attribute = Attribute::fromDocument(new Document([ + '$id' => 'comments', + 'key' => 'comments', + 'type' => 'relationship', + 'required' => false, + 'default' => null, + 'options' => [ + 'relatedCollection' => 'comments', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => 'cascade', + 'side' => 'parent', + ], + ])); + + $this->assertSame(ColumnType::Relationship, $attribute->type); + $this->assertSame(RelationshipSide::Parent, $attribute->side); + $this->assertSame('comments', $attribute->relationship?->relatedCollection); + $this->assertSame(RelationshipType::OneToMany, $attribute->relationship->type); + $this->assertTrue($attribute->relationship->twoWay); + $this->assertSame('comments', $attribute->relationship->key); + $this->assertSame('post', $attribute->relationship->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Cascade, $attribute->relationship->onDelete); + } + + public function testRelationshipRoundTripsThroughStorage(): void + { + $relationship = Relationship::manyToMany('tags', key: 'tags', twoWay: true, twoWayKey: 'posts', onDelete: RelationshipDeleteAction::SetNull); + $attribute = Attribute::relationship('tags', $relationship, RelationshipSide::Child); + + $stored = $attribute->toDocument(); + $hydrated = Attribute::fromDocument($stored); + + $this->assertSame($stored->getArrayCopy(), $hydrated->toDocument()->getArrayCopy()); + $this->assertSame(RelationshipSide::Child, $hydrated->side); + $this->assertSame(RelationshipType::ManyToMany, $hydrated->relationship?->type); + $this->assertSame(RelationshipDeleteAction::SetNull, $hydrated->relationship->onDelete); + } + + public function testRelationshipOptionsMayArriveAsDocument(): void + { + $attribute = Attribute::fromArray([ + 'key' => 'author', + 'type' => 'relationship', + 'options' => new Document([ + 'relatedCollection' => 'users', + 'relationType' => 'manyToOne', + 'twoWay' => false, + 'twoWayKey' => 'posts', + 'onDelete' => 'restrict', + 'side' => 'child', + ]), + ]); + + $this->assertSame('users', $attribute->relationship?->relatedCollection); + $this->assertSame(RelationshipSide::Child, $attribute->side); + } + + public function testRelationshipWithoutSideDefaultsToParent(): void + { + $attribute = Attribute::fromArray([ + 'key' => 'author', + 'type' => 'relationship', + 'options' => [ + 'relatedCollection' => 'users', + 'relationType' => 'oneToOne', + 'twoWay' => false, + 'twoWayKey' => 'post', + 'onDelete' => 'restrict', + ], + ]); + + $this->assertSame(RelationshipSide::Parent, $attribute->side); + } + + public function testRelationshipWithoutOptionsIsRejected(): void + { + $this->expectException(Structure::class); + + Attribute::fromArray(['key' => 'author', 'type' => 'relationship']); + } + + public function testRelationshipWithUnknownSideIsRejected(): void + { + $this->expectException(Structure::class); + + Attribute::fromArray([ + 'key' => 'author', + 'type' => 'relationship', + 'options' => ['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'side' => 'sideways'], + ]); + } + + public function testEmptyStoredFormatHydratesAsNoFormat(): void + { + $attribute = Attribute::fromDocument(new Document([ + '$id' => '$createdAt', + 'type' => 'datetime', + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ])); + + $this->assertNull($attribute->format); + $this->assertSame('$createdAt', $attribute->key); + $this->assertNull($attribute->toDocument()->getAttribute('format')); + $this->assertSame([], $attribute->toDocument()->getAttribute('formatOptions')); + $this->assertFalse($attribute->signed); + $this->assertSame(['datetime'], $attribute->filters); + } + + public function testStatusAndGenericOptionsAreIgnored(): void + { + $attribute = Attribute::fromDocument(new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => 'string', + 'size' => 128, + 'status' => 'available', + 'options' => ['precision' => 2], + 'error' => '', + ])); + + $stored = $attribute->toDocument(); + + $this->assertSame(self::STORED_KEYS, \array_keys($stored->getArrayCopy())); + $this->assertNull($attribute->relationship); + $this->assertNull($attribute->side); + $this->assertSame(128, $attribute->size); + } + + /** + * @return array}> + */ + public static function internalAttributesOfSevenFour(): array + { + return [ + '$id' => [['$id' => '$id', 'type' => 'string', 'size' => 255, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]], + '$sequence' => [['$id' => '$sequence', 'type' => 'id', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]], + '$tenant' => [['$id' => '$tenant', 'type' => 'id', 'size' => 0, 'required' => false, 'default' => null, 'signed' => true, 'array' => false, 'filters' => []]], + '$updatedAt' => [['$id' => '$updatedAt', 'type' => 'datetime', 'format' => '', 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime']]], + '$permissions' => [['$id' => '$permissions', 'type' => 'string', 'size' => 1_000_000, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json']]], + ]; + } + + /** + * @param array $row + */ + #[DataProvider('internalAttributesOfSevenFour')] + public function testSevenFourInternalAttributeRowsRoundTrip(array $row): void + { + $stored = Attribute::fromArray($row)->toDocument(); + + foreach ($row as $key => $value) { + if ($key === 'format') { + $this->assertNull($stored->getAttribute('format'), 'An empty stored format persists as null'); + + continue; + } + $this->assertSame($value, $stored->getAttribute($key), "Stored '{$key}' changed in the round trip"); + } + } + + public function testMissingStoredFieldsTakeTheirDefaults(): void + { + $attribute = Attribute::fromArray(['$id' => 'name', 'type' => 'string']); + + $this->assertSame('name', $attribute->key); + $this->assertNull($attribute->size); + $this->assertFalse($attribute->required); + $this->assertNull($attribute->default); + $this->assertTrue($attribute->signed); + $this->assertFalse($attribute->array); + $this->assertNull($attribute->format); + $this->assertSame([], $attribute->filters); + } + + public function testKeyIsPreferredOverId(): void + { + $this->assertSame('key', Attribute::fromArray(['$id' => 'id', 'key' => 'key', 'type' => 'string'])->key); + $this->assertSame('key', Attribute::fromDocument(new Document(['$id' => 'id', 'key' => 'key', 'type' => 'string']))->key); + } + + public function testFormatAndOptionsHydrate(): void + { + $attribute = Attribute::fromArray([ + 'key' => 'range', + 'type' => 'integer', + 'format' => 'intRange', + 'formatOptions' => ['min' => 1, 'max' => 10], + ]); + + $this->assertSame('intRange', $attribute->format?->name); + $this->assertSame(['min' => 1, 'max' => 10], $attribute->format->options); + } + + public function testFormatOptionsMayArriveAsDocument(): void + { + $attribute = Attribute::fromDocument(new Document([ + '$id' => 'range', + 'type' => 'integer', + 'format' => 'intRange', + 'formatOptions' => new Document(['min' => 1]), + ])); + + $this->assertSame(['min' => 1], $attribute->format?->options); + } + + public function testStoredSizeHydratesAndNumericStringsAreAccepted(): void + { + $this->assertSame(8, Attribute::fromArray(['key' => 'count', 'type' => 'integer', 'size' => 8])->size); + $this->assertSame(IntegerWidth::Bits64, Attribute::fromArray(['key' => 'count', 'type' => 'integer', 'size' => '8'])->width()); + } + + /** + * @return array + */ + public static function factories(): array + { + return [ + 'string' => [static fn (): Attribute => Attribute::string('a', 10, true, 'x', true, new Format('email', ['strict' => true]), ['lowercase'])], + 'varchar' => [static fn (): Attribute => Attribute::varchar('a', 20)], + 'text' => [static fn (): Attribute => Attribute::text('a')], + 'mediumText' => [static fn (): Attribute => Attribute::mediumText('a', 300)], + 'longText' => [static fn (): Attribute => Attribute::longText('a')], + 'integer' => [static fn (): Attribute => Attribute::integer('a', default: 3)], + 'integer64' => [static fn (): Attribute => Attribute::integer('a', signed: false, width: IntegerWidth::Bits64)], + 'bigInteger' => [static fn (): Attribute => Attribute::bigInteger('a', default: '42')], + 'float' => [static fn (): Attribute => Attribute::float('a', default: 1.25)], + 'double' => [static fn (): Attribute => Attribute::double('a', array: true, default: [1.5])], + 'boolean' => [static fn (): Attribute => Attribute::boolean('a', default: true)], + 'datetime' => [static fn (): Attribute => Attribute::datetime('a', required: true)], + 'point' => [static fn (): Attribute => Attribute::point('a', default: [0.0, 0.0])], + 'lineString' => [static fn (): Attribute => Attribute::lineString('a')], + 'polygon' => [static fn (): Attribute => Attribute::polygon('a')], + 'vector' => [static fn (): Attribute => Attribute::vector('a', 3)], + 'object' => [static fn (): Attribute => Attribute::object('a')], + 'id' => [static fn (): Attribute => Attribute::id('a', default: 7)], + ]; + } + + /** + * @param \Closure(): Attribute $factory + */ + #[DataProvider('factories')] + public function testFactoryOutputRoundTripsThroughStorage(\Closure $factory): void + { + $attribute = $factory(); + $stored = $attribute->toDocument(); + + $this->assertSame(self::STORED_KEYS, \array_keys($stored->getArrayCopy())); + + $fromDocument = Attribute::fromDocument($stored); + $fromArray = Attribute::fromArray($stored->getArrayCopy()); + + $this->assertSame($stored->getArrayCopy(), $fromDocument->toDocument()->getArrayCopy()); + $this->assertSame($stored->getArrayCopy(), $fromArray->toDocument()->getArrayCopy()); + $this->assertSame($attribute->type, $fromDocument->type); + $this->assertSame($attribute->size, $fromDocument->size); + $this->assertSame($attribute->format?->name, $fromDocument->format?->name); + } + + /** + * @return array + */ + public static function rejectedTypes(): array + { + return [ + 'tuple' => ['tuple'], + 'uuid7 (sequence type, never an attribute type)' => ['uuid7'], + 'json' => ['json'], + 'unknown' => ['geometry'], + 'empty' => [''], + 'missing' => [null], + 'non-string' => [42], + 'non-attribute enum' => [ColumnType::Serial], + ]; + } + + #[DataProvider('rejectedTypes')] + public function testTypesOutsideTheAttributeTypesAreRejected(mixed $type): void + { + $this->expectException(Structure::class); + + Attribute::fromArray(['key' => 'field', 'type' => $type]); + } + + public function testFromDocumentRejectsTypesOutsideTheAttributeTypes(): void + { + $this->expectException(Structure::class); + + Attribute::fromDocument(new Document(['$id' => 'field', 'type' => 'tuple'])); + } + + public function testFromArrayRejectsTupleWithoutAnyOtherField(): void + { + $this->expectException(Structure::class); + + Attribute::fromArray(['type' => 'tuple']); + } + + public function testColumnTypeInstanceInStorageIsTolerated(): void + { + $this->assertSame(ColumnType::Polygon, Attribute::fromArray(['key' => 'area', 'type' => ColumnType::Polygon])->type); + } +} diff --git a/tests/unit/Model/AttributeTest.php b/tests/unit/Model/AttributeTest.php new file mode 100644 index 0000000000..1b99dc0d44 --- /dev/null +++ b/tests/unit/Model/AttributeTest.php @@ -0,0 +1,206 @@ +expectException(\Error::class); + $this->expectExceptionMessage('Call to private Utopia\Database\Attribute::__construct()'); + + $attribute = new Attribute('name', ColumnType::String, 10, false, null, true, false, null, [], null, null); // @phpstan-ignore new.privateConstructor + } + + public function testTypesListsTheEighteenAttributeTypes(): void + { + $this->assertCount(18, \array_filter(ColumnType::cases(), $this->hydratesAsAttributeType(...))); + $this->assertSame(Attribute::TYPES, \array_values(\array_unique(Attribute::TYPES, \SORT_REGULAR))); + $this->assertContains(ColumnType::Relationship, Attribute::TYPES); + $this->assertNotContains(ColumnType::Tuple, Attribute::TYPES); + $this->assertNotContains(ColumnType::Uuid7, Attribute::TYPES); + } + + /** + * @return array + */ + public static function predicates(): array + { + return [ + 'string' => [Attribute::string('a'), false, false, false], + 'integer' => [Attribute::integer('a'), false, true, true], + 'bigInteger' => [Attribute::bigInteger('a'), false, true, true], + 'float' => [Attribute::float('a'), false, true, false], + 'double' => [Attribute::double('a'), false, true, false], + 'boolean' => [Attribute::boolean('a'), false, false, false], + 'datetime' => [Attribute::datetime('a'), false, false, false], + 'point' => [Attribute::point('a'), true, false, false], + 'lineString' => [Attribute::lineString('a'), true, false, false], + 'polygon' => [Attribute::polygon('a'), true, false, false], + 'vector' => [Attribute::vector('a', 3), false, false, false], + 'object' => [Attribute::object('a'), false, false, false], + 'id' => [Attribute::id('a'), false, false, false], + ]; + } + + #[DataProvider('predicates')] + public function testTypePredicates(Attribute $attribute, bool $spatial, bool $numeric, bool $integer): void + { + $this->assertSame($spatial, $attribute->isSpatial()); + $this->assertSame($numeric, $attribute->isNumeric()); + $this->assertSame($integer, $attribute->isInteger()); + } + + /** + * @return array + */ + public static function bounds(): array + { + return [ + 'signed integer' => [Attribute::integer('a'), Database::MIN_INT, Database::MAX_INT], + 'unsigned integer' => [Attribute::integer('a', signed: false), 0, Database::MAX_INT], + 'signed bigInteger' => [Attribute::bigInteger('a'), \PHP_INT_MIN, Database::MAX_BIG_INT], + 'unsigned bigInteger' => [Attribute::bigInteger('a', signed: false), 0, BigInt::UNSIGNED_MAX], + 'signed float' => [Attribute::float('a'), -Database::MAX_DOUBLE, Database::MAX_DOUBLE], + 'unsigned double' => [Attribute::double('a', signed: false), 0, Database::MAX_DOUBLE], + ]; + } + + #[DataProvider('bounds')] + public function testNumericBounds(Attribute $attribute, int|float|string $min, int|float|string $max): void + { + $bounds = $attribute->bounds(); + + $this->assertNotNull($bounds); + $this->assertSame($min, $bounds->min); + $this->assertSame($max, $bounds->max); + } + + public function testNonNumericTypesHaveNoBounds(): void + { + $this->assertNull(Attribute::string('a')->bounds()); + $this->assertNull(Attribute::boolean('a')->bounds()); + $this->assertNull(Attribute::datetime('a')->bounds()); + } + + public function testResolvedSizeUsesTheEngineMaximumForSizelessText(): void + { + $this->assertSame(Database::MAX_TEXT_BYTES, Attribute::text('a')->resolvedSize()); + $this->assertSame(Database::MAX_MEDIUMTEXT_BYTES, Attribute::mediumText('a')->resolvedSize()); + $this->assertSame(Database::MAX_LONGTEXT_BYTES, Attribute::longText('a')->resolvedSize()); + } + + public function testResolvedSizeUsesTheDeclaredSize(): void + { + $this->assertSame(500, Attribute::text('a', 500)->resolvedSize()); + $this->assertSame(64, Attribute::varchar('a', 64)->resolvedSize()); + $this->assertSame(8, Attribute::fromArray(['key' => 'a', 'type' => 'integer', 'size' => 8])->resolvedSize()); + } + + public function testResolvedSizeIsZeroForSizelessNonTextTypes(): void + { + $this->assertSame(0, Attribute::boolean('a')->resolvedSize()); + $this->assertSame(0, Attribute::integer('a')->resolvedSize()); + } + + public function testWidthIsOnlyDefinedForIntegers(): void + { + $this->assertNull(Attribute::bigInteger('a')->width()); + $this->assertNull(Attribute::string('a')->width()); + $this->assertNull(Attribute::double('a')->width()); + } + + public function testWithFiltersReplacesFiltersOnly(): void + { + $original = Attribute::string('name', 32, required: true, default: 'x'); + + $filtered = $original->withFilters([Filter::Json, 'encrypt']); + + $this->assertSame(['json', 'encrypt'], $filtered->filters); + $this->assertSame([], $original->filters); + $this->assertSame('name', $filtered->key); + $this->assertSame(32, $filtered->size); + $this->assertTrue($filtered->required); + $this->assertSame('x', $filtered->default); + $this->assertNotSame($original, $filtered); + } + + public function testWithFiltersCanClearFilters(): void + { + $this->assertSame([], Attribute::string('a', filters: ['encrypt'])->withFilters([])->filters); + } + + public function testWithFiltersKeepsTheTypeFilter(): void + { + $this->assertSame(['datetime'], Attribute::datetime('a')->withFilters([])->filters); + $this->assertSame(['vector', 'encrypt'], Attribute::vector('a', 3)->withFilters(['encrypt'])->filters); + $this->assertSame(['encrypt', 'object'], Attribute::object('a')->withFilters(['encrypt', Filter::Object])->filters); + } + + public function testIsRelationshipReadsTheRawStoredType(): void + { + $this->assertTrue(Attribute::isRelationship(new Document(['type' => 'relationship']))); + $this->assertTrue(Attribute::isRelationship(new Document(['type' => ColumnType::Relationship]))); + $this->assertFalse(Attribute::isRelationship(new Document(['type' => 'string']))); + $this->assertFalse(Attribute::isRelationship(new Document(['type' => 'bigint']))); + $this->assertFalse(Attribute::isRelationship(new Document([]))); + $this->assertFalse(Attribute::isRelationship(Attribute::string('relationship')->toDocument())); + } + + public function testStoredTypeUsesTheLegacyBigIntegerSpelling(): void + { + $this->assertSame('bigint', Attribute::storedType(ColumnType::BigInteger)); + $this->assertSame('integer', Attribute::storedType(ColumnType::Integer)); + $this->assertSame('linestring', Attribute::storedType(ColumnType::Linestring)); + } + + public function testTypeFromStoredMapsBothBigIntegerSpellings(): void + { + $this->assertSame(ColumnType::BigInteger, Attribute::typeFromStored('bigint')); + $this->assertSame(ColumnType::BigInteger, Attribute::typeFromStored('biginteger')); + $this->assertSame(ColumnType::Double, Attribute::typeFromStored('double')); + } + + public function testEveryTypeRoundTripsThroughItsStoredSpelling(): void + { + foreach (Attribute::TYPES as $type) { + $this->assertSame($type, Attribute::typeFromStored(Attribute::storedType($type))); + } + } + + public function testTypeFromStoredRejectsNonAttributeTypes(): void + { + $this->expectException(Structure::class); + $this->expectExceptionMessage('Unknown attribute type: tuple'); + + Attribute::typeFromStored('tuple'); + } + + public function testTypeFromStoredRejectsUnknownStrings(): void + { + $this->expectException(Structure::class); + + Attribute::typeFromStored('BIGINT'); + } + + private function hydratesAsAttributeType(ColumnType $type): bool + { + try { + Attribute::typeFromStored($type->value); + + return true; + } catch (Structure) { + return false; + } + } +} diff --git a/tests/unit/Model/AttributeUpdateTest.php b/tests/unit/Model/AttributeUpdateTest.php new file mode 100644 index 0000000000..3639b7f760 --- /dev/null +++ b/tests/unit/Model/AttributeUpdateTest.php @@ -0,0 +1,301 @@ +assertTrue($update->isEmpty()); + $this->assertFalse($update->changesDefault()); + $this->assertFalse($update->changesFormat()); + $this->assertSame(Unchanged::Value, $update->default); + $this->assertSame(Unchanged::Value, $update->format); + } + + public function testNullDefaultIsAChange(): void + { + $update = new AttributeUpdate(default: null); + + $this->assertTrue($update->changesDefault()); + $this->assertFalse($update->isEmpty()); + } + + public function testAnySingleFieldMakesTheUpdateNonEmpty(): void + { + $updates = [ + new AttributeUpdate(type: ColumnType::Text), + new AttributeUpdate(size: 10), + new AttributeUpdate(required: false), + new AttributeUpdate(default: 'x'), + new AttributeUpdate(signed: false), + new AttributeUpdate(array: false), + new AttributeUpdate(format: new Format('email')), + new AttributeUpdate(format: null), + new AttributeUpdate(filters: []), + new AttributeUpdate(key: 'renamed'), + ]; + + foreach ($updates as $update) { + $this->assertFalse($update->isEmpty()); + } + } + + public function testEmptyUpdateKeepsEveryField(): void + { + $original = Attribute::string('name', 64, required: true, default: 'x', array: true, format: new Format('email', ['a' => 1]), filters: ['lowercase']); + + $updated = $original->apply(new AttributeUpdate()); + + $this->assertSame($original->toDocument()->getArrayCopy(), $updated->toDocument()->getArrayCopy()); + } + + public function testApplyChangesOnlyTheGivenFields(): void + { + $original = Attribute::string('name', 64, required: true, default: 'x', filters: ['lowercase']); + + $updated = $original->apply(new AttributeUpdate(size: 128, required: false)); + + $this->assertSame(128, $updated->size); + $this->assertFalse($updated->required); + $this->assertSame('x', $updated->default); + $this->assertSame(['lowercase'], $updated->filters); + $this->assertSame('name', $updated->key); + $this->assertSame(ColumnType::String, $updated->type); + } + + public function testZeroSizeClearsTheSize(): void + { + $this->assertNull(Attribute::text('body', 500)->apply(new AttributeUpdate(size: 0))->size); + } + + public function testApplyLeavesTheOriginalUntouched(): void + { + $original = Attribute::string('name', 64); + + $original->apply(new AttributeUpdate(size: 128, key: 'title', default: 'y')); + + $this->assertSame(64, $original->size); + $this->assertSame('name', $original->key); + $this->assertNull($original->default); + } + + public function testNullDefaultClearsTheDefault(): void + { + $updated = Attribute::integer('count', default: 5)->apply(new AttributeUpdate(default: null)); + + $this->assertNull($updated->default); + } + + public function testDefaultCanBeSet(): void + { + $updated = Attribute::integer('count')->apply(new AttributeUpdate(default: 7)); + + $this->assertSame(7, $updated->default); + } + + public function testKeyRenames(): void + { + $this->assertSame('title', Attribute::string('name')->apply(new AttributeUpdate(key: 'title'))->key); + } + + public function testFiltersAcceptEnumCasesAndStrings(): void + { + $updated = Attribute::string('name')->apply(new AttributeUpdate(filters: [Filter::Json, 'encrypt'])); + + $this->assertSame(['json', 'encrypt'], $updated->filters); + } + + public function testEmptyFiltersClearTheFilters(): void + { + $this->assertSame([], Attribute::string('name', filters: ['lowercase'])->apply(new AttributeUpdate(filters: []))->filters); + } + + public function testClearingFiltersKeepsTheTypeFilter(): void + { + $this->assertSame(['datetime'], Attribute::datetime('at')->apply(new AttributeUpdate(filters: []))->filters); + $this->assertSame(['point', 'encrypt'], Attribute::point('at')->apply(new AttributeUpdate(filters: ['encrypt']))->filters); + } + + public function testFormatReplacesTheFormat(): void + { + $updated = Attribute::string('name', format: new Format('email'))->apply(new AttributeUpdate(format: new Format('url', ['scheme' => 'https']))); + + $this->assertSame('url', $updated->format?->name); + $this->assertSame(['scheme' => 'https'], $updated->format->options); + } + + public function testNullFormatIsAChange(): void + { + $update = new AttributeUpdate(format: null); + + $this->assertTrue($update->changesFormat()); + $this->assertFalse($update->isEmpty()); + } + + public function testNullFormatRemovesTheFormat(): void + { + $updated = Attribute::string('name', format: new Format('email', ['strict' => true]))->apply(new AttributeUpdate(format: null)); + + $this->assertNull($updated->format); + $this->assertNull($updated->toDocument()->getAttribute('format')); + $this->assertSame([], $updated->toDocument()->getAttribute('formatOptions')); + } + + public function testOmittedFormatKeepsTheFormat(): void + { + $format = new Format('email', ['strict' => true]); + + $updated = Attribute::string('name', format: $format)->apply(new AttributeUpdate(size: 32)); + + $this->assertSame($format, $updated->format); + } + + public function testSignedAndArrayChange(): void + { + $updated = Attribute::integer('count')->apply(new AttributeUpdate(signed: false, array: true)); + + $this->assertFalse($updated->signed); + $this->assertTrue($updated->array); + } + + public function testTypeChangesToAnotherAttributeType(): void + { + $updated = Attribute::string('body', 100)->apply(new AttributeUpdate(type: ColumnType::Text)); + + $this->assertSame(ColumnType::Text, $updated->type); + $this->assertSame('text', $updated->toDocument()->getAttribute('type')); + $this->assertSame(100, $updated->size); + } + + /** + * @return array + */ + public static function typeChanges(): array + { + $string = Attribute::string('field', 64); + $integer = Attribute::integer('field', signed: false, array: true, width: IntegerWidth::Bits64); + $datetime = Attribute::datetime('field', array: true); + $point = Attribute::point('field'); + + return [ + 'string to varchar' => [$string, ColumnType::Varchar, Attribute::varchar('field', 64)], + 'string to text' => [$string, ColumnType::Text, Attribute::text('field', 64)], + 'string to mediumText' => [$string, ColumnType::MediumText, Attribute::mediumText('field', 64)], + 'string to longText' => [$string, ColumnType::LongText, Attribute::longText('field', 64)], + 'string to integer' => [$string, ColumnType::Integer, Attribute::integer('field', width: IntegerWidth::Bits64)], + 'string to bigInteger' => [$string, ColumnType::BigInteger, Attribute::bigInteger('field')], + 'string to float' => [$string, ColumnType::Float, Attribute::float('field')], + 'string to double' => [$string, ColumnType::Double, Attribute::double('field')], + 'string to boolean' => [$string, ColumnType::Boolean, Attribute::boolean('field')], + 'string to datetime' => [$string, ColumnType::Datetime, Attribute::datetime('field')], + 'string to id' => [$string, ColumnType::Id, Attribute::id('field')], + 'string to point' => [$string, ColumnType::Point, Attribute::point('field')], + 'string to lineString' => [$string, ColumnType::Linestring, Attribute::lineString('field')], + 'string to polygon' => [$string, ColumnType::Polygon, Attribute::polygon('field')], + 'string to object' => [$string, ColumnType::Object, Attribute::object('field')], + 'string to vector' => [$string, ColumnType::Vector, Attribute::vector('field', 64)], + 'unsigned integer to string' => [$integer, ColumnType::String, Attribute::string('field', 8, array: true)], + 'unsigned integer to integer of another width' => [Attribute::integer('field', signed: false), ColumnType::BigInteger, Attribute::bigInteger('field', signed: false)], + 'unsigned integer to double' => [$integer, ColumnType::Double, Attribute::double('field', signed: false, array: true)], + 'unsigned integer to boolean' => [$integer, ColumnType::Boolean, Attribute::boolean('field', array: true)], + 'unsigned integer to id' => [$integer, ColumnType::Id, Attribute::id('field', array: true)], + 'unsigned integer to datetime' => [$integer, ColumnType::Datetime, Attribute::datetime('field', array: true)], + 'integer array to point' => [$integer, ColumnType::Point, Attribute::point('field')], + 'integer array to vector' => [$integer, ColumnType::Vector, Attribute::vector('field', 8)], + 'datetime to integer' => [$datetime, ColumnType::Integer, Attribute::integer('field', signed: false, array: true)], + 'datetime to string' => [$datetime, ColumnType::String, Attribute::string('field', 0, array: true)], + 'point to text' => [$point, ColumnType::Text, Attribute::text('field')], + 'point to polygon' => [$point, ColumnType::Polygon, Attribute::polygon('field')], + ]; + } + + #[DataProvider('typeChanges')] + public function testTypeChangeProducesWhatTheTargetFactoryProduces(Attribute $source, ColumnType $type, Attribute $expected): void + { + $updated = $source->apply(new AttributeUpdate(type: $type)); + + $this->assertSame($expected->toDocument()->getArrayCopy(), $updated->toDocument()->getArrayCopy()); + } + + public function testTypeChangeKeepsUserFiltersAndSwapsTheTypeFilter(): void + { + $updated = Attribute::string('field', filters: ['encrypt']) + ->apply(new AttributeUpdate(type: ColumnType::Datetime)) + ->apply(new AttributeUpdate(type: ColumnType::Polygon)); + + $this->assertSame(['polygon', 'encrypt'], $updated->filters); + } + + public function testExplicitFiltersOnATypeChangeAreKept(): void + { + $updated = Attribute::datetime('field')->apply(new AttributeUpdate(type: ColumnType::String, filters: ['datetime'])); + + $this->assertSame(['datetime'], $updated->filters); + } + + public function testUpdatesCannotBreakTheTypeInvariants(): void + { + $this->assertTrue(Attribute::string('name')->apply(new AttributeUpdate(signed: false))->signed); + $this->assertFalse(Attribute::datetime('at')->apply(new AttributeUpdate(signed: true))->signed); + $this->assertFalse(Attribute::point('at')->apply(new AttributeUpdate(array: true))->array); + $this->assertNull(Attribute::boolean('flag')->apply(new AttributeUpdate(size: 4))->size); + $this->assertSame(8, Attribute::integer('count')->apply(new AttributeUpdate(size: 20))->size); + $this->assertNull(Attribute::integer('count', width: IntegerWidth::Bits64)->apply(new AttributeUpdate(size: 4))->size); + } + + public function testTypeOutsideTheAttributeTypesIsRejected(): void + { + $this->expectException(Structure::class); + + Attribute::string('body')->apply(new AttributeUpdate(type: ColumnType::Tuple)); + } + + public function testTypeCannotBecomeRelationship(): void + { + $this->expectException(Structure::class); + + Attribute::string('author')->apply(new AttributeUpdate(type: ColumnType::Relationship)); + } + + public function testSameTypeIsAccepted(): void + { + $this->assertSame(ColumnType::Integer, Attribute::integer('count')->apply(new AttributeUpdate(type: ColumnType::Integer))->type); + } + + public function testRelationshipAttributeCannotChangeType(): void + { + $attribute = Attribute::relationship('author', Relationship::manyToOne('users'), RelationshipSide::Parent); + + $this->expectException(Structure::class); + + $attribute->apply(new AttributeUpdate(type: ColumnType::String)); + } + + public function testRenamingARelationshipAttributeRenamesItsRelationship(): void + { + $attribute = Attribute::relationship('author', Relationship::manyToOne('users'), RelationshipSide::Parent); + + $renamed = $attribute->apply(new AttributeUpdate(key: 'writer')); + + $this->assertSame('writer', $renamed->key); + $this->assertSame('writer', $renamed->relationship?->key); + $this->assertSame('users', $renamed->relationship->relatedCollection); + $this->assertSame(RelationshipSide::Parent, $renamed->side); + } +} diff --git a/tests/unit/Model/CollectionFromDocumentTest.php b/tests/unit/Model/CollectionFromDocumentTest.php new file mode 100644 index 0000000000..106d9b86e8 --- /dev/null +++ b/tests/unit/Model/CollectionFromDocumentTest.php @@ -0,0 +1,69 @@ +assertSame($collection, Collection::fromDocument($collection)); + } + + public function testTheHydratedAttributesOfACollectionAreKept(): void + { + $collection = Collection::create('books', attributes: [Attribute::string('title', 64)]); + $attributes = $collection->attributes(); + + $this->assertSame($attributes[0], Collection::fromDocument($collection)->attributes()[0]); + } + + public function testAPlainDefinitionIsHydrated(): void + { + $definition = Collection::create( + 'books', + attributes: [Attribute::string('title', 64), Attribute::integer('pages', required: true)], + indexes: [Index::key('by_pages', ['pages'])], + )->toDocument(); + + $collection = Collection::fromDocument($definition); + + $this->assertNotSame($definition, $collection); + $this->assertSame('books', $collection->getId()); + $this->assertSame(['title', 'pages'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributes())); + $this->assertSame(ColumnType::Integer, $collection->attributes()[1]->type); + $this->assertTrue($collection->attributes()[1]->required); + $this->assertSame('by_pages', $collection->indexes()[0]->key); + $this->assertSame(IndexType::Key, $collection->indexes()[0]->type); + } + + public function testAPlainDefinitionWithoutAttributesHasNone(): void + { + $collection = Collection::fromDocument(new Document([Document::ID => 'empty'])); + + $this->assertSame([], $collection->attributes()); + $this->assertSame([], $collection->indexes()); + } + + public function testAnAttributeOfAnUnknownTypeIsRefused(): void + { + $collection = Collection::fromDocument(new Document([ + Document::ID => 'books', + 'attributes' => [new Document([Document::ID => 'title', 'type' => 'mystery'])], + ])); + + $this->expectException(StructureException::class); + + $collection->attributes(); + } +} diff --git a/tests/unit/Model/CollectionTest.php b/tests/unit/Model/CollectionTest.php new file mode 100644 index 0000000000..e1f2c7597d --- /dev/null +++ b/tests/unit/Model/CollectionTest.php @@ -0,0 +1,695 @@ +expectException(\Error::class); + + /** @phpstan-ignore new.privateConstructor */ + new Collection(); + } + + public function testCreateStoresEveryField(): void + { + $collection = Collection::create( + id: 'books', + name: 'Books', + attributes: [Attribute::string('title', 128), Attribute::integer('pages')], + indexes: [Index::key('by_title', ['title'], [], [OrderDirection::Asc])], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + ); + + $this->assertSame('books', $collection->getId()); + $this->assertSame('Books', $collection->name()); + $this->assertSame(['title', 'pages'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributes())); + $this->assertSame([ColumnType::String, ColumnType::Integer], \array_map(static fn (Attribute $attribute): ColumnType => $attribute->type, $collection->attributes())); + $this->assertSame(['by_title'], \array_map(static fn (Index $index): string => $index->key, $collection->indexes())); + $this->assertSame([OrderDirection::Asc], $collection->indexes()[0]->orders); + $this->assertSame(['read("any")'], $collection->declaredPermissions()); + $this->assertFalse($collection->documentSecurity()); + $this->assertFalse($collection->getAttribute('documentSecurity')); + } + + public function testCreateDefaults(): void + { + $collection = Collection::create('books'); + + $this->assertSame('books', $collection->name()); + $this->assertSame([], $collection->attributes()); + $this->assertSame([], $collection->indexes()); + $this->assertNull($collection->declaredPermissions()); + $this->assertTrue($collection->documentSecurity()); + $this->assertTrue($collection->getAttribute('documentSecurity')); + } + + public function testCreateStoresAttributesAndIndexesAsDocuments(): void + { + $collection = Collection::create('books', attributes: [Attribute::string('title', 128)], indexes: [Index::fulltext('search', ['title'])]); + + $attributes = $collection->getAttribute('attributes'); + $indexes = $collection->getAttribute('indexes'); + + $this->assertIsArray($attributes); + $this->assertIsArray($indexes); + $this->assertInstanceOf(Document::class, $attributes[0]); + $this->assertInstanceOf(Document::class, $indexes[0]); + $this->assertSame('title', $attributes[0]->getAttribute('key')); + $this->assertSame('fulltext', $indexes[0]->getAttribute('type')); + } + + public function testCreateWithNoPermissionsDeclaresAnEmptyList(): void + { + $this->assertSame([], Collection::create('books', permissions: [])->declaredPermissions()); + } + + public function testCreateKeepsMetadataInStorage(): void + { + $collection = Collection::create('books', metadata: ['category' => 'fiction', 'shelf' => 4]); + + $this->assertSame('fiction', $collection->getAttribute('category')); + $this->assertSame(4, $collection->getAttribute('shelf')); + $this->assertSame('fiction', $collection->toDocument()->getAttribute('category')); + } + + public function testFromArrayKeepsExtrasAndNormalisesTheCoreKeys(): void + { + $collection = Collection::fromArray([ + '$id' => 'books', + 'attributes' => [ + ['$id' => 'title', 'key' => 'title', 'type' => 'string', 'size' => 128, 'required' => false, 'signed' => true, 'array' => false, 'filters' => []], + ], + 'indexes' => [ + ['$id' => 'by_title', 'key' => 'by_title', 'type' => 'key', 'attributes' => ['title'], 'lengths' => [], 'orders' => ['desc']], + ], + 'category' => 'fiction', + ]); + + $this->assertSame('books', $collection->name()); + $this->assertTrue($collection->documentSecurity()); + $this->assertTrue($collection->getAttribute('documentSecurity')); + $this->assertNull($collection->declaredPermissions()); + $this->assertSame('fiction', $collection->getAttribute('category')); + $this->assertSame('title', $collection->attributes()[0]->key); + $this->assertSame(ColumnType::String, $collection->attributes()[0]->type); + $this->assertSame([OrderDirection::Desc], $collection->indexes()[0]->orders); + } + + public function testFromArrayStoresModelsAndArraysAsDocuments(): void + { + $collection = Collection::fromArray([ + '$id' => 'books', + 'attributes' => [Attribute::string('title', 128), ['key' => 'pages', 'type' => 'integer']], + 'indexes' => [Index::key('by_title', ['title']), ['key' => 'by_pages', 'type' => 'key', 'attributes' => ['pages']]], + ]); + + $this->assertContainsOnlyInstancesOf(Document::class, $collection->getArray('attributes')); + $this->assertContainsOnlyInstancesOf(Document::class, $collection->getArray('indexes')); + $this->assertSame(['title', 'pages'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributes())); + $this->assertSame(['by_title', 'by_pages'], \array_map(static fn (Index $index): string => $index->key, $collection->indexes())); + } + + public function testFromArrayReadsTheStoredDocumentSecurity(): void + { + $this->assertFalse(Collection::fromArray(['$id' => 'books', 'documentSecurity' => false])->documentSecurity()); + } + + public function testFromArrayDropsPermissionsThatAreNotAList(): void + { + $collection = Collection::fromArray(['$id' => 'books', '$permissions' => 'read("any")']); + + $this->assertNull($collection->declaredPermissions()); + } + + public function testFromArrayRejectsInvalidPermissionEntries(): void + { + $this->expectException(StructureException::class); + + Collection::fromArray(['$id' => 'books', '$permissions' => [1]]); + } + + public function testFromArrayWithoutAnIdIsEmptyNamed(): void + { + $collection = Collection::fromArray([]); + + $this->assertSame('', $collection->getId()); + $this->assertSame('', $collection->name()); + $this->assertSame([], $collection->attributes()); + } + + public function testFromArrayKeepsEncodedListsUntilTheyAreDecoded(): void + { + $collection = Collection::fromArray(['$id' => 'books', 'attributes' => '[]', 'indexes' => '[]']); + + $this->assertSame('[]', $collection->getAttribute('attributes')); + $this->assertSame([], $collection->attributes()); + $this->assertSame([], $collection->indexes()); + } + + public function testFromArrayRejectsAnAttributeThatIsNotADocument(): void + { + $this->expectException(StructureException::class); + + Collection::fromArray(['$id' => 'books', 'attributes' => ['title']]); + } + + public function testFromArrayRejectsAnIndexThatIsNotADocument(): void + { + $this->expectException(IndexException::class); + + Collection::fromArray(['$id' => 'books', 'indexes' => [7]]); + } + + public function testToDocumentIsAPlainIndependentCopy(): void + { + $collection = Collection::create('books', attributes: [Attribute::string('title', 128)], metadata: ['category' => 'fiction']); + $document = $collection->toDocument(); + + $this->assertSame(Document::class, $document::class); + $this->assertSame($collection->getArrayCopy(), $document->getArrayCopy()); + + $document->setAttribute('category', 'poetry'); + $document->getDocuments('attributes')[0]->setAttribute('key', 'heading'); + + $this->assertSame('fiction', $collection->getAttribute('category')); + $this->assertSame('title', $collection->attributes()[0]->key); + } + + public function testNameFallsBackToTheId(): void + { + $this->assertSame('books', Collection::fromArray(['$id' => 'books', 'name' => ''])->name()); + $this->assertSame('books', Collection::fromArray(['$id' => 'books', 'name' => 5])->name()); + $this->assertSame('Books', Collection::fromArray(['$id' => 'books', 'name' => 'Books'])->name()); + } + + public function testDeclaredPermissionsFollowStorage(): void + { + $collection = Collection::create('books'); + $collection->setAttribute('$permissions', [Permission::read(Role::any())]); + + $this->assertSame(['read("any")'], $collection->declaredPermissions()); + + $collection->removeAttribute('$permissions'); + + $this->assertNull($collection->declaredPermissions()); + } + + public function testDocumentSecurityFollowsStorage(): void + { + $collection = Collection::create('books'); + $collection->setAttribute('documentSecurity', false); + + $this->assertFalse($collection->documentSecurity()); + } + + public function testAttributesAndIndexesAreHydratedOncePerInstance(): void + { + $collection = $this->collection(); + + $attributes = $collection->attributes(); + $indexes = $collection->indexes(); + + $this->assertSame($attributes, $collection->attributes()); + $this->assertSame($indexes, $collection->indexes()); + } + + public function testSetAttributeRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection->setAttribute('attributes', [Attribute::integer('pages')->toDocument()]); + $collection->setAttribute('indexes', [Index::key('by_pages', ['pages'])->toDocument()]); + + $this->assertSame(['pages'], $this->attributeKeys($collection)); + $this->assertSame(['by_pages'], $this->indexKeys($collection)); + } + + public function testSetAttributeAppendRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection->setAttribute('attributes', Attribute::integer('pages')->toDocument(), SetType::Append); + $collection->setAttribute('indexes', Index::key('by_pages', ['pages'])->toDocument(), SetType::Append); + + $this->assertSame(['title', 'pages'], $this->attributeKeys($collection)); + $this->assertSame(['by_title', 'by_pages'], $this->indexKeys($collection)); + } + + public function testSetAttributePrependRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection->setAttribute('attributes', Attribute::integer('pages')->toDocument(), SetType::Prepend); + $collection->setAttribute('indexes', Index::key('by_pages', ['pages'])->toDocument(), SetType::Prepend); + + $this->assertSame(['pages', 'title'], $this->attributeKeys($collection)); + $this->assertSame(['by_pages', 'by_title'], $this->indexKeys($collection)); + } + + public function testSetAttributeWithTheSameMutatedListRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + + $stored = $collection->getDocuments('attributes'); + $stored[0]->setAttribute('key', 'heading'); + $collection->setAttribute('attributes', $collection->getAttribute('attributes')); + + $this->assertSame(['heading'], $this->attributeKeys($collection)); + } + + public function testSetAttributesRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection->setAttributes([ + 'attributes' => [Attribute::integer('pages')->toDocument()], + 'indexes' => [], + ]); + + $this->assertSame(['pages'], $this->attributeKeys($collection)); + $this->assertSame([], $this->indexKeys($collection)); + } + + public function testRemoveAttributeRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection->removeAttribute('attributes'); + $collection->removeAttribute('indexes'); + + $this->assertSame([], $collection->attributes()); + $this->assertSame([], $collection->indexes()); + } + + public function testOffsetSetRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection['attributes'] = [Attribute::integer('pages')->toDocument()]; + $collection['indexes'] = [Index::unique('by_pages', ['pages'])->toDocument()]; + + $this->assertSame(['pages'], $this->attributeKeys($collection)); + $this->assertSame(IndexType::Unique, $collection->indexes()[0]->type); + } + + public function testOffsetUnsetRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + unset($collection['attributes'], $collection['indexes']); + + $this->assertSame([], $collection->attributes()); + $this->assertSame([], $collection->indexes()); + } + + public function testExchangeArrayRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection->exchangeArray([ + '$id' => 'books', + 'attributes' => [Attribute::integer('pages')->toDocument()], + 'indexes' => [Index::key('by_pages', ['pages'])->toDocument()], + ]); + + $this->assertSame(['pages'], $this->attributeKeys($collection)); + $this->assertSame(['by_pages'], $this->indexKeys($collection)); + } + + public function testAppendRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $collection->append('unkeyed'); + + $this->assertSame(['title'], $this->attributeKeys($collection)); + $this->assertSame(['by_title'], $this->indexKeys($collection)); + } + + public function testIndirectWriteRefreshesTheMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + $collection->indexes(); + + $attributes = &$collection['attributes']; + $indexes = &$collection['indexes']; + if (! \is_array($attributes) || ! \is_array($indexes)) { + $this->fail('The collection stores its attributes and indexes as lists'); + } + $attributes[] = Attribute::integer('pages')->toDocument(); + $indexes[] = Index::key('by_pages', ['pages'])->toDocument(); + + $this->assertSame(['title', 'pages'], $this->attributeKeys($collection)); + $this->assertSame(['by_title', 'by_pages'], $this->indexKeys($collection)); + } + + public function testACloneDoesNotShareAStaleMemo(): void + { + $collection = $this->collection(); + $collection->attributes(); + + $clone = clone $collection; + $clone->setAttribute('attributes', Attribute::integer('pages')->toDocument(), SetType::Append); + + $this->assertSame(['title', 'pages'], $this->attributeKeys($clone)); + $this->assertSame(['title'], $this->attributeKeys($collection)); + } + + public function testACloneHasTheOriginalListsUntilItIsMutated(): void + { + $collection = $this->collection(); + $attributes = $this->attributeDocuments($collection); + $indexes = $this->indexDocuments($collection); + + $clone = clone $collection; + + $this->assertSame($attributes, $this->attributeDocuments($clone)); + $this->assertSame($indexes, $this->indexDocuments($clone)); + + $clone->setAttribute('attributes', Attribute::integer('pages')->toDocument(), SetType::Append); + $clone->setAttribute('indexes', [Index::unique('by_pages', ['pages'])->toDocument()]); + + $this->assertSame(['title', 'pages'], $this->attributeKeys($clone)); + $this->assertSame(['by_pages'], $this->indexKeys($clone)); + $this->assertSame($attributes, $this->attributeDocuments($collection)); + $this->assertSame($indexes, $this->indexDocuments($collection)); + } + + public function testACloneOfAnUnreadCollectionHydratesItsOwnLists(): void + { + $collection = $this->collection(); + + $clone = clone $collection; + $clone->setAttribute('name', 'Copy'); + + $this->assertSame(['title'], $this->attributeKeys($clone)); + $this->assertSame(['by_title'], $this->indexKeys($clone)); + $this->assertSame(['title'], $this->attributeKeys($collection)); + } + + public function testInPlaceNestedMutationNeedsTheListSetBack(): void + { + $collection = $this->collection(); + $collection->attributes(); + + $collection->getDocuments('attributes')[0]->setAttribute('key', 'heading'); + + $this->assertSame(['title'], $this->attributeKeys($collection)); + + $collection->setAttribute('attributes', $collection->getAttribute('attributes')); + + $this->assertSame(['heading'], $this->attributeKeys($collection)); + } + + public function testAttributeModelsWrittenStraightIntoStorageAreRejected(): void + { + $collection = $this->collection(); + $collection->setAttribute('attributes', [Attribute::integer('pages')]); + + $this->expectException(StructureException::class); + + $collection->attributes(); + } + + public function testIndexModelsWrittenStraightIntoStorageAreRejected(): void + { + $collection = $this->collection(); + $collection->setAttribute('indexes', [Index::key('by_pages', ['pages'])]); + + $this->expectException(IndexException::class); + + $collection->indexes(); + } + + /** + * @return array + */ + public static function coreKeys(): array + { + return [ + 'attributes' => ['attributes'], + 'indexes' => ['indexes'], + 'documentSecurity' => ['documentSecurity'], + ]; + } + + #[DataProvider('coreKeys')] + public function testCreateRejectsCoreKeysInMetadata(string $key): void + { + $this->expectException(StructureException::class); + + Collection::create('books', metadata: [$key => []]); + } + + public function testStorageWritesToOtherKeysKeepTheListsCorrect(): void + { + $collection = $this->collection(); + $collection->attributes(); + + $collection->setAttribute('name', 'Renamed'); + + $this->assertSame(['title'], $this->attributeKeys($collection)); + $this->assertSame('Renamed', $collection->name()); + } + + public function testWritesToPermissionsKeepTheHydratedLists(): void + { + $collection = $this->collection(); + $attributes = $collection->attributes(); + $indexes = $collection->indexes(); + + $collection->setAttribute('$permissions', [Permission::read(Role::any())]); + $collection['documentSecurity'] = false; + unset($collection['name']); + + $this->assertSame($attributes, $collection->attributes()); + $this->assertSame($indexes, $collection->indexes()); + } + + public function testExchangeArrayWithTheSameListsKeepsTheHydratedLists(): void + { + $collection = $this->collection(); + $attributes = $collection->attributes(); + $indexes = $collection->indexes(); + + $collection->exchangeArray([...\iterator_to_array($collection), 'name' => 'Renamed']); + + $this->assertSame($attributes, $collection->attributes()); + $this->assertSame($indexes, $collection->indexes()); + $this->assertSame('Renamed', $collection->name()); + } + + public function testEncodedListsAreDecoded(): void + { + $collection = Collection::fromArray([ + '$id' => 'books', + 'attributes' => \json_encode([Attribute::string('title', 128)->toDocument()->getArrayCopy()]), + 'indexes' => \json_encode([Index::key('by_title', ['title'])->toDocument()->getArrayCopy()]), + ]); + + $this->assertSame(['title'], $this->attributeKeys($collection)); + $this->assertSame(['by_title'], $this->indexKeys($collection)); + $this->assertSame($collection->attributes(), $collection->attributes()); + } + + /** + * @return array + */ + public static function undecodableLists(): array + { + return [ + 'invalid JSON' => ['{not json'], + 'JSON that is not a list' => ['7'], + 'a scalar' => [7], + ]; + } + + #[DataProvider('undecodableLists')] + public function testAnUndecodableAttributeListIsRefused(mixed $stored): void + { + $collection = $this->collection(); + $collection->setAttribute('attributes', $stored); + + $this->expectException(StructureException::class); + + $collection->attributes(); + } + + #[DataProvider('undecodableLists')] + public function testAnUndecodableIndexListIsRefused(mixed $stored): void + { + $collection = $this->collection(); + $collection->setAttribute('indexes', $stored); + + $this->expectException(IndexException::class); + + $collection->indexes(); + } + + public function testAttributesWithInternalAreBuiltOncePerSchemaState(): void + { + $collection = $this->collection(); + $internal = [Attribute::string('$id', required: true)]; + + $combined = $collection->attributesWith($internal); + + $this->assertSame(['title', '$id'], \array_map(static fn (Attribute $attribute): string => $attribute->key, $combined)); + $this->assertSame($combined, $collection->attributesWith($internal)); + + $collection->setAttribute('attributes', Attribute::integer('pages')->toDocument(), SetType::Append); + $this->assertSame( + ['title', 'pages', '$id'], + \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributesWith($internal)), + ); + + $other = [Attribute::string('$collection', required: true)]; + $this->assertSame( + ['title', 'pages', '$collection'], + \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributesWith($other)), + ); + } + + public function testTheFingerprintIsStableUntilTheSchemaChanges(): void + { + $collection = $this->collection(); + $fingerprint = $collection->fingerprint(); + + $collection->setAttribute('name', 'Renamed'); + + $this->assertSame($fingerprint, $collection->fingerprint()); + $this->assertSame($fingerprint, $this->collection()->fingerprint()); + $this->assertSame($fingerprint, (clone $collection)->fingerprint()); + } + + /** + * @return array + */ + public static function schemaChanges(): array + { + return [ + 'attribute appended' => [static function (Collection $collection): void { + $collection->setAttribute('attributes', Attribute::integer('pages')->toDocument(), SetType::Append); + }], + 'index replaced' => [static function (Collection $collection): void { + $collection->setAttribute('indexes', [Index::unique('by_title', ['title'])->toDocument()]); + }], + 'attribute written through a reference' => [static function (Collection $collection): void { + $attributes = &$collection['attributes']; + if (\is_array($attributes)) { + $attributes[] = Attribute::integer('pages')->toDocument(); + } + }], + 'nested attribute mutated and set back' => [static function (Collection $collection): void { + $collection->getDocuments('attributes')[0]->setAttribute('size', 256); + $collection->setAttribute('attributes', $collection->getAttribute('attributes')); + }], + 'permissions' => [static function (Collection $collection): void { + $collection->setAttribute('$permissions', [Permission::read(Role::any())]); + }], + 'document security' => [static function (Collection $collection): void { + $collection->setAttribute('documentSecurity', false); + }], + ]; + } + + /** + * @param \Closure(Collection): void $change + */ + #[DataProvider('schemaChanges')] + public function testTheFingerprintFollowsEverySchemaChange(\Closure $change): void + { + $collection = $this->collection(); + $fingerprint = $collection->fingerprint(); + + $change($collection); + + $this->assertNotSame($fingerprint, $collection->fingerprint()); + } + + public function testFingerprintsOfDifferentSchemasWithNanDefaultsDiffer(): void + { + $ratio = Collection::create('books', attributes: [Attribute::double('ratio', default: \NAN)]); + $score = Collection::create('books', attributes: [Attribute::double('score', default: \NAN)]); + + $this->assertNotSame($ratio->fingerprint(), $score->fingerprint()); + } + + private function collection(): Collection + { + return Collection::create( + id: 'books', + attributes: [Attribute::string('title', 128)], + indexes: [Index::key('by_title', ['title'])], + ); + } + + /** + * @return list + */ + private function attributeKeys(Collection $collection): array + { + return \array_map(static fn (Attribute $attribute): string => $attribute->key, $collection->attributes()); + } + + /** + * @return list + */ + private function indexKeys(Collection $collection): array + { + return \array_map(static fn (Index $index): string => $index->key, $collection->indexes()); + } + + /** + * @return list> + */ + private function attributeDocuments(Collection $collection): array + { + return \array_map(static fn (Attribute $attribute): array => $attribute->toDocument()->getArrayCopy(), $collection->attributes()); + } + + /** + * @return list> + */ + private function indexDocuments(Collection $collection): array + { + return \array_map(static fn (Index $index): array => $index->toDocument()->getArrayCopy(), $collection->indexes()); + } +} diff --git a/tests/unit/Model/CollectionUpdateTest.php b/tests/unit/Model/CollectionUpdateTest.php new file mode 100644 index 0000000000..dc13ab385e --- /dev/null +++ b/tests/unit/Model/CollectionUpdateTest.php @@ -0,0 +1,47 @@ +assertNull($update->permissions); + $this->assertNull($update->documentSecurity); + } + + public function testCarriesOnlyTheGivenChanges(): void + { + $permissions = new CollectionUpdate(permissions: [Permission::read(Role::any())]); + $security = new CollectionUpdate(documentSecurity: false); + + $this->assertSame(['read("any")'], $permissions->permissions); + $this->assertNull($permissions->documentSecurity); + $this->assertNull($security->permissions); + $this->assertFalse($security->documentSecurity); + } + + public function testAnEmptyPermissionListIsAChange(): void + { + $update = new CollectionUpdate(permissions: []); + + $this->assertSame([], $update->permissions); + } + + public function testPropertiesAreReadonly(): void + { + $update = new CollectionUpdate(documentSecurity: true); + + $this->expectException(\Error::class); + + /** @phpstan-ignore assign.propertyProtectedSet */ + $update->documentSecurity = false; + } +} diff --git a/tests/unit/Model/FormatTest.php b/tests/unit/Model/FormatTest.php new file mode 100644 index 0000000000..158d741649 --- /dev/null +++ b/tests/unit/Model/FormatTest.php @@ -0,0 +1,63 @@ + 1, 'max' => 10]); + + $this->assertSame('intRange', $format->name); + $this->assertSame(['min' => 1, 'max' => 10], $format->options); + } + + public function testOptionsDefaultToEmpty(): void + { + $this->assertSame([], (new Format('email'))->options); + } + + public function testEmptyNameIsRejected(): void + { + $this->expectException(Structure::class); + + new Format(''); + } + + public function testPersistsAsFormatAndFormatOptions(): void + { + $stored = Attribute::integer('range', format: new Format('intRange', ['min' => 1]))->toDocument(); + + $this->assertSame('intRange', $stored->getAttribute('format')); + $this->assertSame(['min' => 1], $stored->getAttribute('formatOptions')); + } + + public function testNoFormatPersistsAsNullWithEmptyOptions(): void + { + $stored = Attribute::string('name')->toDocument(); + + $this->assertNull($stored->getAttribute('format')); + $this->assertSame([], $stored->getAttribute('formatOptions')); + } + + public function testOptionsWithoutAFormatNameAreDropped(): void + { + $attribute = Attribute::fromArray(['key' => 'name', 'type' => 'string', 'format' => null, 'formatOptions' => ['min' => 1]]); + + $this->assertNull($attribute->format); + $this->assertSame([], $attribute->toDocument()->getAttribute('formatOptions')); + } + + public function testNonArrayOptionsHydrateAsEmpty(): void + { + $attribute = Attribute::fromArray(['key' => 'name', 'type' => 'string', 'format' => 'email', 'formatOptions' => 'invalid']); + + $this->assertSame('email', $attribute->format?->name); + $this->assertSame([], $attribute->format->options); + } +} diff --git a/tests/unit/Model/IndexFactoryTest.php b/tests/unit/Model/IndexFactoryTest.php new file mode 100644 index 0000000000..757d7c0647 --- /dev/null +++ b/tests/unit/Model/IndexFactoryTest.php @@ -0,0 +1,189 @@ +}> + */ + public static function persistedShapes(): iterable + { + yield 'key' => [ + Index::key('by_name', ['first', 'last'], [64, null], [OrderDirection::Asc, OrderDirection::Desc]), + ['$id' => 'by_name', 'key' => 'by_name', 'type' => 'key', 'attributes' => ['first', 'last'], 'lengths' => [64, null], 'orders' => ['ASC', 'DESC']], + ]; + yield 'key without lengths or orders' => [ + Index::key('by_age', ['age']), + ['$id' => 'by_age', 'key' => 'by_age', 'type' => 'key', 'attributes' => ['age'], 'lengths' => [], 'orders' => []], + ]; + yield 'unique' => [ + Index::unique('by_email', ['email'], [128], [null]), + ['$id' => 'by_email', 'key' => 'by_email', 'type' => 'unique', 'attributes' => ['email'], 'lengths' => [128], 'orders' => [null]], + ]; + yield 'fulltext stores no lengths or orders' => [ + Index::fulltext('search', ['title', 'body']), + ['$id' => 'search', 'key' => 'search', 'type' => 'fulltext', 'attributes' => ['title', 'body']], + ]; + yield 'trigram' => [ + Index::trigram('fuzzy', ['name']), + ['$id' => 'fuzzy', 'key' => 'fuzzy', 'type' => 'trigram', 'attributes' => ['name'], 'lengths' => [], 'orders' => []], + ]; + yield 'spatial' => [ + Index::spatial('where', 'location'), + ['$id' => 'where', 'key' => 'where', 'type' => 'spatial', 'attributes' => ['location'], 'lengths' => [], 'orders' => []], + ]; + yield 'spatial with an order' => [ + Index::spatial('where', 'location', OrderDirection::Desc), + ['$id' => 'where', 'key' => 'where', 'type' => 'spatial', 'attributes' => ['location'], 'lengths' => [], 'orders' => ['DESC']], + ]; + yield 'object' => [ + Index::object('payload', 'data'), + ['$id' => 'payload', 'key' => 'payload', 'type' => 'object', 'attributes' => ['data'], 'lengths' => [], 'orders' => []], + ]; + yield 'hnsw euclidean' => [ + Index::hnswEuclidean('near', 'embedding'), + ['$id' => 'near', 'key' => 'near', 'type' => 'hnsw_euclidean', 'attributes' => ['embedding'], 'lengths' => [], 'orders' => []], + ]; + yield 'hnsw cosine' => [ + Index::hnswCosine('near', 'embedding'), + ['$id' => 'near', 'key' => 'near', 'type' => 'hnsw_cosine', 'attributes' => ['embedding'], 'lengths' => [], 'orders' => []], + ]; + yield 'hnsw dot' => [ + Index::hnswDot('near', 'embedding'), + ['$id' => 'near', 'key' => 'near', 'type' => 'hnsw_dot', 'attributes' => ['embedding'], 'lengths' => [], 'orders' => []], + ]; + yield 'ttl stores ttl and no orders' => [ + Index::ttl('expiry', 'expiresAt', 3600), + ['$id' => 'expiry', 'key' => 'expiry', 'type' => 'ttl', 'attributes' => ['expiresAt'], 'lengths' => [], 'ttl' => 3600], + ]; + } + + /** + * @param array $expected + */ + #[DataProvider('persistedShapes')] + public function testFactoryPersistsItsShape(Index $index, array $expected): void + { + $this->assertSame($expected, $index->toDocument()->getArrayCopy()); + } + + /** + * @param array $expected + */ + #[DataProvider('persistedShapes')] + public function testPersistedShapeRoundTrips(Index $index, array $expected): void + { + $this->assertSame($expected, Index::fromDocument($index->toDocument())->toDocument()->getArrayCopy()); + $this->assertSame($expected, Index::fromArray($expected)->toDocument()->getArrayCopy()); + } + + public function testOnlyTtlIndexesCarryATtl(): void + { + $this->assertSame(3600, Index::ttl('expiry', 'expiresAt', 3600)->ttl); + $this->assertNull(Index::key('by_age', ['age'])->ttl); + $this->assertNull(Index::fulltext('search', ['title'])->ttl); + } + + public function testFactoriesExposeTypedProperties(): void + { + $index = Index::unique('by_name', ['first', 'last'], [32, null], [OrderDirection::Asc, null]); + + $this->assertSame('by_name', $index->key); + $this->assertSame(IndexType::Unique, $index->type); + $this->assertSame(['first', 'last'], $index->attributes); + $this->assertSame([32, null], $index->lengths); + $this->assertSame([OrderDirection::Asc, null], $index->orders); + } + + /** + * @return iterable + */ + public static function randomOrders(): iterable + { + yield 'key' => [static fn (): Index => Index::key('by_age', ['age'], [], [OrderDirection::Random])]; + yield 'unique' => [static fn (): Index => Index::unique('by_age', ['age'], [], [OrderDirection::Asc, OrderDirection::Random])]; + yield 'spatial' => [static fn (): Index => Index::spatial('where', 'location', OrderDirection::Random)]; + } + + /** + * @param \Closure(): Index $factory + */ + #[DataProvider('randomOrders')] + public function testRandomOrderIsRejected(\Closure $factory): void + { + $this->expectException(IndexException::class); + $this->expectExceptionMessage('Index orders cannot be random'); + + $factory(); + } + + /** + * @return iterable + */ + public static function invalidTtls(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + } + + #[DataProvider('invalidTtls')] + public function testTtlBelowOneIsRejected(int $ttl): void + { + $this->expectException(IndexException::class); + $this->expectExceptionMessage('TTL must be at least 1 second'); + + Index::ttl('expiry', 'expiresAt', $ttl); + } + + public function testTtlOfOneSecondIsAccepted(): void + { + $this->assertSame(1, Index::ttl('expiry', 'expiresAt', 1)->ttl); + } + + public function testFactoryRejectsAnOrderThatIsNotADirection(): void + { + $this->expectException(IndexException::class); + + /** @phpstan-ignore argument.type */ + Index::key('by_age', ['age'], [], ['ASC']); + } + + public function testFactoryRejectsALengthThatIsNotAnInteger(): void + { + $this->expectException(IndexException::class); + + /** @phpstan-ignore argument.type */ + Index::key('by_name', ['name'], ['64']); + } + + public function testFactoryRejectsAnAttributeThatIsNotAString(): void + { + $this->expectException(IndexException::class); + + /** @phpstan-ignore argument.type */ + Index::key('by_name', [1]); + } + + public function testHydratedListsAreReindexed(): void + { + $index = Index::fromArray([ + 'key' => 'by_name', + 'type' => 'key', + 'attributes' => [3 => 'first', 7 => 'last'], + 'lengths' => [2 => 16], + 'orders' => [5 => 'desc'], + ]); + + $this->assertSame(['first', 'last'], $index->attributes); + $this->assertSame([16], $index->lengths); + $this->assertSame([OrderDirection::Desc], $index->orders); + } +} diff --git a/tests/unit/Model/IndexTest.php b/tests/unit/Model/IndexTest.php new file mode 100644 index 0000000000..343a7f5c8b --- /dev/null +++ b/tests/unit/Model/IndexTest.php @@ -0,0 +1,302 @@ +expectException(\Error::class); + $this->expectExceptionMessage('Call to private Utopia\Database\Index::__construct()'); + + /** @phpstan-ignore new.privateConstructor */ + $index = new Index('by_age', IndexType::Key, ['age'], [], [], null); + } + + public function testPropertiesAreReadonly(): void + { + $index = Index::key('by_age', ['age']); + + $this->expectException(\Error::class); + + /** @phpstan-ignore assign.propertyProtectedSet */ + $index->key = 'renamed'; + } + + public function testFromDocumentReadsTheStoredShape(): void + { + $index = Index::fromDocument(new Document([ + '$id' => 'by_name', + 'key' => 'by_name', + 'type' => 'unique', + 'attributes' => ['first', 'last'], + 'lengths' => [64, null], + 'orders' => ['ASC', null], + ])); + + $this->assertSame('by_name', $index->key); + $this->assertSame(IndexType::Unique, $index->type); + $this->assertSame(['first', 'last'], $index->attributes); + $this->assertSame([64, null], $index->lengths); + $this->assertSame([OrderDirection::Asc, null], $index->orders); + $this->assertNull($index->ttl); + } + + public function testFromDocumentDropsTheLegacyLengthsAndOrdersOfAFulltextIndex(): void + { + $index = Index::fromDocument(new Document([ + '$id' => 'search', + 'type' => 'fulltext', + 'attributes' => ['title'], + 'lengths' => [null], + 'orders' => ['ASC'], + 'ttl' => 1, + ])); + + $this->assertEquals(Index::fulltext('search', ['title']), $index); + } + + public function testFromDocumentDropsTheLegacyOrdersOfATtlIndex(): void + { + $index = Index::fromDocument(new Document([ + '$id' => 'expiry', + 'type' => 'ttl', + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => ['ASC'], + 'ttl' => 60, + ])); + + $this->assertEquals(Index::ttl('expiry', 'expiresAt', 60), $index); + } + + public function testFromDocumentReadsTheLegacyIndexTypeAsKey(): void + { + $index = Index::fromDocument(new Document(['$id' => 'legacy', 'type' => 'index', 'attributes' => ['age']])); + + $this->assertSame(IndexType::Key, $index->type); + $this->assertSame('key', $index->toDocument()->getAttribute('type')); + } + + public function testFromDocumentIgnoresTheLegacyTtlOnNonTtlIndexes(): void + { + $index = Index::fromDocument(new Document([ + '$id' => 'by_age', + 'type' => 'key', + 'attributes' => ['age'], + 'ttl' => 1, + ])); + + $this->assertNull($index->ttl); + $this->assertFalse($index->toDocument()->isSet('ttl')); + } + + public function testFromDocumentToleratesLowercaseAndEmptyOrders(): void + { + $index = Index::fromDocument(new Document([ + '$id' => 'by_name', + 'type' => 'key', + 'attributes' => ['first', 'middle', 'last'], + 'orders' => ['asc', 'desc', ''], + ])); + + $this->assertSame([OrderDirection::Asc, OrderDirection::Desc, null], $index->orders); + } + + public function testFromArrayToleratesSchemaOrderCases(): void + { + $index = Index::fromArray(['key' => 'by_age', 'type' => 'key', 'attributes' => ['age'], 'orders' => [Order::Desc]]); + + $this->assertSame([OrderDirection::Desc], $index->orders); + } + + public function testFromDocumentReadsNumericStringLengthsAndTtl(): void + { + $key = Index::fromDocument(new Document(['$id' => 'by_name', 'type' => 'key', 'attributes' => ['name'], 'lengths' => ['32']])); + $ttl = Index::fromDocument(new Document(['$id' => 'expiry', 'type' => 'ttl', 'attributes' => ['expiresAt'], 'ttl' => '60'])); + + $this->assertSame([32], $key->lengths); + $this->assertSame(60, $ttl->ttl); + } + + public function testFromDocumentFallsBackToTheIdForTheKey(): void + { + $index = Index::fromDocument(new Document(['$id' => 'by_age', 'type' => 'key', 'attributes' => ['age']])); + + $this->assertSame('by_age', $index->key); + } + + public function testFromDocumentDefaultsAMissingTypeToKey(): void + { + $index = Index::fromDocument(new Document(['$id' => 'by_age', 'attributes' => ['age']])); + + $this->assertSame(IndexType::Key, $index->type); + } + + public function testFromDocumentReadsAnUnknownTypeAsKey(): void + { + $index = Index::fromDocument(new Document(['$id' => 'odd', 'type' => 'bitmap', 'attributes' => ['age']])); + + $this->assertSame(IndexType::Key, $index->type); + $this->assertSame(['age'], $index->attributes); + } + + public function testFromDocumentRejectsAStoredRandomOrder(): void + { + $this->expectException(IndexException::class); + $this->expectExceptionMessage('Index orders cannot be random'); + + Index::fromDocument(new Document(['$id' => 'by_age', 'type' => 'key', 'attributes' => ['age'], 'orders' => ['RANDOM']])); + } + + public function testFromDocumentRejectsAnUnknownOrder(): void + { + $this->expectException(IndexException::class); + + Index::fromDocument(new Document(['$id' => 'by_age', 'type' => 'key', 'attributes' => ['age'], 'orders' => ['sideways']])); + } + + public function testFromDocumentRejectsATtlIndexWithoutATtl(): void + { + $this->expectException(IndexException::class); + $this->expectExceptionMessage('TTL must be at least 1 second'); + + Index::fromDocument(new Document(['$id' => 'expiry', 'type' => 'ttl', 'attributes' => ['expiresAt']])); + } + + public function testFromArrayPrefersTheKeyOverTheId(): void + { + $index = Index::fromArray(['$id' => 'old', 'key' => 'new', 'type' => 'key', 'attributes' => ['age']]); + + $this->assertSame('new', $index->key); + } + + public function testFromArrayAcceptsTypedValues(): void + { + $index = Index::fromArray([ + 'key' => 'by_age', + 'type' => IndexType::Unique, + 'attributes' => ['age'], + 'orders' => [OrderDirection::Desc], + ]); + + $this->assertSame(IndexType::Unique, $index->type); + $this->assertSame([OrderDirection::Desc], $index->orders); + } + + public function testToDocumentRoundTripsThroughFromDocument(): void + { + $index = Index::key('by_name', ['first', 'last'], [32, null], [OrderDirection::Desc, null]); + $document = $index->toDocument(); + + $read = Index::fromDocument($document); + + $this->assertSame($index->key, $read->key); + $this->assertSame($index->type, $read->type); + $this->assertSame($index->attributes, $read->attributes); + $this->assertSame($index->lengths, $read->lengths); + $this->assertSame($index->orders, $read->orders); + $this->assertSame($index->ttl, $read->ttl); + $this->assertSame($document->getArrayCopy(), $read->toDocument()->getArrayCopy()); + } + + public function testWithKeyReturnsARenamedCopy(): void + { + $index = Index::key('by_age', ['age'], [8], [OrderDirection::Asc]); + $renamed = $index->withKey('by_years'); + + $this->assertSame('by_years', $renamed->key); + $this->assertSame('by_years', $renamed->toDocument()->getId()); + $this->assertSame(['age'], $renamed->attributes); + $this->assertSame([8], $renamed->lengths); + $this->assertSame([OrderDirection::Asc], $renamed->orders); + $this->assertSame('by_age', $index->key); + } + + public function testWithLengthsReturnsACopyWithNewLengths(): void + { + $index = Index::key('by_name', ['first', 'last'], [16, 16]); + $changed = $index->withLengths([null, 64]); + + $this->assertSame([null, 64], $changed->lengths); + $this->assertSame([16, 16], $index->lengths); + } + + public function testWithLengthsRejectsANonIntegerLength(): void + { + $this->expectException(IndexException::class); + + /** @phpstan-ignore argument.type */ + Index::key('by_name', ['name'])->withLengths(['wide']); + } + + public function testWithOrdersReturnsACopyWithNewOrders(): void + { + $index = Index::key('by_name', ['first', 'last'], [], [OrderDirection::Asc, OrderDirection::Asc]); + $changed = $index->withOrders([null, OrderDirection::Desc]); + + $this->assertSame([null, OrderDirection::Desc], $changed->orders); + $this->assertSame([OrderDirection::Asc, OrderDirection::Asc], $index->orders); + } + + public function testWithOrdersRejectsARandomOrder(): void + { + $this->expectException(IndexException::class); + $this->expectExceptionMessage('Index orders cannot be random'); + + Index::key('by_age', ['age'])->withOrders([OrderDirection::Random]); + } + + /** + * @return array + */ + public static function orderlessIndexes(): array + { + return [ + 'fulltext' => [Index::fulltext('search', ['title'])], + 'ttl' => [Index::ttl('expiry', 'expiresAt', 60)], + ]; + } + + #[DataProvider('orderlessIndexes')] + public function testWithOrdersRejectsOrdersTheIndexCannotStore(Index $index): void + { + $this->expectException(IndexException::class); + + $index->withOrders([OrderDirection::Asc]); + } + + #[DataProvider('orderlessIndexes')] + public function testWithOrdersAcceptsNoOrdersOnAnOrderlessIndex(Index $index): void + { + $this->assertSame([], $index->withOrders([])->orders); + } + + public function testWithLengthsRejectsLengthsOnAFulltextIndex(): void + { + $this->expectException(IndexException::class); + + Index::fulltext('search', ['title'])->withLengths([16]); + } + + public function testWithLengthsAcceptsNoLengthsOnAFulltextIndex(): void + { + $this->assertSame([], Index::fulltext('search', ['title'])->withLengths([])->lengths); + } + + public function testWithLengthsOnATtlIndexArePersisted(): void + { + $index = Index::ttl('expiry', 'expiresAt', 60)->withLengths([8]); + + $this->assertSame([8], $index->toDocument()->getAttribute('lengths')); + } +} diff --git a/tests/unit/Model/IntegerWidthTest.php b/tests/unit/Model/IntegerWidthTest.php new file mode 100644 index 0000000000..9577aee9d1 --- /dev/null +++ b/tests/unit/Model/IntegerWidthTest.php @@ -0,0 +1,56 @@ +assertNull(IntegerWidth::Bits32->size()); + $this->assertSame(8, IntegerWidth::Bits64->size()); + } + + /** + * @return array + */ + public static function storedSizes(): array + { + return [ + 'none' => [null, IntegerWidth::Bits32], + 'zero' => [0, IntegerWidth::Bits32], + 'four' => [4, IntegerWidth::Bits32], + 'seven' => [7, IntegerWidth::Bits32], + 'eight' => [8, IntegerWidth::Bits64], + 'sixteen' => [16, IntegerWidth::Bits64], + ]; + } + + #[DataProvider('storedSizes')] + public function testEnginesOnlyDistinguishSizesOfEightAndAbove(?int $size, IntegerWidth $width): void + { + $this->assertSame($width, IntegerWidth::fromSize($size)); + $this->assertSame($width, Attribute::fromArray(['key' => 'count', 'type' => 'integer', 'size' => $size])->width()); + } + + public function testEachWidthRoundTripsThroughItsSize(): void + { + foreach (IntegerWidth::cases() as $width) { + $this->assertSame($width, IntegerWidth::fromSize($width->size())); + } + } + + public function testIntegerFactoryWidthSurvivesStorage(): void + { + foreach (IntegerWidth::cases() as $width) { + $stored = Attribute::integer('count', width: $width)->toDocument(); + + $this->assertSame($width->size() ?? 0, $stored->getAttribute('size')); + $this->assertSame($width, Attribute::fromDocument($stored)->width()); + } + } +} diff --git a/tests/unit/Model/RelationshipFactoryTest.php b/tests/unit/Model/RelationshipFactoryTest.php new file mode 100644 index 0000000000..73071a4912 --- /dev/null +++ b/tests/unit/Model/RelationshipFactoryTest.php @@ -0,0 +1,84 @@ + + */ + public static function factories(): array + { + return [ + 'oneToOne' => [Relationship::oneToOne(...), RelationshipType::OneToOne], + 'oneToMany' => [Relationship::oneToMany(...), RelationshipType::OneToMany], + 'manyToOne' => [Relationship::manyToOne(...), RelationshipType::ManyToOne], + 'manyToMany' => [Relationship::manyToMany(...), RelationshipType::ManyToMany], + ]; + } + + /** + * @param Closure(string, ?string=, bool=, ?string=, RelationshipDeleteAction=): Relationship $factory + */ + #[DataProvider('factories')] + public function testFactoryCarriesEveryArgument(Closure $factory, RelationshipType $type): void + { + $relationship = $factory('comments', 'comments', true, 'post', RelationshipDeleteAction::Cascade); + + $this->assertSame('comments', $relationship->relatedCollection); + $this->assertSame($type, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('comments', $relationship->key); + $this->assertSame('post', $relationship->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Cascade, $relationship->onDelete); + } + + /** + * @param Closure(string, ?string=, bool=, ?string=, RelationshipDeleteAction=): Relationship $factory + */ + #[DataProvider('factories')] + public function testFactoryDefaultsToOneWayRestrictWithDerivedKeys(Closure $factory, RelationshipType $type): void + { + $relationship = $factory('users'); + + $this->assertSame('users', $relationship->relatedCollection); + $this->assertSame($type, $relationship->type); + $this->assertFalse($relationship->twoWay); + $this->assertNull($relationship->key); + $this->assertNull($relationship->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Restrict, $relationship->onDelete); + } + + public function testFactoriesAcceptNamedArguments(): void + { + $relationship = Relationship::manyToMany( + relatedCollection: 'tags', + key: 'tags', + twoWay: true, + twoWayKey: 'posts', + onDelete: RelationshipDeleteAction::SetNull, + ); + + $this->assertSame('tags', $relationship->relatedCollection); + $this->assertSame(RelationshipType::ManyToMany, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('tags', $relationship->key); + $this->assertSame('posts', $relationship->twoWayKey); + $this->assertSame(RelationshipDeleteAction::SetNull, $relationship->onDelete); + } + + public function testOneWayRelationshipMayNameItsTwoWayKey(): void + { + $relationship = Relationship::manyToOne('users', key: 'author', twoWayKey: 'posts'); + + $this->assertFalse($relationship->twoWay); + $this->assertSame('posts', $relationship->twoWayKey); + } +} diff --git a/tests/unit/Model/RelationshipTest.php b/tests/unit/Model/RelationshipTest.php new file mode 100644 index 0000000000..e3d42e5a98 --- /dev/null +++ b/tests/unit/Model/RelationshipTest.php @@ -0,0 +1,245 @@ +expectException(\Error::class); + $this->expectExceptionMessage('Call to private Utopia\Database\Relationship::__construct()'); + + $relationship = new Relationship('users', RelationshipType::OneToOne, false, null, null, RelationshipDeleteAction::Restrict); // @phpstan-ignore new.privateConstructor + } + + public function testInverseSwapsKeysAndPointsAtTheGivenCollection(): void + { + $relationship = Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade); + + $inverse = $relationship->inverse('posts'); + + $this->assertSame('posts', $inverse->relatedCollection); + $this->assertSame('post', $inverse->key); + $this->assertSame('comments', $inverse->twoWayKey); + $this->assertSame(RelationshipType::OneToMany, $inverse->type); + $this->assertTrue($inverse->twoWay); + $this->assertSame(RelationshipDeleteAction::Cascade, $inverse->onDelete); + } + + public function testInverseLeavesTheOriginalUntouched(): void + { + $relationship = Relationship::manyToMany('tags', key: 'tags', twoWay: true, twoWayKey: 'posts'); + + $relationship->inverse('posts'); + + $this->assertSame('tags', $relationship->relatedCollection); + $this->assertSame('tags', $relationship->key); + $this->assertSame('posts', $relationship->twoWayKey); + } + + public function testInverseOfInverseRestoresTheKeys(): void + { + $relationship = Relationship::manyToOne('users', key: 'author', twoWay: true, twoWayKey: 'posts', onDelete: RelationshipDeleteAction::SetNull); + + $this->assertSameRelationship($relationship, $relationship->inverse('posts')->inverse('users')); + } + + public function testInverseWithUnresolvedKeysKeepsThemUnresolved(): void + { + $inverse = Relationship::oneToOne('profiles', key: 'profile')->inverse('users'); + + $this->assertNull($inverse->key); + $this->assertSame('profile', $inverse->twoWayKey); + } + + public function testToDocumentWritesTheStoredOptionsShape(): void + { + $stored = Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::SetNull)->toDocument(); + + $this->assertSame([ + 'relatedCollection' => 'comments', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'key' => 'comments', + 'twoWayKey' => 'post', + 'onDelete' => 'setNull', + ], $stored->getArrayCopy()); + } + + /** + * @return array + */ + public static function relationships(): array + { + return [ + 'oneToOne defaults' => [Relationship::oneToOne('profiles')], + 'oneToMany two-way cascade' => [Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade)], + 'manyToOne set null' => [Relationship::manyToOne('users', key: 'author', twoWayKey: 'posts', onDelete: RelationshipDeleteAction::SetNull)], + 'manyToMany two-way' => [Relationship::manyToMany('tags', key: 'tags', twoWay: true, twoWayKey: 'posts')], + ]; + } + + #[DataProvider('relationships')] + public function testDocumentRoundTrip(Relationship $relationship): void + { + $this->assertSameRelationship($relationship, Relationship::fromDocument($relationship->toDocument())); + } + + #[DataProvider('relationships')] + public function testArrayRoundTrip(Relationship $relationship): void + { + $this->assertSameRelationship($relationship, Relationship::fromArray($relationship->toDocument()->getArrayCopy())); + } + + public function testSevenXStoredOptionsHydrate(): void + { + $relationship = Relationship::fromDocument(new Document([ + 'relatedCollection' => 'comments', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => 'cascade', + 'side' => 'parent', + ])); + + $this->assertSame('comments', $relationship->relatedCollection); + $this->assertSame(RelationshipType::OneToMany, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertNull($relationship->key); + $this->assertSame('post', $relationship->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Cascade, $relationship->onDelete); + } + + public function testSideIsIgnored(): void + { + $data = ['relatedCollection' => 'users', 'relationType' => 'manyToOne', 'key' => 'author', 'twoWayKey' => 'posts']; + + $this->assertSameRelationship( + Relationship::fromArray($data), + Relationship::fromArray($data + ['side' => RelationshipSide::Child->value]), + ); + $this->assertArrayNotHasKey('side', Relationship::fromArray($data + ['side' => 'child'])->toDocument()->getArrayCopy()); + } + + public function testMissingOptionalKeysFallBackToDefaults(): void + { + $relationship = Relationship::fromArray(['relatedCollection' => 'users', 'relationType' => 'oneToOne']); + + $this->assertFalse($relationship->twoWay); + $this->assertNull($relationship->key); + $this->assertNull($relationship->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Restrict, $relationship->onDelete); + } + + public function testEmptyStoredKeysHydrateAsUnresolved(): void + { + $relationship = Relationship::fromArray(['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'key' => '', 'twoWayKey' => '']); + + $this->assertNull($relationship->key); + $this->assertNull($relationship->twoWayKey); + } + + public function testEnumValuesAreAcceptedInPlaceOfStrings(): void + { + $relationship = Relationship::fromArray([ + 'relatedCollection' => 'users', + 'relationType' => RelationshipType::ManyToMany, + 'onDelete' => RelationshipDeleteAction::SetNull, + ]); + + $this->assertSame(RelationshipType::ManyToMany, $relationship->type); + $this->assertSame(RelationshipDeleteAction::SetNull, $relationship->onDelete); + } + + public function testToOptionsIsTheStoredShapeWithoutTheKeyPlusTheSide(): void + { + $relationship = Relationship::manyToMany('tags', key: 'tags', twoWay: true, twoWayKey: 'posts', onDelete: RelationshipDeleteAction::SetNull); + + $this->assertSame([ + 'relatedCollection' => 'tags', + 'relationType' => 'manyToMany', + 'twoWay' => true, + 'twoWayKey' => 'posts', + 'onDelete' => 'setNull', + 'side' => 'child', + ], $relationship->toOptions(RelationshipSide::Child)); + } + + public function testToOptionsHydratesBackWithTheKey(): void + { + $relationship = Relationship::oneToMany('comments', key: 'comments', twoWayKey: 'post'); + + $options = $relationship->toOptions(RelationshipSide::Parent); + unset($options[Relationship::SIDE]); + + $this->assertSameRelationship($relationship, Relationship::fromArray([...$options, 'key' => 'comments'])); + } + + public function testSupportedForeignKeyActionIsAccepted(): void + { + $relationship = Relationship::fromArray(['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'onDelete' => ForeignKeyAction::Cascade]); + + $this->assertSame(RelationshipDeleteAction::Cascade, $relationship->onDelete); + } + + /** + * @return array}> + */ + public static function invalidRows(): array + { + return [ + 'missing related collection' => [['relationType' => 'oneToOne']], + 'empty related collection' => [['relatedCollection' => '', 'relationType' => 'oneToOne']], + 'non-string related collection' => [['relatedCollection' => 7, 'relationType' => 'oneToOne']], + 'missing type' => [['relatedCollection' => 'users']], + 'unknown type' => [['relatedCollection' => 'users', 'relationType' => 'oneToFew']], + 'non-string type' => [['relatedCollection' => 'users', 'relationType' => 1]], + 'non-string key' => [['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'key' => 5]], + 'non-string two-way key' => [['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'twoWayKey' => ['posts']]], + 'unknown delete action' => [['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'onDelete' => 'explode']], + 'unrelated enum as delete action' => [['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'onDelete' => RelationshipSide::Parent]], + ]; + } + + /** + * @param array $data + */ + #[DataProvider('invalidRows')] + public function testInvalidArrayIsRejected(array $data): void + { + $this->expectException(RelationshipException::class); + + Relationship::fromArray($data); + } + + /** + * @param array $data + */ + #[DataProvider('invalidRows')] + public function testInvalidDocumentIsRejected(array $data): void + { + $this->expectException(RelationshipException::class); + + Relationship::fromDocument(new Document($data)); + } + + private function assertSameRelationship(Relationship $expected, Relationship $actual): void + { + $this->assertSame($expected->relatedCollection, $actual->relatedCollection); + $this->assertSame($expected->type, $actual->type); + $this->assertSame($expected->twoWay, $actual->twoWay); + $this->assertSame($expected->key, $actual->key); + $this->assertSame($expected->twoWayKey, $actual->twoWayKey); + $this->assertSame($expected->onDelete, $actual->onDelete); + } +} diff --git a/tests/unit/Model/RelationshipUpdateTest.php b/tests/unit/Model/RelationshipUpdateTest.php new file mode 100644 index 0000000000..45ba746fc6 --- /dev/null +++ b/tests/unit/Model/RelationshipUpdateTest.php @@ -0,0 +1,96 @@ +assertNull($update->key); + $this->assertNull($update->twoWayKey); + $this->assertNull($update->twoWay); + $this->assertNull($update->onDelete); + } + + public function testEmptyUpdateKeepsEveryField(): void + { + $relationship = Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade); + + $updated = $relationship->apply(new RelationshipUpdate()); + + $this->assertSame('comments', $updated->relatedCollection); + $this->assertSame(RelationshipType::OneToMany, $updated->type); + $this->assertTrue($updated->twoWay); + $this->assertSame('comments', $updated->key); + $this->assertSame('post', $updated->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Cascade, $updated->onDelete); + } + + public function testApplyChangesOnlyTheGivenFields(): void + { + $relationship = Relationship::manyToOne('users', key: 'author', twoWay: true, twoWayKey: 'posts'); + + $updated = $relationship->apply(new RelationshipUpdate(key: 'writer', onDelete: RelationshipDeleteAction::SetNull)); + + $this->assertSame('writer', $updated->key); + $this->assertSame(RelationshipDeleteAction::SetNull, $updated->onDelete); + $this->assertSame('posts', $updated->twoWayKey); + $this->assertTrue($updated->twoWay); + $this->assertSame('users', $updated->relatedCollection); + $this->assertSame(RelationshipType::ManyToOne, $updated->type); + } + + public function testApplyChangesEveryField(): void + { + $relationship = Relationship::oneToOne('profiles', key: 'profile', twoWay: true, twoWayKey: 'user', onDelete: RelationshipDeleteAction::Cascade); + + $updated = $relationship->apply(new RelationshipUpdate( + key: 'avatar', + twoWayKey: 'owner', + twoWay: false, + onDelete: RelationshipDeleteAction::Restrict, + )); + + $this->assertSame('avatar', $updated->key); + $this->assertSame('owner', $updated->twoWayKey); + $this->assertFalse($updated->twoWay); + $this->assertSame(RelationshipDeleteAction::Restrict, $updated->onDelete); + $this->assertSame('profiles', $updated->relatedCollection); + $this->assertSame(RelationshipType::OneToOne, $updated->type); + } + + public function testFalseTwoWayIsAChange(): void + { + $updated = Relationship::manyToMany('tags', twoWay: true)->apply(new RelationshipUpdate(twoWay: false)); + + $this->assertFalse($updated->twoWay); + } + + public function testApplyResolvesAnUnresolvedKey(): void + { + $updated = Relationship::manyToOne('users')->apply(new RelationshipUpdate(key: 'author')); + + $this->assertSame('author', $updated->key); + $this->assertNull($updated->twoWayKey); + } + + public function testApplyLeavesTheOriginalUntouched(): void + { + $relationship = Relationship::oneToMany('comments', key: 'comments', twoWayKey: 'post'); + + $relationship->apply(new RelationshipUpdate(key: 'replies', twoWayKey: 'thread', twoWay: true, onDelete: RelationshipDeleteAction::Cascade)); + + $this->assertSame('comments', $relationship->key); + $this->assertSame('post', $relationship->twoWayKey); + $this->assertFalse($relationship->twoWay); + $this->assertSame(RelationshipDeleteAction::Restrict, $relationship->onDelete); + } +} diff --git a/tests/unit/MongoAdapterPathsTest.php b/tests/unit/MongoAdapterPathsTest.php new file mode 100644 index 0000000000..0123661e6a --- /dev/null +++ b/tests/unit/MongoAdapterPathsTest.php @@ -0,0 +1,524 @@ +}> + */ + private array $calls = []; + + /** + * @var array): mixed> + */ + private array $replies = []; + + public function testManyToManyKeysAreRenamedInTheJunctionOfEitherSide(): void + { + $this->replies['find'] = fn (array $arguments): stdClass => self::batch(match (self::filterOf($arguments)[Storage::UID] ?? null) { + 'books' => [(object) [Storage::UID => 'books', Storage::SEQUENCE => '11']], + 'authors' => [(object) [Storage::UID => 'authors', Storage::SEQUENCE => '22']], + default => [], + }); + $adapter = $this->adapter(); + + $adapter->updateRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'authors', twoWayKey: 'books'), RelationshipSide::Parent, new RelationshipUpdate(key: 'writers')); + $adapter->updateRelationship('authors', Relationship::manyToMany(relatedCollection: 'books', twoWay: true, key: 'books', twoWayKey: 'authors'), RelationshipSide::Child, new RelationshipUpdate(key: 'titles')); + + $this->assertSame([ + [self::NAMESPACE.'__11_22', ['$rename' => ['authors' => 'writers']]], + [self::NAMESPACE.'__11_22', ['$rename' => ['books' => 'titles']]], + ], \array_map(static fn (array $arguments): array => [$arguments[0] ?? null, $arguments[2] ?? null], $this->argumentsOf('update'))); + } + + public function testFulltextIndexesAreCreatedWithoutTheCollation(): void + { + $adapter = $this->adapter(); + + $adapter->createIndex('books', Index::fulltext(key: 'by_text', attributes: ['title']), [], ['locale' => 'en']); + $adapter->createIndex('books', Index::key(key: 'by_title', attributes: ['title']), [], ['locale' => 'en']); + + $specifications = \array_map(static fn (array $arguments): mixed => \is_array($arguments[1] ?? null) ? ($arguments[1][0] ?? null) : null, $this->argumentsOf('createIndexes')); + $this->assertCount(2, $specifications); + $this->assertIsArray($specifications[0]); + $this->assertArrayNotHasKey('collation', $specifications[0]); + $this->assertIsArray($specifications[1]); + $this->assertSame(['locale' => 'en', 'strength' => 1], $specifications[1]['collation'] ?? null); + } + + public function testUniqueIndexCreationWaitsUntilTheBuildIsReady(): void + { + $listings = 0; + $this->replies['query'] = function (array $arguments) use (&$listings): stdClass { + $listings++; + + return (object) ['cursor' => (object) ['firstBatch' => [ + (object) ['name' => 'unique_title', 'buildState' => $listings === 1 ? 'building' : 'ready'], + ]]]; + }; + + $this->assertTrue($this->adapter()->createIndex('books', Index::unique(key: 'unique_title', attributes: ['title']))); + $this->assertSame(2, $listings, 'The index list must be read again while the build is not ready'); + } + + public function testIndexCreationFailuresAreMapped(): void + { + $this->replies['createIndexes'] = static fn (): never => throw new MongoException('Index already exists with a different name', 85); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Index already exists'); + $this->adapter()->createIndex('books', Index::key(key: 'by_title', attributes: ['title'])); + } + + public function testRenamingAnIndexTheMetadataDoesNotListIsRefused(): void + { + $this->replies['find'] = static fn (): stdClass => self::batch([(object) [ + Storage::UID => 'books', + 'indexes' => \json_encode([['$id' => 'by_title', 'key' => 'by_title', 'type' => 'key', 'attributes' => ['title']]]), + 'attributes' => '[]', + ]]); + + try { + $this->adapter()->renameIndex('books', 'missing', 'renamed'); + $this->fail('Renaming an index the metadata does not list must be refused'); + } catch (DatabaseException $exception) { + $this->assertSame('Index not found: missing', $exception->getMessage()); + } + + $this->assertSame([], $this->argumentsOf('dropIndexes')); + $this->assertSame([], $this->argumentsOf('createIndexes')); + } + + public function testRenamingAnIndexTheSchemaDoesNotHaveFailsWithTheDriverError(): void + { + $this->replyWithIndexMetadata(); + $this->replies['dropIndexes'] = static fn (): never => throw new MongoException('index not found with name [by_title]', 27); + + try { + $this->adapter()->renameIndex('books', 'by_title', 'by_name'); + $this->fail('Renaming an index the schema does not have must fail'); + } catch (MongoException $exception) { + $this->assertSame(27, $exception->getCode()); + } + + $this->assertSame([], $this->argumentsOf('createIndexes')); + } + + public function testRenamingAnIndexTheSchemaHasRebuildsItUnderTheNewName(): void + { + $this->replyWithIndexMetadata(); + + $this->assertTrue($this->adapter()->renameIndex('books', 'by_title', 'by_name')); + $this->assertSame([[self::NAMESPACE.'_books', ['by_title'], []]], $this->argumentsOf('dropIndexes')); + $this->assertSame(['by_name'], \array_map(static function (array $arguments): mixed { + $indexes = $arguments[1] ?? null; + $first = \is_array($indexes) ? ($indexes[0] ?? null) : null; + + return \is_array($first) ? ($first['name'] ?? null) : null; + }, $this->argumentsOf('createIndexes'))); + } + + public function testANonNumericPowerExponentIsRefused(): void + { + try { + $this->adapter()->updateDocument(new Document(['$id' => 'books']), 'first', new Document(['price' => Operator::power('two')]), true); + $this->fail('A non-numeric power exponent must be refused'); + } catch (DatabaseException $exception) { + $this->assertSame('Invalid numeric operand for operator power', $exception->getMessage()); + } + + $this->assertSame([], $this->argumentsOf('query'), 'No update may reach the server'); + } + + public function testSequencesAreReadBeyondTheFirstBatch(): void + { + $this->replies['find'] = static fn (): stdClass => (object) ['cursor' => (object) [ + 'firstBatch' => [(object) [Storage::UID => 'first', Storage::SEQUENCE => 'one']], + 'id' => 7, + ]]; + $this->replies['getMore'] = static fn (): stdClass => (object) ['cursor' => (object) [ + 'nextBatch' => [(object) [Storage::UID => 'second', Storage::SEQUENCE => 'two']], + 'id' => 0, + ]]; + + $documents = $this->adapter()->getSequences(new Document(['$id' => 'books']), [new Document(['$id' => 'first']), new Document(['$id' => 'second'])]); + + $this->assertSame(['one', 'two'], \array_map(static fn (Document $document): ?string => $document->getSequence(), $documents)); + $this->assertSame([7], \array_map(static fn (array $arguments): mixed => $arguments[0] ?? null, $this->argumentsOf('getMore'))); + } + + public function testSequenceReadFailuresAreMapped(): void + { + $this->replies['find'] = static fn (): never => throw new MongoException('operation exceeded time limit', 50); + + $this->expectException(TimeoutException::class); + $this->adapter()->getSequences(new Document(['$id' => 'books']), [new Document(['$id' => 'first'])]); + } + + public function testIntegerOperatorOperandsAreCastBeforeTheWrite(): void + { + $adapter = $this->adapter(); + $collection = new Document(['attributes' => [['$id' => 'count', 'type' => ColumnType::Integer->value, 'array' => false]]]); + + $document = $adapter->castBefore($collection, new Document(['count' => Operator::increment('5', '100')])); + + $operator = $document->getAttribute('count'); + $this->assertInstanceOf(Operator::class, $operator); + $this->assertSame([5, 100], $operator->getValues()); + + $this->expectException(TypeException::class); + $this->expectExceptionMessage('outside the signed 64-bit range'); + $adapter->castBefore($collection, new Document(['count' => Operator::increment('9223372036854775808')])); + } + + public function testArrayAttributesAreDecodedOrWrappedBeforeTheWrite(): void + { + $collection = new Document(['attributes' => [ + ['$id' => 'tags', 'type' => ColumnType::String->value, 'array' => true], + ['$id' => 'labels', 'type' => ColumnType::String->value, 'array' => true], + ['$id' => 'meta', 'type' => ColumnType::Object->value, 'array' => false], + ]]); + + $adapter = $this->adapter(); + $document = $adapter->castBefore($collection, new Document([ + 'tags' => '["a","b"]', + 'labels' => 7, + 'meta' => '{"colour":"red"}', + ])); + + $this->assertSame(['a', 'b'], $document->getAttribute('tags')); + $this->assertSame([7], $document->getAttribute('labels')); + $this->assertEquals((object) ['colour' => 'red'], $document->getAttribute('meta')); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Failed to decode JSON for attribute tags'); + $adapter->castBefore($collection, new Document(['tags' => 'not json'])); + } + + public function testStoredValuesAreReadBackByTheirAttributeType(): void + { + $adapter = $this->adapter(); + $collection = new Document(['attributes' => [ + ['$id' => 'tags', 'type' => ColumnType::String->value, 'array' => true], + ['$id' => 'labels', 'type' => ColumnType::String->value, 'array' => true], + ['$id' => 'count', 'type' => ColumnType::Integer->value, 'array' => false], + ]]); + + $empty = new Document(); + $this->assertSame($empty, $adapter->castAfter($collection, [$empty])[0]); + + $document = $adapter->castAfter($collection, [new Document([ + 'tags' => '["a","b"]', + 'labels' => 7, + 'count' => '42', + ])])[0]; + + $this->assertSame(['a', 'b'], $document->getAttribute('tags')); + $this->assertSame(['7'], $document->getAttribute('labels')); + $this->assertSame(42, $document->getAttribute('count')); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Failed to decode JSON for attribute tags'); + $adapter->castAfter($collection, [new Document(['tags' => 'not json'])]); + } + + public function testSchemalessCastingKeepsStringsThatAreNotDates(): void + { + $adapter = $this->adapter(); + $adapter->setSchemaless(true); + $collection = new Document(['indexes' => [['$id' => 'expiry', 'type' => 'ttl', 'attributes' => ['expiresAt']]]]); + + $document = $adapter->castBefore($collection, new Document([ + 'expiresAt' => '2026-13-45T99:99:99Z', + 'label' => 'plain', + ])); + + $this->assertSame('2026-13-45T99:99:99Z', $document->getAttribute('expiresAt')); + $this->assertSame('plain', $document->getAttribute('label')); + } + + #[RequiresPhpExtension('mongodb')] + public function testNumericDatesAndInt64ValuesAreConverted(): void + { + $adapter = $this->adapter(); + + $before = $adapter->castBefore( + new Document(['attributes' => [['$id' => 'when', 'type' => ColumnType::Datetime->value, 'array' => false]]]), + new Document(['when' => '1700000000000']), + ); + $when = $before->getAttribute('when'); + $this->assertInstanceOf(UTCDateTime::class, $when); + $this->assertSame('1700000000000', (string) $when); + + $after = $adapter->castAfter( + new Document(['attributes' => [['$id' => 'count', 'type' => ColumnType::BigInteger->value, 'array' => false]]]), + [new Document(['count' => new Int64('9007199254740993')])], + )[0]; + $this->assertSame(9007199254740993, $after->getAttribute('count')); + } + + public function testReconnectReconnectsTheClient(): void + { + $adapter = $this->adapter(); + $connections = \count($this->argumentsOf('connect')); + + $adapter->reconnect(); + + $this->assertCount($connections + 1, $this->argumentsOf('connect')); + } + + public function testOperatorsRefuseOperandsOfTheWrongType(): void + { + $cases = [ + 'dateAddDays' => [new Operator(OperatorType::DateAddDays, '', ['5']), 'Invalid integer operand for operator dateAddDays'], + 'arrayInsert' => [new Operator(OperatorType::ArrayInsert, '', ['1', 'x']), 'Invalid integer operand for operator arrayInsert'], + 'arrayFilter' => [new Operator(OperatorType::ArrayFilter, '', [5]), 'Invalid string operand for operator arrayFilter'], + ]; + + foreach ($cases as $case => [$operator, $message]) { + try { + $this->adapter()->updateDocument(new Document(['$id' => 'books']), 'first', new Document(['value' => $operator]), true); + $this->fail("{$case}: an operand of the wrong type must be refused"); + } catch (DatabaseException $exception) { + $this->assertSame($message, $exception->getMessage(), $case); + } + } + + $this->assertSame([], $this->argumentsOf('query'), 'No update may reach the server'); + } + + public function testAReadEndingOnAFullBatchKillsItsCursor(): void + { + $this->replies['find'] = static fn (): stdClass => (object) ['cursor' => (object) [ + 'firstBatch' => \array_map(static fn (int $index): object => (object) [Storage::UID => 'row'.$index], \range(1, 3)), + 'id' => 7, + ]]; + $this->replies['getMore'] = static fn (): stdClass => (object) ['cursor' => (object) ['nextBatch' => [], 'id' => 7]]; + + $documents = $this->adapter()->find(new Document(['$id' => 'books']), limit: null); + + $this->assertCount(3, $documents); + $this->assertSame([['killCursors' => self::NAMESPACE.'_books', 'cursors' => [7]]], \array_map(static fn (array $arguments): mixed => $arguments[0] ?? null, $this->argumentsOf('query'))); + } + + public function testResponsesWithoutACursorIdEndTheRead(): void + { + $this->replies['find'] = static fn (): stdClass => (object) ['cursor' => (object) ['firstBatch' => [(object) [Storage::UID => 'first']]]]; + + $this->assertCount(1, $this->adapter()->find(new Document(['$id' => 'books']), limit: null)); + $this->assertSame([], $this->argumentsOf('getMore')); + + $this->calls = []; + $this->replies['find'] = static fn (): stdClass => (object) ['cursor' => (object) ['firstBatch' => [(object) [Storage::UID => 'first']], 'id' => 7]]; + $this->replies['getMore'] = static fn (): stdClass => (object) ['cursor' => (object) ['nextBatch' => [(object) [Storage::UID => 'second']]]]; + + $this->assertCount(2, $this->adapter()->find(new Document(['$id' => 'books']), limit: null)); + $this->assertCount(1, $this->argumentsOf('getMore')); + $this->assertSame([], $this->argumentsOf('query'), 'A cursor the server closed must not be killed'); + } + + public function testDollarPrefixedUserKeyRoundTrips(): void + { + $this->replies['find'] = static fn (): stdClass => self::batch([(object) [Storage::UID => 'first', '_custom' => 'x']]); + + $created = $this->adapter()->createDocument(new Document(['$id' => 'books']), new Document(['$id' => 'first', '$permissions' => [], '$custom' => 'x'])); + + $inserted = $this->argumentsOf('insert')[0][1] ?? null; + $this->assertIsArray($inserted); + $this->assertSame('x', $inserted['_custom'] ?? null); + $this->assertArrayNotHasKey('$custom', $inserted); + $this->assertSame('x', $created->getAttribute('$custom')); + } + + /** + * @param array $arguments + * @return array + */ + private static function filterOf(array $arguments): array + { + $filter = $arguments[1] ?? []; + + return \is_array($filter) ? $filter : []; + } + + /** + * @param list $documents + */ + private static function batch(array $documents): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => $documents, 'id' => 0]]; + } + + /** + * @return list> + */ + private function argumentsOf(string $method): array + { + $calls = \array_filter($this->calls, static fn (array $call): bool => $call[0] === $method); + + return \array_values(\array_map(static fn (array $call): array => $call[1], $calls)); + } + + /** + * @param list $arguments + */ + private function record(string $method, array $arguments): mixed + { + $this->calls[] = [$method, $arguments]; + $reply = $this->replies[$method] ?? null; + + return $reply !== null ? $reply($arguments) : null; + } + + private function replyWithIndexMetadata(): void + { + $this->replies['find'] = static fn (): stdClass => self::batch([(object) [ + Storage::UID => 'books', + 'indexes' => \json_encode([['$id' => 'by_title', 'key' => 'by_title', 'type' => 'key', 'attributes' => ['title']]]), + 'attributes' => \json_encode([['$id' => 'title', 'key' => 'title', 'type' => 'string']]), + ]]); + } + + private function adapter(): Mongo + { + $client = new class ($this->record(...)) extends Client { + /** + * @param Closure(string, list): mixed $record + */ + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function connect(): self + { + ($this->record)('connect', []); + + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + $reply = ($this->record)('find', [$collection, $filters, $options]); + + return $reply instanceof stdClass ? $reply : (object) ['cursor' => (object) ['firstBatch' => [], 'id' => 0]]; + } + + #[\Override] + public function getMore(int $cursorId, string $collection, int $batchSize = 25): stdClass + { + $reply = ($this->record)('getMore', [$cursorId, $collection, $batchSize]); + + return $reply instanceof stdClass ? $reply : (object) ['cursor' => (object) ['nextBatch' => [], 'id' => 0]]; + } + + /** + * @param array $where + * @param array $updates + * @param array $options + */ + #[\Override] + public function update(string $collection, array $where = [], array $updates = [], array $options = [], bool $multi = false): int + { + $reply = ($this->record)('update', [$collection, $where, $updates, $options, $multi]); + + return \is_int($reply) ? $reply : 1; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + $reply = ($this->record)('createIndexes', [$collection, $indexes, $options]); + + return \is_bool($reply) ? $reply : true; + } + + /** + * @param array $document + * @param array $options + * @return array + */ + #[\Override] + public function insert(string $collection, array $document, array $options = []): array + { + ($this->record)('insert', [$collection, $document, $options]); + + return $document; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function dropIndexes(string $collection, array $indexes, array $options = []): self + { + ($this->record)('dropIndexes', [$collection, $indexes, $options]); + + return $this; + } + + /** + * @param array $command + * @return stdClass|array|int + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass|array|int + { + $reply = ($this->record)('query', [$command, $db]); + + return $reply instanceof stdClass || \is_array($reply) || \is_int($reply) ? $reply : 1; + } + }; + + $authorization = new Authorization(); + $authorization->disable(); + + $adapter = new Mongo($client); + $adapter->setAuthorization($authorization); + $adapter->setNamespace(self::NAMESPACE); + + return $adapter; + } +} diff --git a/tests/unit/MongoArrayNotContainsTest.php b/tests/unit/MongoArrayNotContainsTest.php new file mode 100644 index 0000000000..ea5d4c5e24 --- /dev/null +++ b/tests/unit/MongoArrayNotContainsTest.php @@ -0,0 +1,84 @@ +> + */ + private array $filters = []; + + public function testNotContainsOnAnArrayExcludesMissingAndNullArrays(): void + { + $query = Query::notContains('labels', ['a', 'c']); + $query->setOnArray(true); + + $this->createAdapter()->find(new Document(['$id' => self::COLLECTION]), [$query]); + + $this->assertSame( + [['labels' => ['$nin' => ['a', 'c'], '$ne' => null]]], + $this->filters[0]['$and'] ?? null, + 'A document whose array is missing or null must not match notContains, as on MariaDB, MySQL and PostgreSQL', + ); + } + + private function createAdapter(): Mongo + { + $record = function (array $filters): void { + $this->filters[] = $filters; + }; + + $client = new class ($record) extends Client { + /** + * @param Closure(array): void $record + */ + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + ($this->record)($filters); + + return (object) ['cursor' => (object) ['firstBatch' => [], 'id' => 0]]; + } + }; + + $authorization = new Authorization(); + $authorization->disable(); + + $adapter = new Mongo($client); + $adapter->setAuthorization($authorization); + $adapter->setNamespace('array_not_contains'); + + return $adapter; + } +} diff --git a/tests/unit/MongoAttributesSupportTest.php b/tests/unit/MongoAttributesSupportTest.php new file mode 100644 index 0000000000..e62b35af77 --- /dev/null +++ b/tests/unit/MongoAttributesSupportTest.php @@ -0,0 +1,30 @@ +assertFalse($adapter->isSchemaless()); + $this->assertTrue($adapter->supports(Capability::DefinedAttributes)); + + $this->assertSame($adapter, $adapter->setSchemaless(true)); + $this->assertTrue($adapter->isSchemaless()); + $this->assertFalse($adapter->supports(Capability::DefinedAttributes)); + + $adapter->setSchemaless(false); + $this->assertFalse($adapter->isSchemaless()); + $this->assertTrue($adapter->supports(Capability::DefinedAttributes)); + } +} diff --git a/tests/unit/MongoCommitRetryOutcome.php b/tests/unit/MongoCommitRetryOutcome.php new file mode 100644 index 0000000000..1d2cbae4d5 --- /dev/null +++ b/tests/unit/MongoCommitRetryOutcome.php @@ -0,0 +1,17 @@ + + */ + public static function unknownResults(): array + { + return [ + 'primary stepped down (189)' => [self::primarySteppedDown()], + 'max time expired (50)' => [new MongoException('E50 MaxTimeMSExpired: operation exceeded time limit', self::MAX_TIME_EXPIRED)], + 'exceeded time limit (262)' => [new MongoException('E262 ExceededTimeLimit: operation exceeded time limit', self::EXCEEDED_TIME_LIMIT)], + ]; + } + + /** + * Commit errors after which utopia-php/mongo 1.5.4 drops the connection with its sessions, so the commit cannot + * be sent again. + * + * @return array}> + */ + public static function disconnects(): array + { + return [ + 'receive timeout (11601) after the commit applied' => [ + new MongoException('Receive timeout: no data received within reasonable time', self::SOCKET_TIMEOUT), + MongoCommitRetryOutcome::AppliedThenLost, + [self::DOCUMENT], + ], + 'send failure (9001) before the commit applied' => [ + new MongoException('Failed to send data to MongoDB after reconnection attempt', self::SOCKET_EXCEPTION), + MongoCommitRetryOutcome::NotApplied, + [], + ], + ]; + } + + /** + * Commit errors as utopia-php/mongo 1.5.4 raises them, without error labels, for a transaction the server + * aborted, so none of its writes are stored. + * + * @return array + */ + public static function abortedCommits(): array + { + return [ + 'no such transaction (251)' => [self::noSuchTransaction()], + 'write conflict (112)' => [new MongoException('E112 WriteConflict: write conflict during plan execution', self::WRITE_CONFLICT)], + ]; + } + + #[DataProvider('unknownResults')] + public function testAnUnknownCommitResultThatAppliedIsRetriedWithoutRunningTheCallbackAgain(MongoException $error): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::AppliedThenLost, $error], + [MongoCommitRetryOutcome::Committed, null], + ])); + $attempts = 0; + + $result = $adapter->withTransaction($this->work($staged, $attempts)); + + $this->assertSame(self::RESULT, $result); + $this->assertSame(1, $attempts, 'A commit whose result is unknown must not run the callback again'); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * utopia-php/mongo 1.5.4 never sets error labels; this checks a client that does. + */ + public function testForwardCompatibilityALabelledUnknownCommitResultIsRetriedWithoutRunningTheCallbackAgain(): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::AppliedThenLost, new MongoException('Commit failed', 0, null, [Client::UNKNOWN_TRANSACTION_COMMIT_RESULT])], + [MongoCommitRetryOutcome::Committed, null], + ])); + $attempts = 0; + + $result = $adapter->withTransaction($this->work($staged, $attempts)); + + $this->assertSame(self::RESULT, $result); + $this->assertSame(1, $attempts, 'A commit whose result is unknown must not run the callback again'); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * @param list $expected + */ + #[DataProvider('disconnects')] + public function testACommitThatDropsTheConnectionThrowsUnconfirmedWithoutRunningTheCallbackAgain(MongoException $error, MongoCommitRetryOutcome $outcome, array $expected): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [$outcome, $error], + ])); + $attempts = 0; + + $thrown = $this->failure(fn (Closure $callback): mixed => $adapter->withTransaction($callback), $this->work($staged, $attempts)); + + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + $this->assertSame($error, $thrown->getPrevious()); + $this->assertSame(1, $attempts, 'A commit whose result is unknown must not run the callback again'); + $this->assertSame($expected, $stored->getArrayCopy()); + $this->assertFalse($adapter->isRetryable($thrown), 'Running an unconfirmed commit again could store its writes twice'); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * @param list $expected + */ + #[DataProvider('disconnects')] + public function testADatabaseTransactionWhoseCommitDropsTheConnectionThrowsUnconfirmed(MongoException $error, MongoCommitRetryOutcome $outcome, array $expected): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $database = new Database(new Mongo($this->client($staged, $stored, [ + [$outcome, $error], + ])), new Cache(new None())); + $attempts = 0; + + $thrown = $this->failure(fn (Closure $callback): mixed => $database->withTransaction($callback), $this->work($staged, $attempts)); + + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + $this->assertSame($error, $thrown->getPrevious()); + $this->assertSame(1, $attempts, 'A commit whose result is unknown must not run the callback again'); + $this->assertSame($expected, $stored->getArrayCopy()); + } + + public function testACommitThatStaysUnconfirmedThrowsWithoutRunningTheCallbackAgain(): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $unknown = self::primarySteppedDown(); + $outage = new ArrayObject([self::primarySteppedDown()]); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::AppliedThenLost, $unknown], + ], $outage)); + $attempts = 0; + + $thrown = $this->failure(fn (Closure $callback): mixed => $adapter->withTransaction($callback), $this->work($staged, $attempts)); + + $this->assertSame(1, $attempts, 'A commit whose result is unknown must not run the callback again'); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + $this->assertSame($unknown, $thrown->getPrevious()); + $this->assertFalse($adapter->isRetryable($thrown), 'Running an unconfirmed commit again could store its writes twice'); + $this->assertFalse($adapter->inTransaction()); + + $outage->exchangeArray([]); + $attempts = 0; + + $this->assertSame(self::RESULT, $adapter->withTransaction($this->work($staged, $attempts))); + $this->assertSame(1, $attempts); + $this->assertSame([self::DOCUMENT, self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + public function testACommitTimeLimitIsRetriedThenThrowsUnconfirmed(): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $expired = new MongoException('E50 MaxTimeMSExpired: operation exceeded time limit', self::MAX_TIME_EXPIRED); + $adapter = new Mongo($this->client($staged, $stored, [], new ArrayObject([$expired]))); + $attempts = 0; + + $thrown = $this->failure(fn (Closure $callback): mixed => $adapter->withTransaction($callback), $this->work($staged, $attempts)); + + $this->assertSame(1, $attempts, 'A commit whose result is unknown must not run the callback again'); + $this->assertSame([], $stored->getArrayCopy()); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + $this->assertSame($expired, $thrown->getPrevious()); + $this->assertFalse($adapter->inTransaction()); + } + + #[DataProvider('abortedCommits')] + public function testAFirstCommitTheServerReportsAbortedRunsTheCallbackAgain(MongoException $error): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::Aborted, $error], + [MongoCommitRetryOutcome::Committed, null], + ])); + $attempts = 0; + + $result = $adapter->withTransaction($this->work($staged, $attempts)); + + $this->assertSame(self::RESULT, $result); + $this->assertSame(2, $attempts, 'An aborted transaction stored nothing, so the callback must run again'); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + #[DataProvider('abortedCommits')] + public function testARetriedCommitThatReportsTheTransactionAbortedRunsTheCallbackAgain(MongoException $error): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::NotApplied, self::primarySteppedDown()], + [MongoCommitRetryOutcome::Aborted, $error], + [MongoCommitRetryOutcome::Committed, null], + ])); + $attempts = 0; + + $result = $adapter->withTransaction($this->work($staged, $attempts)); + + $this->assertSame(self::RESULT, $result); + $this->assertSame(2, $attempts, 'An aborted transaction stored nothing, so the callback must run again'); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * utopia-php/mongo 1.5.4 never sets error labels; this checks a client that does. + */ + public function testForwardCompatibilityALabelledTransientTransactionErrorAtCommitRunsTheCallbackAgain(): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::NotApplied, new MongoException('Transaction aborted', 0, null, [Client::TRANSIENT_TRANSACTION_ERROR])], + [MongoCommitRetryOutcome::Committed, null], + ])); + $attempts = 0; + + $result = $adapter->withTransaction($this->work($staged, $attempts)); + + $this->assertSame(self::RESULT, $result); + $this->assertSame(2, $attempts); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + public function testAnUnsentCommitRunsTheCallbackAgain(): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::NotApplied, new UnsentException('Connection to MongoDB has been lost')], + [MongoCommitRetryOutcome::Committed, null], + ])); + $attempts = 0; + + $result = $adapter->withTransaction($this->work($staged, $attempts)); + + $this->assertSame(self::RESULT, $result); + $this->assertSame(2, $attempts); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + public function testACommitRetryThatWasNeverSentIsSentAgain(): void + { + $staged = $this->documents(); + $stored = $this->documents(); + $adapter = new Mongo($this->client($staged, $stored, [ + [MongoCommitRetryOutcome::NotApplied, self::primarySteppedDown()], + [MongoCommitRetryOutcome::NotApplied, new UnsentException('Failed to connect to MongoDB')], + [MongoCommitRetryOutcome::Committed, null], + ])); + $attempts = 0; + + $result = $adapter->withTransaction($this->work($staged, $attempts)); + + $this->assertSame(self::RESULT, $result); + $this->assertSame(1, $attempts, 'A commit whose result is unknown must not run the callback again'); + $this->assertSame([self::DOCUMENT], $stored->getArrayCopy()); + $this->assertFalse($adapter->inTransaction()); + } + + private static function primarySteppedDown(): MongoException + { + return new MongoException('E189 PrimarySteppedDown: primary stepped down while waiting for replication', self::PRIMARY_STEPPED_DOWN); + } + + private static function noSuchTransaction(): MongoException + { + return new MongoException('E251 NoSuchTransaction: Transaction with { txnNumber: 1 } has been aborted.', self::NO_SUCH_TRANSACTION); + } + + /** + * @return ArrayObject + */ + private function documents(): ArrayObject + { + /** @var ArrayObject $documents */ + $documents = new ArrayObject(); + + return $documents; + } + + /** + * @param ArrayObject $staged + * @return Closure(): string + */ + private function work(ArrayObject $staged, int &$attempts): Closure + { + return function () use ($staged, &$attempts): string { + $attempts++; + $staged->append(self::DOCUMENT); + + return self::RESULT; + }; + } + + /** + * @param Closure(Closure(): string): mixed $transaction + * @param Closure(): string $callback + */ + private function failure(Closure $transaction, Closure $callback): Throwable + { + try { + $transaction($callback); + } catch (Throwable $thrown) { + return $thrown; + } + + $this->fail('The transaction was expected to fail'); + } + + /** + * A replica-set client that behaves as utopia-php/mongo 1.5.4 does: it never sets error labels, and a socket + * timeout or send failure drops the connection with its sessions and its replica-set state, after which every + * command that needs the server is unsent and a commit or abort reports an invalid session, until the adapter + * reconnects. Each commit follows the next scripted outcome; once the script is spent, commits fail with the + * outage's error while it holds one, and succeed otherwise. + * + * @param ArrayObject $staged + * @param ArrayObject $stored + * @param list $script + * @param ArrayObject|null $outage + */ + private function client(ArrayObject $staged, ArrayObject $stored, array $script, ?ArrayObject $outage = null): Client + { + return new class ($staged, $stored, $script, $outage ?? new ArrayObject()) extends Client { + private const array DISCONNECTING_CODES = [9001, 11601]; + + private const int NO_SUCH_TRANSACTION = 251; + + private bool $connected = false; + + private int $sessions = 0; + + /** + * @param ArrayObject $staged + * @param ArrayObject $stored + * @param list $script + * @param ArrayObject $outage + */ + public function __construct( + private readonly ArrayObject $staged, + private readonly ArrayObject $stored, + private array $script, + private readonly ArrayObject $outage, + ) { + } + + #[\Override] + public function connect(): self + { + $this->connected = true; + + return $this; + } + + #[\Override] + public function close(): void + { + } + + #[\Override] + public function isReplicaSet(): bool + { + $this->ensureConnected(); + + return true; + } + + /** + * @param array $options + * @return array + */ + #[\Override] + public function startSession(array $options = []): array + { + $this->ensureConnected(); + $this->sessions++; + + return ['id' => (object) ['id' => $this->sessions]]; + } + + /** + * @param array $session + * @param array $options + */ + #[\Override] + public function startTransaction(array $session, array $options = []): bool + { + $this->staged->exchangeArray([]); + + return true; + } + + /** + * @param array $session + * @param array $options + */ + #[\Override] + public function commitTransaction(array $session, array $options = []): bool + { + if (! $this->connected) { + throw new MongoException('Invalid session provided to commitTransaction'); + } + + [$outcome, $error] = \array_shift($this->script) ?? $this->unscripted(); + + return match ($outcome) { + MongoCommitRetryOutcome::Committed => $this->store(), + MongoCommitRetryOutcome::AppliedThenLost => $this->fail($error, applied: true), + MongoCommitRetryOutcome::NotApplied => $this->fail($error, applied: false), + MongoCommitRetryOutcome::Aborted => $this->abort($error), + }; + } + + /** + * @param array $session + * @param array $options + */ + #[\Override] + public function abortTransaction(array $session, array $options = []): bool + { + if (! $this->connected) { + throw new MongoException('Invalid session provided to abortTransaction'); + } + + $this->staged->exchangeArray([]); + + return true; + } + + /** + * @param array $sessions + * @param array $options + */ + #[\Override] + public function endSessions(array $sessions, array $options = []): bool + { + $this->ensureConnected(); + + return true; + } + + private function ensureConnected(): void + { + if (! $this->connected) { + throw new UnsentException('Client is not connected to MongoDB'); + } + } + + /** + * @return array{MongoCommitRetryOutcome, MongoException|null} + */ + private function unscripted(): array + { + $error = $this->outage[0] ?? null; + + return $error === null ? [MongoCommitRetryOutcome::Committed, null] : [MongoCommitRetryOutcome::NotApplied, $error]; + } + + private function store(): true + { + foreach ($this->staged as $document) { + $this->stored->append($document); + } + $this->staged->exchangeArray([]); + + return true; + } + + private function abort(?MongoException $error): never + { + $this->staged->exchangeArray([]); + + throw $error ?? new MongoException('E251 NoSuchTransaction: Transaction with { txnNumber: 1 } has been aborted.', self::NO_SUCH_TRANSACTION); + } + + private function fail(?MongoException $error, bool $applied): never + { + $error ??= new MongoException('Commit failed'); + + if ($applied) { + $this->store(); + } + + if (\in_array($error->getCode(), self::DISCONNECTING_CODES, true)) { + $this->connected = false; + } + + throw $error; + } + }; + } +} diff --git a/tests/unit/MongoContainsFilterTest.php b/tests/unit/MongoContainsFilterTest.php new file mode 100644 index 0000000000..55bdc7c777 --- /dev/null +++ b/tests/unit/MongoContainsFilterTest.php @@ -0,0 +1,161 @@ +> + */ + private array $filters = []; + + public function testContainsOnAnArrayMatchesAnyValue(): void + { + $contains = Query::contains('genres', ['comics', 'kids']); + $contains->setOnArray(true); + $containsAny = Query::containsAny('genres', ['comics', 'kids']); + $containsAny->setOnArray(true); + + $this->assertSame(['genres' => ['$in' => ['comics', 'kids']]], $this->filterOf($contains)); + $this->assertSame(['genres' => ['$in' => ['comics', 'kids']]], $this->filterOf($containsAny)); + } + + public function testNotEqualToSeveralValuesExcludesEachOfThem(): void + { + $this->assertSame(['name' => ['$nin' => ['a', 'b']]], $this->filterOf(Query::notEqual('name', ['a', 'b']))); + } + + public function testDollarPrefixedAttributeIsFilteredByItsStoredName(): void + { + $this->assertSame(['_meta__dot__key' => ['$eq' => 'x']], $this->filterOf(Query::equal('$meta.key', ['x']))); + } + + public function testSchemalessUnparsableDateStaysAString(): void + { + $adapter = $this->createAdapter(); + $adapter->setSchemaless(true); + + $this->assertSame( + ['when' => ['$eq' => '2026-13-45T99:99:99Z']], + $this->filterOf(Query::equal('when', ['2026-13-45T99:99:99Z']), $adapter), + ); + } + + #[RequiresPhpExtension('mongodb')] + public function testContainsOnAStringMatchesAnyOfSeveralSubstrings(): void + { + foreach ([Query::contains('name', ['Captain', 'Work']), Query::containsAny('name', ['Captain', 'Work'])] as $query) { + $this->filters = []; + $this->createAdapter()->find(new Document(['$id' => self::COLLECTION]), [$query]); + + $alternatives = $this->recordedCondition()['$or'] ?? null; + $this->assertIsArray($alternatives); + $this->assertSame(['.*Captain.*/i', '.*Work.*/i'], \array_map( + static function (mixed $alternative): string { + $regex = \is_array($alternative) && \is_array($alternative['name'] ?? null) ? ($alternative['name']['$regex'] ?? null) : null; + + return $regex instanceof Regex ? $regex->getPattern().'/'.$regex->getFlags() : ''; + }, + $alternatives, + )); + } + } + + #[RequiresPhpExtension('mongodb')] + public function testNotContainsOnAStringExcludesTheSubstring(): void + { + $filter = $this->filterOf(Query::notContains('name', ['Captain'])); + + $regex = \is_array($filter['name'] ?? null) ? ($filter['name']['$not'] ?? null) : null; + $this->assertInstanceOf(Regex::class, $regex); + $this->assertSame('.*Captain.*', $regex->getPattern()); + $this->assertSame('i', $regex->getFlags()); + } + + /** + * @return array + */ + private function filterOf(Query $query, ?Mongo $adapter = null): array + { + $this->filters = []; + ($adapter ?? $this->createAdapter())->find(new Document(['$id' => self::COLLECTION]), [$query]); + + $filter = $this->recordedCondition(); + $this->assertIsArray($filter); + + return $filter; + } + + /** + * @return array|null + */ + private function recordedCondition(): ?array + { + $filter = $this->filters[0] ?? null; + $conditions = \is_array($filter) ? ($filter['$and'] ?? null) : null; + $condition = \is_array($conditions) ? ($conditions[0] ?? null) : null; + + return \is_array($condition) ? $condition : null; + } + + private function createAdapter(): Mongo + { + $record = function (array $filters): void { + $this->filters[] = $filters; + }; + + $client = new class ($record) extends Client { + /** + * @param Closure(array): void $record + */ + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + ($this->record)($filters); + + return (object) ['cursor' => (object) ['firstBatch' => [], 'id' => 0]]; + } + }; + + $authorization = new Authorization(); + $authorization->disable(); + + $adapter = new Mongo($client); + $adapter->setAuthorization($authorization); + $adapter->setNamespace('contains_filter'); + + return $adapter; + } +} diff --git a/tests/unit/MongoCreateCollectionTest.php b/tests/unit/MongoCreateCollectionTest.php new file mode 100644 index 0000000000..a009a7db79 --- /dev/null +++ b/tests/unit/MongoCreateCollectionTest.php @@ -0,0 +1,350 @@ + + */ + public static function sharedCollectionProvider(): array + { + return [ + 'the client finds the collection, shared tables' => [new MongoException('Collection Exists', 48), 'orders', true], + 'the server answers 48, shared tables' => [new MongoException(self::SERVER_EXISTS, 48), 'orders', true], + 'the server answers 48 for the metadata collection' => [new MongoException(self::SERVER_EXISTS, 48), Database::METADATA, false], + ]; + } + + #[DataProvider('sharedCollectionProvider')] + public function testAnExistingSharedCollectionGetsItsIndexesAndIsReportedAsExisting(MongoException $error, string $name, bool $sharedTables): void + { + $adapter = $this->adapter($error, $sharedTables, $client); + + try { + $adapter->createCollection($name, [Attribute::string(key: 'title', size: 64)], [Index::key(key: 'title', attributes: ['title'])]); + $this->fail('An adopted collection must be reported as existing, so its creator alone may drop it'); + } catch (DuplicateException) { + } + + $this->assertSame(2, $client->indexCalls, 'A shared collection another creator left without its indexes must get them'); + } + + /** + * @return array + */ + public static function existingCollectionProvider(): array + { + return [ + 'the client finds the collection' => [new MongoException('Collection Exists', 48)], + 'an older client finds the collection' => [new MongoException('Collection Exists')], + 'the server answers 48' => [new MongoException(self::SERVER_EXISTS, 48)], + ]; + } + + #[DataProvider('existingCollectionProvider')] + public function testAnExistingCollectionIsDuplicateOutsideSharedTables(MongoException $error): void + { + $failure = $this->createFailure($this->adapter($error, false, $client), 'orders'); + + $this->assertInstanceOf(DuplicateException::class, $failure); + $this->assertSame('Collection already exists', $failure->getMessage()); + $this->assertSame($error, $failure->getPrevious()); + $this->assertSame(0, $client->indexCalls, 'A collection this call did not create must not be adopted'); + } + + public function testOtherErrorsAreRethrown(): void + { + $error = new MongoException('not authorized on utopiaTests to execute command', 13); + + $this->assertSame($error, $this->createFailure($this->adapter($error, true, $client), 'orders')); + } + + public function testAFailureCreatingTheInternalIndexesIsMapped(): void + { + $error = new MongoException('operation exceeded time limit', 50); + + $failure = $this->createFailure($this->indexFailingAdapter($error, failingCall: 1), 'orders'); + + $this->assertInstanceOf(TimeoutException::class, $failure); + $this->assertSame($error, $failure->getPrevious()); + } + + public function testAConflictCreatingTheDeclaredIndexesIsAnIndexError(): void + { + $error = new MongoException('Index with name: title already exists with different options', 85); + $adapter = $this->indexFailingAdapter($error, failingCall: 2); + + try { + $adapter->createCollection('orders', [Attribute::string(key: 'title', size: 64)], [Index::key(key: 'title', attributes: ['title'])]); + $this->fail('The collection was created'); + } catch (IndexException $failure) { + $this->assertSame('Index already exists', $failure->getMessage()); + $this->assertSame($error, $failure->getPrevious()); + } + } + + /** + * @return array}> + */ + public static function indexesNotCreated(): array + { + $title = [Index::key(key: 'title', attributes: ['title'])]; + + return [ + 'the internal indexes are not created' => [static fn (int $call): bool => false, []], + 'the internal indexes fail' => [static fn (int $call): never => throw new MongoException('not authorized', 13), []], + 'the declared indexes are not created' => [static fn (int $call): bool => $call === 1, $title], + 'the declared indexes fail' => [static fn (int $call): bool => $call === 1 ? true : throw new MongoException('not authorized', 13), $title], + 'a declared index of an unsupported type' => [static fn (int $call): bool => true, [Index::spatial(key: 'title', attribute: 'title')]], + ]; + } + + /** + * @param Closure(int): bool $createIndexes + * @param list $indexes + */ + #[DataProvider('indexesNotCreated')] + public function testACollectionWhoseIndexesAreNotCreatedIsDropped(Closure $createIndexes, array $indexes): void + { + $this->assertSame(['engine_orders'], $this->droppedAfterAFailedCreate($createIndexes, $indexes), 'A collection this call created without its indexes must not be left behind'); + } + + /** + * @param Closure(int): bool $createIndexes + * @param list $indexes + */ + #[DataProvider('indexesNotCreated')] + public function testASharedCollectionWhoseIndexesAreNotCreatedIsKept(Closure $createIndexes, array $indexes): void + { + $this->assertSame([], $this->droppedAfterAFailedCreate($createIndexes, $indexes, sharedTables: true), 'Another tenant may already use a shared collection; the next create gives it its indexes'); + } + + /** + * @param Closure(int): bool $createIndexes + * @param list $indexes + */ + #[DataProvider('indexesNotCreated')] + public function testACollectionCreatedInATransactionIsLeftToItsAbort(Closure $createIndexes, array $indexes): void + { + $this->assertSame([], $this->droppedAfterAFailedCreate($createIndexes, $indexes, inTransaction: true), 'MongoDB runs no drop inside a transaction; the abort discards the collection'); + } + + /** + * @param Closure(int): bool $createIndexes + * @param list $indexes + * @return list + */ + private function droppedAfterAFailedCreate(Closure $createIndexes, array $indexes, bool $sharedTables = false, bool $inTransaction = false): array + { + $client = new class ($createIndexes) extends Client { + /** + * @var list + */ + public array $dropped = []; + + private int $calls = 0; + + /** + * @param Closure(int): bool $createIndexes + */ + public function __construct(private readonly Closure $createIndexes) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $options + */ + #[\Override] + public function createCollection(string $name, array $options = []): bool + { + return true; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + return ($this->createIndexes)(++$this->calls); + } + + /** + * @param array $options + */ + #[\Override] + public function dropCollection(string $name, array $options = []): bool + { + $this->dropped[] = $name; + + return true; + } + }; + $adapter = new Mongo($client); + $adapter->setNamespace('engine'); + $adapter->setSharedTables($sharedTables); + if ($inTransaction) { + (new \ReflectionProperty(Mongo::class, 'inTransaction'))->setValue($adapter, 1); + } + + try { + $this->assertFalse($adapter->createCollection('orders', [Attribute::string(key: 'title', size: 64)], $indexes)); + } catch (Throwable) { + // A failure is reported as itself; only whether the collection is dropped matters here. + } + + return $client->dropped; + } + + private function createFailure(Mongo $adapter, string $name): Throwable + { + try { + $adapter->createCollection($name); + } catch (Throwable $failure) { + return $failure; + } + + $this->fail('The collection was created'); + } + + private function indexFailingAdapter(MongoException $error, int $failingCall): Mongo + { + $client = new class ($error, $failingCall) extends Client { + private int $calls = 0; + + public function __construct(private readonly MongoException $error, private readonly int $failingCall) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $options + */ + #[\Override] + public function createCollection(string $name, array $options = []): bool + { + return true; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + if (++$this->calls === $this->failingCall) { + throw $this->error; + } + + return true; + } + }; + + $adapter = new Mongo($client); + $adapter->setNamespace('engine'); + + return $adapter; + } + + /** + * @param-out object{indexCalls: int} $client + */ + private function adapter(MongoException $error, bool $sharedTables, ?object &$client = null): Mongo + { + $client = new class ($error) extends Client { + public int $indexCalls = 0; + + public function __construct(private readonly MongoException $error) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => [], 'id' => 0]]; + } + + /** + * @param array $options + */ + #[\Override] + public function createCollection(string $name, array $options = []): bool + { + throw $this->error; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + $this->indexCalls++; + + return true; + } + }; + + $adapter = new Mongo($client); + $adapter->setNamespace('engine'); + $adapter->setSharedTables($sharedTables); + + return $adapter; + } +} diff --git a/tests/unit/MongoCursorTest.php b/tests/unit/MongoCursorTest.php new file mode 100644 index 0000000000..907ceb09c1 --- /dev/null +++ b/tests/unit/MongoCursorTest.php @@ -0,0 +1,127 @@ + $calls */ + $calls = new ArrayObject(); + $adapter = new Mongo($this->client( + $calls, + firstBatch: [self::row('first', 'sequence-first')], + nextBatches: [[self::row('second', 'sequence-second')], [self::row('third', 'sequence-third')]], + )); + $adapter->setNamespace('cursor'); + + [$first, $second, $third] = $adapter->getSequences(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'first']), + new Document(['$id' => 'second']), + new Document(['$id' => 'third']), + ]); + + $this->assertSame('sequence-first', $first->getSequence()); + $this->assertSame('sequence-second', $second->getSequence()); + $this->assertNull($third->getSequence(), 'a batch without a cursor id is the last one'); + $this->assertSame(['getMore'], $calls->getArrayCopy()); + } + + public function testAFindKeepsItsResultsWhenTheOpenCursorCannotBeKilled(): void + { + /** @var ArrayObject $calls */ + $calls = new ArrayObject(); + $adapter = new Mongo($this->client($calls, firstBatch: [self::row('first', 'sequence-first')], nextBatches: [[]])); + $adapter->setNamespace('cursor'); + $adapter->setAuthorization(new Authorization()); + + $documents = $adapter->find(new Document([Document::ID => self::COLLECTION])); + + $this->assertSame(['first'], \array_map(static fn (Document $document): string => $document->getId(), $documents)); + $this->assertSame(['getMore', 'killCursors'], $calls->getArrayCopy(), 'the cursor left open by an empty batch is killed, and the failure to kill it is ignored'); + } + + private static function row(string $id, string $sequence): stdClass + { + return (object) [Storage::UID => $id, Storage::SEQUENCE => $sequence]; + } + + /** + * A client whose find opens a cursor over $firstBatch and whose getMore hands out $nextBatches in turn, each in a + * reply without a cursor id. Every getMore and killCursors is recorded in $calls; killCursors fails. + * + * @param ArrayObject $calls + * @param list $firstBatch + * @param list> $nextBatches + */ + private function client(ArrayObject $calls, array $firstBatch, array $nextBatches): Client + { + return new class ($calls, $firstBatch, $nextBatches) extends Client { + /** + * @param ArrayObject $calls + * @param list $firstBatch + * @param list> $nextBatches + */ + public function __construct(private readonly ArrayObject $calls, private readonly array $firstBatch, private readonly array $nextBatches) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => $this->firstBatch, 'id' => 7]]; + } + + #[\Override] + public function getMore(int $cursorId, string $collection, int $batchSize = 25): stdClass + { + $batch = $this->nextBatches[\count($this->calls)] ?? []; + $this->calls[] = 'getMore'; + + return (object) ['cursor' => (object) ['nextBatch' => $batch]]; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass + { + $this->calls[] = \array_key_first($command) === 'killCursors' ? 'killCursors' : 'query'; + + throw new RuntimeException('the cursor could not be killed'); + } + }; + } +} diff --git a/tests/unit/MongoDatabaseExistsTest.php b/tests/unit/MongoDatabaseExistsTest.php new file mode 100644 index 0000000000..032839a290 --- /dev/null +++ b/tests/unit/MongoDatabaseExistsTest.php @@ -0,0 +1,96 @@ +assertTrue($this->adapter(['admin', 'library'])->exists('library')); + } + + public function testAnUnlistedDatabaseDoesNotExist(): void + { + $this->assertFalse($this->adapter(['admin', 'library'])->exists('archive')); + } + + public function testNoDatabaseExistsOnAnEmptyServer(): void + { + $this->assertFalse($this->adapter([])->exists('library')); + } + + public function testTheNameIsFilteredBeforeLookup(): void + { + $this->assertTrue($this->adapter(['library'])->exists('lib.rary')); + } + + public function testARefusedRenameUnderSharedTablesLeavesNoDatabaseBehind(): void + { + $adapter = $this->adapter(['library']); + $adapter->setSharedTables(true); + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('library'); + + try { + $database->update('library', 'archive'); + $this->fail('A rename under shared tables must be refused'); + } catch (DatabaseException $error) { + $this->assertSame('Cannot rename a database while shared tables are enabled', $error->getMessage()); + } + + $this->assertSame('library', $database->getDatabase()); + $this->assertTrue($database->exists('library')); + $this->assertFalse($database->exists('archive')); + } + + /** + * @param list $databases + */ + private function adapter(array $databases): Mongo + { + $client = new class ($databases) extends Client { + /** + * @param list $databases + */ + public function __construct(private readonly array $databases) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + #[\Override] + public function listDatabaseNames(): stdClass + { + $listed = new stdClass(); + $listed->databases = \array_map(static function (string $name): stdClass { + $database = new stdClass(); + $database->name = $name; + + return $database; + }, $this->databases); + + return $listed; + } + }; + + return new Mongo($client); + } +} diff --git a/tests/unit/MongoDatabaseRenameClient.php b/tests/unit/MongoDatabaseRenameClient.php new file mode 100644 index 0000000000..d0df971f45 --- /dev/null +++ b/tests/unit/MongoDatabaseRenameClient.php @@ -0,0 +1,122 @@ +> $databases + */ + public function __construct(public array $databases) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + #[\Override] + public function listDatabaseNames(): stdClass + { + $listed = new stdClass(); + $listed->databases = \array_map(static function (string $name): stdClass { + $database = new stdClass(); + $database->name = $name; + + return $database; + }, \array_keys($this->databases)); + + return $listed; + } + + /** + * @param array $options + */ + #[\Override] + public function createCollection(string $name, array $options = []): bool + { + $this->databases['default'][] = $name; + + return true; + } + + /** + * @param array $options + */ + #[\Override] + public function dropDatabase(array $options = [], ?string $db = null): bool + { + unset($this->databases[$db ?? 'default']); + + return true; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass + { + $db ??= 'default'; + + if (isset($command['renameCollection'])) { + /** @var string $from */ + $from = $command['renameCollection']; + /** @var string $to */ + $to = $command['to']; + if ($from === $this->failOn) { + throw new MongoException('rename refused'); + } + [$fromDatabase, $collection] = \explode('.', $from, 2); + [$toDatabase] = \explode('.', $to, 2); + $this->databases[$fromDatabase] = \array_values(\array_diff($this->databases[$fromDatabase], [$collection])); + if ($this->databases[$fromDatabase] === []) { + unset($this->databases[$fromDatabase]); + } + $this->databases[$toDatabase][] = $collection; + \sort($this->databases[$toDatabase]); + + return new stdClass(); + } + + $result = new stdClass(); + if (isset($command['listCollections'])) { + /** @var array{name?: string} $filter */ + $filter = $command['filter'] ?? []; + $names = \array_values(\array_filter( + $this->databases[$db] ?? [], + static fn (string $name): bool => ! isset($filter['name']) || $filter['name'] === $name, + )); + $cursor = new stdClass(); + $cursor->id = $this->cursor; + $cursor->firstBatch = \array_map(static function (string $name): stdClass { + $collection = new stdClass(); + $collection->name = $name; + + return $collection; + }, $names); + $result->cursor = $cursor; + } + + return $result; + } +} diff --git a/tests/unit/MongoDatabaseRenameTest.php b/tests/unit/MongoDatabaseRenameTest.php new file mode 100644 index 0000000000..f579ad7bd0 --- /dev/null +++ b/tests/unit/MongoDatabaseRenameTest.php @@ -0,0 +1,101 @@ +client(['library' => ['authors', 'books', 'system.views']]); + + $this->assertTrue(new Mongo($client)->update('library', 'archive')); + + $this->assertSame(['archive' => ['authors', 'books']], $client->databases); + } + + public function testAFailurePartWayMovesTheMovedCollectionsBack(): void + { + $client = $this->client(['library' => ['authors', 'books', 'loans']]); + $client->failOn = 'library.loans'; + + try { + new Mongo($client)->update('library', 'archive'); + $this->fail('A failed collection move must fail the rename'); + } catch (DatabaseException|MongoException $error) { + $this->assertStringContainsString('rename refused', $error->getMessage()); + } + + $this->assertSame(['library' => ['authors', 'books', 'loans']], $client->databases); + } + + public function testAPagedListingIsRefusedBeforeAnythingMoves(): void + { + $client = $this->client(['library' => ['authors', 'books']]); + $client->cursor = 42; + + try { + new Mongo($client)->update('library', 'archive'); + $this->fail('A listing the server pages must refuse the rename'); + } catch (DatabaseException $error) { + $this->assertSame('Database has more collections than one listing returns, so it cannot be renamed', $error->getMessage()); + } + + $this->assertSame(['library' => ['authors', 'books']], $client->databases); + } + + public function testSharedTablesRefuseTheRenameBeforeAnythingMoves(): void + { + $client = $this->client(['library' => ['books']]); + $adapter = new Mongo($client); + $adapter->setSharedTables(true); + + try { + $adapter->update('library', 'archive'); + $this->fail('A rename under shared tables must be refused'); + } catch (DatabaseException $error) { + $this->assertSame('Cannot rename a database while shared tables are enabled', $error->getMessage()); + } + + $this->assertSame(['library' => ['books']], $client->databases); + } + + public function testCollectionExistsAsksTheNamedDatabase(): void + { + $adapter = new Mongo($this->client(['library' => ['ns_books'], 'archive' => []])); + $adapter->setNamespace('ns'); + + $this->assertTrue($adapter->collectionExists('library', 'books')); + $this->assertFalse($adapter->collectionExists('archive', 'books')); + } + + public function testASharedCollectionAlreadyInTheAdaptersDatabaseIsNotCreatedAgain(): void + { + $client = $this->client(['library' => ['ns_books'], 'archive' => []]); + $adapter = new Mongo($client); + $adapter->setNamespace('ns'); + $adapter->setDatabase('library'); + $adapter->setSharedTables(true); + + try { + $adapter->createCollection('books'); + $this->fail('A shared collection the adapter\'s database already has must be reported as existing'); + } catch (DuplicateException) { + } + + $this->assertSame(['library' => ['ns_books'], 'archive' => []], $client->databases); + } + + /** + * @param array> $databases + */ + private function client(array $databases): MongoDatabaseRenameClient + { + return new MongoDatabaseRenameClient($databases); + } +} diff --git a/tests/unit/MongoDollarWordRegexTest.php b/tests/unit/MongoDollarWordRegexTest.php new file mode 100644 index 0000000000..ef55c2a11e --- /dev/null +++ b/tests/unit/MongoDollarWordRegexTest.php @@ -0,0 +1,105 @@ +> + */ + private array $filters = []; + + /** + * @return array + */ + public static function regexQueries(): array + { + return [ + 'contains' => [Query::contains('label', ['$USD']), '$regex', '.*\$USD.*'], + 'notContains' => [Query::notContains('label', ['$USD']), '$not', '.*\$USD.*'], + 'notSearch' => [Query::notSearch('label', '$USD'), '$not', '.*\$USD.*'], + 'notStartsWith' => [Query::notStartsWith('label', '$USD'), '$not', '^\$USD'], + 'notEndsWith' => [Query::notEndsWith('label', '$USD'), '$not', '\$USD$'], + 'metacharacters' => [Query::contains('label', ['a.b($x']), '$regex', '.*a\.b\(\$x.*'], + ]; + } + + #[DataProvider('regexQueries')] + public function testADollarWordIsMatchedLiterally(Query $query, string $operator, string $pattern): void + { + $this->createAdapter()->find(new Document(['$id' => self::COLLECTION]), [$query]); + + $recorded = $this->filters[0] ?? null; + $conditions = \is_array($recorded) ? ($recorded['$and'] ?? null) : null; + $condition = \is_array($conditions) ? ($conditions[0] ?? null) : null; + $filter = \is_array($condition) ? ($condition['label'] ?? null) : null; + $this->assertIsArray($filter); + $regex = $filter[$operator] ?? null; + $this->assertInstanceOf(Regex::class, $regex); + $this->assertSame($pattern, $regex->getPattern()); + $this->assertSame('i', $regex->getFlags()); + } + + private function createAdapter(): Mongo + { + $record = function (array $filters): void { + $this->filters[] = $filters; + }; + + $client = new class ($record) extends Client { + /** + * @param Closure(array): void $record + */ + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + ($this->record)($filters); + + return (object) ['cursor' => (object) ['firstBatch' => [], 'id' => 0]]; + } + }; + + $authorization = new Authorization(); + $authorization->disable(); + + $adapter = new Mongo($client); + $adapter->setAuthorization($authorization); + $adapter->setNamespace('dollar_word_regex'); + + return $adapter; + } +} diff --git a/tests/unit/MongoFilterScopeTest.php b/tests/unit/MongoFilterScopeTest.php new file mode 100644 index 0000000000..af659d8062 --- /dev/null +++ b/tests/unit/MongoFilterScopeTest.php @@ -0,0 +1,346 @@ +>> + */ + private array $filters = []; + + private Authorization $authorization; + + public function testWritesScopeByTenantWithoutReadPermission(): void + { + $adapter = $this->createAdapter(); + $collection = new Document(['$id' => self::COLLECTION]); + + $adapter->updateDocument($collection, 'first', new Document(['count' => 1]), true); + $adapter->updateDocuments($collection, new Document(['count' => 42]), [ + new Document(['$id' => 'first', '$sequence' => 'sequence-first']), + ]); + $adapter->upsertDocuments($collection, [ + new Change(new Document(), new Document([ + '$id' => 'second', + '$createdAt' => '2026-01-01T00:00:00.000+00:00', + '$updatedAt' => '2026-01-01T00:00:00.000+00:00', + '$permissions' => [], + 'count' => 1, + ])), + ]); + $adapter->deleteDocument(new Document(['$id' => self::COLLECTION]), 'first'); + $adapter->deleteDocuments(new Document(['$id' => self::COLLECTION]), ['sequence-first'], []); + + $this->assertCount(2, $this->filters['update'] ?? []); + $this->assertCount(1, $this->filters['upsert'] ?? []); + $this->assertCount(2, $this->filters['delete'] ?? []); + $this->assertTenantScopeOnly(['update', 'upsert', 'delete']); + } + + #[RequiresPhpExtension('mongodb')] + public function testIncrementsScopeByTenantWithoutReadPermission(): void + { + $adapter = $this->createAdapter(); + + $adapter->increaseDocumentAttribute(new Document(['$id' => self::COLLECTION]), 'first', 'count', 5, '2026-01-01 00:00:00.000'); + $adapter->increaseDocumentAttribute(new Document(['$id' => self::COLLECTION]), 'first', 'count', -2, '2026-01-01 00:00:00.000', min: 0); + + $this->assertCount(2, $this->filters['update'] ?? []); + $this->assertTenantScopeOnly(['update']); + } + + public function testGetDocumentScopesByTenantWithoutReadPermission(): void + { + $adapter = $this->createAdapter(); + + $adapter->getDocument(new Document(['$id' => self::COLLECTION]), 'first'); + + $this->assertSame( + [[Storage::UID => 'first', Storage::TENANT => self::TENANT]], + $this->filters['find'] ?? [], + ); + } + + public function testFindFiltersByTheRequestedPermission(): void + { + $adapter = $this->createAdapter(); + + $adapter->find(new Document(['$id' => self::COLLECTION]), forPermission: PermissionType::Update); + + $filters = $this->filters['find'][0] ?? []; + $this->assertSame(self::TENANT, $filters[Storage::TENANT] ?? null); + $this->assertSame( + ['$in' => ['update("any")', 'update("users")', 'update("user:bob")']], + $filters[Storage::PERMISSIONS] ?? null, + ); + } + + public function testCountAndSumFilterByReadPermission(): void + { + $adapter = $this->createAdapter(); + $collection = new Document(['$id' => self::COLLECTION]); + + $adapter->count($collection); + $adapter->sum($collection, 'count'); + + $this->assertCount(2, $this->filters['aggregate'] ?? []); + foreach ($this->filters['aggregate'] as $filters) { + $this->assertSame(self::TENANT, $filters[Storage::TENANT] ?? null); + $this->assertSame( + ['$in' => ['read("any")', 'read("users")', 'read("user:bob")']], + $filters[Storage::PERMISSIONS] ?? null, + ); + } + } + + public function testMetadataReadsFilterDefinitionsByReadPermission(): void + { + $adapter = $this->createAdapter(); + $metadata = new Document(['$id' => Database::METADATA]); + + $adapter->find($metadata); + $adapter->count($metadata); + $adapter->sum($metadata, 'count'); + + $reads = [...($this->filters['find'] ?? []), ...($this->filters['aggregate'] ?? [])]; + $this->assertCount(3, $reads); + foreach ($reads as $filters) { + $this->assertSame(['$in' => [self::TENANT, null]], $filters[Storage::TENANT] ?? null); + $this->assertSame( + ['$in' => ['read("any")', 'read("users")', 'read("user:bob")']], + $filters[Storage::PERMISSIONS] ?? null, + 'Collection definitions are listed under their own read permissions', + ); + } + } + + public function testReadsSkipThePermissionFilterWhileAuthorizationIsDisabled(): void + { + $adapter = $this->createAdapter(); + $collection = new Document(['$id' => self::COLLECTION]); + + $this->authorization->skip(function () use ($adapter, $collection): void { + $adapter->find($collection); + $adapter->count($collection); + }); + + $this->assertArrayNotHasKey(Storage::PERMISSIONS, $this->filters['find'][0] ?? []); + $this->assertArrayNotHasKey(Storage::PERMISSIONS, $this->filters['aggregate'][0] ?? []); + } + + public function testReadsFilterByPermissionWithoutThePermissionsHook(): void + { + $adapter = $this->createAdapter()->removeWriteHook(Permissions::class); + $collection = new Document(['$id' => self::COLLECTION]); + + $this->assertSame([], \array_filter($adapter->getWriteHooks(), static fn (Write $hook): bool => $hook instanceof Permissions)); + + $adapter->find($collection); + $adapter->find($collection, forPermission: PermissionType::Delete); + $adapter->count($collection); + $adapter->sum($collection, 'count'); + + $read = ['$in' => ['read("any")', 'read("users")', 'read("user:bob")']]; + $this->assertSame( + [$read, ['$in' => ['delete("any")', 'delete("users")', 'delete("user:bob")']]], + $this->permissionFilters('find'), + 'find() must filter by the requested permission whether or not a Permissions write hook is registered', + ); + $this->assertSame( + [$read, $read], + $this->permissionFilters('aggregate'), + 'count() and sum() must filter by read permission whether or not a Permissions write hook is registered', + ); + } + + public function testReadsWithoutThePermissionsHookSkipTheFilterWhileAuthorizationIsDisabled(): void + { + $adapter = $this->createAdapter()->removeWriteHook(Permissions::class); + $collection = new Document(['$id' => self::COLLECTION]); + + $this->authorization->skip(function () use ($adapter, $collection): void { + $adapter->find($collection); + $adapter->count($collection); + $adapter->sum($collection, 'count'); + }); + + $this->assertSame([null], $this->permissionFilters('find')); + $this->assertSame([null, null], $this->permissionFilters('aggregate')); + } + + /** + * @return list + */ + private function permissionFilters(string $operation): array + { + return \array_map( + fn (array $filters): mixed => $filters[Storage::PERMISSIONS] ?? null, + $this->filters[$operation] ?? [], + ); + } + + /** + * @param list $operations + */ + private function assertTenantScopeOnly(array $operations): void + { + foreach ($operations as $operation) { + foreach ($this->filters[$operation] ?? [] as $filters) { + $this->assertArrayNotHasKey( + Storage::PERMISSIONS, + $filters, + "The {$operation} filter must not require read permission: Database authorizes writes before they reach the adapter", + ); + $this->assertSame(self::TENANT, $filters[Storage::TENANT] ?? null, "The {$operation} filter must stay inside the tenant"); + } + } + } + + private function createAdapter(): Mongo + { + $record = function (string $operation, array $filters): void { + $this->filters[$operation][] = $filters; + }; + + $client = new class ($record) extends Client { + /** + * @param Closure(string, array): void $record + */ + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + ($this->record)('find', $filters); + + return (object) ['cursor' => (object) ['firstBatch' => [], 'id' => 0]]; + } + + /** + * @param array $where + * @param array $updates + * @param array $options + */ + #[\Override] + public function update(string $collection, array $where = [], array $updates = [], array $options = [], bool $multi = false): int + { + ($this->record)('update', $where); + + return 1; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): int + { + $updates = $command['updates'] ?? []; + foreach (\is_array($updates) ? $updates : [] as $update) { + $filter = \is_array($update) ? ($update['q'] ?? null) : null; + if ($filter instanceof stdClass) { + ($this->record)('update', (array) $filter); + } + } + + return 1; + } + + /** + * @param array $operations + * @param array $options + */ + #[\Override] + public function upsert(string $collection, array $operations, array $options = []): int + { + foreach ($operations as $operation) { + if (\is_array($operation) && \is_array($operation['filter'] ?? null)) { + ($this->record)('upsert', $operation['filter']); + } + } + + return \count($operations); + } + + /** + * @param array $filters + * @param array $deleteOptions + * @param array $options + */ + #[\Override] + public function delete(string $collection, array $filters = [], int $limit = 1, array $deleteOptions = [], array $options = []): int + { + ($this->record)('delete', $filters); + + return 1; + } + + /** + * @param array $pipeline + * @param array $options + */ + #[\Override] + public function aggregate(string $collection, array $pipeline, array $options = []): stdClass + { + $stage = $pipeline[0] ?? null; + $match = \is_array($stage) ? ($stage['$match'] ?? null) : null; + ($this->record)('aggregate', \is_array($match) || $match instanceof stdClass ? (array) $match : []); + + return (object) ['cursor' => (object) ['firstBatch' => []]]; + } + }; + + $this->authorization = new Authorization(); + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::any()->toString()); + $this->authorization->addRole(Role::users()->toString()); + $this->authorization->addRole(Role::user('bob')->toString()); + + $adapter = new Mongo($client); + $adapter->setAuthorization($this->authorization); + $adapter->setNamespace('scope'); + $adapter->setSharedTables(true); + $adapter->setTenant(self::TENANT); + $adapter->addWriteHook(new Permissions()); + + return $adapter; + } +} diff --git a/tests/unit/MongoFilteredMissCacheTest.php b/tests/unit/MongoFilteredMissCacheTest.php new file mode 100644 index 0000000000..1efa988f9e --- /dev/null +++ b/tests/unit/MongoFilteredMissCacheTest.php @@ -0,0 +1,114 @@ +createDatabase($this->createReadFilteringAdapter()); + + $this->actAs($database, 'bob'); + $this->assertTrue($database->getDocument(self::COLLECTION, 'alice')->isEmpty()); + + $this->actAs($database, 'alice'); + $this->assertSame( + 'Alice', + $database->getDocument(self::COLLECTION, 'alice')->getAttribute('name'), + 'A reader denied the document must not leave a negative cache entry for a reader who may see it', + ); + } + + public function testMissOfAnAbsentDocumentIsStillNegativeCached(): void + { + $adapter = $this->createReadFilteringAdapter(); + $database = $this->createDatabase($adapter); + + $this->actAs($database, 'bob'); + $this->assertTrue($database->getDocument(self::COLLECTION, 'absent')->isEmpty()); + $reads = $adapter->documentReads; + + $this->assertTrue($database->getDocument(self::COLLECTION, 'absent')->isEmpty()); + $this->assertSame( + $reads, + $adapter->documentReads, + 'A document the adapter does not hold must be served from the negative cache', + ); + } + + private function createReadFilteringAdapter(): CountingMemory + { + return new class () extends CountingMemory { + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $document = parent::getDocument($collection, $id, $queries, $forUpdate); + + if ( + $document->isEmpty() + || $collection->getId() === Database::METADATA + || ! $this->authorization->getStatus() + || \array_intersect($document->getPermissionsByType(PermissionType::Read), $this->authorization->getRoles()) !== [] + ) { + return $document; + } + + return new Document([]); + } + }; + } + + private function createDatabase(CountingMemory $adapter): Database + { + $database = new Database($adapter, new Cache(new MemoryCache())); + $database + ->setDatabase('utopiaTests') + ->setNamespace('filtered_miss_'.\uniqid()); + $database->create(); + + $database->getAuthorization()->skip(function () use ($database): void { + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::user('alice'))], + documentSecurity: false, + )); + + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'alice', + '$permissions' => [Permission::read(Role::user('alice'))], + 'name' => 'Alice', + ])); + }); + + return $database; + } + + private function actAs(Database $database, string $user): void + { + $authorization = $database->getAuthorization(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::users()->toString()); + $authorization->addRole(Role::user($user)->toString()); + } +} diff --git a/tests/unit/MongoIdentifierTest.php b/tests/unit/MongoIdentifierTest.php new file mode 100644 index 0000000000..e9ec440797 --- /dev/null +++ b/tests/unit/MongoIdentifierTest.php @@ -0,0 +1,59 @@ +{Storage::SEQUENCE} = new class () { + #[\Override] + public function __toString(): string + { + return '507f1f77bcf86cd799439011'; + } + }; + $row->{Storage::UID} = 'movies'; + + $client = new class ($row) extends Client { + public function __construct(private readonly stdClass $row) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => [$this->row], 'id' => 0]]; + } + }; + + $document = (new Mongo($client))->getDocument(new Document(['$id' => 'movies']), 'movies'); + + $this->assertSame('507f1f77bcf86cd799439011', $document->getAttribute(Document::SEQUENCE)); + $this->assertSame('movies', $document->getId()); + $this->assertArrayNotHasKey(Storage::SEQUENCE, $document->getArrayCopy()); + } +} diff --git a/tests/unit/MongoLenientReadTest.php b/tests/unit/MongoLenientReadTest.php new file mode 100644 index 0000000000..f11698b69c --- /dev/null +++ b/tests/unit/MongoLenientReadTest.php @@ -0,0 +1,99 @@ +getDocument(new Document([Document::ID => self::COLLECTION]), 'note'); + + $this->assertSame('note', $document->getId()); + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + } + + public function testFindDropsAStoredNonStringPermissionInEveryBatch(): void + { + $adapter = self::adapter([self::row('first')], [self::row('second')]); + + $documents = $adapter->find(new Document([Document::ID => self::COLLECTION])); + + $this->assertSame(['first', 'second'], \array_map(fn (Document $document): string => $document->getId(), $documents)); + foreach ($documents as $document) { + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + } + } + + private static function row(string $id): stdClass + { + return (object) [ + Storage::UID => $id, + Storage::PERMISSIONS => [Permission::read(Role::any()), 42, null, Permission::read(Role::any())], + 'title' => 'stored', + ]; + } + + /** + * @param list $firstBatch + * @param list $nextBatch + */ + private static function adapter(array $firstBatch, array $nextBatch = []): Mongo + { + $client = new class ($firstBatch, $nextBatch) extends Client { + /** + * @param list $firstBatch + * @param list $nextBatch + */ + public function __construct(private readonly array $firstBatch, private readonly array $nextBatch) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => $this->firstBatch, 'id' => $this->nextBatch === [] ? 0 : 1]]; + } + + #[\Override] + public function getMore(int $cursorId, string $collection, int $batchSize = 25): stdClass + { + return (object) ['cursor' => (object) ['nextBatch' => $this->nextBatch, 'id' => 0]]; + } + }; + + $adapter = new Mongo($client); + $adapter->setAuthorization(new Authorization()); + $adapter->setNamespace('lenient'); + + return $adapter; + } +} diff --git a/tests/unit/MongoNameFilterTest.php b/tests/unit/MongoNameFilterTest.php new file mode 100644 index 0000000000..5b45cb8d01 --- /dev/null +++ b/tests/unit/MongoNameFilterTest.php @@ -0,0 +1,147 @@ +createCollection('audit.logs'); + + $this->assertTrue($adapter->collectionExists('app', 'audit.logs')); + } + + public function testCollectionWithNulByteInItsIdExistsAfterCreation(): void + { + $adapter = self::adapter(self::client()); + + $adapter->createCollection("audit\0logs"); + + $this->assertTrue($adapter->collectionExists('app', "audit\0logs")); + } + + public function testCollectionThatWasNeverCreatedDoesNotExist(): void + { + $adapter = self::adapter(self::client()); + + $adapter->createCollection('audit.logs'); + + $this->assertFalse($adapter->collectionExists('app', 'audit.events')); + } + + public function testDeleteDropsTheDatabaseThatSetDatabaseSelects(): void + { + /** @var ArrayObject $droppedDatabases */ + $droppedDatabases = new ArrayObject(); + $adapter = self::adapter(self::client($droppedDatabases)); + $adapter->setDatabase("tenant.data\0"); + + $this->assertTrue($adapter->delete("tenant.data\0")); + $this->assertSame([$adapter->getDatabase()], $droppedDatabases->getArrayCopy()); + $this->assertSame(['tenantdata'], $droppedDatabases->getArrayCopy()); + } + + public function testDeleteKeepsAnAlreadyValidDatabaseName(): void + { + /** @var ArrayObject $droppedDatabases */ + $droppedDatabases = new ArrayObject(); + $adapter = self::adapter(self::client($droppedDatabases)); + + $adapter->delete('tenant_data-1'); + + $this->assertSame(['tenant_data-1'], $droppedDatabases->getArrayCopy()); + } + + private static function adapter(Client $client): Mongo + { + $adapter = new Mongo($client); + $adapter->setAuthorization(new Authorization()); + $adapter->setNamespace('names'); + + return $adapter; + } + + /** + * @param ArrayObject $droppedDatabases + */ + private static function client(ArrayObject $droppedDatabases = new ArrayObject()): Client + { + return new class ($droppedDatabases) extends Client { + /** @var array */ + private array $collections = []; + + /** + * @param ArrayObject $droppedDatabases + */ + public function __construct(private readonly ArrayObject $droppedDatabases) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $options + */ + #[\Override] + public function createCollection(string $name, array $options = []): bool + { + $this->collections[$name] = true; + + return true; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + return true; + } + + /** + * @param array $options + */ + #[\Override] + public function dropDatabase(array $options = [], ?string $db = null): bool + { + $this->droppedDatabases->append($db); + + return true; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass + { + /** @var array{name?: string} $filter */ + $filter = $command['filter'] ?? []; + $name = $filter['name'] ?? null; + $matches = $name !== null && isset($this->collections[$name]) ? [(object) ['name' => $name]] : []; + + return (object) ['cursor' => (object) ['firstBatch' => $matches, 'id' => 0]]; + } + }; + } +} diff --git a/tests/unit/MongoPermissionStringsTest.php b/tests/unit/MongoPermissionStringsTest.php index 25cf69da9a..320d673e46 100644 --- a/tests/unit/MongoPermissionStringsTest.php +++ b/tests/unit/MongoPermissionStringsTest.php @@ -3,10 +3,9 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; -use ReflectionClass; -use ReflectionMethod; -use Utopia\Database\Adapter\Mongo; -use Utopia\Database\Database; +use Utopia\Database\Hook\Mongo\Permission; +use Utopia\Database\PermissionType; +use Utopia\Database\Storage; use Utopia\Database\Validator\Authorization; class MongoPermissionStringsTest extends TestCase @@ -15,17 +14,17 @@ public function testPeriodInRoleIsLiteralNotRegexWildcard(): void { $this->assertSame( ['read("user:alice.")'], - $this->permissionStrings(['user:alice.'], Database::PERMISSION_READ) + $this->permissionStrings(['user:alice.'], PermissionType::Read) ); } public function testMassReadDotPaddingStaysExact(): void { - $role = 'user:a' . \str_repeat('.', 19); + $role = 'user:a'.\str_repeat('.', 19); $this->assertSame( - ['read("' . $role . '")'], - $this->permissionStrings([$role], Database::PERMISSION_READ) + ['read("'.$role.'")'], + $this->permissionStrings([$role], PermissionType::Read) ); } @@ -33,7 +32,7 @@ public function testMatchingIsCaseSensitiveAndUsesRequestedType(): void { $this->assertSame( ['update("user:alice")'], - $this->permissionStrings(['user:alice'], Database::PERMISSION_UPDATE) + $this->permissionStrings(['user:alice'], PermissionType::Update) ); } @@ -41,19 +40,18 @@ public function testMultipleRolesMapToExactPermissionStrings(): void { $this->assertSame( ['read("user:alice")', 'read("users")'], - $this->permissionStrings(['user:alice', 'users'], Database::PERMISSION_READ) + $this->permissionStrings(['user:alice', 'users'], PermissionType::Read) ); } public function testEmptyRolesProduceEmptyList(): void { - $this->assertSame([], $this->permissionStrings([], Database::PERMISSION_READ)); + $this->assertSame([], $this->permissionStrings([], PermissionType::Read)); } public function testValuesAreStringsNotRegex(): void { - foreach ($this->permissionStrings(['user:alice.'], Database::PERMISSION_READ) as $value) { - $this->assertIsString($value); + foreach ($this->permissionStrings(['user:alice.'], PermissionType::Read) as $value) { $this->assertStringStartsWith('read("', $value); $this->assertStringEndsWith('")', $value); } @@ -63,7 +61,7 @@ public function testValuesAreStringsNotRegex(): void * @param list $roles * @return list */ - private function permissionStrings(array $roles, string $type): array + private function permissionStrings(array $roles, PermissionType $type): array { $authorization = new Authorization(); $authorization->enable(); @@ -72,14 +70,25 @@ private function permissionStrings(array $roles, string $type): array $authorization->addRole($role); } - $adapter = (new ReflectionClass(Mongo::class))->newInstanceWithoutConstructor(); - $adapter->setAuthorization($authorization); + $filters = (new Permission($authorization))->applyFilters([], 'documents', $type); + $permissionFilter = $filters[Storage::PERMISSIONS] ?? null; + if (! \is_array($permissionFilter)) { + return []; + } - $method = new ReflectionMethod(Mongo::class, 'permissionStrings'); + $values = $permissionFilter['$in'] ?? []; + if (! \is_array($values)) { + return []; + } - /** @var list $values */ - $values = $method->invoke($adapter, $type); + $strings = []; + foreach ($values as $value) { + if (! \is_string($value)) { + $this->fail('Permission $in values must be strings'); + } + $strings[] = $value; + } - return $values; + return $strings; } } diff --git a/tests/unit/MongoQueryFilterTest.php b/tests/unit/MongoQueryFilterTest.php new file mode 100644 index 0000000000..b3eb7d32be --- /dev/null +++ b/tests/unit/MongoQueryFilterTest.php @@ -0,0 +1,471 @@ +>> + */ + private array $calls = []; + + /** + * @var list + */ + private array $rows = []; + + private ?MongoException $aggregateError = null; + + public function testStartsWithIsAnchoredAtTheStartWithoutFlags(): void + { + $this->find([Query::startsWith('name', 'a.b$')]); + + $this->assertSame( + [['name' => ['$regex' => '^a\.b\$']]], + $this->calls['find'][0]['$and'] ?? null, + 'startsWith must anchor at the start, escape the value and stay case-sensitive', + ); + } + + public function testEndsWithIsAnchoredAtTheEndWithoutFlags(): void + { + $this->find([Query::endsWith('name', 'a.b$')]); + + $this->assertSame( + [['name' => ['$regex' => 'a\.b\$$']]], + $this->calls['find'][0]['$and'] ?? null, + 'endsWith must anchor at the end, escape the value and stay case-sensitive', + ); + } + + public function testContainsAllKeepsTheAllOperatorOnFind(): void + { + $query = Query::containsAll('tags', ['a', 'b']); + $query->setOnArray(true); + + $this->find([$query]); + + $this->assertSame( + [['tags' => ['$all' => ['a', 'b']]]], + $this->calls['find'][0]['$and'] ?? null, + 'find() must send $all as count() does, not the rewritten _all', + ); + } + + public function testCountRethrowsDriverErrors(): void + { + $this->aggregateError = new MongoException('invalid pipeline', 2); + + $this->expectException(MongoException::class); + $this->expectExceptionMessage('invalid pipeline'); + + $this->createAdapter()->count(new Document(['$id' => self::COLLECTION])); + } + + public function testNullTenantIsReadAsTheTenantAttribute(): void + { + $this->rows = [(object) ['_uid' => 'first', '_tenant' => null]]; + + $documents = $this->find([]); + + $this->assertCount(1, $documents); + $stored = $documents[0]->getArrayCopy(); + $this->assertArrayNotHasKey('_tenant', $stored, 'A null _tenant must not leak as a storage key'); + $this->assertArrayHasKey('$tenant', $stored); + $this->assertNull($stored['$tenant']); + } + + public function testUniquePartialFiltersMatchTheAttributeTypes(): void + { + $adapter = $this->createAdapter(); + $types = [ + 'count' => ColumnType::Integer->value, + 'total' => ColumnType::BigInteger->value, + 'price' => ColumnType::Float->value, + 'active' => ColumnType::Boolean->value, + 'seenAt' => ColumnType::Datetime->value, + 'name' => ColumnType::String->value, + ]; + + foreach (\array_keys($types) as $attribute) { + $adapter->createIndex(self::COLLECTION, Index::unique(key: $attribute.'_key', attributes: [$attribute]), $types); + } + $adapter->createIndex(self::COLLECTION, Index::unique(key: 'name_count', attributes: ['name', 'count']), $types); + + $this->assertSame( + [ + 'count_key' => ['count' => ['$exists' => true, '$type' => ['int', 'long']]], + 'total_key' => ['total' => ['$exists' => true, '$type' => ['int', 'long']]], + 'price_key' => ['price' => ['$exists' => true, '$type' => ['double', 'int', 'long']]], + 'active_key' => ['active' => ['$exists' => true, '$type' => 'bool']], + 'seenAt_key' => ['seenAt' => ['$exists' => true, '$type' => 'date']], + 'name_key' => ['name' => ['$exists' => true, '$type' => 'string']], + 'name_count' => [ + 'name' => ['$exists' => true, '$type' => 'string'], + 'count' => ['$exists' => true, '$type' => ['int', 'long']], + ], + ], + $this->partialFilters(), + ); + } + + public function testKeyIndexPartialFiltersRequireOnlyThatTheLeadingFieldExists(): void + { + $adapter = $this->createAdapter(); + $types = [ + 'count' => ColumnType::Integer->value, + 'price' => ColumnType::Float->value, + 'active' => ColumnType::Boolean->value, + 'seenAt' => ColumnType::Datetime->value, + 'name' => ColumnType::String->value, + ]; + + $adapter->createIndex(self::COLLECTION, Index::key(key: 'count_key', attributes: ['count']), $types); + $adapter->createIndex(self::COLLECTION, Index::key(key: 'name_seen', attributes: ['name', 'seenAt']), $types); + $adapter->createCollection( + 'created', + [ + Attribute::integer(key: 'count'), + Attribute::double(key: 'price'), + Attribute::boolean(key: 'active'), + ], + [ + Index::key(key: 'price_active', attributes: ['price', 'active']), + Index::unique(key: 'count_unique', attributes: ['count']), + ], + ); + + $this->assertSame( + [ + 'count_key' => ['count' => ['$exists' => true]], + 'name_seen' => ['name' => ['$exists' => true]], + '_uid' => null, + '_createdAt' => null, + '_updatedAt' => null, + '_permissions' => null, + 'price_active' => ['price' => ['$exists' => true]], + 'count_unique' => ['count' => ['$exists' => true, '$type' => ['int', 'long']]], + ], + $this->partialFilters(), + 'A key index must be usable by any filter on a value of its leading field, which implies $exists but never $type', + ); + } + + public function testUniqueIndexOnAnIntegerFiltersOnTheIntegerTypes(): void + { + $this->createAdapter()->createIndex( + self::COLLECTION, + Index::unique(key: 'count_unique', attributes: ['count']), + ['count' => ColumnType::Integer->value], + ); + + $this->assertSame( + ['count_unique' => ['count' => ['$exists' => true, '$type' => ['int', 'long']]]], + $this->partialFilters(), + ); + $this->assertTrue($this->calls['createIndexes'][0]['unique'] ?? false); + } + + public function testUniqueIndexOnAnUntypedAttributeCoversEveryValueButNull(): void + { + $any = ['$exists' => true, '$type' => [ + 'double', + 'string', + 'object', + 'array', + 'binData', + 'objectId', + 'bool', + 'date', + 'regex', + 'javascript', + 'int', + 'timestamp', + 'long', + 'decimal', + 'minKey', + 'maxKey', + ]]; + $adapter = $this->createAdapter(); + + $adapter->createIndex(self::COLLECTION, Index::unique(key: 'created_unique', attributes: ['$createdAt']), ['name' => ColumnType::String->value]); + $adapter->createIndex(self::COLLECTION, Index::unique(key: 'free_unique', attributes: ['free'])); + $adapter->createCollection('created', [], [Index::unique(key: 'free_name', attributes: ['free', 'name'])]); + + $filters = $this->partialFilters(); + + $this->assertSame(['_createdAt' => $any], $filters['created_unique'] ?? null, 'An internal attribute has no declared type, so its unique index must cover every stored type'); + $this->assertSame(['free' => $any], $filters['free_unique'] ?? null, 'A schemaless attribute has no declared type, so its unique index must cover every stored type'); + $this->assertSame(['free' => $any, 'name' => $any], $filters['free_name'] ?? null, 'createCollection() must map an undeclared attribute as createIndex() does'); + } + + public function testDottedAttributesAreRenamedAndDeletedByTheirStoredName(): void + { + $adapter = $this->createAdapter(); + + $adapter->renameAttribute(self::COLLECTION, 'a.b', 'c.d'); + $adapter->deleteAttribute(self::COLLECTION, 'a.b'); + + $this->assertSame( + [ + ['$rename' => ['a__dot__b' => 'c__dot__d']], + ['$unset' => ['a__dot__b' => '']], + ], + $this->calls['update'] ?? [], + 'Renames and deletes must address the __dot__ field name that documents are stored under', + ); + } + + public function testSumEscapesDottedAttributes(): void + { + $collection = new Document([ + '$id' => self::COLLECTION, + 'attributes' => [ + new Document(['$id' => 'a.b', 'key' => 'a.b', 'type' => ColumnType::Integer->value]), + ], + ]); + + $this->createAdapter()->sum($collection, 'a.b', [Query::equal('a.b', [1])]); + + $this->assertSame( + [ + ['$match' => ['$and' => [['a__dot__b' => ['$eq' => 1]]]]], + ['$group' => ['_id' => null, 'total' => ['$sum' => '$a__dot__b']]], + ], + $this->calls['aggregate'][0] ?? null, + 'sum() must filter and sum the stored field name of a dotted attribute, as find() and count() do', + ); + } + + public function testDottedAttributesInsideGroupsAndExistsAreEscaped(): void + { + $collection = new Document([ + '$id' => self::COLLECTION, + 'attributes' => [ + new Document(['$id' => 'a.b', 'key' => 'a.b', 'type' => ColumnType::Integer->value]), + ], + ]); + $queries = [ + Query::or([Query::equal('a.b', [1]), Query::and([Query::greaterThan('a.b', 5), Query::lessThan('a.b', 9)])]), + Query::exists(['a.b']), + Query::notExists(['a.b']), + ]; + + $adapter = $this->createAdapter(); + $adapter->find($collection, $queries); + $adapter->count($collection, $queries); + + $this->assertSame(['a__dot__b'], $this->fieldNames($this->calls['find'][0] ?? []), 'find() must address the stored field name of a dotted attribute in every group and exists'); + $this->assertSame(['a__dot__b'], $this->fieldNames($this->calls['aggregate'][0] ?? []), 'count() must address the stored field name of a dotted attribute in every group and exists'); + } + + public function testRandomOrderIsRejectedAsAQueryError(): void + { + $this->expectException(QueryException::class); + + $this->createAdapter()->find( + new Document(['$id' => self::COLLECTION]), + orderAttributes: [''], + orderTypes: [OrderDirection::Random], + ); + } + + public function testDocumentsValidatorRejectsRandomOrderWithoutTheCapability(): void + { + $unsupported = new Documents([], [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); + + $this->assertFalse($unsupported->isValid([Query::orderRandom()])); + $this->assertStringContainsString('Random order is not supported', $unsupported->getDescription()); + + $supported = new Documents([], [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); + + $this->assertTrue($supported->isValid([Query::orderRandom()]), $supported->getDescription()); + } + + /** + * @param array $queries + * @return array + */ + private function find(array $queries): array + { + return $this->createAdapter()->find(new Document(['$id' => self::COLLECTION]), $queries); + } + + /** + * @param array $filter + * @return list + */ + private function fieldNames(array $filter): array + { + $names = []; + foreach ($filter as $key => $value) { + if (\is_string($key) && $key !== '' && $key[0] !== '$' && $key !== '_permissions') { + $names[] = $key; + } + if ($value instanceof stdClass) { + $value = (array) $value; + } + if (\is_array($value)) { + \array_push($names, ...$this->fieldNames($value)); + } + } + + return \array_values(\array_unique($names)); + } + + /** + * @return array + */ + private function partialFilters(): array + { + $filters = []; + foreach ($this->calls['createIndexes'] ?? [] as $index) { + if (\is_string($index['name'] ?? null)) { + $filters[$index['name']] = $index['partialFilterExpression'] ?? null; + } + } + + return $filters; + } + + private function createAdapter(): Mongo + { + $record = function (string $call, array $payload): void { + $this->calls[$call][] = $payload; + }; + + $client = new class ($record, $this->rows, $this->aggregateError) extends Client { + /** + * @var list + */ + private array $created = []; + + /** + * @param Closure(string, array): void $record + * @param list $rows + */ + public function __construct( + private readonly Closure $record, + private readonly array $rows, + private readonly ?MongoException $aggregateError, + ) { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $options + */ + #[\Override] + public function createCollection(string $name, array $options = []): bool + { + return true; + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + ($this->record)('find', $filters); + + return (object) ['cursor' => (object) ['firstBatch' => $this->rows, 'id' => 0]]; + } + + /** + * @param array $pipeline + * @param array $options + */ + #[\Override] + public function aggregate(string $collection, array $pipeline, array $options = []): stdClass + { + if ($this->aggregateError !== null) { + throw $this->aggregateError; + } + + ($this->record)('aggregate', $pipeline); + + return (object) ['cursor' => (object) ['firstBatch' => []]]; + } + + /** + * @param array $where + * @param array $updates + * @param array $options + */ + #[\Override] + public function update(string $collection, array $where = [], array $updates = [], array $options = [], bool $multi = false): int + { + ($this->record)('update', $updates); + + return 0; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + foreach ($indexes as $index) { + if (\is_array($index)) { + ($this->record)('createIndexes', $index); + $this->created[] = (object) ['name' => $index['name'] ?? null]; + } + } + + return true; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => $this->created]]; + } + }; + + $authorization = new Authorization(); + $authorization->disable(); + + $adapter = new Mongo($client); + $adapter->setAuthorization($authorization); + $adapter->setNamespace('query_filter'); + + return $adapter; + } +} diff --git a/tests/unit/MongoResultDecodingTest.php b/tests/unit/MongoResultDecodingTest.php new file mode 100644 index 0000000000..7336034b91 --- /dev/null +++ b/tests/unit/MongoResultDecodingTest.php @@ -0,0 +1,459 @@ + $record + * @return array + */ + public function restore(array $record): array + { + return $this->replaceCharacters('_', '$', $record); + } + }; + + $restored = $adapter->restore([ + '_uid' => 'movie1', + '_id' => '17', + '_permissions' => ['read("any")', 'update("user:1")'], + '_createdAt' => '2026-01-01 00:00:00.000', + 'tags' => ['t1', 't2'], + 'profile__dot__name' => 'Ann', + 'matrix' => [['_uid' => 'nested', 'a__dot__b' => 1], ['x', 'y']], + ]); + + $this->assertSame([ + 'tags' => ['t1', 't2'], + 'matrix' => [['a.b' => 1, '$id' => 'nested'], ['x', 'y']], + '$permissions' => ['read("any")', 'update("user:1")'], + '$createdAt' => '2026-01-01 00:00:00.000', + 'profile.name' => 'Ann', + '$sequence' => '17', + '$id' => 'movie1', + ], $restored); + } + + public function testDocumentKeysAreStored(): void + { + $adapter = new class () extends Mongo { + public function __construct() + { + } + + /** + * @param array $document + * @return array + */ + public function store(array $document): array + { + return $this->replaceCharacters('$', '_', $document); + } + }; + + $stored = $adapter->store([ + '$id' => 'movie1', + '$permissions' => ['read("any")'], + 'tags' => ['a.b', '$c'], + 'profile.name' => 'Ann', + '$custom' => 'value', + ]); + + $this->assertSame([ + 'tags' => ['a.b', '$c'], + '_permissions' => ['read("any")'], + 'profile__dot__name' => 'Ann', + '_custom' => 'value', + '_uid' => 'movie1', + ], $stored); + } + + public function testCastAfterCastsCollectionAndInternalAttributes(): void + { + $adapter = new class () extends Mongo { + public function __construct() + { + } + }; + + $collection = new Document([ + '$id' => 'movies', + 'attributes' => [ + new Document(['$id' => 'score', 'key' => 'score', 'type' => ColumnType::Integer->value, 'array' => false]), + new Document(['$id' => 'price', 'key' => 'price', 'type' => ColumnType::Float->value, 'array' => false]), + new Document(['$id' => 'active', 'key' => 'active', 'type' => ColumnType::Boolean->value, 'array' => false]), + new Document(['$id' => 'tags', 'key' => 'tags', 'type' => ColumnType::String->value, 'array' => true]), + ], + ]); + + foreach ([['42', 42], ['7', 7]] as [$stored, $expected]) { + $document = $adapter->castAfter($collection, [new Document([ + '$id' => 'movie1', + '$sequence' => 5, + '$permissions' => ['read("any")'], + 'score' => $stored, + 'price' => 3, + 'active' => 1, + 'tags' => ['a', 'b'], + ])])[0]; + + $this->assertSame([ + '$id' => 'movie1', + '$sequence' => '5', + '$permissions' => ['read("any")'], + 'score' => $expected, + 'price' => 3.0, + 'active' => true, + 'tags' => ['a', 'b'], + ], $document->getArrayCopy()); + } + } + + public function testCastAfterRewritesOnlyTheValuesItChanges(): void + { + $adapter = $this->castingAdapter(); + $typed = $adapter->castAfter($this->castingCollection(), [new SetRecordingDocument([ + '$id' => 'movie1', + '$sequence' => '5', + '$permissions' => ['read("any")'], + 'score' => 7, + 'price' => 1.5, + 'active' => false, + 'tags' => ['a'], + 'name' => 'n', + ])])[0]; + + $this->assertInstanceOf(SetRecordingDocument::class, $typed); + $this->assertSame(['$permissions'], $typed->sets); + + $stored = $adapter->castAfter($this->castingCollection(), [new SetRecordingDocument([ + '$id' => 'movie2', + '$sequence' => 6, + 'score' => '42', + 'price' => 1.5, + 'tags' => 5, + 'name' => 12, + ])])[0]; + + $this->assertInstanceOf(SetRecordingDocument::class, $stored); + $this->assertSame(['$sequence', 'name', 'score', 'tags'], $this->sorted($stored->sets)); + $this->assertSame(['$id' => 'movie2', '$sequence' => '6', 'score' => 42, 'price' => 1.5, 'tags' => ['5'], 'name' => '12'], $stored->getArrayCopy()); + } + + public function testCastAfterNormalisesPermissionsWrittenPastSetAttribute(): void + { + $document = new Document(['$id' => 'movie1']); + $document->offsetSet('$permissions', ['read("any")', 'read("any")', 'update("any")']); + + $cast = $this->castingAdapter()->castAfter($this->castingCollection(), [$document])[0]; + + $this->assertSame(['read("any")', 'update("any")'], $cast->getArrayCopy()['$permissions']); + } + + /** + * @return iterable + */ + public static function castingDocuments(): iterable + { + $object = new stdClass(); + $object->a = 1; + $object->b = (object) ['c' => [2, 3]]; + + yield 'typed' => [new Document(['$id' => 'd1', '$sequence' => '1', '$permissions' => ['read("any")'], 'score' => 7, 'price' => 1.5, 'active' => true, 'tags' => ['a', 'b'], 'name' => 'n'])]; + yield 'stored as other types' => [new Document(['$id' => 'd2', '$sequence' => 2, 'score' => '42', 'price' => 3, 'active' => 0, 'tags' => 5, 'name' => 12, 'ratio' => '0.25'])]; + yield 'json list' => [new Document(['$id' => 'd3', 'tags' => '["x","y"]', 'scores' => '[1,"2",3.0]'])]; + yield 'numbers in lists' => [new Document(['$id' => 'd4', 'scores' => ['1', 2, 'x', 4.7], 'flags' => [0, 1, 'yes', '']])]; + yield 'object' => [new Document(['$id' => 'd5', 'meta' => $object, 'free' => $object])]; + yield 'datetime strings' => [new Document(['$id' => 'd6', '$createdAt' => '2026-01-01 00:00:00.000', 'when' => 'not a date'])]; + yield 'nulls and operators' => [new Document(['$id' => 'd7', 'score' => null, 'price' => Operator::increment(2), 'tags' => null])]; + yield 'empty' => [new Document()]; + yield 'internal only' => [new Document(['$id' => 'd8', '$tenant' => 3, '$collection' => 'movies'])]; + + if (\class_exists(Int64::class)) { + yield 'bson values' => [new Document([ + '$id' => 'd9', + '$sequence' => new Int64('9'), + '$createdAt' => new UTCDateTime(1760405478290), + 'score' => new Int64('12'), + 'scores' => [new Int64('1'), 2], + 'when' => new UTCDateTime(0), + 'free' => new UTCDateTime(1000), + ])]; + } + } + + #[DataProvider('castingDocuments')] + public function testCastAfterCastsABatchAsItCastsOneDocument(Document $document): void + { + $collection = $this->castingCollection(); + $one = $this->castingAdapter(defined: false); + $many = $this->castingAdapter(defined: false); + $expected = $one->castAfter($collection, [clone $document])[0]->getArrayCopy(); + + $documents = $many->castAfter($collection, ['first' => clone $document, 7 => clone $document]); + + $this->assertSame(['first', 7], \array_keys($documents)); + $this->assertSame($expected, $documents['first']->getArrayCopy()); + $this->assertSame($expected, $documents[7]->getArrayCopy()); + $this->assertSame([], $many->castAfter($collection, [])); + } + + #[DataProvider('castingDocuments')] + public function testCastAfterIsUnchangedForDefinedAttributes(Document $document): void + { + $collection = $this->castingCollection(); + $expected = $this->referenceCastAfter($collection, clone $document)->getArrayCopy(); + + $this->assertSame($expected, $this->castingAdapter()->castAfter($collection, [clone $document])[0]->getArrayCopy()); + } + + public function testCastAfterRefusesAStoredAttributeOfAnUnknownType(): void + { + $collection = $this->castingCollection(); + $collection->setAttribute('attributes', ['$id' => 'unknown', 'type' => 'no such type', 'array' => false], SetType::Append); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: no such type'); + + $this->castingAdapter()->castAfter($collection, [new Document(['$id' => 'm1', 'unknown' => 'value'])]); + } + + private function castingCollection(): Document + { + return new Document([ + '$id' => 'movies', + 'attributes' => [ + new Document(['$id' => 'score', 'key' => 'score', 'type' => ColumnType::Integer->value, 'array' => false]), + new Document(['$id' => 'scores', 'key' => 'scores', 'type' => ColumnType::BigInteger->value, 'array' => true]), + new Document(['$id' => 'price', 'key' => 'price', 'type' => ColumnType::Float->value, 'array' => false]), + new Document(['$id' => 'ratio', 'key' => 'ratio', 'type' => ColumnType::Double->value, 'array' => false]), + new Document(['$id' => 'active', 'key' => 'active', 'type' => ColumnType::Boolean->value, 'array' => false]), + new Document(['$id' => 'flags', 'key' => 'flags', 'type' => ColumnType::Boolean->value, 'array' => true]), + new Document(['$id' => 'tags', 'key' => 'tags', 'type' => ColumnType::String->value, 'array' => true]), + new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, 'array' => false]), + new Document(['$id' => 'meta', 'key' => 'meta', 'type' => ColumnType::Object->value, 'array' => false]), + new Document(['$id' => 'when', 'key' => 'when', 'type' => ColumnType::Datetime->value, 'array' => false]), + ['$id' => 'legacy', 'type' => 'bigint', 'array' => false], + ], + ]); + } + + private function castingAdapter(bool $defined = true): Mongo + { + $adapter = new class () extends Mongo { + public function __construct() + { + } + }; + $adapter->setSchemaless(! $defined); + + return $adapter; + } + + /** + * castAfter() as it was before it skipped unchanged values and cast many documents at once. + */ + private function referenceCastAfter(Document $collection, Document $document): Document + { + if ($document->isEmpty()) { + return $document; + } + + /** @var array> $attributes */ + $attributes = $collection->getAttribute('attributes', []); + $internal = \array_map( + fn (Attribute $attribute): array => ['$id' => $attribute->key, 'type' => $attribute->type, 'array' => $attribute->array], + Database::internalAttributesFor(true) + ); + + foreach (\array_merge($attributes, $internal) as $attribute) { + $key = \is_string($attribute['$id'] ?? null) ? $attribute['$id'] : ''; + $rawType = $attribute['type'] ?? null; + $type = $rawType instanceof ColumnType ? $rawType : (\is_string($rawType) ? Attribute::typeFromStored($rawType) : null); + $array = (bool) ($attribute['array'] ?? false); + $value = $document->getAttribute($key); + if ($value === null || Operator::isOperator($value)) { + continue; + } + + if ($array) { + if (\is_string($value)) { + $value = \json_decode($value, true); + } + if (! \is_array($value)) { + $value = [$value]; + } + } else { + $value = [$value]; + } + + foreach ($value as $index => $node) { + $value[$index] = match ($type) { + ColumnType::BigInteger, ColumnType::Integer => \is_int($node) ? $node : ($node instanceof Int64 ? (int) (string) $node : (\is_numeric($node) ? (int) $node : 0)), + ColumnType::String, ColumnType::Id => \is_string($node) ? $node : (\is_scalar($node) ? (string) $node : $node), + ColumnType::Float, ColumnType::Double => \is_float($node) ? $node : (\is_numeric($node) ? (float) $node : 0.0), + ColumnType::Boolean => \is_scalar($node) ? (bool) $node : $node, + ColumnType::Datetime => $node instanceof UTCDateTime ? DateTime::format($node->toDateTime()) : $node, + ColumnType::Object => $node instanceof stdClass ? \json_decode((string) \json_encode($node), true) : $node, + default => $node, + }; + } + $document->setAttribute($key, $array ? $value : $value[0]); + } + + return $document; + } + + /** + * @param list $keys + * @return list + */ + private function sorted(array $keys): array + { + \sort($keys); + + return $keys; + } + + public function testFoundRecordsTurnNestedObjectsIntoArrays(): void + { + $empty = new stdClass(); + $nestedEmpty = new stdClass(); + $listedEmpty = new stdClass(); + $record = (object) [ + '_uid' => 'movie1', + '_id' => '17', + '_permissions' => ['read("any")'], + 'meta' => (object) ['a' => 1, 'b' => (object) ['c' => [1, (object) ['d' => 'x']]], 'e' => $nestedEmpty], + 'list' => [1, 'two', (object) ['k' => true], [3, $listedEmpty], null], + 'keyed' => ['x' => (object) ['y' => 2], 'z' => 1.5], + 'empty' => $empty, + 'none' => null, + 'score' => 4, + ]; + + $client = new class ([$record]) extends Client { + /** + * @param list $records + */ + public function __construct(private readonly array $records) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => $this->records, 'id' => 0]]; + } + }; + + $adapter = new Mongo($client); + $adapter->setAuthorization(new Authorization()); + $found = $adapter->find(new Document(['$id' => 'movies'])); + + $this->assertCount(1, $found); + $this->assertSame([ + 'meta' => ['a' => 1, 'b' => ['c' => [1, ['d' => 'x']]], 'e' => $nestedEmpty], + 'list' => [1, 'two', ['k' => true], [3, $listedEmpty], null], + 'keyed' => ['x' => ['y' => 2], 'z' => 1.5], + 'empty' => $empty, + 'none' => null, + 'score' => 4, + '$permissions' => ['read("any")'], + '$sequence' => '17', + '$id' => 'movie1', + ], $found[0]->getArrayCopy()); + } + + public function testProjectionSkipsInternalAttributes(): void + { + $projections = []; + $client = new class (function (mixed $projection) use (&$projections): void { + $projections[] = $projection; + }) extends Client { + /** + * @param Closure(mixed): void $record + */ + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + ($this->record)($options['projection'] ?? null); + + return (object) ['cursor' => (object) ['firstBatch' => [], 'id' => 0]]; + } + }; + + $adapter = new Mongo($client); + $adapter->getDocument(new Document(['$id' => 'movies']), 'movie1', [Query::select(['name', '$id', '$createdAt'])]); + + $this->assertSame([[ + 'name' => 1, + '_uid' => 1, + '_id' => 1, + '_createdAt' => 1, + '_updatedAt' => 1, + '_permissions' => 1, + ]], $projections); + } +} diff --git a/tests/unit/MongoSequencesTest.php b/tests/unit/MongoSequencesTest.php new file mode 100644 index 0000000000..5898a6d97b --- /dev/null +++ b/tests/unit/MongoSequencesTest.php @@ -0,0 +1,201 @@ + + */ + private array $rows = []; + + /** + * @var list> + */ + private array $filters = []; + + public function testSequencesResolvePerTenantForTheSameId(): void + { + $adapter = $this->createAdapter(sharedTables: true, tenant: null); + $this->rows = [ + $this->row('shared', 'sequence-one', tenant: 1), + $this->row('shared', 'sequence-two', tenant: 2), + ]; + + [$one, $two] = $adapter->getSequences(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'shared', '$tenant' => 1]), + new Document(['$id' => 'shared', '$tenant' => 2]), + ]); + + $this->assertSame('sequence-one', $one->getSequence()); + $this->assertSame('sequence-two', $two->getSequence()); + } + + public function testDocumentWithoutTenantResolvesUnderTheAdapterTenant(): void + { + $adapter = $this->createAdapter(sharedTables: true, tenant: 3); + $this->rows = [ + $this->row('first', 'sequence-first', tenant: 3), + $this->row('second', 'sequence-second', tenant: 5), + ]; + + [$first, $second] = $adapter->getSequences(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'first']), + new Document(['$id' => 'second', '$tenant' => 5]), + ]); + + $this->assertSame('sequence-first', $first->getSequence()); + $this->assertSame('sequence-second', $second->getSequence()); + } + + public function testSequenceResolvesOnlyWithinTheDocumentTenant(): void + { + $adapter = $this->createAdapter(sharedTables: true, tenant: 1); + $this->rows = [ + $this->row('shared', 'sequence-other', tenant: 2), + ]; + + [$document] = $adapter->getSequences(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'shared', '$tenant' => 1]), + ]); + + $this->assertNull($document->getSequence()); + } + + public function testGeneratedObjectIdIsReadAsItsString(): void + { + $adapter = $this->createAdapter(sharedTables: false, tenant: null); + $objectId = new class () implements Stringable { + #[\Override] + public function __toString(): string + { + return '6553f1c2a4b8e3d2f0c1a9b7'; + } + }; + $this->rows = [ + $this->row('generated', $objectId, tenant: null), + ]; + + [$document] = $adapter->getSequences(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'generated']), + ]); + + $this->assertSame('6553f1c2a4b8e3d2f0c1a9b7', $document->getSequence()); + } + + public function testDocumentsWithSequencesAreNotLookedUp(): void + { + $adapter = $this->createAdapter(sharedTables: false, tenant: null); + + [$document] = $adapter->getSequences(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'known', '$sequence' => 'sequence-known']), + ]); + + $this->assertSame('sequence-known', $document->getSequence()); + $this->assertSame([], $this->filters); + } + + private function row(string $id, string|Stringable $sequence, ?int $tenant): stdClass + { + $row = new stdClass(); + $row->{Storage::UID} = $id; + $row->{Storage::SEQUENCE} = $sequence; + $row->{Storage::TENANT} = $tenant; + $row->name = 'unprojected'; + + return $row; + } + + private function createAdapter(bool $sharedTables, ?int $tenant): Mongo + { + $rows = fn (): array => $this->rows; + $record = function (array $filters): void { + $this->filters[] = $filters; + }; + + $client = new class ($rows, $record) extends Client { + /** + * @param Closure(): list $rows + * @param Closure(array): void $record + */ + public function __construct( + private readonly Closure $rows, + private readonly Closure $record, + ) { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + ($this->record)($filters); + + $projection = \is_array($options['projection'] ?? null) ? $options['projection'] : null; + $batch = []; + foreach (($this->rows)() as $row) { + if (! $this->matches($row, $filters)) { + continue; + } + + $fields = (array) $row; + if ($projection !== null) { + $fields = \array_intersect_key($fields, $projection + [Storage::SEQUENCE => 1]); + } + $batch[] = (object) $fields; + } + + return (object) ['cursor' => (object) ['firstBatch' => $batch, 'id' => 0]]; + } + + /** + * @param array $filters + */ + private function matches(stdClass $row, array $filters): bool + { + foreach ($filters as $field => $condition) { + $candidates = \is_array($condition) && \is_array($condition['$in'] ?? null) + ? $condition['$in'] + : [$condition]; + + if (! \in_array($row->{$field} ?? null, $candidates, true)) { + return false; + } + } + + return true; + } + }; + + $adapter = new Mongo($client); + $adapter->setNamespace('sequences'); + $adapter->setSharedTables($sharedTables); + $adapter->setTenant($tenant); + + return $adapter; + } +} diff --git a/tests/unit/MongoSharedTablesInsertTest.php b/tests/unit/MongoSharedTablesInsertTest.php new file mode 100644 index 0000000000..2c2f6ba580 --- /dev/null +++ b/tests/unit/MongoSharedTablesInsertTest.php @@ -0,0 +1,133 @@ +adapter(sharedTables: true); + $collection = new Document(['$id' => 'users']); + + $adapter->setTenant(1); + $first = $adapter->createDocument($collection, $this->user(1, 'first@tenant', 'first')); + + $adapter->setTenant(2); + $second = $adapter->createDocument($collection, $this->user(2, 'second@tenant', 'second')); + + $this->assertSame(2, $second->getTenant()); + $this->assertSame('second@tenant', $second->getAttribute('email')); + $this->assertSame('second', $second->getAttribute('secret')); + $this->assertNotSame($first->getSequence(), $second->getSequence()); + + $this->assertSame(1, $first->getTenant()); + $this->assertSame('first@tenant', $first->getAttribute('email')); + } + + public function testCreateDocumentReturnsTheCreatedDocumentWithoutSharedTables(): void + { + $adapter = $this->adapter(sharedTables: false); + + $created = $adapter->createDocument(new Document(['$id' => 'users']), new Document([ + '$id' => 'alice', + '$permissions' => [], + 'email' => 'only@tenant', + ])); + + $this->assertSame('only@tenant', $created->getAttribute('email')); + $this->assertNotSame('', $created->getSequence()); + } + + private function user(int $tenant, string $email, string $secret): Document + { + return new Document([ + '$id' => 'alice', + '$tenant' => $tenant, + '$permissions' => [], + 'email' => $email, + 'secret' => $secret, + ]); + } + + private function adapter(bool $sharedTables): Mongo + { + $client = new class () extends Client { + /** + * @var array>> + */ + private array $rows = []; + + public function __construct() + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + #[\Override] + public function isReplicaSet(): bool + { + return false; + } + + /** + * @param array $document + * @param array $options + * @return array + */ + #[\Override] + public function insert(string $collection, array $document, array $options = []): array + { + $document['_id'] ??= $this->createUuid(); + $this->rows[$collection][] = $document; + + return $document; + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + $matches = []; + foreach ($this->rows[$collection] ?? [] as $row) { + foreach ($filters as $field => $value) { + if (($row[$field] ?? null) !== $value) { + continue 2; + } + } + $matches[] = (object) $row; + } + + return (object) ['cursor' => (object) ['firstBatch' => $matches, 'id' => 0]]; + } + }; + + $authorization = new Authorization(); + $authorization->disable(); + + $adapter = new Mongo($client); + $adapter->setAuthorization($authorization); + $adapter->setNamespace('tenants'); + $adapter->setSharedTables($sharedTables); + + return $adapter; + } +} diff --git a/tests/unit/MongoSkipDuplicatesTest.php b/tests/unit/MongoSkipDuplicatesTest.php new file mode 100644 index 0000000000..910ae7e8e2 --- /dev/null +++ b/tests/unit/MongoSkipDuplicatesTest.php @@ -0,0 +1,281 @@ +row('stored', 'sequence-stored', tenant: null)]); + $adapter = $this->createAdapter($rows, sharedTables: false); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'stored', 'name' => 'replayed']), + new Document(['$id' => 'fresh', 'name' => 'first']), + new Document(['$id' => 'fresh', 'name' => 'second']), + ])); + + $this->assertSame(['fresh'], $this->ids($created)); + $this->assertSame('first', $created[0]->getAttribute('name')); + $this->assertSame(['stored', 'fresh'], $this->storedIds($rows)); + $this->assertSame('first', $rows->getArrayCopy()[1]->name ?? null); + } + + public function testAnIdStoredUnderAnotherTenantIsNew(): void + { + $rows = new ArrayObject([$this->row('shared', 'sequence-one', tenant: 1)]); + $adapter = $this->createAdapter($rows, sharedTables: true); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'shared', '$tenant' => 1, 'name' => 'replayed']), + new Document(['$id' => 'shared', '$tenant' => 2, 'name' => 'new']), + ])); + + $this->assertSame([2], \array_map(static fn (Document $document): int|string|null => $document->getTenant(), $created)); + $this->assertSame(['shared', 'shared'], $this->storedIds($rows)); + } + + public function testAnIdDifferingOnlyInCaseIsStored(): void + { + $rows = new ArrayObject([$this->row('Stored', 'sequence-stored', tenant: null)]); + $adapter = $this->createAdapter($rows, sharedTables: false); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'stored', 'name' => 'replayed']), + new Document(['$id' => 'Fresh', 'name' => 'first']), + new Document(['$id' => 'fresh', 'name' => 'second']), + ])); + + $this->assertSame(['Fresh'], $this->ids($created)); + $this->assertSame(['Stored', 'Fresh'], $this->storedIds($rows)); + } + + public function testAnIdTheIdCollationMatchesIsNotReportedAsCreated(): void + { + $rows = new ArrayObject([$this->row('resume', 'sequence-stored', tenant: null)]); + $adapter = $this->createAdapter($rows, sharedTables: false); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'résumé', 'name' => 'replayed']), + ])); + + $this->assertSame([], $this->ids($created), 'The _uid index collation folds accents, so the upsert matched the stored document and inserted nothing'); + $this->assertSame(['resume'], $this->storedIds($rows)); + } + + public function testADocumentAnotherWriterStoresFirstIsNotReportedAsCreated(): void + { + $rows = new ArrayObject(); + $adapter = $this->createAdapter($rows, sharedTables: false, beforeUpdate: function () use ($rows): void { + $rows->append($this->row('raced', 'sequence-other-writer', tenant: null)); + }); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'raced', 'name' => 'late']), + new Document(['$id' => 'fresh', 'name' => 'new']), + ])); + + $this->assertSame(['fresh'], $this->ids($created), 'A document another writer stored before the upsert ran was matched, not inserted'); + $this->assertSame(['raced', 'fresh'], $this->storedIds($rows)); + } + + public function testAReplayedSequenceIsNotReportedAsCreated(): void + { + $rows = new ArrayObject([$this->row('stored', 'sequence-stored', tenant: null)]); + $adapter = $this->createAdapter($rows, sharedTables: false); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'stored', '$sequence' => 'sequence-stored', 'name' => 'replayed']), + new Document(['$id' => 'moved', '$sequence' => 'sequence-new', 'name' => 'new']), + ])); + + $this->assertSame(['moved'], $this->ids($created)); + $this->assertSame(['stored', 'moved'], $this->storedIds($rows)); + } + + public function testABatchOfStoredIdsWritesNothing(): void + { + $rows = new ArrayObject([$this->row('stored', 'sequence-stored', tenant: null)]); + $adapter = $this->createAdapter($rows, sharedTables: false); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document(['$id' => self::COLLECTION]), [ + new Document(['$id' => 'stored', 'name' => 'replayed']), + ])); + + $this->assertSame([], $created); + $this->assertSame(['stored'], $this->storedIds($rows)); + $this->assertFalse(isset($rows[0]->name), 'A skipped document leaves the stored one untouched'); + } + + private function row(string $id, string $sequence, ?int $tenant): stdClass + { + $row = new stdClass(); + $row->{Storage::UID} = $id; + $row->{Storage::SEQUENCE} = $sequence; + $row->{Storage::TENANT} = $tenant; + + return $row; + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): string => $document->getId(), $documents)); + } + + /** + * @param ArrayObject $rows + * @return list + */ + private function storedIds(ArrayObject $rows): array + { + return \array_values(\array_map(static function (stdClass $row): string { + $id = $row->{Storage::UID}; + self::assertIsString($id); + + return $id; + }, $rows->getArrayCopy())); + } + + /** + * A client that applies an upsert as MongoDB does: a statement whose filter matches a stored document under the + * `_uid` collation (case and accents folded) changes nothing, any other inserts its `$setOnInsert` document. + * + * @param ArrayObject $rows + */ + private function createAdapter(ArrayObject $rows, bool $sharedTables, ?Closure $beforeUpdate = null): Mongo + { + $client = new class ($rows, $beforeUpdate) extends Client { + /** + * @param ArrayObject $rows + */ + public function __construct( + private readonly ArrayObject $rows, + private readonly ?Closure $beforeUpdate, + ) { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $filters + * @param array $options + */ + #[\Override] + public function find(string $collection, array $filters = [], array $options = []): stdClass + { + $collation = $options['collation'] ?? null; + $folded = \is_array($collation) && ($collation['strength'] ?? null) === 1; + $batch = []; + foreach ($this->rows as $row) { + if ($this->matches($row, $filters, $folded)) { + $batch[] = $row; + } + } + + return (object) ['cursor' => (object) ['firstBatch' => $batch, 'id' => 0]]; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): int + { + $updates = $command['updates'] ?? null; + if (! \is_array($updates)) { + throw new LogicException('An upsert command must list its updates'); + } + + if ($this->beforeUpdate !== null) { + ($this->beforeUpdate)(); + } + + foreach ($updates as $update) { + if (! \is_array($update) || ! \is_array($update['q'] ?? null) || ! ($update['u'] ?? null) instanceof stdClass) { + throw new LogicException('An upsert command must hold update documents'); + } + $collation = $update['collation'] ?? null; + if (! \is_array($collation) || ($collation['strength'] ?? null) !== 1) { + throw new LogicException('An upsert by id must use the _uid index collation'); + } + if (! \is_string($update['q'][Storage::UID] ?? null)) { + throw new LogicException('An upsert by id must filter on _uid'); + } + + foreach ($this->rows as $row) { + if ($this->matches($row, $update['q'], true)) { + continue 2; + } + } + + $row = new stdClass(); + foreach ($update['q'] as $field => $value) { + $row->{$field} = $value; + } + foreach ((array) $update['u']->{'$setOnInsert'} as $field => $value) { + $row->{$field} = $value; + } + $this->rows->append($row); + } + + return \count($updates); + } + + /** + * @param array $filters + */ + private function matches(stdClass $row, array $filters, bool $folded): bool + { + $normalize = static fn (mixed $value): mixed => $folded && \is_string($value) + ? \strtolower(\strtr($value, ['é' => 'e', 'É' => 'E'])) + : $value; + foreach ($filters as $field => $condition) { + $candidates = \is_array($condition) && \is_array($condition['$in'] ?? null) + ? $condition['$in'] + : [$condition]; + + if (! \in_array($normalize($row->{$field} ?? null), \array_map($normalize, $candidates), true)) { + return false; + } + } + + return true; + } + }; + + $adapter = new Mongo($client); + $adapter->setNamespace('skip_duplicates'); + $adapter->setSharedTables($sharedTables); + + return $adapter; + } +} diff --git a/tests/unit/MongoTestOverridesTest.php b/tests/unit/MongoTestOverridesTest.php new file mode 100644 index 0000000000..5294208790 --- /dev/null +++ b/tests/unit/MongoTestOverridesTest.php @@ -0,0 +1,62 @@ +}> + */ + public static function mongoTestClasses(): array + { + return [ + 'MongoDB' => [MongoDBTest::class], + 'SharedTables/MongoDB' => [SharedTablesMongoDBTest::class], + 'Schemaless/MongoDB' => [SchemalessMongoDBTest::class], + ]; + } + + /** + * @param class-string $class + */ + #[DataProvider('mongoTestClasses')] + public function testOverridesUseTheNameOfTheTestTheyReplace(string $class): void + { + $inherited = []; + foreach ((new ReflectionClass(Base::class))->getMethods() as $method) { + $inherited[$this->comparable($method->getName())] = $method->getName(); + } + + $phantoms = []; + foreach ((new ReflectionClass($class))->getMethods() as $method) { + $name = $method->getName(); + if ($method->getDeclaringClass()->getName() !== $class || ! \str_starts_with(\strtolower($name), 'test')) { + continue; + } + + $replaced = $inherited[$this->comparable($name)] ?? null; + if ($replaced !== null && \strtolower($replaced) !== \strtolower($name)) { + $phantoms[$name] = $replaced; + } + } + + $this->assertSame( + [], + $phantoms, + "{$class} declares tests that override nothing: PHP method names ignore case but not underscores, so the inherited tests still run", + ); + } + + private function comparable(string $name): string + { + return \strtolower(\str_replace('_', '', $name)); + } +} diff --git a/tests/unit/MongoTransactionRetryTest.php b/tests/unit/MongoTransactionRetryTest.php new file mode 100644 index 0000000000..b1338b7b28 --- /dev/null +++ b/tests/unit/MongoTransactionRetryTest.php @@ -0,0 +1,188 @@ + + */ + public static function deterministicFailures(): array + { + return [ + 'structure' => [new StructureException('Invalid document structure')], + 'not found' => [new NotFoundException('Collection not found')], + 'duplicate' => [new DuplicateException('Document already exists')], + 'timeout' => [new TimeoutException('Query timed out')], + 'database' => [new DatabaseException('Missing ID')], + 'invalid argument' => [new InvalidArgumentException('Invalid argument')], + 'server error' => [new MongoException('BadValue', self::BAD_VALUE)], + ]; + } + + /** + * @return array + */ + public static function transientFailures(): array + { + return [ + 'contention' => [new ContentionException('Write conflict')], + 'transaction' => [new TransactionException('Transaction aborted')], + 'labelled transient' => [new MongoException('Transaction was aborted', 251, null, [self::TRANSIENT_TRANSACTION_ERROR])], + 'write conflict' => [new MongoException('WriteConflict', self::WRITE_CONFLICT)], + 'network error' => [new MongoException('Socket error', self::SOCKET_EXCEPTION)], + 'unsent' => [new UnsentException('Connection to MongoDB has been lost')], + 'failure wrapping a transient error' => [new DatabaseException('Failed to commit transaction', previous: new MongoException('Transaction was aborted', 0, null, [self::TRANSIENT_TRANSACTION_ERROR]))], + ]; + } + + #[DataProvider('deterministicFailures')] + public function testDeterministicFailureRunsOnce(Throwable $failure): void + { + $client = new ReplicaSetClient(); + $adapter = new Mongo($client); + + [$thrown, $attempts] = $this->attempt($adapter, $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(1, $attempts, 'A deterministic failure must not run again'); + $this->assertSame(1, $client->sessions); + $this->assertSame(1, $client->aborts); + $this->assertFalse($adapter->inTransaction()); + } + + #[DataProvider('transientFailures')] + public function testTransientFailureRunsEveryAttempt(Throwable $failure): void + { + $client = new ReplicaSetClient(); + $adapter = new Mongo($client); + + [$thrown, $attempts] = $this->attempt($adapter, $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(self::ATTEMPTS, $attempts); + $this->assertSame(self::ATTEMPTS, $client->sessions); + $this->assertFalse($adapter->inTransaction()); + } + + public function testTransientFailureSucceedsWhenItRunsAgain(): void + { + $client = new ReplicaSetClient(); + $adapter = new Mongo($client); + $attempts = 0; + $stored = \uniqid(); + + $result = $adapter->withTransaction(function () use (&$attempts, $stored): string { + $attempts++; + if ($attempts === 1) { + throw new MongoException('WriteConflict', self::WRITE_CONFLICT, null, [self::TRANSIENT_TRANSACTION_ERROR]); + } + + return $stored; + }); + + $this->assertSame($stored, $result); + $this->assertSame(2, $attempts); + $this->assertSame(1, $client->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A standalone server has no transactions, so withTransaction() runs the callback once and retries nothing: a + * caller that retries on its own must not take a transient failure for one the transaction already retried. + */ + public function testAStandaloneServerRetriesNothing(): void + { + $adapter = new Mongo(new ReplicaSetClient(replicaSet: false)); + $failure = new ContentionException('Write conflict'); + + [$thrown, $attempts] = $this->attempt($adapter, $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(1, $attempts); + $this->assertFalse($adapter->isRetryable($failure)); + $this->assertTrue((new Mongo(new ReplicaSetClient()))->isRetryable($failure)); + } + + /** + * Without savepoints a nested call runs inside the caller's transaction, so only the outermost call decides. + */ + public function testDeterministicFailureInANestedCallRunsOnce(): void + { + $client = new ReplicaSetClient(); + $adapter = new Mongo($client); + $failure = new StructureException('Invalid document structure'); + $outer = 0; + $nested = 0; + + $thrown = null; + try { + $adapter->withTransaction(function () use ($adapter, $failure, &$outer, &$nested): void { + $outer++; + $adapter->withTransaction(function () use ($failure, &$nested): never { + $nested++; + + throw $failure; + }); + }); + } catch (Throwable $error) { + $thrown = $error; + } + + $this->assertSame($failure, $thrown); + $this->assertSame(1, $outer); + $this->assertSame(1, $nested); + $this->assertSame(1, $client->sessions); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * @return array{?Throwable, int} What the transaction threw and how many times it ran the callback + */ + private function attempt(Mongo $adapter, Throwable $failure): array + { + $attempts = 0; + $thrown = null; + + try { + $adapter->withTransaction(function () use ($failure, &$attempts): never { + $attempts++; + + throw $failure; + }); + } catch (Throwable $error) { + $thrown = $error; + } + + return [$thrown, $attempts]; + } +} diff --git a/tests/unit/MongoUniqueIndexWaitTest.php b/tests/unit/MongoUniqueIndexWaitTest.php new file mode 100644 index 0000000000..cbf3b00e98 --- /dev/null +++ b/tests/unit/MongoUniqueIndexWaitTest.php @@ -0,0 +1,104 @@ +adapter(static function () use (&$listings): stdClass { + if (++$listings === 1) { + throw new RuntimeException('the listing timed out'); + } + + return (object) ['cursor' => (object) ['firstBatch' => [(object) ['name' => 'unique_email', 'buildState' => 'ready']]]]; + }); + + $this->assertTrue($adapter->createIndex('users', Index::unique(key: 'unique_email', attributes: ['email']))); + $this->assertSame(2, $listings); + } + + public function testAListingThatKeepsFailingTimesOutWithItsLastError(): void + { + $listings = 0; + $adapter = $this->adapter(static function () use (&$listings): stdClass { + $listings++; + + throw new RuntimeException('the listing timed out'); + }); + + try { + $adapter->createIndex('users', Index::unique(key: 'unique_email', attributes: ['email'])); + $this->fail('an index whose build cannot be confirmed must not be reported as created'); + } catch (DatabaseException $error) { + $this->assertSame('Timeout waiting for index creation: the listing timed out', $error->getMessage()); + $this->assertInstanceOf(RuntimeException::class, $error->getPrevious()); + } + + $this->assertSame(10, $listings); + } + + /** + * @param Closure(): stdClass $listIndexes + */ + private function adapter(Closure $listIndexes): Mongo + { + $client = new class ($listIndexes) extends Client { + /** + * @param Closure(): stdClass $listIndexes + */ + public function __construct(private readonly Closure $listIndexes) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + return true; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass + { + return ($this->listIndexes)(); + } + }; + + $adapter = new Mongo($client); + $adapter->setNamespace('index_wait'); + + return $adapter; + } +} diff --git a/tests/unit/MongoUpsertTenantTest.php b/tests/unit/MongoUpsertTenantTest.php new file mode 100644 index 0000000000..80bf66ccd0 --- /dev/null +++ b/tests/unit/MongoUpsertTenantTest.php @@ -0,0 +1,155 @@ +> + */ + private array $operations = []; + + public function testUpsertStampsAndMatchesTheDocumentsOwnTenant(): void + { + $adapter = $this->createAdapter(sharedTables: true, tenant: 6); + + $adapter->upsertDocuments($this->collection(), [$this->change('shared', tenant: 5)]); + + $this->assertSame(5, $this->filter(0)[Storage::TENANT]); + $this->assertSame(5, $this->set(0)[Storage::TENANT]); + } + + public function testUpsertFallsBackToTheSelectedTenant(): void + { + $adapter = $this->createAdapter(sharedTables: true, tenant: 3); + + $adapter->upsertDocuments($this->collection(), [$this->change('own', tenant: null)]); + + $this->assertSame(3, $this->filter(0)[Storage::TENANT]); + $this->assertSame(3, $this->set(0)[Storage::TENANT]); + } + + public function testUpsertKeepsEachDocumentUnderItsTenant(): void + { + $adapter = $this->createAdapter(sharedTables: true, tenant: null); + + $adapter->upsertDocuments($this->collection(), [ + $this->change('shared', tenant: 1), + $this->change('shared', tenant: 2), + ]); + + $this->assertSame([1, 2], [$this->filter(0)[Storage::TENANT], $this->filter(1)[Storage::TENANT]]); + } + + public function testUpsertWithoutSharedTablesStampsNoTenant(): void + { + $adapter = $this->createAdapter(sharedTables: false, tenant: null); + + $adapter->upsertDocuments($this->collection(), [$this->change('single', tenant: null)]); + + $this->assertArrayNotHasKey(Storage::TENANT, $this->filter(0)); + $this->assertArrayNotHasKey(Storage::TENANT, $this->set(0)); + } + + /** + * @return array + */ + private function filter(int $index): array + { + /** @var array $filter */ + $filter = $this->operations[$index]['filter']; + + return $filter; + } + + /** + * @return array + */ + private function set(int $index): array + { + /** @var array> $update */ + $update = $this->operations[$index]['update']; + + return $update['$set']; + } + + private function collection(): Document + { + return new Document([Document::ID => self::COLLECTION]); + } + + private function change(string $id, ?int $tenant): Change + { + $document = new Document([ + Document::ID => $id, + Document::CREATED_AT => '2026-01-01 00:00:00.000', + Document::UPDATED_AT => '2026-01-01 00:00:00.000', + Document::PERMISSIONS => [], + 'name' => 'renamed', + ]); + + if ($tenant !== null) { + $document->setAttribute(Document::TENANT, $tenant); + } + + return new Change(new Document(), $document); + } + + private function createAdapter(bool $sharedTables, ?int $tenant): Mongo + { + $record = function (array $operations): void { + /** @var array> $typed */ + $typed = $operations; + $this->operations = $typed; + }; + + $client = new class ($record) extends Client { + /** + * @param Closure(array>): void $record + */ + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + /** + * @param array> $operations + * @param array $options + */ + #[\Override] + public function upsert(string $collection, array $operations, array $options = []): int + { + ($this->record)($operations); + + return \count($operations); + } + }; + + $adapter = new Mongo($client); + $adapter->setNamespace('tenants'); + $adapter->setSharedTables($sharedTables); + $adapter->setTenant($tenant); + + return $adapter; + } +} diff --git a/tests/unit/ObjectAttribute/ObjectAttributeValidationTest.php b/tests/unit/ObjectAttribute/ObjectAttributeValidationTest.php new file mode 100644 index 0000000000..720834deb7 --- /dev/null +++ b/tests/unit/ObjectAttribute/ObjectAttributeValidationTest.php @@ -0,0 +1,273 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Objects, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createDocument')->willReturnArgument(1); + $this->adapter->method('updateDocument')->willReturnArgument(2); + $this->adapter->method('createAttribute')->willReturn(true); + $this->adapter->method('getSequences')->willReturnArgument(1); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + private function metaCollection(): Document + { + return new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $attributes + */ + private function makeCollection(string $id, array $attributes = []): Document + { + return new Document([ + '$id' => $id, + '$sequence' => $id, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $collections + */ + private function setupCollections(array $collections): void + { + $meta = $this->metaCollection(); + $map = []; + foreach ($collections as $col) { + $map[$col->getId()] = $col; + } + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($meta, $map) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + if ($col->getId() === Database::METADATA && isset($map[$docId])) { + return $map[$docId]; + } + + return new Document(); + } + ); + $this->adapter->method('updateDocument')->willReturnArgument(2); + } + + public function testObjectAttributeInvalidCases(): void + { + $metaAttr = new Document([ + '$id' => 'meta', 'key' => 'meta', + 'type' => ColumnType::Object->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->makeCollection('objInvalid', [$metaAttr]); + $this->setupCollections([$col]); + + $exceptionThrown = false; + try { + $this->database->createDocument('objInvalid', new Document([ + '$id' => 'invalid1', + '$permissions' => [Permission::read(Role::any())], + 'meta' => 'this is a string not an object', + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for string value'); + + $exceptionThrown = false; + try { + $this->database->createDocument('objInvalid', new Document([ + '$id' => 'invalid2', + '$permissions' => [Permission::read(Role::any())], + 'meta' => 12345, + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for integer value'); + + $exceptionThrown = false; + try { + $this->database->createDocument('objInvalid', new Document([ + '$id' => 'invalid3', + '$permissions' => [Permission::read(Role::any())], + 'meta' => true, + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for boolean value'); + } + + public function testObjectAttributeDefaults(): void + { + $emptyDefault = new Document([ + '$id' => 'metaDefaultEmpty', 'key' => 'metaDefaultEmpty', + 'type' => ColumnType::Object->value, + 'size' => 0, 'required' => false, 'default' => [], + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $settingsDefault = new Document([ + '$id' => 'settings', 'key' => 'settings', + 'type' => ColumnType::Object->value, + 'size' => 0, 'required' => false, 'default' => ['config' => ['theme' => 'light', 'lang' => 'en']], + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $profileRequired = new Document([ + '$id' => 'profile', 'key' => 'profile', + 'type' => ColumnType::Object->value, + 'size' => 0, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $profile2Default = new Document([ + '$id' => 'profile2', 'key' => 'profile2', + 'type' => ColumnType::Object->value, + 'size' => 0, 'required' => false, 'default' => ['name' => 'anon'], + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $miscNull = new Document([ + '$id' => 'misc', 'key' => 'misc', + 'type' => ColumnType::Object->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->makeCollection('objDefaults', [$emptyDefault, $settingsDefault, $profileRequired, $profile2Default, $miscNull]); + $this->setupCollections([$col]); + + $exceptionThrown = false; + try { + $this->database->createDocument('objDefaults', new Document([ + '$id' => 'def1', + '$permissions' => [Permission::read(Role::any())], + ])); + } catch (\Exception $e) { + $exceptionThrown = true; + $this->assertInstanceOf(StructureException::class, $e); + } + $this->assertTrue($exceptionThrown, 'Expected Structure exception for missing required object attribute'); + + $doc = $this->database->createDocument('objDefaults', new Document([ + '$id' => 'def2', + '$permissions' => [Permission::read(Role::any())], + 'profile' => ['name' => 'provided'], + ])); + + $this->assertIsArray($doc->getAttribute('metaDefaultEmpty')); + $this->assertEmpty($doc->getAttribute('metaDefaultEmpty')); + + $settings = $doc->getArray('settings'); + $config = $settings['config'] ?? null; + $this->assertIsArray($config); + $this->assertEquals('light', $config['theme'] ?? null); + + $profile = $doc->getArray('profile'); + $this->assertEquals('provided', $profile['name'] ?? null); + + $profile2 = $doc->getArray('profile2'); + $this->assertEquals('anon', $profile2['name'] ?? null); + + $this->assertNull($doc->getAttribute('misc')); + } +} diff --git a/tests/unit/ObjectFilterTest.php b/tests/unit/ObjectFilterTest.php new file mode 100644 index 0000000000..826724f1d8 --- /dev/null +++ b/tests/unit/ObjectFilterTest.php @@ -0,0 +1,66 @@ + [new Document([ + '$id' => 'meta', + 'type' => ColumnType::Object->value, + 'array' => false, + 'filters' => [ColumnType::Object->value], + ])], + ]); + $document = new Document([ + 'meta' => [ + 'emptyObject' => new \stdClass(), + 'emptyArray' => [], + 'nested' => [new \stdClass(), ['value' => 1]], + ], + ]); + + $encoded = $database->encode($collection, $document); + $this->assertSame('{"emptyObject":{},"emptyArray":[],"nested":[{},{"value":1}]}', $encoded->getAttribute('meta')); + + $decoded = $database->decode($collection, $encoded)->getAttribute('meta'); + $this->assertIsArray($decoded); + $this->assertInstanceOf(\stdClass::class, $decoded['emptyObject']); + $this->assertSame([], $decoded['emptyArray']); + $nested = $decoded['nested']; + $this->assertIsArray($nested); + $this->assertInstanceOf(\stdClass::class, $nested[0]); + $this->assertSame(['value' => 1], $nested[1]); + } + + public function testObjectFilterEncodesTopLevelEmptyObject(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $collection = new Document([ + 'attributes' => [new Document([ + '$id' => 'meta', + 'type' => ColumnType::Object->value, + 'array' => false, + 'filters' => [ColumnType::Object->value], + ])], + ]); + + $encoded = $database->encode($collection, new Document(['meta' => new \stdClass()])); + $this->assertSame('{}', $encoded->getAttribute('meta')); + $this->assertInstanceOf( + \stdClass::class, + $database->decode($collection, $encoded)->getAttribute('meta'), + ); + } +} diff --git a/tests/unit/Operator/OperatorLimitTest.php b/tests/unit/Operator/OperatorLimitTest.php new file mode 100644 index 0000000000..655693d194 --- /dev/null +++ b/tests/unit/Operator/OperatorLimitTest.php @@ -0,0 +1,101 @@ + + */ + public static function fractionalLimits(): array + { + return [ + 'increment max' => [OperatorType::Increment, 'count', 102.4], + 'decrement min' => [OperatorType::Decrement, 'count', -0.5], + 'multiply max' => [OperatorType::Multiply, 'count', 99.9], + 'divide min' => [OperatorType::Divide, 'count', 1.5], + 'power max' => [OperatorType::Power, 'count', 1000.01], + 'numeric string' => [OperatorType::Increment, 'count', '102.4'], + 'big integer' => [OperatorType::Increment, 'big', 4.0e15 + 0.5], + ]; + } + + #[DataProvider('fractionalLimits')] + public function testAFractionalLimitOnAnIntegerAttributeIsRefused(OperatorType $method, string $attribute, int|float|string $limit): void + { + $validator = $this->validator(); + + $this->assertFalse($validator->isValid(new Operator($method, $attribute, [2, $limit]))); + $this->assertSame( + "Cannot apply {$method->value} operator: max/min limit must be a whole number for integer attribute '{$attribute}', got {$limit}", + $validator->getDescription(), + ); + } + + /** + * @return array + */ + public static function wholeLimits(): array + { + return [ + 'integer' => ['count', 100], + 'whole float' => ['count', 100.0], + 'integer string' => ['count', '100'], + 'whole float string' => ['count', '100.0'], + 'big whole float' => ['big', 9.0e18], + 'unsigned whole float beyond the signed range' => ['unsigned', 1.0e19], + 'fractional limit on a double' => ['score', 102.4], + ]; + } + + #[DataProvider('wholeLimits')] + public function testAWholeLimitIsAccepted(string $attribute, int|float|string $limit): void + { + $validator = $this->validator(); + + $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, $attribute, [1, $limit])), $validator->getDescription()); + } + + public function testALimitOutsideTheAttributeRangeIsRefused(): void + { + $validator = $this->validator(); + + $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'big', [1, 1.0e19]))); + $this->assertSame( + 'Cannot apply increment operator: max/min limit must be between -9223372036854775808 and 9223372036854775807', + $validator->getDescription(), + ); + } + + public function testANonNumericLimitIsRefused(): void + { + $validator = $this->validator(); + + $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [1, 'many']))); + $this->assertSame('Cannot apply increment operator: max/min limit must be numeric, got string', $validator->getDescription()); + } + + private function validator(): OperatorValidator + { + return new OperatorValidator(new Document([ + '$id' => 'counters', + '$collection' => Database::METADATA, + 'attributes' => \array_map(static fn (Attribute $attribute): Document => $attribute->toDocument(), [ + Attribute::integer(key: 'count'), + Attribute::bigInteger(key: 'big'), + Attribute::bigInteger(key: 'unsigned', signed: false), + Attribute::double(key: 'score'), + ]), + 'indexes' => [], + ])); + } +} diff --git a/tests/unit/Operator/OperatorValidationTest.php b/tests/unit/Operator/OperatorValidationTest.php new file mode 100644 index 0000000000..47d7843620 --- /dev/null +++ b/tests/unit/Operator/OperatorValidationTest.php @@ -0,0 +1,1645 @@ + $attributes + */ + private function makeCollection(array $attributes): Document + { + $attrDocs = []; + foreach ($attributes as $attr) { + $attrDocs[] = $attr->toDocument(); + } + + return new Document([ + '$id' => 'test_collection', + '$collection' => Database::METADATA, + 'name' => 'test_collection', + 'attributes' => $attrDocs, + 'indexes' => [], + ]); + } + + /** + * @param array $attributes + */ + private function makeValidator(array $attributes, ?Document $currentDoc = null): OperatorValidator + { + return new OperatorValidator($this->makeCollection($attributes), $currentDoc); + } + + /** + * @param array $values + */ + private function makeOperator(OperatorType $method, string $attribute, array $values = []): Operator + { + return new Operator($method, $attribute, $values); + } + + public function testIncrementOnInteger(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'count', [3]); + $this->assertTrue($validator->isValid($op)); + } + + public function testIncrementExceedsMax(): void + { + $currentDoc = new Document(['count' => Database::MAX_INT - 5]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Increment, 'count', [10]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('would overflow', $validator->getDescription()); + } + + public function testDecrementOnInteger(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::Decrement, 'count', [3]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDecrementBelowMin(): void + { + $currentDoc = new Document(['count' => Database::MIN_INT + 5]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Decrement, 'count', [10]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('would underflow', $validator->getDescription()); + } + + public function testMultiplyOnInteger(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Multiply, 'value', [3]); + $this->assertTrue($validator->isValid($op)); + } + + public function testMultiplyOnFloat(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + ]); + + $op = $this->makeOperator(OperatorType::Multiply, 'score', [2.5]); + $this->assertTrue($validator->isValid($op)); + } + + public function testMultiplyViolatesRange(): void + { + $currentDoc = new Document(['value' => Database::MAX_INT]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Multiply, 'value', [2]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('would overflow', $validator->getDescription()); + } + + public function testMultiplyNegative(): void + { + $currentDoc = new Document(['value' => Database::MAX_INT]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Multiply, 'value', [-2]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('would underflow', $validator->getDescription()); + } + + public function testDivideOnInteger(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Divide, 'value', [2]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDivideOnFloat(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + ]); + + $op = $this->makeOperator(OperatorType::Divide, 'score', [3.0]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDivideByZero(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Division by zero is not allowed'); + Operator::divide(0); + } + + public function testDivideByZeroValidator(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Divide, 'value', [0]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('division', $validator->getDescription()); + } + + public function testModuloOnInteger(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Modulo, 'value', [3]); + $this->assertTrue($validator->isValid($op)); + } + + public function testModuloByZero(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Modulo by zero is not allowed'); + Operator::modulo(0); + } + + public function testModuloByZeroValidator(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Modulo, 'value', [0]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('modulo', $validator->getDescription()); + } + + public function testModuloNegative(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Modulo, 'value', [-3]); + $this->assertTrue($validator->isValid($op)); + } + + public function testPowerOnInteger(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Power, 'value', [3]); + $this->assertTrue($validator->isValid($op)); + } + + public function testPowerFractional(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Power, 'value', [0.5]); + $this->assertTrue($validator->isValid($op)); + } + + public function testPowerNegativeExponent(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Power, 'value', [-2]); + $this->assertTrue($validator->isValid($op)); + } + + public function testPowerOverflow(): void + { + $currentDoc = new Document(['value' => 100]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Power, 'value', [10]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('would overflow', $validator->getDescription()); + } + + public function testStringConcat(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'title'), + ]); + + $op = $this->makeOperator(OperatorType::StringConcat, 'title', [' World']); + $this->assertTrue($validator->isValid($op)); + } + + public function testStringConcatExceedsMaxLength(): void + { + $currentDoc = new Document(['title' => str_repeat('a', 95)]); + $validator = $this->makeValidator([ + Attribute::string(key: 'title', size: 100), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::StringConcat, 'title', [str_repeat('b', 10)]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('exceed maximum length', $validator->getDescription()); + } + + public function testStringConcatWithinMaxLength(): void + { + $currentDoc = new Document(['title' => str_repeat('a', 90)]); + $validator = $this->makeValidator([ + Attribute::string(key: 'title', size: 100), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::StringConcat, 'title', [str_repeat('b', 10)]); + $this->assertTrue($validator->isValid($op)); + } + + public function testStringConcatRequiresStringValue(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'title'), + ]); + + $op = $this->makeOperator(OperatorType::StringConcat, 'title', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires a string value', $validator->getDescription()); + } + + public function testStringConcatNonStringValue(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'title'), + ]); + + $op = $this->makeOperator(OperatorType::StringConcat, 'title', [123]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires a string value', $validator->getDescription()); + } + + public function testStringReplace(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'text'), + ]); + + $op = $this->makeOperator(OperatorType::StringReplace, 'text', ['old', 'new']); + $this->assertTrue($validator->isValid($op)); + } + + public function testStringReplaceMultipleOccurrences(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'text'), + ]); + + $op = $this->makeOperator(OperatorType::StringReplace, 'text', ['test', 'demo']); + $this->assertTrue($validator->isValid($op)); + } + + public function testStringReplaceValidation(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'text'), + ]); + + $op = $this->makeOperator(OperatorType::StringReplace, 'text', ['only_search']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires exactly 2 string values', $validator->getDescription()); + } + + public function testStringReplaceWithNonStringValues(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'text'), + ]); + + $op = $this->makeOperator(OperatorType::StringReplace, 'text', [123, 456]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires exactly 2 string values', $validator->getDescription()); + } + + public function testStringReplaceOnNonStringField(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'number'), + ]); + + $op = $this->makeOperator(OperatorType::StringReplace, 'number', ['old', 'new']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-string field', $validator->getDescription()); + } + + public function testToggleBoolean(): void + { + $validator = $this->makeValidator([ + Attribute::boolean(key: 'active'), + ]); + + $op = $this->makeOperator(OperatorType::Toggle, 'active', []); + $this->assertTrue($validator->isValid($op)); + } + + public function testToggleFromDefault(): void + { + $validator = $this->makeValidator([ + Attribute::boolean(key: 'active', default: false), + ]); + + $op = $this->makeOperator(OperatorType::Toggle, 'active', []); + $this->assertTrue($validator->isValid($op)); + } + + public function testToggleOnNonBoolean(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::Toggle, 'count', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-boolean field', $validator->getDescription()); + } + + public function testToggleOnStringField(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::Toggle, 'name', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-boolean field', $validator->getDescription()); + } + + public function testDateAddDays(): void + { + $validator = $this->makeValidator([ + Attribute::datetime(key: 'date'), + ]); + + $op = $this->makeOperator(OperatorType::DateAddDays, 'date', [5]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDateSubDays(): void + { + $validator = $this->makeValidator([ + Attribute::datetime(key: 'date'), + ]); + + $op = $this->makeOperator(OperatorType::DateSubDays, 'date', [3]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDateSetNow(): void + { + $validator = $this->makeValidator([ + Attribute::datetime(key: 'timestamp'), + ]); + + $op = $this->makeOperator(OperatorType::DateSetNow, 'timestamp', []); + $this->assertTrue($validator->isValid($op)); + } + + public function testDateAtYearBoundaries(): void + { + $validator = $this->makeValidator([ + Attribute::datetime(key: 'date'), + ]); + + $op = $this->makeOperator(OperatorType::DateAddDays, 'date', [365]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::DateSubDays, 'date', [365]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::DateAddDays, 'date', [-365]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDateAddDaysOnNonDateField(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::DateAddDays, 'name', [5]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-datetime field', $validator->getDescription()); + } + + public function testDateAddDaysRequiresIntValue(): void + { + $validator = $this->makeValidator([ + Attribute::datetime(key: 'date'), + ]); + + $op = $this->makeOperator(OperatorType::DateAddDays, 'date', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires an integer number of days', $validator->getDescription()); + } + + public function testDateAddDaysNonIntegerValue(): void + { + $validator = $this->makeValidator([ + Attribute::datetime(key: 'date'), + ]); + + $op = $this->makeOperator(OperatorType::DateAddDays, 'date', [3.5]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires an integer number of days', $validator->getDescription()); + } + + public function testDateSetNowOnNonDateField(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::DateSetNow, 'name', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-datetime field', $validator->getDescription()); + } + + public function testArrayAppend(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayAppend, 'tags', ['new', 'items']); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayAppendViolatesConstraints(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayAppend, 'name', ['item']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array field', $validator->getDescription()); + } + + public function testArrayAppendIntegerBounds(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayAppend, 'numbers', [Database::MAX_INT + 1]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('array items must be between', $validator->getDescription()); + } + + public function testArrayPrepend(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayPrepend, 'tags', ['first', 'second']); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayPrependOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayPrepend, 'name', ['item']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array field', $validator->getDescription()); + } + + public function testArrayInsert(): void + { + $currentDoc = new Document(['numbers' => [1, 2, 3]]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::ArrayInsert, 'numbers', [1, 99]); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayInsertAtBoundaries(): void + { + $currentDoc = new Document(['numbers' => [1, 2, 3]]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ], $currentDoc); + + $opStart = $this->makeOperator(OperatorType::ArrayInsert, 'numbers', [0, 0]); + $this->assertTrue($validator->isValid($opStart)); + + $opEnd = $this->makeOperator(OperatorType::ArrayInsert, 'numbers', [3, 4]); + $this->assertTrue($validator->isValid($opEnd)); + } + + public function testArrayInsertOutOfBounds(): void + { + $currentDoc = new Document(['items' => ['a', 'b', 'c']]); + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::ArrayInsert, 'items', [10, 'new']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('index 10 is out of bounds for array of length 3', $validator->getDescription()); + } + + public function testArrayInsertNegativeIndex(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayInsert, 'items', [-1, 'new']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('index must be a non-negative integer', $validator->getDescription()); + } + + public function testArrayInsertMissingValues(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayInsert, 'items', [0]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires exactly 2 values', $validator->getDescription()); + } + + public function testArrayInsertOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayInsert, 'name', [0, 'val']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array field', $validator->getDescription()); + } + + public function testArrayRemove(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayRemove, 'tags', ['unwanted']); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayRemoveOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayRemove, 'name', ['val']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array field', $validator->getDescription()); + } + + public function testArrayRemoveEmptyValues(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayRemove, 'tags', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires a value to remove', $validator->getDescription()); + } + + public function testArrayFilter(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', ['greaterThan', 5]); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayFilterNumeric(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $opGt = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', ['greaterThan', 10]); + $this->assertTrue($validator->isValid($opGt)); + + $opLt = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', ['lessThan', 3]); + $this->assertTrue($validator->isValid($opLt)); + + $opGte = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', ['greaterThanEqual', 5]); + $this->assertTrue($validator->isValid($opGte)); + + $opLte = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', ['lessThanEqual', 5]); + $this->assertTrue($validator->isValid($opLte)); + } + + public function testArrayFilterValidation(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', ['invalidCondition', 5]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('Invalid array filter condition', $validator->getDescription()); + } + + public function testArrayFilterOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'name', ['equal', 'test']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array field', $validator->getDescription()); + } + + public function testArrayFilterEmptyValues(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires 1 or 2 values', $validator->getDescription()); + } + + public function testArrayFilterTooManyValues(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', ['greaterThan', 5, 'extra']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires 1 or 2 values', $validator->getDescription()); + } + + public function testArrayFilterConditionNotString(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'numbers', [123, 5]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('condition must be a string', $validator->getDescription()); + } + + public function testArrayFilterNullConditions(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $opNull = $this->makeOperator(OperatorType::ArrayFilter, 'tags', ['isNull']); + $this->assertTrue($validator->isValid($opNull)); + + $opNotNull = $this->makeOperator(OperatorType::ArrayFilter, 'tags', ['isNotNull']); + $this->assertTrue($validator->isValid($opNotNull)); + } + + public function testArrayDiff(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayDiff, 'tags', ['remove_me', 'and_me']); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayDiffOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayDiff, 'name', ['val']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array attribute', $validator->getDescription()); + } + + public function testArrayIntersect(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayIntersect, 'items', ['a', 'b', 'c']); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayIntersectEmpty(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayIntersect, 'items', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('requires a non-empty array value', $validator->getDescription()); + } + + public function testArrayIntersectOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayIntersect, 'name', ['val']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array attribute', $validator->getDescription()); + } + + public function testArrayUnique(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayUnique, 'items', []); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayUniqueOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $op = $this->makeOperator(OperatorType::ArrayUnique, 'name', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-array field', $validator->getDescription()); + } + + public function testArrayOperationsOnEmpty(): void + { + $currentDoc = new Document(['items' => []]); + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ], $currentDoc); + + $opAppend = $this->makeOperator(OperatorType::ArrayAppend, 'items', ['first']); + $this->assertTrue($validator->isValid($opAppend)); + + $opPrepend = $this->makeOperator(OperatorType::ArrayPrepend, 'items', ['first']); + $this->assertTrue($validator->isValid($opPrepend)); + + $opInsert = $this->makeOperator(OperatorType::ArrayInsert, 'items', [0, 'first']); + $this->assertTrue($validator->isValid($opInsert)); + + $opInsertOOB = $this->makeOperator(OperatorType::ArrayInsert, 'items', [1, 'second']); + $this->assertFalse($validator->isValid($opInsertOOB)); + } + + public function testArrayWithSingleElement(): void + { + $currentDoc = new Document(['items' => ['only']]); + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ], $currentDoc); + + $opInsert0 = $this->makeOperator(OperatorType::ArrayInsert, 'items', [0, 'before']); + $this->assertTrue($validator->isValid($opInsert0)); + + $opInsert1 = $this->makeOperator(OperatorType::ArrayInsert, 'items', [1, 'after']); + $this->assertTrue($validator->isValid($opInsert1)); + + $opInsertOOB = $this->makeOperator(OperatorType::ArrayInsert, 'items', [2, 'oob']); + $this->assertFalse($validator->isValid($opInsertOOB)); + } + + public function testArrayWithNull(): void + { + $currentDoc = new Document(['items' => null]); + $validator = $this->makeValidator([ + Attribute::string(key: 'items', size: 50, array: true), + ], $currentDoc); + + $opAppend = $this->makeOperator(OperatorType::ArrayAppend, 'items', ['first']); + $this->assertTrue($validator->isValid($opAppend)); + } + + public function testIncrementOnFloat(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'score', [1.5]); + $this->assertTrue($validator->isValid($op)); + } + + public function testIncrementWithPreciseFloats(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'score', [0.1]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::Increment, 'score', [PHP_FLOAT_EPSILON]); + $this->assertTrue($validator->isValid($op)); + } + + public function testFloatPrecisionLoss(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'score', [0.000000001]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::Multiply, 'score', [1.0000000001]); + $this->assertTrue($validator->isValid($op)); + } + + public function testSequentialOperators(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + Attribute::double(key: 'score'), + Attribute::string(key: 'name'), + ]); + + $op1 = $this->makeOperator(OperatorType::Increment, 'count', [1]); + $op2 = $this->makeOperator(OperatorType::Multiply, 'score', [2.0]); + $op3 = $this->makeOperator(OperatorType::StringConcat, 'name', [' suffix']); + + $this->assertTrue($validator->isValid($op1)); + $this->assertTrue($validator->isValid($op2)); + $this->assertTrue($validator->isValid($op3)); + } + + public function testComplexScenarios(): void + { + $currentDoc = new Document([ + 'count' => 50, + 'tags' => ['a', 'b', 'c'], + 'name' => 'Hello', + 'active' => false, + 'date' => '2023-01-01 00:00:00', + ]); + + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + Attribute::string(key: 'tags', size: 50, array: true), + Attribute::string(key: 'name'), + Attribute::boolean(key: 'active'), + Attribute::datetime(key: 'date'), + ], $currentDoc); + + $this->assertTrue($validator->isValid($this->makeOperator(OperatorType::Increment, 'count', [10]))); + $this->assertTrue($validator->isValid($this->makeOperator(OperatorType::ArrayAppend, 'tags', ['new']))); + $this->assertTrue($validator->isValid($this->makeOperator(OperatorType::StringConcat, 'name', [' World']))); + $this->assertTrue($validator->isValid($this->makeOperator(OperatorType::Toggle, 'active', []))); + $this->assertTrue($validator->isValid($this->makeOperator(OperatorType::DateAddDays, 'date', [7]))); + } + + public function testErrorHandling(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'nonexistent', [1]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('does not exist', $validator->getDescription()); + } + + public function testNullValueHandling(): void + { + $currentDoc = new Document(['count' => null, 'name' => null]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + Attribute::string(key: 'name', size: 100), + ], $currentDoc); + + $opInc = $this->makeOperator(OperatorType::Increment, 'count', [5]); + $this->assertTrue($validator->isValid($opInc)); + + $opConcat = $this->makeOperator(OperatorType::StringConcat, 'name', ['hello']); + $this->assertTrue($validator->isValid($opConcat)); + } + + public function testValueLimits(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'counter'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'counter', [5, 50]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::Decrement, 'counter', [5, 0]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::Multiply, 'counter', [2, 100]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::Power, 'counter', [3, 1000]); + $this->assertTrue($validator->isValid($op)); + } + + public function testValueLimitsNonNumeric(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'counter'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'counter', [5, 'not_a_number']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('max/min limit must be numeric', $validator->getDescription()); + } + + public function testAttributeConstraints(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $opNumericOnArray = $this->makeOperator(OperatorType::Increment, 'tags', [1]); + $this->assertFalse($validator->isValid($opNumericOnArray)); + + $opArrayOnNumeric = $this->makeOperator(OperatorType::ArrayAppend, 'score', ['val']); + $this->assertFalse($validator->isValid($opArrayOnNumeric)); + } + + public function testEmptyStrings(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'text'), + ]); + + $opConcat = $this->makeOperator(OperatorType::StringConcat, 'text', ['']); + $this->assertTrue($validator->isValid($opConcat)); + + $opReplace = $this->makeOperator(OperatorType::StringReplace, 'text', ['old', '']); + $this->assertTrue($validator->isValid($opReplace)); + } + + public function testExtremeIntegerValues(): void + { + $currentDoc = new Document(['value' => 0]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $opMaxInc = $this->makeOperator(OperatorType::Increment, 'value', [Database::MAX_INT]); + $this->assertTrue($validator->isValid($opMaxInc)); + + $currentDoc2 = new Document(['value' => 1]); + $validator2 = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc2); + + $opOverflow = $this->makeOperator(OperatorType::Increment, 'value', [Database::MAX_INT]); + $this->assertFalse($validator2->isValid($opOverflow)); + } + + public function testUnicodeCharacters(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'text'), + ]); + + $op = $this->makeOperator(OperatorType::StringConcat, 'text', [' mundo']); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::StringReplace, 'text', ['hello', 'hola']); + $this->assertTrue($validator->isValid($op)); + } + + public function testVeryLongStrings(): void + { + $currentDoc = new Document(['text' => '']); + $validator = $this->makeValidator([ + Attribute::string(key: 'text', size: 100), + ], $currentDoc); + + $opFits = $this->makeOperator(OperatorType::StringConcat, 'text', [str_repeat('x', 100)]); + $this->assertTrue($validator->isValid($opFits)); + + $opExceeds = $this->makeOperator(OperatorType::StringConcat, 'text', [str_repeat('x', 101)]); + $this->assertFalse($validator->isValid($opExceeds)); + $this->assertStringContainsString('exceed maximum length', $validator->getDescription()); + } + + public function testZeroValues(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + Attribute::double(key: 'score'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'count', [0]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::Decrement, 'count', [0]); + $this->assertTrue($validator->isValid($op)); + + $op = $this->makeOperator(OperatorType::Multiply, 'score', [0]); + $this->assertTrue($validator->isValid($op)); + } + + public function testBatchOperators(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + Attribute::double(key: 'score'), + Attribute::string(key: 'tags', size: 50, array: true), + Attribute::string(key: 'title'), + Attribute::boolean(key: 'active'), + Attribute::datetime(key: 'date'), + ]); + + $operators = [ + $this->makeOperator(OperatorType::Increment, 'count', [5]), + $this->makeOperator(OperatorType::Multiply, 'score', [2.0]), + $this->makeOperator(OperatorType::ArrayAppend, 'tags', ['new']), + $this->makeOperator(OperatorType::StringConcat, 'title', [' Updated']), + $this->makeOperator(OperatorType::Toggle, 'active', []), + $this->makeOperator(OperatorType::DateSetNow, 'date', []), + ]; + + foreach ($operators as $op) { + $this->assertTrue($validator->isValid($op), "Failed for operator: {$op->getMethod()->value} on {$op->getAttribute()}"); + } + } + + public function testIncrementOnTextAttribute(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'text_field', size: 100), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'text_field', [1]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString("non-numeric field 'text_field'", $validator->getDescription()); + } + + public function testIncrementOnArrayAttribute(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'tags', [1]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-numeric field', $validator->getDescription()); + } + + public function testIncrementOnBooleanAttribute(): void + { + $validator = $this->makeValidator([ + Attribute::boolean(key: 'active'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'active', [1]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-numeric field', $validator->getDescription()); + } + + public function testNumericOperatorNonNumericValue(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'count', ['not_a_number']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('value must be numeric', $validator->getDescription()); + } + + public function testNumericOperatorEmptyValues(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'count', []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('value must be numeric', $validator->getDescription()); + } + + public function testStringConcatOnNonStringField(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::StringConcat, 'count', [' suffix']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-string field', $validator->getDescription()); + } + + public function testStringConcatOnArrayField(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::StringConcat, 'tags', [' suffix']); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-string field', $validator->getDescription()); + } + + public function testArrayInsertIntegerBounds(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'numbers', array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayInsert, 'numbers', [0, Database::MAX_INT + 1]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('array items must be between', $validator->getDescription()); + } + + public function testDecrementUnderflow(): void + { + $currentDoc = new Document(['count' => Database::MIN_INT + 2]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Decrement, 'count', [5]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('would underflow', $validator->getDescription()); + } + + public function testModuloOnFloat(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + ]); + + $op = $this->makeOperator(OperatorType::Modulo, 'score', [3.5]); + $this->assertTrue($validator->isValid($op)); + } + + public function testPowerWithMaxLimit(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Power, 'value', [2, 1000]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDivideWithMinLimit(): void + { + $validator = $this->makeValidator([ + Attribute::double(key: 'value'), + ]); + + $op = $this->makeOperator(OperatorType::Divide, 'value', [2.0, 1.0]); + $this->assertTrue($validator->isValid($op)); + } + + public function testIncrementWithMaxCap(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'counter'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'counter', [100, 50]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDecrementWithMinCap(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'counter'), + ]); + + $op = $this->makeOperator(OperatorType::Decrement, 'counter', [100, 0]); + $this->assertTrue($validator->isValid($op)); + } + + public function testOperatorOnNonexistentAttribute(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + ]); + + $op = $this->makeOperator(OperatorType::Increment, 'nonexistent', [1]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString("'nonexistent' does not exist", $validator->getDescription()); + } + + public function testAllNumericOperatorsOnString(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $numericTypes = [ + OperatorType::Increment, + OperatorType::Decrement, + OperatorType::Multiply, + OperatorType::Divide, + OperatorType::Modulo, + OperatorType::Power, + ]; + + foreach ($numericTypes as $type) { + $op = $this->makeOperator($type, 'name', [1]); + $this->assertFalse($validator->isValid($op), "Expected {$type->value} to fail on string field"); + $this->assertStringContainsString('non-numeric field', $validator->getDescription()); + } + } + + public function testAllArrayOperatorsOnNonArray(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'name'), + ]); + + $opAppend = $this->makeOperator(OperatorType::ArrayAppend, 'name', ['val']); + $this->assertFalse($validator->isValid($opAppend)); + + $opPrepend = $this->makeOperator(OperatorType::ArrayPrepend, 'name', ['val']); + $this->assertFalse($validator->isValid($opPrepend)); + + $opInsert = $this->makeOperator(OperatorType::ArrayInsert, 'name', [0, 'val']); + $this->assertFalse($validator->isValid($opInsert)); + + $opRemove = $this->makeOperator(OperatorType::ArrayRemove, 'name', ['val']); + $this->assertFalse($validator->isValid($opRemove)); + + $opUnique = $this->makeOperator(OperatorType::ArrayUnique, 'name', []); + $this->assertFalse($validator->isValid($opUnique)); + + $opDiff = $this->makeOperator(OperatorType::ArrayDiff, 'name', ['val']); + $this->assertFalse($validator->isValid($opDiff)); + + $opIntersect = $this->makeOperator(OperatorType::ArrayIntersect, 'name', ['val']); + $this->assertFalse($validator->isValid($opIntersect)); + + $opFilter = $this->makeOperator(OperatorType::ArrayFilter, 'name', ['equal', 'val']); + $this->assertFalse($validator->isValid($opFilter)); + } + + public function testDateOperatorsOnNonDateFields(): void + { + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + Attribute::string(key: 'name'), + Attribute::boolean(key: 'active'), + ]); + + foreach (['count', 'name', 'active'] as $field) { + $op = $this->makeOperator(OperatorType::DateAddDays, $field, [5]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-datetime field', $validator->getDescription()); + + $op = $this->makeOperator(OperatorType::DateSubDays, $field, [5]); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-datetime field', $validator->getDescription()); + + $op = $this->makeOperator(OperatorType::DateSetNow, $field, []); + $this->assertFalse($validator->isValid($op)); + $this->assertStringContainsString('non-datetime field', $validator->getDescription()); + } + } + + public function testExtractOperatorsAndValidate(): void + { + $data = [ + 'count' => Operator::increment(5), + 'tags' => Operator::arrayAppend(['new']), + 'name' => 'Regular value', + ]; + + $result = Operator::extractOperators($data); + $this->assertCount(2, $result['operators']); + $this->assertCount(1, $result['updates']); + + $validator = $this->makeValidator([ + Attribute::integer(key: 'count'), + Attribute::string(key: 'tags', size: 50, array: true), + Attribute::string(key: 'name'), + ]); + + foreach ($result['operators'] as $op) { + $this->assertTrue($validator->isValid($op)); + } + } + + public function testOperatorTypeClassificationMethods(): void + { + $this->assertTrue(OperatorType::Increment->isNumeric()); + $this->assertTrue(OperatorType::Decrement->isNumeric()); + $this->assertTrue(OperatorType::Multiply->isNumeric()); + $this->assertTrue(OperatorType::Divide->isNumeric()); + $this->assertTrue(OperatorType::Modulo->isNumeric()); + $this->assertTrue(OperatorType::Power->isNumeric()); + + $this->assertTrue(OperatorType::ArrayAppend->isArray()); + $this->assertTrue(OperatorType::ArrayPrepend->isArray()); + $this->assertTrue(OperatorType::ArrayInsert->isArray()); + $this->assertTrue(OperatorType::ArrayRemove->isArray()); + $this->assertTrue(OperatorType::ArrayUnique->isArray()); + $this->assertTrue(OperatorType::ArrayIntersect->isArray()); + $this->assertTrue(OperatorType::ArrayDiff->isArray()); + $this->assertTrue(OperatorType::ArrayFilter->isArray()); + + $this->assertTrue(OperatorType::StringConcat->isString()); + $this->assertTrue(OperatorType::StringReplace->isString()); + + $this->assertTrue(OperatorType::Toggle->isBoolean()); + + $this->assertTrue(OperatorType::DateAddDays->isDate()); + $this->assertTrue(OperatorType::DateSubDays->isDate()); + $this->assertTrue(OperatorType::DateSetNow->isDate()); + + $this->assertFalse(OperatorType::Increment->isArray()); + $this->assertFalse(OperatorType::ArrayAppend->isNumeric()); + $this->assertFalse(OperatorType::StringConcat->isNumeric()); + $this->assertFalse(OperatorType::Toggle->isNumeric()); + $this->assertFalse(OperatorType::DateAddDays->isNumeric()); + } + + public function testOperatorHelperMethods(): void + { + $inc = Operator::increment(5, 100); + $this->assertEquals(OperatorType::Increment, $inc->getMethod()); + $this->assertEquals([5, 100], $inc->getValues()); + + $dec = Operator::decrement(3, 0); + $this->assertEquals(OperatorType::Decrement, $dec->getMethod()); + $this->assertEquals([3, 0], $dec->getValues()); + + $mul = Operator::multiply(2, 50); + $this->assertEquals(OperatorType::Multiply, $mul->getMethod()); + $this->assertEquals([2, 50], $mul->getValues()); + + $div = Operator::divide(4, 1); + $this->assertEquals(OperatorType::Divide, $div->getMethod()); + $this->assertEquals([4, 1], $div->getValues()); + + $mod = Operator::modulo(7); + $this->assertEquals(OperatorType::Modulo, $mod->getMethod()); + $this->assertEquals([7], $mod->getValues()); + + $pow = Operator::power(3, 999); + $this->assertEquals(OperatorType::Power, $pow->getMethod()); + $this->assertEquals([3, 999], $pow->getValues()); + } + + public function testArrayFilterEqualCondition(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'tags', ['equal', 'active']); + $this->assertTrue($validator->isValid($op)); + } + + public function testArrayFilterNotEqualCondition(): void + { + $validator = $this->makeValidator([ + Attribute::string(key: 'tags', size: 50, array: true), + ]); + + $op = $this->makeOperator(OperatorType::ArrayFilter, 'tags', ['notEqual', 'inactive']); + $this->assertTrue($validator->isValid($op)); + } + + public function testMultiplyByZero(): void + { + $currentDoc = new Document(['value' => 42]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Multiply, 'value', [0]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDecrementFromZero(): void + { + $currentDoc = new Document(['value' => 0]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Decrement, 'value', [1]); + $this->assertTrue($validator->isValid($op)); + } + + public function testIncrementFromMaxMinusOne(): void + { + $currentDoc = new Document(['value' => Database::MAX_INT - 1]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Increment, 'value', [1]); + $this->assertTrue($validator->isValid($op)); + } + + public function testDecrementFromMinPlusOne(): void + { + $currentDoc = new Document(['value' => Database::MIN_INT + 1]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Decrement, 'value', [1]); + $this->assertTrue($validator->isValid($op)); + } + + public function testFloatOperatorsSkipOverflowCheck(): void + { + $currentDoc = new Document(['score' => PHP_FLOAT_MAX / 2]); + $validator = $this->makeValidator([ + Attribute::double(key: 'score'), + ], $currentDoc); + + $op = $this->makeOperator(OperatorType::Increment, 'score', [PHP_FLOAT_MAX / 2]); + $this->assertTrue($validator->isValid($op)); + } + + public function testIntegerOverflowWithMaxCap(): void + { + $currentDoc = new Document(['value' => Database::MAX_INT - 5]); + $validator = $this->makeValidator([ + Attribute::integer(key: 'value'), + ], $currentDoc); + + $opWithCap = $this->makeOperator(OperatorType::Increment, 'value', [100, Database::MAX_INT]); + $this->assertTrue($validator->isValid($opWithCap)); + + $opWithoutCap = $this->makeOperator(OperatorType::Increment, 'value', [100]); + $this->assertFalse($validator->isValid($opWithoutCap)); + } + + public function testNumericOperatorsAcceptEveryNumericColumnType(): void + { + foreach ([ + ColumnType::Integer, + ColumnType::Float, + ColumnType::Double, + ColumnType::BigInteger, + ] as $type) { + $validator = $this->makeValidator([ + Attribute::fromArray(['key' => 'value', 'type' => $type]), + ]); + $operator = $this->makeOperator(OperatorType::Increment, 'value', [1]); + + $this->assertTrue($validator->isValid($operator), $type->value); + } + } + + public function testNumericOperatorAcceptsLegacyBigIntegerMetadata(): void + { + $collection = $this->makeCollection([]); + $collection->setAttribute('attributes', [new Document([ + '$id' => 'value', + 'key' => 'value', + 'type' => 'bigint', + 'signed' => true, + 'array' => false, + ])]); + $validator = new OperatorValidator($collection, new Document(['value' => PHP_INT_MAX - 1])); + + $this->assertTrue($validator->isValid( + $this->makeOperator(OperatorType::Increment, 'value', [1]) + )); + } + + public function testBigIntegerOperatorRejectsOverflow(): void + { + $validator = $this->makeValidator([ + Attribute::bigInteger(key: 'value'), + ], new Document(['value' => PHP_INT_MAX])); + + $this->assertFalse($validator->isValid( + $this->makeOperator(OperatorType::Increment, 'value', [1]) + )); + $this->assertStringContainsString('would overflow', $validator->getDescription()); + } + + public function testUnsignedBigIntegerOperatorRejectsUnderflow(): void + { + $validator = $this->makeValidator([ + Attribute::bigInteger(key: 'value', signed: false), + ], new Document(['value' => 0])); + + $this->assertFalse($validator->isValid( + $this->makeOperator(OperatorType::Decrement, 'value', [1]) + )); + $this->assertStringContainsString('would underflow', $validator->getDescription()); + } + + public function testUnsignedBigIntegerOperatorCrossesPhpIntegerBoundary(): void + { + $validator = $this->makeValidator([ + Attribute::bigInteger(key: 'value', signed: false), + ], new Document(['value' => PHP_INT_MAX])); + + $this->assertTrue($validator->isValid( + $this->makeOperator(OperatorType::Increment, 'value', [1]) + )); + } + + public function testUnsignedBigIntegerOperatorAcceptsAndProtectsExactMaximum(): void + { + $attribute = Attribute::bigInteger(key: 'value', signed: false); + $atBoundary = $this->makeValidator([$attribute], new Document(['value' => '18446744073709551614'])); + $overflow = $this->makeValidator([$attribute], new Document(['value' => '18446744073709551615'])); + + $this->assertTrue($atBoundary->isValid( + $this->makeOperator(OperatorType::Increment, 'value', [1]) + )); + $this->assertFalse($overflow->isValid( + $this->makeOperator(OperatorType::Increment, 'value', [1]) + )); + $this->assertStringContainsString('18446744073709551615', $overflow->getDescription()); + } + + public function testUnsignedBigIntegerOperatorRejectsUnsupportedAdapter(): void + { + $validator = new OperatorValidator( + $this->makeCollection([ + Attribute::bigInteger(key: 'value', signed: false), + ]), + new Document(['value' => 1]), + false, + ); + + $this->assertFalse($validator->isValid( + $this->makeOperator(OperatorType::Increment, 'value', [1]) + )); + $this->assertStringContainsString('not supported', $validator->getDescription()); + } + + public function testUnsignedBigIntegerArrayOperatorUsesExactBounds(): void + { + $validator = $this->makeValidator([ + Attribute::bigInteger(key: 'values', signed: false, array: true), + ]); + + $this->assertTrue($validator->isValid( + $this->makeOperator(OperatorType::ArrayAppend, 'values', [['18446744073709551615']]) + )); + $this->assertFalse($validator->isValid( + $this->makeOperator(OperatorType::ArrayAppend, 'values', [['18446744073709551616']]) + )); + } +} diff --git a/tests/unit/OperatorTest.php b/tests/unit/OperatorTest.php index 0c07a6d032..d960b77129 100644 --- a/tests/unit/OperatorTest.php +++ b/tests/unit/OperatorTest.php @@ -5,39 +5,40 @@ use PHPUnit\Framework\TestCase; use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Operator; +use Utopia\Database\OperatorType; class OperatorTest extends TestCase { - public function testCreate(): void + public function test_create(): void { // Test basic construction - $operator = new Operator(Operator::TYPE_INCREMENT, 'count', [1]); + $operator = new Operator(OperatorType::Increment, 'count', [1]); - $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals(OperatorType::Increment, $operator->getMethod()); $this->assertEquals('count', $operator->getAttribute()); $this->assertEquals([1], $operator->getValues()); $this->assertEquals(1, $operator->getValue()); // Test with different types - $operator = new Operator(Operator::TYPE_ARRAY_APPEND, 'tags', ['php', 'database']); + $operator = new Operator(OperatorType::ArrayAppend, 'tags', ['php', 'database']); - $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayAppend, $operator->getMethod()); $this->assertEquals('tags', $operator->getAttribute()); $this->assertEquals(['php', 'database'], $operator->getValues()); $this->assertEquals('php', $operator->getValue()); } - public function testHelperMethods(): void + public function test_helper_methods(): void { // Test increment helper $operator = Operator::increment(5); - $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals(OperatorType::Increment, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); // Initially empty $this->assertEquals([5], $operator->getValues()); // Test decrement helper $operator = Operator::decrement(1); - $this->assertEquals(Operator::TYPE_DECREMENT, $operator->getMethod()); + $this->assertEquals(OperatorType::Decrement, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); // Initially empty $this->assertEquals([1], $operator->getValues()); @@ -47,81 +48,81 @@ public function testHelperMethods(): void // Test string helpers $operator = Operator::stringConcat(' - Updated'); - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operator->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([' - Updated'], $operator->getValues()); $operator = Operator::stringReplace('old', 'new'); - $this->assertEquals(Operator::TYPE_STRING_REPLACE, $operator->getMethod()); + $this->assertEquals(OperatorType::StringReplace, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals(['old', 'new'], $operator->getValues()); // Test math helpers $operator = Operator::multiply(2, 1000); - $this->assertEquals(Operator::TYPE_MULTIPLY, $operator->getMethod()); + $this->assertEquals(OperatorType::Multiply, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([2, 1000], $operator->getValues()); $operator = Operator::divide(2, 1); - $this->assertEquals(Operator::TYPE_DIVIDE, $operator->getMethod()); + $this->assertEquals(OperatorType::Divide, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([2, 1], $operator->getValues()); // Test boolean helper $operator = Operator::toggle(); - $this->assertEquals(Operator::TYPE_TOGGLE, $operator->getMethod()); + $this->assertEquals(OperatorType::Toggle, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([], $operator->getValues()); $operator = Operator::dateSetNow(); - $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operator->getMethod()); + $this->assertEquals(OperatorType::DateSetNow, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([], $operator->getValues()); // Test concat helper $operator = Operator::stringConcat(' - Updated'); - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operator->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([' - Updated'], $operator->getValues()); // Test modulo and power operators $operator = Operator::modulo(3); - $this->assertEquals(Operator::TYPE_MODULO, $operator->getMethod()); + $this->assertEquals(OperatorType::Modulo, $operator->getMethod()); $this->assertEquals([3], $operator->getValues()); $operator = Operator::power(2, 1000); - $this->assertEquals(Operator::TYPE_POWER, $operator->getMethod()); + $this->assertEquals(OperatorType::Power, $operator->getMethod()); $this->assertEquals([2, 1000], $operator->getValues()); // Test new array helper methods $operator = Operator::arrayAppend(['new', 'values']); - $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayAppend, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals(['new', 'values'], $operator->getValues()); $operator = Operator::arrayPrepend(['first', 'second']); - $this->assertEquals(Operator::TYPE_ARRAY_PREPEND, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayPrepend, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals(['first', 'second'], $operator->getValues()); $operator = Operator::arrayInsert(2, 'inserted'); - $this->assertEquals(Operator::TYPE_ARRAY_INSERT, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayInsert, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([2, 'inserted'], $operator->getValues()); $operator = Operator::arrayRemove('unwanted'); - $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayRemove, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals(['unwanted'], $operator->getValues()); } - public function testSetters(): void + public function test_setters(): void { - $operator = new Operator(Operator::TYPE_INCREMENT, 'test', [1]); + $operator = new Operator(OperatorType::Increment, 'test', [1]); // Test setMethod - $operator->setMethod(Operator::TYPE_DECREMENT); - $this->assertEquals(Operator::TYPE_DECREMENT, $operator->getMethod()); + $operator->setMethod(OperatorType::Decrement); + $this->assertEquals(OperatorType::Decrement, $operator->getMethod()); // Test setAttribute $operator->setAttribute('newAttribute'); @@ -137,7 +138,7 @@ public function testSetters(): void $this->assertEquals(50, $operator->getValue()); } - public function testTypeMethods(): void + public function test_type_methods(): void { // Test numeric operations $incrementOp = Operator::increment(1); @@ -165,7 +166,6 @@ public function testTypeMethods(): void $this->assertFalse($toggleOp->isArrayOperation()); $this->assertTrue($toggleOp->isBooleanOperation()); - // Test date operations $dateSetNowOp = Operator::dateSetNow(); $this->assertFalse($dateSetNowOp->isNumericOperation()); @@ -190,26 +190,26 @@ public function testTypeMethods(): void $this->assertTrue($arrayRemoveOp->isArrayOperation()); } - public function testIsMethod(): void + public function test_is_method(): void { // Test valid methods - $this->assertTrue(Operator::isMethod(Operator::TYPE_INCREMENT)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_DECREMENT)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_MULTIPLY)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_DIVIDE)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_STRING_CONCAT)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_STRING_REPLACE)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_TOGGLE)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_STRING_CONCAT)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_DATE_SET_NOW)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_MODULO)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_POWER)); + $this->assertTrue(Operator::isMethod(OperatorType::Increment->value)); + $this->assertTrue(Operator::isMethod(OperatorType::Decrement->value)); + $this->assertTrue(Operator::isMethod(OperatorType::Multiply->value)); + $this->assertTrue(Operator::isMethod(OperatorType::Divide->value)); + $this->assertTrue(Operator::isMethod(OperatorType::StringConcat->value)); + $this->assertTrue(Operator::isMethod(OperatorType::StringReplace->value)); + $this->assertTrue(Operator::isMethod(OperatorType::Toggle->value)); + $this->assertTrue(Operator::isMethod(OperatorType::StringConcat->value)); + $this->assertTrue(Operator::isMethod(OperatorType::DateSetNow->value)); + $this->assertTrue(Operator::isMethod(OperatorType::Modulo->value)); + $this->assertTrue(Operator::isMethod(OperatorType::Power->value)); // Test new array methods - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_APPEND)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_PREPEND)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_INSERT)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_REMOVE)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayAppend->value)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayPrepend->value)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayInsert->value)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayRemove->value)); // Test invalid methods $this->assertFalse(Operator::isMethod('invalid')); @@ -219,7 +219,7 @@ public function testIsMethod(): void $this->assertFalse(Operator::isMethod('insert')); // Old method should be false } - public function testIsOperator(): void + public function test_is_operator(): void { $operator = Operator::increment(1); $this->assertTrue(Operator::isOperator($operator)); @@ -230,29 +230,22 @@ public function testIsOperator(): void $this->assertFalse(Operator::isOperator(null)); } - public function testExtractOperators(): void + public function test_extract_operators(): void { $data = [ 'name' => 'John', 'count' => Operator::increment(5), 'tags' => Operator::arrayAppend(['new']), - 'age' => 30 + 'age' => 30, ]; $result = Operator::extractOperators($data); - $this->assertArrayHasKey('operators', $result); - $this->assertArrayHasKey('updates', $result); - $operators = $result['operators']; $updates = $result['updates']; - // Check operators $this->assertCount(2, $operators); - $this->assertInstanceOf(Operator::class, $operators['count']); - $this->assertInstanceOf(Operator::class, $operators['tags']); - // Check that attributes are set from document keys $this->assertEquals('count', $operators['count']->getAttribute()); $this->assertEquals('tags', $operators['tags']->getAttribute()); @@ -260,7 +253,7 @@ public function testExtractOperators(): void $this->assertEquals(['name' => 'John', 'age' => 30], $updates); } - public function testSerialization(): void + public function test_serialization(): void { $operator = Operator::increment(10); $operator->setAttribute('score'); // Simulate setting attribute @@ -268,9 +261,9 @@ public function testSerialization(): void // Test toArray $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_INCREMENT, + 'method' => OperatorType::Increment->value, 'attribute' => 'score', - 'values' => [10] + 'values' => [10], ]; $this->assertEquals($expected, $array); @@ -281,17 +274,17 @@ public function testSerialization(): void $this->assertEquals($expected, $decoded); } - public function testParsing(): void + public function test_parsing(): void { // Test parseOperator from array $array = [ - 'method' => Operator::TYPE_INCREMENT, + 'method' => OperatorType::Increment->value, 'attribute' => 'score', - 'values' => [5] + 'values' => [5], ]; $operator = Operator::parseOperator($array); - $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals(OperatorType::Increment, $operator->getMethod()); $this->assertEquals('score', $operator->getAttribute()); $this->assertEquals([5], $operator->getValues()); @@ -299,15 +292,15 @@ public function testParsing(): void $json = json_encode($array); $this->assertIsString($json); $operator = Operator::parse($json); - $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals(OperatorType::Increment, $operator->getMethod()); $this->assertEquals('score', $operator->getAttribute()); $this->assertEquals([5], $operator->getValues()); } - public function testParseOperators(): void + public function test_parse_operators(): void { - $json1 = json_encode(['method' => Operator::TYPE_INCREMENT, 'attribute' => 'count', 'values' => [1]]); - $json2 = json_encode(['method' => Operator::TYPE_ARRAY_APPEND, 'attribute' => 'tags', 'values' => ['new']]); + $json1 = json_encode(['method' => OperatorType::Increment->value, 'attribute' => 'count', 'values' => [1]]); + $json2 = json_encode(['method' => OperatorType::ArrayAppend->value, 'attribute' => 'tags', 'values' => ['new']]); $this->assertIsString($json1); $this->assertIsString($json2); @@ -316,13 +309,11 @@ public function testParseOperators(): void $parsed = Operator::parseOperators($operators); $this->assertCount(2, $parsed); - $this->assertInstanceOf(Operator::class, $parsed[0]); - $this->assertInstanceOf(Operator::class, $parsed[1]); - $this->assertEquals(Operator::TYPE_INCREMENT, $parsed[0]->getMethod()); - $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $parsed[1]->getMethod()); + $this->assertEquals(OperatorType::Increment, $parsed[0]->getMethod()); + $this->assertEquals(OperatorType::ArrayAppend, $parsed[1]->getMethod()); } - public function testClone(): void + public function test_clone(): void { $operator1 = Operator::increment(5); $operator2 = clone $operator1; @@ -332,39 +323,39 @@ public function testClone(): void $this->assertEquals($operator1->getValues(), $operator2->getValues()); // Ensure they are different objects - $operator2->setMethod(Operator::TYPE_DECREMENT); - $this->assertEquals(Operator::TYPE_INCREMENT, $operator1->getMethod()); - $this->assertEquals(Operator::TYPE_DECREMENT, $operator2->getMethod()); + $operator2->setMethod(OperatorType::Decrement); + $this->assertEquals(OperatorType::Increment, $operator1->getMethod()); + $this->assertEquals(OperatorType::Decrement, $operator2->getMethod()); } - public function testGetValueWithDefault(): void + public function test_get_value_with_default(): void { $operator = Operator::increment(5); $this->assertEquals(5, $operator->getValue()); $this->assertEquals(5, $operator->getValue('default')); - $emptyOperator = new Operator(Operator::TYPE_INCREMENT, 'count', []); + $emptyOperator = new Operator(OperatorType::Increment, 'count', []); $this->assertEquals('default', $emptyOperator->getValue('default')); $this->assertNull($emptyOperator->getValue()); } // Exception tests - public function testParseInvalidJson(): void + public function test_parse_invalid_json(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Invalid operator'); Operator::parse('invalid json'); } - public function testParseNonArray(): void + public function test_parse_non_array(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Invalid operator. Must be an array'); Operator::parse('"string"'); } - public function testParseInvalidMethod(): void + public function test_parse_invalid_method(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Invalid operator method. Must be a string'); @@ -372,7 +363,7 @@ public function testParseInvalidMethod(): void Operator::parseOperator($array); } - public function testParseUnsupportedMethod(): void + public function test_parse_unsupported_method(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Invalid operator method: invalid'); @@ -380,26 +371,26 @@ public function testParseUnsupportedMethod(): void Operator::parseOperator($array); } - public function testParseInvalidAttribute(): void + public function test_parse_invalid_attribute(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Invalid operator attribute. Must be a string'); - $array = ['method' => Operator::TYPE_INCREMENT, 'attribute' => 123, 'values' => []]; + $array = ['method' => OperatorType::Increment->value, 'attribute' => 123, 'values' => []]; Operator::parseOperator($array); } - public function testParseInvalidValues(): void + public function test_parse_invalid_values(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Invalid operator values. Must be an array'); - $array = ['method' => Operator::TYPE_INCREMENT, 'attribute' => 'test', 'values' => 'not array']; + $array = ['method' => OperatorType::Increment->value, 'attribute' => 'test', 'values' => 'not array']; Operator::parseOperator($array); } - public function testToStringInvalidJson(): void + public function test_to_string_invalid_json(): void { // Create an operator with values that can't be JSON encoded - $operator = new Operator(Operator::TYPE_INCREMENT, 'test', []); + $operator = new Operator(OperatorType::Increment, 'test', []); $operator->setValues([fopen('php://memory', 'r')]); // Resource can't be JSON encoded $this->expectException(OperatorException::class); @@ -409,11 +400,11 @@ public function testToStringInvalidJson(): void // New functionality tests - public function testIncrementWithMax(): void + public function test_increment_with_max(): void { // Test increment with max limit $operator = Operator::increment(5, 10); - $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals(OperatorType::Increment, $operator->getMethod()); $this->assertEquals([5, 10], $operator->getValues()); // Test increment without max (should be same as original behavior) @@ -421,11 +412,11 @@ public function testIncrementWithMax(): void $this->assertEquals([5], $operator->getValues()); } - public function testDecrementWithMin(): void + public function test_decrement_with_min(): void { // Test decrement with min limit $operator = Operator::decrement(3, 0); - $this->assertEquals(Operator::TYPE_DECREMENT, $operator->getMethod()); + $this->assertEquals(OperatorType::Decrement, $operator->getMethod()); $this->assertEquals([3, 0], $operator->getValues()); // Test decrement without min (should be same as original behavior) @@ -433,15 +424,15 @@ public function testDecrementWithMin(): void $this->assertEquals([3], $operator->getValues()); } - public function testArrayRemove(): void + public function test_array_remove(): void { $operator = Operator::arrayRemove('spam'); - $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayRemove, $operator->getMethod()); $this->assertEquals(['spam'], $operator->getValues()); $this->assertEquals('spam', $operator->getValue()); } - public function testExtractOperatorsWithNewMethods(): void + public function test_extract_operators_with_new_methods(): void { $data = [ 'name' => 'John', @@ -460,7 +451,7 @@ public function testExtractOperatorsWithNewMethods(): void 'title_prefix' => Operator::stringConcat(' - Updated'), 'views_modulo' => Operator::modulo(3), 'score_power' => Operator::power(2, 1000), - 'age' => 30 + 'age' => 30, ]; $result = Operator::extractOperators($data); @@ -471,33 +462,30 @@ public function testExtractOperatorsWithNewMethods(): void // Check operators count (all fields except 'name' and 'age') $this->assertCount(15, $operators); - // Check that array methods are properly extracted - $this->assertInstanceOf(Operator::class, $operators['tags']); $this->assertEquals('tags', $operators['tags']->getAttribute()); - $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operators['tags']->getMethod()); + $this->assertEquals(OperatorType::ArrayAppend, $operators['tags']->getMethod()); - $this->assertInstanceOf(Operator::class, $operators['blacklist']); $this->assertEquals('blacklist', $operators['blacklist']->getAttribute()); - $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operators['blacklist']->getMethod()); + $this->assertEquals(OperatorType::ArrayRemove, $operators['blacklist']->getMethod()); // Check string operators - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operators['title']->getMethod()); - $this->assertEquals(Operator::TYPE_STRING_REPLACE, $operators['content']->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operators['title']->getMethod()); + $this->assertEquals(OperatorType::StringReplace, $operators['content']->getMethod()); // Check math operators - $this->assertEquals(Operator::TYPE_MULTIPLY, $operators['views']->getMethod()); - $this->assertEquals(Operator::TYPE_DIVIDE, $operators['rating']->getMethod()); + $this->assertEquals(OperatorType::Multiply, $operators['views']->getMethod()); + $this->assertEquals(OperatorType::Divide, $operators['rating']->getMethod()); // Check boolean operator - $this->assertEquals(Operator::TYPE_TOGGLE, $operators['featured']->getMethod()); + $this->assertEquals(OperatorType::Toggle, $operators['featured']->getMethod()); // Check new operators - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operators['title_prefix']->getMethod()); - $this->assertEquals(Operator::TYPE_MODULO, $operators['views_modulo']->getMethod()); - $this->assertEquals(Operator::TYPE_POWER, $operators['score_power']->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operators['title_prefix']->getMethod()); + $this->assertEquals(OperatorType::Modulo, $operators['views_modulo']->getMethod()); + $this->assertEquals(OperatorType::Power, $operators['score_power']->getMethod()); // Check date operator - $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operators['last_modified']->getMethod()); + $this->assertEquals(OperatorType::DateSetNow, $operators['last_modified']->getMethod()); // Check that max/min values are preserved $this->assertEquals([5, 100], $operators['count']->getValues()); @@ -507,26 +495,25 @@ public function testExtractOperatorsWithNewMethods(): void $this->assertEquals(['name' => 'John', 'age' => 30], $updates); } - - public function testParsingWithNewConstants(): void + public function test_parsing_with_new_constants(): void { // Test parsing new array methods $arrayRemove = [ - 'method' => Operator::TYPE_ARRAY_REMOVE, + 'method' => OperatorType::ArrayRemove->value, 'attribute' => 'blacklist', - 'values' => ['spam'] + 'values' => ['spam'], ]; $operator = Operator::parseOperator($arrayRemove); - $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayRemove, $operator->getMethod()); $this->assertEquals('blacklist', $operator->getAttribute()); $this->assertEquals(['spam'], $operator->getValues()); // Test parsing increment with max $incrementWithMax = [ - 'method' => Operator::TYPE_INCREMENT, + 'method' => OperatorType::Increment->value, 'attribute' => 'score', - 'values' => [1, 10] + 'values' => [1, 10], ]; $operator = Operator::parseOperator($incrementWithMax); @@ -535,7 +522,7 @@ public function testParsingWithNewConstants(): void // Edge case tests - public function testIncrementMaxLimitEdgeCases(): void + public function test_increment_max_limit_edge_cases(): void { // Test that max limit is properly stored $operator = Operator::increment(5, 10); @@ -556,7 +543,7 @@ public function testIncrementMaxLimitEdgeCases(): void $this->assertEquals(-5, $values[1]); } - public function testDecrementMinLimitEdgeCases(): void + public function test_decrement_min_limit_edge_cases(): void { // Test that min limit is properly stored $operator = Operator::decrement(3, 0); @@ -577,7 +564,7 @@ public function testDecrementMinLimitEdgeCases(): void $this->assertEquals(-10, $values[1]); } - public function testArrayRemoveEdgeCases(): void + public function test_array_remove_edge_cases(): void { // Test removing various types of values $operator = Operator::arrayRemove('string'); @@ -597,7 +584,7 @@ public function testArrayRemoveEdgeCases(): void $this->assertEquals(['nested'], $operator->getValue()); } - public function testOperatorCloningWithNewMethods(): void + public function test_operator_cloning_with_new_methods(): void { // Test cloning increment with max $operator1 = Operator::increment(5, 10); @@ -621,7 +608,7 @@ public function testOperatorCloningWithNewMethods(): void $this->assertEquals('ham', $removeOp2->getValue()); } - public function testSerializationWithNewOperators(): void + public function test_serialization_with_new_operators(): void { // Test serialization of increment with max $operator = Operator::increment(5, 100); @@ -629,9 +616,9 @@ public function testSerializationWithNewOperators(): void $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_INCREMENT, + 'method' => OperatorType::Increment->value, 'attribute' => 'score', - 'values' => [5, 100] + 'values' => [5, 100], ]; $this->assertEquals($expected, $array); @@ -641,9 +628,9 @@ public function testSerializationWithNewOperators(): void $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_ARRAY_REMOVE, + 'method' => OperatorType::ArrayRemove->value, 'attribute' => 'blacklist', - 'values' => ['unwanted'] + 'values' => ['unwanted'], ]; $this->assertEquals($expected, $array); @@ -654,7 +641,7 @@ public function testSerializationWithNewOperators(): void $this->assertEquals($expected, $decoded); } - public function testMixedOperatorTypes(): void + public function test_mixed_operator_types(): void { // Test that all new operator types can coexist $data = [ @@ -678,26 +665,26 @@ public function testMixedOperatorTypes(): void $this->assertCount(12, $operators); // Verify each operator type - $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operators['arrayAppend']->getMethod()); - $this->assertEquals(Operator::TYPE_INCREMENT, $operators['incrementWithMax']->getMethod()); + $this->assertEquals(OperatorType::ArrayAppend, $operators['arrayAppend']->getMethod()); + $this->assertEquals(OperatorType::Increment, $operators['incrementWithMax']->getMethod()); $this->assertEquals([1, 10], $operators['incrementWithMax']->getValues()); - $this->assertEquals(Operator::TYPE_DECREMENT, $operators['decrementWithMin']->getMethod()); + $this->assertEquals(OperatorType::Decrement, $operators['decrementWithMin']->getMethod()); $this->assertEquals([2, 0], $operators['decrementWithMin']->getValues()); - $this->assertEquals(Operator::TYPE_MULTIPLY, $operators['multiply']->getMethod()); + $this->assertEquals(OperatorType::Multiply, $operators['multiply']->getMethod()); $this->assertEquals([3, 100], $operators['multiply']->getValues()); - $this->assertEquals(Operator::TYPE_DIVIDE, $operators['divide']->getMethod()); + $this->assertEquals(OperatorType::Divide, $operators['divide']->getMethod()); $this->assertEquals([2, 1], $operators['divide']->getValues()); - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operators['concat']->getMethod()); - $this->assertEquals(Operator::TYPE_STRING_REPLACE, $operators['replace']->getMethod()); - $this->assertEquals(Operator::TYPE_TOGGLE, $operators['toggle']->getMethod()); - $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operators['dateSetNow']->getMethod()); - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operators['concat']->getMethod()); - $this->assertEquals(Operator::TYPE_MODULO, $operators['modulo']->getMethod()); - $this->assertEquals(Operator::TYPE_POWER, $operators['power']->getMethod()); - $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operators['remove']->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operators['concat']->getMethod()); + $this->assertEquals(OperatorType::StringReplace, $operators['replace']->getMethod()); + $this->assertEquals(OperatorType::Toggle, $operators['toggle']->getMethod()); + $this->assertEquals(OperatorType::DateSetNow, $operators['dateSetNow']->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operators['concat']->getMethod()); + $this->assertEquals(OperatorType::Modulo, $operators['modulo']->getMethod()); + $this->assertEquals(OperatorType::Power, $operators['power']->getMethod()); + $this->assertEquals(OperatorType::ArrayRemove, $operators['remove']->getMethod()); } - public function testTypeValidationWithNewMethods(): void + public function test_type_validation_with_new_methods(): void { // All new array methods should be detected as array operations $this->assertTrue(Operator::arrayAppend([])->isArrayOperation()); @@ -728,7 +715,6 @@ public function testTypeValidationWithNewMethods(): void $this->assertFalse(Operator::toggle()->isNumericOperation()); $this->assertFalse(Operator::toggle()->isArrayOperation()); - // Test date operations $this->assertTrue(Operator::dateSetNow()->isDateOperation()); $this->assertFalse(Operator::dateSetNow()->isNumericOperation()); @@ -736,33 +722,33 @@ public function testTypeValidationWithNewMethods(): void // New comprehensive tests for all operators - public function testStringOperators(): void + public function test_string_operators(): void { // Test concat operator $operator = Operator::stringConcat(' - Updated'); - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operator->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operator->getMethod()); $this->assertEquals([' - Updated'], $operator->getValues()); $this->assertEquals(' - Updated', $operator->getValue()); $this->assertEquals('', $operator->getAttribute()); // Test concat with different values $operator = Operator::stringConcat('prefix-'); - $this->assertEquals(Operator::TYPE_STRING_CONCAT, $operator->getMethod()); + $this->assertEquals(OperatorType::StringConcat, $operator->getMethod()); $this->assertEquals(['prefix-'], $operator->getValues()); $this->assertEquals('prefix-', $operator->getValue()); // Test replace operator $operator = Operator::stringReplace('old', 'new'); - $this->assertEquals(Operator::TYPE_STRING_REPLACE, $operator->getMethod()); + $this->assertEquals(OperatorType::StringReplace, $operator->getMethod()); $this->assertEquals(['old', 'new'], $operator->getValues()); $this->assertEquals('old', $operator->getValue()); } - public function testMathOperators(): void + public function test_math_operators(): void { // Test multiply operator $operator = Operator::multiply(2.5, 100); - $this->assertEquals(Operator::TYPE_MULTIPLY, $operator->getMethod()); + $this->assertEquals(OperatorType::Multiply, $operator->getMethod()); $this->assertEquals([2.5, 100], $operator->getValues()); $this->assertEquals(2.5, $operator->getValue()); @@ -772,7 +758,7 @@ public function testMathOperators(): void // Test divide operator $operator = Operator::divide(2, 1); - $this->assertEquals(Operator::TYPE_DIVIDE, $operator->getMethod()); + $this->assertEquals(OperatorType::Divide, $operator->getMethod()); $this->assertEquals([2, 1], $operator->getValues()); $this->assertEquals(2, $operator->getValue()); @@ -782,13 +768,13 @@ public function testMathOperators(): void // Test modulo operator $operator = Operator::modulo(3); - $this->assertEquals(Operator::TYPE_MODULO, $operator->getMethod()); + $this->assertEquals(OperatorType::Modulo, $operator->getMethod()); $this->assertEquals([3], $operator->getValues()); $this->assertEquals(3, $operator->getValue()); // Test power operator $operator = Operator::power(2, 1000); - $this->assertEquals(Operator::TYPE_POWER, $operator->getMethod()); + $this->assertEquals(OperatorType::Power, $operator->getMethod()); $this->assertEquals([2, 1000], $operator->getValues()); $this->assertEquals(2, $operator->getValue()); @@ -797,57 +783,55 @@ public function testMathOperators(): void $this->assertEquals([3], $operator->getValues()); } - public function testDivideByZero(): void + public function test_divide_by_zero(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Division by zero is not allowed'); Operator::divide(0); } - public function testModuloByZero(): void + public function test_modulo_by_zero(): void { $this->expectException(OperatorException::class); $this->expectExceptionMessage('Modulo by zero is not allowed'); Operator::modulo(0); } - public function testBooleanOperator(): void + public function test_boolean_operator(): void { $operator = Operator::toggle(); - $this->assertEquals(Operator::TYPE_TOGGLE, $operator->getMethod()); + $this->assertEquals(OperatorType::Toggle, $operator->getMethod()); $this->assertEquals([], $operator->getValues()); $this->assertNull($operator->getValue()); } - - public function testUtilityOperators(): void + public function test_utility_operators(): void { // Test dateSetNow $operator = Operator::dateSetNow(); - $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operator->getMethod()); + $this->assertEquals(OperatorType::DateSetNow, $operator->getMethod()); $this->assertEquals([], $operator->getValues()); $this->assertNull($operator->getValue()); } - - public function testNewOperatorParsing(): void + public function test_new_operator_parsing(): void { // Test parsing all new operators $operators = [ - ['method' => Operator::TYPE_STRING_CONCAT, 'attribute' => 'title', 'values' => [' - Updated']], - ['method' => Operator::TYPE_STRING_CONCAT, 'attribute' => 'subtitle', 'values' => [' - Updated']], - ['method' => Operator::TYPE_STRING_REPLACE, 'attribute' => 'content', 'values' => ['old', 'new']], - ['method' => Operator::TYPE_MULTIPLY, 'attribute' => 'score', 'values' => [2, 100]], - ['method' => Operator::TYPE_DIVIDE, 'attribute' => 'rating', 'values' => [2, 1]], - ['method' => Operator::TYPE_MODULO, 'attribute' => 'remainder', 'values' => [3]], - ['method' => Operator::TYPE_POWER, 'attribute' => 'exponential', 'values' => [2, 1000]], - ['method' => Operator::TYPE_TOGGLE, 'attribute' => 'active', 'values' => []], - ['method' => Operator::TYPE_DATE_SET_NOW, 'attribute' => 'updated', 'values' => []], + ['method' => OperatorType::StringConcat->value, 'attribute' => 'title', 'values' => [' - Updated']], + ['method' => OperatorType::StringConcat->value, 'attribute' => 'subtitle', 'values' => [' - Updated']], + ['method' => OperatorType::StringReplace->value, 'attribute' => 'content', 'values' => ['old', 'new']], + ['method' => OperatorType::Multiply->value, 'attribute' => 'score', 'values' => [2, 100]], + ['method' => OperatorType::Divide->value, 'attribute' => 'rating', 'values' => [2, 1]], + ['method' => OperatorType::Modulo->value, 'attribute' => 'remainder', 'values' => [3]], + ['method' => OperatorType::Power->value, 'attribute' => 'exponential', 'values' => [2, 1000]], + ['method' => OperatorType::Toggle->value, 'attribute' => 'active', 'values' => []], + ['method' => OperatorType::DateSetNow->value, 'attribute' => 'updated', 'values' => []], ]; foreach ($operators as $operatorData) { $operator = Operator::parseOperator($operatorData); - $this->assertEquals($operatorData['method'], $operator->getMethod()); + $this->assertEquals($operatorData['method'], $operator->getMethod()->value); $this->assertEquals($operatorData['attribute'], $operator->getAttribute()); $this->assertEquals($operatorData['values'], $operator->getValues()); @@ -860,7 +844,7 @@ public function testNewOperatorParsing(): void } } - public function testOperatorCloning(): void + public function test_operator_cloning(): void { // Test cloning all new operator types $operators = [ @@ -888,7 +872,7 @@ public function testOperatorCloning(): void // Test edge cases and error conditions - public function testOperatorEdgeCases(): void + public function test_operator_edge_cases(): void { // Test multiply with zero $operator = Operator::multiply(0); @@ -915,11 +899,11 @@ public function testOperatorEdgeCases(): void $this->assertEquals(0, $operator->getValue()); } - public function testPowerOperatorWithMax(): void + public function test_power_operator_with_max(): void { // Test power with max limit $operator = Operator::power(2, 1000); - $this->assertEquals(Operator::TYPE_POWER, $operator->getMethod()); + $this->assertEquals(OperatorType::Power, $operator->getMethod()); $this->assertEquals([2, 1000], $operator->getValues()); // Test power without max @@ -927,7 +911,7 @@ public function testPowerOperatorWithMax(): void $this->assertEquals([3], $operator->getValues()); } - public function testOperatorTypeValidation(): void + public function test_operator_type_validation(): void { // Test that operators have proper type checking methods $numericOp = Operator::power(2); @@ -943,11 +927,11 @@ public function testOperatorTypeValidation(): void } // Tests for arrayUnique() method - public function testArrayUnique(): void + public function test_array_unique(): void { // Test basic creation $operator = Operator::arrayUnique(); - $this->assertEquals(Operator::TYPE_ARRAY_UNIQUE, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayUnique, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([], $operator->getValues()); $this->assertNull($operator->getValue()); @@ -960,7 +944,7 @@ public function testArrayUnique(): void $this->assertFalse($operator->isDateOperation()); } - public function testArrayUniqueSerialization(): void + public function test_array_unique_serialization(): void { $operator = Operator::arrayUnique(); $operator->setAttribute('tags'); @@ -968,9 +952,9 @@ public function testArrayUniqueSerialization(): void // Test toArray $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_ARRAY_UNIQUE, + 'method' => OperatorType::ArrayUnique->value, 'attribute' => 'tags', - 'values' => [] + 'values' => [], ]; $this->assertEquals($expected, $array); @@ -981,17 +965,17 @@ public function testArrayUniqueSerialization(): void $this->assertEquals($expected, $decoded); } - public function testArrayUniqueParsing(): void + public function test_array_unique_parsing(): void { // Test parseOperator from array $array = [ - 'method' => Operator::TYPE_ARRAY_UNIQUE, + 'method' => OperatorType::ArrayUnique->value, 'attribute' => 'items', - 'values' => [] + 'values' => [], ]; $operator = Operator::parseOperator($array); - $this->assertEquals(Operator::TYPE_ARRAY_UNIQUE, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayUnique, $operator->getMethod()); $this->assertEquals('items', $operator->getAttribute()); $this->assertEquals([], $operator->getValues()); @@ -999,12 +983,12 @@ public function testArrayUniqueParsing(): void $json = json_encode($array); $this->assertIsString($json); $operator = Operator::parse($json); - $this->assertEquals(Operator::TYPE_ARRAY_UNIQUE, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayUnique, $operator->getMethod()); $this->assertEquals('items', $operator->getAttribute()); $this->assertEquals([], $operator->getValues()); } - public function testArrayUniqueCloning(): void + public function test_array_unique_cloning(): void { $operator1 = Operator::arrayUnique(); $operator1->setAttribute('original'); @@ -1021,11 +1005,11 @@ public function testArrayUniqueCloning(): void } // Tests for arrayIntersect() method - public function testArrayIntersect(): void + public function test_array_intersect(): void { // Test basic creation $operator = Operator::arrayIntersect(['a', 'b', 'c']); - $this->assertEquals(Operator::TYPE_ARRAY_INTERSECT, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayIntersect, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals(['a', 'b', 'c'], $operator->getValues()); $this->assertEquals('a', $operator->getValue()); @@ -1038,7 +1022,7 @@ public function testArrayIntersect(): void $this->assertFalse($operator->isDateOperation()); } - public function testArrayIntersectEdgeCases(): void + public function test_array_intersect_edge_cases(): void { // Test with empty array $operator = Operator::arrayIntersect([]); @@ -1060,7 +1044,7 @@ public function testArrayIntersectEdgeCases(): void $this->assertEquals([['nested'], ['array']], $operator->getValues()); } - public function testArrayIntersectSerialization(): void + public function test_array_intersect_serialization(): void { $operator = Operator::arrayIntersect(['x', 'y', 'z']); $operator->setAttribute('common'); @@ -1068,9 +1052,9 @@ public function testArrayIntersectSerialization(): void // Test toArray $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_ARRAY_INTERSECT, + 'method' => OperatorType::ArrayIntersect->value, 'attribute' => 'common', - 'values' => ['x', 'y', 'z'] + 'values' => ['x', 'y', 'z'], ]; $this->assertEquals($expected, $array); @@ -1081,17 +1065,17 @@ public function testArrayIntersectSerialization(): void $this->assertEquals($expected, $decoded); } - public function testArrayIntersectParsing(): void + public function test_array_intersect_parsing(): void { // Test parseOperator from array $array = [ - 'method' => Operator::TYPE_ARRAY_INTERSECT, + 'method' => OperatorType::ArrayIntersect->value, 'attribute' => 'allowed', - 'values' => ['admin', 'user'] + 'values' => ['admin', 'user'], ]; $operator = Operator::parseOperator($array); - $this->assertEquals(Operator::TYPE_ARRAY_INTERSECT, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayIntersect, $operator->getMethod()); $this->assertEquals('allowed', $operator->getAttribute()); $this->assertEquals(['admin', 'user'], $operator->getValues()); @@ -1099,17 +1083,17 @@ public function testArrayIntersectParsing(): void $json = json_encode($array); $this->assertIsString($json); $operator = Operator::parse($json); - $this->assertEquals(Operator::TYPE_ARRAY_INTERSECT, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayIntersect, $operator->getMethod()); $this->assertEquals('allowed', $operator->getAttribute()); $this->assertEquals(['admin', 'user'], $operator->getValues()); } // Tests for arrayDiff() method - public function testArrayDiff(): void + public function test_array_diff(): void { // Test basic creation $operator = Operator::arrayDiff(['remove', 'these']); - $this->assertEquals(Operator::TYPE_ARRAY_DIFF, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayDiff, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals(['remove', 'these'], $operator->getValues()); $this->assertEquals('remove', $operator->getValue()); @@ -1122,7 +1106,7 @@ public function testArrayDiff(): void $this->assertFalse($operator->isDateOperation()); } - public function testArrayDiffEdgeCases(): void + public function test_array_diff_edge_cases(): void { // Test with empty array $operator = Operator::arrayDiff([]); @@ -1143,7 +1127,7 @@ public function testArrayDiffEdgeCases(): void $this->assertEquals([false, 0, ''], $operator->getValues()); } - public function testArrayDiffSerialization(): void + public function test_array_diff_serialization(): void { $operator = Operator::arrayDiff(['spam', 'unwanted']); $operator->setAttribute('blocklist'); @@ -1151,9 +1135,9 @@ public function testArrayDiffSerialization(): void // Test toArray $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_ARRAY_DIFF, + 'method' => OperatorType::ArrayDiff->value, 'attribute' => 'blocklist', - 'values' => ['spam', 'unwanted'] + 'values' => ['spam', 'unwanted'], ]; $this->assertEquals($expected, $array); @@ -1164,17 +1148,17 @@ public function testArrayDiffSerialization(): void $this->assertEquals($expected, $decoded); } - public function testArrayDiffParsing(): void + public function test_array_diff_parsing(): void { // Test parseOperator from array $array = [ - 'method' => Operator::TYPE_ARRAY_DIFF, + 'method' => OperatorType::ArrayDiff->value, 'attribute' => 'exclude', - 'values' => ['bad', 'invalid'] + 'values' => ['bad', 'invalid'], ]; $operator = Operator::parseOperator($array); - $this->assertEquals(Operator::TYPE_ARRAY_DIFF, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayDiff, $operator->getMethod()); $this->assertEquals('exclude', $operator->getAttribute()); $this->assertEquals(['bad', 'invalid'], $operator->getValues()); @@ -1182,17 +1166,17 @@ public function testArrayDiffParsing(): void $json = json_encode($array); $this->assertIsString($json); $operator = Operator::parse($json); - $this->assertEquals(Operator::TYPE_ARRAY_DIFF, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayDiff, $operator->getMethod()); $this->assertEquals('exclude', $operator->getAttribute()); $this->assertEquals(['bad', 'invalid'], $operator->getValues()); } // Tests for arrayFilter() method - public function testArrayFilter(): void + public function test_array_filter(): void { // Test basic creation with equals condition $operator = Operator::arrayFilter('equals', 'active'); - $this->assertEquals(Operator::TYPE_ARRAY_FILTER, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayFilter, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals(['equals', 'active'], $operator->getValues()); $this->assertEquals('equals', $operator->getValue()); @@ -1205,7 +1189,7 @@ public function testArrayFilter(): void $this->assertFalse($operator->isDateOperation()); } - public function testArrayFilterConditions(): void + public function test_array_filter_conditions(): void { // Test different filter conditions $operator = Operator::arrayFilter('notEquals', 'inactive'); @@ -1229,7 +1213,7 @@ public function testArrayFilterConditions(): void $this->assertEquals(['null', null], $operator->getValues()); } - public function testArrayFilterEdgeCases(): void + public function test_array_filter_edge_cases(): void { // Test with boolean value $operator = Operator::arrayFilter('equals', true); @@ -1248,7 +1232,7 @@ public function testArrayFilterEdgeCases(): void $this->assertEquals(['equals', ['nested', 'array']], $operator->getValues()); } - public function testArrayFilterSerialization(): void + public function test_array_filter_serialization(): void { $operator = Operator::arrayFilter('greaterThan', 100); $operator->setAttribute('scores'); @@ -1256,9 +1240,9 @@ public function testArrayFilterSerialization(): void // Test toArray $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_ARRAY_FILTER, + 'method' => OperatorType::ArrayFilter->value, 'attribute' => 'scores', - 'values' => ['greaterThan', 100] + 'values' => ['greaterThan', 100], ]; $this->assertEquals($expected, $array); @@ -1269,17 +1253,17 @@ public function testArrayFilterSerialization(): void $this->assertEquals($expected, $decoded); } - public function testArrayFilterParsing(): void + public function test_array_filter_parsing(): void { // Test parseOperator from array $array = [ - 'method' => Operator::TYPE_ARRAY_FILTER, + 'method' => OperatorType::ArrayFilter->value, 'attribute' => 'ratings', - 'values' => ['lessThan', 3] + 'values' => ['lessThan', 3], ]; $operator = Operator::parseOperator($array); - $this->assertEquals(Operator::TYPE_ARRAY_FILTER, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayFilter, $operator->getMethod()); $this->assertEquals('ratings', $operator->getAttribute()); $this->assertEquals(['lessThan', 3], $operator->getValues()); @@ -1287,17 +1271,17 @@ public function testArrayFilterParsing(): void $json = json_encode($array); $this->assertIsString($json); $operator = Operator::parse($json); - $this->assertEquals(Operator::TYPE_ARRAY_FILTER, $operator->getMethod()); + $this->assertEquals(OperatorType::ArrayFilter, $operator->getMethod()); $this->assertEquals('ratings', $operator->getAttribute()); $this->assertEquals(['lessThan', 3], $operator->getValues()); } // Tests for dateAddDays() method - public function testDateAddDays(): void + public function test_date_add_days(): void { // Test basic creation $operator = Operator::dateAddDays(7); - $this->assertEquals(Operator::TYPE_DATE_ADD_DAYS, $operator->getMethod()); + $this->assertEquals(OperatorType::DateAddDays, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([7], $operator->getValues()); $this->assertEquals(7, $operator->getValue()); @@ -1310,7 +1294,7 @@ public function testDateAddDays(): void $this->assertFalse($operator->isBooleanOperation()); } - public function testDateAddDaysEdgeCases(): void + public function test_date_add_days_edge_cases(): void { // Test with zero days $operator = Operator::dateAddDays(0); @@ -1333,7 +1317,7 @@ public function testDateAddDaysEdgeCases(): void $this->assertEquals(-1000, $operator->getValue()); } - public function testDateAddDaysSerialization(): void + public function test_date_add_days_serialization(): void { $operator = Operator::dateAddDays(30); $operator->setAttribute('expiresAt'); @@ -1341,9 +1325,9 @@ public function testDateAddDaysSerialization(): void // Test toArray $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_DATE_ADD_DAYS, + 'method' => OperatorType::DateAddDays->value, 'attribute' => 'expiresAt', - 'values' => [30] + 'values' => [30], ]; $this->assertEquals($expected, $array); @@ -1354,17 +1338,17 @@ public function testDateAddDaysSerialization(): void $this->assertEquals($expected, $decoded); } - public function testDateAddDaysParsing(): void + public function test_date_add_days_parsing(): void { // Test parseOperator from array $array = [ - 'method' => Operator::TYPE_DATE_ADD_DAYS, + 'method' => OperatorType::DateAddDays->value, 'attribute' => 'scheduledFor', - 'values' => [14] + 'values' => [14], ]; $operator = Operator::parseOperator($array); - $this->assertEquals(Operator::TYPE_DATE_ADD_DAYS, $operator->getMethod()); + $this->assertEquals(OperatorType::DateAddDays, $operator->getMethod()); $this->assertEquals('scheduledFor', $operator->getAttribute()); $this->assertEquals([14], $operator->getValues()); @@ -1372,12 +1356,12 @@ public function testDateAddDaysParsing(): void $json = json_encode($array); $this->assertIsString($json); $operator = Operator::parse($json); - $this->assertEquals(Operator::TYPE_DATE_ADD_DAYS, $operator->getMethod()); + $this->assertEquals(OperatorType::DateAddDays, $operator->getMethod()); $this->assertEquals('scheduledFor', $operator->getAttribute()); $this->assertEquals([14], $operator->getValues()); } - public function testDateAddDaysCloning(): void + public function test_date_add_days_cloning(): void { $operator1 = Operator::dateAddDays(10); $operator1->setAttribute('date1'); @@ -1394,11 +1378,11 @@ public function testDateAddDaysCloning(): void } // Tests for dateSubDays() method - public function testDateSubDays(): void + public function test_date_sub_days(): void { // Test basic creation $operator = Operator::dateSubDays(3); - $this->assertEquals(Operator::TYPE_DATE_SUB_DAYS, $operator->getMethod()); + $this->assertEquals(OperatorType::DateSubDays, $operator->getMethod()); $this->assertEquals('', $operator->getAttribute()); $this->assertEquals([3], $operator->getValues()); $this->assertEquals(3, $operator->getValue()); @@ -1411,7 +1395,7 @@ public function testDateSubDays(): void $this->assertFalse($operator->isBooleanOperation()); } - public function testDateSubDaysEdgeCases(): void + public function test_date_sub_days_edge_cases(): void { // Test with zero days $operator = Operator::dateSubDays(0); @@ -1434,7 +1418,7 @@ public function testDateSubDaysEdgeCases(): void $this->assertEquals(10000, $operator->getValue()); } - public function testDateSubDaysSerialization(): void + public function test_date_sub_days_serialization(): void { $operator = Operator::dateSubDays(7); $operator->setAttribute('reminderDate'); @@ -1442,9 +1426,9 @@ public function testDateSubDaysSerialization(): void // Test toArray $array = $operator->toArray(); $expected = [ - 'method' => Operator::TYPE_DATE_SUB_DAYS, + 'method' => OperatorType::DateSubDays->value, 'attribute' => 'reminderDate', - 'values' => [7] + 'values' => [7], ]; $this->assertEquals($expected, $array); @@ -1455,17 +1439,17 @@ public function testDateSubDaysSerialization(): void $this->assertEquals($expected, $decoded); } - public function testDateSubDaysParsing(): void + public function test_date_sub_days_parsing(): void { // Test parseOperator from array $array = [ - 'method' => Operator::TYPE_DATE_SUB_DAYS, + 'method' => OperatorType::DateSubDays->value, 'attribute' => 'dueDate', - 'values' => [5] + 'values' => [5], ]; $operator = Operator::parseOperator($array); - $this->assertEquals(Operator::TYPE_DATE_SUB_DAYS, $operator->getMethod()); + $this->assertEquals(OperatorType::DateSubDays, $operator->getMethod()); $this->assertEquals('dueDate', $operator->getAttribute()); $this->assertEquals([5], $operator->getValues()); @@ -1473,12 +1457,12 @@ public function testDateSubDaysParsing(): void $json = json_encode($array); $this->assertIsString($json); $operator = Operator::parse($json); - $this->assertEquals(Operator::TYPE_DATE_SUB_DAYS, $operator->getMethod()); + $this->assertEquals(OperatorType::DateSubDays, $operator->getMethod()); $this->assertEquals('dueDate', $operator->getAttribute()); $this->assertEquals([5], $operator->getValues()); } - public function testDateSubDaysCloning(): void + public function test_date_sub_days_cloning(): void { $operator1 = Operator::dateSubDays(15); $operator1->setAttribute('date1'); @@ -1495,18 +1479,18 @@ public function testDateSubDaysCloning(): void } // Integration tests for all six new operators - public function testIsMethodForNewOperators(): void + public function test_is_method_for_new_operators(): void { // Test that all new operators are valid methods - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_UNIQUE)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_INTERSECT)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_DIFF)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_FILTER)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_DATE_ADD_DAYS)); - $this->assertTrue(Operator::isMethod(Operator::TYPE_DATE_SUB_DAYS)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayUnique->value)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayIntersect->value)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayDiff->value)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayFilter->value)); + $this->assertTrue(Operator::isMethod(OperatorType::DateAddDays->value)); + $this->assertTrue(Operator::isMethod(OperatorType::DateSubDays->value)); } - public function testExtractOperatorsWithNewOperators(): void + public function test_extract_operators_with_new_operators(): void { $data = [ 'uniqueTags' => Operator::arrayUnique(), @@ -1527,25 +1511,43 @@ public function testExtractOperatorsWithNewOperators(): void $this->assertCount(6, $operators); // Check each operator type - $this->assertInstanceOf(Operator::class, $operators['uniqueTags']); - $this->assertEquals(Operator::TYPE_ARRAY_UNIQUE, $operators['uniqueTags']->getMethod()); + $this->assertEquals(OperatorType::ArrayUnique, $operators['uniqueTags']->getMethod()); + $this->assertEquals(OperatorType::ArrayIntersect, $operators['commonItems']->getMethod()); + $this->assertEquals(OperatorType::ArrayDiff, $operators['filteredList']->getMethod()); + $this->assertEquals(OperatorType::ArrayFilter, $operators['activeUsers']->getMethod()); + $this->assertEquals(OperatorType::DateAddDays, $operators['expiry']->getMethod()); + $this->assertEquals(OperatorType::DateSubDays, $operators['reminder']->getMethod()); - $this->assertInstanceOf(Operator::class, $operators['commonItems']); - $this->assertEquals(Operator::TYPE_ARRAY_INTERSECT, $operators['commonItems']->getMethod()); + // Check updates + $this->assertEquals(['name' => 'Regular value'], $updates); + } - $this->assertInstanceOf(Operator::class, $operators['filteredList']); - $this->assertEquals(Operator::TYPE_ARRAY_DIFF, $operators['filteredList']->getMethod()); + public function test_clone_deep_copies_nested_operator_values(): void + { + $nested = Operator::increment(1); + $parent = new Operator(OperatorType::ArrayAppend, 'items', [$nested, 'plain']); - $this->assertInstanceOf(Operator::class, $operators['activeUsers']); - $this->assertEquals(Operator::TYPE_ARRAY_FILTER, $operators['activeUsers']->getMethod()); + $cloned = clone $parent; - $this->assertInstanceOf(Operator::class, $operators['expiry']); - $this->assertEquals(Operator::TYPE_DATE_ADD_DAYS, $operators['expiry']->getMethod()); + $parentValues = $parent->getValues(); + $clonedValues = $cloned->getValues(); - $this->assertInstanceOf(Operator::class, $operators['reminder']); - $this->assertEquals(Operator::TYPE_DATE_SUB_DAYS, $operators['reminder']->getMethod()); + $this->assertNotSame($parentValues[0], $clonedValues[0]); + $this->assertInstanceOf(Operator::class, $clonedValues[0]); + $this->assertInstanceOf(Operator::class, $parentValues[0]); + $this->assertEquals($nested->getMethod(), $clonedValues[0]->getMethod()); + $this->assertEquals($nested->getValues(), $clonedValues[0]->getValues()); - // Check updates - $this->assertEquals(['name' => 'Regular value'], $updates); + $clonedValues[0]->setMethod(OperatorType::Decrement); + $this->assertEquals(OperatorType::Increment, $parentValues[0]->getMethod()); + } + + public function test_is_method_with_operator_type_enum(): void + { + $this->assertTrue(Operator::isMethod(OperatorType::Increment)); + $this->assertTrue(Operator::isMethod(OperatorType::Decrement)); + $this->assertTrue(Operator::isMethod(OperatorType::ArrayAppend)); + $this->assertTrue(Operator::isMethod(OperatorType::Toggle)); + $this->assertTrue(Operator::isMethod(OperatorType::DateSetNow)); } } diff --git a/tests/unit/PDOStatementTest.php b/tests/unit/PDOStatementTest.php index 8bd8f280b7..866389f639 100644 --- a/tests/unit/PDOStatementTest.php +++ b/tests/unit/PDOStatementTest.php @@ -7,7 +7,7 @@ use Utopia\Database\PDO; use Utopia\Database\PDOStatement; -class PDOStatementTest extends TestCase +final class PDOStatementTest extends TestCase { /** * @return PDO&\PHPUnit\Framework\MockObject\MockObject @@ -16,8 +16,7 @@ private function pdoMock(bool $inTransaction): PDO { $pdo = $this->getMockBuilder(PDO::class) ->disableOriginalConstructor() - ->onlyMethods(['reconnect', 'prepareNative']) - ->addMethods(['inTransaction']) + ->onlyMethods(['inTransaction', 'prepareNative', 'reconnect']) ->getMock(); $pdo->method('inTransaction')->willReturn($inTransaction); @@ -109,7 +108,7 @@ public function testExecuteRethrowsNonConnectionErrors(): void public function testForwardsCallsAndPropertiesToUnderlyingStatement(): void { - $pdo = $this->pdoMock(inTransaction: false); + $pdo = self::createStub(PDO::class); $statement = $this->statementMock(); $statement->expects($this->once()) @@ -124,13 +123,14 @@ public function testForwardsCallsAndPropertiesToUnderlyingStatement(): void public function testIsIterableAndDelegatesIterationToTheStatement(): void { - $pdo = $this->pdoMock(inTransaction: false); - $statement = $this->statementMock(); + $rows = [['id' => 1], ['id' => 2]]; + $pdo = self::createStub(PDO::class); + $statement = self::createStub(\PDOStatement::class); + $statement->method('getIterator')->willReturn(new \ArrayIterator($rows)); $wrapper = new PDOStatement($pdo, $statement, 'SELECT 1'); - $this->assertInstanceOf(\IteratorAggregate::class, $wrapper); - $this->assertSame($statement, $wrapper->getIterator()); + $this->assertSame($rows, iterator_to_array($wrapper)); } public function testDoesNotReconnectForNonExecuteMethods(): void @@ -199,11 +199,11 @@ public function testReplaysMixedBindingsInOriginalCallOrder(): void $replay = []; $second = $this->statementMock(); $second->method('bindValue')->willReturnCallback(function (int|string $p, mixed $v) use (&$replay): bool { - $replay[] = "value:{$v}"; + $replay[] = ['value', $v]; return true; }); $second->method('bindParam')->willReturnCallback(function (int|string $p, mixed &$v) use (&$replay): bool { - $replay[] = "param:{$v}"; + $replay[] = ['param', $v]; return true; }); $second->expects($this->once())->method('execute')->willReturn(true); @@ -219,6 +219,22 @@ public function testReplaysMixedBindingsInOriginalCallOrder(): void $wrapper->bindParam(':id', $current); $this->assertTrue($wrapper->execute()); - $this->assertSame(['value:old', 'param:new'], $replay, 'replay must preserve original bind order so the last binding wins'); + $this->assertSame([['value', 'old'], ['param', 'new']], $replay, 'replay must preserve original bind order so the last binding wins'); + } + + public function testExecuteRetriedAfterALostConnectionRunsOnTheConfiguredSession(): void + { + $pdo = new PDO('sqlite::memory:', null, null); + $pdo->configure('marker', 'CREATE TEMP TABLE marker AS SELECT 7 AS value'); + + $lost = $this->statementMock(); + $lost->expects($this->once()) + ->method('execute') + ->willThrowException(new PDOException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away')); + + $statement = new PDOStatement($pdo, $lost, 'SELECT value FROM temp.marker'); + + $this->assertTrue($statement->execute()); + $this->assertSame(7, $statement->fetchColumn()); } } diff --git a/tests/unit/PDOTest.php b/tests/unit/PDOTest.php index 09e4ac122d..8b8be4e925 100644 --- a/tests/unit/PDOTest.php +++ b/tests/unit/PDOTest.php @@ -2,50 +2,44 @@ namespace Tests\Unit; +use Closure; +use PDOException; use PHPUnit\Framework\TestCase; -use ReflectionClass; +use Utopia\Database\Connection; use Utopia\Database\PDO; use Utopia\Database\PDOStatement; class PDOTest extends TestCase { - public function testMethodCallIsForwardedToPDO(): void + public function test_method_call_is_forwarded_to_pdo(): void { $dsn = 'sqlite::memory:'; - $pdoWrapper = new PDO($dsn, null, null); - - // Use Reflection to replace the internal PDO instance with a mock - $reflection = new ReflectionClass($pdoWrapper); - $pdoProperty = $reflection->getProperty('pdo'); - $pdoProperty->setAccessible(true); + $pdoWrapper = new PDOTestConnection($dsn, null, null); // Create a mock for the internal \PDO object. $pdoMock = $this->getMockBuilder(\PDO::class) ->disableOriginalConstructor() ->getMock(); - // Create a PDOStatement mock since query returns a PDOStatement - $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) - ->disableOriginalConstructor() - ->getMock(); + $pdoStatementStub = self::createStub(\PDOStatement::class); - // Expect that when we call 'query', the mock returns our PDOStatement mock. + // Expect that when we call 'query', the mock returns our PDOStatement stub. $pdoMock->expects($this->once()) ->method('query') ->with('SELECT 1') - ->willReturn($pdoStatementMock); + ->willReturn($pdoStatementStub); - $pdoProperty->setValue($pdoWrapper, $pdoMock); + $pdoWrapper->useConnection($pdoMock); $result = $pdoWrapper->query('SELECT 1'); - $this->assertSame($pdoStatementMock, $result); + $this->assertSame($pdoStatementStub, $result); } - public function testLostConnectionRetriesCall(): void + public function test_lost_connection_retries_call(): void { $dsn = 'sqlite::memory:'; - $pdoWrapper = $this->getMockBuilder(PDO::class) + $pdoWrapper = $this->getMockBuilder(PDOTestConnection::class) ->setConstructorArgs([$dsn, null, null, []]) ->onlyMethods(['reconnect']) ->getMock(); @@ -53,42 +47,37 @@ public function testLostConnectionRetriesCall(): void $pdoMock = $this->getMockBuilder(\PDO::class) ->disableOriginalConstructor() ->getMock(); - $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) - ->disableOriginalConstructor() - ->getMock(); + $pdoStatementStub = self::createStub(\PDOStatement::class); + $callCount = 0; $pdoMock->expects($this->exactly(2)) ->method('query') ->with('SELECT 1') - ->will($this->onConsecutiveCalls( - $this->throwException(new \Exception("Lost connection")), - $pdoStatementMock - )); + ->willReturnCallback(function () use (&$callCount, $pdoStatementStub) { + $callCount++; + if ($callCount === 1) { + throw new \Exception('Lost connection'); + } + return $pdoStatementStub; + }); - $reflection = new ReflectionClass($pdoWrapper); - $pdoProperty = $reflection->getProperty('pdo'); - $pdoProperty->setAccessible(true); - $pdoProperty->setValue($pdoWrapper, $pdoMock); + $pdoWrapper->useConnection($pdoMock); $pdoWrapper->expects($this->once()) ->method('reconnect') - ->willReturnCallback(function () use ($pdoWrapper, $pdoMock, $pdoProperty) { - $pdoProperty->setValue($pdoWrapper, $pdoMock); + ->willReturnCallback(function () use ($pdoWrapper, $pdoMock) { + $pdoWrapper->useConnection($pdoMock); }); $result = $pdoWrapper->query('SELECT 1'); - $this->assertSame($pdoStatementMock, $result); + $this->assertSame($pdoStatementStub, $result); } - public function testNonLostConnectionExceptionIsRethrown(): void + public function test_non_lost_connection_exception_is_rethrown(): void { $dsn = 'sqlite::memory:'; - $pdoWrapper = new PDO($dsn, null, null); - - $reflection = new ReflectionClass($pdoWrapper); - $pdoProperty = $reflection->getProperty('pdo'); - $pdoProperty->setAccessible(true); + $pdoWrapper = new PDOTestConnection($dsn, null, null); $pdoMock = $this->getMockBuilder(\PDO::class) ->disableOriginalConstructor() @@ -97,65 +86,53 @@ public function testNonLostConnectionExceptionIsRethrown(): void $pdoMock->expects($this->once()) ->method('query') ->with('SELECT 1') - ->will($this->throwException(new \Exception("Other error"))); + ->will($this->throwException(new \Exception('Other error'))); - $pdoProperty->setValue($pdoWrapper, $pdoMock); + $pdoWrapper->useConnection($pdoMock); $this->expectException(\Exception::class); - $this->expectExceptionMessage("Other error"); + $this->expectExceptionMessage('Other error'); $pdoWrapper->query('SELECT 1'); } - public function testReconnectCreatesNewPDOInstance(): void + public function test_reconnect_creates_new_pdo_instance(): void { $dsn = 'sqlite::memory:'; - $pdoWrapper = new PDO($dsn, null, null); - - $reflection = new ReflectionClass($pdoWrapper); - $pdoProperty = $reflection->getProperty('pdo'); - $pdoProperty->setAccessible(true); - - $oldPDO = $pdoProperty->getValue($pdoWrapper); + $pdoWrapper = new PDOTestConnection($dsn, null, null); + $oldPDO = $pdoWrapper->connection(); $pdoWrapper->reconnect(); - $newPDO = $pdoProperty->getValue($pdoWrapper); + $newPDO = $pdoWrapper->connection(); - $this->assertNotSame($oldPDO, $newPDO, "Reconnect should create a new PDO instance"); + $this->assertNotSame($oldPDO, $newPDO, 'Reconnect should create a new PDO instance'); } - public function testMethodCallForPrepare(): void + public function test_method_call_for_prepare(): void { $dsn = 'sqlite::memory:'; - $pdoWrapper = new PDO($dsn, null, null); - - $reflection = new ReflectionClass($pdoWrapper); - $pdoProperty = $reflection->getProperty('pdo'); - $pdoProperty->setAccessible(true); + $pdoWrapper = new PDOTestConnection($dsn, null, null); $pdoMock = $this->getMockBuilder(\PDO::class) ->disableOriginalConstructor() ->getMock(); - $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) - ->disableOriginalConstructor() - ->getMock(); + $pdoStatementStub = self::createStub(\PDOStatement::class); $pdoMock->expects($this->once()) ->method('prepare') ->with('SELECT * FROM table', [\PDO::ATTR_CURSOR => \PDO::CURSOR_FWDONLY]) - ->willReturn($pdoStatementMock); + ->willReturn($pdoStatementStub); - $pdoProperty->setValue($pdoWrapper, $pdoMock); + $pdoWrapper->useConnection($pdoMock); $result = $pdoWrapper->prepare('SELECT * FROM table', [\PDO::ATTR_CURSOR => \PDO::CURSOR_FWDONLY]); - $this->assertInstanceOf(PDOStatement::class, $result); - $this->assertSame($pdoStatementMock, $result->getStatement()); + $this->assertSame($pdoStatementStub, $result->getStatement()); } public function testPrepareNativeReconnectsOutsideTransaction(): void { - $pdoWrapper = $this->getMockBuilder(PDO::class) + $pdoWrapper = $this->getMockBuilder(PDOTestConnection::class) ->setConstructorArgs(['sqlite::memory:', null, null, []]) ->onlyMethods(['reconnect']) ->getMock(); @@ -163,26 +140,320 @@ public function testPrepareNativeReconnectsOutsideTransaction(): void $pdoMock = $this->getMockBuilder(\PDO::class) ->disableOriginalConstructor() ->getMock(); - $statementMock = $this->getMockBuilder(\PDOStatement::class) - ->disableOriginalConstructor() - ->getMock(); + $statement = self::createStub(\PDOStatement::class); $pdoMock->method('inTransaction')->willReturn(false); + $calls = 0; $pdoMock->expects($this->exactly(2)) ->method('prepare') - ->with('SELECT 1') - ->willReturnOnConsecutiveCalls( - $this->throwException(new \PDOException('server has gone away')), - $statementMock - ); + ->with('SELECT 1', []) + ->willReturnCallback(function () use (&$calls, $statement): \PDOStatement { + $calls++; + if ($calls === 1) { + throw new \PDOException('server has gone away'); + } + + return $statement; + }); - $reflection = new ReflectionClass($pdoWrapper); - $pdoProperty = $reflection->getProperty('pdo'); - $pdoProperty->setAccessible(true); - $pdoProperty->setValue($pdoWrapper, $pdoMock); + $pdoWrapper->useConnection($pdoMock); $pdoWrapper->expects($this->once())->method('reconnect'); - $this->assertSame($statementMock, $pdoWrapper->prepareNative('SELECT 1')); + $this->assertSame($statement, $pdoWrapper->prepareNative('SELECT 1')); + } + + public function testPrepareNativeThrowsWhenNativePrepareReturnsFalse(): void + { + $pdoWrapper = new PDOTestConnection('sqlite::memory:', null, null); + + $pdoMock = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdoMock->expects($this->once()) + ->method('prepare') + ->with('INVALID', []) + ->willReturn(false); + + $pdoWrapper->useConnection($pdoMock); + + $this->expectException(\PDOException::class); + $this->expectExceptionMessage('Failed to prepare statement: INVALID'); + + $pdoWrapper->prepareNative('INVALID'); + } + + public function testReconnectReplaysTheConfiguredSession(): void + { + $pdo = new PDO('sqlite::memory:', null, null); + $pdo->configure('cache', 'PRAGMA cache_size = 100'); + $pdo->configure('cache', 'PRAGMA cache_size = 200'); + $pdo->configure('keys', 'PRAGMA foreign_keys = ON'); + + $pdo->reconnect(); + + $this->assertSame(200, $this->pragma($pdo, 'cache_size'), 'The latest statement for a setting must win'); + $this->assertSame(1, $this->pragma($pdo, 'foreign_keys')); + } + + public function testCallRetriedAfterALostConnectionRunsOnTheConfiguredSession(): void + { + $pdo = new PDOTestConnection('sqlite::memory:', null, null); + $pdo->configure('marker', 'CREATE TEMP TABLE marker AS SELECT 7 AS value'); + + $lost = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $lost->method('inTransaction')->willReturn(false); + $lost->expects($this->once()) + ->method('query') + ->willThrowException(new PDOException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away')); + $pdo->useConnection($lost); + + $statement = $pdo->query('SELECT value FROM temp.marker'); + + $this->assertInstanceOf(\PDOStatement::class, $statement); + $this->assertSame(7, $statement->fetchColumn()); + } + + public function testReconnectKeepsTheLostConnectionWhenTheSessionCannotBeReplayed(): void + { + $pdo = new PDOTestConnection('sqlite::memory:', null, null); + $pdo->exec('CREATE TEMP TABLE local (value INTEGER)'); + $pdo->configure('row', 'INSERT INTO temp.local VALUES (1)'); + + $lost = $pdo->connection(); + + $failure = null; + try { + $pdo->reconnect(); + } catch (PDOException $error) { + $failure = $error; + } + + $this->assertInstanceOf(PDOException::class, $failure, 'The new connection has no temp.local to replay into'); + $this->assertSame($lost, $pdo->connection(), 'A connection missing the configured session must never be used'); + } + + public function testReconnectReplaysAttributes(): void + { + $pdo = new PDO('sqlite::memory:', null, null); + $this->assertTrue($pdo->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_UPPER)); + $this->assertTrue($pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_NUM)); + + $pdo->reconnect(); + + $this->assertSame(\PDO::CASE_UPPER, $pdo->getAttribute(\PDO::ATTR_CASE)); + $this->assertSame(\PDO::FETCH_NUM, $pdo->getAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE)); + } + + public function testStatementsAfterALostTransactionAreRefusedUntilItIsRolledBack(): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $pdo->exec('CREATE TABLE items (value INTEGER)'); + $pdo->beginTransaction(); + $pdo->exec('INSERT INTO items VALUES (1)'); + $endSession(); + + try { + $pdo->exec('INSERT INTO items VALUES (2)'); + } catch (PDOException) { + } + + $this->assertStatementRefused(fn (): mixed => $pdo->exec('INSERT INTO items VALUES (3)')); + $this->assertStatementRefused(fn (): mixed => $pdo->query('SELECT value FROM items')); + $this->assertStatementRefused(fn (): mixed => $pdo->prepare('INSERT INTO items VALUES (4)')); + $this->assertStatementRefused(fn (): mixed => $pdo->commit()); + $this->assertSame([], $this->values($path), 'Nothing may run on its own after the transaction was lost'); + $this->assertTrue($pdo->inTransaction(), 'The caller still holds a transaction until it rolls back'); + + $this->assertTrue($pdo->rollBack()); + $this->assertFalse($pdo->inTransaction()); + + $pdo->exec('INSERT INTO items VALUES (5)'); + $this->assertSame([5], $this->values($path)); + } + + public function testARefusedStatementCarriesTheLostConnection(): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $pdo->exec('CREATE TABLE items (value INTEGER)'); + $pdo->beginTransaction(); + $endSession(); + + $lost = null; + try { + $pdo->exec('INSERT INTO items VALUES (1)'); + } catch (PDOException $error) { + $lost = $error; + } + + $refusal = null; + try { + $pdo->exec('INSERT INTO items VALUES (2)'); + } catch (PDOException $error) { + $refusal = $error; + } + + $this->assertInstanceOf(PDOException::class, $lost); + $this->assertInstanceOf(PDOException::class, $refusal); + $this->assertSame($lost, $refusal->getPrevious(), 'A refusal must carry the lost connection that caused it'); + $this->assertFalse(Connection::hasError($refusal), 'A refusal must not be taken for a lost connection itself'); + } + + public function testARollbackStatementEndsALostTransaction(): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $pdo->exec('CREATE TABLE items (value INTEGER)'); + $pdo->beginTransaction(); + $endSession(); + + try { + $pdo->exec('INSERT INTO items VALUES (1)'); + } catch (PDOException) { + } + + $pdo->prepare('ROLLBACK'); + + $this->assertFalse($pdo->inTransaction()); + $pdo->exec('INSERT INTO items VALUES (2)'); + $this->assertSame([2], $this->values($path)); + } + + public function testAnExplicitReconnectEndsALostTransaction(): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $pdo->exec('CREATE TABLE items (value INTEGER)'); + $pdo->beginTransaction(); + $endSession(); + + try { + $pdo->exec('INSERT INTO items VALUES (1)'); + } catch (PDOException) { + } + + $pdo->reconnect(); + + $this->assertFalse($pdo->inTransaction()); + $pdo->exec('INSERT INTO items VALUES (2)'); + $this->assertSame([2], $this->values($path)); + } + + public function testARollbackThatFindsTheConnectionLostEndsTheTransaction(): void + { + $path = $this->createDatabaseFile(); + [$pdo, $endSession] = $this->createLosableConnection($path); + $pdo->exec('CREATE TABLE items (value INTEGER)'); + $pdo->beginTransaction(); + $pdo->exec('INSERT INTO items VALUES (1)'); + $endSession(); + + $lost = null; + try { + $pdo->rollBack(); + } catch (PDOException $error) { + $lost = $error; + } + + $this->assertInstanceOf(PDOException::class, $lost, 'The rollback must report the lost connection'); + $this->assertFalse($pdo->inTransaction(), 'The lost connection ended the transaction'); + $pdo->exec('INSERT INTO items VALUES (2)'); + $this->assertSame([2], $this->values($path)); + } + + private function createDatabaseFile(): string + { + $path = \tempnam(\sys_get_temp_dir(), 'pdo-test-'); + $this->assertIsString($path); + \register_shutdown_function(static fn (): bool => @\unlink($path)); + + return $path; + } + + /** + * A connection whose session the server can end: afterwards every statement on the old + * handle fails as a dropped MySQL connection does, and the handle still reports its + * transaction. + * + * @return array{PDO, Closure(): void} + */ + private function createLosableConnection(string $path): array + { + $pdo = new class ("sqlite:{$path}", null, null) extends PDO { + public function endSession(): void + { + $this->pdo = new class () extends \PDO { + public function __construct() + { + } + + #[\Override] + public function inTransaction(): bool + { + return true; + } + + #[\Override] + public function exec(string $statement): int|false + { + throw new PDOException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'); + } + + #[\Override] + public function rollBack(): bool + { + throw new PDOException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'); + } + }; + } + }; + + return [$pdo, $pdo->endSession(...)]; + } + + /** + * @param callable(): mixed $statement + */ + private function assertStatementRefused(callable $statement): void + { + try { + $statement(); + } catch (PDOException $error) { + $this->assertStringContainsString('roll it back', $error->getMessage()); + + return; + } + + $this->fail('A statement after a lost transaction must be refused'); + } + + /** + * @return array + */ + private function values(string $path): array + { + $statement = (new \PDO("sqlite:{$path}"))->query('SELECT value FROM items ORDER BY value'); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + return \array_map(static function (mixed $value): int { + self::assertIsNumeric($value); + + return (int) $value; + }, $statement->fetchAll(\PDO::FETCH_COLUMN)); + } + + private function pragma(PDO $pdo, string $name): int + { + $statement = $pdo->query("PRAGMA {$name}"); + $this->assertInstanceOf(\PDOStatement::class, $statement); + + $value = $statement->fetchColumn(); + $this->assertIsInt($value); + + return $value; } } diff --git a/tests/unit/PDOTestConnection.php b/tests/unit/PDOTestConnection.php new file mode 100644 index 0000000000..969ba5e233 --- /dev/null +++ b/tests/unit/PDOTestConnection.php @@ -0,0 +1,18 @@ +pdo = $connection; + } + + public function connection(): \PDO + { + return $this->pdo; + } +} diff --git a/tests/unit/PHPStan/Data/Fixture.php b/tests/unit/PHPStan/Data/Fixture.php new file mode 100644 index 0000000000..a193e33ad6 --- /dev/null +++ b/tests/unit/PHPStan/Data/Fixture.php @@ -0,0 +1,69 @@ + $this->backing; + set { + $this->backing = $value; + } + } + + public int $counter = 0 { + get => $this->counter; + set => $value + 1; + } + + public bool $plain = false; + + public function read(): bool + { + return $this->flag; + } + + public function write(): void + { + $this->flag = true; + } + + public function plain(): bool + { + return $this->plain; + } + + public function fromOutside(self $other): int + { + return $other->counter; + } + + public function byName(self $other): bool + { + return $other->{'flag'}; + } + + public function statementRead(PDOStatement $statement): mixed + { + return $statement->queryString; + } + + public function statementPrivateRead(PDOStatement $statement): mixed + { + return $statement->values; + } + + public function statementWrite(PDOStatement $statement): void + { + $statement->custom = true; + } + + public function statementMethod(PDOStatement $statement): int + { + return $statement->rowCount(); + } +} diff --git a/tests/unit/PHPStan/MagicPropertyFetchRule.php b/tests/unit/PHPStan/MagicPropertyFetchRule.php new file mode 100644 index 0000000000..12a1cbd0ab --- /dev/null +++ b/tests/unit/PHPStan/MagicPropertyFetchRule.php @@ -0,0 +1,136 @@ + + */ +final readonly class MagicPropertyFetchRule implements Rule +{ + public const string MAGIC_IDENTIFIER = 'utopiaDatabase.magicProperty'; + + public const string HOOKED_IDENTIFIER = 'utopiaDatabase.hookedProperty'; + + /** + * Classes in src that still resolve inaccessible properties through __get/__set/__isset. + * + * @var list + */ + private const array MAGIC_CLASSES = [ + PDOStatement::class, + ]; + + private string $sourceDirectory; + + /** + * @throws \InvalidArgumentException When the source directory does not exist + */ + public function __construct(string $sourceDirectory, FileHelper $fileHelper) + { + $directory = $fileHelper->normalizePath($fileHelper->absolutizePath($sourceDirectory)); + if (! \is_dir($directory)) { + throw new \InvalidArgumentException(\sprintf('Source directory "%s" does not exist, so the rule would report nothing.', $sourceDirectory)); + } + + $this->sourceDirectory = \rtrim($directory, '/').'/'; + } + + #[\Override] + public function getNodeType(): string + { + return PropertyFetch::class; + } + + /** + * @return list + */ + #[\Override] + public function processNode(Node $node, Scope $scope): array + { + if (! \str_starts_with($scope->getFile(), $this->sourceDirectory)) { + return []; + } + + $classes = $scope->getType($node->var)->getObjectClassReflections(); + + $errors = []; + foreach ($this->namesOf($node, $scope) as $name) { + foreach ($classes as $class) { + if ($this->isMagic($class, $name, $scope)) { + $errors[] = RuleErrorBuilder::message(\sprintf( + 'Property %s::$%s is reached through __get/__set/__isset, which the PHP 8.5 tracing JIT miscompiles (php/php-src#22084). Use the wrapped \PDOStatement directly instead.', + $class->getDisplayName(), + $name, + ))->identifier(self::MAGIC_IDENTIFIER)->build(); + } elseif ($this->isHooked($class, $name) && ! $this->isInsideOwnHook($node, $scope, $name)) { + $errors[] = RuleErrorBuilder::message(\sprintf( + 'Property %s::$%s is reached through its hook, which the PHP 8.5 tracing JIT miscompiles (php/php-src#22084). Use a plain property or a method instead.', + $class->getNativeProperty($name)->getDeclaringClass()->getDisplayName(), + $name, + ))->identifier(self::HOOKED_IDENTIFIER)->build(); + } + } + } + + return $errors; + } + + /** + * @return list + */ + private function namesOf(PropertyFetch $node, Scope $scope): array + { + if ($node->name instanceof Identifier) { + return [$node->name->toString()]; + } + + return \array_map( + static fn (ConstantStringType $name): string => $name->getValue(), + $scope->getType($node->name)->getConstantStrings(), + ); + } + + private function isMagic(ClassReflection $class, string $name, Scope $scope): bool + { + foreach (self::MAGIC_CLASSES as $magic) { + if ($class->is($magic)) { + return ! $class->hasNativeProperty($name) || ! $scope->canAccessProperty($class->getNativeProperty($name)); + } + } + + return false; + } + + private function isHooked(ClassReflection $class, string $name): bool + { + return $class->hasNativeProperty($name) && $class->getNativeProperty($name)->isHooked(); + } + + private function isInsideOwnHook(PropertyFetch $node, Scope $scope, string $name): bool + { + $function = $scope->getFunction(); + + return $function !== null + && $function->isMethodOrPropertyHook() + && $function->isPropertyHook() + && $function->getHookedPropertyName() === $name + && $node->var instanceof Variable + && $node->var->name === 'this'; + } +} diff --git a/tests/unit/PHPStan/MagicPropertyFetchRuleTest.php b/tests/unit/PHPStan/MagicPropertyFetchRuleTest.php new file mode 100644 index 0000000000..8648d309c9 --- /dev/null +++ b/tests/unit/PHPStan/MagicPropertyFetchRuleTest.php @@ -0,0 +1,71 @@ + + */ +final class MagicPropertyFetchRuleTest extends RuleTestCase +{ + private const string FIXTURE = __DIR__.'/Data/Fixture.php'; + + private const string MAGIC = 'Property Utopia\Database\PDOStatement::$%s is reached through __get/__set/__isset, which the PHP 8.5 tracing JIT miscompiles (php/php-src#22084). Use the wrapped \PDOStatement directly instead.'; + + private const string HOOKED = 'Property Tests\Unit\PHPStan\Data\Fixture::$%s is reached through its hook, which the PHP 8.5 tracing JIT miscompiles (php/php-src#22084). Use a plain property or a method instead.'; + + private string $sourceDirectory = __DIR__.'/Data'; + + #[\Override] + protected function getRule(): Rule + { + return new MagicPropertyFetchRule($this->sourceDirectory, self::getContainer()->getByType(FileHelper::class)); + } + + public function testReportsMagicAndHookedPropertyFetchesInTheSourceDirectory(): void + { + $this->analyse([self::FIXTURE], [ + [\sprintf(self::HOOKED, 'flag'), 27], + [\sprintf(self::HOOKED, 'flag'), 32], + [\sprintf(self::HOOKED, 'counter'), 42], + [\sprintf(self::HOOKED, 'flag'), 47], + [\sprintf(self::MAGIC, 'queryString'), 52], + [\sprintf(self::MAGIC, 'values'), 57], + [\sprintf(self::MAGIC, 'custom'), 62], + ]); + } + + public function testErrorsCarryTheRuleIdentifiers(): void + { + $identifiers = \array_map( + static fn (Error $error): ?string => $error->getIdentifier(), + $this->gatherAnalyserErrors([self::FIXTURE]), + ); + + $this->assertSame( + [MagicPropertyFetchRule::HOOKED_IDENTIFIER, MagicPropertyFetchRule::MAGIC_IDENTIFIER], + \array_values(\array_unique($identifiers)), + ); + } + + public function testIgnoresFilesOutsideTheSourceDirectory(): void + { + $this->sourceDirectory = \sys_get_temp_dir(); + + $this->analyse([self::FIXTURE], []); + } + + public function testRejectsAMissingSourceDirectory(): void + { + $missing = __DIR__.'/Data/Missing'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Source directory "'.$missing.'" does not exist, so the rule would report nothing.'); + + new MagicPropertyFetchRule($missing, self::getContainer()->getByType(FileHelper::class)); + } +} diff --git a/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php b/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php new file mode 100644 index 0000000000..766d3c08d9 --- /dev/null +++ b/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php @@ -0,0 +1,352 @@ + + */ + private const array AUTHOR_ROWS = [ + 'a1' => ['red', 1, true], + 'a2' => ['blue', 2, true], + 'a3' => ['red', 4, true], + 'hidden' => ['blue', 8, false], + 'absent' => ['green', 16, false], + ]; + + /** + * Every book as [author id, genre, pages, readable]. a1 has a readable and an unreadable book, + * a2 only an unreadable one, and "ghost" and "phantom" name no author. + * + * @var array + */ + private const array BOOK_ROWS = [ + 'b1' => ['a1', 'poetry', 10, true], + 'b2' => ['a1', 'prose', 20, false], + 'b3' => ['hidden', 'prose', 40, true], + 'b4' => ['ghost', null, 80, true], + 'b5' => ['a2', 'poetry', 160, false], + 'b6' => ['hidden', 'poetry', 320, false], + 'b7' => ['phantom', 'prose', 640, false], + ]; + + /** + * Each grant as [authors granted, books granted, the totals direct reads allow]. A granted + * collection shows every document; otherwise document security shows the readable ones. + * + * @return array + */ + private static function grants(): array + { + return [ + 'document security on both collections' => [false, false, ['rows' => 5, 'authors' => 3, 'books' => 3, 'pages' => 130, 'ranks' => 7]], + 'granted authors, document security on books' => [true, false, ['rows' => 6, 'authors' => 5, 'books' => 3, 'pages' => 130, 'ranks' => 31]], + 'document security on authors, granted books' => [false, true, ['rows' => 8, 'authors' => 4, 'books' => 7, 'pages' => 1270, 'ranks' => 8]], + 'collection grants on both collections' => [true, true, ['rows' => 9, 'authors' => 7, 'books' => 7, 'pages' => 1270, 'ranks' => 40]], + ]; + } + + /** + * @return iterable + */ + public static function configurations(): iterable + { + foreach (['emulated' => false, 'native' => true] as $mode => $native) { + foreach (['dedicated tables' => false, 'shared tables' => true] as $tables => $sharedTables) { + foreach (self::grants() as $grant => [$grantAuthors, $grantBooks, $totals]) { + yield "{$mode} full outer join, {$tables}, {$grant}" => [$native, $sharedTables, $grantAuthors, $grantBooks, $totals]; + } + } + } + } + + /** + * @param array{rows: int, authors: int, books: int, pages: int, ranks: int} $totals + */ + #[DataProvider('configurations')] + public function testAggregatesCountOnlyTheRowsDirectReadsReturn(bool $native, bool $sharedTables, bool $grantAuthors, bool $grantBooks, array $totals): void + { + $database = $this->database($native, $sharedTables, $grantAuthors, $grantBooks); + $rows = $this->directlyJoinedRows($database); + + $this->assertSame($totals, $this->totals($rows), 'Direct reads must return the readable documents of the selected tenant'); + $this->assertSame($totals, $this->readTotals($database), 'count() and sum() over the join'); + $this->assertSame($this->groupCounts($rows, 'genre'), $this->readGroupCounts($database, self::GENRE, 'genre'), 'counts grouped by a joined attribute'); + $this->assertSame($this->groupCounts($rows, self::TEAM), $this->readGroupCounts($database, self::TEAM, self::TEAM), 'counts grouped by a main attribute'); + } + + private function database(bool $native, bool $sharedTables, bool $grantAuthors, bool $grantBooks): Database + { + $pdo = new PDO('sqlite::memory:'); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database( + $native ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), + new Cache(new None()), + ); + $database + ->setAuthorization($authorization) + ->setDatabase('joins') + ->setNamespace('aggregates') + ->setSharedTables($sharedTables) + ->setTenant(null); + $database->addHook(new Permissions()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [ + Attribute::string(key: self::TEAM, size: 16, required: true), + Attribute::integer(key: 'rank', required: true), + ], + permissions: $this->collectionPermissions($grantAuthors), + documentSecurity: ! $grantAuthors, + )); + $database->createCollection(Collection::create( + id: self::BOOKS, + attributes: [ + Attribute::string(key: 'authorId', size: 16, required: true), + Attribute::string(key: 'genre', size: 16, required: false), + Attribute::integer(key: 'pages', required: true), + ], + permissions: $this->collectionPermissions($grantBooks), + documentSecurity: ! $grantBooks, + )); + + if ($sharedTables) { + $database->setTenant(self::OTHER_TENANT); + $this->seed($database, self::OTHER_TENANT_OFFSET, everyoneReads: true); + $database->setTenant(self::TENANT); + } + $this->seed($database, 0, everyoneReads: false); + + return $database; + } + + /** + * @return list + */ + private function collectionPermissions(bool $granted): array + { + return $granted + ? [Permission::create(Role::any()), Permission::read(Role::any())] + : [Permission::create(Role::any())]; + } + + private function seed(Database $database, int $offset, bool $everyoneReads): void + { + foreach (self::AUTHOR_ROWS as $id => [$team, $rank, $readable]) { + $database->createDocument(self::AUTHORS, new Document([ + '$id' => $id, + '$permissions' => [$this->readPermission($readable || $everyoneReads)], + self::TEAM => $team, + 'rank' => $rank + $offset, + ])); + } + foreach (self::BOOK_ROWS as $id => [$authorId, $genre, $pages, $readable]) { + $database->createDocument(self::BOOKS, new Document([ + '$id' => $id, + '$permissions' => [$this->readPermission($readable || $everyoneReads)], + 'authorId' => $authorId, + 'genre' => $genre, + 'pages' => $pages + $offset, + ])); + } + } + + private function readPermission(bool $readable): string + { + return $readable ? Permission::read(Role::any()) : Permission::read(Role::user('someone-else')); + } + + private function join(): Query + { + return Query::fullOuterJoin(self::BOOKS, self::BOOK, [Query::on('$id', 'authorId')]); + } + + /** + * The rows the full outer join returns over what direct reads of each collection return, with + * nulls on the side a row has no document on. + * + * @return list + */ + private function directlyJoinedRows(Database $database): array + { + $authors = []; + foreach ($database->find(self::AUTHORS, [Query::limit(100)]) as $author) { + $authors[$author->getId()] = $author; + } + + $rows = []; + $matched = []; + foreach ($database->find(self::BOOKS, [Query::limit(100)]) as $book) { + $authorId = $book->getAttribute('authorId'); + $author = \is_string($authorId) ? $authors[$authorId] ?? null : null; + if ($author !== null) { + $matched[$author->getId()] = true; + } + $rows[] = $this->row($author, $book); + } + foreach ($authors as $id => $author) { + if (! isset($matched[$id])) { + $rows[] = $this->row($author, null); + } + } + + return $rows; + } + + /** + * @return array{author: ?string, team: ?string, rank: ?int, book: ?string, genre: ?string, pages: ?int} + */ + private function row(?Document $author, ?Document $book): array + { + return [ + 'author' => $author?->getId(), + 'team' => $this->stringOrNull($author?->getAttribute(self::TEAM)), + 'rank' => $this->integerOrNull($author?->getAttribute('rank')), + 'book' => $book?->getId(), + 'genre' => $this->stringOrNull($book?->getAttribute('genre')), + 'pages' => $this->integerOrNull($book?->getAttribute('pages')), + ]; + } + + /** + * @param list $rows + * @return array{rows: int, authors: int, books: int, pages: int, ranks: int} + */ + private function totals(array $rows): array + { + return [ + 'rows' => \count($rows), + 'authors' => \count(\array_filter($rows, static fn (array $row): bool => $row['author'] !== null)), + 'books' => \count(\array_filter($rows, static fn (array $row): bool => $row['book'] !== null)), + 'pages' => \array_sum(\array_map(static fn (array $row): int => $row['pages'] ?? 0, $rows)), + 'ranks' => \array_sum(\array_map(static fn (array $row): int => $row['rank'] ?? 0, $rows)), + ]; + } + + /** + * @return array{rows: int, authors: int, books: int, pages: int, ranks: int} + */ + private function readTotals(Database $database): array + { + $documents = $database->aggregate(self::AUTHORS, [ + $this->join(), + Query::count('*', 'rows'), + Query::count('$id', 'authors'), + Query::count(self::BOOK.'.$id', 'books'), + Query::sum(self::BOOK.'.pages', 'pages'), + Query::sum('rank', 'ranks'), + ]); + $this->assertCount(1, $documents); + $totals = $documents[0]; + + return [ + 'rows' => $this->integerOrNull($totals['rows'] ?? null) ?? -1, + 'authors' => $this->integerOrNull($totals['authors'] ?? null) ?? -1, + 'books' => $this->integerOrNull($totals['books'] ?? null) ?? -1, + 'pages' => $this->integerOrNull($totals['pages'] ?? null) ?? -1, + 'ranks' => $this->integerOrNull($totals['ranks'] ?? null) ?? -1, + ]; + } + + /** + * @param list $rows + * @param 'genre'|'team' $key + * @return list + */ + private function groupCounts(array $rows, string $key): array + { + $counts = []; + foreach ($rows as $row) { + $group = \json_encode($row[$key]); + $counts[$group] = [$row[$key], ($counts[$group][1] ?? 0) + 1]; + } + + return $this->sorted(\array_values($counts)); + } + + /** + * A group comes back under the grouped attribute's own name, without the join alias. + * + * @return list + */ + private function readGroupCounts(Database $database, string $attribute, string $key): array + { + $counts = \array_map( + fn (array $group): array => [ + $this->stringOrNull($group[$key] ?? null), + $this->integerOrNull($group['rows'] ?? null) ?? -1, + ], + $database->aggregate(self::AUTHORS, [$this->join(), Query::groupBy([$attribute]), Query::count('*', 'rows')]), + ); + + return $this->sorted($counts); + } + + /** + * @param list $counts + * @return list + */ + private function sorted(array $counts): array + { + \usort($counts, static fn (array $left, array $right): int => \strcmp((string) \json_encode($left), (string) \json_encode($right))); + + return $counts; + } + + private function stringOrNull(mixed $value): ?string + { + return \is_string($value) && $value !== '' ? $value : null; + } + + private function integerOrNull(mixed $value): ?int + { + return \is_numeric($value) ? (int) $value : null; + } +} diff --git a/tests/unit/PermissionScope/JoinChainVisibilityTest.php b/tests/unit/PermissionScope/JoinChainVisibilityTest.php new file mode 100644 index 0000000000..48f8237be6 --- /dev/null +++ b/tests/unit/PermissionScope/JoinChainVisibilityTest.php @@ -0,0 +1,434 @@ + 'pages', + self::REVIEWS => 'stars', + self::EXTRAS => 'weight', + ]; + + /** + * Every document and whether the caller holds document-level read on it. The unreadable ones + * share keys with readable ones: author "hidden" has a readable book and review, "b5" and "x1" + * belong to a1 like b1 and r1 do, "b2" and "r5" to a2 like r2 does. + * + * @var array, bool}>> + */ + private const array DOCUMENTS = [ + self::AUTHORS => [ + 'a1' => [['name' => 'a1'], true], + 'a2' => [['name' => 'a2'], true], + 'hidden' => [['name' => 'hidden'], false], + ], + self::BOOKS => [ + 'b1' => [['authorId' => 'a1', 'pages' => 1], true], + 'b2' => [['authorId' => 'a2', 'pages' => 2], false], + 'b3' => [['authorId' => 'hidden', 'pages' => 3], true], + 'b4' => [['authorId' => 'ghost', 'pages' => 4], true], + 'b5' => [['authorId' => 'a1', 'pages' => 5], false], + ], + self::REVIEWS => [ + 'r1' => [['authorId' => 'a1', 'stars' => 10], true], + 'r2' => [['authorId' => 'a2', 'stars' => 20], true], + 'r3' => [['authorId' => 'hidden', 'stars' => 30], true], + 'r4' => [['authorId' => 'ghost', 'stars' => 40], true], + 'r5' => [['authorId' => 'a2', 'stars' => 50], false], + ], + self::EXTRAS => [ + 'x1' => [['authorId' => 'a1', 'weight' => 100], false], + ], + ]; + + /** + * How the caller may read each configuration's collections: a collection-level grant shows + * every document, document security alone only the documents the caller holds read on. + * + * @return array + */ + private static function grants(): array + { + return [ + 'document security on every collection' => ['authors' => false, 'joined' => false], + 'collection grants on every collection' => ['authors' => true, 'joined' => true], + 'granted authors, document security on the joined collections' => ['authors' => true, 'joined' => false], + 'document security on authors, granted joined collections' => ['authors' => false, 'joined' => true], + ]; + } + + /** + * @return iterable + */ + public static function configurations(): iterable + { + foreach (['emulated full outer join' => false, 'native full outer join' => true] as $mode => $native) { + foreach (self::grants() as $grant => ['authors' => $authors, 'joined' => $joined]) { + yield "{$mode}, {$grant}" => [$native, $authors, $joined, false]; + } + yield "{$mode}, document security on every collection, shared tables" => [$native, false, false, true]; + } + } + + /** + * @return iterable + */ + public static function outerJoins(): iterable + { + foreach (['emulated full outer join' => false, 'native full outer join' => true] as $mode => $native) { + foreach ([Method::RightJoin, Method::FullOuterJoin] as $join) { + yield "{$join->value}, {$mode}" => [$native, $join]; + } + } + } + + #[DataProvider('configurations')] + public function testEveryJoinChainReadsWhatDirectReadsAllow(bool $native, bool $grantAuthors, bool $grantJoined, bool $sharedTables): void + { + $database = $this->database($native, $grantAuthors, $grantJoined, $sharedTables); + $this->seed($database, $sharedTables); + $direct = $this->directReads($database, $native); + + $expected = []; + $actual = []; + foreach (self::chains() as $label => $joins) { + $expected[$label] = $this->joined($direct, $joins); + $actual[$label] = $this->read($database, $joins); + } + + $this->assertSame( + $expected, + $actual, + 'Every chain must return what the same joins return over the documents direct reads return', + ); + } + + #[DataProvider('outerJoins')] + public function testAReviewOfAnUnreadableAuthorComesBackLikeAReviewOfAMissingAuthor(bool $native, Method $join): void + { + $database = $this->database($native, grantAuthors: false, grantJoined: false, sharedTables: false); + $this->seed($database, sharedTables: false); + + foreach ([ + 'alone' => [$this->join($join, self::REVIEWS, self::REVIEW, '$id')], + 'after an inner join' => [$this->join(Method::Join, self::BOOKS, self::BOOK, '$id'), $this->join($join, self::REVIEWS, self::REVIEW, '$id')], + 'after a left join' => [$this->join(Method::LeftJoin, self::BOOKS, self::BOOK, '$id'), $this->join($join, self::REVIEWS, self::REVIEW, '$id')], + ] as $label => $joins) { + $stars = []; + foreach ($database->find(self::AUTHORS, [...$joins, Query::select(['name', self::REVIEW.'.stars'])]) as $document) { + $value = $document->getAttribute(self::REVIEW.'.stars'); + if (\is_numeric($value) && \in_array((int) $value, [30, 40], true)) { + $name = $document->getAttribute('name'); + $stars[(int) $value] = \is_string($name) && $name !== '' ? $name : null; + } + } + + $this->assertSame( + [30 => null, 40 => null], + $this->sortedByKey($stars), + "Through a {$join->value} {$label}, the review of the unreadable author must come back unmatched, like the review of the author that does not exist", + ); + } + } + + public function testCombiningAFullOuterJoinWithARightJoinReadsWhatDirectReadsAllow(): void + { + foreach ([false, true] as $native) { + foreach ([false, true] as $sharedTables) { + $database = $this->database($native, grantAuthors: false, grantJoined: false, sharedTables: $sharedTables); + $this->seed($database, $sharedTables); + + $joins = [ + $this->join(Method::FullOuterJoin, self::BOOKS, self::BOOK, '$id'), + $this->join(Method::RightJoin, self::REVIEWS, self::REVIEW, '$id'), + ]; + $expected = $this->joined($this->directReads($database, $native), $joins); + + $this->assertNotSame(self::REJECTED, $expected, 'Direct reads must answer the combination'); + $this->assertSame($expected, $this->read($database, $joins), 'The combination must read what direct reads allow, with and without shared tables'); + } + } + } + + /** + * Two joins of every kind in a row, the second joined on the main table or on the first join, + * and every kind of join alone. + * + * @return array> + */ + private static function chains(): array + { + $chains = []; + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin, Method::CrossJoin] as $first) { + [$collection, $alias] = $first === Method::CrossJoin ? [self::EXTRAS, self::EXTRA] : [self::BOOKS, self::BOOK]; + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin] as $second) { + foreach (['$id' => self::AUTHORS, $alias.'.authorId' => $alias] as $on => $target) { + $chains["{$first->value} {$collection}, {$second->value} reviews on {$target}"] = [ + self::join($first, $collection, $alias, '$id'), + self::join($second, self::REVIEWS, self::REVIEW, $on), + ]; + } + } + } + foreach ([Method::Join, Method::LeftJoin, Method::RightJoin, Method::FullOuterJoin, Method::CrossJoin] as $single) { + $chains["{$single->value} books alone"] = [self::join($single, self::BOOKS, self::BOOK, '$id')]; + } + + return $chains; + } + + private static function join(Method $method, string $collection, string $alias, string $on): Query + { + return match ($method) { + Method::Join => Query::join($collection, $alias, [Query::on($on, 'authorId')]), + Method::LeftJoin => Query::leftJoin($collection, $alias, [Query::on($on, 'authorId')]), + Method::RightJoin => Query::rightJoin($collection, $alias, [Query::on($on, 'authorId')]), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $alias, [Query::on($on, 'authorId')]), + Method::CrossJoin => Query::crossJoin($collection, $alias), + default => throw new \InvalidArgumentException("{$method->value} is not a join this test covers"), + }; + } + + private function database(bool $native, bool $grantAuthors, bool $grantJoined, bool $sharedTables): Database + { + $pdo = new PDO('sqlite::memory:'); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database( + $native ? new NativeFullOuterJoinSQLite($pdo) : new SQLite($pdo), + new Cache(new None()), + ); + $database + ->setAuthorization($authorization) + ->setDatabase('joins') + ->setNamespace('visibility') + ->setSharedTables($sharedTables) + ->setTenant(null); + $database->addHook(new Permissions()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $this->collectionPermissions($grantAuthors), + documentSecurity: true, + )); + foreach (self::NUMBERS as $collection => $number) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'authorId', size: 64, required: true), + Attribute::integer(key: $number, required: true), + ], + permissions: $this->collectionPermissions($grantJoined), + documentSecurity: ! $grantJoined, + )); + } + + return $database; + } + + /** + * @return list + */ + private function collectionPermissions(bool $granted): array + { + return $granted + ? [Permission::create(Role::any()), Permission::read(Role::any())] + : [Permission::create(Role::any())]; + } + + /** + * Under shared tables another tenant holds a readable copy of every document, so a join that + * pairs across tenants would find a match for each one. + */ + private function seed(Database $database, bool $sharedTables): void + { + $tenants = $sharedTables ? [self::OTHER_TENANT, self::TENANT] : [null]; + + foreach ($tenants as $tenant) { + $database->setTenant($tenant); + foreach (self::DOCUMENTS as $collection => $documents) { + foreach ($documents as $id => [$attributes, $readable]) { + $database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [ + $readable || $tenant === self::OTHER_TENANT + ? Permission::read(Role::any()) + : Permission::read(Role::user('someone-else')), + ], + ...$attributes, + ])); + } + } + } + } + + /** + * A database of the documents direct reads of each collection return, with nothing left to + * filter: every collection granted, no document security, no other tenant. + */ + private function directReads(Database $database, bool $native): Database + { + $direct = $this->database($native, grantAuthors: true, grantJoined: true, sharedTables: false); + foreach (\array_keys(self::DOCUMENTS) as $collection) { + foreach ($database->find($collection, [Query::limit(100)]) as $document) { + $direct->createDocument($collection, new Document([ + '$id' => $document->getId(), + '$permissions' => [Permission::read(Role::any())], + ...\array_intersect_key( + $document->getArrayCopy(), + \array_flip(['name', 'authorId', ...\array_values(self::NUMBERS)]), + ), + ])); + } + } + + return $direct; + } + + /** + * The rows as [author name, then each join's number], their count and the sum of the last + * join's number, as find(), count() and sum() return them, or REJECTED when refused. + * + * @param list $joins + * @return array{rows: list>, count: int, sum: int|float}|string + */ + private function read(Database $database, array $joins): array|string + { + $copies = static fn (): array => \array_map(static fn (Query $join): Query => clone $join, $joins); + $numbers = $this->numbers($joins); + + try { + return [ + 'rows' => $this->rows($database, $copies(), $numbers), + 'count' => $database->count(self::AUTHORS, $copies()), + 'sum' => $database->sum(self::AUTHORS, $numbers[\count($numbers) - 1], $copies()), + ]; + } catch (QueryException) { + return self::REJECTED; + } + } + + /** + * What read() must return, taken from the rows alone: nothing filters the joins of a + * database of direct reads, so its count and sum follow from its rows. A chain the adapter + * refuses outright is refused here too. + * + * @param list $joins + * @return array{rows: list>, count: int, sum: int}|string + */ + private function joined(Database $direct, array $joins): array|string + { + try { + $rows = $this->rows( + $direct, + \array_map(static fn (Query $join): Query => clone $join, $joins), + $this->numbers($joins), + ); + } catch (QueryException) { + return self::REJECTED; + } + + return [ + 'rows' => $rows, + 'count' => \count($rows), + 'sum' => \array_sum(\array_map(static fn (array $row): int => (int) $row[\count($row) - 1], $rows)), + ]; + } + + /** + * @param list $joins + * @return list + */ + private function numbers(array $joins): array + { + return \array_map( + static fn (Query $join): string => $join->getAlias().'.'.self::NUMBERS[$join->getAttribute()], + $joins, + ); + } + + /** + * @param list $joins + * @param list $numbers + * @return list> + */ + private function rows(Database $database, array $joins, array $numbers): array + { + $rows = \array_map(static function (Document $document) use ($numbers): array { + $name = $document->getAttribute('name'); + $row = [\is_string($name) && $name !== '' ? $name : null]; + foreach ($numbers as $number) { + $value = $document->getAttribute($number); + $row[] = \is_numeric($value) ? (int) $value : null; + } + + return $row; + }, $database->find(self::AUTHORS, [...$joins, Query::select(['name', ...$numbers]), Query::limit(100)])); + \usort($rows, static fn (array $left, array $right): int => \json_encode($left) <=> \json_encode($right)); + + return $rows; + } + + /** + * @param array $values + * @return array + */ + private function sortedByKey(array $values): array + { + \ksort($values); + + return $values; + } +} diff --git a/tests/unit/PermissionScope/JoinVisibilityTest.php b/tests/unit/PermissionScope/JoinVisibilityTest.php new file mode 100644 index 0000000000..b16c92c806 --- /dev/null +++ b/tests/unit/PermissionScope/JoinVisibilityTest.php @@ -0,0 +1,254 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase('join_visibility') + ->setNamespace('join_visibility_'.\uniqid()) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + + $this->database->createCollection(Collection::create( + id: 'customers', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'visits'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: true, + )); + $this->database->createCollection(Collection::create( + id: 'profiles', + attributes: [ + Attribute::string(key: 'customerId', size: 64), + Attribute::string(key: 'bio', size: 64), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: true, + )); + $this->database->createCollection(Collection::create( + id: 'orders', + attributes: [ + Attribute::string(key: 'customerId', size: 64), + Attribute::integer(key: 'amount'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: true, + )); + $this->database->createCollection(Collection::create( + id: 'notes', + attributes: [ + Attribute::string(key: 'customerId', size: 64), + Attribute::string(key: 'text', size: 64), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $this->database->createCollection(Collection::create( + id: 'ledger', + attributes: [ + Attribute::string(key: 'customerId', size: 64), + Attribute::integer(key: 'balance'), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: false, + )); + + $this->create('customers', 'open', ['name' => 'Open', 'visits' => 1], [Permission::read(Role::any())]); + $this->create('customers', 'bare', ['name' => 'Bare', 'visits' => 10], []); + $this->create('profiles', 'open-profile', ['customerId' => 'open', 'bio' => 'Hello'], [Permission::read(Role::any())]); + $this->create('orders', 'public-order', ['customerId' => 'open', 'amount' => 100], [Permission::read(Role::any())]); + $this->create('orders', 'secret-order', ['customerId' => 'open', 'amount' => 9999], [Permission::read(Role::user('other'))]); + $this->create('notes', 'alice-note', ['customerId' => 'open', 'text' => 'mine'], [Permission::read(Role::user('alice'))]); + $this->create('notes', 'bob-note', ['customerId' => 'open', 'text' => 'theirs'], [Permission::read(Role::user('bob'))]); + $this->create('ledger', 'entry', ['customerId' => 'open', 'balance' => 5], [Permission::read(Role::any())]); + + $authorization = $this->database->getAuthorization(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + $authorization->addRole(Role::user('alice')->toString()); + } + + public function testJoinKeepsMainRowsReadableThroughTheCollectionGrant(): void + { + $join = Query::leftJoin('profiles', 'profile', [Query::on('$id', 'customerId')]); + + $this->assertSame(['bare', 'open'], $this->ids($this->database->find('customers'))); + $this->assertSame( + ['bare', 'open'], + $this->ids($this->database->find('customers', [$join, Query::select(['name', 'profile.bio'])])), + 'A left join is additive: it must not hide a row the collection grant makes readable', + ); + + $this->assertSame(2, $this->database->count('customers')); + $this->assertSame(2, $this->database->count('customers', [$join])); + + $this->assertSame(11, $this->database->sum('customers', 'visits')); + $this->assertSame(11, $this->database->sum('customers', 'visits', [$join])); + + $this->assertSame('bare', $this->database->getDocument('customers', 'bare')->getId()); + $this->assertSame('bare', $this->database->getDocument('customers', 'bare', [$join])->getId()); + } + + public function testJoinedCollectionWithCollectionGrantShowsEveryRow(): void + { + $join = Query::join('orders', 'ord', [Query::on('$id', 'customerId')]); + + $this->assertSame([100, 9999], $this->integers($this->database->find('orders'), 'amount')); + $this->assertSame( + [100, 9999], + $this->integers($this->database->find('customers', [$join, Query::select(['name', 'ord.amount'])]), 'ord.amount'), + 'The collection grant makes every order readable directly, so the join must show every order', + ); + + $this->assertSame(2, $this->database->count('orders')); + $this->assertSame(2, $this->database->count('customers', [$join])); + $this->assertSame(2, $this->database->sum('customers', 'visits', [$join])); + } + + public function testJoinedCollectionWithOnlyDocumentGrantsShowsTheCallerRows(): void + { + $join = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + $this->assertSame(['mine'], $this->strings($this->database->find('notes'), 'text')); + $this->assertSame( + ['mine'], + $this->strings($this->database->find('customers', [$join, Query::select(['name', 'note.text'])]), 'note.text'), + 'Without a collection grant the joined rows are filtered per document, exactly like a direct list', + ); + + $this->assertSame(1, $this->database->count('notes')); + $this->assertSame(1, $this->database->count('customers', [$join])); + $this->assertSame(1, $this->database->sum('customers', 'visits', [$join])); + + $document = $this->database->getDocument('customers', 'open', [$join, Query::select(['name', 'note.text'])]); + $this->assertSame('mine', $document->getAttribute('note.text')); + } + + public function testTheAdapterHonoursTheGrantOnlyOnJoinReads(): void + { + $adapter = $this->database->getAdapter(); + $collection = clone $this->database->getCollection('customers'); + $collection->setAttribute(Database::COLLECTION_GRANTED, true); + $collection->setAttribute(Database::JOIN_DOCUMENT_SECURITY, ['profiles' => false]); + + $this->assertSame(['open'], $this->ids($adapter->find($collection)), 'Without joins the Database layer grants by disabling authorization, never by marking the collection'); + $this->assertSame(1, $adapter->count($collection)); + $this->assertSame(1, $adapter->sum($collection, 'visits')); + $this->assertSame(['bare', 'open'], $this->ids($adapter->find($collection, [Query::leftJoin('profiles', 'profile', [Query::on('$id', 'customerId')])]))); + } + + public function testJoinedCollectionWithoutGrantOrDocumentSecurityIsRejected(): void + { + $join = Query::join('ledger', 'ledger', [Query::on('$id', 'customerId')]); + + $this->assertRejected(fn () => $this->database->find('ledger')); + $this->assertRejected(fn () => $this->database->find('customers', [$join]), "joined collection 'ledger'"); + $this->assertRejected(fn () => $this->database->count('customers', [$join]), "joined collection 'ledger'"); + $this->assertRejected(fn () => $this->database->sum('customers', 'visits', [$join]), "joined collection 'ledger'"); + $this->assertRejected(fn () => $this->database->getDocument('customers', 'open', [$join]), "joined collection 'ledger'"); + } + + /** + * @param array $attributes + * @param array $permissions + */ + private function create(string $collection, string $id, array $attributes, array $permissions): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => $permissions, + ...$attributes, + ])); + } + + private function assertRejected(callable $read, string $message = ''): void + { + try { + $read(); + } catch (AuthorizationException $exception) { + $this->assertStringContainsString($message, $exception->getMessage()); + + return; + } + + $this->fail('Reading a collection the caller holds neither a collection nor a document grant on must be rejected'); + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + $ids = \array_values(\array_unique(\array_map(static fn (Document $document): string => $document->getId(), $documents))); + \sort($ids); + + return $ids; + } + + /** + * @param array $documents + * @return list + */ + private function integers(array $documents, string $attribute): array + { + $values = []; + foreach ($documents as $document) { + $value = $document->getAttribute($attribute); + if (\is_numeric($value)) { + $values[] = (int) $value; + } + } + \sort($values); + + return $values; + } + + /** + * @param array $documents + * @return list + */ + private function strings(array $documents, string $attribute): array + { + $values = []; + foreach ($documents as $document) { + $value = $document->getAttribute($attribute); + if (\is_string($value)) { + $values[] = $value; + } + } + \sort($values); + + return $values; + } +} diff --git a/tests/unit/PermissionScope/MetadataVisibilityTest.php b/tests/unit/PermissionScope/MetadataVisibilityTest.php new file mode 100644 index 0000000000..f4f87007dc --- /dev/null +++ b/tests/unit/PermissionScope/MetadataVisibilityTest.php @@ -0,0 +1,137 @@ +database(); + $database->create(); + $database->createCollection(Collection::create(id: 'public', permissions: [Permission::read(Role::any())])); + $database->createCollection(Collection::create(id: 'private', permissions: [Permission::read(Role::user('admin'))])); + $database->createCollection(Collection::create(id: 'unlisted', permissions: [Permission::create(Role::any())])); + + $authorization = $database->getAuthorization(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + $this->assertSame(['public'], $this->ids($database->listCollections())); + $this->assertSame(['public'], $this->ids($database->find(Database::METADATA))); + $this->assertSame(1, $database->count(Database::METADATA), 'count() and find() must agree on the metadata collection'); + + $authorization->addRole(Role::user('admin')->toString()); + + $this->assertSame(['private', 'public'], $this->ids($database->listCollections())); + $this->assertSame(2, $database->count(Database::METADATA)); + + $everything = $authorization->skip(fn (): array => $database->listCollections()); + $this->assertSame(['private', 'public', 'unlisted'], $this->ids($everything)); + } + + public function testTenantlessDefinitionsStayReadableFromEveryTenantOfASharedPool(): void + { + $database = $this->database(); + $database->setSharedTables(true)->setTenant(null); + $database->create(); + $database->createCollection(Collection::create(id: 'pooled', permissions: [Permission::read(Role::any())])); + + $database->setTenant(1); + $database->createCollection(Collection::create(id: 'owned', permissions: [Permission::read(Role::any())])); + + $database->setTenant(990); + $pooled = [Query::equal('$id', ['pooled'])]; + + $this->assertSame(1, $database->count(Database::METADATA, $pooled), 'A tenantless definition carries tenantless permission rows'); + $this->assertSame(['pooled'], $this->ids($database->find(Database::METADATA, $pooled))); + $this->assertSame(['pooled'], $this->ids($database->listCollections()), 'Another tenant\'s definition must stay invisible'); + } + + public function testMetadataPermissionSubqueryMatchesTenantlessRows(): void + { + $this->assertStringContainsString( + Storage::TENANT.' IS NULL', + $this->permissionSubquery(new Document(['$id' => Database::METADATA])), + 'The metadata permissions table holds tenantless rows for pooled definitions', + ); + + $this->assertStringNotContainsString( + Storage::TENANT.' IS NULL', + $this->permissionSubquery(new Document(['$id' => 'orders', 'documentSecurity' => true])), + 'A project collection\'s permission rows must stay strictly tenanted', + ); + } + + private function permissionSubquery(Document $collection): string + { + $statement = self::createStub(\PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + + $sql = ''; + $pdo = self::createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setSharedTables(true); + $adapter->setTenant(990); + $adapter->setAuthorization(new Authorization()); + + $adapter->find($collection); + + $subquery = \strpos($sql, '_perms`'); + $this->assertNotFalse($subquery, $sql); + + return \substr($sql, $subquery); + } + + private function database(): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $database + ->setDatabase('metadata_visibility') + ->setNamespace('metadata_visibility_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + + return $database; + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + $ids = \array_map(static fn (Document $document): string => $document->getId(), $documents); + \sort($ids); + + return $ids; + } +} diff --git a/tests/unit/PermissionSubqueryTest.php b/tests/unit/PermissionSubqueryTest.php new file mode 100644 index 0000000000..ead5991d3c --- /dev/null +++ b/tests/unit/PermissionSubqueryTest.php @@ -0,0 +1,455 @@ +_perms …)`. IN already + * compares against a set, so the subquery needs no DISTINCT: SQLite builds a temporary B-tree for one + * on every restricted read. + * + * On MySQL a joined table's check carries NO_SEMIJOIN when the table is outer-joined, whatever the + * number of joins, and when the read has LARGE_JOIN joins or more. + */ +final class PermissionSubqueryTest extends TestCase +{ + private const string NAMESPACE = 'subquery'; + + private const string COLLECTION = 'posts'; + + private const string TABLE = '`'.self::NAMESPACE.'_'.self::COLLECTION.'`'; + + private const string DISTINCT_STEP = 'USE TEMP B-TREE FOR DISTINCT'; + + private const string DETAIL_COLUMN = 'detail'; + + private const int DOCUMENTS = 60; + + private const int TENANT = 1; + + private const string READER = 'alice'; + + private const string OTHER_READER = 'other-'; + + private const string NO_SEMIJOIN = '/*+ NO_SEMIJOIN() */ '; + + private const int LARGE_JOIN = 5; + + private const string MAIN_ALIAS = 'table_main'; + + private PDO $pdo; + + private Authorization $authorization; + + /** + * @var ArrayObject}> + */ + private ArrayObject $statements; + + #[\Override] + protected function setUp(): void + { + $this->statements = new ArrayObject(); + } + + /** + * @return iterable + */ + public static function modes(): iterable + { + yield 'plain tables' => [false]; + yield 'shared tables' => [true]; + } + + #[DataProvider('modes')] + public function testRestrictedFindBuildsNoDistinctStep(bool $shared): void + { + $database = $this->database($shared); + + $documents = $this->recording(fn (): array => $database->find(self::COLLECTION, [ + Query::greaterThan('score', -1), + Query::notEqual('name', 'none'), + Query::limit(self::DOCUMENTS), + ])); + + $this->assertSame($this->readableIds(), $this->ids($documents)); + $this->assertNoDistinctStep(); + } + + #[DataProvider('modes')] + public function testRestrictedCountBuildsNoDistinctStep(bool $shared): void + { + $database = $this->database($shared); + + $total = $this->recording(fn (): int => $database->count(self::COLLECTION, [Query::greaterThan('score', -1)])); + + $this->assertSame(\count($this->readableIds()), $total); + $this->assertNoDistinctStep(); + } + + #[DataProvider('modes')] + public function testJoinedCheckBuildsNoDistinctStep(bool $shared): void + { + $database = $this->database($shared); + + $documents = $this->recording(fn (): array => $database->find(self::COLLECTION, [ + Query::join(self::COLLECTION, 'peer', [Query::on('$id', '$id')]), + Query::limit(self::DOCUMENTS), + ])); + + $this->assertSame($this->readableIds(), $this->ids($documents)); + $this->assertNoDistinctStep(); + } + + #[DataProvider('modes')] + public function testDocumentReadableThroughSeveralRolesIsReturnedOnce(bool $shared): void + { + $database = $this->database($shared); + $this->authorization->addRole(Role::any()->toString()); + + $documents = $database->find(self::COLLECTION, [Query::limit(self::DOCUMENTS)]); + + $ids = $this->ids($documents); + $this->assertSame(\array_values(\array_unique($ids)), $ids, 'A document several roles may read must come back once'); + $this->assertSame($this->readableIds(), $ids); + } + + /** + * @return iterable + */ + public static function mySQLJoinChains(): iterable + { + foreach ([JoinType::Left, JoinType::Right] as $joinType) { + for ($links = 1; $links < self::LARGE_JOIN; $links++) { + yield $joinType->name.' join chain of '.$links => [$joinType, $links, true]; + } + } + yield 'FullOuter join of 1' => [JoinType::FullOuter, 1, true]; + for ($links = 1; $links < self::LARGE_JOIN; $links++) { + yield 'Inner join chain of '.$links => [JoinType::Inner, $links, false]; + } + yield 'Inner join chain of '.self::LARGE_JOIN => [JoinType::Inner, self::LARGE_JOIN, true]; + yield 'Left join chain of '.self::LARGE_JOIN => [JoinType::Left, self::LARGE_JOIN, true]; + } + + #[DataProvider('mySQLJoinChains')] + public function testMySQLJoinedChecksStaySubqueriesUnderOuterJoins(JoinType $joinType, int $links, bool $hinted): void + { + $sql = $this->mySQLFindSql(\array_map( + static fn (int $link): Query => self::join($joinType, 'orders'.$link, 'o'.$link), + \range(1, $links), + )); + + for ($link = 1; $link <= $links; $link++) { + $checks = $this->checks($sql, 'o'.$link); + $this->assertNotSame([], $checks, 'Every joined table must be checked: '.$sql); + foreach ($checks as $hint) { + $this->assertSame($hinted, $hint, 'The check of o'.$link.' in: '.$sql); + } + } + + $main = $this->checks($sql, self::MAIN_ALIAS); + $this->assertNotSame([], $main, 'The main table must be checked: '.$sql); + $this->assertNotContains(true, $main, 'The main table\'s check stays a semi-join candidate: '.$sql); + } + + public function testMySQLMixedChainHintsOnlyTheOuterJoinedChecks(): void + { + $sql = $this->mySQLFindSql([ + self::join(JoinType::Inner, 'orders1', 'o1'), + self::join(JoinType::Left, 'orders2', 'o2'), + self::join(JoinType::Inner, 'orders3', 'o3'), + ]); + + $this->assertSame([false], $this->checks($sql, 'o1'), $sql); + $this->assertSame([true], $this->checks($sql, 'o2'), $sql); + $this->assertSame([false], $this->checks($sql, 'o3'), $sql); + } + + public function testMySQLUnaliasedOuterJoinIsHinted(): void + { + $sql = $this->mySQLFindSql([Query::leftJoin('orders', 'j0', [Query::on('$id', 'customerId')])]); + + $this->assertSame(1, \substr_count($sql, self::NO_SEMIJOIN), $sql); + } + + /** + * @return iterable + */ + public static function mySQLJoinsOnTheJoinedId(): iterable + { + foreach ([JoinType::Left, JoinType::Right] as $joinType) { + for ($links = 1; $links <= self::LARGE_JOIN; $links++) { + yield $joinType->name.' join chain of '.$links => [$joinType, $links, true]; + } + } + yield 'FullOuter join of 1' => [JoinType::FullOuter, 1, true]; + for ($links = 1; $links < self::LARGE_JOIN; $links++) { + yield 'Inner join chain of '.$links => [JoinType::Inner, $links, false]; + } + yield 'Inner join chain of '.self::LARGE_JOIN => [JoinType::Inner, self::LARGE_JOIN, true]; + } + + #[DataProvider('mySQLJoinsOnTheJoinedId')] + public function testMySQLJoinOnTheJoinedIdIsHintedLikeAnyOuterJoin(JoinType $joinType, int $links, bool $hinted): void + { + $sql = $this->mySQLFindSql(\array_map( + static fn (int $link): Query => self::joinOnId($joinType, 'orders'.$link, 'o'.$link), + \range(1, $links), + )); + + for ($link = 1; $link <= $links; $link++) { + $checks = $this->checks($sql, 'o'.$link); + $this->assertNotSame([], $checks, 'Every joined table must be checked: '.$sql); + foreach ($checks as $hint) { + $this->assertSame($hinted, $hint, 'The check of o'.$link.' in: '.$sql); + } + } + + $this->assertNotContains(true, $this->checks($sql, self::MAIN_ALIAS), 'The main table\'s check stays a semi-join candidate: '.$sql); + } + + public function testMySQLMixedOuterJoinShapesHintOnlyTheOuterJoinedChecks(): void + { + $sql = $this->mySQLFindSql([ + self::joinOnId(JoinType::Left, 'orders1', 'o1'), + self::join(JoinType::Left, 'orders2', 'o2'), + Query::leftJoin('orders3', 'o3', [Query::on('o1.customerId', '$id')]), + Query::join('orders4', 'o4', [Query::on('customerId', '$id')]), + ]); + + $this->assertSame([true], $this->checks($sql, 'o1'), $sql); + $this->assertSame([true], $this->checks($sql, 'o2'), $sql); + $this->assertSame([true], $this->checks($sql, 'o3'), $sql); + $this->assertSame([false], $this->checks($sql, 'o4'), $sql); + } + + private static function joinOnId(JoinType $joinType, string $collection, string $alias): Query + { + return match ($joinType) { + JoinType::Left => Query::leftJoin($collection, $alias, [Query::on('customerId', '$id')]), + JoinType::Right => Query::rightJoin($collection, $alias, [Query::on('customerId', '$id')]), + JoinType::FullOuter => Query::fullOuterJoin($collection, $alias, [Query::on('customerId', '$id')]), + default => Query::join($collection, $alias, [Query::on('customerId', '$id')]), + }; + } + + private static function join(JoinType $joinType, string $collection, string $alias): Query + { + return match ($joinType) { + JoinType::Left => Query::leftJoin($collection, $alias, [Query::on('$id', 'customerId')]), + JoinType::Right => Query::rightJoin($collection, $alias, [Query::on('$id', 'customerId')]), + JoinType::FullOuter => Query::fullOuterJoin($collection, $alias, [Query::on('$id', 'customerId')]), + default => Query::join($collection, $alias, [Query::on('$id', 'customerId')]), + }; + } + + /** + * @param list $queries + */ + private function mySQLFindSql(array $queries): string + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new MySQL($pdo); + $adapter->setDatabase(self::NAMESPACE); + $adapter->setNamespace(self::NAMESPACE); + $authorization = new Authorization(); + $authorization->addRole(Role::user(self::READER)->toString()); + $adapter->setAuthorization($authorization); + + $adapter->find(new Document(['$id' => self::COLLECTION, 'documentSecurity' => true]), $queries, limit: 25); + + $this->assertNotSame('', $sql); + + return $sql; + } + + /** + * Whether each check of $alias in $sql carries the NO_SEMIJOIN hint. + * + * @return list + */ + private function checks(string $sql, string $alias): array + { + \preg_match_all('/`'.\preg_quote($alias, '/').'`\.`_uid` IN \(SELECT (\/\*\+ NO_SEMIJOIN\(\) \*\/ )?_document /', $sql, $matches); + + return \array_map(static fn (string $hint): bool => $hint !== '', $matches[1]); + } + + private function database(bool $shared): Database + { + $this->pdo = new PDO('sqlite::memory:'); + $this->authorization = new Authorization(); + + $adapter = new class ($this->pdo, $this->statements) extends SQLite { + /** + * @param ArrayObject}> $statements + */ + public function __construct(object $pdo, private readonly ArrayObject $statements) + { + parent::__construct($pdo); + } + + #[\Override] + protected function prepareStatement(string $sql, ?Event $event = null): DatabasePDOStatement|PDOStatementProxy|PDOStatement + { + $this->statements->append([$sql, []]); + + return parent::prepareStatement($sql, $event); + } + + #[\Override] + protected function executeResult(Statement $result, ?Event $event = null, string $collection = ''): PDOStatement|DatabasePDOStatement|PDOStatementProxy + { + $statement = parent::executeResult($result, $event, $collection); + $this->statements[$this->statements->count() - 1] = [$result->query, $result->bindings]; + + return $statement; + } + }; + + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization($this->authorization); + $database->addHook(new Permissions()); + + if ($shared) { + $database->setSharedTables(true)->setTenant(self::TENANT); + } + + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string('name', size: 64), + Attribute::integer('score'), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + $documents = []; + for ($position = 0; $position < self::DOCUMENTS; $position++) { + $readable = $position % 3 === 0; + $documents[] = new Document([ + '$id' => $this->id($position), + 'name' => 'name-'.$position, + 'score' => $position, + '$permissions' => $readable + ? [Permission::read(Role::user(self::READER)), Permission::read(Role::any())] + : [Permission::read(Role::user(self::OTHER_READER.$position))], + ]); + } + $database->createDocuments(self::COLLECTION, $documents); + + $this->pdo->exec('ANALYZE'); + $this->authorization->addRole(Role::user(self::READER)->toString()); + + return $database; + } + + /** + * @template T + * + * @param callable(): T $operation + * @return T + */ + private function recording(callable $operation): mixed + { + $this->statements->exchangeArray([]); + + return $operation(); + } + + private function assertNoDistinctStep(): void + { + $checked = 0; + foreach ($this->statements as [$sql, $bindings]) { + if (! \str_contains($sql, 'SELECT') || ! \str_contains($sql, self::TABLE) || ! \str_contains($sql, '_perms')) { + continue; + } + + $checked++; + $plan = $this->plan($sql, $bindings); + $this->assertNotContains(self::DISTINCT_STEP, $plan, 'The permission subquery built a DISTINCT step: '.$sql."\n ".\implode("\n ", $plan)); + } + + $this->assertGreaterThan(0, $checked, 'The operation must have read the collection through its permission check'); + } + + /** + * @param list $bindings + * @return list + */ + private function plan(string $sql, array $bindings): array + { + $statement = $this->pdo->prepare('EXPLAIN QUERY PLAN '.$sql); + $this->assertInstanceOf(PDOStatement::class, $statement); + $statement->execute(\array_map(static fn (mixed $value): mixed => \is_bool($value) ? (int) $value : $value, $bindings)); + + /** @var list> $rows */ + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + return \array_map(static fn (array $row): string => (string) $row[self::DETAIL_COLUMN], $rows); + } + + private function id(int $position): string + { + return 'doc'.\str_pad((string) $position, 3, '0', STR_PAD_LEFT); + } + + /** + * @return list + */ + private function readableIds(): array + { + return \array_map($this->id(...), \range(0, self::DOCUMENTS - 1, 3)); + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): string => $document->getId(), $documents)); + } +} diff --git a/tests/unit/PermissionTest.php b/tests/unit/PermissionTest.php index 6ca554f378..047f7fecf5 100644 --- a/tests/unit/PermissionTest.php +++ b/tests/unit/PermissionTest.php @@ -3,14 +3,14 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; +use Utopia\Database\Role; class PermissionTest extends TestCase { - public function testOutputFromString(): void + public function test_output_from_string(): void { $permission = Permission::parse('read("any")'); $this->assertEquals('read', $permission->getPermission()); @@ -141,7 +141,7 @@ public function testOutputFromString(): void $this->assertEquals('unverified', $permission->getDimension()); } - public function testInputFromParameters(): void + public function test_input_from_parameters(): void { $permission = new Permission('read', 'any'); $this->assertEquals('read("any")', $permission->toString()); @@ -192,7 +192,7 @@ public function testInputFromParameters(): void $this->assertEquals('delete("team:123/admin")', $permission->toString()); } - public function testInputFromRoles(): void + public function test_input_from_roles(): void { $permission = Permission::read(Role::any()); $this->assertEquals('read("any")', $permission); @@ -200,10 +200,10 @@ public function testInputFromRoles(): void $permission = Permission::read(Role::users()); $this->assertEquals('read("users")', $permission); - $permission = Permission::read(Role::user(ID::custom('123'))); + $permission = Permission::read(Role::user(Id::custom('123'))); $this->assertEquals('read("user:123")', $permission); - $permission = Permission::read(Role::team(ID::custom('123'), 'admin')); + $permission = Permission::read(Role::team(Id::custom('123'), 'admin')); $this->assertEquals('read("team:123/admin")', $permission); $permission = Permission::read(Role::guests()); @@ -215,10 +215,10 @@ public function testInputFromRoles(): void $permission = Permission::create(Role::users()); $this->assertEquals('create("users")', $permission); - $permission = Permission::create(Role::user(ID::custom('123'))); + $permission = Permission::create(Role::user(Id::custom('123'))); $this->assertEquals('create("user:123")', $permission); - $permission = Permission::create(Role::team(ID::custom('123'), 'admin')); + $permission = Permission::create(Role::team(Id::custom('123'), 'admin')); $this->assertEquals('create("team:123/admin")', $permission); $permission = Permission::create(Role::guests()); @@ -230,10 +230,10 @@ public function testInputFromRoles(): void $permission = Permission::update(Role::users()); $this->assertEquals('update("users")', $permission); - $permission = Permission::update(Role::user(ID::custom('123'))); + $permission = Permission::update(Role::user(Id::custom('123'))); $this->assertEquals('update("user:123")', $permission); - $permission = Permission::update(Role::team(ID::custom('123'), 'admin')); + $permission = Permission::update(Role::team(Id::custom('123'), 'admin')); $this->assertEquals('update("team:123/admin")', $permission); $permission = Permission::update(Role::guests()); @@ -245,10 +245,10 @@ public function testInputFromRoles(): void $permission = Permission::delete(Role::users()); $this->assertEquals('delete("users")', $permission); - $permission = Permission::delete(Role::user(ID::custom('123'))); + $permission = Permission::delete(Role::user(Id::custom('123'))); $this->assertEquals('delete("user:123")', $permission); - $permission = Permission::delete(Role::team(ID::custom('123'), 'admin')); + $permission = Permission::delete(Role::team(Id::custom('123'), 'admin')); $this->assertEquals('delete("team:123/admin")', $permission); $permission = Permission::delete(Role::guests()); @@ -258,7 +258,7 @@ public function testInputFromRoles(): void $this->assertEquals('write("any")', $permission); } - public function testInvalidFormats(): void + public function test_invalid_formats(): void { try { Permission::parse('read'); @@ -289,16 +289,26 @@ public function testInvalidFormats(): void } } + public function test_custom_type_rejected_as_in_7_4(): void + { + try { + Permission::parse('execute("any")'); + $this->fail('Failed to throw Exception'); + } catch (\Exception $e) { + $this->assertSame('Invalid permission type: "execute".', $e->getMessage()); + } + } + /** * @throws \Exception */ - public function testAggregation(): void + public function test_aggregation(): void { $permissions = ['write("any")']; $parsed = Permission::aggregate($permissions); $this->assertEquals(['create("any")', 'update("any")', 'delete("any")'], $parsed); - $parsed = Permission::aggregate($permissions, [Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE]); + $parsed = Permission::aggregate($permissions, [PermissionType::Update, PermissionType::Delete]); $this->assertEquals(['update("any")', 'delete("any")'], $parsed); $permissions = [ @@ -307,10 +317,10 @@ public function testAggregation(): void 'read("user:123")', 'write("user:123")', 'update("user:123")', - 'delete("user:123")' + 'delete("user:123")', ]; - $parsed = Permission::aggregate($permissions, Database::PERMISSIONS); + $parsed = Permission::aggregate($permissions, [PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete]); $this->assertEquals([ 'read("any")', 'read("user:123")', diff --git a/tests/unit/PermissionsTenantPerDocumentTest.php b/tests/unit/PermissionsTenantPerDocumentTest.php new file mode 100644 index 0000000000..9654a15cec --- /dev/null +++ b/tests/unit/PermissionsTenantPerDocumentTest.php @@ -0,0 +1,498 @@ + + */ + private array $spannedTenants = []; + + #[\Override] + protected function setUp(): void + { + $this->pdo = new PDO('sqlite::memory:'); + $this->adapter = new class ($this->pdo, fn (): array => $this->spannedTenants) extends SQLite { + /** + * @param Closure(): list $spannedTenants + */ + public function __construct(PDO $pdo, private readonly Closure $spannedTenants) + { + parent::__construct($pdo); + } + + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array + { + $tenants = ($this->spannedTenants)(); + if ($tenants === [] || $collection->getId() === Database::METADATA) { + return parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); + } + + $documents = []; + foreach ($tenants as $tenant) { + \array_push($documents, ...$this->withTenant( + $tenant, + fn (): array => parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission), + )); + } + + return $documents; + } + }; + $this->authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + + $this->database = (new Database($this->adapter, new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('permissions_tenant_per_document') + ->setNamespace('permissions_tenant_per_document') + ->setSharedTables(true) + ->setTenant(null) + ->setTenantPerDocument(true) + ->addHook(new Permissions()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::integer(key: self::COUNTER), + ], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: true, + )); + + foreach ([self::TENANT, self::OTHER_TENANT] as $tenant) { + $this->database->createDocument(self::COLLECTION, $this->note($tenant, [self::ALICE, self::BOB])); + } + } + + public function testCreatingWithNoTenantSelectedStoresEachGrantUnderItsDocumentsTenant(): void + { + $this->assertSame($this->untouchedGrants(), $this->grants()); + $this->assertSame([self::TENANT => [self::DOCUMENT], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + } + + public function testAnUpsertWithNoTenantSelectedRevokesTheGrantUnderTheDocumentsTenant(): void + { + $this->database->upsertDocuments(self::COLLECTION, [$this->note(self::TENANT, [self::ALICE], 'revoked')]); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testAnUpsertWithIncreaseWithNoTenantSelectedRevokesTheGrantUnderTheDocumentsTenant(): void + { + $this->database->upsertDocuments( + self::COLLECTION, + [$this->note(self::TENANT, [self::ALICE])->setAttribute(self::COUNTER, 1)], + increase: self::COUNTER, + ); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testAnUpsertUnderAnotherTenantRevokesOnlyTheDocumentsOwnGrant(): void + { + $this->database->withTenant( + self::OTHER_TENANT, + fn (): int => $this->database->upsertDocuments(self::COLLECTION, [$this->note(self::TENANT, [self::ALICE], 'revoked')]), + ); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testAnUpsertBatchAcrossTenantsRevokesOnlyWhereTheDocumentRevoked(): void + { + $this->database->upsertDocuments(self::COLLECTION, [ + $this->note(self::TENANT, [self::ALICE], 'revoked'), + $this->note(self::OTHER_TENANT, [self::ALICE, self::BOB], 'retitled'), + ]); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testAnUpsertBatchScopesEveryRevokeToItsOwnDocumentsTenant(): void + { + $this->database->upsertDocuments(self::COLLECTION, [ + $this->note(self::TENANT, [self::ALICE], 'revoked'), + $this->note(self::OTHER_TENANT, [self::BOB], 'revoked'), + ]); + + $this->assertSame( + [ + $this->grant(self::TENANT, self::ALICE), + $this->grant(self::OTHER_TENANT, self::BOB), + ], + $this->grants(), + 'Each tenant revoked a different reader, so each must keep the grant it did not revoke', + ); + $this->assertSame([self::TENANT => [self::DOCUMENT], self::OTHER_TENANT => []], $this->readableBy(self::ALICE)); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + } + + public function testAnAdapterUpsertOfADocumentWithoutATenantRevokesUnderTheSelectedTenant(): void + { + $collection = $this->database->getCollection(self::COLLECTION); + $stored = $this->authorization->skip(fn (): Document => $this->database->withTenant( + self::TENANT, + fn (): Document => $this->database->getDocument(self::COLLECTION, self::DOCUMENT), + )); + + $this->database->withTenant(self::TENANT, fn (): array => $this->adapter->upsertDocuments($collection, [ + new Change($stored, new Document([ + '$id' => self::DOCUMENT, + '$createdAt' => $stored->getCreatedAt(), + '$updatedAt' => $stored->getUpdatedAt(), + 'title' => 'revoked', + '$permissions' => [Permission::read(Role::user(self::ALICE))], + ])), + ])); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testAnUpsertWithoutSharedTablesStillRevokesTheGrant(): void + { + $pdo = new PDO('sqlite::memory:'); + $database = (new Database(new SQLite($pdo), new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('permissions_not_shared') + ->setNamespace('permissions_not_shared') + ->addHook(new Permissions()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any()), Permission::update(Role::any())], + documentSecurity: true, + )); + $database->createDocument(self::COLLECTION, $this->readers([self::ALICE, self::BOB])->setAttribute('$id', self::DOCUMENT)); + + $database->upsertDocuments(self::COLLECTION, [$this->readers([self::ALICE])->setAttribute('$id', self::DOCUMENT)]); + + $statement = $pdo->query( + 'SELECT '.Storage::PERMISSIONS_PERMISSION.' FROM `permissions_not_shared_'.Storage::permissionsTable(self::COLLECTION).'`', + ); + $this->assertNotFalse($statement); + $this->assertSame([Role::user(self::ALICE)->toString()], $statement->fetchAll(PDO::FETCH_COLUMN)); + } + + public function testAnUpdateUnderTheDocumentsTenantRevokesOnlyThatTenantsGrant(): void + { + $this->database->withTenant( + self::TENANT, + fn (): Document => $this->database->updateDocument(self::COLLECTION, self::DOCUMENT, $this->readers([self::ALICE])), + ); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + public function testABatchUpdateUnderTheDocumentsTenantRevokesOnlyThatTenantsGrant(): void + { + $this->database->withTenant( + self::TENANT, + fn (): int => $this->database->updateDocuments(self::COLLECTION, $this->readers([self::ALICE]), [Query::equal('$id', [self::DOCUMENT])]), + ); + + $this->assertBobRevokedOnlyUnderTheTenant(); + } + + /** + * @return array}> + */ + public static function crossTenantBatchOrders(): array + { + return [ + 'changing document first' => [[self::TENANT, self::OTHER_TENANT]], + 'keeping document first' => [[self::OTHER_TENANT, self::TENANT]], + ]; + } + + /** + * A batch that spans tenants holds the same id once per tenant. When one of those documents changes its + * permissions and the other keeps them, the id must not be skipped, whichever order the batch holds them in. + * + * @param list $order + */ + #[DataProvider('crossTenantBatchOrders')] + public function testABatchUpdateSpanningTenantsRewritesAnIdOnlyOneTenantChanges(array $order): void + { + $this->database->withTenant( + self::OTHER_TENANT, + fn (): Document => $this->database->updateDocument(self::COLLECTION, self::DOCUMENT, $this->readers([self::ALICE])), + ); + $this->spannedTenants = $order; + + try { + $updated = $this->database->withTenant( + self::TENANT, + fn (): int => $this->database->updateDocuments(self::COLLECTION, $this->readers([self::ALICE]), [Query::equal('$id', [self::DOCUMENT])]), + ); + } finally { + $this->spannedTenants = []; + } + + $this->assertSame(2, $updated); + $this->assertSame( + [ + $this->grant(self::TENANT, self::ALICE), + $this->grant(self::OTHER_TENANT, self::ALICE), + ], + $this->grants(), + 'Tenant 5 revoked bob and tenant 6 already had, so neither tenant may keep a grant for bob', + ); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => []], $this->readableBy(self::BOB)); + $this->assertSame([self::TENANT => [self::DOCUMENT], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::ALICE)); + } + + public function testARenameUnderTheDocumentsTenantMovesOnlyThatTenantsGrants(): void + { + $this->database->withTenant( + self::TENANT, + fn (): Document => $this->database->updateDocument( + self::COLLECTION, + self::DOCUMENT, + $this->readers([self::ALICE])->setAttribute('$id', self::RENAMED), + ), + ); + + $this->assertSame( + [ + $this->grant(self::TENANT, self::ALICE, self::RENAMED), + $this->grant(self::OTHER_TENANT, self::ALICE), + $this->grant(self::OTHER_TENANT, self::BOB), + ], + $this->grants(), + ); + $this->assertSame([self::TENANT => [self::RENAMED], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::ALICE)); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + } + + public function testADeleteUnderTheDocumentsTenantRemovesOnlyThatTenantsGrants(): void + { + $this->database->withTenant( + self::TENANT, + fn (): bool => $this->database->deleteDocument(self::COLLECTION, self::DOCUMENT), + ); + + $this->assertOnlyTheOtherTenantsGrantsRemain(); + } + + public function testABatchDeleteUnderTheDocumentsTenantRemovesOnlyThatTenantsGrants(): void + { + $this->database->withTenant( + self::TENANT, + fn (): int => $this->database->deleteDocuments(self::COLLECTION, [Query::equal('$id', [self::DOCUMENT])]), + ); + + $this->assertOnlyTheOtherTenantsGrantsRemain(); + } + + public function testAnUpdateWithNoTenantSelectedChangesNoGrant(): void + { + $updated = $this->database->updateDocument(self::COLLECTION, self::DOCUMENT, $this->readers([self::ALICE])); + + $this->assertTrue($updated->isEmpty(), 'With no tenant selected no tenant\'s document is found to update'); + $this->assertSame($this->untouchedGrants(), $this->grants()); + } + + public function testABatchUpdateWithNoTenantSelectedChangesNoGrant(): void + { + $updated = $this->database->updateDocuments(self::COLLECTION, $this->readers([self::ALICE]), [Query::equal('$id', [self::DOCUMENT])]); + + $this->assertSame(0, $updated); + $this->assertSame($this->untouchedGrants(), $this->grants()); + } + + public function testADeleteWithNoTenantSelectedChangesNoGrant(): void + { + $this->assertFalse($this->database->deleteDocument(self::COLLECTION, self::DOCUMENT)); + $this->assertSame($this->untouchedGrants(), $this->grants()); + } + + public function testABatchDeleteWithNoTenantSelectedIsRejected(): void + { + try { + $this->database->deleteDocuments(self::COLLECTION, [Query::equal('$id', [self::DOCUMENT])]); + $this->fail('A batch delete with no tenant selected must be rejected'); + } catch (DatabaseException $exception) { + $this->assertStringStartsWith('Missing tenant', $exception->getMessage()); + } + + $this->assertSame($this->untouchedGrants(), $this->grants()); + } + + private function assertBobRevokedOnlyUnderTheTenant(): void + { + $this->assertSame( + [ + $this->grant(self::TENANT, self::ALICE), + $this->grant(self::OTHER_TENANT, self::ALICE), + $this->grant(self::OTHER_TENANT, self::BOB), + ], + $this->grants(), + 'Revoking bob on tenant 5\'s document must remove tenant 5\'s row and no other tenant\'s', + ); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + $this->assertSame([self::TENANT => [self::DOCUMENT], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::ALICE)); + } + + private function assertOnlyTheOtherTenantsGrantsRemain(): void + { + $this->assertSame( + [ + $this->grant(self::OTHER_TENANT, self::ALICE), + $this->grant(self::OTHER_TENANT, self::BOB), + ], + $this->grants(), + ); + $this->assertSame([self::TENANT => [], self::OTHER_TENANT => [self::DOCUMENT]], $this->readableBy(self::BOB)); + } + + /** + * @param list $readers + */ + private function note(int $tenant, array $readers, string $title = 'first'): Document + { + return $this->readers($readers) + ->setAttribute('$id', self::DOCUMENT) + ->setAttribute('$tenant', $tenant) + ->setAttribute('title', $title); + } + + /** + * @param list $readers + */ + private function readers(array $readers): Document + { + return new Document([ + '$permissions' => \array_map( + static fn (string $reader): string => Permission::read(Role::user($reader)), + $readers, + ), + ]); + } + + /** + * @return list + */ + private function untouchedGrants(): array + { + return [ + $this->grant(self::TENANT, self::ALICE), + $this->grant(self::TENANT, self::BOB), + $this->grant(self::OTHER_TENANT, self::ALICE), + $this->grant(self::OTHER_TENANT, self::BOB), + ]; + } + + /** + * @return array{int, string, string, string} + */ + private function grant(int $tenant, string $reader, string $document = self::DOCUMENT): array + { + return [$tenant, $document, PermissionType::Read->value, Role::user($reader)->toString()]; + } + + /** + * @return list + */ + private function grants(): array + { + $columns = [Storage::TENANT, Storage::PERMISSIONS_DOCUMENT, Storage::PERMISSIONS_TYPE, Storage::PERMISSIONS_PERMISSION]; + $statement = $this->pdo->query( + 'SELECT '.\implode(', ', $columns) + .' FROM `permissions_tenant_per_document_'.Storage::permissionsTable(self::COLLECTION).'`' + .' ORDER BY '.\implode(', ', $columns), + ); + $this->assertNotFalse($statement); + + /** @var list $rows */ + $rows = $statement->fetchAll(PDO::FETCH_NUM); + + return $rows; + } + + /** + * @return array> + */ + private function readableBy(string $reader): array + { + $roles = $this->authorization->getRoles(); + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::user($reader)->toString()); + + try { + $readable = []; + foreach ([self::TENANT, self::OTHER_TENANT] as $tenant) { + $readable[$tenant] = \array_values(\array_map( + static fn (Document $document): string => $document->getId(), + $this->database->withTenant($tenant, fn (): array => $this->database->find(self::COLLECTION)), + )); + } + + return $readable; + } finally { + $this->authorization->cleanRoles(); + foreach ($roles as $role) { + $this->authorization->addRole($role); + } + } + } +} diff --git a/tests/unit/PoolTimeoutTest.php b/tests/unit/PoolTimeoutTest.php index 2dcab66d9b..4bbaafd2b6 100644 --- a/tests/unit/PoolTimeoutTest.php +++ b/tests/unit/PoolTimeoutTest.php @@ -3,9 +3,10 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; +use Utopia\Database\Adapter\Feature; use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\Pool; -use Utopia\Database\Database; +use Utopia\Database\Event; use Utopia\Database\Validator\Authorization; use Utopia\Pools\Adapter\Stack; use Utopia\Pools\Pool as UtopiaPool; @@ -45,14 +46,14 @@ public function testTimeoutIsAppliedToEveryCheckout(): void $adapter->setAuthorization(new Authorization()); $adapter->setTimeout(300000); - $adapter->getSupportForTimeouts(); + $adapter->getDriver(); - $this->assertSame([Database::EVENT_ALL => 300000], $connection->timeouts); + $this->assertSame([Event::All->value => 300000], $connection->timeouts); $connection->timeouts = []; - $adapter->getSupportForTimeouts(); + $adapter->getDriver(); - $this->assertSame([Database::EVENT_ALL => 300000], $connection->timeouts); + $this->assertSame([Event::All->value => 300000], $connection->timeouts); } public function testClearedTimeoutIsClearedOnEveryCheckout(): void @@ -62,9 +63,9 @@ public function testClearedTimeoutIsClearedOnEveryCheckout(): void $adapter->setAuthorization(new Authorization()); $adapter->setTimeout(300000); - $adapter->getSupportForTimeouts(); - $adapter->clearTimeout(Database::EVENT_ALL); - $adapter->getSupportForTimeouts(); + $adapter->getDriver(); + $adapter->clearTimeout(Event::All); + $adapter->getDriver(); $this->assertSame([], $connection->timeouts); $this->assertSame(0, $connection->getTimeout()); @@ -78,12 +79,12 @@ public function testPerEventTimeoutReachesTheConnection(): void $adapter->setAuthorization(new Authorization()); $adapter->setTimeout(300000); - $adapter->setTimeout(5000, Database::EVENT_DOCUMENT_READ); - $adapter->getSupportForTimeouts(); + $adapter->setTimeout(5000, Event::DocumentRead); + $adapter->getDriver(); $this->assertSame([ - Database::EVENT_DOCUMENT_READ => 5000, - Database::EVENT_ALL => 300000, + Event::DocumentRead->value => 5000, + Event::All->value => 300000, ], $connection->timeouts, 'The global timeout is applied last so the connection ends on the scalar the pool reports'); $this->assertSame(300000, $connection->getTimeout()); } @@ -101,11 +102,11 @@ public function testClearingOneEventLeavesTheGlobalTimeoutInPlace(): void $adapter->setAuthorization(new Authorization()); $adapter->setTimeout(300000); - $adapter->setTimeout(5000, Database::EVENT_DOCUMENT_READ); - $adapter->clearTimeout(Database::EVENT_DOCUMENT_READ); - $adapter->getSupportForTimeouts(); + $adapter->setTimeout(5000, Event::DocumentRead); + $adapter->clearTimeout(Event::DocumentRead); + $adapter->getDriver(); - $this->assertSame([Database::EVENT_ALL => 300000], $connection->timeouts); + $this->assertSame([Event::All->value => 300000], $connection->timeouts); $this->assertSame(300000, $connection->getTimeout(), 'Postgres and Mongo bound every statement by this scalar, so clearing one event must not zero it'); $this->assertSame(300000, $adapter->getTimeout()); } @@ -124,11 +125,11 @@ public function testHandleWithNoTimeoutResetsTheConnectionItIsGiven(): void $bounded = new Pool($pool); $bounded->setAuthorization(new Authorization()); $bounded->setTimeout(5000); - $bounded->getSupportForTimeouts(); + $bounded->getDriver(); $unbounded = new Pool($pool); $unbounded->setAuthorization(new Authorization()); - $unbounded->getSupportForTimeouts(); + $unbounded->getDriver(); $this->assertSame([], $connection->timeouts, 'A handle that asked for no timeout must not run under the last holder\'s'); $this->assertSame(0, $connection->getTimeout()); @@ -148,9 +149,9 @@ public function testClearingEveryTimeoutDropsPerEventEntriesToo(): void $adapter->setAuthorization(new Authorization()); $adapter->setTimeout(300000); - $adapter->setTimeout(5000, Database::EVENT_DOCUMENT_READ); - $adapter->clearTimeouts(); - $adapter->getSupportForTimeouts(); + $adapter->setTimeout(5000, Event::DocumentRead); + $adapter->clearTimeout(Event::All); + $adapter->getDriver(); $this->assertSame([], $connection->timeouts, 'A timeout the caller cleared must not come back on the next checkout'); $this->assertSame(0, $adapter->getTimeout()); @@ -175,13 +176,13 @@ public function testTimeoutChangedInsideATransactionReachesThePinnedConnection() $adapter->setTimeout(300000); $insideBody['raised'] = $connection->timeouts; - $adapter->clearTimeout(Database::EVENT_ALL); + $adapter->clearTimeout(Event::All); $insideBody['cleared'] = $connection->timeouts; return 'row-written'; }); - $this->assertSame([Database::EVENT_ALL => 300000], $insideBody['raised'], 'The rest of the body runs on this connection, so the new timeout must reach it before the commit'); + $this->assertSame([Event::All->value => 300000], $insideBody['raised'], 'The rest of the body runs on this connection, so the new timeout must reach it before the commit'); $this->assertSame([], $insideBody['cleared']); } @@ -197,7 +198,7 @@ public function testZeroIsHeldAsNoTimeout(): void $adapter->setAuthorization(new Authorization()); $adapter->setTimeout(0); - $adapter->getSupportForTimeouts(); + $adapter->getDriver(); $this->assertSame([], $connection->timeouts); $this->assertSame(0, $adapter->getTimeout()); @@ -208,23 +209,40 @@ public function testZeroIsHeldAsNoTimeout(): void * Stands in for a connection: records what the pooled adapter applied to it, * the way a concrete adapter records a timeout for the statements it builds. */ -class TimeoutRecordingMemory extends Memory +class TimeoutRecordingMemory extends Memory implements Feature\Timeouts { /** * @var array */ public array $timeouts = []; - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + private int $timeout = 0; + + #[\Override] + public function getTimeout(Event $event = Event::All): int { - $this->timeouts[$event] = $milliseconds; + return $this->timeouts[$event->value] + ?? $this->timeouts[Event::All->value] + ?? $this->timeout; + } + + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + $this->timeouts[$event->value] = $milliseconds; $this->timeout = $milliseconds; } - public function clearTimeout(string $event): void + #[\Override] + public function clearTimeout(Event $event = Event::All): void { - unset($this->timeouts[$event]); + if ($event === Event::All) { + $this->timeouts = []; + $this->timeout = 0; + + return; + } - parent::clearTimeout($event); + unset($this->timeouts[$event->value]); } } diff --git a/tests/unit/PostgresIdentifierTest.php b/tests/unit/PostgresIdentifierTest.php new file mode 100644 index 0000000000..22e298b486 --- /dev/null +++ b/tests/unit/PostgresIdentifierTest.php @@ -0,0 +1,127 @@ +assertGreaterThan(Postgres::MAX_IDENTIFIER_NAME, \strlen($unhashed)); + + $physical = $this->existsBinding(self::COLLECTION); + $queries = $this->createCollectionQueries(self::COLLECTION); + + $this->assertNotSame($unhashed, $physical); + $this->assertLessThanOrEqual(Postgres::MAX_IDENTIFIER_NAME, \strlen($physical)); + $this->assertStringContainsString('"appwrite"."'.$physical.'"', $queries[0]); + } + + public function testCreateCollectionSqlUsesHashedTableName(): void + { + $physical = $this->existsBinding(self::COLLECTION); + $permissions = $this->existsBinding(Storage::permissionsTable(self::COLLECTION)); + + $queries = $this->createCollectionQueries(self::COLLECTION); + + $this->assertNotSame(self::NAMESPACE.'_'.self::COLLECTION, $physical); + $this->assertStringContainsString('"'.$physical.'"', $queries[0]); + $this->assertStringNotContainsString(self::COLLECTION, $queries[0]); + $this->assertStringContainsString('"'.$permissions.'"', $queries[1]); + } + + public function testExistsBindsHashedTableName(): void + { + $queries = $this->createCollectionQueries(self::COLLECTION); + + $physical = $this->existsBinding(self::COLLECTION); + + $this->assertNotSame(self::NAMESPACE.'_'.self::COLLECTION, $physical); + $this->assertStringContainsString('"appwrite"."'.$physical.'"', $queries[0]); + } + + /** + * @return list + */ + private function createCollectionQueries(string $collection): array + { + $statement = $this->getMockBuilder(PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->exactly(2)) + ->method('execute') + ->willReturn(true); + + $queries = []; + $pdo = $this->getMockBuilder(PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2)) + ->method('prepare') + ->willReturnCallback(function (string $sql) use (&$queries, $statement): PDOStatement { + $queries[] = $sql; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('appwrite'); + $adapter->setNamespace(self::NAMESPACE); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $this->assertTrue($adapter->createCollection($collection)); + $this->assertCount(2, $queries); + + return $queries; + } + + private function existsBinding(string $collection): string + { + $bound = []; + + $statement = $this->getMockBuilder(PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->exactly(2)) + ->method('bindValue') + ->willReturnCallback(function (int $position, mixed $value) use (&$bound): bool { + $bound[$position] = $value; + + return true; + }); + $statement->expects($this->once())->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willReturn([['table_name' => 'hashed']]); + $statement->expects($this->once())->method('closeCursor')->willReturn(true); + + $pdo = $this->getMockBuilder(PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturn($statement); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('appwrite'); + $adapter->setNamespace(self::NAMESPACE); + + $this->assertTrue($adapter->collectionExists('appwrite', $collection)); + $this->assertSame('appwrite', $bound[1] ?? null); + $physical = $bound[2] ?? null; + $this->assertIsString($physical); + + return $physical; + } +} diff --git a/tests/unit/PostgresQueryBehaviorTest.php b/tests/unit/PostgresQueryBehaviorTest.php new file mode 100644 index 0000000000..382426b783 --- /dev/null +++ b/tests/unit/PostgresQueryBehaviorTest.php @@ -0,0 +1,315 @@ +capturePermissionFilteredFind(['any', 'user:1']); + + $this->assertStringContainsString( + 'WHERE ("'.Storage::PERMISSIONS.'" @> ?::jsonb OR "'.Storage::PERMISSIONS.'" @> ?::jsonb)', + $sql + ); + $this->assertSame(['["read(\\"any\\")"]', '["read(\\"user:1\\")"]', 25], $bindings); + } + + public function testPermissionHookRejectsEmptyRoles(): void + { + [$sql, $bindings] = $this->capturePermissionFilteredFind([]); + + $this->assertStringContainsString('WHERE 1 = 0', $sql); + $this->assertSame([25], $bindings); + } + + public function testCreateCollectionStoresJsonbPermissionsWithGinIndex(): void + { + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->exactly(2)) + ->method('execute') + ->willReturn(true); + + $queries = []; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2)) + ->method('prepare') + ->willReturnCallback(function (string $sql) use (&$queries, $statement): \PDOStatement { + $queries[] = $sql; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $this->assertTrue($adapter->createCollection('movies')); + $this->assertStringContainsString('"'.Storage::PERMISSIONS.'" JSONB', $queries[0]); + $this->assertStringContainsString('USING GIN ("'.Storage::PERMISSIONS.'")', $queries[0]); + } + + public function testVectorDistanceIsProjectedHydratedAndOrderedBeforeTieBreaker(): void + { + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $bindings = []; + $statement->expects($this->exactly(3)) + ->method('bindValue') + ->willReturnCallback(function (int $position, mixed $value, int $type) use (&$bindings): bool { + $bindings[] = [$position, $value, $type]; + + return true; + }); + $statement->expects($this->once())->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willReturn([[ + Storage::UID => 'movie', + Storage::SEQUENCE => 1, + Storage::PERMISSIONS => '[]', + Storage::DISTANCE => '0.25', + ]]); + $statement->expects($this->once())->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $documents = $adapter->find( + new Document([Document::ID => 'movies']), + [Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc] + ); + + $this->assertCount(1, $documents); + $this->assertSame(0.25, $documents[0]->getAttribute(Document::DISTANCE)); + $this->assertMatchesRegularExpression('/SELECT \*, .*::text AS "'.Storage::DISTANCE.'"/', $sql); + $this->assertStringContainsString('WHERE "table_main"."embedding" IS NOT NULL', $sql); + $this->assertMatchesRegularExpression('/ORDER BY .*<=>.*\), "'.Storage::SEQUENCE.'" ASC/', $sql); + $this->assertSame([ + [1, '[1,0,0]', \PDO::PARAM_STR], + [2, '[1,0,0]', \PDO::PARAM_STR], + [3, 25, \PDO::PARAM_INT], + ], $bindings); + } + + public function testVectorCursorComparesDistanceBeforeSequenceTieBreaker(): void + { + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $bindings = []; + $statement->method('bindValue') + ->willReturnCallback(function (int $position, mixed $value, int $type) use (&$bindings): bool { + $bindings[] = [$position, $value, $type]; + + return true; + }); + $statement->expects($this->once())->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willReturn([]); + $statement->expects($this->once())->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document([Document::ID => 'movies']), + [Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + cursor: [Document::DISTANCE => 0.25, Document::SEQUENCE => 17], + ); + + $this->assertStringContainsString( + '"table_main"."embedding" IS NOT NULL AND ((("table_main"."embedding" <=> ?::vector)) > ? OR ((("table_main"."embedding" <=> ?::vector)) = ? AND "table_main"."'.Storage::SEQUENCE.'" > ?))', + $sql, + ); + $this->assertMatchesRegularExpression('/ORDER BY .*<=>.*\), "'.Storage::SEQUENCE.'" ASC/', $sql); + $this->assertSame([ + [1, '[1,0,0]', \PDO::PARAM_STR], + [2, '[1,0,0]', \PDO::PARAM_STR], + [3, '0.25', \PDO::PARAM_STR], + [4, '[1,0,0]', \PDO::PARAM_STR], + [5, '0.25', \PDO::PARAM_STR], + [6, 17, \PDO::PARAM_INT], + [7, '[1,0,0]', \PDO::PARAM_STR], + [8, 25, \PDO::PARAM_INT], + ], $bindings); + } + + public function testVectorCursorBeforeBindsRoundTripDistance(): void + { + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $bindings = []; + $statement->method('bindValue') + ->willReturnCallback(function (int $position, mixed $value, int $type) use (&$bindings): bool { + $bindings[] = [$position, $value, $type]; + + return true; + }); + $statement->expects($this->once())->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willReturn([]); + $statement->expects($this->once())->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $distance = 0.015216441182063223; + $adapter->find( + new Document([Document::ID => 'movies']), + [Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + cursor: [Document::DISTANCE => $distance, Document::SEQUENCE => 17], + cursorDirection: CursorDirection::Before, + ); + + $this->assertStringContainsString( + '"table_main"."embedding" IS NOT NULL AND ((("table_main"."embedding" <=> ?::vector)) < ? OR ((("table_main"."embedding" <=> ?::vector)) = ? AND "table_main"."'.Storage::SEQUENCE.'" < ?))', + $sql, + ); + $this->assertMatchesRegularExpression('/ORDER BY .*<=>.*\) DESC, "'.Storage::SEQUENCE.'" DESC/', $sql); + $this->assertSame(\json_encode($distance, JSON_THROW_ON_ERROR), $bindings[2][1]); + $this->assertSame(\json_encode($distance, JSON_THROW_ON_ERROR), $bindings[4][1]); + } + + public function testInvalidPowerArgumentIsTranslatedToLimitException(): void + { + $pdoException = new class ('zero raised to a negative power is undefined', '2201F') extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $pdoException->errorInfo = ['2201F', 7, 'zero raised to a negative power is undefined']; + + $statement = self::createStub(\PDOStatement::class); + $statement->method('execute')->willThrowException($pdoException); + $pdo = self::createStub(\PDO::class); + $pdo->method('prepare')->willReturn($statement); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + try { + $adapter->updateDocuments( + new Document([Document::ID => 'scores', 'attributes' => []]), + new Document(['value' => Operator::power(-1)]), + [new Document([Document::ID => 'first', Document::SEQUENCE => '1'])], + ); + $this->fail('The update succeeded'); + } catch (LimitException $exception) { + $this->assertSame($pdoException, $exception->getPrevious()); + } + } + + /** + * @param list $roles + * @return array{string, list} + */ + private function capturePermissionFilteredFind(array $roles): array + { + $bindings = []; + $statement = self::createStub(\PDOStatement::class); + $statement->method('bindValue')->willReturnCallback(function (int $position, mixed $value) use (&$bindings): bool { + $bindings[] = $value; + + return true; + }); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + + $sql = ''; + $pdo = self::createStub(\PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + $adapter->setAuthorization($authorization); + + $adapter->find(new Document([Document::ID => 'movies', 'documentSecurity' => true])); + + return [$sql, $bindings]; + } +} diff --git a/tests/unit/PostgresSpatialCacheTest.php b/tests/unit/PostgresSpatialCacheTest.php new file mode 100644 index 0000000000..45cddc2346 --- /dev/null +++ b/tests/unit/PostgresSpatialCacheTest.php @@ -0,0 +1,142 @@ + + */ + private array $statements = []; + + /** + * @var list + */ + private array $bindings = []; + + public function testSpatialCacheRescansWhenAttributeSetChanges(): void + { + $adapter = $this->adapter(); + + $before = new Document([ + '$id' => 'places', + 'attributes' => [new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value])], + ]); + $this->assertStringNotContainsString('ST_GeomFromText', $this->insert($adapter, $before, ['name' => 'x', 'loc' => [0.0, 0.0]])); + + $after = new Document([ + '$id' => 'places', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value]), + new Document(['$id' => 'loc', 'key' => 'loc', 'type' => ColumnType::Point->value]), + ], + ]); + $this->assertStringContainsString('VALUES (?, ?, ST_GeomFromText(?, 4326), ?', $this->insert($adapter, $after, ['name' => 'x', 'loc' => [0.0, 0.0]])); + $this->assertSame(['x', 'POINT(0 0)'], \array_slice($this->bindings, 1, 2)); + } + + public function testSpatialAttributesFromTypedObjectsAndEnums(): void + { + $collection = new Document([ + '$id' => 'mixed', + 'attributes' => [ + Attribute::point(key: 'loc'), + new Document(['$id' => 'route', 'key' => 'route', 'type' => ColumnType::Linestring]), + ['$id' => 'area', 'key' => 'area', 'type' => ColumnType::Polygon->value], + new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value]), + ], + ]); + + $statement = $this->insert($this->adapter(), $collection, [ + 'loc' => [0.0, 0.0], + 'route' => [[0.0, 0.0], [1.0, 1.0]], + 'area' => 'POLYGON((0 0, 1 0, 1 1, 0 0))', + 'name' => 'x', + ]); + + $this->assertStringContainsString('VALUES (?, ST_GeomFromText(?, 4326), ST_GeomFromText(?, 4326), ST_GeomFromText(?, 4326), ?,', $statement); + } + + public function testSpatialWriteValueEncoding(): void + { + $collection = new Document([ + '$id' => 'shapes', + 'attributes' => [ + Attribute::point(key: 'origin'), + Attribute::lineString(key: 'path'), + Attribute::point(key: 'wellKnown'), + ], + ]); + + $this->insert($this->adapter(), $collection, [ + 'origin' => [0.0, 0.0], + 'path' => [[0.0, 0.0], [1.0, 1.0]], + 'wellKnown' => 'POINT(0 0)', + ]); + + $this->assertSame(['POINT(0 0)', 'LINESTRING(0 0, 1 1)', 'POINT(0 0)'], \array_slice($this->bindings, 1, 3)); + } + + public function testAttributeWidthAcceptsDocumentAndTypedAttributes(): void + { + $adapter = new Postgres($this->createStub(\PDO::class)); + $collection = new Document([ + '$id' => 'export', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, 'size' => 255, 'array' => false]), + new Document(['$id' => 'body', 'key' => 'body', 'type' => ColumnType::MediumText->value, 'size' => 0, 'array' => false]), + new Document(['$id' => 'notes', 'key' => 'notes', 'type' => ColumnType::LongText->value, 'size' => 0, 'array' => false]), + new Document(['$id' => 'count', 'key' => 'count', 'type' => ColumnType::BigInteger, 'size' => 0, 'array' => false]), + Attribute::point(key: 'loc'), + ], + ]); + + $this->assertGreaterThan(0, $adapter->getAttributeWidth($collection)); + } + + private function adapter(): Postgres + { + $statement = self::createStub(\PDOStatement::class); + $statement->method('bindValue')->willReturnCallback(function (int|string $position, mixed $value): bool { + $this->bindings[] = $value; + + return true; + }); + $statement->method('execute')->willReturn(true); + $pdo = self::createStub(\PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): \PDOStatement { + $this->statements[] = $query; + + return $statement; + }); + $pdo->method('lastInsertId')->willReturn('1'); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } + + /** + * @param array $attributes + */ + private function insert(Postgres $adapter, Document $collection, array $attributes): string + { + $prepared = \count($this->statements); + $this->bindings = []; + + $adapter->createDocument($collection, new Document(['$id' => 'document', '$permissions' => [], ...$attributes])); + + $statement = $this->statements[$prepared] ?? null; + $this->assertIsString($statement); + + return $statement; + } +} diff --git a/tests/unit/PostgresTimeoutStatementTest.php b/tests/unit/PostgresTimeoutStatementTest.php new file mode 100644 index 0000000000..fe2af3276d --- /dev/null +++ b/tests/unit/PostgresTimeoutStatementTest.php @@ -0,0 +1,261 @@ + */ + private array $log = []; + + public function testStatementsOfOneTransactionShareOneLocalTimeout(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25, Event::DocumentFind); + + $adapter->startTransaction(); + $this->find($adapter); + $this->find($adapter); + $this->find($adapter); + $adapter->commitTransaction(); + + $this->assertSame([self::SET_LOCAL, 'SELECT', 'SELECT', 'SELECT'], $this->log); + } + + public function testGlobalTimeoutIsSetOncePerTransaction(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25); + + $adapter->startTransaction(); + $this->find($adapter); + $this->create($adapter); + $this->find($adapter); + $adapter->commitTransaction(); + + $this->assertSame([self::SET_LOCAL, 'SELECT', 'INSERT', 'SELECT'], $this->log); + } + + public function testReadScopedTimeoutDoesNotCarryToACreateInTheSameTransaction(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25, Event::DocumentFind); + + $adapter->startTransaction(); + $this->find($adapter); + $this->create($adapter); + $this->create($adapter); + $adapter->commitTransaction(); + + $this->assertSame([self::SET_LOCAL, 'SELECT', self::DEFAULT_LOCAL, 'INSERT', 'INSERT'], $this->log); + } + + public function testStatementsWithoutATimeoutInATransactionWriteNone(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25, Event::DocumentFind); + + $adapter->startTransaction(); + $this->create($adapter); + $this->create($adapter); + $adapter->commitTransaction(); + + $this->assertSame(['INSERT', 'INSERT'], $this->log); + } + + public function testNextTransactionSetsItsTimeoutAgain(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25); + + $adapter->startTransaction(); + $this->find($adapter); + $adapter->commitTransaction(); + $adapter->startTransaction(); + $this->find($adapter); + $adapter->rollbackTransaction(); + $adapter->startTransaction(); + $this->find($adapter); + $adapter->commitTransaction(); + + $this->assertSame([self::SET_LOCAL, 'SELECT', self::SET_LOCAL, 'SELECT', self::SET_LOCAL, 'SELECT'], $this->log); + } + + public function testRollbackToASavepointUndoesATimeoutSetAfterIt(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25, Event::DocumentFind); + + $adapter->startTransaction(); + $adapter->startTransaction(); + $this->find($adapter); + $adapter->rollbackTransaction(); + $this->find($adapter); + $adapter->commitTransaction(); + + $this->assertSame([self::SET_LOCAL, 'SELECT', 'ROLLBACK TO transaction1', self::SET_LOCAL, 'SELECT'], $this->log); + } + + /** + * The rollback brings back the read timeout set before the savepoint, so the + * create after it has to clear that timeout again. + */ + public function testCreateAfterARollbackToASavepointRunsWithoutTheReadTimeout(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25, Event::DocumentFind); + + $adapter->startTransaction(); + $this->find($adapter); + $adapter->startTransaction(); + $this->create($adapter); + $adapter->rollbackTransaction(); + $this->create($adapter); + $adapter->commitTransaction(); + + $this->assertSame([ + self::SET_LOCAL, + 'SELECT', + self::DEFAULT_LOCAL, + 'INSERT', + 'ROLLBACK TO transaction1', + self::DEFAULT_LOCAL, + 'INSERT', + ], $this->log); + } + + public function testReconnectInsideATransactionForgetsTheLocalTimeout(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25); + + $adapter->startTransaction(); + $this->find($adapter); + $adapter->reconnect(); + $adapter->startTransaction(); + $this->find($adapter); + $adapter->commitTransaction(); + + $this->assertSame([self::SET_LOCAL, 'SELECT', self::SET_LOCAL, 'SELECT'], $this->log); + } + + public function testNestedCommitKeepsTheTimeoutSetInsideIt(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25, Event::DocumentFind); + + $adapter->startTransaction(); + $adapter->startTransaction(); + $this->find($adapter); + $adapter->commitTransaction(); + $this->find($adapter); + $adapter->commitTransaction(); + + $this->assertSame([self::SET_LOCAL, 'SELECT', 'SELECT'], $this->log); + } + + public function testStatementsOutsideATransactionSetAndResetTheSessionTimeout(): void + { + $adapter = $this->createAdapter(); + $adapter->setTimeout(25, Event::DocumentFind); + + $this->find($adapter); + $this->create($adapter); + $this->find($adapter); + + $this->assertSame([ + "SET statement_timeout = '25ms'", + 'SELECT', + 'RESET statement_timeout', + 'INSERT', + "SET statement_timeout = '25ms'", + 'SELECT', + 'RESET statement_timeout', + ], $this->log); + } + + private function createAdapter(): Postgres + { + $this->log = []; + $open = false; + + $pdo = $this->createStub(\PDO::class); + $pdo->method('inTransaction')->willReturnCallback(function () use (&$open): bool { + return $open; + }); + $pdo->method('beginTransaction')->willReturnCallback(function () use (&$open): bool { + $open = true; + + return true; + }); + $pdo->method('commit')->willReturnCallback(function () use (&$open): bool { + $open = false; + + return true; + }); + $pdo->method('rollBack')->willReturnCallback(function () use (&$open): bool { + $open = false; + + return true; + }); + $pdo->method('exec')->willReturnCallback(function (string $sql): int { + if (! \str_starts_with($sql, 'SAVEPOINT')) { + $this->log[] = $sql; + } + + return 0; + }); + $pdo->method('lastInsertId')->willReturn('1'); + $pdo->method('prepare')->willReturnCallback(function (string $sql): \PDOStatement { + $statement = $this->createStub(\PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + $statement->method('execute')->willReturnCallback(function () use ($sql): bool { + $keyword = \strtoupper(\strtok(\ltrim($sql), " \n") ?: ''); + if ($keyword !== 'ROLLBACK') { + $this->log[] = $keyword; + } + + return true; + }); + $statement->method('fetchAll')->willReturn([]); + $statement->method('fetch')->willReturn(false); + $statement->method('rowCount')->willReturn(1); + $statement->method('closeCursor')->willReturn(true); + + return $statement; + }); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + return $adapter; + } + + private function find(Postgres $adapter): void + { + $adapter->find(new Document(['$id' => 'movies']), orderAttributes: ['$sequence']); + } + + private function create(Postgres $adapter): void + { + $adapter->createDocument(new Document(['$id' => 'movies']), new Document(['$id' => 'movie', 'title' => 'Alien'])); + } +} diff --git a/tests/unit/Profiler/ProfiledStatementTest.php b/tests/unit/Profiler/ProfiledStatementTest.php new file mode 100644 index 0000000000..e391401952 --- /dev/null +++ b/tests/unit/Profiler/ProfiledStatementTest.php @@ -0,0 +1,132 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase('profiled_statement') + ->setNamespace(self::NAMESPACE) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: 'items', + attributes: [ + Attribute::string(key: 'category', size: 16), + Attribute::integer(key: 'price'), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + foreach ([['i1', 'first', 10], ['i2', 'second', 20]] as [$id, $category, $price]) { + $this->database->createDocument('items', new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'category' => $category, + 'price' => $price, + ])); + } + } + + public function testAFilteredFindLogsItsValuesAndCollection(): void + { + $log = $this->logOn(fn (): int => \count($this->database->find('items', [Query::equal('category', ['second'])]))); + + $this->assertContains('second', $log->bindings); + $this->assertSame('items', $log->collection); + $this->assertSame(Event::DocumentFind->value, $log->operation); + } + + public function testCountAndSumLogTheirValuesAndCollection(): void + { + $count = $this->logOn(fn (): int => $this->database->count('items', [Query::greaterThan('price', 15)])); + $this->assertContains(15, $count->bindings); + $this->assertSame('items', $count->collection); + $this->assertSame(Event::DocumentCount->value, $count->operation); + + $sum = $this->logOn(fn (): int|float => $this->database->sum('items', 'price', [Query::equal('category', ['first'])], 5)); + $this->assertContains('first', $sum->bindings); + $this->assertContains(5, $sum->bindings); + $this->assertSame('items', $sum->collection); + $this->assertSame(Event::DocumentSum->value, $sum->operation); + } + + public function testAnUnfilteredReadLogsItsCollection(): void + { + $count = $this->database->getAuthorization()->skip(fn (): Log => $this->logOn(fn (): int => $this->database->count('items'))); + $this->assertSame([], $count->bindings); + $this->assertSame('items', $count->collection); + $this->assertSame(Event::DocumentCount->value, $count->operation); + + $read = $this->logOn(fn (): string => $this->database->getDocument('items', 'i2')->getId()); + $this->assertSame([':_uid' => 'i2'], $read->bindings); + $this->assertSame('items', $read->collection); + $this->assertSame(Event::DocumentRead->value, $read->operation); + } + + public function testStatementsRunWhileTheProfilerIsOffAreNotDescribed(): void + { + $this->database->find('items', [Query::equal('category', ['first'])]); + + $log = $this->logOn(fn (): int => \count($this->database->find('items', [Query::equal('category', ['second'])]))); + + $this->assertNotContains('first', $log->bindings); + $this->assertContains('second', $log->bindings); + } + + /** + * The one statement $read ran on the collection's table. + * + * @param callable(): mixed $read + */ + private function logOn(callable $read): Log + { + $profiler = $this->database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + + try { + $profiler->reset(); + $read(); + } finally { + $this->database->setProfiling(false); + } + + $logs = \array_values(\array_filter( + $profiler->getLogs(), + static fn (Log $log): bool => \str_contains($log->query, self::NAMESPACE.'_items`'), + )); + $this->assertCount(1, $logs); + + return $logs[0]; + } +} diff --git a/tests/unit/Profiler/ProfilerAdvancedTest.php b/tests/unit/Profiler/ProfilerAdvancedTest.php new file mode 100644 index 0000000000..ebdd07772a --- /dev/null +++ b/tests/unit/Profiler/ProfilerAdvancedTest.php @@ -0,0 +1,206 @@ +profiler = new Profiler(); + } + + public function testBacktraceCaptureWhenEnabled(): void + { + $this->profiler->enable(); + $this->profiler->enableBacktrace(true); + $this->profiler->log('SELECT 1', [], 1.0); + + $logs = $this->profiler->getLogs(); + $this->assertCount(1, $logs); + $this->assertNotNull($logs[0]->backtrace); + $this->assertNotEmpty($logs[0]->backtrace); + } + + public function testBacktraceIsNullWhenDisabled(): void + { + $this->profiler->enable(); + $this->profiler->log('SELECT 1', [], 1.0); + + $logs = $this->profiler->getLogs(); + $this->assertNull($logs[0]->backtrace); + } + + public function testEnableBacktraceToggle(): void + { + $this->profiler->enable(); + + $this->profiler->enableBacktrace(true); + $this->profiler->log('Q1', [], 1.0); + $this->assertNotNull($this->profiler->getLogs()[0]->backtrace); + + $this->profiler->enableBacktrace(false); + $this->profiler->log('Q2', [], 1.0); + $this->assertNull($this->profiler->getLogs()[1]->backtrace); + } + + public function testMultipleSlowQueryCallbacks(): void + { + $this->profiler->enable(); + $this->profiler->setSlowThreshold(10.0); + + $received = new class () { + public mixed $entry = null; + }; + $this->profiler->onSlowQuery(function ($entry) use ($received) { + $received->entry = $entry; + }); + + $this->profiler->log('fast', [], 5.0); + $this->assertEmpty($this->profiler->getSlowQueries()); + + $this->profiler->log('slow', [], 20.0); + $this->assertInstanceOf(Log::class, $received->entry); + $this->assertEquals('slow', $received->entry->query); + } + + public function testDetectNPlusOneWithVariedQueryPatterns(): void + { + $this->profiler->enable(); + + for ($i = 0; $i < 10; $i++) { + $this->profiler->log("SELECT * FROM users WHERE id = {$i}", [], 1.0); + } + + for ($i = 0; $i < 3; $i++) { + $this->profiler->log("SELECT * FROM posts WHERE id = {$i}", [], 1.0); + } + + $violations = $this->profiler->detectNPlusOne(5); + $this->assertNotEmpty($violations); + + $hasUsersPattern = false; + foreach ($violations as $pattern => $count) { + if ($count >= 10) { + $hasUsersPattern = true; + } + } + $this->assertTrue($hasUsersPattern); + } + + public function testDetectNPlusOneBelowThresholdReturnsEmpty(): void + { + $this->profiler->enable(); + + $this->profiler->log('SELECT * FROM users WHERE id = 1', [], 1.0); + $this->profiler->log('SELECT * FROM users WHERE id = 2', [], 1.0); + + $violations = $this->profiler->detectNPlusOne(5); + $this->assertEmpty($violations); + } + + public function testGetTotalTimeWithNoLogsReturnsZero(): void + { + $this->assertEquals(0.0, $this->profiler->getTotalTime()); + } + + public function testGetSlowQueriesReturnsEmptyWhenNoneExceedThreshold(): void + { + $this->profiler->enable(); + $this->profiler->setSlowThreshold(50.0); + + $this->profiler->log('fast1', [], 10.0); + $this->profiler->log('fast2', [], 20.0); + + $slow = $this->profiler->getSlowQueries(); + $this->assertEmpty($slow); + } + + public function testLogWithAllParameters(): void + { + $this->profiler->enable(); + $this->profiler->log('SELECT * FROM orders', ['active'], 15.5, 'orders', 'find'); + + $logs = $this->profiler->getLogs(); + $this->assertCount(1, $logs); + $this->assertEquals('SELECT * FROM orders', $logs[0]->query); + $this->assertEquals(['active'], $logs[0]->bindings); + $this->assertEquals(15.5, $logs[0]->durationMs); + $this->assertEquals('orders', $logs[0]->collection); + $this->assertEquals('find', $logs[0]->operation); + } + + public function testResetClearsEverything(): void + { + $this->profiler->enable(); + $this->profiler->log('Q1', [], 10.0); + $this->profiler->log('Q2', [], 20.0); + + $this->profiler->reset(); + + $this->assertCount(0, $this->profiler->getLogs()); + $this->assertEquals(0, $this->profiler->getQueryCount()); + $this->assertEquals(0.0, $this->profiler->getTotalTime()); + $this->assertEmpty($this->profiler->getSlowQueries()); + } + + public function testSlowQueryCallbackReceivesTheLogEntry(): void + { + $this->profiler->enable(); + $this->profiler->setSlowThreshold(10.0); + + $received = new class () { + public mixed $entry = null; + }; + $this->profiler->onSlowQuery(function ($entry) use ($received) { + $received->entry = $entry; + }); + + $this->profiler->log('SELECT slow', ['param'], 50.0, 'users', 'find'); + + $this->assertInstanceOf(Log::class, $received->entry); + $this->assertEquals('SELECT slow', $received->entry->query); + $this->assertEquals(50.0, $received->entry->durationMs); + $this->assertEquals('users', $received->entry->collection); + } + + public function testDetectNPlusOneNormalizesQueryParameters(): void + { + $this->profiler->enable(); + + for ($i = 0; $i < 6; $i++) { + $this->profiler->log("SELECT * FROM users WHERE name = 'user_{$i}'", [], 1.0); + } + + $violations = $this->profiler->detectNPlusOne(5); + $this->assertNotEmpty($violations); + } + + public function testGetSlowQueriesAtExactThreshold(): void + { + $this->profiler->enable(); + $this->profiler->setSlowThreshold(50.0); + + $this->profiler->log('exact', [], 50.0); + + $slow = $this->profiler->getSlowQueries(); + $this->assertCount(1, $slow); + } + + public function testEnabledProfilerLogsTotalTimeCorrectly(): void + { + $this->profiler->enable(); + + $this->profiler->log('Q1', [], 1.5); + $this->profiler->log('Q2', [], 2.5); + $this->profiler->log('Q3', [], 3.0); + + $this->assertEquals(7.0, $this->profiler->getTotalTime()); + } +} diff --git a/tests/unit/Profiler/ProfilerTest.php b/tests/unit/Profiler/ProfilerTest.php new file mode 100644 index 0000000000..59d886505b --- /dev/null +++ b/tests/unit/Profiler/ProfilerTest.php @@ -0,0 +1,227 @@ +profiler = new Profiler(); + } + + public function testDisabledByDefault(): void + { + $this->assertFalse($this->profiler->isEnabled()); + } + + public function testEnableDisable(): void + { + $this->profiler->enable(); + $this->assertTrue($this->profiler->isEnabled()); + + $this->profiler->disable(); + $this->assertFalse($this->profiler->isEnabled()); + } + + public function testLogWhenDisabled(): void + { + $this->profiler->log('SELECT 1', [], 1.0); + $this->assertCount(0, $this->profiler->getLogs()); + } + + public function testLogWhenEnabled(): void + { + $this->profiler->enable(); + $this->profiler->log('SELECT * FROM users', [], 5.5, 'users', 'find'); + $this->profiler->log('SELECT * FROM posts', [], 3.2, 'posts', 'find'); + + $logs = $this->profiler->getLogs(); + $this->assertCount(2, $logs); + $this->assertEquals('SELECT * FROM users', $logs[0]->query); + $this->assertEquals(5.5, $logs[0]->durationMs); + $this->assertEquals('users', $logs[0]->collection); + } + + public function testQueryCount(): void + { + $this->profiler->enable(); + $this->profiler->log('Q1', [], 1.0); + $this->profiler->log('Q2', [], 2.0); + $this->profiler->log('Q3', [], 3.0); + + $this->assertEquals(3, $this->profiler->getQueryCount()); + } + + public function testTotalTime(): void + { + $this->profiler->enable(); + $this->profiler->log('Q1', [], 10.0); + $this->profiler->log('Q2', [], 20.0); + + $this->assertEquals(30.0, $this->profiler->getTotalTime()); + } + + public function testSlowQueryDetection(): void + { + $this->profiler->enable(); + $this->profiler->setSlowThreshold(50.0); + + $this->profiler->log('fast', [], 10.0); + $this->profiler->log('slow', [], 100.0); + $this->profiler->log('medium', [], 49.0); + + $slow = $this->profiler->getSlowQueries(); + $this->assertCount(1, $slow); + $slowEntry = \array_values($slow)[0]; + $this->assertEquals('slow', $slowEntry->query); + } + + public function testSlowQueryCallback(): void + { + $this->profiler->enable(); + $this->profiler->setSlowThreshold(50.0); + + $called = new \stdClass(); + $called->value = false; + $this->profiler->onSlowQuery(function () use ($called) { + $called->value = true; + }); + + $this->profiler->log('fast', [], 10.0); + $this->assertFalse($called->value); + + $this->profiler->log('slow', [], 100.0); + $this->assertTrue($called->value); + } + + public function testNPlusOneDetection(): void + { + $this->profiler->enable(); + + for ($i = 0; $i < 10; $i++) { + $this->profiler->log('SELECT * FROM users WHERE id = ?', [$i], 1.0); + } + + $violations = $this->profiler->detectNPlusOne(5); + $this->assertNotEmpty($violations); + } + + public function testReset(): void + { + $this->profiler->enable(); + $this->profiler->log('Q1', [], 1.0); + $this->profiler->reset(); + + $this->assertCount(0, $this->profiler->getLogs()); + $this->assertEquals(0, $this->profiler->getQueryCount()); + } + + public function testLogsKeepOnlyTheNewestEntriesWithinTheCapacity(): void + { + $this->profiler->enable(); + $this->profiler->setCapacity(3); + + foreach (['Q1', 'Q2', 'Q3', 'Q4', 'Q5'] as $query) { + $this->profiler->log($query, [], 1.0); + } + + $this->assertSame(['Q3', 'Q4', 'Q5'], $this->loggedQueries()); + $this->assertSame(3, $this->profiler->getCapacity()); + } + + public function testDefaultCapacityBoundsTheLogs(): void + { + $this->profiler->enable(); + + for ($index = 0; $index <= Profiler::DEFAULT_CAPACITY; $index++) { + $this->profiler->log("Q{$index}", [], 1.0); + } + + $logs = $this->profiler->getLogs(); + $this->assertCount(Profiler::DEFAULT_CAPACITY, $logs); + $this->assertSame('Q1', $logs[0]->query); + $this->assertSame('Q'.Profiler::DEFAULT_CAPACITY, $logs[Profiler::DEFAULT_CAPACITY - 1]->query); + } + + public function testCountAndTotalTimeCoverEntriesPastTheCapacity(): void + { + $this->profiler->enable(); + $this->profiler->setCapacity(2); + + $this->profiler->log('Q1', [], 1.0); + $this->profiler->log('Q2', [], 2.0); + $this->profiler->log('Q3', [], 4.0); + + $this->assertSame(['Q2', 'Q3'], $this->loggedQueries()); + $this->assertSame(3, $this->profiler->getQueryCount()); + $this->assertSame(7.0, $this->profiler->getTotalTime()); + } + + public function testShrinkingTheCapacityKeepsTheNewestEntries(): void + { + $this->profiler->enable(); + $this->profiler->setCapacity(3); + + foreach (['Q1', 'Q2', 'Q3', 'Q4'] as $query) { + $this->profiler->log($query, [], 1.0); + } + + $this->profiler->setCapacity(2); + $this->assertSame(['Q3', 'Q4'], $this->loggedQueries()); + + $this->profiler->log('Q5', [], 1.0); + $this->assertSame(['Q4', 'Q5'], $this->loggedQueries()); + } + + public function testGrowingTheCapacityKeepsTheOrder(): void + { + $this->profiler->enable(); + $this->profiler->setCapacity(2); + + foreach (['Q1', 'Q2', 'Q3'] as $query) { + $this->profiler->log($query, [], 1.0); + } + + $this->profiler->setCapacity(3); + $this->profiler->log('Q4', [], 1.0); + $this->assertSame(['Q2', 'Q3', 'Q4'], $this->loggedQueries()); + + $this->profiler->log('Q5', [], 1.0); + $this->assertSame(['Q3', 'Q4', 'Q5'], $this->loggedQueries()); + } + + public function testCapacityMustBeAtLeastOne(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->profiler->setCapacity(0); + } + + public function testDisableStopsRecordingAndKeepsTheLogsForInspection(): void + { + $this->profiler->enable(); + $this->profiler->log('Q1', [], 1.0); + + $this->profiler->disable(); + $this->profiler->log('Q2', [], 1.0); + + $this->assertSame(['Q1'], $this->loggedQueries()); + $this->assertSame(1, $this->profiler->getQueryCount()); + } + + /** + * @return array + */ + private function loggedQueries(): array + { + return \array_map(static fn (Log $log): string => $log->query, $this->profiler->getLogs()); + } +} diff --git a/tests/unit/QueryBuilderAuthorizationTest.php b/tests/unit/QueryBuilderAuthorizationTest.php new file mode 100644 index 0000000000..dab6616400 --- /dev/null +++ b/tests/unit/QueryBuilderAuthorizationTest.php @@ -0,0 +1,155 @@ +pdo = new PDO('sqlite::memory:'); + $this->authorization = new Authorization(); + $this->database = new Database(new SQLite($this->pdo), new Cache(new None())); + $this->database + ->setAuthorization($this->authorization) + ->setDatabase('builder') + ->setNamespace('authorization'); + $this->database->addHook(new Permissions()); + $this->database->create(); + + $this->authorization->skip(function (): void { + $this->database->createCollection(Collection::create( + id: self::POSTS, + attributes: [Attribute::string(key: 'title', size: 64, required: true)], + permissions: [Permission::create(Role::users())], + documentSecurity: true, + )); + $this->database->createCollection(Collection::create( + id: self::PRIVATE, + attributes: [Attribute::string(key: 'title', size: 64, required: true)], + permissions: [], + documentSecurity: false, + )); + $this->database->createDocument(self::POSTS, new Document([ + '$id' => 'secret', + '$permissions' => [Permission::read(Role::user('alice')), Permission::update(Role::user('alice'))], + 'title' => 'alice only', + ])); + $this->database->createDocument(self::PRIVATE, new Document(['$id' => 'hidden', 'title' => 'private row'])); + }); + + $this->authorization->addRole(Role::any()->toString()); + $this->authorization->addRole(Role::users()->toString()); + $this->authorization->addRole(Role::user('bob')->toString()); + } + + public function testDirectReadsHideWhatBobMayNotRead(): void + { + $this->assertSame([], $this->database->find(self::POSTS)); + $this->assertTrue($this->database->getDocument(self::POSTS, 'secret')->isEmpty()); + + $this->expectException(AuthorizationException::class); + $this->database->find(self::PRIVATE); + } + + public function testFromIsRefusedWhileAuthorizationIsEnabled(): void + { + foreach ([self::POSTS, self::PRIVATE] as $collection) { + try { + $this->database->from($collection); + $this->fail("from({$collection}) must not hand Bob a builder that reads past his permissions"); + } catch (AuthorizationException $exception) { + $this->assertStringContainsString('skip', $exception->getMessage(), 'The refusal must say how to use the builder'); + } + } + } + + public function testStatementsBuiltInsideSkipDoNotRunOutsideIt(): void + { + $builder = $this->authorization->skip(fn () => $this->database->from(self::POSTS)->select(['$id', 'title'])); + $read = $builder->build(); + $write = $this->authorization->skip(fn (): Statement => $this->database->from(self::POSTS) + ->set(['title' => 'defaced']) + ->filter([Query::equal('$id', ['secret'])]) + ->update()); + + foreach ([ + 'the builder' => fn () => $builder->execute(), + 'a read statement' => fn () => $read->execute(), + 'a read statement through Database::query()' => fn () => $this->database->query($read), + 'the builder through Database::query()' => fn () => $this->database->query($builder), + 'a write statement' => fn () => $write->execute(), + 'a write statement through Database::mutate()' => fn () => $this->database->mutate($write), + ] as $label => $run) { + try { + $run(); + $this->fail("Running {$label} with authorization enabled must be refused"); + } catch (AuthorizationException) { + } + } + + $this->assertSame('alice only', $this->title('secret'), 'A refused write must not reach the table'); + } + + public function testTheBuilderReadsAndWritesPastPermissionsInsideSkip(): void + { + $rows = $this->authorization->skip(fn () => $this->database->from(self::POSTS)->select(['$id', 'title'])->execute()); + + $this->assertIsArray($rows); + $this->assertCount(1, $rows); + $this->assertInstanceOf(Document::class, $rows[0]); + $this->assertSame('alice only', $rows[0]->getAttribute('title')); + + $affected = $this->authorization->skip(fn () => $this->database->from(self::POSTS) + ->set(['title' => 'edited']) + ->filter([Query::equal('$id', ['secret'])]) + ->update() + ->execute()); + + $this->assertSame(1, $affected); + $this->assertSame('edited', $this->title('secret')); + $this->assertTrue($this->authorization->getStatus(), 'skip() must restore authorization afterwards'); + } + + private function title(string $id): string + { + $statement = $this->pdo->prepare('SELECT title FROM authorization_'.self::POSTS.' WHERE _uid = ?'); + $statement->execute([$id]); + $title = $statement->fetchColumn(); + $this->assertIsString($title); + + return $title; + } +} diff --git a/tests/unit/QueryBuilderTenancyTest.php b/tests/unit/QueryBuilderTenancyTest.php new file mode 100644 index 0000000000..8e1775c2f5 --- /dev/null +++ b/tests/unit/QueryBuilderTenancyTest.php @@ -0,0 +1,654 @@ + 'book', + self::REVIEWS => 'review', + self::EXTRAS => 'extra', + ]; + + private const array NUMBERS = [ + self::BOOKS => 'pages', + self::REVIEWS => 'stars', + self::EXTRAS => 'weight', + ]; + + /** + * Tenant one's book "b2" belongs to an author only tenant two has and its "b3" to the + * tenantless author; its review "r3" names no author and "r4" one only tenant two has. Only + * tenant two and the tenantless rows have extras, so tenant one's cross join with them is + * empty and every row a later outer join keeps must come back unmatched. + * + * @var array>>> + */ + private const array ROWS = [ + self::FIRST => [ + self::AUTHORS => [ + 'a1' => ['name' => 'one-a1'], + 'a2' => ['name' => 'one-a2'], + ], + self::BOOKS => [ + 'b1' => ['authorId' => 'a1', 'pages' => 11], + 'b2' => ['authorId' => 'shared', 'pages' => 12], + 'b3' => ['authorId' => 'legacy', 'pages' => 13], + ], + self::REVIEWS => [ + 'r1' => ['authorId' => 'a1', 'stars' => 5], + 'r2' => ['authorId' => 'a2', 'stars' => 4], + 'r3' => ['authorId' => 'ghost', 'stars' => 3], + 'r4' => ['authorId' => 'shared', 'stars' => 2], + ], + self::EXTRAS => [], + ], + self::SECOND => [ + self::AUTHORS => [ + 'a1' => ['name' => 'two-a1'], + 'a2' => ['name' => 'two-a2'], + 'shared' => ['name' => 'two-shared'], + ], + self::BOOKS => [ + 'b1' => ['authorId' => 'a1', 'pages' => 21], + 'b2' => ['authorId' => 'a2', 'pages' => 22], + ], + self::REVIEWS => [ + 'r1' => ['authorId' => 'shared', 'stars' => 1], + ], + self::EXTRAS => [ + 'x1' => ['authorId' => 'a1', 'weight' => 5], + ], + ], + self::TENANTLESS => [ + self::AUTHORS => [ + 'legacy' => ['name' => 'no-tenant'], + ], + self::BOOKS => [ + 'orphan' => ['authorId' => 'a1', 'pages' => 99], + ], + self::REVIEWS => [ + 'stale' => ['authorId' => 'a2', 'stars' => 9], + ], + self::EXTRAS => [ + 'x9' => ['authorId' => 'a2', 'weight' => 9], + ], + ], + ]; + + private PDO $pdo; + + /** + * Every single join and every chain of two joins, the second joined on the main table or on + * the first join: chains.php's ten chains among them. A right or full outer join that follows a + * right, full outer or cross join must not pair its rows with another tenant's rows of the + * earlier table, or they vanish instead of coming back unmatched. + */ + public function testEveryJoinAndChainOfTwoJoinsReadsWhatADedicatedDatabaseReads(): void + { + $this->assertChainsReadWhatADedicatedDatabaseReads(self::chainsOfTwo()); + } + + public function testChainsOfThreeJoinsReadWhatADedicatedDatabaseReads(): void + { + $this->assertChainsReadWhatADedicatedDatabaseReads([ + 'right, right, right' => [[JoinType::Right, self::BOOKS, null], [JoinType::Right, self::REVIEWS, null], [JoinType::Right, self::EXTRAS, self::REVIEWS]], + 'cross, right, full on it' => [[JoinType::Cross, self::EXTRAS, null], [JoinType::Right, self::BOOKS, null], [JoinType::FullOuter, self::REVIEWS, self::BOOKS]], + 'full, cross, right on the first' => [[JoinType::FullOuter, self::BOOKS, null], [JoinType::Cross, self::EXTRAS, null], [JoinType::Right, self::REVIEWS, self::BOOKS]], + 'left, right on it, full on that' => [[JoinType::Left, self::BOOKS, null], [JoinType::Right, self::REVIEWS, self::BOOKS], [JoinType::FullOuter, self::EXTRAS, self::REVIEWS]], + 'inner, cross, right' => [[JoinType::Inner, self::BOOKS, null], [JoinType::Cross, self::EXTRAS, null], [JoinType::Right, self::REVIEWS, null]], + 'right, left on it, right on the main table' => [[JoinType::Right, self::BOOKS, null], [JoinType::Left, self::REVIEWS, self::BOOKS], [JoinType::Right, self::EXTRAS, null]], + 'full, full on it, full on that' => [[JoinType::FullOuter, self::BOOKS, null], [JoinType::FullOuter, self::REVIEWS, self::BOOKS], [JoinType::FullOuter, self::EXTRAS, self::REVIEWS]], + ]); + } + + public function testARenamedMainTableIsScopedAndRefusedOnlyForRightAndFullOuterJoins(): void + { + $shared = $this->shared(); + $shared->setTenant(self::FIRST); + $dedicated = $this->dedicated(self::FIRST); + + foreach ([JoinType::Inner, JoinType::Left, JoinType::Cross] as $type) { + $joins = [[$type, $type === JoinType::Cross ? self::EXTRAS : self::BOOKS, null]]; + + $this->assertSame( + $this->read($dedicated, $joins, 'author'), + $this->read($shared, $joins, 'author'), + "A {$type->value} must read the selected tenant's rows when the main table is aliased", + ); + } + + foreach ([JoinType::Right, JoinType::FullOuter] as $type) { + $this->assertSame( + self::REJECTED, + $this->read($shared, [[$type, self::BOOKS, null]], 'author'), + "A {$type->value} pairs rows with the main table named as Database::from() names it, so renaming it is refused", + ); + } + } + + public function testABuilderWithoutATableIsRefused(): void + { + $database = $this->shared(); + $database->setTenant(self::FIRST); + + $this->expectException(QueryException::class); + $database->getAuthorization()->skip(fn () => $this->builder($database)->fromNone()->selectRaw('1')->execute()); + } + + public function testARawUpdateChangesOnlyTheSelectedTenantsRows(): void + { + $database = $this->shared(); + $database->setTenant(self::FIRST); + + $this->assertSame(1, $this->update($database, self::BOOKS, ['pages' => 100], [Query::equal('$id', ['b1'])]), 'Tenant two also has a book "b1"'); + $this->assertSame( + [ + 'b1' => [self::FIRST => 100, self::SECOND => 21], + 'b2' => [self::FIRST => 12, self::SECOND => 22], + 'b3' => [self::FIRST => 13], + 'orphan' => [self::NO_TENANT => 99], + ], + $this->stored(self::BOOKS, 'pages'), + ); + + $this->assertSame(3, $this->update($database, self::BOOKS, ['pages' => 0]), 'An unfiltered update must reach only the selected tenant'); + $this->assertSame( + [ + 'b1' => [self::FIRST => 0, self::SECOND => 21], + 'b2' => [self::FIRST => 0, self::SECOND => 22], + 'b3' => [self::FIRST => 0], + 'orphan' => [self::NO_TENANT => 99], + ], + $this->stored(self::BOOKS, 'pages'), + ); + } + + public function testARawDeleteRemovesOnlyTheSelectedTenantsRows(): void + { + $database = $this->shared(); + $database->setTenant(self::FIRST); + + $this->assertSame(1, $this->delete($database, self::REVIEWS, [Query::equal('$id', ['r1'])]), 'Tenant two also has a review "r1"'); + $this->assertSame( + [ + 'r1' => [self::SECOND => 1], + 'r2' => [self::FIRST => 4], + 'r3' => [self::FIRST => 3], + 'r4' => [self::FIRST => 2], + 'stale' => [self::NO_TENANT => 9], + ], + $this->stored(self::REVIEWS, 'stars'), + ); + + $this->assertSame(3, $this->delete($database, self::REVIEWS), 'An unfiltered delete must reach only the selected tenant'); + $this->assertSame( + ['r1' => [self::SECOND => 1], 'stale' => [self::NO_TENANT => 9]], + $this->stored(self::REVIEWS, 'stars'), + ); + } + + public function testWithoutATenantTheBuilderReadsAndWritesNothing(): void + { + $database = $this->shared(); + $database->setTenant(null); + $before = $this->stored(self::AUTHORS, 'name'); + + $this->assertSame([], $this->read($database, []), 'Not even the rows without a tenant'); + $this->assertSame(0, $this->update($database, self::AUTHORS, ['name' => 'renamed'])); + $this->assertSame(0, $this->delete($database, self::AUTHORS)); + $this->assertSame($before, $this->stored(self::AUTHORS, 'name')); + } + + public function testAnotherTenantIsReadOnlyBySelectingIt(): void + { + $database = $this->shared(); + $database->setTenant(self::FIRST); + + $selected = $this->read($database, []); + $other = $database->withTenant(self::SECOND, fn () => $this->read($database, [])); + + $this->assertSame(['["one-a1"]', '["one-a2"]'], $selected); + $this->assertSame(['["two-a1"]', '["two-a2"]', '["two-shared"]'], $other); + } + + /** + * @return iterable + */ + public static function dialects(): iterable + { + yield 'PostgreSQL' => [new Postgres(new PDO('sqlite::memory:')), '"']; + yield 'MariaDB' => [new MariaDB(new PDO('sqlite::memory:')), '`']; + } + + /** + * PostgreSQL folds an unquoted identifier to lower case, so a condition that names a mixed-case + * alias unquoted finds no table by that name. Every table a tenant condition names must be named + * as the builder declares it, and the main table by its own name, since a bare column is + * ambiguous once a join is added. + */ + #[DataProvider('dialects')] + public function testEveryTenantConditionNamesItsTableAsTheBuilderDeclaresIt(SQL $adapter, string $quote): void + { + $adapter->setDatabase('builder'); + $adapter->setNamespace('capture'); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + $raw = fn (string $collection): string => $this->rawTable($adapter, $collection); + $quoted = static fn (string $identifier): string => \implode('.', \array_map( + static fn (string $part): string => $quote.$part.$quote, + \explode('.', $identifier), + )); + $authors = $quoted($raw(self::AUTHORS)); + + $builder = $adapter->builder()->from(self::AUTHORS) + ->crossJoin(self::EXTRAS, 'Extra') + ->rightJoin(self::REVIEWS, 'Extra.authorId', 'Review.authorId', '=', 'Review') + ->joinWhere(self::BOOKS, static function (JoinBuilder $join): void { + $join->on('Review.authorId', 'Book.authorId'); + }, JoinType::FullOuter, 'Book') + ->select([$raw(self::AUTHORS).'.name', 'Book.pages']); + $sql = $builder->build()->query; + + $tenant = static fn (string $table): string => "{$quoted($table)}._tenant IN (?)"; + $missing = static fn (string $table): string => "({$tenant($table)} OR {$quoted($table.'.'.Storage::UID)} IS NULL)"; + $this->assertSame( + "SELECT {$authors}.{$quote}name{$quote}, {$quote}Book{$quote}.{$quote}pages{$quote}" + ." FROM {$authors}" + ." CROSS JOIN {$quoted($raw(self::EXTRAS))} AS {$quote}Extra{$quote}" + ." RIGHT JOIN {$quoted($raw(self::REVIEWS))} AS {$quote}Review{$quote}" + ." ON {$quote}Extra{$quote}.{$quote}authorId{$quote} = {$quote}Review{$quote}.{$quote}authorId{$quote}" + ." AND {$missing($raw(self::AUTHORS))} AND {$tenant('Review')} AND {$missing('Extra')}" + ." FULL OUTER JOIN {$quoted($raw(self::BOOKS))} AS {$quote}Book{$quote}" + ." ON {$quote}Review{$quote}.{$quote}authorId{$quote} = {$quote}Book{$quote}.{$quote}authorId{$quote}" + ." AND {$missing($raw(self::AUTHORS))} AND {$tenant('Book')} AND {$missing('Extra')} AND {$missing('Review')}" + ." WHERE {$missing($raw(self::AUTHORS))} AND {$missing('Extra')} AND {$missing('Review')} AND {$missing('Book')}", + $sql, + ); + $this->assertDoesNotMatchRegularExpression('/(?assertDoesNotMatchRegularExpression('/(?assertSame( + "UPDATE {$authors} SET {$quote}name{$quote} = ? WHERE {$tenant($raw(self::AUTHORS))}", + $adapter->builder()->from(self::AUTHORS)->set(['name' => 'renamed'])->update()->query, + ); + $this->assertSame( + "DELETE FROM {$authors} WHERE {$tenant($raw(self::AUTHORS))}", + $adapter->builder()->from(self::AUTHORS)->delete()->query, + ); + } + + #[DataProvider('dialects')] + public function testADigitLeadingDatabaseAndJoinAliasAreQuotedInTheTenantConditions(SQL $adapter, string $quote): void + { + $adapter->setDatabase('1db'); + $adapter->setNamespace('capture'); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + $raw = fn (string $collection): string => $this->rawTable($adapter, $collection); + $quoted = static fn (string $identifier): string => \implode('.', \array_map( + static fn (string $part): string => $quote.$part.$quote, + \explode('.', $identifier), + )); + $authors = $quoted($raw(self::AUTHORS)); + + $sql = $adapter->builder()->from(self::AUTHORS) + ->rightJoin(self::REVIEWS, $raw(self::AUTHORS).'.authorId', '9x.authorId', '=', '9x') + ->select([$raw(self::AUTHORS).'.name']) + ->build() + ->query; + + $tenant = static fn (string $table): string => "{$quoted($table)}._tenant IN (?)"; + $missing = static fn (string $table): string => "({$tenant($table)} OR {$quoted($table.'.'.Storage::UID)} IS NULL)"; + $this->assertStringStartsWith("SELECT {$authors}.{$quote}name{$quote} FROM {$authors}", $sql); + $this->assertStringContainsString("{$quote}1db{$quote}.", $sql); + $this->assertStringContainsString($tenant('9x'), $sql); + $this->assertStringEndsWith(" WHERE {$missing($raw(self::AUTHORS))} AND {$missing('9x')}", $sql); + } + + #[DataProvider('dialects')] + public function testAnEmptyDatabaseIsRefusedWithALibraryException(SQL $adapter, string $quote): void + { + $adapter->setDatabase(''); + $adapter->setNamespace('capture'); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + $builder = $adapter->builder()->from(self::AUTHORS) + ->rightJoin(self::REVIEWS, $this->rawTable($adapter, self::AUTHORS).'.authorId', 'Review.authorId', '=', 'Review'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Invalid column name: '.$this->rawTable($adapter, self::AUTHORS).'._uid'); + + $builder->build(); + } + + #[DataProvider('dialects')] + public function testARawJoinAliasWithASpaceIsRefusedWithALibraryException(SQL $adapter, string $quote): void + { + $adapter->setDatabase('builder'); + $adapter->setNamespace('capture'); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + + $builder = $adapter->builder()->from(self::AUTHORS) + ->rightJoin(self::REVIEWS, $this->rawTable($adapter, self::AUTHORS).'.authorId', 'x y.authorId', '=', 'x y'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Invalid column name: x y._uid'); + + $builder->build(); + } + + /** + * @param array> $chains + */ + private function assertChainsReadWhatADedicatedDatabaseReads(array $chains): void + { + $shared = $this->shared(); + + foreach ([self::FIRST, self::SECOND] as $tenant) { + $dedicated = $this->dedicated($tenant); + $shared->setTenant($tenant); + + $expected = []; + $actual = []; + foreach ($chains as $label => $joins) { + $expected[$label] = $this->read($dedicated, $joins); + $actual[$label] = $this->read($shared, $joins); + } + + $this->assertSame($expected, $actual, "Tenant {$tenant} must read through every chain what its own database would return"); + } + } + + /** + * @return array> + */ + private static function chainsOfTwo(): array + { + $chains = []; + foreach ([JoinType::Inner, JoinType::Left, JoinType::Right, JoinType::FullOuter, JoinType::Cross] as $first) { + $collection = $first === JoinType::Cross ? self::EXTRAS : self::BOOKS; + $chains["{$first->value} {$collection}"] = [[$first, $collection, null]]; + $chains["{$first->value} ".self::REVIEWS] = [[$first, self::REVIEWS, null]]; + + foreach ([JoinType::Inner, JoinType::Left, JoinType::Right, JoinType::FullOuter] as $second) { + foreach ([null, $collection] as $on) { + $target = $on ?? self::AUTHORS; + $chains["{$first->value} {$collection}, {$second->value} reviews on {$target}"] = [ + [$first, $collection, null], + [$second, self::REVIEWS, $on], + ]; + } + } + } + + return $chains; + } + + /** + * Both tenants' rows and the tenantless ones, in one set of shared tables. + */ + private function shared(): Database + { + $pdo = new PDO('sqlite::memory:'); + $database = $this->database($pdo, sharedTables: true); + + foreach (self::ROWS as $tenant => $collections) { + $database->setTenant($tenant); + $this->write($database, $collections); + } + + foreach (\array_keys(self::ROWS[self::FIRST]) as $collection) { + foreach ([$collection, Storage::permissionsTable($collection)] as $table) { + $pdo->exec('UPDATE '.self::table($table).' SET '.Storage::TENANT.' = NULL WHERE '.Storage::TENANT.' = '.self::TENANTLESS); + } + } + + $this->pdo = $pdo; + + return $database; + } + + /** + * One tenant's rows alone, in tables of their own: what that tenant must read. + */ + private function dedicated(int $tenant): Database + { + $database = $this->database(new PDO('sqlite::memory:'), sharedTables: false); + $this->write($database, self::ROWS[$tenant]); + + return $database; + } + + private function database(PDO $pdo, bool $sharedTables): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database(new SQLite($pdo), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('joins') + ->setNamespace(self::NAMESPACE) + ->setSharedTables($sharedTables) + ->setTenant(null); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + foreach (self::NUMBERS as $collection => $number) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::string(key: 'authorId', size: 64, required: true), + Attribute::integer(key: $number, required: true), + ], + permissions: $permissions, + documentSecurity: false, + )); + } + + return $database; + } + + /** + * @param array>> $collections + */ + private function write(Database $database, array $collections): void + { + foreach ($collections as $collection => $documents) { + foreach ($documents as $id => $attributes) { + $database->createDocument($collection, new Document(['$id' => $id, ...$attributes])); + } + } + } + + private function builder(Database $database, string $collection = self::AUTHORS): SQLBuilder + { + $builder = $database->from($collection); + $this->assertInstanceOf(SQLBuilder::class, $builder); + + return $builder; + } + + /** + * The rows as [author name, then each join's number], sorted, or REJECTED when refused. + * + * @param list $joins Each join's type, collection, and the + * collection its ON names, or null for the main one + * @param string $alias The main table's alias, or empty for none + * @return list|string + */ + private function read(Database $database, array $joins, string $alias = ''): array|string + { + $main = $alias !== '' ? $alias : self::table(self::AUTHORS); + + try { + $documents = $database->getAuthorization()->skip(function () use ($database, $joins, $alias, $main): mixed { + $builder = $this->builder($database); + if ($alias !== '') { + $builder->from(self::AUTHORS, $alias); + } + + $columns = [$main.'.name']; + foreach ($joins as [$type, $collection, $on]) { + $this->join($builder, $type, $collection, $on === null ? $main.'.'.Storage::UID : self::ALIASES[$on].'.authorId'); + $columns[] = self::ALIASES[$collection].'.'.self::NUMBERS[$collection]; + } + + return $builder->select($columns)->execute(); + }); + } catch (QueryException) { + return self::REJECTED; + } + + $this->assertIsArray($documents); + $rows = []; + foreach ($documents as $document) { + $this->assertInstanceOf(Document::class, $document); + $name = $document->getAttribute('name'); + $row = [\is_string($name) ? $name : null]; + foreach ($joins as [, $collection]) { + $number = $document->getAttribute(self::NUMBERS[$collection]); + $row[] = \is_numeric($number) ? (int) $number : null; + } + $rows[] = (string) \json_encode($row); + } + \sort($rows); + + return $rows; + } + + /** + * @param array $values + * @param list $filters + */ + private function update(Database $database, string $collection, array $values, array $filters = []): mixed + { + return $database->getAuthorization()->skip( + fn () => $this->builder($database, $collection)->set($values)->filter($filters)->update()->execute(), + ); + } + + /** + * @param list $filters + */ + private function delete(Database $database, string $collection, array $filters = []): mixed + { + return $database->getAuthorization()->skip( + fn () => $this->builder($database, $collection)->filter($filters)->delete()->execute(), + ); + } + + private function join(SQLBuilder $builder, JoinType $type, string $collection, string $on): void + { + $table = $collection; + $alias = self::ALIASES[$collection]; + $column = $alias.'.authorId'; + + match ($type) { + JoinType::Inner => $builder->join($table, $on, $column, '=', $alias), + JoinType::Left => $builder->leftJoin($table, $on, $column, '=', $alias), + JoinType::Right => $builder->rightJoin($table, $on, $column, '=', $alias), + JoinType::FullOuter => $builder->joinWhere($table, static function (JoinBuilder $join) use ($on, $column): void { + $join->on($on, $column); + }, JoinType::FullOuter, $alias), + JoinType::Cross => $builder->crossJoin($table, $alias), + JoinType::Natural => $builder->naturalJoin($table, $alias), + }; + } + + /** + * Each stored row's value by document id and tenant, the tenantless row under NO_TENANT. + * + * @return array> + */ + private function stored(string $collection, string $column): array + { + $statement = $this->pdo->query('SELECT _uid, _tenant, '.$column.' FROM '.self::table($collection).' ORDER BY _uid, _tenant'); + $this->assertNotFalse($statement); + + $stored = []; + foreach ($statement->fetchAll(PDO::FETCH_NUM) as $row) { + $this->assertIsArray($row); + [$id, $tenant, $value] = $row; + $this->assertIsString($id); + $stored[$id][\is_numeric($tenant) ? (int) $tenant : self::NO_TENANT] = \is_numeric($value) ? (int) $value : (\is_string($value) ? $value : null); + } + + return $stored; + } + + private function rawTable(SQL $adapter, string $collection): string + { + return $adapter->getDatabase().'.'.$adapter->getNamespace().'_'.$collection; + } + + private static function table(string $collection): string + { + return self::NAMESPACE.'_'.$collection; + } +} diff --git a/tests/unit/QueryConversionTest.php b/tests/unit/QueryConversionTest.php new file mode 100644 index 0000000000..dce9e620b7 --- /dev/null +++ b/tests/unit/QueryConversionTest.php @@ -0,0 +1,174 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUnparseableDatetimeQueryValueIsAQueryException(\Closure $adapter): void + { + $database = $this->eventsDatabase($adapter()); + + try { + $database->convertQuery($database->getCollection(self::COLLECTION), Query::equal('occurredAt', ['not-a-date'])); + $this->fail('convertQuery() must refuse an unparseable datetime'); + } catch (QueryException $error) { + $this->assertNotNull($error->getPrevious(), 'the parse failure is kept as the previous exception'); + } + + $this->expectException(QueryException::class); + $database->skipValidation(fn (): array => $database->find(self::COLLECTION, [Query::equal('occurredAt', ['not-a-date'])])); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAttributeWithoutAStringTypeIsRefused(\Closure $adapter): void + { + $database = $this->database($adapter()); + $collection = new Document([ + Document::ID => self::COLLECTION, + 'attributes' => [new Document([Document::ID => 'untyped', 'type' => 5, 'array' => true])], + ]); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Attribute type must be a string, int given'); + $database->convertQuery($collection, Query::equal('untyped', ['x'])); + } + + /** + * @return array + */ + public static function undeclaredValues(): array + { + return [ + 'map value' => [Query::equal('coords', [['x' => 1]]), ColumnType::Object->value], + 'empty map value' => [Query::equal('coords', [[]]), ColumnType::Object->value], + 'list value' => [Query::equal('coords', [[1, 2]]), ''], + 'list after a map' => [Query::equal('coords', [['x' => 1], [1, 2]]), ''], + 'scalar value' => [Query::equal('coords', ['x']), ''], + 'no values' => [Query::isNull('coords'), ''], + ]; + } + + #[DataProvider('undeclaredValues')] + public function testOnlyMapValuesOfAnUndeclaredAttributeAreObjectQueries(Query $query, string $type): void + { + $database = $this->database(new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes, + )); + } + }); + + $converted = $database->convertQuery(new Document([Document::ID => self::COLLECTION, 'attributes' => []]), $query); + + $this->assertSame($type, $converted->getAttributeType()); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testConvertQueryMatchesConvertQueries(\Closure $adapter): void + { + $database = $this->eventsDatabase($adapter()); + $collection = $database->getCollection(self::COLLECTION); + + foreach ([ + static fn (): Query => Query::equal('occurredAt', ['2026-09-30 10:00:00']), + static fn (): Query => Query::containsAny('tags', ['a']), + static fn (): Query => Query::equal(Document::CREATED_AT, ['2026-09-30 10:00:00']), + ] as $build) { + $single = $database->convertQuery($collection, $build()); + [$listed] = $database->convertQueries($collection, [$build()]); + + $this->assertNotSame('', $single->getAttributeType(), $single->getAttribute()); + $this->assertSame($listed->getAttributeType(), $single->getAttributeType(), $single->getAttribute()); + $this->assertSame($listed->onArray(), $single->onArray(), $single->getAttribute()); + $this->assertSame($listed->getValues(), $single->getValues(), $single->getAttribute()); + } + + $this->assertTrue($database->convertQuery($collection, Query::containsAny('tags', ['a']))->onArray()); + + if ($database->getAdapter()->supports(Capability::Objects)) { + $database->createAttribute(self::COLLECTION, Attribute::object(key: 'meta')); + $withObject = $database->getCollection(self::COLLECTION); + $this->assertSame( + ColumnType::Object->value, + $database->convertQuery($withObject, Query::equal('meta.level', ['x']))->getAttributeType(), + 'a path into an object attribute converts as an object query', + ); + } + $this->assertNotSame( + ['2026-09-30 10:00:00'], + $database->convertQuery($collection, Query::equal('occurredAt', ['2026-09-30 10:00:00']))->getValues(), + 'a datetime value is converted to the storage format', + ); + } + + private function eventsDatabase(Adapter $adapter): Database + { + $database = $this->database($adapter); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::datetime(key: 'occurredAt'), + Attribute::string(key: 'tags', size: 16, array: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setDatabase('conversion')->setNamespace('conversion_'.\uniqid()); + $database->create(); + + return $database; + } +} diff --git a/tests/unit/QuerySerializationTest.php b/tests/unit/QuerySerializationTest.php new file mode 100644 index 0000000000..5de062d12a --- /dev/null +++ b/tests/unit/QuerySerializationTest.php @@ -0,0 +1,209 @@ + + */ + public static function nestedQueriesWithoutAList(): array + { + return [ + 'and with a string' => ['{"method":"and","values":"x"}'], + 'or with a number' => ['{"method":"or","values":5}'], + 'elemMatch with an object' => ['{"method":"elemMatch","attribute":"items","values":{"method":"equal"}}'], + ]; + } + + #[DataProvider('nestedQueriesWithoutAList')] + public function testParseRejectsANestedQueryWhoseValuesAreNotAList(string $json): void + { + $this->expectException(QueryException::class); + + Query::parse($json); + } + + public function testParseDecodesStringChildrenOfANestedQuery(): void + { + $query = Query::parse('{"method":"and","values":["{\"method\":\"equal\",\"attribute\":\"a\",\"values\":[1]}",{"method":"equal","attribute":"b","values":[2]}]}'); + + $children = $query->getValues(); + $this->assertCount(2, $children); + $this->assertInstanceOf(Query::class, $children[0]); + $this->assertSame('a', $children[0]->getAttribute()); + $this->assertInstanceOf(Query::class, $children[1]); + $this->assertSame('b', $children[1]->getAttribute()); + } + + /** + * @return array + */ + public static function nonQueryChildren(): array + { + return [ + 'or with a string' => [Method::Or, 'x', 'string'], + 'and with an array' => [Method::And, ['method' => 'equal'], 'array'], + 'having with an integer' => [Method::Having, 7, 'int'], + ]; + } + + #[DataProvider('nonQueryChildren')] + public function testToArrayRejectsANonQueryChild(Method $method, mixed $child, string $type): void + { + $query = new Query($method, '', [Query::equal('a', ['x']), $child]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage("Invalid child query in {$method->value} at index 1: expected Query, got {$type}"); + + $query->toArray(); + } + + public function testToStringRejectsANonQueryChild(): void + { + $query = new Query(Method::Or, '', [Query::equal('a', ['x']), 'x']); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid child query in or at index 1: expected Query, got string'); + + $query->toString(); + } + + public function testJoinToArrayWritesTheAliasAndEveryOnCondition(): void + { + $join = Query::leftJoin('orders', 'ord', [Query::on('$id', 'ord.user'), Query::equal('ord.status', ['paid'])]); + + $this->assertSame([ + 'method' => 'leftJoin', + 'attribute' => 'orders', + 'alias' => 'ord', + 'values' => [ + ['method' => 'on', 'values' => ['$id', '=', 'ord.user']], + ['method' => 'equal', 'attribute' => 'ord.status', 'values' => ['paid']], + ], + ], $join->toArray()); + } + + public function testParsedJoinKeepsItsAliasAndOnConditions(): void + { + $join = Query::leftJoin('orders', 'ord', [Query::on('$id', 'ord.user', '!='), Query::equal('ord.status', ['paid'])]); + + $parsed = Query::parse($join->toString()); + + $this->assertSame(Method::LeftJoin, $parsed->getMethod()); + $this->assertSame('orders', $parsed->getAttribute()); + $this->assertSame('ord', $parsed->getAlias()); + $on = $parsed->getJoinOnQueries(); + $this->assertCount(2, $on); + $this->assertInstanceOf(Query::class, $on[0]); + $this->assertSame(['$id', '!=', 'ord.user'], $on[0]->getValues()); + $this->assertSame(Method::Equal, $on[1]->getMethod()); + $this->assertSame($join->toArray(), $parsed->toArray()); + } + + public function testParsedCrossJoinKeepsItsAlias(): void + { + $parsed = Query::parseQuery(Query::crossJoin('regions', 'r')->toArray()); + + $this->assertSame(Method::CrossJoin, $parsed->getMethod()); + $this->assertSame('r', $parsed->getAlias()); + $this->assertSame([], $parsed->getValues()); + } + + public function testParsedColumnFormJoinBecomesAnOnCondition(): void + { + $parsed = Query::parseQuery(['method' => 'join', 'attribute' => 'orders', 'values' => ['$id', '>', 'ord.total', 'ord']]); + + $this->assertSame('ord', $parsed->getAlias()); + $this->assertSame( + Query::join('orders', 'ord', [Query::on('$id', 'ord.total', '>')])->toArray(), + $parsed->toArray(), + ); + } + + public function testParsedJoinWithoutAnAliasIsAQueryException(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join alias is required: join orders'); + + Query::parseQuery(['method' => 'join', 'attribute' => 'orders', 'values' => [['method' => 'on', 'values' => ['$id', '=', 'user']]]]); + } + + /** + * @return array, string}> + */ + public static function aggregates(): array + { + return [ + 'alias property' => [Query::sum('price', 'revenue')->toArray(), 'revenue'], + 'legacy alias value' => [['method' => 'sum', 'attribute' => 'price', 'values' => ['revenue']], 'revenue'], + 'no alias' => [Query::sum('price')->toArray(), ''], + ]; + } + + /** + * @param array $array + */ + #[DataProvider('aggregates')] + public function testParsedAggregateKeepsItsAlias(array $array, string $alias): void + { + $parsed = Query::parseQuery($array); + + $this->assertSame(Method::Sum, $parsed->getMethod()); + $this->assertSame('price', $parsed->getAttribute()); + $this->assertSame($alias, $parsed->getAlias()); + $this->assertSame([], $parsed->getValues()); + } + + public function testDocumentCursorSerialisesToItsId(): void + { + $cursor = Query::cursorAfter(new Document(['$id' => 'doc1'])); + + $this->assertSame(['method' => 'cursorAfter', 'values' => ['doc1']], $cursor->toArray()); + $this->assertTrue((new Cursor())->isValid($cursor)); + } + + public function testArrayCursorIsRefusedByTheCursorValidator(): void + { + $cursor = Query::cursorBefore(['$id' => 'doc1']); + + $validator = new Cursor(); + + $this->assertFalse($validator->isValid($cursor)); + $this->assertStringStartsWith('Invalid cursor: ', $validator->getDescription()); + } + + /** + * @return array + */ + public static function attributeTypes(): array + { + return [ + 'point' => [ColumnType::Point->value, true], + 'linestring' => [ColumnType::Linestring->value, true], + 'polygon' => [ColumnType::Polygon->value, true], + 'string' => [ColumnType::String->value, false], + 'vector' => [ColumnType::Vector->value, false], + 'unset' => ['', false], + 'unknown' => ['circle', false], + ]; + } + + #[DataProvider('attributeTypes')] + public function testIsSpatialAttributeFollowsTheAttributeType(string $type, bool $spatial): void + { + $query = Query::equal('shape', ['x']); + $query->setAttributeType($type); + + $this->assertSame($spatial, $query->isSpatialAttribute()); + } +} diff --git a/tests/unit/QueryShapeDatabaseTest.php b/tests/unit/QueryShapeDatabaseTest.php new file mode 100644 index 0000000000..e5081535b3 --- /dev/null +++ b/tests/unit/QueryShapeDatabaseTest.php @@ -0,0 +1,267 @@ +setDatabase('query_shape') + ->setNamespace('query_shape_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer(key: 'amount', required: true), + Attribute::double(key: 'rating', default: 0.0), + Attribute::string(key: 'status', size: 20, required: true), + Attribute::string(key: 'body', size: 200, default: ''), + Attribute::boolean(key: 'paid', default: false), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + foreach ([[5, 'paid'], [7, 'paid'], [3, 'open']] as [$amount, $status]) { + $database->createDocument(self::COLLECTION, new Document([ + 'amount' => $amount, + 'rating' => $amount / 2, + 'status' => $status, + 'body' => 'order of '.$amount, + 'paid' => $status === 'paid', + '$permissions' => [Permission::read(Role::any())], + ])); + } + + return $database; + } + + /** + * @return list + */ + private static function crossJoins(int $count): array + { + return \array_map(fn (int $index): Query => Query::crossJoin(self::COLLECTION, 'joined'.$index), \range(1, $count)); + } + + /** + * @return array, 1: string}> + */ + public static function rejectedShapeProvider(): array + { + return [ + 'more joins than allowed' => [ + self::crossJoins(9), + 'Too many joins: at most 8 are allowed', + ], + 'having an attribute outside the schema' => [ + [Query::sum('amount', 'total'), Query::groupBy(['status']), Query::having([Query::equal('no_such_attribute', ['x'])])], + 'Invalid query: Having can only compare an aggregate alias or a groupBy attribute: no_such_attribute', + ], + 'having an attribute that is not grouped' => [ + [Query::sum('amount', 'total'), Query::groupBy(['status']), Query::having([Query::equal('body', ['x'])])], + 'Invalid query: Having can only compare an aggregate alias or a groupBy attribute: body', + ], + 'having a search without a fulltext index' => [ + [Query::count('*', 'rows'), Query::groupBy(['body']), Query::having([Query::search('body', 'order')])], + 'Searching by attribute "body" requires a fulltext index.', + ], + 'having more values than allowed' => [ + [Query::count('*', 'rows'), Query::groupBy(['status']), Query::having([Query::equal('status', \array_map(fn (int $index): string => 'status'.$index, \range(1, 5001)))])], + 'Invalid query: Query on attribute has greater than 5000 values: status', + ], + 'having a value of the wrong type' => [ + [Query::count('*', 'rows'), Query::groupBy(['paid']), Query::having([Query::greaterThan('paid', 'yes')])], + 'Invalid query: Query value is invalid for attribute "paid"', + ], + 'having a numeric alias compared with text' => [ + [Query::sum('amount', 'total'), Query::groupBy(['status']), Query::having([Query::greaterThan('total', 'abc')])], + 'Invalid query: Query value is invalid for aggregate alias "total"', + ], + 'having an alias inside a logical group' => [ + [Query::sum('amount', 'total'), Query::groupBy(['status']), Query::having([Query::or([Query::greaterThan('total', 10), Query::lessThan('total', 1)])])], + 'Invalid query: Aggregate alias "total" can only be compared at the top level of having', + ], + 'sum of a string attribute' => [ + [Query::sum('status', 'total')], + 'Invalid query: Aggregate sum requires a numeric attribute that is not an array: status', + ], + 'standard deviation of a string attribute' => [ + [Query::stddev('status', 'spread')], + 'Invalid query: Aggregate stddev requires a numeric attribute that is not an array: status', + ], + 'average of a boolean attribute' => [ + [Query::avg('paid', 'mean')], + 'Invalid query: Aggregate avg requires a numeric attribute that is not an array: paid', + ], + 'bitwise and of a double attribute' => [ + [Query::bitAnd('rating', 'bits')], + 'Invalid query: Aggregate bitAnd requires an integer attribute that is not an array: rating', + ], + 'minimum of every row' => [ + [Query::min('*', 'least')], + 'Invalid query: Only count can aggregate "*"', + ], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('rejectedShapeProvider')] + public function testFindRejectsTheShapeWithAQueryException(array $queries, string $message): void + { + $error = $this->capture(fn () => $this->database()->find(self::COLLECTION, $queries)); + + $this->assertInstanceOf(QueryException::class, $error, $error === null ? 'find() accepted the query shape' : $error::class.': '.$error->getMessage()); + $this->assertSame($message, $error->getMessage()); + } + + private function capture(callable $call): ?Throwable + { + try { + $call(); + } catch (Throwable $error) { + return $error; + } + + return null; + } + + public function testCountAndSumRejectTooManyJoins(): void + { + $database = $this->database(); + + $this->assertSame(3, $database->count(self::COLLECTION, [])); + + foreach ([ + 'count' => fn () => $database->count(self::COLLECTION, self::crossJoins(9)), + 'sum' => fn () => $database->sum(self::COLLECTION, 'amount', self::crossJoins(9)), + ] as $method => $call) { + $error = $this->capture($call); + + $this->assertInstanceOf(QueryException::class, $error, $error === null ? $method.'() accepted nine joins' : $error::class.': '.$error->getMessage()); + $this->assertSame('Too many joins: at most 8 are allowed', $error->getMessage()); + } + } + + public function testEightJoinsAreStillAllowed(): void + { + $database = $this->database(); + + $rows = $database->find(self::COLLECTION, [ + Query::equal('status', ['open']), + ...self::crossJoins(8), + Query::limit(1), + ]); + + $this->assertCount(1, $rows); + } + + public function testEmptySetAggregatesFollowTheContract(): void + { + $database = $this->database(); + + $queries = [ + Query::equal('status', ['nonexistent']), + Query::count('*', 'rows'), + Query::countDistinct('status', 'statuses'), + Query::sum('amount', 'total'), + Query::avg('amount', 'mean'), + Query::min('amount', 'least'), + Query::max('amount', 'most'), + ]; + + $results = $database->aggregate(self::COLLECTION, $queries); + + $this->assertCount(1, $results); + $this->assertSame(0, $results[0]['rows']); + $this->assertSame(0, $results[0]['statuses']); + foreach (['total', 'mean', 'least', 'most'] as $alias) { + $this->assertTrue(\array_key_exists($alias, $results[0]), $alias.' must be present'); + $this->assertNull($results[0][$alias], $alias.' over no rows must be null'); + } + } + + /** + * @return iterable + */ + public static function fullOuterJoinModes(): iterable + { + yield 'emulated full outer join' => [false]; + yield 'native full outer join' => [true]; + } + + /** + * The contract holds over a full outer join too, whose aggregation runs once over both halves of + * the emulation on engines without one. + */ + #[DataProvider('fullOuterJoinModes')] + public function testEmptySetAggregatesOverAFullOuterJoinFollowTheContract(bool $nativeFullOuterJoin): void + { + $database = $this->database($nativeFullOuterJoin); + $database->createCollection(Collection::create( + id: 'refunds', + attributes: [ + Attribute::integer(key: 'amount', required: true), + Attribute::string(key: 'status', size: 20, required: true), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument('refunds', new Document([ + 'amount' => 2, + 'status' => 'refunded', + '$permissions' => [Permission::read(Role::any())], + ])); + $noRows = [ + Query::fullOuterJoin('refunds', 'refund', [Query::on('status', 'status')]), + Query::equal('status', ['nonexistent']), + ]; + + $results = $database->aggregate(self::COLLECTION, [ + ...$noRows, + Query::count('*', 'rows'), + Query::countDistinct('refund.status', 'statuses'), + Query::sum('refund.amount', 'total'), + Query::avg('refund.amount', 'mean'), + Query::min('amount', 'least'), + Query::max('refund.amount', 'most'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(0, $results[0]['rows']); + $this->assertSame(0, $results[0]['statuses']); + foreach (['total', 'mean', 'least', 'most'] as $alias) { + $this->assertTrue(\array_key_exists($alias, $results[0]), $alias.' must be present'); + $this->assertNull($results[0][$alias], $alias.' over no rows must be null'); + } + $this->assertSame(0, $database->count(self::COLLECTION, $noRows)); + $this->assertSame(0, $database->sum(self::COLLECTION, 'amount', $noRows), 'Database::sum() still answers 0 over no rows'); + } +} diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index 7d1414c0fb..f2adebb774 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -6,46 +6,50 @@ use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; class QueryTest extends TestCase { - public function setUp(): void + #[\Override] + protected function setUp(): void { } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } - public function testCreate(): void + public function test_create(): void { - $query = new Query(Query::TYPE_EQUAL, 'title', ['Iron Man']); + $query = new Query(Method::Equal, 'title', ['Iron Man']); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals(Method::Equal, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals('Iron Man', $query->getValues()[0]); - $query = new Query(Query::TYPE_ORDER_DESC, 'score'); + $query = new Query(Method::OrderDesc, 'score'); - $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertEquals(Method::OrderDesc, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); $this->assertEquals([], $query->getValues()); - $query = new Query(Query::TYPE_LIMIT, values: [10]); + $query = new Query(Method::Limit, values: [10]); - $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertEquals(Method::Limit, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals(10, $query->getValues()[0]); $query = Query::equal('title', ['Iron Man']); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals(Method::Equal, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals('Iron Man', $query->getValues()[0]); $query = Query::greaterThan('score', 10); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals(Method::GreaterThan, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); $this->assertEquals(10, $query->getValues()[0]); @@ -53,275 +57,274 @@ public function testCreate(): void $vector = [0.1, 0.2, 0.3]; $query = Query::vectorDot('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_DOT, $query->getMethod()); + $this->assertEquals(Method::VectorDot, $query->getMethod()); $this->assertEquals('embedding', $query->getAttribute()); $this->assertEquals([$vector], $query->getValues()); $query = Query::vectorCosine('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_COSINE, $query->getMethod()); + $this->assertEquals(Method::VectorCosine, $query->getMethod()); $this->assertEquals('embedding', $query->getAttribute()); $this->assertEquals([$vector], $query->getValues()); $query = Query::vectorEuclidean('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_EUCLIDEAN, $query->getMethod()); + $this->assertEquals(Method::VectorEuclidean, $query->getMethod()); $this->assertEquals('embedding', $query->getAttribute()); $this->assertEquals([$vector], $query->getValues()); $query = Query::search('search', 'John Doe'); - $this->assertEquals(Query::TYPE_SEARCH, $query->getMethod()); + $this->assertEquals(Method::Search, $query->getMethod()); $this->assertEquals('search', $query->getAttribute()); $this->assertEquals('John Doe', $query->getValues()[0]); $query = Query::orderAsc('score'); - $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertEquals(Method::OrderAsc, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); $this->assertEquals([], $query->getValues()); $query = Query::limit(10); - $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertEquals(Method::Limit, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([10], $query->getValues()); $cursor = new Document(); $query = Query::cursorAfter($cursor); - $this->assertEquals(Query::TYPE_CURSOR_AFTER, $query->getMethod()); + $this->assertEquals(Method::CursorAfter, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([$cursor], $query->getValues()); $query = Query::isNull('title'); - $this->assertEquals(Query::TYPE_IS_NULL, $query->getMethod()); + $this->assertEquals(Method::IsNull, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals([], $query->getValues()); $query = Query::isNotNull('title'); - $this->assertEquals(Query::TYPE_IS_NOT_NULL, $query->getMethod()); + $this->assertEquals(Method::IsNotNull, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals([], $query->getValues()); $query = Query::notContains('tags', ['test', 'example']); - $this->assertEquals(Query::TYPE_NOT_CONTAINS, $query->getMethod()); + $this->assertEquals(Method::NotContains, $query->getMethod()); $this->assertEquals('tags', $query->getAttribute()); $this->assertEquals(['test', 'example'], $query->getValues()); $query = Query::notSearch('content', 'keyword'); - $this->assertEquals(Query::TYPE_NOT_SEARCH, $query->getMethod()); + $this->assertEquals(Method::NotSearch, $query->getMethod()); $this->assertEquals('content', $query->getAttribute()); $this->assertEquals(['keyword'], $query->getValues()); $query = Query::notStartsWith('title', 'prefix'); - $this->assertEquals(Query::TYPE_NOT_STARTS_WITH, $query->getMethod()); + $this->assertEquals(Method::NotStartsWith, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals(['prefix'], $query->getValues()); $query = Query::notEndsWith('url', '.html'); - $this->assertEquals(Query::TYPE_NOT_ENDS_WITH, $query->getMethod()); + $this->assertEquals(Method::NotEndsWith, $query->getMethod()); $this->assertEquals('url', $query->getAttribute()); $this->assertEquals(['.html'], $query->getValues()); $query = Query::notBetween('score', 10, 20); - $this->assertEquals(Query::TYPE_NOT_BETWEEN, $query->getMethod()); + $this->assertEquals(Method::NotBetween, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); $this->assertEquals([10, 20], $query->getValues()); // Test new date query wrapper methods $query = Query::createdBefore('2023-01-01T00:00:00.000Z'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertEquals(Method::LessThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); $query = Query::createdAfter('2023-01-01T00:00:00.000Z'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); $query = Query::updatedBefore('2023-12-31T23:59:59.999Z'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertEquals(Method::LessThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); $query = Query::updatedAfter('2023-12-31T23:59:59.999Z'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); $query = Query::createdBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertEquals(Method::Between, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); $query = Query::updatedBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertEquals(Method::Between, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); // Test orderRandom query $query = Query::orderRandom(); - $this->assertEquals(Query::TYPE_ORDER_RANDOM, $query->getMethod()); + $this->assertEquals(Method::OrderRandom, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } /** - * @return void * @throws QueryException */ - public function testParse(): void + public function test_parse(): void { $jsonString = Query::equal('title', ['Iron Man'])->toString(); $query = Query::parse($jsonString); $this->assertEquals('{"method":"equal","attribute":"title","values":["Iron Man"]}', $jsonString); - $this->assertEquals('equal', $query->getMethod()); + $this->assertEquals(Method::Equal, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals('Iron Man', $query->getValues()[0]); $query = Query::parse(Query::lessThan('year', 2001)->toString()); - $this->assertEquals('lessThan', $query->getMethod()); + $this->assertEquals(Method::LessThan, $query->getMethod()); $this->assertEquals('year', $query->getAttribute()); $this->assertEquals(2001, $query->getValues()[0]); $query = Query::parse(Query::equal('published', [true])->toString()); - $this->assertEquals('equal', $query->getMethod()); + $this->assertEquals(Method::Equal, $query->getMethod()); $this->assertEquals('published', $query->getAttribute()); $this->assertTrue($query->getValues()[0]); $query = Query::parse(Query::equal('published', [false])->toString()); - $this->assertEquals('equal', $query->getMethod()); + $this->assertEquals(Method::Equal, $query->getMethod()); $this->assertEquals('published', $query->getAttribute()); $this->assertFalse($query->getValues()[0]); $query = Query::parse(Query::equal('actors', [' Johnny Depp ', ' Brad Pitt', 'Al Pacino '])->toString()); - $this->assertEquals('equal', $query->getMethod()); + $this->assertEquals(Method::Equal, $query->getMethod()); $this->assertEquals('actors', $query->getAttribute()); $this->assertEquals(' Johnny Depp ', $query->getValues()[0]); $this->assertEquals(' Brad Pitt', $query->getValues()[1]); $this->assertEquals('Al Pacino ', $query->getValues()[2]); $query = Query::parse(Query::equal('actors', ['Brad Pitt', 'Johnny Depp'])->toString()); - $this->assertEquals('equal', $query->getMethod()); + $this->assertEquals(Method::Equal, $query->getMethod()); $this->assertEquals('actors', $query->getAttribute()); $this->assertEquals('Brad Pitt', $query->getValues()[0]); $this->assertEquals('Johnny Depp', $query->getValues()[1]); - $query = Query::parse(Query::contains('writers', ['Tim O\'Reilly'])->toString()); - $this->assertEquals('contains', $query->getMethod()); + $query = Query::parse(Query::containsString('writers', ['Tim O\'Reilly'])->toString()); + $this->assertEquals(Method::Contains, $query->getMethod()); $this->assertEquals('writers', $query->getAttribute()); $this->assertEquals('Tim O\'Reilly', $query->getValues()[0]); $query = Query::parse(Query::greaterThan('score', 8.5)->toString()); - $this->assertEquals('greaterThan', $query->getMethod()); + $this->assertEquals(Method::GreaterThan, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); $this->assertEquals(8.5, $query->getValues()[0]); $query = Query::parse(Query::notContains('tags', ['unwanted', 'spam'])->toString()); - $this->assertEquals('notContains', $query->getMethod()); + $this->assertEquals(Method::NotContains, $query->getMethod()); $this->assertEquals('tags', $query->getAttribute()); $this->assertEquals(['unwanted', 'spam'], $query->getValues()); $query = Query::parse(Query::notSearch('content', 'unwanted content')->toString()); - $this->assertEquals('notSearch', $query->getMethod()); + $this->assertEquals(Method::NotSearch, $query->getMethod()); $this->assertEquals('content', $query->getAttribute()); $this->assertEquals(['unwanted content'], $query->getValues()); $query = Query::parse(Query::notStartsWith('title', 'temp')->toString()); - $this->assertEquals('notStartsWith', $query->getMethod()); + $this->assertEquals(Method::NotStartsWith, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals(['temp'], $query->getValues()); $query = Query::parse(Query::notEndsWith('filename', '.tmp')->toString()); - $this->assertEquals('notEndsWith', $query->getMethod()); + $this->assertEquals(Method::NotEndsWith, $query->getMethod()); $this->assertEquals('filename', $query->getAttribute()); $this->assertEquals(['.tmp'], $query->getValues()); $query = Query::parse(Query::notBetween('score', 0, 50)->toString()); - $this->assertEquals('notBetween', $query->getMethod()); + $this->assertEquals(Method::NotBetween, $query->getMethod()); $this->assertEquals('score', $query->getAttribute()); $this->assertEquals([0, 50], $query->getValues()); $query = Query::parse(Query::notEqual('director', 'null')->toString()); - $this->assertEquals('notEqual', $query->getMethod()); + $this->assertEquals(Method::NotEqual, $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); $this->assertEquals('null', $query->getValues()[0]); $query = Query::parse(Query::isNull('director')->toString()); - $this->assertEquals('isNull', $query->getMethod()); + $this->assertEquals(Method::IsNull, $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); $this->assertEquals([], $query->getValues()); $query = Query::parse(Query::isNotNull('director')->toString()); - $this->assertEquals('isNotNull', $query->getMethod()); + $this->assertEquals(Method::IsNotNull, $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); $this->assertEquals([], $query->getValues()); $query = Query::parse(Query::startsWith('director', 'Quentin')->toString()); - $this->assertEquals('startsWith', $query->getMethod()); + $this->assertEquals(Method::StartsWith, $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); $this->assertEquals(['Quentin'], $query->getValues()); $query = Query::parse(Query::endsWith('director', 'Tarantino')->toString()); - $this->assertEquals('endsWith', $query->getMethod()); + $this->assertEquals(Method::EndsWith, $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); $this->assertEquals(['Tarantino'], $query->getValues()); $query = Query::parse(Query::select(['title', 'director'])->toString()); - $this->assertEquals('select', $query->getMethod()); + $this->assertEquals(Method::Select, $query->getMethod()); $this->assertEquals(null, $query->getAttribute()); $this->assertEquals(['title', 'director'], $query->getValues()); // Test new date query wrapper methods parsing $query = Query::parse(Query::createdBefore('2023-01-01T00:00:00.000Z')->toString()); - $this->assertEquals('lessThan', $query->getMethod()); + $this->assertEquals(Method::LessThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); $query = Query::parse(Query::createdAfter('2023-01-01T00:00:00.000Z')->toString()); - $this->assertEquals('greaterThan', $query->getMethod()); + $this->assertEquals(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z'], $query->getValues()); $query = Query::parse(Query::updatedBefore('2023-12-31T23:59:59.999Z')->toString()); - $this->assertEquals('lessThan', $query->getMethod()); + $this->assertEquals(Method::LessThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); $query = Query::parse(Query::updatedAfter('2023-12-31T23:59:59.999Z')->toString()); - $this->assertEquals('greaterThan', $query->getMethod()); + $this->assertEquals(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-12-31T23:59:59.999Z'], $query->getValues()); $query = Query::parse(Query::createdBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z')->toString()); - $this->assertEquals('between', $query->getMethod()); + $this->assertEquals(Method::Between, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); $query = Query::parse(Query::updatedBetween('2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z')->toString()); - $this->assertEquals('between', $query->getMethod()); + $this->assertEquals(Method::Between, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); $query = Query::parse(Query::between('age', 15, 18)->toString()); - $this->assertEquals('between', $query->getMethod()); + $this->assertEquals(Method::Between, $query->getMethod()); $this->assertEquals('age', $query->getAttribute()); $this->assertEquals([15, 18], $query->getValues()); $query = Query::parse(Query::between('lastUpdate', 'DATE1', 'DATE2')->toString()); - $this->assertEquals('between', $query->getMethod()); + $this->assertEquals(Method::Between, $query->getMethod()); $this->assertEquals('lastUpdate', $query->getAttribute()); $this->assertEquals(['DATE1', 'DATE2'], $query->getValues()); @@ -347,7 +350,7 @@ public function testParse(): void $json = Query::or([ Query::equal('actors', ['Brad Pitt']), - Query::equal('actors', ['Johnny Depp']) + Query::equal('actors', ['Johnny Depp']), ])->toString(); $query = Query::parse($json); @@ -355,8 +358,8 @@ public function testParse(): void /** @var array $queries */ $queries = $query->getValues(); $this->assertCount(2, $query->getValues()); - $this->assertEquals(Query::TYPE_OR, $query->getMethod()); - $this->assertEquals(Query::TYPE_EQUAL, $queries[0]->getMethod()); + $this->assertEquals(Method::Or, $query->getMethod()); + $this->assertEquals(Method::Equal, $queries[0]->getMethod()); $this->assertEquals('actors', $queries[0]->getAttribute()); $this->assertEquals($json, '{"method":"or","values":[{"method":"equal","attribute":"actors","values":["Brad Pitt"]},{"method":"equal","attribute":"actors","values":["Johnny Depp"]}]}'); @@ -390,9 +393,16 @@ public function testParse(): void // Test orderRandom query parsing $query = Query::parse(Query::orderRandom()->toString()); - $this->assertEquals('orderRandom', $query->getMethod()); + $this->assertEquals(Method::OrderRandom, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([], $query->getValues()); + + $query = Query::parse(Query::having([Query::greaterThanEqual('total', 1)])->toString()); + $this->assertSame(Method::Having, $query->getMethod()); + $this->assertCount(1, $query->getValues()); + $this->assertInstanceOf(Query::class, $query->getValues()[0]); + $this->assertSame(Method::GreaterThanEqual, $query->getValues()[0]->getMethod()); + $this->assertSame('total', $query->getValues()[0]->getAttribute()); } public function testParseNestedStringValues(): void @@ -400,7 +410,7 @@ public function testParseNestedStringValues(): void // Some clients serialize the children of a logical query as JSON strings // rather than nested objects. Parsing must handle that without a TypeError. $json = (string) \json_encode([ - 'method' => Query::TYPE_OR, + 'method' => Method::Or, 'values' => [ Query::equal('actors', ['Brad Pitt'])->toString(), Query::equal('actors', ['Johnny Depp'])->toString(), @@ -411,25 +421,26 @@ public function testParseNestedStringValues(): void /** @var array $queries */ $queries = $query->getValues(); - $this->assertEquals(Query::TYPE_OR, $query->getMethod()); + $this->assertEquals(Method::Or, $query->getMethod()); $this->assertCount(2, $queries); - $this->assertEquals(Query::TYPE_EQUAL, $queries[0]->getMethod()); + $this->assertEquals(Method::Equal, $queries[0]->getMethod()); $this->assertEquals('actors', $queries[0]->getAttribute()); $this->assertEquals(['Brad Pitt'], $queries[0]->getValues()); - $this->assertEquals(Query::TYPE_EQUAL, $queries[1]->getMethod()); + $this->assertEquals(Method::Equal, $queries[1]->getMethod()); $this->assertEquals(['Johnny Depp'], $queries[1]->getValues()); // A nested value that is neither an array nor a string is a clean // QueryException, never an uncaught TypeError. try { - Query::parse((string) \json_encode(['method' => Query::TYPE_OR, 'values' => [123]])); + Query::parse((string) \json_encode(['method' => Method::Or, 'values' => [123]])); $this->fail('Failed to throw exception'); } catch (QueryException $e) { $this->assertEquals('Invalid nested query. Must be an array or string, got integer', $e->getMessage()); } } - public function testIsMethod(): void + + public function test_is_method(): void { $this->assertTrue(Query::isMethod('equal')); $this->assertTrue(Query::isMethod('notEqual')); @@ -460,47 +471,48 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod('or')); $this->assertTrue(Query::isMethod('and')); - $this->assertTrue(Query::isMethod(Query::TYPE_EQUAL)); - $this->assertTrue(Query::isMethod(Query::TYPE_NOT_EQUAL)); - $this->assertTrue(Query::isMethod(Query::TYPE_LESSER)); - $this->assertTrue(Query::isMethod(Query::TYPE_LESSER_EQUAL)); - $this->assertTrue(Query::isMethod(Query::TYPE_GREATER)); - $this->assertTrue(Query::isMethod(Query::TYPE_GREATER_EQUAL)); - $this->assertTrue(Query::isMethod(Query::TYPE_CONTAINS)); - $this->assertTrue(Query::isMethod(Query::TYPE_NOT_CONTAINS)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_SEARCH)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_SEARCH)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_STARTS_WITH)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_STARTS_WITH)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_ENDS_WITH)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_ENDS_WITH)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_ASC)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_DESC)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_LIMIT)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_OFFSET)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_CURSOR_AFTER)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_CURSOR_BEFORE)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_RANDOM)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NULL)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NOT_NULL)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_BETWEEN)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_BETWEEN)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_SELECT)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_OR)); - $this->assertTrue(Query::isMethod(QUERY::TYPE_AND)); + $this->assertTrue(Query::isMethod(Method::Equal)); + $this->assertTrue(Query::isMethod(Method::NotEqual)); + $this->assertTrue(Query::isMethod(Method::LessThan)); + $this->assertTrue(Query::isMethod(Method::LessThanEqual)); + $this->assertTrue(Query::isMethod(Method::GreaterThan)); + $this->assertTrue(Query::isMethod(Method::GreaterThanEqual)); + $this->assertTrue(Query::isMethod(Method::Contains)); + $this->assertTrue(Query::isMethod(Method::NotContains)); + $this->assertTrue(Query::isMethod(Method::Search)); + $this->assertTrue(Query::isMethod(Method::NotSearch)); + $this->assertTrue(Query::isMethod(Method::StartsWith)); + $this->assertTrue(Query::isMethod(Method::NotStartsWith)); + $this->assertTrue(Query::isMethod(Method::EndsWith)); + $this->assertTrue(Query::isMethod(Method::NotEndsWith)); + $this->assertTrue(Query::isMethod(Method::OrderAsc)); + $this->assertTrue(Query::isMethod(Method::OrderDesc)); + $this->assertTrue(Query::isMethod(Method::Limit)); + $this->assertTrue(Query::isMethod(Method::Offset)); + $this->assertTrue(Query::isMethod(Method::CursorAfter)); + $this->assertTrue(Query::isMethod(Method::CursorBefore)); + $this->assertTrue(Query::isMethod(Method::OrderRandom)); + $this->assertTrue(Query::isMethod(Method::IsNull)); + $this->assertTrue(Query::isMethod(Method::IsNotNull)); + $this->assertTrue(Query::isMethod(Method::Between)); + $this->assertTrue(Query::isMethod(Method::NotBetween)); + $this->assertTrue(Query::isMethod(Method::Select)); + $this->assertTrue(Query::isMethod(Method::Or)); + $this->assertTrue(Query::isMethod(Method::And)); $this->assertFalse(Query::isMethod('invalid')); $this->assertFalse(Query::isMethod('lte ')); } - public function testNewQueryTypesInTypesArray(): void + public function test_new_query_types_in_types_array(): void { - $this->assertContains(Query::TYPE_NOT_CONTAINS, Query::TYPES); - $this->assertContains(Query::TYPE_NOT_SEARCH, Query::TYPES); - $this->assertContains(Query::TYPE_NOT_STARTS_WITH, Query::TYPES); - $this->assertContains(Query::TYPE_NOT_ENDS_WITH, Query::TYPES); - $this->assertContains(Query::TYPE_NOT_BETWEEN, Query::TYPES); - $this->assertContains(Query::TYPE_ORDER_RANDOM, Query::TYPES); + $allMethods = Method::cases(); + $this->assertContains(Method::NotContains, $allMethods); + $this->assertContains(Method::NotSearch, $allMethods); + $this->assertContains(Method::NotStartsWith, $allMethods); + $this->assertContains(Method::NotEndsWith, $allMethods); + $this->assertContains(Method::NotBetween, $allMethods); + $this->assertContains(Method::OrderRandom, $allMethods); } public function testFingerprint(): void @@ -564,12 +576,12 @@ public function testFingerprintNestedLogicalQueries(): void $this->assertNotSame(Query::fingerprint([$andOne]), Query::fingerprint([$andTwo])); // elemMatch attribute matters: same inner shape on different fields must NOT collide - $elemTags = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]); - $elemCategories = new Query(Query::TYPE_ELEM_MATCH, 'categories', [Query::equal('name', ['php'])]); + $elemTags = Query::elemMatch('tags', [Query::equal('name', ['php'])]); + $elemCategories = Query::elemMatch('categories', [Query::equal('name', ['php'])]); $this->assertNotSame(Query::fingerprint([$elemTags]), Query::fingerprint([$elemCategories])); // elemMatch values-only change (same field, same child shape) still collides — as expected - $elemTagsOther = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['js'])]); + $elemTagsOther = Query::elemMatch('tags', [Query::equal('name', ['js'])]); $this->assertSame(Query::fingerprint([$elemTags]), Query::fingerprint([$elemTagsOther])); } @@ -590,7 +602,7 @@ public function testShape(): void $this->assertSame('and:(equal:name|greaterThan:age)', $and->shape()); // elemMatch preserves the attribute (the field being matched) - $elem = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]); + $elem = Query::elemMatch('tags', [Query::equal('name', ['php'])]); $this->assertSame('elemMatch:tags(equal:name)', $elem->shape()); // Deeply nested — iterative traversal must match recursive result @@ -609,4 +621,58 @@ public function testShape(): void $deep->shape(), ); } + + public function testShapeKeepsASharedChildUnderEveryParent(): void + { + // The same Query object under two parents is a DAG, not a cycle, and it + // has to be shaped under both. This is why the walk cannot simply skip + // a node it has already seen: dropping the second occurrence would + // leave the reversed pass shaping a parent before its child. + $shared = Query::equal('a', ['x']); + $tree = Query::and([$shared, Query::or([$shared, Query::greaterThan('b', 1)])]); + + $this->assertSame('and:(equal:a|or:(equal:a|greaterThan:b))', $tree->shape()); + } + + public function testShapeRefusesATreeThatWouldExplodeTheWalk(): void + { + // Each level reuses the level below it twice, so the preorder walk sees + // 2^depth nodes from a structure built with 40 public calls. Before the + // cap this exhausted memory; a cycle behaves the same way, unbounded. + $node = Query::equal('a', ['x']); + for ($i = 0; $i < 40; $i++) { + $node = Query::and([$node, $node]); + } + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Query is too deeply nested to fingerprint'); + + $node->shape(); + } + public function testGroupByTypeKeepsTheOrdersInTheOrderGiven(): void + { + $cursor = new Document([Document::ID => 'first']); + $parsed = Query::groupByType([ + Query::orderDesc('views'), + Query::equal('title', ['first']), + Query::orderAsc('title'), + Query::limit(5), + Query::cursorAfter($cursor), + ]); + + $this->assertSame(['views', 'title'], $parsed->orderAttributes); + $this->assertSame([OrderDirection::Desc, OrderDirection::Asc], $parsed->orderTypes); + $this->assertCount(1, $parsed->filters); + $this->assertSame(5, $parsed->limit); + $this->assertSame($cursor, $parsed->cursor); + } + + public function testGroupByTypeRefusesACursorThatIsNotADocument(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Invalid cursor: a cursor must be a document, array given'); + + Query::groupByType([Query::cursorAfter([Document::ID => 'first'])]); + } + } diff --git a/tests/unit/RedisLenientReadTest.php b/tests/unit/RedisLenientReadTest.php new file mode 100644 index 0000000000..60b8778f49 --- /dev/null +++ b/tests/unit/RedisLenientReadTest.php @@ -0,0 +1,63 @@ +method('get')->willReturn(self::payload('note')); + + $document = self::adapter($client)->getDocument(new Document([Document::ID => self::COLLECTION]), 'note'); + + $this->assertSame('note', $document->getId()); + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + } + + public function testFindDropsAStoredNonStringPermission(): void + { + $client = self::createStub(Redis::class); + $client->method('exists')->willReturn(1); + $client->method('sMembers')->willReturn(['first', 'second']); + $client->method('mGet')->willReturn([self::payload('first'), self::payload('second')]); + + $authorization = new Authorization(); + $authorization->disable(); + + $documents = self::adapter($client, $authorization)->find(new Document([Document::ID => self::COLLECTION])); + + $this->assertSame(['first', 'second'], \array_map(fn (Document $document): string => $document->getId(), $documents)); + foreach ($documents as $document) { + $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); + } + } + + private static function payload(string $id): string + { + return \json_encode([ + Document::ID => $id, + Document::PERMISSIONS => [Permission::read(Role::any()), 42, null, Permission::read(Role::any())], + 'title' => 'stored', + ], JSON_THROW_ON_ERROR); + } + + private static function adapter(Redis $client, Authorization $authorization = new Authorization()): RedisAdapter + { + $adapter = new RedisAdapter($client); + $adapter->setAuthorization($authorization); + $adapter->setNamespace('lenient'); + + return $adapter; + } +} diff --git a/tests/unit/RedisSkipDuplicatesTest.php b/tests/unit/RedisSkipDuplicatesTest.php new file mode 100644 index 0000000000..16cbf83257 --- /dev/null +++ b/tests/unit/RedisSkipDuplicatesTest.php @@ -0,0 +1,53 @@ +method('exists')->willReturnCallback(static fn (mixed $key): int => \is_string($key) && \str_contains($key, self::STORED) ? 1 : 0); + $client->method('get')->willReturn(\json_encode([Document::ID => self::STORED, Document::SEQUENCE => '7'], JSON_THROW_ON_ERROR)); + $client->method('incr')->willReturn(8); + + $adapter = new RedisAdapter($client); + $adapter->setNamespace('skip_duplicates'); + + $created = $adapter->ignoreDuplicates(fn (): array => $adapter->createDocuments(new Document([Document::ID => 'notes']), [ + new Document([Document::ID => self::STORED]), + new Document([Document::ID => 'fresh']), + ])); + + $this->assertSame(['fresh'], \array_map(static fn (Document $document): string => $document->getId(), $created)); + $this->assertSame('8', $created[0]->getSequence()); + } + + public function testASingleSkippedDocumentStillCarriesTheStoredSequence(): void + { + $client = self::createStub(Redis::class); + $client->method('exists')->willReturn(1); + $client->method('get')->willReturn(\json_encode([Document::ID => self::STORED, Document::SEQUENCE => '7'], JSON_THROW_ON_ERROR)); + + $adapter = new RedisAdapter($client); + $adapter->setNamespace('skip_duplicates'); + + $document = $adapter->ignoreDuplicates(fn (): Document => $adapter->createDocument( + new Document([Document::ID => 'notes']), + new Document([Document::ID => self::STORED]), + )); + + $this->assertSame('7', $document->getSequence()); + } +} diff --git a/tests/unit/RefusedSchemaChangeTest.php b/tests/unit/RefusedSchemaChangeTest.php new file mode 100644 index 0000000000..a7a2d2377a --- /dev/null +++ b/tests/unit/RefusedSchemaChangeTest.php @@ -0,0 +1,268 @@ + + */ + public static function operations(): array + { + return [ + 'create' => [ + 'create', + static fn (Database $database): bool => $database->create('archive'), + 'Failed to create database', + ], + 'update' => [ + 'update', + static fn (Database $database): bool => $database->update('refused', 'renamed'), + "Failed to rename database 'refused' to 'renamed'", + ], + 'delete' => [ + 'delete', + static fn (Database $database): bool => $database->delete('refused'), + 'Failed to delete database', + ], + 'createCollection' => [ + 'createCollection', + static fn (Database $database): Collection => $database->createCollection(Collection::create(id: 'drafts')), + 'Failed to create collection', + ], + 'deleteCollection' => [ + 'deleteCollection', + static fn (Database $database) => $database->deleteCollection('notes'), + 'Failed to delete collection', + ], + 'createAttribute' => [ + 'createAttribute', + static fn (Database $database): Attribute => $database->createAttribute('books', Attribute::string(key: 'summary', size: 64)), + 'Failed to create attribute', + ], + 'createAttributes' => [ + 'createAttributes', + static fn (Database $database): array => $database->createAttributes('books', [Attribute::string(key: 'summary', size: 64), Attribute::integer(key: 'pages')]), + 'Failed to create attributes', + ], + 'updateAttribute' => [ + 'updateAttribute', + static fn (Database $database): Attribute => $database->updateAttribute('books', 'label', new AttributeUpdate(size: 128)), + 'Failed to update attribute', + ], + 'updateAttribute relaxing required' => [ + 'relaxAttributeRequired', + static fn (Database $database): Attribute => $database->updateAttribute('books', 'title', new AttributeUpdate(required: false)), + 'Failed to update attribute', + ], + 'deleteAttribute' => [ + 'deleteAttribute', + static fn (Database $database) => $database->deleteAttribute('books', 'label'), + 'Failed to delete attribute', + ], + 'renameAttribute' => [ + 'renameAttribute', + static fn (Database $database) => $database->renameAttribute('books', 'label', 'caption'), + "Failed to rename attribute 'label' to 'caption'", + ], + 'createIndex' => [ + 'createIndex', + static fn (Database $database): Index => $database->createIndex('books', Index::key(key: 'byLabel', attributes: ['label'])), + 'Failed to create index', + ], + 'deleteIndex' => [ + 'deleteIndex', + static fn (Database $database) => $database->deleteIndex('books', 'byTitle'), + 'Failed to delete index', + ], + 'renameIndex' => [ + 'renameIndex', + static fn (Database $database) => $database->renameIndex('books', 'byTitle', 'byHeading'), + "Failed to rename index 'byTitle' to 'byHeading'", + ], + 'createRelationship' => [ + 'createRelationship', + static fn (Database $database): Relationship => $database->createRelationship('books', Relationship::oneToMany(relatedCollection: 'authors', twoWay: true, key: 'editors', twoWayKey: 'edited')), + 'Failed to create relationship', + ], + 'updateRelationship' => [ + 'updateRelationship', + static fn (Database $database): Relationship => $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')), + "Failed to update relationship 'author'", + ], + 'deleteRelationship' => [ + 'deleteRelationship', + static fn (Database $database) => $database->deleteRelationship('books', 'author'), + 'Failed to delete relationship', + ], + ]; + } + + /** + * @param Closure(Database): mixed $operation + */ + #[DataProvider('operations')] + public function testAFalseReturnIsRefusedWithASingleMessage(string $method, Closure $operation, string $message): void + { + [$database, $adapter] = $this->database(); + $adapter->verdicts[$method] = static fn (): bool => false; + + $error = $this->attempt($database, $operation); + + $this->assertInstanceOf(RefusedException::class, $error); + $this->assertSame($message, $error->getMessage()); + $this->assertNull($error->getPrevious(), 'A refusal has no cause to wrap'); + } + + /** + * @param Closure(Database): mixed $operation + */ + #[DataProvider('operations')] + public function testAnAdapterErrorIsNotARefusal(string $method, Closure $operation, string $message): void + { + [$database, $adapter] = $this->database(); + $cause = new RuntimeException('the engine failed'); + $adapter->verdicts[$method] = static fn (): never => throw $cause; + + $error = $this->attempt($database, $operation); + + $this->assertNotInstanceOf(RefusedException::class, $error); + $this->assertTrue( + $error === $cause || $error->getPrevious() === $cause, + 'The adapter error must reach the caller, as itself or as the cause: '.$error::class.': '.$error->getMessage(), + ); + } + + public function testARefusedCollectionStoresNoDefinition(): void + { + [$database, $adapter] = $this->database(); + $adapter->verdicts['createCollection'] = static fn (): bool => false; + + $this->assertInstanceOf(RefusedException::class, $this->attempt($database, static fn (Database $database): Collection => $database->createCollection(Collection::create(id: 'drafts')))); + $this->assertNull($database->findCollection('drafts')); + } + + public function testARefusedCollectionDropKeepsItsDefinition(): void + { + [$database, $adapter] = $this->database(); + $adapter->verdicts['deleteCollection'] = static fn (): bool => false; + + $this->assertInstanceOf(RefusedException::class, $this->attempt($database, static fn (Database $database) => $database->deleteCollection('notes'))); + $this->assertNotNull($database->findCollection('notes')); + } + + public function testColumnsCreatedBeforeARefusalInTheOneAtATimeFallbackAreDropped(): void + { + [$database, $adapter] = $this->database(); + $adapter->verdicts['createAttributes'] = static fn (): never => throw new DuplicateException('Attribute already exists'); + $calls = 0; + $adapter->verdicts['createAttribute'] = static function () use (&$calls): ?bool { + return ++$calls === 1 ? null : false; + }; + $dropped = []; + $adapter->verdicts['deleteAttribute'] = static function () use (&$dropped): ?bool { + $dropped[] = true; + + return null; + }; + + $error = $this->attempt($database, static fn (Database $database): array => $database->createAttributes('books', [Attribute::string(key: 'summary', size: 64), Attribute::integer(key: 'pages')])); + + $this->assertInstanceOf(RefusedException::class, $error); + $this->assertCount(1, $dropped, 'The column created before the refusal must be dropped'); + $this->assertNotContains('summary', \array_map(static fn (Attribute $attribute): string => $attribute->key, $database->getCollection('books')->attributes())); + } + + public function testAColumnTheFallbackCannotDropIsLogged(): void + { + [$database, $adapter] = $this->database(); + $adapter->verdicts['createAttributes'] = static fn (): never => throw new DuplicateException('Attribute already exists'); + $calls = 0; + $adapter->verdicts['createAttribute'] = static function () use (&$calls): ?bool { + return ++$calls === 1 ? null : false; + }; + $adapter->verdicts['deleteAttribute'] = static fn (): never => throw new DatabaseException('the engine is read-only'); + + $log = StderrCapture::during(function () use ($database): void { + $this->assertInstanceOf(RefusedException::class, $this->attempt($database, static fn (Database $database): array => $database->createAttributes('books', [Attribute::string(key: 'summary', size: 64), Attribute::integer(key: 'pages')]))); + }); + + $this->assertStringContainsString('the engine is read-only', $log); + } + + /** + * @param Closure(Database): mixed $operation + */ + private function attempt(Database $database, Closure $operation): Throwable + { + try { + $operation($database); + } catch (Throwable $error) { + return $error; + } + + $this->fail('The schema call must fail'); + } + + /** + * @return array{Database, VerdictMemory} + */ + private function database(): array + { + $adapter = new VerdictMemory(); + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('refused') + ->setNamespace('refused_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create( + id: 'books', + attributes: [ + Attribute::string(key: 'title', size: 64, required: true), + Attribute::string(key: 'label', size: 64), + ], + indexes: [Index::key(key: 'byTitle', attributes: ['title'])], + permissions: $permissions, + )); + $database->createCollection(Collection::create(id: 'authors', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions)); + $database->createCollection(Collection::create(id: 'notes', permissions: $permissions)); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + return [$database, $adapter]; + } +} diff --git a/tests/unit/Relationships/CreateReturnsStoredTest.php b/tests/unit/Relationships/CreateReturnsStoredTest.php new file mode 100644 index 0000000000..2dc18537ec --- /dev/null +++ b/tests/unit/Relationships/CreateReturnsStoredTest.php @@ -0,0 +1,197 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testNullKeysAreDerivedFromTheCollectionIds(Closure $adapter): void + { + $database = $this->database($adapter()); + + $created = $database->createRelationship('artists', Relationship::oneToMany('albums', twoWay: true)); + + $this->assertSame('albums', $created->relatedCollection); + $this->assertSame(RelationshipType::OneToMany, $created->type); + $this->assertTrue($created->twoWay); + $this->assertSame('albums', $created->key); + $this->assertSame('artists', $created->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Restrict, $created->onDelete); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testGivenKeysAreKept(Closure $adapter): void + { + $database = $this->database($adapter()); + + $created = $database->createRelationship('artists', Relationship::manyToMany('albums', key: 'records', twoWay: true, twoWayKey: 'performers', onDelete: RelationshipDeleteAction::Cascade)); + + $this->assertSame('records', $created->key); + $this->assertSame('performers', $created->twoWayKey); + $this->assertSame(RelationshipDeleteAction::Cascade, $created->onDelete); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testTheReturnedRelationshipIsTheStoredParentSide(Closure $adapter): void + { + $database = $this->database($adapter()); + + $created = $database->createRelationship('artists', Relationship::manyToOne('albums', twoWay: true, onDelete: RelationshipDeleteAction::SetNull)); + + $parent = $this->attribute($database, 'artists', 'albums'); + $this->assertSame(RelationshipSide::Parent, $parent->side); + $this->assertSame($created->toDocument()->getArrayCopy(), $parent->relationship?->toDocument()->getArrayCopy()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testTheChildSideIsStoredAsTheInverse(Closure $adapter): void + { + $database = $this->database($adapter()); + + $created = $database->createRelationship('artists', Relationship::oneToOne('albums', key: 'debut', twoWay: true, twoWayKey: 'artist')); + + $child = $this->attribute($database, 'albums', 'artist'); + $relationship = $child->relationship; + $this->assertNotNull($relationship); + $this->assertSame(RelationshipSide::Child, $child->side); + $this->assertSame($created->inverse('artists')->toDocument()->getArrayCopy(), $relationship->toDocument()->getArrayCopy()); + $this->assertSame('artists', $relationship->relatedCollection); + $this->assertSame('artist', $relationship->key); + $this->assertSame('debut', $relationship->twoWayKey); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAOneWayRelationshipStillStoresItsChildSide(Closure $adapter): void + { + $database = $this->database($adapter()); + + $created = $database->createRelationship('artists', Relationship::oneToMany('albums')); + + $this->assertFalse($created->twoWay); + $child = $this->attribute($database, 'albums', 'artists'); + $this->assertSame(RelationshipSide::Child, $child->side); + $this->assertFalse($child->relationship?->twoWay); + } + + public function testAMissingCollectionIsNotFound(): void + { + $database = $this->database(new Memory()); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $database->createRelationship('missing', Relationship::oneToMany('albums')); + } + + public function testAMissingRelatedCollectionIsNotFound(): void + { + $database = $this->database(new Memory()); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Related collection not found'); + + $database->createRelationship('artists', Relationship::oneToMany('missing')); + } + + public function testADerivedKeyThatIsTakenIsADuplicate(): void + { + $database = $this->database(new Memory()); + $database->createAttribute('artists', Attribute::string('albums')); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Attribute already exists'); + + $database->createRelationship('artists', Relationship::oneToMany('albums')); + } + + public function testASecondRelationshipToTheSameTwoWayKeyIsADuplicate(): void + { + $database = $this->database(new Memory()); + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWayKey: 'artist')); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Related attribute already exists'); + + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'records', twoWayKey: 'artist')); + } + + private function attribute(Database $database, string $collection, string $key): Attribute + { + foreach ($database->getCollection($collection)->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + $this->fail('Collection "'.$collection.'" stores no attribute "'.$key.'"'); + } + + private function database(Adapter $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('create_returns_stored') + ->setNamespace('create_returns_stored_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $database->createCollection(Collection::create('artists', attributes: [Attribute::string('name', 64)], permissions: $permissions)); + $database->createCollection(Collection::create('albums', attributes: [Attribute::string('title', 64)], permissions: $permissions)); + + return $database; + } +} diff --git a/tests/unit/Relationships/DeleteActionTest.php b/tests/unit/Relationships/DeleteActionTest.php new file mode 100644 index 0000000000..edb0113531 --- /dev/null +++ b/tests/unit/Relationships/DeleteActionTest.php @@ -0,0 +1,229 @@ + + */ + public static function unsupportedActions(): array + { + return [ + 'setDefault' => [ForeignKeyAction::SetDefault], + 'noAction' => [ForeignKeyAction::NoAction], + ]; + } + + #[DataProvider('unsupportedActions')] + public function testDeleteActionCannotExpressUnsupportedAction(ForeignKeyAction $action): void + { + $this->assertNull(RelationshipDeleteAction::tryFrom($action->value)); + } + + #[DataProvider('unsupportedActions')] + public function testStoredUnsupportedActionIsRejectedFromArray(ForeignKeyAction $action): void + { + $this->expectException(RelationshipException::class); + $this->expectExceptionMessage('"'.$action->value.'"'); + + Relationship::fromArray(['relatedCollection' => 'albums', 'relationType' => 'oneToMany', 'twoWay' => true, 'twoWayKey' => 'artist', 'onDelete' => $action->value]); + } + + #[DataProvider('unsupportedActions')] + public function testStoredUnsupportedActionIsRejectedFromDocument(ForeignKeyAction $action): void + { + $this->expectException(RelationshipException::class); + $this->expectExceptionMessage('"'.$action->value.'"'); + + Relationship::fromDocument(new Document(['relatedCollection' => 'albums', 'relationType' => 'oneToMany', 'twoWay' => true, 'twoWayKey' => 'artist', 'onDelete' => $action->value])); + } + + #[DataProvider('unsupportedActions')] + public function testUnsupportedForeignKeyActionIsRejected(ForeignKeyAction $action): void + { + $this->expectException(RelationshipException::class); + + Relationship::fromArray(['relatedCollection' => 'albums', 'relationType' => 'oneToMany', 'onDelete' => $action]); + } + + /** + * @return array + */ + public static function supportedActions(): array + { + return [ + 'cascade' => [RelationshipDeleteAction::Cascade, ForeignKeyAction::Cascade], + 'restrict' => [RelationshipDeleteAction::Restrict, ForeignKeyAction::Restrict], + 'setNull' => [RelationshipDeleteAction::SetNull, ForeignKeyAction::SetNull], + ]; + } + + #[DataProvider('supportedActions')] + public function testSupportedActionMapsToItsForeignKeyAction(RelationshipDeleteAction $action, ForeignKeyAction $foreignKeyAction): void + { + $this->assertSame($foreignKeyAction, $action->toForeignKeyAction()); + $this->assertSame($action->value, $foreignKeyAction->value); + } + + #[DataProvider('supportedActions')] + public function testStoredSupportedActionHydrates(RelationshipDeleteAction $action, ForeignKeyAction $foreignKeyAction): void + { + $fromValue = Relationship::fromArray(['relatedCollection' => 'albums', 'relationType' => 'oneToMany', 'onDelete' => $action->value]); + $fromForeignKeyAction = Relationship::fromArray(['relatedCollection' => 'albums', 'relationType' => 'oneToMany', 'onDelete' => $foreignKeyAction]); + + $this->assertSame($action, $fromValue->onDelete); + $this->assertSame($action, $fromForeignKeyAction->onDelete); + } + + #[DataProvider('unsupportedActions')] + public function testDeletingParentWithStoredUnsupportedActionThrowsAndLeavesChildrenLinked(ForeignKeyAction $action): void + { + $database = $this->database(); + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWay: true, twoWayKey: 'artist', onDelete: RelationshipDeleteAction::Cascade)); + $database->createDocument('albums', new Document(['$id' => 'b1'])); + $database->createDocument('artists', new Document(['$id' => 'a1', 'albums' => ['b1']])); + + $this->storeDeleteAction($database, 'artists', 'albums', $action->value); + + $thrown = null; + try { + $database->deleteDocument('artists', 'a1'); + } catch (RelationshipException $exception) { + $thrown = $exception; + } + + $this->assertInstanceOf(RelationshipException::class, $thrown, 'Deleting a parent whose relationship stores onDelete "'.$action->value.'" must throw'); + + $this->storeDeleteAction($database, 'artists', 'albums', RelationshipDeleteAction::Cascade->value); + + $artist = $database->skipRelationships(fn (): Document => $database->getDocument('artists', 'a1')); + $album = $database->skipRelationships(fn (): Document => $database->getDocument('albums', 'b1')); + + $this->assertFalse($artist->isEmpty(), 'The parent survives the refused delete'); + $this->assertFalse($album->isEmpty(), 'The child survives the refused delete'); + $this->assertSame('a1', $album->getAttribute('artist'), 'The child stays linked to its parent'); + } + + #[DataProvider('unsupportedActions')] + public function testCreatingACollectionWithAnUnsupportedActionIsRefused(ForeignKeyAction $action): void + { + $database = $this->database(); + $relationship = Relationship::oneToMany('albums', key: 'releases', twoWay: true, twoWayKey: 'artist')->toDocument()->getArrayCopy(); + $relationship['onDelete'] = $action->value; + + try { + $database->createCollection(Collection::fromArray([ + '$id' => 'labels', + 'attributes' => [[ + 'key' => 'releases', + 'type' => 'relationship', + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + 'options' => [...$relationship, 'side' => 'parent'], + ]], + ])); + $this->fail('Creating a collection whose relationship stores onDelete "'.$action->value.'" must throw'); + } catch (RelationshipException $exception) { + $this->assertStringContainsString('"'.$action->value.'"', $exception->getMessage()); + } + + $this->assertNull($database->findCollection('labels')); + } + + #[DataProvider('unsupportedActions')] + public function testACollectionStoringAnUnsupportedActionCannotBeRead(ForeignKeyAction $action): void + { + $database = $this->database(); + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWay: true, twoWayKey: 'artist', onDelete: RelationshipDeleteAction::Cascade)); + $database->createDocument('albums', new Document(['$id' => 'b1'])); + $database->createDocument('artists', new Document(['$id' => 'a1', 'albums' => ['b1']])); + + $this->storeDeleteAction($database, 'artists', 'albums', $action->value); + + foreach ([ + 'getCollection' => fn (): mixed => $database->getCollection('artists')->attributes(), + 'getDocument' => fn (): mixed => $database->getDocument('artists', 'a1'), + 'find' => fn (): mixed => $database->find('artists'), + ] as $read => $callback) { + try { + $callback(); + $this->fail($read.' of a collection storing onDelete "'.$action->value.'" must throw'); + } catch (RelationshipException $exception) { + $this->assertStringContainsString('"'.$action->value.'"', $exception->getMessage(), $read); + } + } + } + + private function storeDeleteAction(Database $database, string $collection, string $key, string $action): void + { + $authorization = $database->getAuthorization(); + $metadata = $authorization->skip(fn (): Document => $database->getDocument(Database::METADATA, $collection)); + + $attributes = []; + /** @var array> $stored */ + $stored = $metadata->getAttribute('attributes', []); + foreach ($stored as $attribute) { + $attribute = $attribute instanceof Document ? $attribute : new Document($attribute); + if ($attribute->getId() === $key) { + $options = $attribute->getAttribute('options', []); + /** @var array $options */ + $options = $options instanceof Document ? $options->getArrayCopy() : $options; + $options['onDelete'] = $action; + $attribute->setAttribute('options', $options); + } + $attributes[] = $attribute; + } + $metadata->setAttribute('attributes', $attributes); + + $authorization->skip(fn (): Document => $database->silent(fn (): Document => $database->updateDocument(Database::METADATA, $collection, $metadata))); + $database->purgeCachedCollection($collection); + } + + private function database(): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('delete_action') + ->setNamespace('delete_action_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + $database->addHook(new Permissions()); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create('artists', permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create('albums', permissions: $permissions, documentSecurity: false)); + + return $database; + } +} diff --git a/tests/unit/Relationships/DeleteRelatedUpdateRetention.php b/tests/unit/Relationships/DeleteRelatedUpdateRetention.php new file mode 100644 index 0000000000..ad4a2a4a0d --- /dev/null +++ b/tests/unit/Relationships/DeleteRelatedUpdateRetention.php @@ -0,0 +1,61 @@ +> */ + private array $earlier = []; + + public function __construct(private readonly string $collection) + { + } + + public function reset(): void + { + $this->written = 0; + $this->mostAlive = 0; + $this->earlier = []; + } + + /** + * @param array $documents + * @param Closure(): int $write + */ + public function watch(Document $collection, array $documents, Closure $write): int + { + if ($collection->getId() !== $this->collection) { + return $write(); + } + + \gc_collect_cycles(); + $alive = 0; + foreach ($this->earlier as $reference) { + if ($reference->get() !== null) { + $alive++; + } + } + $this->mostAlive = \max($this->mostAlive, $alive); + + $modified = $write(); + + foreach ($documents as $document) { + $this->earlier[] = WeakReference::create($document); + $this->written++; + } + + return $modified; + } +} diff --git a/tests/unit/Relationships/DeleteRelatedUpdateTest.php b/tests/unit/Relationships/DeleteRelatedUpdateTest.php new file mode 100644 index 0000000000..06a547ac10 --- /dev/null +++ b/tests/unit/Relationships/DeleteRelatedUpdateTest.php @@ -0,0 +1,645 @@ + + */ + public static function adapters(): iterable + { + yield 'memory' => [static fn (): Adapter => new Memory()]; + yield 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))]; + } + + /** + * Adapters that hand every bulk update to $watch, along with the write itself. + * + * @return iterable, Closure(): int): int): Adapter}> + */ + public static function watchedAdapters(): iterable + { + yield 'memory' => [static fn (Closure $watch): Adapter => new class ($watch) extends Memory { + /** + * @param Closure(Document, array, Closure(): int): int $watch + */ + public function __construct(private readonly Closure $watch) + { + parent::__construct(); + } + + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int + { + return ($this->watch)($collection, $documents, fn (): int => parent::updateDocuments($collection, $updates, $documents, $skipPermissions)); + } + }]; + yield 'sqlite' => [static fn (Closure $watch): Adapter => new class (new PDO('sqlite::memory:'), $watch) extends SQLite { + /** + * @param Closure(Document, array, Closure(): int): int $watch + */ + public function __construct(PDO $pdo, private readonly Closure $watch) + { + parent::__construct($pdo); + } + + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): int + { + return ($this->watch)($collection, $documents, fn (): int => parent::updateDocuments($collection, $updates, $documents, $skipPermissions)); + } + }]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeletingAParentReportsEachChildAsTheSetNullWroteIt(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2']); + + $recorder = $this->record($database); + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + + $this->assertSame(['child1', 'child2'], $this->reported($recorder)); + foreach ($this->updated($recorder) as $related) { + $this->assertSame('child', $related->getCollection()); + $this->assertTrue(\array_key_exists('parent', $related->getArrayCopy())); + $this->assertNull($related->getAttribute('parent')); + $this->assertSame($database->getDocument('child', $related->getId())->getUpdatedAt(), $related->getUpdatedAt()); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeletingAChildReportsTheParentItNeverWrote(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2']); + + $recorder = $this->record($database); + $database->deleteDocument('child', 'child1'); + + $this->assertSame(['parent1'], $this->reported($recorder)); + $related = $this->updated($recorder)[0]; + $this->assertSame('parent', $related->getCollection()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testACascadeReportsOnlyThePeersItDidNotRemove(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::Cascade); + $database->createCollection(Collection::create(id: 'owner', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship('parent', Relationship::manyToOne(relatedCollection: 'owner', twoWay: true, key: 'owner', twoWayKey: 'owned', onDelete: RelationshipDeleteAction::SetNull)); + $database->createDocument('owner', new Document(['$id' => 'owner1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('child', new Document(['$id' => 'child1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => $this->documentPermissions(), 'children' => ['child1'], 'owner' => 'owner1'])); + + $recorder = $this->record($database); + $database->deleteDocument('parent', 'parent1'); + + $this->assertSame(['owner1'], $this->reported($recorder)); + $this->assertTrue($database->getDocument('child', 'child1')->isEmpty()); + } + + public function testACascadeReadsNoPeersWhenNoHookListensForRelatedUpdates(): void + { + $reads = []; + foreach (['no lifecycle hook' => null, 'a hook for deletes only' => DocumentDeleted::class, 'a hook for updates' => DocumentUpdated::class] as $case => $listened) { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $this->relateParentToChildren($database, RelationshipDeleteAction::Cascade); + $database->createCollection(Collection::create(id: 'owner', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship('parent', Relationship::manyToOne(relatedCollection: 'owner', twoWay: true, key: 'owner', twoWayKey: 'owned', onDelete: RelationshipDeleteAction::SetNull)); + $database->createDocument('owner', new Document(['$id' => 'owner1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('child', new Document(['$id' => 'child1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => $this->documentPermissions(), 'children' => ['child1'], 'owner' => 'owner1'])); + + $heard = []; + if ($listened !== null) { + $dispatcher = new DispatcherHook(); + $dispatcher->on($listened, function (DocumentDeleted|DocumentUpdated $event) use (&$heard): void { + $heard[] = $event->document->getId(); + }); + $database->addHook($dispatcher); + } + + $database->setProfiling(true); + $this->assertTrue($database->deleteDocument('parent', 'parent1'), $case); + $reads[$case] = \count(\array_filter( + $database->getProfiler()?->getLogs() ?? [], + static fn (Log $log): bool => \str_starts_with(\ltrim($log->query), 'SELECT'), + )); + + $this->assertSame(match ($listened) { + DocumentDeleted::class => ['parent1'], + DocumentUpdated::class => ['owner1'], + default => [], + }, $heard, $case); + } + + $this->assertSame($reads['no lifecycle hook'], $reads['a hook for deletes only'], 'A hook that does not listen for related updates must not cost the delete the reads that find them'); + $this->assertGreaterThan($reads['no lifecycle hook'], $reads['a hook for updates'], 'A hook that listens for them still gets the peers the cascade left'); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeletingAChildUnderRestrictReportsItsParent(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::Restrict); + $this->createFamily($database, 'parent1', ['child1']); + + $recorder = $this->record($database); + $database->deleteDocument('child', 'child1'); + + $this->assertSame(['parent1'], $this->reported($recorder)); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeletingAManyToManySideReportsThePeers(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->createCollections($database, 'parent', 'child'); + $database->createRelationship('parent', Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)); + $this->createFamily($database, 'parent1', ['child1', 'child2']); + + $recorder = $this->record($database); + $database->deleteDocument('parent', 'parent1'); + + $this->assertSame(['child1', 'child2'], $this->reported($recorder)); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeletingAOneToOneSideReportsItsPartner(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->createCollections($database, 'parent', 'child'); + $database->createRelationship('parent', Relationship::oneToOne(relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'partnerOf', onDelete: RelationshipDeleteAction::SetNull)); + $database->createDocument('child', new Document(['$id' => 'child1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => $this->documentPermissions(), 'partner' => 'child1'])); + + $recorder = $this->record($database); + $database->deleteDocument('parent', 'parent1'); + + $this->assertSame(['child1'], $this->reported($recorder)); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testOneWayPeersAreNotReportedWhileTwoWayPeersAre(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->createCollections($database, 'parent', 'stray', 'child'); + $database->createRelationship('parent', Relationship::oneToMany(relatedCollection: 'stray', key: 'strays', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('parent', Relationship::manyToOne(relatedCollection: 'stray', key: 'stray', twoWayKey: 'strayOf', onDelete: RelationshipDeleteAction::SetNull)); + $database->createDocument('stray', new Document(['$id' => 'stray1', '$permissions' => $this->documentPermissions()])); + $database->createRelationship('parent', Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + $database->createDocument('child', new Document(['$id' => 'child1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => $this->documentPermissions(), 'strays' => ['stray1'], 'stray' => 'stray1', 'children' => ['child1']])); + + $recorder = $this->record($database); + $database->deleteDocument('parent', 'parent1'); + + $this->assertSame(['child1'], $this->reported($recorder)); + $this->assertFalse($database->getDocument('stray', 'stray1')->isEmpty()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAPeerCascadedAwayByAnotherRelationshipIsNotReportedWhileASurvivorIs(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $database->createCollection(Collection::create(id: 'pair', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship('parent', Relationship::manyToOne(relatedCollection: 'pair', twoWay: true, key: 'owner', twoWayKey: 'owned', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('parent', Relationship::oneToOne(relatedCollection: 'pair', twoWay: true, key: 'buddy', twoWayKey: 'buddyOf', onDelete: RelationshipDeleteAction::Cascade)); + $database->createDocument('pair', new Document(['$id' => 'pair1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('child', new Document(['$id' => 'child1', '$permissions' => $this->documentPermissions()])); + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => $this->documentPermissions(), 'owner' => 'pair1', 'buddy' => 'pair1', 'children' => ['child1']])); + + $recorder = $this->record($database); + $database->deleteDocument('parent', 'parent1'); + + $this->assertSame(['child1'], $this->reported($recorder)); + $this->assertTrue($database->getDocument('pair', 'pair1')->isEmpty()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAPeerRemovedDownACascadeChainIsNotReportedWhileItsSiblingIs(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $database->createCollection(Collection::create(id: 'pair', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship('parent', Relationship::oneToOne(relatedCollection: 'pair', twoWay: true, key: 'buddy', twoWayKey: 'buddyOf', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship('pair', Relationship::oneToOne(relatedCollection: 'child', twoWay: true, key: 'tail', twoWayKey: 'tailOf', onDelete: RelationshipDeleteAction::Cascade)); + foreach (['child1', 'child2'] as $childId) { + $database->createDocument('child', new Document(['$id' => $childId, '$permissions' => $this->documentPermissions()])); + } + $database->createDocument('pair', new Document(['$id' => 'pair1', '$permissions' => $this->documentPermissions(), 'tail' => 'child1'])); + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => $this->documentPermissions(), 'children' => ['child1', 'child2'], 'buddy' => 'pair1'])); + + $recorder = $this->record($database); + $database->deleteDocument('parent', 'parent1'); + + $this->assertSame(['child2'], $this->reported($recorder)); + $this->assertTrue($database->getDocument('child', 'child1')->isEmpty()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRelatedUpdatesFireAfterTheDelete(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1']); + + $recorder = $this->record($database); + $database->deleteDocument('parent', 'parent1'); + + $this->assertSame([Event::DocumentPurge, Event::DocumentDelete, Event::DocumentUpdate], $recorder->getEvents()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testASilentDeleteReportsNothingWhileAHeardOneDoes(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1']); + $this->createFamily($database, 'parent2', ['child2']); + + $recorder = $this->record($database); + $database->silent(fn (): bool => $database->deleteDocument('parent', 'parent1')); + + $this->assertSame([], $recorder->getEvents()); + $this->assertNull($database->getDocument('child', 'child1')->getAttribute('parent')); + + $database->deleteDocument('parent', 'parent2'); + + $this->assertSame(['child2'], $this->reported($recorder)); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testABulkDeleteReportsNoRelatedUpdatesWhileASingleDeleteDoes(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1']); + $this->createFamily($database, 'parent2', ['child2']); + + $recorder = $this->record($database); + $this->assertSame(1, $database->deleteDocuments('parent', [Query::equal(Document::ID, ['parent1'])])); + + $this->assertSame([], $this->reported($recorder)); + + $database->deleteDocument('parent', 'parent2'); + + $this->assertSame(['child2'], $this->reported($recorder)); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testOneFailingReportDoesNotCostTheOthersTheirs(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2']); + + $recorder = $this->record($database); + $failure = new RuntimeException('related update hook failed'); + $database->addHook(new FailingLifecycle(Event::DocumentUpdate, $failure)); + + try { + $database->deleteDocument('parent', 'parent1'); + $this->fail('The failing hook must reach the caller'); + } catch (RuntimeException $caught) { + $this->assertSame($failure, $caught); + } + + $this->assertSame(['child1', 'child2'], $this->reported($recorder)); + $this->assertTrue($database->getDocument('parent', 'parent1')->isEmpty()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAFailingDeleteHookDoesNotCostTheRelatedUpdates(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1']); + + $failure = new RuntimeException('delete hook failed'); + $database->addHook(new FailingLifecycle(Event::DocumentDelete, $failure)); + $recorder = $this->record($database); + + try { + $database->deleteDocument('parent', 'parent1'); + $this->fail('The failing hook must reach the caller'); + } catch (RuntimeException $caught) { + $this->assertSame($failure, $caught); + } + + $this->assertSame(['child1'], $this->reported($recorder)); + } + + /** + * @param Closure(Closure(Document, array, Closure(): int): int): Adapter $adapter + */ + #[DataProvider('watchedAdapters')] + public function testASetNullDeleteNobodyHearsKeepsNoEarlierChunkOfPeers(Closure $adapter): void + { + $peers = new DeleteRelatedUpdateRetention('child'); + $database = $this->database($adapter($peers->watch(...))); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2', 'child3']); + $dispatcher = new DispatcherHook(); + $deleted = []; + $dispatcher->on(DocumentDeleted::class, static function (DocumentDeleted $event) use (&$deleted): void { + $deleted[] = $event->document->getId(); + }); + $database->addHook($dispatcher); + $database->setMaxQueryValues(1); + $this->assertSame(['child1', 'child2', 'child3'], $this->childIds($database, 'parent1')); + + $peers->reset(); + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + + $this->assertSame(3, $peers->written, 'Every child must be cleared'); + $this->assertSame(0, $peers->mostAlive, 'A delete nobody hears must let each chunk of cleared children go before writing the next'); + $this->assertSame(['parent1'], $deleted); + foreach (['child1', 'child2', 'child3'] as $childId) { + $this->assertNull($database->getDocument('child', $childId)->getAttribute('parent'), $childId); + } + } + + /** + * @param Closure(Closure(Document, array, Closure(): int): int): Adapter $adapter + */ + #[DataProvider('watchedAdapters')] + public function testASetNullBulkDeleteKeepsNoEarlierChunkOfPeers(Closure $adapter): void + { + $peers = new DeleteRelatedUpdateRetention('child'); + $database = $this->database($adapter($peers->watch(...))); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2', 'child3']); + $this->record($database); + $database->setMaxQueryValues(1); + + $peers->reset(); + $this->assertSame(1, $database->deleteDocuments('parent', [Query::equal(Document::ID, ['parent1'])])); + + $this->assertSame(3, $peers->written, 'Every child must be cleared'); + $this->assertSame(0, $peers->mostAlive, 'A bulk delete reports no related updates, so it must let each chunk of cleared children go'); + foreach (['child1', 'child2', 'child3'] as $childId) { + $this->assertNull($database->getDocument('child', $childId)->getAttribute('parent'), $childId); + } + } + + /** + * @param Closure(Closure(Document, array, Closure(): int): int): Adapter $adapter + */ + #[DataProvider('watchedAdapters')] + public function testASetNullDeleteSomeoneHearsReportsEveryChunksPeers(Closure $adapter): void + { + $peers = new DeleteRelatedUpdateRetention('child'); + $database = $this->database($adapter($peers->watch(...))); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2', 'child3']); + $recorder = $this->record($database); + $database->setMaxQueryValues(1); + + $peers->reset(); + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + + $this->assertSame(3, $peers->written, 'Every child must be cleared'); + $this->assertSame(['child1', 'child2', 'child3'], $this->reported($recorder)); + foreach ($this->updated($recorder) as $related) { + $this->assertNull($related->getAttribute('parent')); + } + } + + public function testACommitThatFailsAndRetriesReportsEachPeerOnce(): void + { + $adapter = new class () extends Memory { + public bool $failNextCommit = false; + + #[\Override] + public function commitTransaction(): bool + { + if ($this->failNextCommit && $this->inTransaction === 1) { + $this->failNextCommit = false; + + throw new TransactionException('Failed to commit transaction: commit failed'); + } + + return parent::commitTransaction(); + } + }; + + $database = $this->database($adapter); + $this->relateParentToChildren($database, RelationshipDeleteAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2']); + + $recorder = $this->record($database); + $adapter->failNextCommit = true; + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + + $this->assertFalse($adapter->failNextCommit); + $this->assertSame(['child1', 'child2'], $this->reported($recorder)); + } + + private function database(Adapter $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('related_update') + ->setNamespace('related_update_'.\uniqid()); + + $database->create(); + $database->addHook(new Relationships()); + $database->addHook(new Permissions()); + + return $database; + } + + private function relateParentToChildren(Database $database, RelationshipDeleteAction $onDelete): void + { + $this->createCollections($database, 'parent', 'child'); + $database->createRelationship('parent', Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: $onDelete)); + } + + private function createCollections(Database $database, string ...$ids): void + { + foreach ($ids as $id) { + $database->createCollection(Collection::create(id: $id, permissions: $this->collectionPermissions(), documentSecurity: true)); + } + } + + /** + * @param list $children + */ + private function createFamily(Database $database, string $parent, array $children): void + { + foreach ($children as $child) { + $database->createDocument('child', new Document(['$id' => $child, '$permissions' => $this->documentPermissions()])); + } + + $database->createDocument('parent', new Document(['$id' => $parent, '$permissions' => $this->documentPermissions(), 'children' => $children])); + } + + /** + * @return list + */ + private function childIds(Database $database, string $parent): array + { + $children = $database->getDocument('parent', $parent)->getAttribute('children', []); + $this->assertIsArray($children); + + $ids = []; + foreach ($children as $child) { + $this->assertInstanceOf(Document::class, $child); + $ids[] = $child->getId(); + } + \sort($ids); + + return $ids; + } + + private function record(Database $database): RecordingLifecycle + { + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + return $recorder; + } + + /** + * @return list + */ + private function reported(RecordingLifecycle $recorder): array + { + $ids = []; + foreach ($this->updated($recorder) as $related) { + $ids[] = $related->getId(); + } + \sort($ids); + + return $ids; + } + + /** + * The documents the update events recorded carry, in the order they fired. + * + * @return list + */ + private function updated(RecordingLifecycle $recorder): array + { + $documents = []; + foreach ($recorder->received(Event::DocumentUpdate) as $event) { + $this->assertInstanceOf(Event\Document\Updated::class, $event); + $documents[] = $event->document; + } + + return $documents; + } + + /** + * @return list + */ + private function collectionPermissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @return list + */ + private function documentPermissions(): array + { + return [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } +} diff --git a/tests/unit/Relationships/ManyToManyDeleteTest.php b/tests/unit/Relationships/ManyToManyDeleteTest.php new file mode 100644 index 0000000000..787a0d01c9 --- /dev/null +++ b/tests/unit/Relationships/ManyToManyDeleteTest.php @@ -0,0 +1,94 @@ + + */ + public static function deletions(): array + { + $memory = static fn (): Adapter => new Memory(); + $sqlite = static fn (): Adapter => new SQLite(new PDO('sqlite::memory:')); + + return [ + 'memory from the parent side' => [$memory, RelationshipSide::Parent], + 'memory from the child side' => [$memory, RelationshipSide::Child], + 'sqlite from the parent side' => [$sqlite, RelationshipSide::Parent], + 'sqlite from the child side' => [$sqlite, RelationshipSide::Child], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('deletions')] + public function testDeletingAManyToManyDropsItsJunction(Closure $adapter, RelationshipSide $side): void + { + $database = $this->database($adapter()); + $junction = '_'.$database->getCollection('books')->getSequence().'_'.$database->getCollection('authors')->getSequence(); + + match ($side) { + RelationshipSide::Parent => $database->deleteRelationship('books', 'authors'), + RelationshipSide::Child => $database->deleteRelationship('authors', 'books'), + }; + + $this->assertNull($database->findCollection($junction)); + + $database->createRelationship('books', $this->relationship()); + + $this->assertNotNull($database->findCollection($junction)); + $this->assertSame([], $database->getDocument('books', 'b1')->getAttribute('authors')); + $this->assertSame([], $database->getDocument('authors', 'a1')->getAttribute('books')); + } + + private function relationship(): Relationship + { + return Relationship::manyToMany('authors', key: 'authors', twoWay: true, twoWayKey: 'books'); + } + + private function database(Adapter $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('many_to_many_delete') + ->setNamespace('many_to_many_delete_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $database->createCollection(Collection::create(id: 'books', attributes: [Attribute::string(key: 'title', size: 64)], permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: 'authors', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions, documentSecurity: false)); + $database->createRelationship('books', $this->relationship()); + + $database->createDocument('authors', new Document(['$id' => 'a1', 'name' => 'Ada'])); + $database->createDocument('books', new Document(['$id' => 'b1', 'title' => 'Notes', 'authors' => ['a1']])); + + return $database; + } +} diff --git a/tests/unit/Relationships/OneWayChildSideTest.php b/tests/unit/Relationships/OneWayChildSideTest.php new file mode 100644 index 0000000000..cfef27dd3a --- /dev/null +++ b/tests/unit/Relationships/OneWayChildSideTest.php @@ -0,0 +1,138 @@ + + */ + public static function databases(): array + { + return [ + 'memory' => [static fn (): Database => self::database(new Memory())], + 'sqlite' => [static fn (): Database => self::database(new SQLite(new PDO('sqlite::memory:')))], + ]; + } + + /** + * @param Closure(): Database $factory + */ + #[DataProvider('databases')] + public function testAChildSideUpdateRenamesTheParentKey(Closure $factory): void + { + $database = $factory(); + + $updated = $database->updateRelationship('passports', 'holder', new RelationshipUpdate(key: 'owner', twoWayKey: 'document')); + + $this->assertSame('owner', $updated->key); + $this->assertSame('document', $updated->twoWayKey); + $this->assertSame(RelationshipSide::Child, $this->attribute($database, 'passports', 'owner')?->side); + $this->assertSame(RelationshipSide::Parent, $this->attribute($database, 'people', 'document')?->side); + $this->assertNull($this->attribute($database, 'people', 'passport')); + $this->assertSame(['_index_document'], $this->indexKeys($database, 'people')); + $this->assertSame([], $this->indexKeys($database, 'passports')); + + $person = $database->getDocument('people', 'p1'); + $this->assertSame('x1', $this->id($person->getAttribute('document'))); + $this->assertFalse($person->offsetExists('passport')); + } + + /** + * @param Closure(): Database $factory + */ + #[DataProvider('databases')] + public function testAChildSideDeleteRemovesTheParentKey(Closure $factory): void + { + $database = $factory(); + + $database->deleteRelationship('passports', 'holder'); + + $this->assertNull($this->attribute($database, 'passports', 'holder')); + $this->assertNull($this->attribute($database, 'people', 'passport')); + $this->assertSame([], $this->indexKeys($database, 'people')); + + $person = $database->getDocument('people', 'p1'); + $this->assertSame('Ada', $person->getAttribute('name')); + $this->assertNull($person->getAttribute('passport')); + + $database->createRelationship('people', Relationship::oneToOne('passports', key: 'passport', twoWayKey: 'holder')); + $database->updateDocument('people', 'p1', new Document(['passport' => 'x1'])); + + $this->assertSame('x1', $this->id($database->getDocument('people', 'p1')->getAttribute('passport'))); + } + + private static function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('one_way') + ->setNamespace('one_way_'.\uniqid()); + $database->create(); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create('people', attributes: [Attribute::string('name', 64)], permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create('passports', attributes: [Attribute::string('number', 64)], permissions: $permissions, documentSecurity: false)); + $database->createRelationship('people', Relationship::oneToOne('passports', key: 'passport', twoWayKey: 'holder')); + + $database->createDocument('passports', new Document(['$id' => 'x1', 'number' => 'A-1'])); + $database->createDocument('people', new Document(['$id' => 'p1', 'name' => 'Ada', 'passport' => 'x1'])); + + return $database; + } + + private function attribute(Database $database, string $collection, string $key): ?Attribute + { + foreach ($database->getCollection($collection)->attributes() as $attribute) { + if ($attribute->key === $key) { + return $attribute; + } + } + + return null; + } + + private function id(mixed $related): ?string + { + return match (true) { + $related instanceof Document => $related->getId(), + \is_string($related) => $related, + default => null, + }; + } + + /** + * @return list + */ + private function indexKeys(Database $database, string $collection): array + { + return \array_map(static fn (Index $index): string => $index->key, $database->getCollection($collection)->indexes()); + } +} diff --git a/tests/unit/Relationships/ParallelPopulationTest.php b/tests/unit/Relationships/ParallelPopulationTest.php new file mode 100644 index 0000000000..b493d87eb8 --- /dev/null +++ b/tests/unit/Relationships/ParallelPopulationTest.php @@ -0,0 +1,476 @@ +markTestSkipped('ext-swoole is required for coroutine population'); + } + + $file = \tempnam(\sys_get_temp_dir(), 'parallel-population-'); + $this->assertIsString($file); + $this->file = $file; + } + + #[\Override] + protected function tearDown(): void + { + if (isset($this->file) && \is_file($this->file)) { + \unlink($this->file); + } + } + + public function testParallelPopulationLeavesAuthorizationAndRelationshipsEnabled(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool()); + + for ($round = 1; $round <= self::ROUNDS; $round++) { + $this->assertCount(self::DOCUMENTS, $this->findParents($database)); + $this->assertTrue($database->getAuthorization()->getStatus(), "Authorization is left disabled after round {$round}"); + $this->assertTrue($this->hook($database)->isEnabled(), "Relationships are left disabled after round {$round}"); + } + }); + } + + public function testParallelPopulationReturnsNoRelatedDocumentWithoutAReadGrant(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool()); + + for ($round = 1; $round <= self::ROUNDS; $round++) { + $parents = $this->findParents($database); + + $this->assertSame([], $this->populatedSecrets($parents), "Round {$round} populated secrets the caller cannot read"); + $this->assertSame([], $this->ids($database->find('secrets')), "Round {$round} left secrets readable"); + } + }); + } + + public function testSilentCoversTheParallelReads(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool()); + + $this->finds = 0; + $this->findParents($database); + $this->assertSame(1, $this->finds, 'Population delivered find events of its own'); + + $this->finds = 0; + $database->silent(fn (): array => $this->findParents($database)); + $this->assertSame(0, $this->finds, 'Find events were delivered inside silent()'); + }); + } + + public function testPopulationRunsItsChunksInParallel(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool()); + + $this->findParents($database); + + $this->assertGreaterThan(1, $this->peak, 'Population read its chunks one at a time'); + }); + } + + public function testPopulationOnAnAdapterWithoutAPoolReadsOneChunkAtATime(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->sqlite()); + + for ($round = 1; $round <= self::ROUNDS; $round++) { + $parents = $this->findParents($database); + + $this->assertSame([], $this->populatedSecrets($parents)); + $this->assertTrue($database->getAuthorization()->getStatus()); + $this->assertTrue($this->hook($database)->isEnabled()); + } + + $this->assertSame(1, $this->peak, 'Chunk reads shared one connection at the same time'); + }); + } + + public function testPopulationInsideATransactionReadsOneChunkAtATime(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool()); + + $parents = $database->withTransaction(fn (): array => $this->findParents($database)); + + $this->assertCount(self::DOCUMENTS, $parents); + $this->assertSame(\array_map(fn (int $index): string => "label{$index}", \range(1, self::DOCUMENTS)), \array_map( + fn (Document $parent): string => $parent->getDocuments('labels')[0]->getId(), + $parents, + )); + $this->assertSame(1, $this->peak, 'Chunk reads ran at the same time inside a transaction'); + $this->assertTrue($database->getAuthorization()->getStatus()); + }); + } + + public function testPopulationReadsNoMoreChunksAtOnceThanItsReadConcurrency(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool(size: 16)); + $database->setMaxQueryValues(1); + + $parents = $this->findParents($database); + + $this->assertSame($this->labels(), $this->populatedLabels($parents)); + $this->assertGreaterThan(1, $this->peak, 'Population read its chunks one at a time'); + $this->assertLessThanOrEqual(Relationships::READ_CONCURRENCY, $this->peak, 'Population read more chunks at once than its read concurrency'); + $this->assertLessThanOrEqual(Relationships::READ_CONCURRENCY, $this->connections, 'Population checked out more connections at once than its read concurrency'); + }); + } + + public function testPopulationOnAPoolWithFewIdleConnectionsReadsWithoutWaitingForOne(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool(size: 2, timeout: 0.0)); + $database->setMaxQueryValues(1); + + for ($round = 1; $round <= self::ROUNDS; $round++) { + $parents = $this->findParents($database); + + $this->assertSame($this->labels(), $this->populatedLabels($parents), "Round {$round} populated the wrong labels"); + $this->assertSame([], $this->populatedSecrets($parents)); + } + + $this->assertSame(1, $this->peak, 'Population read chunks at once without an idle connection to spare'); + }); + } + + public function testPopulationReadsFromAReadPoolWithFewIdleConnectionsWithoutWaitingForOne(): void + { + $this->inCoroutine(function (): void { + $pool = new ReadWritePool( + new UtopiaPool(new Stack(), 'parallel-population-writes', 16, $this->sqlite(...), timeout: 0.0), + new UtopiaPool(new Stack(), 'parallel-population-reads', 2, $this->sqlite(...), timeout: 0.0), + ); + $pool->setSticky(false); + $database = $this->database($pool); + $database->setMaxQueryValues(1); + + $parents = $this->findParents($database); + + $this->assertSame($this->labels(), $this->populatedLabels($parents)); + $this->assertSame(1, $this->peak, 'Population read chunks at once without an idle read connection to spare'); + }); + } + + public function testPopulationOnAPinnedConnectionWithoutATransactionReadsOneChunkAtATime(): void + { + $this->inCoroutine(function (): void { + $database = $this->database($this->pool(connect: $this->sqliteWithoutTransactions(...))); + $connections = $this->connections; + + $parents = $database->withTransaction(function () use ($database): array { + $this->assertFalse($database->getAdapter()->inTransaction(), 'The pinned connection opened a transaction'); + + return $this->findParents($database); + }); + + $this->assertSame($this->labels(), $this->populatedLabels($parents)); + $this->assertSame([], $this->populatedSecrets($parents)); + $this->assertSame(1, $this->peak, 'Chunk reads ran at the same time on the pinned connection'); + $this->assertSame($connections, $this->connections, 'Population borrowed connections besides the pinned one'); + }); + } + + public function testPopulationOutsideACoroutineKeepsTheCallersState(): void + { + $database = $this->database($this->pool()); + $this->selectedInACoroutine = false; + + $parents = $database->find('parents', [Query::limit(self::DOCUMENTS)]); + + $this->assertFalse($this->selectedInACoroutine, 'Population started coroutines outside a scheduler'); + + $this->assertCount(self::DOCUMENTS, $parents); + $this->assertSame([], $this->populatedSecrets($parents)); + $this->assertTrue($database->getAuthorization()->getStatus()); + $this->assertTrue($this->hook($database)->isEnabled()); + $this->assertSame([], $this->ids($database->find('secrets'))); + + $this->finds = 0; + $database->silent(fn (): array => $database->find('parents', [Query::limit(self::DOCUMENTS)])); + $this->assertSame(0, $this->finds, 'Find events were delivered inside silent()'); + } + + private function inCoroutine(Closure $test): void + { + $hookFlags = Runtime::getHookFlags(); + $failure = null; + + try { + run(static function () use ($test, &$failure): void { + try { + $test(); + } catch (\Throwable $error) { + $failure = $error; + } + }); + } finally { + Runtime::setHookFlags($hookFlags); + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * @return array + */ + private function findParents(Database $database): array + { + $this->latency = true; + + try { + return $database->find('parents', [Query::limit(self::DOCUMENTS)]); + } finally { + $this->latency = false; + } + } + + /** + * @param (Closure(): SQLite)|null $connect + */ + private function pool(int $size = 8, float $timeout = 1.0, ?Closure $connect = null): Pool + { + return new Pool(new UtopiaPool(new Stack(), 'parallel-population', $size, $connect ?? $this->sqlite(...), $timeout)); + } + + private function sqlite(): SQLite + { + $this->connections++; + + return new SQLite(new PDO('sqlite:' . $this->file)); + } + + /** + * A connection whose withTransaction() runs the callback without a transaction, as MongoDB does on a standalone + * server or under ignoreDuplicates(). + */ + private function sqliteWithoutTransactions(): SQLite + { + $this->connections++; + + return new class (new PDO('sqlite:' . $this->file)) extends SQLite { + #[\Override] + public function withTransaction(callable $callback): mixed + { + return $callback(); + } + }; + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database->setAuthorization(new Authorization()); + $database->setDatabase('population')->setNamespace('population'); + $database->create(); + $database->addHook(new Permissions()); + $database->addHook(new Relationships()); + $database->addHook(new class ($this->select(...)) implements Transform { + public function __construct(private readonly Closure $select) + { + } + + #[\Override] + public function transform(Event $event, string $query): string + { + if (\stripos($query, 'select') !== false) { + ($this->select)(); + } + + return $query; + } + }); + $database->addHook(new class ($this->record(...)) implements Lifecycle { + public function __construct(private readonly Closure $record) + { + } + + #[\Override] + public function handle(Domain $event): void + { + ($this->record)($event->event); + } + }); + + $open = [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $database->createCollection(Collection::create(id: 'parents', permissions: $open, documentSecurity: false, attributes: [Attribute::string('name', 32)])); + $database->createCollection(Collection::create(id: 'children', permissions: $open, documentSecurity: false, attributes: [Attribute::string('name', 32)])); + $database->createCollection(Collection::create(id: 'labels', permissions: $open, documentSecurity: false, attributes: [Attribute::string('name', 32)])); + $database->createCollection(Collection::create(id: 'secrets', permissions: [Permission::create(Role::any())], documentSecurity: true, attributes: [Attribute::string('name', 32)])); + $database->createRelationship('parents', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, key: 'children', twoWayKey: 'parent')); + $database->createRelationship('parents', Relationship::manyToMany(relatedCollection: 'labels', twoWay: true, key: 'labels', twoWayKey: 'parents')); + $database->createRelationship('parents', Relationship::manyToOne(relatedCollection: 'secrets', twoWay: false, key: 'secret', twoWayKey: 'parents')); + + $database->getAuthorization()->skip(function () use ($database, $open): void { + for ($index = 1; $index <= self::DOCUMENTS; $index++) { + $database->createDocument('secrets', new Document(['$id' => "secret{$index}", 'name' => "secret {$index}", '$permissions' => [Permission::read(Role::user('owner'))]])); + $database->createDocument('labels', new Document(['$id' => "label{$index}", 'name' => "label {$index}", '$permissions' => $open])); + $database->createDocument('parents', new Document([ + '$id' => "parent{$index}", + 'name' => "parent {$index}", + '$permissions' => $open, + 'children' => [new Document(['$id' => "child{$index}", 'name' => "child {$index}", '$permissions' => $open])], + 'labels' => ["label{$index}"], + 'secret' => "secret{$index}", + ])); + } + }); + + $database->setMaxQueryValues(2); + $this->peak = 0; + + return $database; + } + + private function select(): void + { + if (Coroutine::getCid() > 0) { + $this->selectedInACoroutine = true; + } + + if (! $this->latency || Coroutine::getCid() <= 0) { + return; + } + + $this->inFlight++; + $this->peak = \max($this->peak, $this->inFlight); + + try { + Coroutine::sleep(0.002 * $this->inFlight); + } finally { + $this->inFlight--; + } + } + + private function record(Event $event): void + { + if ($event === Event::DocumentFind) { + $this->finds++; + } + } + + private function hook(Database $database): Relationships + { + $hook = $database->getRelationshipHook(); + $this->assertNotNull($hook); + + return $hook; + } + + /** + * @param array $parents + * @return array + */ + private function populatedSecrets(array $parents): array + { + $secrets = []; + foreach ($parents as $parent) { + $secret = $parent->getAttribute('secret'); + if ($secret instanceof Document && ! $secret->isEmpty()) { + $secrets[] = $secret->getId(); + } + } + + return $secrets; + } + + /** + * @return array + */ + private function labels(): array + { + return \array_map(static fn (int $index): string => "label{$index}", \range(1, self::DOCUMENTS)); + } + + /** + * @param array $parents + * @return array + */ + private function populatedLabels(array $parents): array + { + $labels = []; + foreach ($parents as $parent) { + foreach ($parent->getDocuments('labels') as $label) { + $labels[] = $label->getId(); + } + } + + return $labels; + } + + /** + * @param array $documents + * @return array + */ + private function ids(array $documents): array + { + return \array_map(static fn (Document $document): string => $document->getId(), $documents); + } +} diff --git a/tests/unit/Relationships/ParentConflictRetryTest.php b/tests/unit/Relationships/ParentConflictRetryTest.php new file mode 100644 index 0000000000..4f589723cb --- /dev/null +++ b/tests/unit/Relationships/ParentConflictRetryTest.php @@ -0,0 +1,184 @@ + + */ + public static function modes(): iterable + { + yield 'prepared' => [true]; + yield 'one by one' => [false]; + } + + #[DataProvider('modes')] + public function testARetriedCreateKeepsItsNewRelatedDocuments(bool $prepare): void + { + $database = $this->database($prepare, 'createDocument'); + + $database->createDocument('parents', new Document([ + '$id' => 'p1', + 'name' => 'p1', + 'children' => [new Document([ + '$id' => 'c1', + 'name' => 'c1', + 'toys' => [new Document(['$id' => 't1', 'name' => 't1'])], + ])], + ])); + + $this->assertSame([['p1', 'p1']], $this->stored($database, 'parents', 'name')); + $this->assertSame([['c1', 'p1']], $this->stored($database, 'children', 'parent')); + $this->assertSame([['t1', 'c1']], $this->stored($database, 'toys', 'child')); + } + + #[DataProvider('modes')] + public function testACreateRetriedByAnEnclosingTransactionKeepsItsNewRelatedDocuments(bool $prepare): void + { + $database = $this->database($prepare, 'createDocument'); + $document = new Document([ + '$id' => 'p1', + 'name' => 'p1', + 'children' => [new Document([ + '$id' => 'c1', + 'name' => 'c1', + 'toys' => [new Document(['$id' => 't1', 'name' => 't1'])], + ])], + ]); + + $database->withTransaction(static fn (): Document => $database->createDocument('parents', $document)); + + $this->assertSame([['p1', 'p1']], $this->stored($database, 'parents', 'name')); + $this->assertSame([['c1', 'p1']], $this->stored($database, 'children', 'parent')); + $this->assertSame([['t1', 'c1']], $this->stored($database, 'toys', 'child')); + } + + #[DataProvider('modes')] + public function testARetriedCreateKeepsItsRelatedDocumentIds(bool $prepare): void + { + $database = $this->database($prepare, 'createDocument'); + $database->createDocument('children', new Document(['$id' => 'c1', 'name' => 'c1'])); + + $database->createDocument('parents', new Document(['$id' => 'p1', 'name' => 'p1', 'children' => ['c1']])); + + $this->assertSame([['p1', 'p1']], $this->stored($database, 'parents', 'name')); + $this->assertSame([['c1', 'p1']], $this->stored($database, 'children', 'parent')); + } + + #[DataProvider('modes')] + public function testARetriedUpdateKeepsItsNewRelatedDocuments(bool $prepare): void + { + $database = $this->database($prepare, 'updateDocument'); + $database->createDocument('parents', new Document(['$id' => 'p1', 'name' => 'p1'])); + + $database->updateDocument('parents', 'p1', new Document([ + 'name' => 'renamed', + 'children' => [new Document([ + '$id' => 'c1', + 'name' => 'c1', + 'toys' => [new Document(['$id' => 't1', 'name' => 't1'])], + ])], + ])); + + $this->assertSame([['p1', 'renamed']], $this->stored($database, 'parents', 'name')); + $this->assertSame([['c1', 'p1']], $this->stored($database, 'children', 'parent')); + $this->assertSame([['t1', 'c1']], $this->stored($database, 'toys', 'child')); + } + + /** + * A database whose first write of the given operation to the parents collection meets a lock conflict. + */ + private function database(bool $prepare, string $operation): Database + { + $adapter = new class (new PDO('sqlite::memory:'), $operation) extends RelationshipSQLite { + private bool $conflicted = false; + + public function __construct(PDO $pdo, private readonly string $operation) + { + parent::__construct($pdo); + } + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + $this->conflict('createDocument', $collection); + + return parent::createDocument($collection, $document); + } + + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + $this->conflict('updateDocument', $collection); + + return parent::updateDocument($collection, $id, $document, $skipPermissions); + } + + private function conflict(string $operation, Document $collection): void + { + if ($this->conflicted || $operation !== $this->operation || $collection->getId() !== 'parents') { + return; + } + + $this->conflicted = true; + $this->getDriver()->exec('ROLLBACK'); + + throw new Contention('Deadlock found when trying to get lock'); + } + }; + + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new Database($adapter, new Cache(new None())); + $database->setAuthorization($authorization)->setDatabase('parent_conflict')->setNamespace('conflict'); + $database->create(); + $database->addHook(new Relationships(prepare: $prepare)); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + foreach (['parents', 'children', 'toys'] as $collection) { + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions, documentSecurity: false)); + } + $database->createRelationship('parents', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, key: 'children', twoWayKey: 'parent')); + $database->createRelationship('children', Relationship::oneToMany(relatedCollection: 'toys', twoWay: true, key: 'toys', twoWayKey: 'child')); + + return $database; + } + + /** + * @return list + */ + private function stored(Database $database, string $collection, string $attribute): array + { + return \array_values(\array_map( + static fn (Document $document): array => [$document->getId(), $document->getAttribute($attribute)], + $database->skipRelationships(static fn (): array => $database->find($collection)), + )); + } +} diff --git a/tests/unit/Relationships/PreparedCreateTest.php b/tests/unit/Relationships/PreparedCreateTest.php new file mode 100644 index 0000000000..1ec8d7169c --- /dev/null +++ b/tests/unit/Relationships/PreparedCreateTest.php @@ -0,0 +1,929 @@ + 7, 'shared tables with an existing related document' => 7]; + + /** + * The scenarios that create only new related documents and succeed, so their related documents are prepared. + */ + private const array PREPARED = [ + 'one to many', + 'one to many past the maximum depth', + 'one to many one way', + 'many to many', + 'many to many past the maximum depth', + 'many to many one way', + 'many to one', + 'one to one', + 'one to one one way', + 'generated ids and own permissions', + 'documents mixed with ids', + 'one to many documents mixed with ids', + 'one to one by id', + 'existence checks skipped', + 'new document then its id', + 'one collection at several depths', + 'inside a transaction', + 'created through an update', + 'one to many update', + 'many to many update', + 'many to one update', + 'shared tables', + ]; + + private const array TENANT_PER_DOCUMENT = ['tenant per document', 'missing tenant on a related document']; + + /** + * What a write assigns to the documents handed to it. Written one by one, each related document is also left + * populated and decoded, as createDocument() leaves the document it is given; prepared, it is left as written. + */ + private const array ASSIGNED = ['$id', '$sequence', '$collection', '$permissions']; + + /** + * @return iterable + */ + public static function scenarios(): iterable + { + foreach (['memory', 'sqlite'] as $engine) { + foreach (\array_keys(self::writes()) as $scenario) { + yield $engine.': '.$scenario => [$engine, $scenario]; + } + } + } + + #[DataProvider('scenarios')] + public function testDeferredCreateMatchesOneByOne(string $engine, string $scenario): void + { + $this->assertSameObservations($this->observe($engine, self::ONE_BY_ONE, $scenario), $this->observe($engine, self::DEFERRED, $scenario)); + } + + /** + * Written one by one or each where it would be written, a write that fails restores the related documents it was + * given, so a transaction that retries the write starts over from them. + */ + #[DataProvider('scenarios')] + public function testImmediateCreateMatchesOneByOne(string $engine, string $scenario): void + { + $oneByOne = $this->observe($engine, self::ONE_BY_ONE_WITHOUT_SAVEPOINTS, $scenario); + $immediate = $this->observe($engine, self::IMMEDIATE, $scenario); + + if (isset($immediate['thrown'])) { + foreach ([$oneByOne, $immediate] as $observation) { + $given = $observation['given']; + $inputs = $observation['inputs']; + if (! \is_array($given) || ! \is_array($inputs)) { + $this->fail('An observation lacks the documents handed in'); + } + $this->assertSame(\array_slice($given, 1), \array_slice($inputs, 1), 'A failed write left the related documents it was given changed'); + } + } + + $this->assertSameObservations($oneByOne, $immediate); + } + + /** + * @return iterable + */ + public static function preparedScenarios(): iterable + { + foreach (self::PREPARED as $scenario) { + yield $scenario => [$scenario]; + } + } + + #[DataProvider('preparedScenarios')] + public function testScenarioPreparesItsRelatedDocuments(string $scenario): void + { + foreach ([self::DEFERRED => self::ONE_BY_ONE, self::IMMEDIATE => self::ONE_BY_ONE_WITHOUT_SAVEPOINTS] as $prepared => $oneByOne) { + $this->assertLessThan( + $this->observe('memory', $oneByOne, $scenario)['reads'], + $this->observe('memory', $prepared, $scenario)['reads'], + 'The '.$prepared.' write related its documents one by one, so the scenario does not compare it', + ); + } + } + + public function testPreparedCreateReadsNoRelatedDocumentBeforeOrAfterWritingIt(): void + { + foreach ([self::ONE_BY_ONE, self::DEFERRED, self::ONE_BY_ONE_WITHOUT_SAVEPOINTS, self::IMMEDIATE] as $mode) { + $database = $this->database('memory', $mode); + self::chain($database, RelationshipType::OneToMany, 2); + $adapter = $database->getAdapter(); + $this->assertInstanceOf(CountingMemory::class, $adapter); + $adapter->reset(); + + $database->createDocument('level0', self::tree(RelationshipType::OneToMany, 'root', 0, 2)); + + if ($mode === self::DEFERRED || $mode === self::IMMEDIATE) { + $this->assertSame(0, $adapter->documentReads, 'A '.$mode.' create read a related document'); + } else { + $this->assertGreaterThan(0, $adapter->documentReads, 'Relating one by one reads each related document'); + } + } + } + + public function testAFilterIsAppliedOnceWhenAnAssociativeValueHoldsAStoredDocument(): void + { + $filters = [new Callback( + 'wrap', + static fn (mixed $value): mixed => \is_string($value) ? '['.$value.']' : $value, + static fn (mixed $value): mixed => \is_string($value) && \str_starts_with($value, '[') ? \substr($value, 1, -1) : $value, + )]; + + foreach (['memory', 'sqlite'] as $engine) { + foreach ([self::ONE_BY_ONE, self::DEFERRED] as $mode) { + $database = $this->database($engine, $mode, filters: $filters); + foreach (['root', 'mid', 'side'] as $collection) { + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::string(key: 'name', size: 64)], permissions: self::permissions(), documentSecurity: true)); + } + $database->createCollection(Collection::create(id: 'leaf', attributes: [Attribute::string(key: 'name', size: 64, filters: ['wrap'])], permissions: self::permissions(), documentSecurity: true)); + $database->createRelationship('root', Relationship::manyToOne(relatedCollection: 'mid', twoWay: true, key: 'mid', twoWayKey: 'roots', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship('root', Relationship::manyToOne(relatedCollection: 'side', twoWay: true, key: 'side', twoWayKey: 'roots', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship('mid', Relationship::oneToMany(relatedCollection: 'leaf', twoWay: true, key: 'leaves', twoWayKey: 'mid', onDelete: RelationshipDeleteAction::Cascade)); + $database->createDocument('leaf', new Document(['$id' => 'existing', 'name' => 'old'])); + + $database->createDocument('root', new Document([ + '$id' => 'root', + 'side' => new Document(['name' => 'side']), + 'mid' => ['name' => 'mid', 'leaves' => [new Document(['$id' => 'k1', 'name' => 'k1']), new Document(['$id' => 'existing', 'name' => 'new'])]], + ])); + + $names = \array_map( + static fn (string $id): mixed => $database->skipRelationships(static fn (): Document => $database->getDocument('leaf', $id))->getAttribute('name'), + ['k1', 'existing'], + ); + $this->assertSame(['k1', 'new'], $names, 'Relating '.$mode.' on '.$engine.' encoded a name more than once'); + } + } + } + + /** + * @return iterable + */ + public static function preparing(): iterable + { + yield 'prepared' => [true]; + yield 'one by one' => [false]; + } + + #[DataProvider('preparing')] + public function testALockConflictWhileWritingPreparedDocumentsIsRetried(bool $prepare): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends RelationshipSQLite { + private bool $conflicted = false; + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + if (! $this->conflicted && $collection->getId() === 'children') { + $this->conflicted = true; + $this->getDriver()->exec('ROLLBACK'); + + throw new Contention('Deadlock found when trying to get lock'); + } + + return parent::createDocument($collection, $document); + } + }; + $database = $this->family($adapter, $prepare); + + $database->createDocument('parents', self::parent()); + + $this->assertFamilyStored($database); + } + + /** + * A lock wait that times out without the engine rolling the transaction back leaves the savepoint to roll back, + * but the conflicting lock is held until the whole transaction rolls back, so relating one by one in the same + * transaction would only wait for it again. + */ + #[DataProvider('preparing')] + public function testALockConflictTheSavepointSurvivesIsLeftToTheTransactionRetry(bool $prepare): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends RelationshipSQLite { + public int $lockWaits = 0; + + public bool $locked = false; + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + if ($this->locked && $collection->getId() === 'children') { + $this->lockWaits++; + + throw new Contention('Lock wait timeout exceeded; try restarting transaction'); + } + + return parent::createDocument($collection, $document); + } + + #[\Override] + public function rollbackTransaction(): bool + { + $rolledBack = parent::rollbackTransaction(); + if (! $this->inTransaction()) { + $this->locked = false; + } + + return $rolledBack; + } + }; + $database = $this->family($adapter, $prepare); + $adapter->locked = true; + + $database->createDocument('parents', self::parent()); + + $this->assertFamilyStored($database); + $this->assertSame(1, $adapter->lockWaits, 'A write waited for a lock its own transaction still held'); + } + + public function testADocumentWhosePreparedSavepointFailedToCommitIsRelatedAgainOnRetry(): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends RelationshipSQLite { + public bool $failSavepointCommit = false; + + #[\Override] + public function commitTransaction(): bool + { + if ($this->failSavepointCommit && $this->inTransaction > 1) { + $this->failSavepointCommit = false; + $this->getDriver()->exec('ROLLBACK'); + $this->inTransaction = 0; + + throw new Contention('Deadlock found when trying to get lock'); + } + + return parent::commitTransaction(); + } + }; + $database = $this->family($adapter, true); + $adapter->failSavepointCommit = true; + + $database->createDocument('parents', self::parent()); + + $this->assertFalse($adapter->failSavepointCommit, 'The savepoint commit never failed'); + $this->assertFamilyStored($database); + } + + private function family(RelationshipSQLite $adapter, bool $prepare): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new Database($adapter, new Cache(new None())); + $database->setAuthorization($authorization)->setDatabase('prepared_create')->setNamespace('prepared'); + $database->create(); + $database->addHook(new Relationships(prepare: $prepare)); + foreach (['parents', 'children'] as $collection) { + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::string(key: 'name', size: 64)], permissions: self::permissions(), documentSecurity: false)); + } + $database->createRelationship('parents', Relationship::oneToMany(relatedCollection: 'children', twoWay: true, key: 'children', twoWayKey: 'parent')); + + return $database; + } + + private static function parent(): Document + { + return new Document(['$id' => 'p1', 'name' => 'p1', 'children' => [new Document(['$id' => 'c1', 'name' => 'c1'])]]); + } + + private function assertFamilyStored(Database $database): void + { + $parents = $database->skipRelationships(static fn (): array => $database->find('parents')); + $children = $database->skipRelationships(static fn (): array => $database->find('children')); + + $this->assertSame(['p1'], \array_map(static fn (Document $parent): string => $parent->getId(), $parents)); + $this->assertSame( + [['c1', 'p1']], + \array_map(static fn (Document $child): array => [$child->getId(), $child->getAttribute('parent')], $children), + ); + } + + /** + * @param array $expected + * @param array $actual + */ + private function assertSameObservations(array $expected, array $actual): void + { + unset($expected['reads'], $actual['reads']); + + $this->assertSame($expected, $actual); + } + + /** + * Each scenario sets up its schema and data, and returns the document it writes with the write. + * + * @return array + */ + private static function writes(): array + { + $create = static fn (Database $database, string $collection): Closure => static fn (Document $document): Document => $database->createDocument($collection, $document); + $tree = static fn (RelationshipType $type, int $depth, bool $twoWay = true): Closure => static function (Database $database) use ($type, $depth, $twoWay, $create): array { + self::chain($database, $type, $depth, $twoWay); + + return [self::tree($type, 'root', 0, $depth), $create($database, 'level0')]; + }; + $nodes = static function (Database $database): void { + $database->createCollection(Collection::create(id: 'node', attributes: [Attribute::string(key: 'name', size: 64)], permissions: self::permissions(), documentSecurity: true)); + $database->createCollection(Collection::create(id: 'tag', attributes: [Attribute::string(key: 'name', size: 64)], permissions: self::permissions(), documentSecurity: true)); + $database->createRelationship('node', Relationship::oneToMany(relatedCollection: 'tag', twoWay: true, key: 'tags', twoWayKey: 'node', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship('tag', Relationship::manyToMany(relatedCollection: 'node', twoWay: true, key: 'nodes', twoWayKey: 'labels', onDelete: RelationshipDeleteAction::Cascade)); + }; + + return [ + 'one to many' => $tree(RelationshipType::OneToMany, 2), + 'one to many past the maximum depth' => $tree(RelationshipType::OneToMany, 4), + 'one to many one way' => $tree(RelationshipType::OneToMany, 2, false), + 'many to many' => $tree(RelationshipType::ManyToMany, 2), + 'many to many past the maximum depth' => $tree(RelationshipType::ManyToMany, 4), + 'many to many one way' => $tree(RelationshipType::ManyToMany, 2, false), + 'many to one' => $tree(RelationshipType::ManyToOne, 3), + 'one to one' => $tree(RelationshipType::OneToOne, 3), + 'one to one one way' => $tree(RelationshipType::OneToOne, 3, false), + 'generated ids and own permissions' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'next' => [ + new Document(['name' => 'first', 'next' => [new Document(['name' => 'deep'])]]), + new Document(['name' => 'second', '$permissions' => [Permission::read(Role::user('someone'))]]), + ], + ]), $create($database, 'level0')]; + }, + 'documents mixed with ids' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::ManyToMany, 2); + $database->createDocument('level1', new Document(['$id' => 'existing', 'name' => 'existing'])); + $database->createDocument('level2', new Document(['$id' => 'leaf', 'name' => 'leaf'])); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => [ + new Document(['$id' => 'a', 'name' => 'a', 'next' => ['leaf', new Document(['$id' => 'a1', 'name' => 'a1'])]]), + 'existing', + new Document(['$id' => 'b', 'name' => 'b']), + ], + ]), $create($database, 'level0')]; + }, + 'one to many documents mixed with ids' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createDocument('level1', new Document(['$id' => 'existing', 'name' => 'existing'])); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => [ + new Document(['$id' => 'a', 'name' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'a1'])]]), + 'existing', + new Document(['$id' => 'b', 'name' => 'b']), + ], + ]), $create($database, 'level0')]; + }, + 'existing related documents' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createDocument('level1', new Document(['$id' => 'same', 'name' => 'same', 'score' => 1, '$permissions' => [Permission::read(Role::any())]])); + $database->createDocument('level1', new Document(['$id' => 'changed', 'name' => 'before', 'score' => 1])); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => [ + new Document(['$id' => 'new', 'name' => 'new', 'next' => [new Document(['$id' => 'leaf', 'name' => 'leaf'])]]), + new Document(['$id' => 'same', 'name' => 'same', 'score' => 1, '$permissions' => [Permission::read(Role::any())]]), + new Document(['$id' => 'changed', 'name' => 'after', 'next' => [new Document(['$id' => 'under', 'name' => 'under'])]]), + ], + ]), $create($database, 'level0')]; + }, + 'existing many to many related document' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::ManyToMany, 2); + $database->createDocument('level1', new Document(['$id' => 'shared', 'name' => 'shared'])); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => [ + new Document(['$id' => 'new', 'name' => 'new']), + new Document(['$id' => 'shared', 'name' => 'renamed']), + ], + ]), $create($database, 'level0')]; + }, + 'associative related document holding a stored one' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::ManyToOne, 2); + $database->createDocument('level2', new Document(['$id' => 'existing', 'name' => 'before'])); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => ['name' => 'mid', 'next' => new Document(['$id' => 'existing', 'name' => 'after'])], + ]), $create($database, 'level0')]; + }, + 'repeated related document' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::ManyToMany, 2); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => [ + new Document(['$id' => 'twice', 'name' => 'twice']), + new Document(['$id' => 'twice', 'name' => 'twice']), + ], + ]), $create($database, 'level0')]; + }, + 'one to one by id' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToOne, 2); + $database->createDocument('level2', new Document(['$id' => 'leaf', 'name' => 'leaf'])); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => new Document(['$id' => 'a', 'name' => 'a', 'next' => 'leaf']), + ]), $create($database, 'level0')]; + }, + 'existence checks skipped' => static function (Database $database): array { + self::chain($database, RelationshipType::OneToOne, 2); + + return [ + self::tree(RelationshipType::OneToOne, 'root', 0, 2), + static fn (Document $document): Document => $database->skipRelationshipsExistCheck(static fn (): Document => $database->createDocument('level0', $document)), + ]; + }, + 'new document then its id' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::ManyToMany, 2); + + return [new Document([ + '$id' => 'root', + 'name' => 'root', + 'next' => [ + new Document(['$id' => 'a', 'name' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'a1']), 'a1']]), + 'a', + ], + ]), $create($database, 'level0')]; + }, + 'one collection at several depths' => static function (Database $database) use ($create, $nodes): array { + $nodes($database); + + return [new Document([ + '$id' => 'n0', + 'name' => 'n0', + 'tags' => [ + new Document(['$id' => 't1', 'name' => 't1', 'nodes' => [new Document(['$id' => 'n1', 'name' => 'n1']), new Document(['$id' => 'n2', 'name' => 'n2'])]]), + new Document(['$id' => 't2', 'name' => 't2', 'nodes' => [new Document(['$id' => 'n3', 'name' => 'n3'])]]), + ], + ]), $create($database, 'node')]; + }, + 'id already taken' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToOne, 2); + $database->createDocument('level0', new Document(['$id' => 'root', 'name' => 'taken'])); + + return [self::tree(RelationshipType::OneToOne, 'root', 0, 2), $create($database, 'level0')]; + }, + 'related document with the id of the document it belongs to' => static function (Database $database) use ($create, $nodes): array { + $nodes($database); + + return [new Document([ + '$id' => 'n0', + 'tags' => [new Document(['$id' => 't1', 'nodes' => [new Document(['$id' => 'n1']), new Document(['$id' => 'n0'])]])], + ]), $create($database, 'node')]; + }, + 'inside a transaction' => static function (Database $database): array { + self::chain($database, RelationshipType::ManyToMany, 2); + + return [ + self::tree(RelationshipType::ManyToMany, 'root', 0, 2), + static fn (Document $document): Document => $database->withTransaction(static fn (): Document => $database->createDocument('level0', $document)), + ]; + }, + 'created through an update' => static function (Database $database): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createDocument('level0', new Document(['$id' => 'root', 'name' => 'root'])); + + return [new Document([ + 'next' => [ + new Document(['$id' => 'a', 'name' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'a1']), new Document(['$id' => 'a2', 'name' => 'a2'])]]), + ], + ]), static fn (Document $document): Document => $database->updateDocument('level0', 'root', $document)]; + }, + 'one to many update' => static function (Database $database): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createDocument('level0', new Document(['$id' => 'root', 'name' => 'root', 'next' => [ + new Document(['$id' => 'kept', 'name' => 'kept']), + new Document(['$id' => 'dropped', 'name' => 'dropped']), + ]])); + $database->createDocument('level1', new Document(['$id' => 'loose', 'name' => 'loose'])); + + return [new Document([ + 'name' => 'renamed', + 'next' => [ + 'kept', + new Document(['$id' => 'a', 'name' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'a1']), new Document(['$id' => 'a2', 'name' => 'a2'])]]), + 'loose', + new Document(['$id' => 'b', 'name' => 'b']), + ], + ]), static fn (Document $document): Document => $database->updateDocument('level0', 'root', $document)]; + }, + 'many to many update' => static function (Database $database): array { + self::chain($database, RelationshipType::ManyToMany, 2); + $database->createDocument('level0', new Document(['$id' => 'root', 'name' => 'root', 'next' => [ + new Document(['$id' => 'kept', 'name' => 'kept']), + new Document(['$id' => 'dropped', 'name' => 'dropped']), + ]])); + $database->createDocument('level1', new Document(['$id' => 'loose', 'name' => 'loose'])); + + return [new Document([ + 'next' => [ + 'kept', + new Document(['$id' => 'a', 'name' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'a1'])]]), + 'loose', + new Document(['$id' => 'b', 'name' => 'b']), + ], + ]), static fn (Document $document): Document => $database->updateDocument('level0', 'root', $document)]; + }, + 'many to one update' => static function (Database $database): array { + self::chain($database, RelationshipType::ManyToOne, 2); + $database->createDocument('level1', new Document(['$id' => 'shared', 'name' => 'shared'])); + $database->createDocument('level0', new Document(['$id' => 'old', 'name' => 'old', 'next' => 'shared'])); + + return [new Document([ + 'prev' => [ + 'old', + new Document(['$id' => 'p1', 'name' => 'p1']), + new Document(['$id' => 'p2', 'name' => 'p2']), + ], + ]), static fn (Document $document): Document => $database->updateDocument('level1', 'shared', $document)]; + }, + 'update with an existing related document' => static function (Database $database): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createDocument('level0', new Document(['$id' => 'root', 'name' => 'root'])); + $database->createDocument('level1', new Document(['$id' => 'loose', 'name' => 'before'])); + + return [new Document([ + 'next' => [ + new Document(['$id' => 'a', 'name' => 'a']), + new Document(['$id' => 'loose', 'name' => 'after']), + ], + ]), static fn (Document $document): Document => $database->updateDocument('level0', 'root', $document)]; + }, + 'update with an invalid related document' => static function (Database $database): array { + self::chain($database, RelationshipType::ManyToMany, 2); + $database->createDocument('level0', new Document(['$id' => 'root', 'name' => 'root'])); + + return [new Document([ + 'next' => [ + new Document(['$id' => 'a', 'name' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'a1'])]]), + new Document(['$id' => 'b', 'score' => 'many']), + ], + ]), static fn (Document $document): Document => $database->updateDocument('level0', 'root', $document)]; + }, + 'shared tables' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::ManyToMany, 2); + + return [self::tree(RelationshipType::ManyToMany, 'root', 0, 2), $create($database, 'level0')]; + }, + 'shared tables with an existing related document' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createDocument('level2', new Document(['$id' => 'existing', 'name' => 'before'])); + + return [new Document([ + '$id' => 'root', + 'next' => [new Document(['$id' => 'a', 'next' => [new Document(['$id' => 'a1']), new Document(['$id' => 'existing', 'name' => 'after'])]])], + ]), $create($database, 'level0')]; + }, + 'tenant per document' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + $tenant = static fn (string $id, array $next = []): Document => new Document(['$id' => $id, 'name' => $id, '$tenant' => 7] + ($next === [] ? [] : ['next' => $next])); + + return [$tenant('root', [$tenant('a', [$tenant('a1')]), $tenant('b')]), $create($database, 'level0')]; + }, + 'missing tenant on a related document' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + + return [new Document([ + '$id' => 'root', + '$tenant' => 7, + 'next' => [new Document(['$id' => 'a', '$tenant' => 7, 'next' => [new Document(['$id' => 'a1'])]])], + ]), $create($database, 'level0')]; + }, + 'invalid related document' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + + return [new Document([ + '$id' => 'root', + 'next' => [ + new Document(['$id' => 'a', 'name' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'a1']), new Document(['$id' => 'a2', 'score' => 'many'])]]), + new Document(['$id' => 'b', 'name' => 'b', 'score' => 'many']), + ], + ]), $create($database, 'level0')]; + }, + 'invalid relationship value' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + + return [new Document([ + '$id' => 'root', + 'next' => [ + new Document(['$id' => 'a', 'next' => [new Document(['$id' => 'a1'])]]), + new Document(['$id' => 'b', 'next' => [7]]), + ], + ]), $create($database, 'level0')]; + }, + 'related collection the caller may not create in' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->updateCollection('level2', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + + return [self::tree(RelationshipType::OneToMany, 'root', 0, 2), $create($database, 'level0')]; + }, + 'many to many link the caller may not update' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::ManyToMany, 2); + $database->updateCollection('level2', new CollectionUpdate(permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: true)); + $database->createDocument('level2', new Document(['$id' => 'locked', 'name' => 'locked', '$permissions' => [Permission::read(Role::any())]])); + + return [new Document([ + '$id' => 'root', + 'next' => [new Document(['$id' => 'a', 'next' => [new Document(['$id' => 'a1']), new Document(['$id' => 'locked'])]])], + ]), $create($database, 'level0')]; + }, + 'unreadable existing related document' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createDocument('level2', new Document(['$id' => 'hidden', 'name' => 'hidden', '$permissions' => []])); + + return [new Document([ + '$id' => 'root', + 'next' => [new Document(['$id' => 'a', 'next' => [new Document(['$id' => 'a1']), new Document(['$id' => 'hidden', 'name' => 'mine'])]])], + ]), $create($database, 'level0')]; + }, + 'unique attribute shared by related documents' => static function (Database $database) use ($create): array { + self::chain($database, RelationshipType::OneToMany, 2); + $database->createIndex('level2', Index::unique('name', ['name'])); + + return [new Document([ + '$id' => 'root', + 'next' => [ + new Document(['$id' => 'a', 'next' => [new Document(['$id' => 'a1', 'name' => 'same'])]]), + new Document(['$id' => 'b', 'score' => 'many', 'next' => [new Document(['$id' => 'b1', 'name' => 'same'])]]), + ], + ]), $create($database, 'level0')]; + }, + ]; + } + + /** + * @return array + */ + private function observe(string $engine, string $mode, string $scenario): array + { + $database = $this->database($engine, $mode, self::SHARED[$scenario] ?? null, \in_array($scenario, self::TENANT_PER_DOCUMENT, true)); + [$document, $write] = self::writes()[$scenario]($database); + + $lifecycle = new RecordingLifecycle(); + $writes = new RecordingWrite(); + $database->addHook($lifecycle); + $database->addHook($writes); + + $inputs = []; + $this->collect($document, $inputs); + $assigned = static fn (Document $input): array => \array_intersect_key($input->getArrayCopy(), \array_flip(self::ASSIGNED)); + $given = \array_map($assigned, $inputs); + + $adapter = $database->getAdapter(); + if ($adapter instanceof CountingMemory) { + $adapter->reset(); + } + + try { + $result = $write($document); + $outcome = ['returned' => $result instanceof Document ? $this->export($result) : $result]; + } catch (Throwable $error) { + $outcome = ['thrown' => [$error::class, $error->getMessage()]]; + } + + return $this->normalize($outcome + [ + 'given' => $given, + 'inputs' => \array_map($assigned, $inputs), + 'stored' => $this->stored($database), + 'events' => \array_map( + static fn (Event $event): string => $event->name, + $lifecycle->getEvents(), + ), + 'writes' => $writes->writes, + 'reads' => $adapter instanceof CountingMemory ? $adapter->documentReads : null, + ]); + } + + /** + * @param list $documents + */ + private function collect(Document $document, array &$documents): void + { + $documents[] = $document; + foreach ((array) $document as $value) { + foreach (\is_array($value) ? $value : [$value] as $item) { + if ($item instanceof Document) { + $this->collect($item, $documents); + } + } + } + } + + /** + * @param list $filters + */ + private function database(string $engine, string $mode, ?int $tenant = null, bool $tenantPerDocument = false, array $filters = []): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $savepoints = $mode === self::ONE_BY_ONE || $mode === self::DEFERRED; + $adapter = $engine === 'memory' + ? new RelationshipMemory($savepoints) + : new RelationshipSQLite(new PDO('sqlite::memory:'), $savepoints); + + $database = new Database($adapter, new Cache(new None()), $filters); + $database + ->setAuthorization($authorization) + ->setDatabase('prepared_create') + ->setNamespace('prepared'); + + if ($tenant !== null || $tenantPerDocument) { + $database + ->setSharedTables(true) + ->setTenantPerDocument($tenantPerDocument) + ->setTenant($tenant); + } + + $database->create(); + $database->addHook(new Relationships(prepare: $mode === self::DEFERRED || $mode === self::IMMEDIATE)); + $database->addHook(new Permissions()); + + return $database; + } + + private static function chain(Database $database, RelationshipType $type, int $depth, bool $twoWay = true): void + { + for ($level = 0; $level <= $depth; $level++) { + $database->createCollection(Collection::create( + id: 'level'.$level, + attributes: [Attribute::string(key: 'name', size: 64), Attribute::integer(key: 'score')], + permissions: self::permissions(), + documentSecurity: true, + )); + } + + for ($level = 0; $level < $depth; $level++) { + $database->createRelationship('level'.$level, Relationship::fromArray([ + 'relatedCollection' => 'level'.($level + 1), + 'relationType' => $type, + 'twoWay' => $twoWay, + 'key' => 'next', + 'twoWayKey' => 'prev', + 'onDelete' => RelationshipDeleteAction::Cascade, + ])); + } + } + + private static function tree(RelationshipType $type, string $id, int $level, int $depth): Document + { + $node = [ + '$id' => $id, + 'name' => 'node '.$id, + 'score' => $level, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + ]; + + if ($level < $depth) { + $node['next'] = match ($type) { + RelationshipType::ManyToOne, RelationshipType::OneToOne => self::tree($type, $id.'_0', $level + 1, $depth), + default => [self::tree($type, $id.'_0', $level + 1, $depth), self::tree($type, $id.'_1', $level + 1, $depth)], + }; + } + + return new Document($node); + } + + /** + * @return list + */ + private static function permissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @return array>> + */ + private function stored(Database $database): array + { + return $database->getAuthorization()->skip(fn (): array => $database->skipRelationships(function () use ($database): array { + $collections = \array_map(static fn (Document $collection): string => $collection->getId(), $database->listCollections(100)); + \sort($collections); + + $stored = []; + foreach ($collections as $collection) { + $rows = $database->find($collection, [Query::limit(1000), Query::orderAsc('$sequence')]); + $stored[$collection] = \array_values(\array_map($this->export(...), $rows)); + } + + return $stored; + })); + } + + /** + * @return array + */ + private function export(Document $document): array + { + return $this->withoutTimestamps($document->getArrayCopy()); + } + + /** + * @param array $values + * @return array + */ + private function withoutTimestamps(array $values): array + { + unset($values['$createdAt'], $values['$updatedAt']); + + foreach ($values as $key => $value) { + if (\is_array($value)) { + $values[$key] = $this->withoutTimestamps($value); + } + } + + return $values; + } + + /** + * Generated ids differ from run to run, so each is replaced by the order it first appears in. + * + * @param array $values + * @param array $ids + * @return array + */ + private function normalize(array $values, array &$ids = []): array + { + foreach ($values as $key => $value) { + if (\is_array($value)) { + $values[$key] = $this->normalize($value, $ids); + } elseif (\is_string($value) && \preg_match(self::GENERATED_ID, $value) === 1) { + $values[$key] = $ids[$value] ??= 'generated-'.\count($ids); + } + } + + return $values; + } +} diff --git a/tests/unit/Relationships/RecordingWrite.php b/tests/unit/Relationships/RecordingWrite.php new file mode 100644 index 0000000000..4e6cb79e1c --- /dev/null +++ b/tests/unit/Relationships/RecordingWrite.php @@ -0,0 +1,31 @@ +}> */ + public array $writes = []; + + /** + * @param array $documents + */ + #[\Override] + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + $this->writes[] = ['create', $collection, \array_values(\array_map(static fn (Document $document): string => $document->getId(), $documents))]; + } + + #[\Override] + public function afterDocumentUpdate(string $collection, string $id, Document $document, WriteContext $context): void + { + $this->writes[] = ['update', $collection, [$document->getId()]]; + } +} diff --git a/tests/unit/Relationships/RelationshipHookCoverageTest.php b/tests/unit/Relationships/RelationshipHookCoverageTest.php new file mode 100644 index 0000000000..0e28646d20 --- /dev/null +++ b/tests/unit/Relationships/RelationshipHookCoverageTest.php @@ -0,0 +1,231 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testANestedPathThroughAPlainAttributeMatchesNothing(Closure $adapter): void + { + $database = $this->library($adapter()); + + $this->assertSame(['notes'], $this->ids($database->find('books', [Query::equal('author.publisher.name', ['Acme'])]))); + $this->assertSame([], $database->find('books', [Query::equal('author.name.first', ['Ada'])])); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testANestedPathWhoseHopFindsNoParentMatchesNothing(Closure $adapter): void + { + $database = $this->library($adapter()); + $database->createDocument('publishers', new Document([Document::ID => 'lonely', 'name' => 'Lonely'])); + + $this->assertSame([], $database->find('books', [Query::equal('author.publisher.name', ['Lonely'])])); + $this->assertSame([], $database->find('books', [Query::equal('author.publisher.name', ['Nobody'])])); + + $database->createCollection(Collection::create(id: 'countries', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions())); + $database->createRelationship('publishers', Relationship::manyToOne(relatedCollection: 'countries', twoWay: true, key: 'country', twoWayKey: 'publishers')); + $database->createDocument('countries', new Document([Document::ID => 'nowhere', 'name' => 'Nowhere'])); + $database->createDocument('countries', new Document([Document::ID => 'home', 'name' => 'Home'])); + $database->updateDocument('publishers', 'acme', new Document(['country' => 'home'])); + + $this->assertSame(['notes'], $this->ids($database->find('books', [Query::equal('author.publisher.country.name', ['Home'])]))); + $this->assertSame([], $database->find('books', [Query::equal('author.publisher.country.name', ['Nowhere'])]), 'a hop that finds nothing ends the path before the next hop'); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRemovingAValueThatIsNotAnIdentifierKeepsTheLinks(Closure $adapter): void + { + $database = $this->library($adapter()); + $database->createDocument('books', new Document([Document::ID => 'essays', 'title' => 'Essays', 'author' => 'ada'])); + + $database->skipValidation(fn (): Document => $database->updateDocument('authors', 'ada', new Document([ + 'books' => Operator::arrayRemove(5), + ]))); + + $this->assertEqualsCanonicalizing(['essays', 'notes'], $this->ids($database->getDocument('authors', 'ada')->getAttribute('books'))); + $author = $database->getDocument('books', 'essays')->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('ada', $author->getId()); + } + + /** + * @return array + */ + public static function races(): array + { + return [ + 'the child was deleted meanwhile' => ['gone'], + 'the child was linked meanwhile' => ['linked'], + ]; + } + + #[DataProvider('races')] + public function testALinkWhoseChildChangedSinceTheBulkLinkIsSkipped(string $race): void + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new class (new SQLite(new PDO('sqlite::memory:')), new Cache(new None()), $race) extends Database { + public bool $racing = false; + + public function __construct(Adapter $adapter, Cache $cache, private readonly string $race) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $document = parent::getDocument($collection, $id, $queries, $forUpdate); + if (! $this->racing || ! $forUpdate || $collection !== 'child') { + return $document; + } + + return $this->race === 'gone' ? new Document() : $document->setAttribute('parent', 'parent1'); + } + }; + $database + ->setAuthorization($authorization) + ->setDatabase('relationship_hook_coverage') + ->setNamespace('race_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + $database->createCollection(Collection::create(id: 'parent', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'child', permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: true)); + $database->createRelationship('parent', Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + $database->createDocument('parent', new Document([Document::ID => 'parent1'])); + $database->createDocument('child', new Document([Document::ID => 'child1', Document::PERMISSIONS => [Permission::read(Role::any())]])); + + $database->racing = true; + $database->updateDocument('parent', 'parent1', new Document(['children' => ['child1']])); + $database->racing = false; + + $child = $database->skipRelationships(fn (): Document => $database->getDocument('child', 'child1')); + $this->assertNull($child->getAttribute('parent'), 'a child that changed since the bulk link is left as it is'); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testARelationshipChangeNestedPastTheMaximumDepthIsDropped(Closure $adapter): void + { + $database = $this->library($adapter()); + $database->createCollection(Collection::create(id: 'countries', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions())); + $database->createRelationship('publishers', Relationship::manyToOne(relatedCollection: 'countries', twoWay: true, key: 'country', twoWayKey: 'publishers')); + $database->createDocument('countries', new Document([Document::ID => 'home', 'name' => 'Home'])); + $database->createDocument('countries', new Document([Document::ID => 'away', 'name' => 'Away'])); + $database->updateDocument('publishers', 'acme', new Document(['country' => 'home'])); + + $database->updateDocument('books', 'notes', new Document([ + 'author' => new Document([ + Document::ID => 'ada', + 'name' => 'Ada Lovelace', + 'publisher' => new Document([ + Document::ID => 'acme', + 'name' => 'Acme Press', + 'country' => new Document([Document::ID => 'away', 'name' => 'Abroad']), + ]), + ]), + ])); + + $this->assertSame('Ada Lovelace', $database->getDocument('authors', 'ada')->getAttribute('name')); + $this->assertSame('Acme Press', $database->getDocument('publishers', 'acme')->getAttribute('name')); + $this->assertSame(['acme'], $this->ids($database->find('publishers', [Query::equal('country', ['home'])])), 'the link three levels down is past the maximum depth and stays'); + $this->assertSame('Away', $database->getDocument('countries', 'away')->getAttribute('name'), 'so is the nested document, which is not written'); + } + + /** + * @return list + */ + private function ids(mixed $documents): array + { + $this->assertIsArray($documents); + $ids = []; + foreach ($documents as $document) { + $this->assertInstanceOf(Document::class, $document); + $ids[] = $document->getId(); + } + + return $ids; + } + + private function library(Adapter $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('relationship_hook_coverage') + ->setNamespace('library_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + + foreach (['books' => 'title', 'authors' => 'name', 'publishers' => 'name'] as $collection => $attribute) { + $database->createCollection(Collection::create(id: $collection, attributes: [Attribute::string(key: $attribute, size: 64)], permissions: $this->permissions())); + } + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $database->createRelationship('authors', Relationship::manyToOne(relatedCollection: 'publishers', twoWay: true, key: 'publisher', twoWayKey: 'authors')); + + $database->createDocument('publishers', new Document([Document::ID => 'acme', 'name' => 'Acme'])); + $database->createDocument('authors', new Document([Document::ID => 'ada', 'name' => 'Ada', 'publisher' => 'acme'])); + $database->createDocument('books', new Document([Document::ID => 'notes', 'title' => 'Notes', 'author' => 'ada'])); + + return $database; + } + + /** + * @return list + */ + private function permissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } +} diff --git a/tests/unit/Relationships/RelationshipHookTest.php b/tests/unit/Relationships/RelationshipHookTest.php new file mode 100644 index 0000000000..9cb657781c --- /dev/null +++ b/tests/unit/Relationships/RelationshipHookTest.php @@ -0,0 +1,1351 @@ + + */ + public static function adapters(): iterable + { + yield 'memory' => [static fn (): Adapter => new Memory()]; + yield 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testOneToManyCascadeDeletesMoreChildrenThanTheQueryValueLimit(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade)); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + foreach (['child1', 'child2', 'child3'] as $id) { + $database->createDocument('child', new Document(['$id' => $id, 'parent' => 'parent1'])); + } + + $database->setMaxQueryValues(2); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertTrue($database->getDocument('parent', 'parent1')->isEmpty()); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testManyToOneCascadeDeletesMoreChildrenThanTheQueryValueLimit(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::manyToOne(relatedCollection: 'parent', twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: RelationshipDeleteAction::Cascade)); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + foreach (['child1', 'child2', 'child3'] as $id) { + $database->createDocument('child', new Document(['$id' => $id, 'parent' => 'parent1'])); + } + + $database->setMaxQueryValues(2); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertTrue($database->getDocument('parent', 'parent1')->isEmpty()); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testManyToManyCascadeDeletesMoreRelatedDocumentsThanTheQueryValueLimit(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::Cascade)); + + foreach (['child1', 'child2', 'child3'] as $id) { + $database->createDocument('child', new Document(['$id' => $id])); + } + $database->createDocument('parent', new Document(['$id' => 'parent1', 'children' => ['child1', 'child2', 'child3']])); + + $database->setMaxQueryValues(2); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertTrue($database->getDocument('parent', 'parent1')->isEmpty()); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testManyToManySetNullDeletesMoreJunctionRowsThanTheQueryValueLimit(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)); + + foreach (['child1', 'child2', 'child3'] as $id) { + $database->createDocument('child', new Document(['$id' => $id])); + } + $database->createDocument('parent', new Document(['$id' => 'parent1', 'children' => ['child1', 'child2', 'child3']])); + $database->createDocument('parent', new Document(['$id' => 'parent2', 'children' => ['child1']])); + + $database->setMaxQueryValues(2); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertTrue($database->getDocument('parent', 'parent1')->isEmpty()); + $this->assertSame(['child1', 'child2', 'child3'], $this->ids($database, 'child')); + $this->assertSame(['parent2'], $this->relatedIds($database->getDocument('child', 'child1'), 'parents')); + $this->assertSame([], $this->relatedIds($database->getDocument('child', 'child2'), 'parents')); + $this->assertSame([], $this->relatedIds($database->getDocument('child', 'child3'), 'parents')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testOneToManyCascadeRollsBackWhenAChildCannotBeDeleted(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'deletable', 'parent' => 'parent1', '$permissions' => [Permission::delete(Role::any())]])); + $database->createDocument('child', new Document(['$id' => 'protected', 'parent' => 'parent1', '$permissions' => [Permission::delete(Role::user('admin'))]])); + + $this->assertDeleteRejected($database, 'parent', 'parent1'); + + $this->assertFalse($database->getDocument('parent', 'parent1')->isEmpty()); + $this->assertSame(['deletable', 'protected'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + + $database->getAuthorization()->addRole(self::ADMIN); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testManyToOneCascadeRollsBackWhenAChildCannotBeDeleted(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::manyToOne(relatedCollection: 'parent', twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: RelationshipDeleteAction::Cascade), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'deletable', 'parent' => 'parent1', '$permissions' => [Permission::delete(Role::any())]])); + $database->createDocument('child', new Document(['$id' => 'protected', 'parent' => 'parent1', '$permissions' => [Permission::delete(Role::user('admin'))]])); + + $this->assertDeleteRejected($database, 'parent', 'parent1'); + + $this->assertFalse($database->getDocument('parent', 'parent1')->isEmpty()); + $this->assertSame(['deletable', 'protected'], $this->ids($database, 'child')); + + $database->getAuthorization()->addRole(self::ADMIN); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testManyToManyCascadeRollsBackWhenARelatedDocumentCannotBeDeleted(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::Cascade), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + + $database->createDocument('child', new Document(['$id' => 'deletable', '$permissions' => [Permission::delete(Role::any())]])); + $database->createDocument('child', new Document(['$id' => 'protected', '$permissions' => [Permission::delete(Role::user('admin'))]])); + $database->getAuthorization()->skip(fn () => $database->createDocument('parent', new Document(['$id' => 'parent1', 'children' => ['deletable', 'protected']]))); + + $this->assertDeleteRejected($database, 'parent', 'parent1'); + + $this->assertSame(['deletable', 'protected'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + $this->assertSame(['deletable', 'protected'], $this->ids($database, 'child')); + + $database->getAuthorization()->addRole(self::ADMIN); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCascadeSkipsARelatedDocumentThatIsAlreadyGone(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::Cascade), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + + foreach (['child1', 'child2', 'child3'] as $id) { + $database->createDocument('child', new Document(['$id' => $id, '$permissions' => [Permission::delete(Role::any())]])); + } + $database->getAuthorization()->skip(fn () => $database->createDocument('parent', new Document(['$id' => 'parent1', 'children' => ['child1', 'child2', 'child3']]))); + + $database->skipRelationships(fn () => $database->deleteDocument('child', 'child2')); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCascadeRetriedAfterAFailedCascadeStillDeletesTheChildren(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade)); + $database->createCollection(Collection::create(id: 'grandchild', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship('child', Relationship::oneToMany(relatedCollection: 'grandchild', twoWay: true, key: 'grandchildren', twoWayKey: 'child', onDelete: RelationshipDeleteAction::Restrict)); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'child1', 'parent' => 'parent1'])); + $database->createDocument('grandchild', new Document(['$id' => 'grandchild1', 'child' => 'child1'])); + + try { + $database->deleteDocument('parent', 'parent1'); + $this->fail('A restricted grandchild must stop the cascade'); + } catch (RestrictedException) { + } + + $database->deleteDocument('grandchild', 'grandchild1'); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAChildWithoutUpdatePermissionIsRejected(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'updatable', '$permissions' => [Permission::update(Role::any())]])); + $database->createDocument('child', new Document(['$id' => 'readonly', '$permissions' => [Permission::update(Role::user('admin'))]])); + + try { + $database->updateDocument('parent', 'parent1', new Document(['children' => ['updatable', 'readonly']])); + $this->fail('Linking a child the caller may not update must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "update" permission for role "user:admin". Only "["any"]" scopes are allowed and "["user:admin"]" was given.', $exception->getMessage()); + } + + $this->assertSame([], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + + $database->getAuthorization()->addRole(self::ADMIN); + + $database->updateDocument('parent', 'parent1', new Document(['children' => ['updatable', 'readonly']])); + $this->assertSame(['readonly', 'updatable'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testNestedUpdateCannotLinkAChildWithoutUpdatePermission(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + $database->createCollection(Collection::create(id: 'grandparent', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship('grandparent', Relationship::oneToOne(relatedCollection: 'parent', key: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + + $database->createDocument('child', new Document(['$id' => 'readonly', '$permissions' => [Permission::update(Role::user('admin'))]])); + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('grandparent', new Document(['$id' => 'grandparent1', 'parent' => 'parent1'])); + + try { + $database->updateDocument('grandparent', 'grandparent1', new Document(['parent' => new Document(['$id' => 'parent1', 'children' => ['readonly']])])); + $this->fail('Linking a child the caller may not update must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "update" permission for role "user:admin". Only "["any"]" scopes are allowed and "["user:admin"]" was given.', $exception->getMessage()); + } + + $this->assertSame([], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAChildGivenAsADocumentThroughANestedUpdateNeedsUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + + $this->assertLinkRejected(fn () => $database->updateDocument('grandparent', 'grandparent1', new Document([ + 'parent' => new Document(['$id' => 'parent1', 'children' => [new Document(['$id' => 'readonly'])]]), + ]))); + + $this->assertSame([], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingExistingChildrenThroughANestedCreateNeedsUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + + $this->assertLinkRejected(fn () => $database->createDocument('grandparent', new Document([ + '$id' => 'grandparent2', + 'parent' => new Document(['$id' => 'parent2', 'children' => ['readonly']]), + ]))); + + $this->assertNull($database->getDocument('child', 'readonly')->getAttribute('parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAPartnerThroughANestedTwoWayOneToOneUpdateNeedsUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, Relationship::oneToOne(relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + + $this->assertLinkRejected(fn () => $database->updateDocument('grandparent', 'grandparent1', new Document([ + 'parent' => new Document(['$id' => 'parent1', 'partner' => 'readonly']), + ]))); + + $this->assertNull($database->getDocument('child', 'readonly')->getAttribute('parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRelinkingAnUnchangedChildNeedsOnlyReadPermission(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull), + [Permission::create(Role::any()), Permission::read(Role::any())], + false, + ); + + $database->createDocument('parent', new Document(['$id' => 'parent1', 'name' => 'before'])); + $database->createDocument('child', new Document(['$id' => 'child1', 'parent' => 'parent1'])); + + $parent = $database->updateDocument('parent', 'parent1', new Document(['name' => 'after', 'children' => ['child1']])); + + $this->assertSame('after', $parent->getAttribute('name')); + $this->assertSame(['child1'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRelinkingKeepsAnUnchangedChildTheCallerMayNotUpdate(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'linked', 'parent' => 'parent1', '$permissions' => [Permission::update(Role::user('admin'))]])); + $database->createDocument('child', new Document(['$id' => 'added', '$permissions' => [Permission::update(Role::any())]])); + + $database->updateDocument('parent', 'parent1', new Document(['children' => ['linked', 'added']])); + + $this->assertSame(['added', 'linked'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRelationshipMaintenanceKeepsTheTenantOfEveryRelatedDocument(Closure $adapter): void + { + $database = $this->database($adapter, sharedTables: true); + $this->relate($database, Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + $database->setTenant(1); + + $database->createDocument('parent', new Document(['$id' => 'parent1', '$tenant' => 1])); + $database->createDocument('child', new Document(['$id' => 'child1', '$tenant' => 1, 'parent' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'child2', '$tenant' => 1])); + $database->createDocument('child', new Document(['$id' => 'foreign', '$tenant' => 2, 'parent' => 'parent1'])); + + $database->updateDocument('parent', 'parent1', new Document(['children' => ['child2']])); + $this->assertSame(['child2'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + + foreach (['child1', 'child2'] as $id) { + $child = $database->getDocument('child', $id); + $this->assertSame(1, $child->getTenant(), "{$id} must keep its tenant"); + $this->assertNull($child->getAttribute('parent'), "{$id} must no longer reference the deleted parent"); + } + + $foreign = $database->withTenant(2, fn () => $database->skipRelationships(fn () => $database->getDocument('child', 'foreign'))); + $this->assertSame(2, $foreign->getTenant()); + $this->assertSame('parent1', $foreign->getAttribute('parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCascadeDeletesAGrandchildTheCallerCannotRead(Closure $adapter): void + { + $database = $this->nestedCascadeDatabase($adapter, [Permission::create(Role::any()), Permission::delete(Role::any())], RelationshipDeleteAction::Cascade); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + + $this->assertSame([], $this->ids($database, 'child')); + $this->assertSame([], $this->ids($database, 'grandchild')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCascadeIsRestrictedByAGrandchildTheCallerCannotRead(Closure $adapter): void + { + $database = $this->nestedCascadeDatabase($adapter, [Permission::create(Role::any()), Permission::delete(Role::any())], RelationshipDeleteAction::Restrict); + + try { + $database->deleteDocument('parent', 'parent1'); + $this->fail('Cascading into a document whose relationship restricts its delete must be rejected'); + } catch (RestrictedException) { + } + + $this->assertSame(['parent1'], $this->ids($database, 'parent')); + $this->assertSame(['child1'], $this->ids($database, 'child')); + $this->assertSame(['grandchild1'], $this->ids($database, 'grandchild')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCascadeRollsBackWhenAGrandchildTheCallerCannotReadIsProtected(Closure $adapter): void + { + $database = $this->nestedCascadeDatabase($adapter, [Permission::create(Role::any()), Permission::delete(Role::user('admin'))], RelationshipDeleteAction::Cascade); + + $this->assertDeleteRejected($database, 'parent', 'parent1'); + + $this->assertSame(['parent1'], $this->ids($database, 'parent')); + $this->assertSame(['child1'], $this->ids($database, 'child')); + $this->assertSame(['grandchild1'], $this->ids($database, 'grandchild')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUpdateRelationshipFiresStringOptions(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade)); + + $updates = new class () implements Lifecycle { + /** @var array */ + public array $options = []; + + #[\Override] + public function handle(Domain $event): void + { + if ($event instanceof Event\Attribute\Updated) { + $this->options[$event->collection.'.'.$event->attribute->key] = $event->attribute->toDocument()->getAttribute('options'); + } + } + }; + $database->addHook($updates); + + $updated = $database->updateRelationship('parent', 'children', new RelationshipUpdate(key: 'kids', onDelete: RelationshipDeleteAction::SetNull)); + $this->assertSame('kids', $updated->key); + $this->assertSame(RelationshipDeleteAction::SetNull, $updated->onDelete); + + $this->assertSame( + [ + 'parent.kids' => [ + 'relatedCollection' => 'child', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'parent', + 'onDelete' => 'setNull', + 'side' => 'parent', + ], + 'child.parent' => [ + 'relatedCollection' => 'parent', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'kids', + 'onDelete' => 'setNull', + 'side' => 'child', + ], + ], + $updates->options, + ); + } + + /** + * @param Closure(): Adapter $adapter + * @param list $grandchildPermissions + */ + private function nestedCascadeDatabase(Closure $adapter, array $grandchildPermissions, RelationshipDeleteAction $onDelete): Database + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade), + ); + $database->createCollection(Collection::create(id: 'grandchild', permissions: $grandchildPermissions, documentSecurity: false)); + $database->createRelationship('child', Relationship::oneToMany(relatedCollection: 'grandchild', twoWay: true, key: 'grandchildren', twoWayKey: 'child', onDelete: $onDelete)); + + $database->getAuthorization()->skip(function () use ($database): void { + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'child1', 'parent' => 'parent1'])); + $database->createDocument('grandchild', new Document(['$id' => 'grandchild1', 'child' => 'child1'])); + }); + + return $database; + } + + /** + * @param Closure(): Adapter $adapter + */ + private function database(Closure $adapter, bool $sharedTables = false): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter(), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('relationship_hook') + ->setNamespace('relationship_hook_'.\uniqid()); + + if ($sharedTables) { + $database + ->setSharedTables(true) + ->setTenantPerDocument(true) + ->setTenant(null); + } + + $database->create(); + $database->addHook(new Relationships()); + $database->addHook(new Permissions()); + + return $database; + } + + /** + * @param list $childPermissions + */ + private function relate(Database $database, Relationship $relationship, array $childPermissions = [], bool $childDocumentSecurity = true): void + { + $database->createCollection(Collection::create(id: 'parent', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'child', permissions: $childPermissions === [] ? $this->permissions() : $childPermissions, documentSecurity: $childDocumentSecurity)); + $database->createRelationship($relationship->relatedCollection === 'parent' ? 'child' : 'parent', $relationship); + } + + /** + * @param Closure(): Adapter $adapter + */ + private function nestedLinkDatabase(Closure $adapter, Relationship $relationship): Database + { + $database = $this->database($adapter); + $this->relate($database, $relationship, [Permission::create(Role::any()), Permission::read(Role::any())]); + $database->createCollection(Collection::create(id: 'grandparent', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship('grandparent', Relationship::oneToOne(relatedCollection: 'parent', key: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + + $database->createDocument('child', new Document(['$id' => 'readonly', '$permissions' => [Permission::update(Role::user('admin'))]])); + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('grandparent', new Document(['$id' => 'grandparent1', 'parent' => 'parent1'])); + + return $database; + } + + private function assertLinkRejected(callable $write): void + { + try { + $write(); + $this->fail('Linking a document the caller may not update was accepted'); + } catch (AuthorizationException $exception) { + $this->assertStringContainsString('"update"', $exception->getMessage()); + } + } + + /** + * @return list + */ + private function permissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + private function assertDeleteRejected(Database $database, string $collection, string $id): void + { + try { + $database->deleteDocument($collection, $id); + $this->fail('Cascading into a document the caller may not delete must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame('Missing "delete" permission for role "user:admin". Only "["any"]" scopes are allowed and "["user:admin"]" was given.', $exception->getMessage()); + } + } + + /** + * @return array + */ + private function ids(Database $database, string $collection): array + { + $ids = \array_map( + fn (Document $document) => $document->getId(), + $database->getAuthorization()->skip(fn () => $database->skipRelationships(fn () => $database->find($collection))), + ); + \sort($ids); + + return $ids; + } + + /** + * @return array + */ + private function relatedIds(Document $document, string $key): array + { + $ids = \array_map(fn (Document $related) => $related->getId(), $document->getDocuments($key)); + \sort($ids); + + return $ids; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAFailedNestedOneToOneWriteLeavesNoWriteStackEntry(Closure $adapter): void + { + $database = $this->writeStackDatabase($adapter); + + foreach ([1, 2] as $attempt) { + $this->failNestedOneToOneWrite($database, $attempt); + + $this->assertSame(0, $database->getRelationshipHook()?->getWriteStackCount(), "Attempt {$attempt} left an entry on the write stack"); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testANestedCreateAfterAFailedNestedWriteStoresItsRelatedDocuments(Closure $adapter): void + { + $database = $this->writeStackDatabase($adapter); + + $this->failNestedOneToOneWrite($database, 1); + $this->failNestedOneToOneWrite($database, 2); + + $database->createDocument('owner', new Document([ + '$id' => 'owner2', + 'items' => [new Document(['$id' => 'item1', 'details' => [new Document(['$id' => 'detail1'])]])], + ])); + + $this->assertSame(['item1'], $this->ids($database, 'item')); + $this->assertSame(['detail1'], $this->ids($database, 'detail')); + $this->assertSame(['item1'], $this->relatedIds($database->getDocument('owner', 'owner2'), 'items')); + } + + /** + * @param Closure(): Adapter $adapter + */ + private function writeStackDatabase(Closure $adapter): Database + { + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'owner', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'solo', attributes: [Attribute::string(key: 'name', size: 64)], permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createCollection(Collection::create(id: 'item', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'detail', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship('owner', Relationship::oneToOne(relatedCollection: 'solo', twoWay: true, key: 'solo', twoWayKey: 'owner', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('owner', Relationship::oneToMany(relatedCollection: 'item', twoWay: true, key: 'items', twoWayKey: 'owner', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('item', Relationship::oneToMany(relatedCollection: 'detail', twoWay: true, key: 'details', twoWayKey: 'item', onDelete: RelationshipDeleteAction::SetNull)); + + $database->getAuthorization()->skip(function () use ($database): void { + $database->createDocument('owner', new Document(['$id' => 'owner1'])); + $database->createDocument('solo', new Document(['$id' => 'solo1', 'name' => 'before'])); + }); + + return $database; + } + + private function failNestedOneToOneWrite(Database $database, int $attempt): void + { + try { + $database->updateDocument('owner', 'owner1', new Document(['solo' => new Document(['$id' => 'solo1', 'name' => "attempt {$attempt}"])])); + $this->fail('Updating a related document the caller may not update must be rejected'); + } catch (AuthorizationException $exception) { + $this->assertSame("No permissions provided for action 'update'", $exception->getMessage()); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeleteDocumentsWithASelectCascadesToChildren(Closure $adapter): void + { + foreach ($this->deletePairs() as $type => [$relationship, $link]) { + $database = $this->database($adapter); + $this->relate($database, $relationship(RelationshipDeleteAction::Cascade)); + $link($database, 'parent1', 'child1'); + $link($database, 'parent2', 'child2'); + + $deleted = $database->deleteDocuments('parent', [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + + $this->assertSame(1, $deleted, $type); + $this->assertSame(['parent1'], $this->ids($database, 'parent'), $type); + $this->assertSame(['child1'], $this->ids($database, 'child'), "{$type}: the deleted parent's child must be deleted with it"); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeleteDocumentsWithASelectHonoursRestrict(Closure $adapter): void + { + foreach ($this->deletePairs() as $type => [$relationship, $link]) { + $database = $this->database($adapter); + $this->relate($database, $relationship(RelationshipDeleteAction::Restrict)); + $link($database, 'parent1', 'child1'); + $link($database, 'parent2', 'child2'); + + try { + $database->deleteDocuments('parent', [Query::equal('$id', ['parent2']), Query::select(['$id', 'name'])]); + $this->fail("{$type}: deleting a parent with a related document must be restricted"); + } catch (RestrictedException) { + } + + $this->assertSame(['parent1', 'parent2'], $this->ids($database, 'parent'), $type); + $this->assertSame(['child1', 'child2'], $this->ids($database, 'child'), $type); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeleteDocumentsWithASelectCascadesFromTheChildSideOfATwoWayOneToOne(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::oneToOne(relatedCollection: 'child', twoWay: true, key: 'child', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::Cascade)); + foreach (['1', '2'] as $suffix) { + $database->createDocument('child', new Document(['$id' => "child{$suffix}"])); + $database->createDocument('parent', new Document(['$id' => "parent{$suffix}", 'child' => "child{$suffix}"])); + } + + $this->assertSame(1, $database->deleteDocuments('child', [Query::equal('$id', ['child2']), Query::select(['$id'])])); + + $this->assertSame(['child1'], $this->ids($database, 'child')); + $this->assertSame(['parent1'], $this->ids($database, 'parent')); + } + + /** + * @return array + */ + private function deletePairs(): array + { + $parentHoldsChild = function (Database $database, string $parent, string $child): void { + $database->createDocument('child', new Document(['$id' => $child])); + $database->createDocument('parent', new Document(['$id' => $parent, 'child' => $child])); + }; + $parentListsChild = function (Database $database, string $parent, string $child): void { + $database->createDocument('child', new Document(['$id' => $child])); + $database->createDocument('parent', new Document(['$id' => $parent, 'children' => [$child]])); + }; + $childHoldsKey = function (Database $database, string $parent, string $child): void { + $database->createDocument('parent', new Document(['$id' => $parent])); + $database->createDocument('child', new Document(['$id' => $child, 'parent' => $parent])); + }; + + return [ + 'one-to-one' => [ + fn (RelationshipDeleteAction $onDelete): Relationship => Relationship::oneToOne(relatedCollection: 'child', key: 'child', twoWayKey: 'parent', onDelete: $onDelete), + $parentHoldsChild, + ], + 'one-to-many' => [ + fn (RelationshipDeleteAction $onDelete): Relationship => Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: $onDelete), + $childHoldsKey, + ], + 'many-to-one' => [ + fn (RelationshipDeleteAction $onDelete): Relationship => Relationship::manyToOne(relatedCollection: 'parent', twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: $onDelete), + $childHoldsKey, + ], + 'many-to-many' => [ + fn (RelationshipDeleteAction $onDelete): Relationship => Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: $onDelete), + $parentListsChild, + ], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAManyToManyDocumentByIdNeedsUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, $this->manyToManyLink()); + + $this->assertLinkRejected(fn () => $database->updateDocument('parent', 'parent1', new Document(['children' => ['readonly']]))); + + $this->assertSame([], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + $this->assertSame([], $this->relatedIds($database->getDocument('child', 'readonly'), 'parents')); + + $database->getAuthorization()->addRole(self::ADMIN); + + $database->updateDocument('parent', 'parent1', new Document(['children' => ['readonly']])); + $this->assertSame(['readonly'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAManyToManyDocumentGivenAsADocumentNeedsUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, $this->manyToManyLink()); + + $this->assertLinkRejected(fn () => $database->updateDocument('parent', 'parent1', new Document(['children' => [new Document(['$id' => 'readonly'])]]))); + + $this->assertSame([], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + $this->assertSame([], $this->relatedIds($database->getDocument('child', 'readonly'), 'parents')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAManyToManyDocumentThroughANestedUpdateNeedsUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, $this->manyToManyLink()); + + $this->assertLinkRejected(fn () => $database->updateDocument('grandparent', 'grandparent1', new Document([ + 'parent' => new Document(['$id' => 'parent1', 'children' => ['readonly']]), + ]))); + + $this->assertSame([], $this->relatedIds($database->getDocument('child', 'readonly'), 'parents')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAnExistingManyToManyDocumentThroughACreateNeedsUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, $this->manyToManyLink()); + + $this->assertLinkRejected(fn () => $database->createDocument('parent', new Document(['$id' => 'parent2', 'children' => ['readonly']]))); + $this->assertLinkRejected(fn () => $database->createDocument('parent', new Document(['$id' => 'parent3', 'children' => [new Document(['$id' => 'readonly'])]]))); + $this->assertLinkRejected(fn () => $database->createDocument('grandparent', new Document([ + '$id' => 'grandparent2', + 'parent' => new Document(['$id' => 'parent4', 'children' => ['readonly']]), + ]))); + + $this->assertSame(['parent1'], $this->ids($database, 'parent')); + $this->assertSame([], $this->relatedIds($database->getDocument('child', 'readonly'), 'parents')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCreatingAManyToManyDocumentThroughALinkNeedsNoUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, $this->manyToManyLink()); + + $database->updateDocument('parent', 'parent1', new Document(['children' => [new Document(['$id' => 'created'])]])); + $database->createDocument('parent', new Document(['$id' => 'parent2', 'children' => [new Document(['$id' => 'nested'])]])); + + $this->assertSame(['created'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + $this->assertSame(['nested'], $this->relatedIds($database->getDocument('parent', 'parent2'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testKeepingOrUnlinkingAManyToManyDocumentNeedsNoUpdatePermission(Closure $adapter): void + { + $database = $this->nestedLinkDatabase($adapter, $this->manyToManyLink()); + $database->getAuthorization()->skip(fn () => $database->updateDocument('parent', 'parent1', new Document(['children' => ['readonly']]))); + + $database->updateDocument('parent', 'parent1', new Document(['name' => 'kept', 'children' => ['readonly']])); + $this->assertSame(['readonly'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + + $database->updateDocument('parent', 'parent1', new Document(['children' => []])); + $this->assertSame([], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + $this->assertSame([], $this->relatedIds($database->getDocument('child', 'readonly'), 'parents')); + } + + private function manyToManyLink(): Relationship + { + return Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testANewNestedManyToManyDocumentKeepsItsOwnPermissions(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)); + + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => [Permission::read(Role::any())]])); + + $own = [Permission::read(Role::any()), Permission::update(Role::user('owner'))]; + $database->updateDocument('parent', 'parent1', new Document([ + 'children' => [new Document(['$id' => 'child1', '$permissions' => $own])], + ])); + + $child = $database->getAuthorization()->skip(fn () => $database->getDocument('child', 'child1')); + $this->assertSame($own, $child->getPermissions(), 'A nested many-to-many document created with its own permissions must keep them'); + $this->assertSame(['child1'], $this->relatedIds($database->getDocument('parent', 'parent1'), 'children')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testANewNestedManyToManyDocumentWithoutPermissionsTakesTheParentPermissions(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)); + + $parentPermissions = [Permission::read(Role::any()), Permission::update(Role::user('owner'))]; + $database->createDocument('parent', new Document(['$id' => 'parent1', '$permissions' => $parentPermissions])); + + $database->updateDocument('parent', 'parent1', new Document([ + 'children' => [new Document(['$id' => 'child1'])], + ])); + + $child = $database->getAuthorization()->skip(fn () => $database->getDocument('child', 'child1')); + $this->assertSame($parentPermissions, $child->getPermissions()); + } + + public function testNestedPathFiltersStayWithinTheQueryValueLimit(): void + { + $adapter = new class () extends Memory { + /** @var array */ + public array $largestValueCounts = []; + + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array + { + $this->largestValueCounts[$collection->getId()] = \max($this->largestValueCounts[$collection->getId()] ?? 0, $this->largestValueCount($queries)); + + return parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); + } + + public function forgetValueCounts(): void + { + $this->largestValueCounts = []; + } + + /** + * @param array $queries + */ + private function largestValueCount(array $queries): int + { + $largest = 0; + foreach ($queries as $query) { + if (! $query instanceof Query) { + continue; + } + $largest = \max($largest, $query->isNested() ? $this->largestValueCount($query->getValues()) : \count($query->getValues())); + } + + return $largest; + } + }; + + $database = $this->database(fn (): Adapter => $adapter); + $database->createCollection(Collection::create(id: 'parent', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'child', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'tag', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'label', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'owner', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship('parent', Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('child', Relationship::manyToMany(relatedCollection: 'tag', twoWay: true, key: 'tags', twoWayKey: 'children', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('tag', Relationship::oneToMany(relatedCollection: 'label', twoWay: true, key: 'labels', twoWayKey: 'tag', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('child', Relationship::manyToOne(relatedCollection: 'owner', twoWay: true, key: 'owner', twoWayKey: 'children', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship('parent', Relationship::manyToMany(relatedCollection: 'tag', twoWay: true, key: 'topics', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)); + + foreach (\range(1, 6) as $number) { + $name = $number === 6 ? 'other' : 'match'; + $database->createDocument('owner', new Document(['$id' => "owner{$number}", 'name' => $name])); + $database->createDocument('tag', new Document(['$id' => "tag{$number}"])); + $database->createDocument('label', new Document(['$id' => "label{$number}", 'name' => $name, 'tag' => "tag{$number}"])); + $database->createDocument('child', new Document(['$id' => "child{$number}", 'tags' => ["tag{$number}"], 'owner' => "owner{$number}"])); + $database->createDocument('parent', new Document(['$id' => "parent{$number}", 'children' => ["child{$number}"], 'topics' => ["tag{$number}"]])); + } + + $database->setMaxQueryValues(2); + + $matching = ['parent1', 'parent2', 'parent3', 'parent4', 'parent5']; + $filters = [ + 'children.tags.labels.name' => [['match'], $matching, ['child', 'junction', 'label']], + 'children.owner.name' => [['match'], $matching, ['child', 'owner']], + 'topics.labels.name' => [['match'], $matching, ['junction', 'label', 'tag']], + 'children.$id' => [['child1', 'child2'], ['parent1', 'parent2'], ['child']], + ]; + foreach ($filters as $path => [$values, $expected, $collections]) { + $adapter->forgetValueCounts(); + + $ids = \array_map(fn (Document $parent): string => $parent->getId(), $database->find('parent', [Query::equal($path, $values), Query::select(['$id'])])); + \sort($ids); + $this->assertSame($expected, $ids, "Filtering by {$path}"); + + unset($adapter->largestValueCounts['parent']); + $collectionsRead = \array_values(\array_unique(\array_map(fn (string $collection): string => \str_starts_with($collection, '_') ? 'junction' : $collection, \array_keys($adapter->largestValueCounts)))); + \sort($collectionsRead); + $this->assertSame($collections, $collectionsRead, "Filtering by {$path} reads only the collections on the path"); + foreach ($adapter->largestValueCounts as $collection => $largest) { + $this->assertLessThanOrEqual(2, $largest, "A read of {$collection} while filtering by {$path} carried {$largest} values"); + } + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCascadeWithOnlyADanglingJunctionRowDeletesTheParent(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::Cascade), + [Permission::create(Role::any()), Permission::read(Role::any())], + false, + ); + $junction = '_'.$database->getCollection('parent')->getSequence().'_'.$database->getCollection('child')->getSequence(); + + $database->getAuthorization()->skip(function () use ($database): void { + $database->createDocument('child', new Document(['$id' => 'child1'])); + $database->createDocument('parent', new Document(['$id' => 'parent1', 'children' => ['child1']])); + $database->skipRelationships(fn () => $database->deleteDocument('child', 'child1')); + }); + + $this->assertSame([], $this->ids($database, 'child')); + $this->assertCount(1, $this->ids($database, $junction), 'The junction row must outlive the child it points at'); + + $this->assertTrue($database->deleteDocument('parent', 'parent1')); + $this->assertSame([], $this->ids($database, 'parent')); + $this->assertSame([], $this->ids($database, $junction)); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testCreatingWithAListOnTheChildSideOfAOneWayOneToOneIsRejected(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::oneToOne(relatedCollection: 'child', key: 'partner', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)); + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + + try { + $database->createDocument('child', new Document(['$id' => 'child1', 'parent' => ['parent1']])); + $this->fail('A list on the child side of a one-way one-to-one must be rejected'); + } catch (RelationshipException $exception) { + $this->assertSame('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.', $exception->getMessage()); + } + + $this->assertSame([], $this->ids($database, 'child')); + } + + /** + * @return iterable, string, bool}> + */ + public static function invalidRelationshipUpdates(): iterable + { + $oneToOne = Relationship::oneToOne(relatedCollection: 'child', key: 'partner', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull); + $twoWayOneToOne = Relationship::oneToOne(relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull); + $oneToMany = Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull); + $manyToOne = Relationship::manyToOne(relatedCollection: 'child', twoWay: true, key: 'child', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull); + $manyToMany = Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull); + + $cases = [ + 'one-way one-to-one child side' => [$oneToOne, 'child', false, ['parent' => 'parent1'], 'Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.', false], + 'two-way one-to-one integer' => [$twoWayOneToOne, 'parent', false, ['partner' => 123], 'Invalid relationship value. Must be either a document, document ID or null.', false], + 'two-way one-to-one list' => [$twoWayOneToOne, 'parent', false, ['partner' => ['child1']], 'Invalid relationship value. Must be either a document, document ID or null.', false], + 'one-to-many list item' => [$oneToMany, 'parent', false, ['children' => [123]], 'Invalid relationship value. Must be either a document or document ID.', false], + 'many-to-many list item' => [$manyToMany, 'parent', false, ['children' => [123]], 'Invalid relationship value. Must be either a document or document ID.', false], + 'many-to-one document without id' => [$manyToOne, 'parent', false, ['child' => new Document(['name' => 'n'])], 'Invalid relationship value. Document must have a valid $id.', false], + 'many-to-one empty scalar' => [$manyToOne, 'parent', true, ['child' => false], 'Invalid relationship value. Must be either a document ID or a document.', false], + 'many-to-one scalar' => [$manyToOne, 'parent', false, ['child' => 123], 'Invalid relationship value.', false], + 'many-to-many bulk string' => [$manyToMany, 'parent', false, ['children' => 'child1'], 'Invalid relationship value. Must be an array of documents or document IDs.', true], + ]; + + foreach (self::adapters() as $adapterName => [$adapter]) { + foreach ($cases as $caseName => [$relationship, $collection, $linked, $update, $message, $bulk]) { + yield "{$adapterName}: {$caseName}" => [$adapter, $relationship, $collection, $linked, $update, $message, $bulk]; + } + } + } + + /** + * @param Closure(): Adapter $adapter + * @param array $update + */ + #[DataProvider('invalidRelationshipUpdates')] + public function testUpdateRejectsInvalidRelationshipValues(Closure $adapter, Relationship $relationship, string $collection, bool $linked, array $update, string $message, bool $bulk): void + { + $database = $this->database($adapter); + $this->relate($database, $relationship); + $database->createDocument('child', new Document(['$id' => 'child1'])); + $key = $relationship->key ?? $this->fail('an update case relates under a key'); + $database->createDocument('parent', new Document(['$id' => 'parent1', ...($linked ? [$key => 'child1'] : [])])); + + $id = $collection === 'parent' ? 'parent1' : 'child1'; + $stored = fn (): array => $database->getAuthorization()->skip(fn () => $database->skipRelationships(fn () => $database->getDocument($collection, $id)))->getArrayCopy(); + $before = $stored(); + + try { + if ($bulk) { + $database->updateDocuments($collection, new Document($update)); + } else { + $database->updateDocument($collection, $id, new Document($update)); + } + $this->fail('An invalid relationship value must be rejected'); + } catch (RelationshipException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + + $this->assertSame($before, $stored()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testSelectingNestedAttributesThroughTheChildSideOfAManyToOne(Closure $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'store', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'product', attributes: [Attribute::string(key: 'name', size: 64), Attribute::string(key: 'sku', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship('product', Relationship::manyToOne(relatedCollection: 'store', twoWay: true, key: 'store', twoWayKey: 'products', onDelete: RelationshipDeleteAction::SetNull)); + + $database->createDocument('store', new Document(['$id' => 'store1', 'name' => 'Store 1'])); + foreach (['product1', 'product2'] as $id) { + $database->createDocument('product', new Document(['$id' => $id, 'name' => "Name {$id}", 'sku' => "sku-{$id}", 'store' => 'store1'])); + } + + $stores = [ + 'getDocument' => $database->getDocument('store', 'store1', [Query::select(['*', 'products.name'])]), + 'findOne' => $database->findOne('store', [Query::select(['*', 'products.name'])]), + ]; + foreach ($stores as $read => $store) { + $this->assertSame('Store 1', $store->getAttribute('name'), $read); + $products = $store->getDocuments('products'); + $this->assertSame(['product1', 'product2'], $this->relatedIds($store, 'products'), $read); + foreach ($products as $product) { + $this->assertSame("Name {$product->getId()}", $product->getAttribute('name'), $read); + $this->assertFalse($product->offsetExists('sku'), "{$read} must return only the selected attribute of {$product->getId()}"); + $this->assertFalse($product->offsetExists('store'), "{$read} must not return the back-reference of {$product->getId()}"); + } + } + + $store = $database->getDocument('store', 'store1', [Query::select(['*', 'products.'])]); + $this->assertSame(['product1', 'product2'], $this->relatedIds($store, 'products')); + foreach ($store->getDocuments('products') as $product) { + $this->assertSame("sku-{$product->getId()}", $product->getAttribute('sku'), 'A trailing dot selects every attribute of the related documents'); + $this->assertSame("Name {$product->getId()}", $product->getAttribute('name')); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRelationshipFilterConversionEdgeCases(Closure $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(Collection::create(id: 'project', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(Collection::create(id: 'developer', attributes: [Attribute::string(key: 'devName', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship('project', Relationship::manyToMany(relatedCollection: 'developer', twoWay: true, key: 'developers', twoWayKey: 'projects', onDelete: RelationshipDeleteAction::SetNull)); + + foreach (['dev1' => 'Alice', 'dev2' => 'Bob', 'dev3' => 'Carol'] as $id => $name) { + $database->createDocument('developer', new Document(['$id' => $id, 'devName' => $name])); + } + $database->createDocument('project', new Document(['$id' => 'project1', 'developers' => ['dev1', 'dev2']])); + $database->createDocument('project', new Document(['$id' => 'project2', 'developers' => ['dev1', 'dev3']])); + + $projects = function (Query $query) use ($database): array { + $ids = \array_map(fn (Document $project): string => $project->getId(), $database->find('project', [$query])); + \sort($ids); + + return $ids; + }; + + $this->assertSame(['project1'], $projects(Query::containsAll('developers.$id', ['dev2']))); + $this->assertSame(['project2'], $projects(Query::containsAll('developers.$id', ['dev1', 'dev3']))); + $this->assertSame([], $projects(Query::containsAll('developers.$id', ['dev1', 'nobody'])), 'A value no related document matches leaves no project'); + $this->assertSame([], $projects(Query::containsAll('developers.$id', ['dev2', 'dev3'])), 'Values no single project holds together leave no project'); + $this->assertSame([], $projects(Query::equal('developers.devName', ['Nobody']))); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + return; + } + + try { + $database->find('project', [Query::equal('developers.unknownAttribute', ['x'])]); + $this->fail('A filter on an unknown related attribute must be rejected'); + } catch (QueryException $exception) { + $this->assertStringContainsString('unknownAttribute', $exception->getMessage()); + } + } + + /** + * @return iterable + */ + public static function manySideRelationships(): iterable + { + foreach (self::adapters() as $adapterName => [$adapter]) { + yield "{$adapterName}: one-to-many" => [$adapter, Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull)]; + yield "{$adapterName}: many-to-many" => [$adapter, Relationship::manyToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: RelationshipDeleteAction::SetNull)]; + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('manySideRelationships')] + public function testSetOperatorsDecideWhichDocumentsStayLinked(Closure $adapter, Relationship $relationship): void + { + $database = $this->database($adapter); + $this->relate($database, $relationship); + foreach (['child1', 'child2', 'child3'] as $id) { + $database->createDocument('child', new Document(['$id' => $id])); + } + $database->createDocument('parent', new Document(['$id' => 'parent1', 'children' => ['child1', 'child2', 'child3']])); + + $steps = [ + 'arrayUnique' => [Operator::arrayUnique(), ['child1', 'child2', 'child3']], + 'arrayFilter' => [Operator::arrayFilter('isNotNull'), ['child1', 'child2', 'child3']], + 'arrayIntersect' => [Operator::arrayIntersect(['child1', 'child2']), ['child1', 'child2']], + 'arrayDiff' => [Operator::arrayDiff(['child1']), ['child2']], + 'arrayInsert' => [Operator::arrayInsert(0, 'child3'), ['child2', 'child3']], + ]; + foreach ($steps as $step => [$operator, $expected]) { + $database->updateDocument('parent', 'parent1', new Document(['children' => $operator])); + + $this->assertSame($expected, $this->relatedIds($database->getDocument('parent', 'parent1'), 'children'), "After {$step}"); + foreach (['child1', 'child2', 'child3'] as $id) { + $linked = $relationship->type === RelationshipType::OneToMany + ? $database->getDocument('child', $id)->getDocument('parent')->getId() === 'parent1' + : $this->relatedIds($database->getDocument('child', $id), 'parents') === ['parent1']; + $this->assertSame(\in_array($id, $expected, true), $linked, "After {$step}, {$id} seen from its own side"); + } + } + } + + public function testLinkingAChildGrantedUpdateOnlyByItsOwnPermissionsWithoutThePermissionsHook(): void + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('relationship_hook') + ->setNamespace('relationship_hook_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + + $this->relate( + $database, + Relationship::oneToMany(relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: RelationshipDeleteAction::SetNull), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + + $database->createDocument('parent', new Document(['$id' => 'parent1'])); + $database->createDocument('child', new Document(['$id' => 'child1', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())]])); + + $database->updateDocument('parent', 'parent1', new Document(['children' => ['child1']])); + + $child = $database->skipRelationships(fn () => $database->getDocument('child', 'child1')); + $this->assertSame('parent1', $child->getAttribute('parent'), 'A child the caller may update through its own permissions must be linked'); + } +} diff --git a/tests/unit/Relationships/RelationshipRenameRecoveryTest.php b/tests/unit/Relationships/RelationshipRenameRecoveryTest.php new file mode 100644 index 0000000000..287bd2e251 --- /dev/null +++ b/tests/unit/Relationships/RelationshipRenameRecoveryTest.php @@ -0,0 +1,130 @@ +addRole(Role::any()->toString()); + + $this->pdo = new PDO('sqlite::memory:'); + $this->database = new Database(new SQLite($this->pdo), new Cache(new NoCache())); + $this->database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization($authorization); + $this->database->addHook(new Relationships()); + $this->database->create(); + + foreach (['books', 'authors'] as $collection) { + $this->database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string('name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); + } + } + + public function testAnOrphanColumnUnderTheNewKeyIsNotTakenForAnEarlierRename(): void + { + $this->database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', key: 'author')); + $this->database->createDocument('authors', new Document(['$id' => 'herbert', 'name' => 'Herbert'])); + $this->database->createDocument('books', new Document(['$id' => 'dune', 'name' => 'Dune', 'author' => 'herbert'])); + $this->addColumn('books', 'writer'); + + $this->assertRenameFails('books', 'author', new RelationshipUpdate(key: 'writer')); + + $this->assertSame(['author', 'name'], $this->attributeKeys('books')); + $author = $this->database->getDocument('books', 'dune')->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('herbert', $author->getId()); + } + + public function testATwoWayKeyRenameTheRelatedTableRejectsIsNotTakenForAnEarlierRename(): void + { + $this->database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'book')); + $this->addColumn('authors', 'novel'); + + $this->assertRenameFails('books', 'author', new RelationshipUpdate(twoWayKey: 'novel')); + + $this->assertSame(['book', 'name'], $this->attributeKeys('authors')); + } + + public function testARetryCompletesARenameThatMovedOnlyOneOfItsColumns(): void + { + $this->database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'book')); + $this->database->createDocument('authors', new Document(['$id' => 'herbert', 'name' => 'Herbert'])); + $this->database->createDocument('books', new Document(['$id' => 'dune', 'name' => 'Dune', 'author' => 'herbert'])); + $this->addColumn('authors', 'novel'); + $update = new RelationshipUpdate(key: 'writer', twoWayKey: 'novel'); + + $this->assertRenameFails('books', 'author', $update); + $this->assertSame(['author', 'name'], $this->attributeKeys('books')); + + $this->pdo->exec('ALTER TABLE `'.self::NAMESPACE.'_authors` DROP COLUMN `novel`'); + $this->database->updateRelationship('books', 'author', $update); + + $this->assertSame(['name', 'writer'], $this->attributeKeys('books')); + $this->assertSame(['name', 'novel'], $this->attributeKeys('authors')); + $writer = $this->database->getDocument('books', 'dune')->getAttribute('writer'); + $this->assertInstanceOf(Document::class, $writer); + $this->assertSame('herbert', $writer->getId()); + $novel = $this->database->getDocument('authors', 'herbert')->getAttribute('novel'); + $this->assertInstanceOf(Document::class, $novel); + $this->assertSame('dune', $novel->getId()); + } + + private function addColumn(string $collection, string $column): void + { + $this->pdo->exec('ALTER TABLE `'.self::NAMESPACE."_{$collection}` ADD COLUMN `{$column}` VARCHAR(255)"); + } + + private function assertRenameFails(string $collection, string $key, RelationshipUpdate $update): void + { + try { + $this->database->updateRelationship($collection, $key, $update); + } catch (Throwable) { + return; + } + + $this->fail('A rename the engine rejected was reported as done'); + } + + /** + * @return list + */ + private function attributeKeys(string $collection): array + { + $keys = \array_map(static fn (Attribute $attribute): string => $attribute->key, $this->database->getCollection($collection)->attributes()); + \sort($keys); + + return $keys; + } +} diff --git a/tests/unit/Relationships/RelationshipSchemaTest.php b/tests/unit/Relationships/RelationshipSchemaTest.php new file mode 100644 index 0000000000..326a9a81df --- /dev/null +++ b/tests/unit/Relationships/RelationshipSchemaTest.php @@ -0,0 +1,1114 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRenamingARelationshipWhoseIndexIsGoneKeepsTheOldKey(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $database->deleteIndex('books', '_index_author'); + + try { + $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $this->fail('a relationship whose index is gone cannot be renamed'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to update relationship indexes for 'author': Index not found", $error->getMessage()); + $this->assertInstanceOf(NotFoundException::class, $error->getPrevious()); + } + + $keys = $this->attributeKeys($database, 'books'); + $this->assertContains('author', $keys); + $this->assertNotContains('writer', $keys); + $this->assertSame('author', $this->relationship($database, 'authors', 'books')->twoWayKey); + } + + public function testIndexesOfTheMetadataCollectionCannotBeRenamed(): void + { + $database = $this->database(new Memory()); + $before = $this->indexKeys($database, Database::METADATA); + + try { + $database->renameIndex(Database::METADATA, '_key_title', 'renamed'); + $this->fail('the metadata collection\'s indexes must not be changed'); + } catch (NotFoundException $error) { + $this->assertSame('Index not found', $error->getMessage()); + } + + $this->assertSame($before, $this->indexKeys($database, Database::METADATA)); + + try { + $database->renameIndex('books', 'missing', 'renamed'); + $this->fail('an unknown index cannot be changed'); + } catch (NotFoundException $error) { + $this->assertSame('Index not found', $error->getMessage()); + } + } + + public function testAFailedDefinitionUpdateReversesTheSchemaRename(): void + { + $renames = []; + $failure = new RuntimeException('the related definition could not be written'); + $adapter = $this->memory([ + 'updateRelationship' => static function (Relationship $relationship, ?string $newKey, ?string $newTwoWayKey) use (&$renames): ?bool { + $renames[] = "{$relationship->key}->{$newKey}"; + + return null; + }, + ]); + $database = $this->intercepting($adapter, attributeMeta: static function (string $collection, string $id) use ($failure): void { + if ($collection === 'authors' && $id === 'books') { + throw $failure; + } + }); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + try { + $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $this->fail('a failed definition update must fail the rename'); + } catch (DatabaseException $error) { + $this->assertSame($failure, $error->getPrevious()); + } + + $this->assertSame(['author->writer', 'writer->author'], $renames); + $this->assertContains('author', $this->attributeKeys($database, 'books'), 'the definition that was written is restored with the schema'); + $this->assertNotContains('writer', $this->attributeKeys($database, 'books')); + $this->assertSame('books', $this->relationship($database, 'books', 'author')->twoWayKey); + $this->assertSame('author', $this->relationship($database, 'authors', 'books')->twoWayKey); + } + + public function testAFailedJunctionDefinitionUpdateRestoresBothSides(): void + { + $failure = new RuntimeException('the junction definition could not be written'); + $database = $this->intercepting(new Memory(), attributeMeta: static function (string $collection, string $id) use ($failure): void { + if (\str_starts_with($collection, '_') && $id === 'writers') { + throw $failure; + } + }); + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + + try { + $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $this->fail('a failed junction definition update must fail the rename'); + } catch (DatabaseException $error) { + $this->assertSame($failure, $error->getPrevious()); + } + + $this->assertContains('writers', $this->attributeKeys($database, 'books')); + $this->assertNotContains('authors_of', $this->attributeKeys($database, 'books')); + $this->assertContains('works', $this->attributeKeys($database, 'authors')); + $this->assertNotContains('written', $this->attributeKeys($database, 'authors')); + $this->assertSame('works', $this->relationship($database, 'books', 'writers')->twoWayKey); + $this->assertSame('writers', $this->relationship($database, 'authors', 'works')->twoWayKey); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAFailedSecondIndexRenameReversesTheFirstAndTheDefinitions(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', twoWay: true, key: 'library', twoWayKey: 'owner')); + $database->deleteIndex('authors', '_index_owner'); + $physical = $this->schemaIndexIds($database, 'books'); + + try { + $database->updateRelationship('books', 'library', new RelationshipUpdate(key: 'shelf', twoWayKey: 'keeper')); + $this->fail('a rename whose second index is gone must fail'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to update relationship indexes for 'library': Index not found", $error->getMessage()); + } + + $this->assertSame(['library'], $this->indexAttributes($database, 'books', '_index_library')); + $this->assertNull($this->index($database, 'books', '_index_shelf')); + $this->assertContains('library', $this->attributeKeys($database, 'books')); + $this->assertNotContains('shelf', $this->attributeKeys($database, 'books')); + $this->assertContains('owner', $this->attributeKeys($database, 'authors')); + $this->assertNotContains('keeper', $this->attributeKeys($database, 'authors')); + $this->assertSame($physical, $this->schemaIndexIds($database, 'books'), 'the physical index is back under its old name'); + } + + public function testRelationshipSchemaChangesNeedTheRelationshipsFeature(): void + { + $database = new Database($this->createStub(Adapter::class), new Cache(new None())); + + foreach ([ + static fn (): Relationship => $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors')), + static fn (): Relationship => $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')), + static fn () => $database->deleteRelationship('books', 'author'), + ] as $change) { + try { + $change(); + $this->fail('an adapter without relationships must refuse the change'); + } catch (DatabaseException $error) { + $this->assertSame('Adapter does not support relationships', $error->getMessage()); + } + } + } + + public function testAnAdapterThatDoesNotCreateTheRelationshipFailsTheCreate(): void + { + $adapter = $this->memory([ + 'createRelationship' => static fn (): bool => false, + 'deleteCollection' => static fn (string $id): never => throw new RuntimeException("cannot drop {$id}"), + ]); + $database = $this->database($adapter); + + $error = null; + $log = StderrCapture::during(function () use ($database, &$error): void { + try { + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + } catch (DatabaseException $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(RefusedException::class, $error); + $this->assertSame('Failed to create relationship', $error->getMessage()); + $this->assertStringContainsString('Failed to cleanup junction collection', $log); + $this->assertNotContains('writers', $this->attributeKeys($database, 'books')); + $this->assertNotContains('works', $this->attributeKeys($database, 'authors')); + } + + public function testARelationshipOnlyInTheSchemaIsAdopted(): void + { + $database = $this->database($this->memory([ + 'createRelationship' => static fn (): never => throw new DuplicateException('Relationship already exists in the schema'), + ])); + + $this->assertSame('author', $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books'))->key); + $this->assertContains('author', $this->attributeKeys($database, 'books')); + $this->assertContains('books', $this->attributeKeys($database, 'authors')); + + $database->createDocument('authors', new Document([Document::ID => 'ada', 'name' => 'Ada'])); + $database->createDocument('books', new Document([Document::ID => 'notes', 'title' => 'Notes', 'author' => 'ada'])); + $author = $database->getDocument('books', 'notes')->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author); + $this->assertSame('ada', $author->getId()); + } + + public function testAFailedDefinitionWriteRollsBackAndLogsTheFailedCleanups(): void + { + $adapter = $this->memory([ + 'deleteRelationship' => static fn (): never => throw new RuntimeException('cannot drop the relationship'), + 'deleteCollection' => static fn (string $id): never => throw new RuntimeException("cannot drop {$id}"), + ]); + $database = $this->intercepting($adapter, update: static function (string $collection, string $id, Document $document): void { + if ($collection === Database::METADATA && $id === 'books' && \in_array('writers', self::keysOf($document), true)) { + throw new RuntimeException('the definition could not be written'); + } + }); + + $error = null; + $log = StderrCapture::during(function () use ($database, &$error): void { + try { + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + } catch (DatabaseException $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertStringStartsWith('Failed to create relationship: ', $error->getMessage()); + $this->assertStringContainsString("Failed to cleanup relationship 'writers': ", $log); + $this->assertStringContainsString('Failed to cleanup junction collection', $log); + $this->assertNotContains('writers', $this->attributeKeys($database, 'books')); + } + + public function testAFailedIndexRollsBackAndLogsTheFailedIndexAndDefinitionCleanups(): void + { + $adapter = $this->memory([ + 'createIndex' => static fn (string $collection, Index $index): ?bool => $index->key === '_index_owner' ? throw new RuntimeException('cannot index the owner') : null, + 'deleteIndex' => static fn (string $collection, string $id): never => throw new RuntimeException("cannot drop {$id}"), + ]); + /** @var bool $armed */ + $armed = false; + $database = $this->intercepting($adapter, update: static function (string $collection, string $id, Document $document) use (&$armed): void { + if ($armed && $collection === Database::METADATA && $id === 'books' && ! \in_array('library', self::keysOf($document), true)) { + throw new RuntimeException('the definitions could not be removed'); + } + }); + $armed = true; + + $error = null; + $log = StderrCapture::during(function () use ($database, &$error): void { + try { + $database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', twoWay: true, key: 'library', twoWayKey: 'owner')); + } catch (DatabaseException $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame('Failed to create relationship indexes: cannot index the owner', $error->getMessage()); + $this->assertStringContainsString("Failed to cleanup index '_index_library'", $log); + $this->assertStringContainsString("Failed to cleanup metadata for relationship 'library'", $log); + } + + public function testAFailedIndexWhoseRelationshipCleanupFailsIsLogged(): void + { + $database = $this->database($this->memory([ + 'createIndex' => static fn (string $collection, Index $index): ?bool => $index->key === '_index_owner' ? throw new RuntimeException('cannot index the owner') : null, + 'deleteRelationship' => static fn (): never => throw new RuntimeException('cannot drop the relationship'), + ])); + + $error = null; + $log = StderrCapture::during(function () use ($database, &$error): void { + try { + $database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', twoWay: true, key: 'library', twoWayKey: 'owner')); + } catch (DatabaseException $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertSame('Failed to create relationship indexes: cannot index the owner', $error->getMessage()); + $this->assertStringContainsString("Failed to cleanup relationship 'library': ", $log); + $this->assertNotContains('library', $this->attributeKeys($database, 'books')); + $this->assertNotContains('owner', $this->attributeKeys($database, 'authors')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAnUpdateWithoutChangesIsAcceptedAndAnUnknownRelationshipIsNotFound(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $before = $this->relationship($database, 'books', 'author'); + + $this->assertSame($before->toDocument()->getArrayCopy(), $database->updateRelationship('books', 'author', new RelationshipUpdate())->toDocument()->getArrayCopy()); + $this->assertSame($before->toDocument()->getArrayCopy(), $this->relationship($database, 'books', 'author')->toDocument()->getArrayCopy()); + + try { + $database->updateRelationship('books', 'missing', new RelationshipUpdate(key: 'other')); + $this->fail('an unknown relationship cannot be updated'); + } catch (NotFoundException $error) { + $this->assertSame('Relationship not found', $error->getMessage()); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRenamingFromTheChildSideOfAOneToManyRenamesItsIndex(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('authors', Relationship::oneToMany(relatedCollection: 'books', twoWay: true, key: 'books', twoWayKey: 'author')); + $database->createDocument('authors', new Document([Document::ID => 'ada', 'name' => 'Ada'])); + $database->createDocument('books', new Document([Document::ID => 'notes', 'title' => 'Notes', 'author' => 'ada'])); + + $this->assertSame('writer', $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer'))->key); + + $this->assertSame(['writer'], $this->indexAttributes($database, 'books', '_index_writer')); + $this->assertNull($this->index($database, 'books', '_index_author')); + $this->assertSame(['notes'], \array_map( + static fn (Document $book): string => $book->getId(), + $database->find('books', [Query::equal('writer', ['ada'])]), + )); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testRenamingTheParentKeyFromTheChildSideOfAManyToOneRenamesItsIndex(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + $this->assertSame('writer', $database->updateRelationship('authors', 'books', new RelationshipUpdate(twoWayKey: 'writer'))->twoWayKey); + + $this->assertSame(['writer'], $this->indexAttributes($database, 'books', '_index_writer')); + $this->assertNull($this->index($database, 'books', '_index_author')); + $this->assertSame('writer', $this->relationship($database, 'authors', 'books')->twoWayKey); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAnAdapterThatDoesNotUpdateTheRelationshipRefusesTheUpdate(Closure $adapter): void + { + $database = $this->database($this->refusingUpdates($adapter())); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + try { + $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $this->fail('an adapter that does not update the relationship must refuse the update'); + } catch (RefusedException $error) { + $this->assertSame("Failed to update relationship 'author'", $error->getMessage()); + $this->assertNull($error->getPrevious()); + } + + $this->assertContains('author', $this->attributeKeys($database, 'books')); + $this->assertNotContains('writer', $this->attributeKeys($database, 'books')); + } + + public function testAnUpdateTheAdapterFailsIsWrappedWithItsCause(): void + { + $cause = new RuntimeException('the engine failed the rename'); + $database = $this->database($this->memory(['updateRelationship' => static fn (): never => throw $cause])); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + try { + $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $this->fail('an update the adapter fails must be reported'); + } catch (DatabaseException $error) { + $this->assertNotInstanceOf(RefusedException::class, $error); + $this->assertSame("Failed to update relationship 'author': the engine failed the rename", $error->getMessage()); + $this->assertSame($cause, $error->getPrevious()); + } + + $this->assertContains('author', $this->attributeKeys($database, 'books')); + $this->assertNotContains('writer', $this->attributeKeys($database, 'books')); + } + + /** + * @return array + */ + public static function typedUpdateFailures(): array + { + return [ + 'not found' => [new NotFoundException('Attribute not found')], + 'duplicate' => [new DuplicateException('Attribute already exists')], + ]; + } + + #[DataProvider('typedUpdateFailures')] + public function testATypedUpdateFailureIsTheAdaptersOwn(\Throwable $failure): void + { + $database = $this->database($this->memory(['updateRelationship' => static fn (): never => throw $failure])); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + try { + $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $this->fail('an update the adapter fails must be reported'); + } catch (\Throwable $error) { + $this->assertSame($failure, $error); + } + + $this->assertContains('author', $this->attributeKeys($database, 'books')); + $this->assertNotContains('writer', $this->attributeKeys($database, 'books')); + } + + public function testARenameTheSchemaAlreadyAppliedIsCompleted(): void + { + $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + { + parent::updateRelationship($collection, $relationship, $side, $update); + + throw new RuntimeException('the connection dropped after the rename'); + } + }; + $database = $this->database($adapter); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + $this->assertSame('writer', $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer'))->key); + + $this->assertContains('writer', $this->attributeKeys($database, 'books')); + $this->assertSame('writer', $this->relationship($database, 'authors', 'books')->twoWayKey); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAFailedJunctionIndexRenameRestoresTheJunctionDefinitions(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + $junction = $this->junction($database); + $database->deleteIndex($junction, '_index_writers'); + + try { + $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $this->fail('a rename whose junction index is gone must fail'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to update relationship indexes for 'writers': Index not found", $error->getMessage()); + } + + $this->assertEqualsCanonicalizing(['writers', 'works'], $this->attributeKeys($database, $junction)); + $this->assertContains('writers', $this->attributeKeys($database, 'books')); + $this->assertContains('works', $this->attributeKeys($database, 'authors')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testARelationshipWhoseColumnIsGoneIsStillDeleted(Closure $adapter): void + { + $inner = $adapter(); + $database = $this->database($this->missingRelationships($inner)); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + $database->deleteRelationship('books', 'author'); + + $this->assertNotContains('author', $this->attributeKeys($database, 'books')); + $this->assertNotContains('books', $this->attributeKeys($database, 'authors')); + } + + /** + * @return array}> + */ + public static function failedRelationshipDrops(): array + { + $refuse = static fn (): bool => false; + $fail = static fn (): never => throw new RuntimeException('the engine failed the drop'); + + return [ + 'memory refusal' => [static fn (): Adapter => new Memory(), $refuse, RefusedException::class], + 'memory error' => [static fn (): Adapter => new Memory(), $fail, RuntimeException::class], + 'sqlite refusal' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:')), $refuse, RefusedException::class], + 'sqlite error' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:')), $fail, RuntimeException::class], + ]; + } + + /** + * @param Closure(): Adapter $adapter + * @param Closure(): bool $drop + * @param class-string<\Throwable> $expected + */ + #[DataProvider('failedRelationshipDrops')] + public function testADropTheAdapterDoesNotMakeKeepsTheRelationshipsIndexes(Closure $adapter, Closure $drop, string $expected): void + { + $database = $this->database($this->droppingRelationships($adapter(), $drop)); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $schemaIndexes = $this->schemaIndexIds($database, 'books'); + + try { + $database->deleteRelationship('books', 'author'); + $this->fail('a drop the adapter does not make must fail the delete'); + } catch (\Throwable $error) { + $this->assertInstanceOf($expected, $error); + } + + $this->assertContains('author', $this->attributeKeys($database, 'books')); + $this->assertContains('books', $this->attributeKeys($database, 'authors')); + $this->assertSame(['author'], $this->indexAttributes($database, 'books', '_index_author')); + $this->assertSame($schemaIndexes, $this->schemaIndexIds($database, 'books')); + } + + /** + * @param Closure(): Adapter $adapter + * @param Closure(): bool $drop + * @param class-string<\Throwable> $expected + */ + #[DataProvider('failedRelationshipDrops')] + public function testRestoringTheIndexesOfAFailedDropFiresNoIndexEvents(Closure $adapter, Closure $drop, string $expected): void + { + $database = $this->database($this->droppingRelationships($adapter(), $drop)); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + try { + $database->deleteRelationship('books', 'author'); + $this->fail('a drop the adapter does not make must fail the delete'); + } catch (\Throwable $error) { + $this->assertInstanceOf($expected, $error); + } + + $this->assertSame([], $recorder->received(Event::IndexCreate), 'The restore fired an index_create with no matching index_delete'); + } + + /** + * @param Closure(): Adapter $adapter + * @param Closure(): bool $drop + * @param class-string<\Throwable> $expected + */ + #[DataProvider('failedRelationshipDrops')] + public function testADropTheAdapterDoesNotMakeKeepsTheJunctionDefinition(Closure $adapter, Closure $drop, string $expected): void + { + $database = $this->database($this->droppingRelationships($adapter(), $drop)); + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + $junction = $this->junction($database); + + try { + $database->deleteRelationship('books', 'writers'); + $this->fail('a drop the adapter does not make must fail the delete'); + } catch (\Throwable $error) { + $this->assertInstanceOf($expected, $error); + } + + $this->assertContains('writers', $this->attributeKeys($database, 'books')); + $this->assertEqualsCanonicalizing(['writers', 'works'], $this->attributeKeys($database, $junction)); + } + + public function testAFailedDefinitionWriteOnDeleteKeepsItsErrorWhenTheRollbackFails(): void + { + $failure = new RuntimeException('the definitions could not be written'); + /** @var bool $armed */ + $armed = false; + $adapter = $this->memory([ + 'createRelationship' => static function () use (&$armed): ?bool { + if ($armed) { + throw new RuntimeException('the relationship could not be recreated'); + } + + return null; + }, + ]); + $database = $this->intercepting($adapter, update: static function (string $collection, string $id, Document $document) use (&$armed, $failure): void { + if ($armed && $collection === Database::METADATA && $id === 'books' && ! \in_array('writers', self::keysOf($document), true)) { + throw $failure; + } + }, create: static function (string $collection) use (&$armed): void { + if ($armed && $collection === Database::METADATA) { + throw new RuntimeException('the junction definition could not be restored'); + } + }); + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + $armed = true; + + try { + $database->deleteRelationship('books', 'writers'); + $this->fail('a failed definition write must fail the delete'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to persist metadata after retries for relationship deletion 'writers': the definitions could not be written", $error->getMessage()); + $this->assertSame($failure, $error->getPrevious()); + } + + /** @var bool $armed */ + $armed = false; + $this->assertContains('writers', $this->attributeKeys($database, 'books')); + } + + public function testAFailedDefinitionWriteOnDeleteKeepsItsErrorWhenTheIndexesCannotBeRestored(): void + { + $failure = new RuntimeException('the definitions could not be written'); + /** @var bool $armed */ + $armed = false; + $adapter = $this->memory([ + 'createIndex' => static function (string $collection, Index $index) use (&$armed): ?bool { + if ($armed) { + throw new RuntimeException('the index could not be restored'); + } + + return null; + }, + ]); + $database = $this->intercepting($adapter, update: static function (string $collection, string $id, Document $document) use (&$armed, $failure): void { + if ($armed && $collection === Database::METADATA && $id === 'books' && ! \in_array('author', self::keysOf($document), true)) { + throw $failure; + } + }); + $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $armed = true; + + try { + $database->deleteRelationship('books', 'author'); + $this->fail('a failed definition write must fail the delete'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to persist metadata after retries for relationship deletion 'author': the definitions could not be written", $error->getMessage()); + $this->assertSame($failure, $error->getPrevious()); + } + + /** @var bool $armed */ + $armed = false; + $this->assertContains('author', $this->attributeKeys($database, 'books')); + } + + public function testAFailedJunctionPurgeRestoresEveryDefinitionItCanAndReversesTheSchemaRename(): void + { + $renames = []; + $adapter = $this->memory([ + 'updateRelationship' => static function (Relationship $relationship, ?string $newKey) use (&$renames): ?bool { + $renames[] = "{$relationship->key}->{$newKey}"; + + return \count($renames) > 1 ? throw new RuntimeException('the schema rename could not be reversed') : null; + }, + ]); + $failure = new RuntimeException('the junction cache could not be purged'); + $database = new class ($adapter, new Cache(new None()), $failure) extends Database { + public bool $armed = false; + + private int $junctionPurges = 0; + + public function __construct(Adapter $adapter, Cache $cache, private readonly RuntimeException $failure) + { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function purgeCachedCollection(string $collection): void + { + if ($this->armed && \str_starts_with($collection, '_') && \in_array(++$this->junctionPurges, [3, 4, 5], true)) { + throw $this->failure; + } + + parent::purgeCachedCollection($collection); + } + + #[\Override] + public function updateDocument(string $collection, string $id, Document $document): Document + { + if ($this->armed && $collection === self::METADATA && $id === 'books' && RelationshipSchemaTest::replacedAttribute($this, $id, $document) === 'authors_of') { + throw new RuntimeException('the definition could not be restored'); + } + + return parent::updateDocument($collection, $id, $document); + } + }; + $this->prepare($database); + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + $junction = $this->junction($database); + $database->armed = true; + + try { + $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $this->fail('a failed junction purge must fail the rename'); + } catch (RuntimeException $error) { + $this->assertSame($failure, $error); + } + + $database->armed = false; + $this->assertSame(['writers->authors_of', 'authors_of->writers'], $renames, 'the schema rename is reversed, and its failure is not reported over the purge failure'); + $this->assertEqualsCanonicalizing(['writers', 'works'], $this->attributeKeys($database, $junction)); + $this->assertContains('works', $this->attributeKeys($database, 'authors')); + $this->assertNotContains('written', $this->attributeKeys($database, 'authors')); + $this->assertContains('authors_of', $this->attributeKeys($database, 'books'), 'the one restore that failed leaves its definition; the others still run'); + } + + public function testAFailedJunctionIndexRenameKeepsItsErrorWhenEveryRollbackStepFails(): void + { + $renames = []; + $adapter = $this->memory([ + 'updateRelationship' => static function (Relationship $relationship, ?string $newKey) use (&$renames): ?bool { + $renames[] = "{$relationship->key}->{$newKey}"; + + return \count($renames) > 1 ? throw new RuntimeException('the schema rename could not be reversed') : null; + }, + ]); + $database = new class ($adapter, new Cache(new None())) extends Database { + public bool $armed = false; + + /** @var list */ + public array $rollbacks = []; + + #[\Override] + public function renameIndex(string $collection, string $old, string $new): void + { + if ($this->armed && $new === '_index_writers') { + $this->rollbacks[] = "index {$old}->{$new}"; + + throw new RuntimeException('the index rename could not be reversed'); + } + + parent::renameIndex($collection, $old, $new); + } + + #[\Override] + public function updateDocument(string $collection, string $id, Document $document): Document + { + $replaced = $this->armed && $collection === self::METADATA ? RelationshipSchemaTest::replacedAttribute($this, $id, $document) : null; + if ($replaced !== null && \in_array($replaced, ['authors_of', 'written'], true)) { + $step = (\str_starts_with($id, '_') ? 'junction' : $id).' '.$replaced; + if (\end($this->rollbacks) !== $step) { + $this->rollbacks[] = $step; + } + + throw new RuntimeException('the definition could not be restored'); + } + + return parent::updateDocument($collection, $id, $document); + } + }; + $this->prepare($database); + $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + $database->deleteIndex($this->junction($database), '_index_works'); + $database->armed = true; + + try { + $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $this->fail('a rename whose second junction index is gone must fail'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to update relationship indexes for 'writers': Index not found", $error->getMessage()); + $this->assertInstanceOf(NotFoundException::class, $error->getPrevious()); + } + + $this->assertSame(['writers->authors_of', 'authors_of->writers'], $renames); + $this->assertSame([ + 'index _index_authors_of->_index_writers', + 'books authors_of', + 'authors written', + 'junction authors_of', + 'junction written', + ], $database->rollbacks, 'every rollback step runs although each one fails'); + } + + /** + * @return list + */ + private function schemaIndexIds(Database $database, string $collection): array + { + $ids = \array_map(static fn (SchemaIndex $index): string => $index->name, $database->getSchemaIndexes($collection)); + \sort($ids); + + return $ids; + } + + /** + * @return list + */ + private function attributeKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $this->attributes($database, $collection), + ); + } + + private function relationship(Database $database, string $collection, string $key): Relationship + { + foreach ($this->attributes($database, $collection) as $attribute) { + if ($attribute->key === $key && $attribute->relationship !== null) { + return $attribute->relationship; + } + } + + $this->fail("{$collection} has no relationship {$key}"); + } + + /** + * The key of the one stored attribute $definition replaces in the stored definition of $collection, or null when + * the write adds, removes or changes no attribute. + */ + public static function replacedAttribute(Database $database, string $collection, Document $definition): ?string + { + $stored = self::attributeDefinitions($database->silent(fn (): Document => $database->getDocument(Database::METADATA, $collection))); + $written = self::attributeDefinitions($definition); + + if (\count($stored) !== \count($written)) { + return null; + } + + foreach ($stored as $key => $attribute) { + if (($written[$key] ?? null) !== $attribute) { + return $key; + } + } + + return null; + } + + /** + * @return array> + */ + private static function attributeDefinitions(Document $definition): array + { + $definitions = []; + foreach (Collection::fromDocument($definition)->attributes() as $attribute) { + $definitions[$attribute->key] = $attribute->toDocument()->getArrayCopy(); + } + + return $definitions; + } + + /** + * @return list + */ + private function attributes(Database $database, string $collection): array + { + return $database->getCollection($collection)->attributes(); + } + + private function database(Adapter $adapter): Database + { + return $this->prepare(new Database($adapter, new Cache(new None()))); + } + + private function prepare(Database $database): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database + ->setAuthorization($authorization) + ->setDatabase('relationship_schema') + ->setNamespace('relationship_schema_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $database->createCollection(Collection::create(id: 'books', attributes: [Attribute::string(key: 'title', size: 64)], permissions: $permissions)); + $database->createCollection(Collection::create(id: 'authors', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions)); + + return $database; + } + + /** + * @param array $overrides + */ + private function memory(array $overrides): Memory + { + return new class ($overrides) extends Memory { + /** + * @param array $overrides + */ + public function __construct(private readonly array $overrides) + { + parent::__construct(); + } + + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool + { + return $this->intercept(__FUNCTION__, [$relationship]) ?? parent::createRelationship($collection, $relationship); + } + + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + { + return $this->intercept(__FUNCTION__, [$relationship, $update->key, $update->twoWayKey]) ?? parent::updateRelationship($collection, $relationship, $side, $update); + } + + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + return $this->intercept(__FUNCTION__, [$relationship]) ?? parent::deleteRelationship($collection, $relationship, $side); + } + + #[\Override] + public function deleteCollection(string $id): bool + { + if (! \str_starts_with($id, '_')) { + return parent::deleteCollection($id); + } + + return $this->intercept(__FUNCTION__, [$id]) ?? parent::deleteCollection($id); + } + + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool + { + return $this->intercept(__FUNCTION__, [$collection, $index]) ?? parent::createIndex($collection, $index, $indexAttributeTypes, $collation); + } + + #[\Override] + public function deleteIndex(string $collection, string $id): bool + { + return $this->intercept(__FUNCTION__, [$collection, $id]) ?? parent::deleteIndex($collection, $id); + } + + /** + * @param list $arguments + */ + private function intercept(string $method, array $arguments): ?bool + { + $override = $this->overrides[$method] ?? null; + $result = $override === null ? null : $override(...$arguments); + + return \is_bool($result) ? $result : null; + } + }; + } + + /** + * @param (Closure(string, string, Document): void)|null $update + * @param (Closure(string, string): void)|null $attributeMeta + * @param (Closure(string): void)|null $create + */ + private function intercepting(Adapter $adapter, ?Closure $update = null, ?Closure $attributeMeta = null, ?Closure $create = null): Database + { + $database = new class ($adapter, new Cache(new None()), $update, $attributeMeta, $create) extends Database { + public function __construct( + Adapter $adapter, + Cache $cache, + private readonly ?Closure $update, + private readonly ?Closure $attributeMeta, + private readonly ?Closure $create, + ) { + parent::__construct($adapter, $cache); + } + + #[\Override] + public function updateDocument(string $collection, string $id, Document $document): Document + { + if ($this->update !== null) { + ($this->update)($collection, $id, $document); + } + + $attributeMeta = $this->attributeMeta; + $replaced = $attributeMeta === null || $collection !== self::METADATA ? null : RelationshipSchemaTest::replacedAttribute($this, $id, $document); + if ($attributeMeta !== null && $replaced !== null) { + $attributeMeta($id, $replaced); + } + + return parent::updateDocument($collection, $id, $document); + } + + #[\Override] + public function createDocument(string $collection, Document $document): Document + { + if ($this->create !== null) { + ($this->create)($collection); + } + + return parent::createDocument($collection, $document); + } + }; + + return $this->prepare($database); + } + + private function index(Database $database, string $collection, string $key): ?Index + { + foreach ($database->getCollection($collection)->indexes() as $index) { + if ($index->key === $key) { + return $index; + } + } + + return null; + } + + /** + * @return array + */ + private function indexAttributes(Database $database, string $collection, string $key): array + { + $index = $this->index($database, $collection, $key); + $this->assertNotNull($index, "{$collection} has no index {$key}"); + + return $index->attributes; + } + + /** + * @return list + */ + private function indexKeys(Database $database, string $collection): array + { + return \array_map(static fn (Index $index): string => $index->key, $database->getCollection($collection)->indexes()); + } + + /** + * @return list + */ + private static function keysOf(Document $document): array + { + /** @var array> $attributes */ + $attributes = $document->getAttribute('attributes', []); + $keys = []; + foreach ($attributes as $attribute) { + $key = $attribute instanceof Document ? $attribute->getAttribute('key', $attribute->getId()) : ($attribute['key'] ?? $attribute[Document::ID] ?? ''); + $keys[] = \is_string($key) ? $key : ''; + } + + return $keys; + } + + private function junction(Database $database): string + { + return '_'.$database->getCollection('books')->getSequence().'_'.$database->getCollection('authors')->getSequence(); + } + + /** + * @param Closure(): bool $drop + */ + private function droppingRelationships(Adapter $adapter, Closure $drop): Adapter + { + if ($adapter instanceof SQLite) { + return new class (new PDO('sqlite::memory:'), $drop) extends SQLite { + /** + * @param Closure(): bool $drop + */ + public function __construct(PDO $pdo, private readonly Closure $drop) + { + parent::__construct($pdo); + } + + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + return ($this->drop)(); + } + }; + } + + return $this->memory(['deleteRelationship' => $drop]); + } + + private function refusingUpdates(Adapter $adapter): Adapter + { + if ($adapter instanceof SQLite) { + return new class (new PDO('sqlite::memory:')) extends SQLite { + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + { + return false; + } + }; + } + + return $this->memory(['updateRelationship' => static fn (): bool => false]); + } + + private function missingRelationships(Adapter $adapter): Adapter + { + if ($adapter instanceof SQLite) { + return new class (new PDO('sqlite::memory:')) extends SQLite { + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + throw new NotFoundException('Relationship not found in the schema'); + } + }; + } + + return $this->memory(['deleteRelationship' => static fn (): never => throw new NotFoundException('Relationship not found in the schema')]); + } +} diff --git a/tests/unit/Relationships/RelationshipValidationTest.php b/tests/unit/Relationships/RelationshipValidationTest.php new file mode 100644 index 0000000000..b78cec9053 --- /dev/null +++ b/tests/unit/Relationships/RelationshipValidationTest.php @@ -0,0 +1,766 @@ + Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $attributes + * @param array $permissions + */ + private function makeCollection(string $id, array $attributes = [], array $permissions = []): Document + { + if (empty($permissions)) { + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + return new Document([ + '$id' => $id, + '$sequence' => $id, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => $permissions, + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $collections + * @param array $documents keyed by "collectionId:docId" + */ + private function buildDatabase(array $collections, array $documents = [], bool $withRelationshipHook = false): Database + { + $sequence = 1; + foreach ($documents as $document) { + if ($document->getSequence() === null) { + $document->setAttribute('$sequence', $sequence++); + } + } + + $adapter = self::createStub(RelationshipsAdapter::class); + $adapter->method('hasFeature')->willReturnCallback( + static fn (string $feature): bool => is_a(RelationshipsAdapter::class, $feature, true) + ); + $adapter->method('hasSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('isTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::Integer, + keywords: [], + internalIndexKeys: [], + )); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Operators, + ]); + }); + $adapter->method('startTransaction')->willReturn(true); + $adapter->method('commitTransaction')->willReturn(true); + $adapter->method('rollbackTransaction')->willReturn(true); + $adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $adapter->method('createDocument')->willReturnArgument(1); + $adapter->method('updateDocument')->willReturnArgument(2); + $adapter->method('createRelationship')->willReturn(true); + $adapter->method('deleteRelationship')->willReturn(true); + $adapter->method('updateRelationship')->willReturn(true); + $adapter->method('createIndex')->willReturn(true); + $adapter->method('deleteIndex')->willReturn(true); + $adapter->method('renameIndex')->willReturn(true); + $adapter->method('getSequences')->willReturnArgument(1); + + $meta = $this->metaCollection(); + $colMap = []; + foreach ($collections as $col) { + $colMap[$col->getId()] = $col; + } + + $adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($meta, $colMap, $documents) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + if ($col->getId() === Database::METADATA && isset($colMap[$docId])) { + return $colMap[$docId]; + } + $key = $col->getId() . ':' . $docId; + if (isset($documents[$key])) { + return $documents[$key]; + } + + return new Document(); + } + ); + + $cache = new Cache(new None()); + $database = new Database($adapter, $cache); + $database->getAuthorization()->addRole(Role::any()->toString()); + + if ($withRelationshipHook) { + $database->addHook(new Relationships()); + } + + return $database; + } + + public function testStructureValidationAfterRelationsAttribute(): void + { + $relAttr = new Document([ + '$id' => 'structure_2', 'key' => 'structure_2', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'structure_2', + 'relationType' => RelationshipType::OneToOne, + 'twoWay' => false, + 'twoWayKey' => 'structure_1', + 'onDelete' => 'restrict', + 'side' => 'parent', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('structure_1', [$relAttr]), + $this->makeCollection('structure_2'), + ]); + + $this->expectException(StructureException::class); + + $db->createDocument('structure_1', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'structure_2' => '100', + 'name' => 'Frozen', + ])); + } + + public function testNoChangeUpdateDocumentWithRelationWithoutPermission(): void + { + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 100, 'required' => false, 'default' => null, + 'signed' => false, 'array' => false, 'filters' => [], + ]); + + $perms = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::delete(Role::any()), + ]; + + $doc = new Document([ + '$id' => 'level1', + '$collection' => 'level1', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [], + 'name' => 'Level 1', + ]); + + $db = $this->buildDatabase( + [$this->makeCollection('level1', [$nameAttr], $perms)], + ['level1:level1' => $doc] + ); + + $created = $db->createDocument('level1', new Document([ + '$id' => 'level1', + '$permissions' => [], + 'name' => 'Level 1', + ])); + + $this->expectException(AuthorizationException::class); + + $db->updateDocument('level1', 'level1', $created->setAttribute('name', 'haha')); + } + + public function testNoInvalidKeysWithRelationships(): void + { + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $speciesRelAttr = new Document([ + '$id' => 'creature', 'key' => 'creature', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'creatures', + 'relationType' => RelationshipType::OneToOne, + 'twoWay' => true, + 'twoWayKey' => 'species', + 'onDelete' => 'restrict', + 'side' => 'parent', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('species', [$nameAttr, $speciesRelAttr]), + $this->makeCollection('creatures', [$nameAttr]), + $this->makeCollection('characteristics', [$nameAttr]), + ]); + + $doc = $db->createDocument('species', new Document([ + '$id' => Id::custom('1'), + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Canine', + 'creature' => null, + ])); + + $this->assertEquals('1', $doc->getId()); + } + + public function testEnforceRelationshipPermissions(): void + { + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $perms = [ + Permission::read(Role::any()), + Permission::update(Role::user('user1')), + Permission::delete(Role::user('user2')), + ]; + + $doc = new Document([ + '$id' => 'lawn1', + '$collection' => 'lawns', + '$sequence' => '1', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => $perms, + 'name' => 'Lawn 1', + ]); + + $colPerms = [Permission::create(Role::any()), Permission::read(Role::any())]; + + $db = $this->buildDatabase( + [$this->makeCollection('lawns', [$nameAttr], $colPerms)], + ['lawns:lawn1' => $doc] + ); + + $db->getAuthorization()->cleanRoles(); + $db->getAuthorization()->addRole(Role::any()->toString()); + + try { + $db->updateDocument('lawns', 'lawn1', new Document([ + '$permissions' => $perms, + 'name' => 'Lawn 1 Updated', + ])); + $this->fail('Failed to throw exception'); + } catch (\Exception $e) { + $this->assertInstanceOf(AuthorizationException::class, $e); + } + } + + public function testCreateRelationshipMissingCollection(): void + { + $db = $this->buildDatabase([]); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Collection not found'); + + $db->createRelationship('missing', Relationship::oneToMany(relatedCollection: 'missing', twoWay: true)); + } + + public function testCreateRelationshipMissingRelatedCollection(): void + { + $db = $this->buildDatabase([$this->makeCollection('test')]); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Related collection not found'); + + $db->createRelationship('test', Relationship::oneToMany(relatedCollection: 'missing', twoWay: true)); + } + + public function testCreateDuplicateRelationship(): void + { + $relAttr = new Document([ + '$id' => 'test2', 'key' => 'test2', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'test2', + 'relationType' => RelationshipType::OneToMany, + 'twoWay' => true, 'twoWayKey' => 'test1', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('test1', [$relAttr]), + $this->makeCollection('test2'), + ]); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Attribute already exists'); + + $db->createRelationship('test1', Relationship::oneToMany(relatedCollection: 'test2', twoWay: true)); + } + + public function testCreateInvalidRelationship(): void + { + $this->expectException(RelationshipException::class); + $this->expectExceptionMessage('Unknown relationship type "invalid"'); + + Relationship::fromArray(['relatedCollection' => 'test4', 'relationType' => 'invalid', 'twoWay' => true]); + } + + public function testDeleteMissingRelationship(): void + { + $db = $this->buildDatabase([$this->makeCollection('test')]); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Relationship not found'); + + $db->deleteRelationship('test', 'test2'); + } + + public function testCreateInvalidIntValueRelationship(): void + { + $relAttr = new Document([ + '$id' => 'invalid2', 'key' => 'invalid2', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'invalid2', + 'relationType' => RelationshipType::OneToOne, + 'twoWay' => true, 'twoWayKey' => 'invalid1', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('invalid1', [$relAttr]), + $this->makeCollection('invalid2'), + ], [], true); + + $this->expectException(RelationshipException::class); + $this->expectExceptionMessage('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); + + $db->createDocument('invalid1', new Document([ + '$id' => Id::unique(), + 'invalid2' => 10, + ])); + } + + public function testCreateInvalidObjectValueRelationship(): void + { + $relAttr = new Document([ + '$id' => 'invalid2', 'key' => 'invalid2', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'invalid2', + 'relationType' => RelationshipType::OneToOne, + 'twoWay' => true, 'twoWayKey' => 'invalid1', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('invalid1', [$relAttr]), + $this->makeCollection('invalid2'), + ], [], true); + + $this->expectException(RelationshipException::class); + $this->expectExceptionMessage('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); + + $db->createDocument('invalid1', new Document([ + '$id' => Id::unique(), + 'invalid2' => new \stdClass(), + ])); + } + + public function testCreateInvalidArrayIntValueRelationship(): void + { + $relAttr = new Document([ + '$id' => 'invalid3', 'key' => 'invalid3', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'invalid2', + 'relationType' => RelationshipType::OneToMany, + 'twoWay' => true, 'twoWayKey' => 'invalid4', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('invalid1', [$relAttr]), + $this->makeCollection('invalid2'), + ], [], true); + + $this->expectException(RelationshipException::class); + $this->expectExceptionMessage('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); + + $db->createDocument('invalid1', new Document([ + '$id' => Id::unique(), + 'invalid3' => [10], + ])); + } + + public function testCreateInvalidOneWayChildArrayValueRelationship(): void + { + $relAttr = new Document([ + '$id' => 'reverse1', 'key' => 'reverse1', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'reverse1', + 'relationType' => RelationshipType::OneToOne, + 'twoWay' => false, 'twoWayKey' => 'reverse2', + 'onDelete' => 'restrict', 'side' => 'child', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('reverse2', [$relAttr]), + $this->makeCollection('reverse1'), + ], [], true); + + $this->expectException(RelationshipException::class); + $this->expectExceptionMessage('Invalid relationship value. Cannot set a value from the child side of a oneToOne relationship when twoWay is false.'); + + $db->createDocument('reverse2', new Document([ + '$id' => Id::unique(), + 'reverse1' => ['name' => 'reverse'], + ])); + } + + public function testCreateEmptyValueRelationship(): void + { + $o2oRel = new Document([ + '$id' => 'null2', 'key' => 'null2', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'null2', + 'relationType' => RelationshipType::OneToOne, + 'twoWay' => true, 'twoWayKey' => 'null1', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('null1', [$o2oRel]), + $this->makeCollection('null2'), + ], [], true); + + $doc = $db->createDocument('null1', new Document([ + '$id' => Id::unique(), + 'null2' => null, + ])); + + $this->assertNull($doc->getAttribute('null2')); + } + + public function testUpdateRelationshipToExistingKey(): void + { + $ownerAttr = new Document([ + '$id' => 'owner', 'key' => 'owner', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $cakesRelAttr = new Document([ + '$id' => 'cakes', 'key' => 'cakes', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'cakes', + 'relationType' => RelationshipType::OneToMany, + 'twoWay' => true, 'twoWayKey' => 'oven', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + $ovenRelAttr = new Document([ + '$id' => 'oven', 'key' => 'oven', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'ovens', + 'relationType' => RelationshipType::OneToMany, + 'twoWay' => true, 'twoWayKey' => 'cakes', + 'onDelete' => 'restrict', 'side' => 'child', + ], + ]); + + $db = $this->buildDatabase([ + $this->makeCollection('ovens', [$ownerAttr, $cakesRelAttr]), + $this->makeCollection('cakes', [$ovenRelAttr]), + ]); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Relationship already exists'); + + $db->updateRelationship('ovens', 'cakes', new RelationshipUpdate(key: 'owner')); + } + + public function testOneToOneRelationshipRejectsArrayOperators(): void + { + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $relAttr = new Document([ + '$id' => 'profile', 'key' => 'profile', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'profile_o2o', + 'relationType' => RelationshipType::OneToOne, + 'twoWay' => true, 'twoWayKey' => 'user', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + + $existingDoc = new Document([ + '$id' => 'user1', '$collection' => 'user_o2o', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'User 1', 'profile' => null, + ]); + + $db = $this->buildDatabase( + [$this->makeCollection('user_o2o', [$nameAttr, $relAttr]), $this->makeCollection('profile_o2o')], + ['user_o2o:user1' => $existingDoc] + ); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('single-value relationship'); + + $db->updateDocument('user_o2o', 'user1', new Document([ + 'profile' => Operator::arrayAppend(['profile2']), + ])); + } + + public function testOneToManyRelationshipWithArrayOperators(): void + { + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $relAttr = new Document([ + '$id' => 'articles', 'key' => 'articles', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'article', + 'relationType' => RelationshipType::OneToMany, + 'twoWay' => true, 'twoWayKey' => 'author', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + $authorRel = new Document([ + '$id' => 'author', 'key' => 'author', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'author', + 'relationType' => RelationshipType::OneToMany, + 'twoWay' => true, 'twoWayKey' => 'articles', + 'onDelete' => 'restrict', 'side' => 'child', + ], + ]); + + $existingDoc = new Document([ + '$id' => 'author1', '$collection' => 'author', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Author 1', 'articles' => [], + ]); + + $db = $this->buildDatabase( + [$this->makeCollection('author', [$nameAttr, $relAttr]), $this->makeCollection('article', [$authorRel])], + ['author:author1' => $existingDoc] + ); + + $updated = $db->updateDocument('author', 'author1', new Document([ + 'articles' => Operator::arrayAppend(['article2']), + ])); + + $this->assertSame('author1', $updated->getId()); + } + + public function testOneToManyChildSideRejectsArrayOperators(): void + { + $titleAttr = new Document([ + '$id' => 'title', 'key' => 'title', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $childRelAttr = new Document([ + '$id' => 'parent', 'key' => 'parent', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'parent_o2m', + 'relationType' => RelationshipType::OneToMany, + 'twoWay' => true, 'twoWayKey' => 'children', + 'onDelete' => 'restrict', 'side' => 'child', + ], + ]); + + $existingDoc = new Document([ + '$id' => 'child1', '$collection' => 'child_o2m', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Child 1', 'parent' => null, + ]); + + $db = $this->buildDatabase( + [$this->makeCollection('parent_o2m'), $this->makeCollection('child_o2m', [$titleAttr, $childRelAttr])], + ['child_o2m:child1' => $existingDoc] + ); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('single-value relationship'); + + $db->updateDocument('child_o2m', 'child1', new Document([ + 'parent' => Operator::arrayAppend(['parent2']), + ])); + } + + public function testManyToManyRelationshipWithArrayOperators(): void + { + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $relAttr = new Document([ + '$id' => 'books', 'key' => 'books', + 'type' => ColumnType::Relationship->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + 'options' => [ + 'relatedCollection' => 'book', + 'relationType' => RelationshipType::ManyToMany, + 'twoWay' => true, 'twoWayKey' => 'libraries', + 'onDelete' => 'restrict', 'side' => 'parent', + ], + ]); + + $existingDoc = new Document([ + '$id' => 'library1', '$collection' => 'library', + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Library 1', 'books' => [], + ]); + + $db = $this->buildDatabase( + [$this->makeCollection('library', [$nameAttr, $relAttr]), $this->makeCollection('book')], + ['library:library1' => $existingDoc] + ); + + $updated = $db->updateDocument('library', 'library1', new Document([ + 'books' => Operator::arrayAppend(['book2']), + ])); + + $this->assertSame('library1', $updated->getId()); + } +} diff --git a/tests/unit/Relationships/TwoWayOneToOneDocumentLinkTest.php b/tests/unit/Relationships/TwoWayOneToOneDocumentLinkTest.php new file mode 100644 index 0000000000..262094d5dd --- /dev/null +++ b/tests/unit/Relationships/TwoWayOneToOneDocumentLinkTest.php @@ -0,0 +1,245 @@ + + */ + public static function adapters(): iterable + { + yield 'memory' => [static fn (): Adapter => new Memory()]; + yield 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAnExistingDocumentByDocumentStoresTheBackReference(Closure $adapter): void + { + $database = $this->database($adapter); + + $updated = $database->updateDocument('parent', 'p2', new Document(['partner' => new Document(['$id' => 'c1'])])); + + $this->assertSame('c1', $this->idOf($updated->getAttribute('partner'))); + $this->assertSame('c1', $this->link($database, 'parent', 'p2', 'partner')); + $this->assertSame('p2', $this->link($database, 'child', 'c1', 'parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAnExistingDocumentByIdStoresTheBackReference(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->updateDocument('parent', 'p2', new Document(['partner' => 'c1'])); + + $this->assertSame('c1', $this->link($database, 'parent', 'p2', 'partner')); + $this->assertSame('p2', $this->link($database, 'child', 'c1', 'parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingFromTheChildSideByDocumentStoresTheBackReference(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->updateDocument('child', 'c1', new Document(['parent' => new Document(['$id' => 'p2'])])); + + $this->assertSame('p2', $this->link($database, 'child', 'c1', 'parent')); + $this->assertSame('c1', $this->link($database, 'parent', 'p2', 'partner')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingByDocumentWritesTheDocumentsOwnAttributesWithTheBackReference(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->updateDocument('parent', 'p2', new Document(['partner' => new Document(['$id' => 'c1', 'name' => 'renamed'])])); + + $child = $this->stored($database, 'child', 'c1'); + $this->assertSame('renamed', $child->getAttribute('name')); + $this->assertSame('p2', $child->getAttribute('parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingADocumentLinkedElsewhereByDocumentIsRefused(Closure $adapter): void + { + $database = $this->database($adapter); + + try { + $database->updateDocument('parent', 'p2', new Document(['partner' => new Document(['$id' => 'c3'])])); + $this->fail('Linking a document that is already linked elsewhere was accepted'); + } catch (Throwable $exception) { + $this->assertInstanceOf(DuplicateException::class, $exception, $exception::class.': '.$exception->getMessage()); + } + + $this->assertNull($this->link($database, 'parent', 'p2', 'partner')); + $this->assertSame('p1', $this->link($database, 'child', 'c3', 'parent')); + $this->assertSame('c3', $this->link($database, 'parent', 'p1', 'partner')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUnlinkingThenRelinkingByDocumentStoresTheBackReference(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->updateDocument('parent', 'p1', new Document(['partner' => null])); + $database->updateDocument('parent', 'p2', new Document(['partner' => new Document(['$id' => 'c3'])])); + + $this->assertNull($this->link($database, 'parent', 'p1', 'partner')); + $this->assertSame('c3', $this->link($database, 'parent', 'p2', 'partner')); + $this->assertSame('p2', $this->link($database, 'child', 'c3', 'parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingANewDocumentByDocumentStoresTheBackReference(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->updateDocument('parent', 'p2', new Document(['partner' => new Document(['$id' => 'c9', 'name' => 'new'])])); + + $this->assertSame('c9', $this->link($database, 'parent', 'p2', 'partner')); + $this->assertSame('p2', $this->link($database, 'child', 'c9', 'parent')); + $this->assertSame('new', $this->stored($database, 'child', 'c9')->getAttribute('name')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingByDocumentWritesNestedDocumentsToTheSameDepthAsCreating(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->createDocument('parent', new Document([ + '$id' => 'p8', + 'partner' => ['$id' => 'c8', 'toy' => ['$id' => 't8', 'part' => ['$id' => 'x8']]], + ])); + $database->updateDocument('parent', 'p2', new Document([ + 'partner' => new Document(['$id' => 'c1', 'toy' => ['$id' => 't1', 'part' => ['$id' => 'x1']]]), + ])); + $database->updateDocument('parent', 'p3', new Document([ + 'partner' => new Document(['$id' => 'c7', 'toy' => ['$id' => 't7', 'part' => ['$id' => 'x7']]]), + ])); + + foreach (['created' => ['p8', 'c8', 't8', 'x8'], 'linked' => ['p2', 'c1', 't1', 'x1'], 'new' => ['p3', 'c7', 't7', 'x7']] as $case => [$parent, $child, $toy, $part]) { + $this->assertSame($child, $this->link($database, 'parent', $parent, 'partner'), $case); + $this->assertSame($parent, $this->link($database, 'child', $child, 'parent'), $case); + $this->assertSame($toy, $this->link($database, 'child', $child, 'toy'), $case); + $this->assertSame($child, $this->link($database, 'toy', $toy, 'owner'), $case); + $this->assertNull($this->link($database, 'toy', $toy, 'part'), $case); + $this->assertTrue($this->stored($database, 'part', $part, false)->isEmpty(), $case.': the level past the relation depth limit was written'); + } + } + + private function idOf(mixed $value): ?string + { + if ($value instanceof Document) { + return $value->getId(); + } + + return \is_string($value) ? $value : null; + } + + private function link(Database $database, string $collection, string $id, string $key): ?string + { + return $this->idOf($this->stored($database, $collection, $id)->getAttribute($key)); + } + + private function stored(Database $database, string $collection, string $id, bool $required = true): Document + { + $document = $database->getAuthorization()->skip(fn () => $database->skipRelationships(fn () => $database->getDocument($collection, $id))); + + if ($required) { + $this->assertFalse($document->isEmpty(), $collection.' '.$id.' is missing'); + } + + return $document; + } + + /** + * @param Closure(): Adapter $adapter + */ + private function database(Closure $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter(), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('two_way_one_to_one_document') + ->setNamespace('two_way_one_to_one_document_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + $database->addHook(new Permissions()); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + foreach (['parent', 'child', 'toy', 'part'] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64, required: false)], + permissions: $permissions, + documentSecurity: false, + )); + } + $database->createRelationship('parent', Relationship::oneToOne(relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'parent')); + $database->createRelationship('child', Relationship::oneToOne(relatedCollection: 'toy', twoWay: true, key: 'toy', twoWayKey: 'owner')); + $database->createRelationship('toy', Relationship::oneToOne(relatedCollection: 'part', twoWay: true, key: 'part', twoWayKey: 'toy')); + + foreach (['c1', 'c2', 'c3'] as $id) { + $database->createDocument('child', new Document(['$id' => $id])); + } + $database->createDocument('parent', new Document(['$id' => 'p1', 'partner' => 'c3'])); + $database->createDocument('parent', new Document(['$id' => 'p2'])); + $database->createDocument('parent', new Document(['$id' => 'p3'])); + + return $database; + } +} diff --git a/tests/unit/Relationships/TwoWayOneToOneLinkTest.php b/tests/unit/Relationships/TwoWayOneToOneLinkTest.php new file mode 100644 index 0000000000..bac4547e03 --- /dev/null +++ b/tests/unit/Relationships/TwoWayOneToOneLinkTest.php @@ -0,0 +1,199 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @return iterable + */ + public static function links(): iterable + { + $values = [ + 'id' => static fn (string $id): string => $id, + 'document' => static fn (string $id): Document => new Document(['$id' => $id]), + ]; + + foreach (self::adapters() as $adapterName => [$adapter]) { + foreach ($values as $valueName => $value) { + yield $adapterName.', '.$valueName => [$adapter, $value]; + } + } + } + + /** + * @param Closure(): Adapter $adapter + * @param Closure(string): (string|Document) $value + */ + #[DataProvider('links')] + public function testLinkingAFreeDocumentWhoseIdMatchesALinkedDocumentOfThisCollectionSucceeds(Closure $adapter, Closure $value): void + { + $database = $this->database($adapter); + + $database->updateDocument('parent', 'c', new Document(['partner' => $value('a')])); + + $this->assertSame('a', $this->link($database, 'parent', 'c', 'partner')); + $this->assertSame('b', $this->link($database, 'parent', 'a', 'partner')); + $this->assertSame('a', $this->link($database, 'child', 'b', 'parent')); + } + + /** + * @param Closure(): Adapter $adapter + * @param Closure(string): (string|Document) $value + */ + #[DataProvider('links')] + public function testLinkingADocumentLinkedElsewhereThrowsTheRelationshipDuplicate(Closure $adapter, Closure $value): void + { + $database = $this->database($adapter); + + $this->assertRelationshipDuplicate(fn () => $database->updateDocument('parent', 'c', new Document(['partner' => $value('L')]))); + + $this->assertNull($this->link($database, 'parent', 'c', 'partner')); + $this->assertSame('x', $this->link($database, 'child', 'L', 'parent')); + $this->assertSame('L', $this->link($database, 'parent', 'x', 'partner')); + } + + /** + * @param Closure(): Adapter $adapter + * @param Closure(string): (string|Document) $value + */ + #[DataProvider('links')] + public function testLinkingFromTheChildSideToADocumentLinkedElsewhereThrowsTheRelationshipDuplicate(Closure $adapter, Closure $value): void + { + $database = $this->database($adapter); + + $this->assertRelationshipDuplicate(fn () => $database->updateDocument('child', 'free', new Document(['parent' => $value('x')]))); + + $this->assertNull($this->link($database, 'child', 'free', 'parent')); + $this->assertSame('L', $this->link($database, 'parent', 'x', 'partner')); + $this->assertSame('x', $this->link($database, 'child', 'L', 'parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testLinkingAFreeDocumentByIdWhoseIdMatchesALinkedDocumentOfThisCollectionLinksBothSides(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->updateDocument('parent', 'c', new Document(['partner' => 'a'])); + + $this->assertSame('a', $this->link($database, 'parent', 'c', 'partner')); + $this->assertSame('c', $this->link($database, 'child', 'a', 'parent')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUnlinkingThenRelinkingToAnotherDocumentSucceeds(Closure $adapter): void + { + $database = $this->database($adapter); + + $database->updateDocument('parent', 'x', new Document(['partner' => null])); + $database->updateDocument('parent', 'c', new Document(['partner' => 'L'])); + + $this->assertNull($this->link($database, 'parent', 'x', 'partner')); + $this->assertSame('L', $this->link($database, 'parent', 'c', 'partner')); + $this->assertSame('c', $this->link($database, 'child', 'L', 'parent')); + } + + /** + * @param callable(): mixed $write + */ + private function assertRelationshipDuplicate(callable $write): void + { + try { + $write(); + $this->fail('Linking a document that is already linked elsewhere was accepted'); + } catch (Throwable $exception) { + $this->assertSame(DuplicateException::class, $exception::class, $exception::class.': '.$exception->getMessage()); + $this->assertSame(self::MESSAGE, $exception->getMessage()); + } + } + + private function link(Database $database, string $collection, string $id, string $key): ?string + { + $document = $database->getAuthorization()->skip(fn () => $database->skipRelationships(fn () => $database->getDocument($collection, $id))); + $this->assertFalse($document->isEmpty(), $collection.' '.$id.' is missing'); + $value = $document->getAttribute($key); + if ($value instanceof Document) { + return $value->getId(); + } + $this->assertTrue($value === null || \is_string($value)); + + return $value; + } + + /** + * @param Closure(): Adapter $adapter + */ + private function database(Closure $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter(), new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('two_way_one_to_one') + ->setNamespace('two_way_one_to_one_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + $database->addHook(new Permissions()); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(Collection::create(id: 'parent', permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: 'child', permissions: $permissions, documentSecurity: false)); + $database->createRelationship('parent', Relationship::oneToOne(relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'parent')); + + foreach (['a', 'b', 'L', 'free'] as $id) { + $database->createDocument('child', new Document(['$id' => $id])); + } + $database->createDocument('parent', new Document(['$id' => 'x', 'partner' => 'L'])); + $database->createDocument('parent', new Document(['$id' => 'a', 'partner' => 'b'])); + $database->createDocument('parent', new Document(['$id' => 'c'])); + + return $database; + } +} diff --git a/tests/unit/Relationships/TypedOptionsTest.php b/tests/unit/Relationships/TypedOptionsTest.php new file mode 100644 index 0000000000..f53ced2776 --- /dev/null +++ b/tests/unit/Relationships/TypedOptionsTest.php @@ -0,0 +1,196 @@ + + */ + public static function adapters(): array + { + return [ + 'memory' => [static fn (): Adapter => new Memory()], + 'sqlite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:'))], + ]; + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testBothSidesStoreTheRelationshipOptionsShape(Closure $adapter): void + { + $database = $this->database($adapter()); + + $created = $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWay: true, twoWayKey: 'artist')); + + $this->assertSame($this->sorted($created->toOptions(RelationshipSide::Parent)), $this->storedOptions($database, 'artists', 'albums')); + $this->assertSame($this->sorted($created->inverse('artists')->toOptions(RelationshipSide::Child)), $this->storedOptions($database, 'albums', 'artist')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testAnUpdateFromTheChildSideStoresTheOptionsOnBothSides(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWay: true, twoWayKey: 'artist')); + + $updated = $database->updateRelationship('albums', 'artist', new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull)); + + $this->assertSame('artists', $updated->relatedCollection); + $this->assertSame('artist', $updated->key); + $this->assertSame('albums', $updated->twoWayKey); + $this->assertSame(RelationshipDeleteAction::SetNull, $updated->onDelete); + $this->assertSame($this->sorted($updated->toOptions(RelationshipSide::Child)), $this->storedOptions($database, 'albums', 'artist')); + $this->assertSame($this->sorted($updated->inverse('albums')->toOptions(RelationshipSide::Parent)), $this->storedOptions($database, 'artists', 'albums')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testDeletingFollowsTheStoredDeleteAction(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWay: true, twoWayKey: 'artist')); + $database->createDocument('albums', new Document(['$id' => 'b1', 'title' => 'First'])); + $database->createDocument('artists', new Document(['$id' => 'a1', 'name' => 'Ada', 'albums' => ['b1']])); + + try { + $database->deleteDocument('artists', 'a1'); + $this->fail('A restricted relationship must keep a parent that has children'); + } catch (RestrictedException) { + $this->assertFalse($database->getDocument('artists', 'a1')->isEmpty()); + } + + $database->updateRelationship('albums', 'artist', new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull)); + $database->deleteDocument('artists', 'a1'); + + $album = $database->getDocument('albums', 'b1'); + $this->assertFalse($album->isEmpty()); + $this->assertNull($album->getAttribute('artist')); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testPopulationReadsEachSideFromItsStoredSide(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWay: true, twoWayKey: 'artist')); + $database->createDocument('albums', new Document(['$id' => 'b1', 'title' => 'First'])); + $database->createDocument('albums', new Document(['$id' => 'b2', 'title' => 'Second'])); + $database->createDocument('artists', new Document(['$id' => 'a1', 'name' => 'Ada', 'albums' => ['b1', 'b2']])); + + $albums = $database->getDocument('artists', 'a1')->getAttribute('albums'); + $this->assertIsArray($albums); + $ids = []; + foreach ($albums as $album) { + $this->assertInstanceOf(Document::class, $album); + $ids[] = $album->getId(); + } + \sort($ids); + $this->assertSame(['b1', 'b2'], $ids); + + $artist = $database->getDocument('albums', 'b2')->getAttribute('artist'); + $this->assertInstanceOf(Document::class, $artist); + $this->assertSame('a1', $artist->getId()); + $this->assertSame('Ada', $artist->getAttribute('name')); + } + + public function testAOneWayChildSideIsNotPopulated(): void + { + $database = $this->database(new Memory()); + $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWayKey: 'artist')); + $database->createDocument('albums', new Document(['$id' => 'b1', 'title' => 'First'])); + $database->createDocument('artists', new Document(['$id' => 'a1', 'name' => 'Ada', 'albums' => ['b1']])); + + $album = $database->getDocument('albums', 'b1'); + + $this->assertFalse($album->isEmpty()); + $this->assertArrayNotHasKey('artist', $album->getArrayCopy()); + } + + /** + * @return array + */ + private function storedOptions(Database $database, string $collection, string $key): array + { + $metadata = $database->getAuthorization()->skip(fn (): Document => $database->getDocument(Database::METADATA, $collection)); + + /** @var array> $stored */ + $stored = $metadata->getAttribute('attributes', []); + foreach ($stored as $attribute) { + $attribute = $attribute instanceof Document ? $attribute : new Document($attribute); + if ($attribute->getId() !== $key) { + continue; + } + + $options = $attribute->getAttribute('options', []); + /** @var array $options */ + $options = $options instanceof Document ? $options->getArrayCopy() : $options; + + return $this->sorted($options); + } + + $this->fail('Collection "'.$collection.'" stores no attribute "'.$key.'"'); + } + + /** + * @param array $options + * @return array + */ + private function sorted(array $options): array + { + \ksort($options); + + return $options; + } + + private function database(Adapter $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('typed_options') + ->setNamespace('typed_options_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $database->createCollection(Collection::create('artists', attributes: [Attribute::string('name', 64)], permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create('albums', attributes: [Attribute::string('title', 64)], permissions: $permissions, documentSecurity: false)); + + return $database; + } +} diff --git a/tests/unit/RoleTest.php b/tests/unit/RoleTest.php index 2c1cbee27d..9a693d9bf3 100644 --- a/tests/unit/RoleTest.php +++ b/tests/unit/RoleTest.php @@ -2,13 +2,15 @@ namespace Tests\Unit; +use Exception; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Role; class RoleTest extends TestCase { - public function testOutputFromString(): void + public function test_output_from_string(): void { $role = Role::parse('any'); $this->assertEquals('any', $role->getRole()); @@ -66,7 +68,7 @@ public function testOutputFromString(): void $this->assertEmpty($role->getDimension()); } - public function testInputFromParameters(): void + public function test_input_from_parameters(): void { $role = new Role('any'); $this->assertEquals('any', $role->toString()); @@ -96,7 +98,7 @@ public function testInputFromParameters(): void $this->assertEquals('label:vip', $role->toString()); } - public function testInputFromRoles(): void + public function test_input_from_roles(): void { $role = Role::any(); $this->assertEquals('any', $role->toString()); @@ -107,34 +109,54 @@ public function testInputFromRoles(): void $role = Role::users(); $this->assertEquals('users', $role->toString()); - $role = Role::user(ID::custom('123')); + $role = Role::user(Id::custom('123')); $this->assertEquals('user:123', $role->toString()); - $role = Role::team(ID::custom('123')); + $role = Role::team(Id::custom('123')); $this->assertEquals('team:123', $role->toString()); - $role = Role::team(ID::custom('123'), '456'); + $role = Role::team(Id::custom('123'), '456'); $this->assertEquals('team:123/456', $role->toString()); - $role = Role::team(ID::custom('123'), 'project-456-owner'); + $role = Role::team(Id::custom('123'), 'project-456-owner'); $this->assertEquals('team:123/project-456-owner', $role->toString()); - $role = Role::team(ID::custom('123'), 'project-456'); + $role = Role::team(Id::custom('123'), 'project-456'); $this->assertEquals('team:123/project-456', $role->toString()); $role = Role::label('vip'); $this->assertEquals('label:vip', $role->toString()); } - public function testInputFromID(): void + public function test_input_from_id(): void { - $role = Role::user(ID::custom('123')); + $role = Role::user(Id::custom('123')); $this->assertEquals('user:123', $role->toString()); - $role = Role::team(ID::custom('123')); + $role = Role::team(Id::custom('123')); $this->assertEquals('team:123', $role->toString()); - $role = Role::team(ID::custom('123'), '456'); + $role = Role::team(Id::custom('123'), '456'); $this->assertEquals('team:123/456', $role->toString()); } + + #[DataProvider('malformedDimensions')] + public function testParseRejectsMalformedDimensions(string $role, string $message): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage($message); + + Role::parse($role); + } + + /** + * @return iterable + */ + public static function malformedDimensions(): iterable + { + yield 'two dimensions' => ['team/a/b', 'Only one dimension can be provided']; + yield 'an empty dimension' => ['team/', 'Dimension must not be empty']; + yield 'two dimensions after an identifier' => ['team:abc/x/y', 'Only one dimension can be provided']; + yield 'an empty dimension after an identifier' => ['team:abc/', 'Dimension must not be empty']; + } } diff --git a/tests/unit/SQLFindTest.php b/tests/unit/SQLFindTest.php new file mode 100644 index 0000000000..f105df1e50 --- /dev/null +++ b/tests/unit/SQLFindTest.php @@ -0,0 +1,702 @@ +createTimeoutException(); + $statement = $this->statementMock(); + $statement->expects($this->once())->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willThrowException($timeout); + $statement->expects($this->once()) + ->method('closeCursor') + ->willThrowException(new PDOException('Failed to close cursor')); + + try { + $this->find($this->adapter($statement), $fast); + } catch (TimeoutException $exception) { + $this->assertSame($timeout, $exception->getPrevious()); + + return; + } + + $this->fail('Expected a timeout exception.'); + } + + #[DataProvider('paths')] + public function testCloseFailureIsTranslated(bool $fast): void + { + $close = new PDOException('Lost connection while closing cursor'); + $statement = $this->statementMock(); + $statement->expects($this->once())->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willReturn([]); + $statement->expects($this->once())->method('closeCursor')->willThrowException($close); + + try { + $this->find($this->adapter($statement), $fast); + } catch (PDOException $exception) { + $this->assertSame($close, $exception); + + return; + } + + $this->fail('Expected a close cursor exception.'); + } + + /** + * @return iterable + */ + public static function paths(): iterable + { + yield 'fast path' => [true]; + yield 'builder path' => [false]; + } + + /** + * @return iterable, bool}> + */ + public static function joinDocumentSecurityMaps(): iterable + { + yield 'disabled under the collection id' => [['database_1_collection_2' => false], false]; + yield 'disabled under the qualified physical name' => [['appwrite._5_database_1_collection_2' => false], false]; + yield 'disabled under the physical table name' => [['_5_database_1_collection_2' => false], false]; + yield 'enabled under the qualified physical name' => [['appwrite._5_database_1_collection_2' => true], true]; + yield 'disabled for another collection only' => [['database_1_collection_9' => false], true]; + yield 'no entries' => [[], true]; + } + + /** + * @param array $joinDocumentSecurity + */ + #[DataProvider('joinDocumentSecurityMaps')] + public function testJoinDocumentSecurityLookupMatchesRemappedPhysicalIds(array $joinDocumentSecurity, bool $filtered): void + { + $sql = $this->captureJoinDocumentSecuritySql($joinDocumentSecurity); + + $this->assertSame($filtered, \str_contains($sql, '`appwrite`.`_5_database_1_collection_2_perms`'), $sql); + } + + public function testJoinWithoutSelectLeavesJoinedInternalsOut(): void + { + $sql = $this->captureFindSql([ + Query::leftJoin('orders', 'j0', [Query::on('$id', 'customerId')]), + ]); + + $this->assertJoinProjection($sql); + $this->assertStringContainsString('LEFT JOIN', $sql); + } + + public function testNestedJoinOnCompilesPredicatesOntoJoin(): void + { + $sql = $this->captureFindSql([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]), + ]); + + $this->assertJoinProjection($sql, joinAlias: 'ord'); + $this->assertStringContainsString('LEFT JOIN', $sql); + $this->assertStringContainsString('AS `ord`', $sql); + $this->assertMatchesRegularExpression('/ON\s+`table_main`\.`_uid`\s*=\s*`ord`\.`customerId`/i', $sql); + $this->assertStringContainsString('`ord`.`status`', $sql); + $this->assertDoesNotMatchRegularExpression('/WHERE[\s\S]*`ord`\.`status`/i', $sql); + } + + public function testEmulatesFullOuterJoinWithOuterLimit(): void + { + $sql = $this->captureFindSql( + [Query::fullOuterJoin('orders', 'j0', [Query::on('$id', 'customerId')])], + limit: 2, + ); + + $this->assertEmulatedFullOuterJoin($sql); + $this->assertJoinProjection($sql); + $this->assertSame(1, $this->countLimitsAfterUnion($sql), $sql); + } + + public function testEmulatesFullOuterJoinOrderByIsUnambiguousAfterUnion(): void + { + $sql = $this->captureFindSql( + [Query::fullOuterJoin('orders', 'j0', [Query::on('$id', 'customerId')])], + limit: 2, + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + ); + + $this->assertEmulatedFullOuterJoin($sql); + $this->assertSame(1, $this->countLimitsAfterUnion($sql), $sql); + $this->assertUnambiguousUnionOrderBy($sql); + } + + public function testEmulatesFullOuterJoinOrderByMatchesProjectedUnionOutput(): void + { + $sql = $this->captureFindSql( + [ + Query::fullOuterJoin('orders', 'j0', [Query::on('$id', 'customerId')]), + Query::select(['name']), + ], + limit: 2, + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + ); + + $this->assertEmulatedFullOuterJoin($sql); + $this->assertSame(1, $this->countLimitsAfterUnion($sql), $sql); + $this->assertUnambiguousUnionOrderBy($sql); + } + + public function testEmulatesFullOuterJoinOrderByHandlesMultipleAttributes(): void + { + $sql = $this->captureFindSql( + [Query::fullOuterJoin('orders', 'j0', [Query::on('$id', 'customerId')])], + limit: 2, + orderAttributes: ['name', Document::SEQUENCE], + orderTypes: [OrderDirection::Asc, OrderDirection::Asc], + ); + + $this->assertEmulatedFullOuterJoin($sql); + $this->assertSame(1, $this->countLimitsAfterUnion($sql), $sql); + $this->assertUnambiguousUnionOrderBy($sql, expectedTerms: 2); + } + + public function testEmulatesFullOuterJoinStripsOrderAliasesFromDocuments(): void + { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([ + [ + '_uid' => 'doc1', + '_id' => 1, + '_permissions' => '[]', + '_createdAt' => '2020-01-01 00:00:00.000', + '_updatedAt' => '2020-01-01 00:00:00.000', + 'name' => 'Alice', + '$foj_ord_0' => 1, + ], + ]); + $statement->method('closeCursor')->willReturn(true); + + $results = $this->adapter($statement)->find( + new Document(['$id' => 'collection']), + [Query::fullOuterJoin('orders', 'j0', [Query::on('$id', 'customerId')])], + limit: 1, + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + ); + + $this->assertSame(1, \count($results)); + $this->assertSame('doc1', $results[0]->getId()); + $this->assertSame(false, $results[0]->isSet('$foj_ord_0')); + } + + public function testEmulatesFullOuterJoinRemapsQualifiedUnionColumns(): void + { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([ + [ + 'table_main._uid' => 'doc1', + 'table_main._id' => '1', + 'table_main._permissions' => '[]', + 'table_main._createdAt' => '2020-01-01 00:00:00.000', + 'table_main._updatedAt' => '2020-01-01 00:00:00.000', + 'table_main.name' => 'Alice', + 'orders._uid' => 'order1', + 'orders._permissions' => '["read"]', + '$foj_ord_0' => 1, + ], + ]); + $statement->method('closeCursor')->willReturn(true); + + $results = $this->adapter($statement)->find( + new Document(['$id' => 'collection']), + [ + Query::fullOuterJoin('orders', 'j0', [Query::on('$id', 'customerId')]), + Query::select(['name']), + ], + limit: 1, + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + ); + + $this->assertSame(1, \count($results)); + $this->assertSame('doc1', $results[0]->getId()); + $this->assertSame('1', $results[0]->getSequence()); + $this->assertSame('Alice', $results[0]->getAttribute('name')); + $this->assertSame(false, $results[0]->isSet('$foj_ord_0')); + $this->assertSame(false, $results[0]->isSet('table_main._uid')); + $this->assertSame('order1', $results[0]->getAttribute('orders.$id')); + $this->assertSame(['read'], $results[0]->getAttribute('orders.$permissions')); + $this->assertSame(false, $results[0]->isSet('orders._uid')); + } + + public function testRemapRowPreservesSelectedJoinIdentity(): void + { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([ + [ + '_uid' => 'hm1', + '_id' => 1, + '_permissions' => '[]', + '_createdAt' => '2020-01-01 00:00:00.000', + '_updatedAt' => '2020-01-01 00:00:00.000', + 'name' => 'Main', + 'alpha.$id' => 'peer-a', + 'beta.$id' => 'peer-b', + 'alpha.label' => 'alpha-one', + 'beta.label' => 'beta-key', + 'alpha.score' => 11, + ], + ]); + $statement->method('closeCursor')->willReturn(true); + + $results = $this->adapter($statement)->find( + new Document(['$id' => 'collection']), + [ + Query::join('peers', 'alpha', [Query::on('$id', 'mainId')]), + Query::join('peers', 'beta', [Query::on('peerKey', '$id')]), + Query::select(['name', 'alpha.$id', 'beta.$id', 'alpha.label', 'beta.label', 'alpha.score']), + ], + ); + + $this->assertSame(1, \count($results)); + $this->assertSame('hm1', $results[0]->getId()); + $this->assertSame('peer-a', $results[0]->getAttribute('alpha.$id')); + $this->assertSame('peer-b', $results[0]->getAttribute('beta.$id')); + $this->assertSame('alpha-one', $results[0]->getAttribute('alpha.label')); + $this->assertSame('beta-key', $results[0]->getAttribute('beta.label')); + $this->assertNotSame('peer-a', $results[0]->getId()); + $this->assertNotSame('peer-b', $results[0]->getId()); + } + + public function testRemapRowDoesNotCopyJoinIdentityOntoMain(): void + { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([ + [ + 'table_main._uid' => 'hm1', + 'table_main._id' => '1', + 'table_main._permissions' => '[]', + 'table_main._createdAt' => '2020-01-01 00:00:00.000', + 'table_main._updatedAt' => '2020-01-01 00:00:00.000', + 'table_main.name' => 'Main', + 'twin.$id' => 'hm1', + 'twin.name' => 'Main', + 'twin.$permissions' => '["read(\"any\")"]', + ], + ]); + $statement->method('closeCursor')->willReturn(true); + + $results = $this->adapter($statement)->find( + new Document(['$id' => 'collection']), + [ + Query::join('collection', 'twin', [Query::on('$id', '$id')]), + Query::select(['name', 'twin.$id', 'twin.name', 'twin.$permissions']), + ], + ); + + $this->assertSame(1, \count($results)); + $this->assertSame('hm1', $results[0]->getId()); + $this->assertSame('hm1', $results[0]->getAttribute('twin.$id')); + $this->assertSame('Main', $results[0]->getAttribute('twin.name')); + $this->assertSame(['read("any")'], $results[0]->getAttribute('twin.$permissions')); + } + + public function testRemapRowMapsMainAliasIdentityWhenJoinIdentityIsSelected(): void + { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([ + [ + 'table_main._uid' => '', + 'table_main._id' => null, + 'table_main._permissions' => '[]', + 'table_main._createdAt' => null, + 'table_main._updatedAt' => null, + 'table_main.name' => null, + 'tail.score' => 7, + ], + ]); + $statement->method('closeCursor')->willReturn(true); + + $results = $this->adapter($statement)->find( + new Document(['$id' => 'collection']), + [ + Query::rightJoin('tail', 'tail', [Query::on('$id', 'mainId')]), + Query::select(['name', 'tail.score']), + ], + ); + + $this->assertSame(1, \count($results)); + $this->assertSame('', $results[0]->getId()); + $this->assertSame(7, $results[0]->getAttribute('tail.score')); + } + + public function testDistinctSelectIdProjectsMainUid(): void + { + $sql = $this->captureFindSql([ + Query::distinct(), + Query::join('orders', 'b', [Query::on('$id', 'customerId')]), + Query::select(['$id', 'name', 'b.label']), + ]); + + $this->assertMatchesRegularExpression('/SELECT\s+DISTINCT/i', $sql); + $this->assertStringContainsString('`table_main`.`_uid`', $sql); + $this->assertStringContainsString('`b`.`label` AS `b.label`', $sql); + } + + public function testSelectedJoinIdentityIsProjectedAsQualifiedInternal(): void + { + $sql = $this->captureFindSql([ + Query::join('peers', 'alpha', [Query::on('$id', 'mainId')]), + Query::select(['name', 'alpha.$id', 'alpha.label']), + ]); + + $this->assertStringContainsString('`alpha`.`_uid` AS `alpha._uid`', $sql); + $this->assertStringContainsString('`alpha`.`label` AS `alpha.label`', $sql); + $this->assertStringNotContainsString('`alpha.$id`', $sql); + $this->assertStringContainsString('`table_main`.`_uid`', $sql); + } + + public function testJoinSideSearchUsesJoinAlias(): void + { + $sql = $this->captureFindSql([ + Query::leftJoin('meta', 'meta', [Query::on('$id', 'mainId')]), + Query::search('meta.body', 'needle'), + ]); + + $this->assertStringContainsString('`meta`.`body`', $sql); + $this->assertStringNotContainsString('metabody', $sql); + $this->assertStringNotContainsString('`table_main`.`metabody`', $sql); + } + + public function testSqliteJoinSideSearchQuotesJoinAlias(): void + { + $joined = $this->captureSqliteSearchCondition(Query::search('meta.body', 'needle')); + + $this->assertStringContainsString('`meta`.`body`', $joined); + $this->assertStringContainsString('LIKE', $joined); + $this->assertStringNotContainsString('metabody', $joined); + $this->assertStringNotContainsString('`table_main`.`metabody`', $joined); + $this->assertStringNotContainsString(Storage::SEQUENCE, $joined); + + $main = $this->captureSqliteSearchCondition(Query::search('body', 'needle')); + + $this->assertStringContainsString('`table_main`.`body`', $main); + } + + public function testEmulatesFullOuterJoinCursorAfterUsesJoinQualifiedOrder(): void + { + $sql = $this->captureFindSql( + [Query::fullOuterJoin('meta', 'meta', [Query::on('$id', 'mainId')])], + limit: 1, + orderAttributes: ['meta.score', Document::SEQUENCE], + orderTypes: [OrderDirection::Asc, OrderDirection::Asc], + cursor: [ + 'meta.score' => 10, + Document::SEQUENCE => '5', + ], + ); + + $this->assertEmulatedFullOuterJoin($sql); + $this->assertSame(1, $this->countLimitsAfterUnion($sql), $sql); + $this->assertUnambiguousUnionOrderBy($sql, expectedTerms: 2); + $this->assertGreaterThanOrEqual(2, \preg_match_all('/`meta`\.`score`\s*>/i', $sql)); + $this->assertDoesNotMatchRegularExpression( + '/ORDER BY\s+`score`\b/i', + $sql, + ); + } + + public function testQualifyDottedAttributeKeepsNestedObjectPaths(): void + { + $sql = $this->captureFindSql( + [ + Query::join('orders', 'orders', [Query::on('$id', 'customerId')]), + Query::equal('meta.score', [1]), + Query::equal('orders.email', ['a@b.co']), + Query::equal('orders.$id', ['order-1']), + Query::equal('profile.user.email', ['c@d.co']), + ], + attributes: [new Document(['$id' => 'meta.score', 'key' => 'meta.score', 'type' => ColumnType::Integer->value])], + ); + + $this->assertStringContainsString('`table_main`.`metascore` IN (?)', $sql); + $this->assertStringContainsString('`orders`.`email` IN (?)', $sql); + $this->assertStringContainsString('`orders`.`_uid` IN (?)', $sql); + $this->assertStringContainsString('`profile`.`user`.`email` IN (?)', $sql); + } + + /** + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @param array $attributes + */ + private function captureFindSql( + array $queries, + ?int $limit = 25, + array $orderAttributes = [], + array $orderTypes = [], + array $cursor = [], + array $attributes = [], + ): string { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document(['$id' => 'collection', 'attributes' => $attributes]), + $queries, + limit: $limit, + orderAttributes: $orderAttributes, + orderTypes: $orderTypes, + cursor: $cursor, + ); + + $this->assertNotSame('', $sql); + + return $sql; + } + + /** + * @param array $joinDocumentSecurity + */ + private function captureJoinDocumentSecuritySql(array $joinDocumentSecurity): string + { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = self::createStub(\PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('appwrite'); + $adapter->setNamespace('_5'); + $authorization = new Authorization(); + $authorization->addRole('any'); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document(['$id' => 'database_1_collection_1', Database::JOIN_DOCUMENT_SECURITY => $joinDocumentSecurity]), + [Query::join('database_1_collection_2', 'j0', [Query::on('$id', 'mainId')])], + ); + + $this->assertNotSame('', $sql); + + return $sql; + } + + private function captureSqliteSearchCondition(Query $search): string + { + $statement = $this->statement(); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = self::createStub(\PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new SQLite($pdo); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find(new Document(['$id' => 'collection']), [ + Query::leftJoin('meta', 'meta', [Query::on('$id', 'mainId')]), + $search, + ]); + + $where = \strpos($sql, ' WHERE '); + $limit = \strrpos($sql, ' LIMIT '); + $this->assertNotFalse($where, $sql); + $this->assertNotFalse($limit, $sql); + + return \substr($sql, $where, $limit - $where); + } + + private function assertEmulatedFullOuterJoin(string $sql): void + { + $this->assertStringContainsString('UNION ALL', $sql); + $this->assertSame(0, \preg_match_all('/UNION(?! ALL)/i', $sql), $sql); + $this->assertStringContainsString('LEFT JOIN', $sql); + $this->assertStringContainsString('RIGHT JOIN', $sql); + $this->assertStringContainsString('IS NULL', $sql); + $this->assertStringNotContainsString('FULL OUTER JOIN', $sql); + $this->assertDoesNotMatchRegularExpression('/FROM\s*\(\s*SELECT\s+\*/i', $sql); + } + + private function assertJoinProjection(string $sql, string $quote = '`', string $joinAlias = 'j0'): void + { + $this->assertStringContainsString($quote.'table_main'.$quote.'.*', $sql); + $this->assertStringContainsString($quote.$joinAlias.$quote.'.'.$quote.'_uid'.$quote.' AS '.$quote.$joinAlias.'._uid'.$quote, $sql); + $this->assertStringNotContainsString($quote.$joinAlias.$quote.'.*', $sql); + $this->assertDoesNotMatchRegularExpression('/SELECT\s+\*(?:\s|,|$)/i', $sql); + } + + private function countLimitsAfterUnion(string $sql): int + { + $unionPosition = \stripos($sql, 'UNION'); + $this->assertNotFalse($unionPosition); + + $limitMatches = \preg_match_all('/\bLIMIT\s+(?:2|\?)/i', $sql, $matches, PREG_OFFSET_CAPTURE); + $this->assertNotFalse($limitMatches); + foreach ($matches[0] as $match) { + $this->assertGreaterThan($unionPosition, $match[1], $sql); + } + + return $limitMatches; + } + + private function assertUnambiguousUnionOrderBy(string $sql, int $expectedTerms = 1): void + { + $unionPosition = \stripos($sql, 'UNION'); + $this->assertNotFalse($unionPosition); + + $afterUnion = \substr($sql, $unionPosition); + $this->assertMatchesRegularExpression('/ORDER BY/i', $afterUnion, $sql); + + $this->assertDoesNotMatchRegularExpression( + '/ORDER BY\s+`_id`\b/i', + $afterUnion, + $sql, + ); + + $aliasMatches = \preg_match_all('/`\$foj_ord_\d+`/', $afterUnion); + $positionalMatches = \preg_match_all('/ORDER BY\s+\d+/i', $afterUnion); + + $this->assertTrue( + $aliasMatches >= $expectedTerms || $positionalMatches === 1, + $sql, + ); + + if ($aliasMatches >= $expectedTerms) { + $orderByPosition = \stripos($afterUnion, 'ORDER BY'); + $this->assertNotFalse($orderByPosition); + $selectSql = \substr($sql, 0, $unionPosition + $orderByPosition); + for ($index = 0; $index < $expectedTerms; $index++) { + $this->assertStringContainsString('$foj_ord_'.$index, $selectSql, $sql); + $this->assertStringContainsString('`$foj_ord_'.$index.'`', $afterUnion, $sql); + } + } + + $this->assertDoesNotMatchRegularExpression( + '/ORDER BY\s+`table_main`\.`_id`/i', + $afterUnion, + $sql, + ); + } + + private function find(MySQL $adapter, bool $fast): void + { + $adapter->find( + new Document(['$id' => 'collection']), + $fast ? [] : [Query::equal('title', ['value'])], + orderAttributes: ['$sequence'], + orderTypes: [OrderDirection::Asc], + ); + } + + private function adapter(\PDOStatement $statement): MySQL + { + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once())->method('prepare')->willReturn($statement); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + return $adapter; + } + + private function statement(): \PDOStatement&Stub + { + $statement = self::createStub(\PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + + return $statement; + } + + private function statementMock(): \PDOStatement&MockObject + { + $statement = $this->createMock(\PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + + return $statement; + } + + private function createTimeoutException(): PDOException + { + $exception = new class ('Query execution was interrupted', 'HY000') extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; + $exception->errorInfo = ['HY000', 3024, 'Query execution was interrupted']; + + return $exception; + } +} diff --git a/tests/unit/SQLGetDocumentTest.php b/tests/unit/SQLGetDocumentTest.php index d938b85d41..2b723767e2 100644 --- a/tests/unit/SQLGetDocumentTest.php +++ b/tests/unit/SQLGetDocumentTest.php @@ -2,14 +2,16 @@ namespace Tests\Unit; -use Exception; use PDOException; use PHPUnit\Framework\TestCase; -use ReflectionProperty; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Adapter\Postgres; use Utopia\Database\Document; use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Hook\Transform; +use Utopia\Database\Query; +use Utopia\Database\Storage; +use Utopia\Database\Validator\Authorization; final class SQLGetDocumentTest extends TestCase { @@ -22,13 +24,13 @@ public function testTranslatesExecuteTimeoutClosesCursorAndPreservesOriginalWhen ->getMock(); $statement->expects($this->once()) ->method('bindValue') - ->with(':_uid', 'document') + ->with(':'.Storage::UID, 'document', \PDO::PARAM_STR) ->willReturn(true); $statement->expects($this->once()) ->method('execute') ->willThrowException($exception); $statement->expects($this->never()) - ->method('fetchAll'); + ->method('fetch'); $statement->expects($this->once()) ->method('closeCursor') ->willThrowException(new PDOException('Failed to close cursor')); @@ -45,13 +47,13 @@ public function testTranslatesFetchTimeoutAndClosesCursor(): void ->getMock(); $statement->expects($this->once()) ->method('bindValue') - ->with(':_uid', 'document') + ->with(':'.Storage::UID, 'document', \PDO::PARAM_STR) ->willReturn(true); $statement->expects($this->once()) ->method('execute') ->willReturn(true); $statement->expects($this->once()) - ->method('fetchAll') + ->method('fetch') ->willThrowException($exception); $statement->expects($this->once()) ->method('closeCursor') @@ -67,14 +69,14 @@ public function testUsesPostgresExecuteHook(): void ->getMock(); $statement->expects($this->once()) ->method('bindValue') - ->with(':_uid', 'document') + ->with(':'.Storage::UID, 'document', \PDO::PARAM_STR) ->willReturn(true); $statement->expects($this->once()) ->method('execute') ->willReturn(true); $statement->expects($this->once()) - ->method('fetchAll') - ->willReturn([]); + ->method('fetch') + ->willReturn(false); $statement->expects($this->once()) ->method('closeCursor') ->willReturn(true); @@ -85,13 +87,14 @@ public function testUsesPostgresExecuteHook(): void $pdo->expects($this->once()) ->method('prepare') ->willReturn($statement); + $executed = []; $pdo->expects($this->exactly(2)) ->method('exec') - ->withConsecutive( - ["SET statement_timeout = '25ms'"], - ['RESET statement_timeout'] - ) - ->willReturnOnConsecutiveCalls(0, 0); + ->willReturnCallback(function (string $sql) use (&$executed): int { + $executed[] = $sql; + + return 0; + }); $adapter = new Postgres($pdo); $adapter->setDatabase('database'); @@ -99,18 +102,236 @@ public function testUsesPostgresExecuteHook(): void $adapter->setTimeout(25); $document = $adapter->getDocument( - new Document(['$id' => 'collection']), + new Document([Document::ID => 'collection']), 'document' ); $this->assertSame([], $document->getArrayCopy()); + $this->assertSame(["SET statement_timeout = '25ms'", 'RESET statement_timeout'], $executed); + } + + public function testTranslatesBuilderFetchTimeoutAndClosesCursor(): void + { + $exception = $this->createTimeoutException(); + + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->once()) + ->method('execute') + ->willReturn(true); + $statement->expects($this->once()) + ->method('fetchAll') + ->willThrowException($exception); + $statement->expects($this->once()) + ->method('closeCursor') + ->willReturn(true); + + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturn($statement); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + try { + $adapter->getDocument( + new Document([Document::ID => 'collection']), + 'document', + [Query::select(['title'])] + ); + } catch (TimeoutException $timeout) { + $this->assertSame($exception, $timeout->getPrevious()); + + return; + } + + $this->fail('Expected a timeout exception.'); + } + + public function testAppliesTypedReadTransformOnFastAndBuilderPaths(): void + { + $statement = self::createStub(\PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + $statement->method('execute')->willReturn(true); + $statement->method('fetch')->willReturn(false); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $queries = []; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2)) + ->method('prepare') + ->willReturnCallback(function (string $sql) use (&$queries, $statement): \PDOStatement { + $queries[] = $sql; + + return $statement; + }); + + $events = []; + $transform = $this->createMock(Transform::class); + $transform->expects($this->exactly(2)) + ->method('transform') + ->willReturnCallback(function (\Utopia\Database\Event $event, string $query) use (&$events): string { + $events[] = $event; + + return $query.' /* transformed */'; + }); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->addTransform('test', $transform); + $collection = new Document([Document::ID => 'collection']); + + $adapter->getDocument($collection, 'fast'); + $adapter->getDocument($collection, 'builder', [Query::select(['title'])]); + + $this->assertSame([ + \Utopia\Database\Event::DocumentRead, + \Utopia\Database\Event::DocumentRead, + ], $events); + $this->assertStringEndsWith('/* transformed */', $queries[0]); + $this->assertStringEndsWith('/* transformed */', $queries[1]); + } + + public function testJoinSkipsFastPath(): void + { + $statement = self::createStub(\PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + $statement->method('execute')->willReturn(true); + $statement->method('fetch')->willReturn(false); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->getDocument( + new Document([Document::ID => 'collection']), + 'document', + [Query::leftJoin('orders', 'j0', [Query::on('$id', 'customerId')])] + ); + + $this->assertNotSame('', $sql); + $this->assertStringContainsString('JOIN', $sql); + $this->assertDoesNotMatchRegularExpression( + '/WHERE\s+`_uid`\s*=\s*:_uid\s*$/', + $sql + ); + $this->assertJoinProjection($sql); + } + + public function testJoinWithoutSelectLeavesJoinedInternalsOut(): void + { + $sql = $this->captureGetDocumentSql([ + Query::leftJoin('orders', 'j0', [Query::on('$id', 'customerId')]), + ]); + + $this->assertJoinProjection($sql); + $this->assertStringContainsString('LEFT JOIN', $sql); + } + + public function testJoinAliasWithoutSelectLeavesJoinedInternalsOut(): void + { + $sql = $this->captureGetDocumentSql([ + Query::join('orders', 'rev', [Query::on('$id', 'customerId')]), + ]); + + $this->assertJoinProjection($sql, joinAlias: 'rev'); + } + + public function testGetDocumentFullOuterJoinUsesLeftJoinOnPostgres(): void + { + $sql = $this->captureGetDocumentSql( + [Query::fullOuterJoin('orders', 'j0', [Query::on('$id', 'customerId')])], + postgres: true, + ); + + $this->assertStringContainsString('LEFT JOIN', $sql); + $this->assertStringNotContainsString('FULL OUTER JOIN', $sql); + $this->assertStringNotContainsString('UNION ALL', $sql); + $this->assertJoinProjection($sql, '"'); + } + + /** + * @param array $queries + */ + private function captureGetDocumentSql(array $queries, bool $postgres = false): string + { + $statement = self::createStub(\PDOStatement::class); + $statement->method('bindValue')->willReturn(true); + $statement->method('execute')->willReturn(true); + $statement->method('fetch')->willReturn(false); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $query) use (&$sql, $statement): \PDOStatement { + $sql = $query; + + return $statement; + }); + + $adapter = $postgres ? new Postgres($pdo) : new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->getDocument( + new Document([Document::ID => 'collection']), + 'document', + $queries + ); + + $this->assertNotSame('', $sql); + + return $sql; + } + + private function assertJoinProjection(string $sql, string $quote = '`', string $joinAlias = 'j0'): void + { + $this->assertStringContainsString($quote.'table_main'.$quote.'.*', $sql); + $this->assertStringContainsString($quote.$joinAlias.$quote.'.'.$quote.'_uid'.$quote.' AS '.$quote.$joinAlias.'._uid'.$quote, $sql); + $this->assertStringNotContainsString($quote.$joinAlias.$quote.'.*', $sql); + $this->assertDoesNotMatchRegularExpression('/SELECT\s+\*(?:\s|,|$)/i', $sql); + $this->assertDoesNotMatchRegularExpression('/FROM\s*\(\s*SELECT\s+\*/i', $sql); } private function assertTimeout(MySQL $adapter, PDOException $exception): void { try { $adapter->getDocument( - new Document(['$id' => 'collection']), + new Document([Document::ID => 'collection']), 'document' ); } catch (TimeoutException $timeout) { @@ -140,9 +361,13 @@ private function createMySQL(\PDOStatement $statement): MySQL private function createTimeoutException(): PDOException { - $exception = new PDOException('Query execution was interrupted'); - $code = new ReflectionProperty(Exception::class, 'code'); - $code->setValue($exception, 'HY000'); + $exception = new class ('Query execution was interrupted', 'HY000') extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; $exception->errorInfo = ['HY000', 3024, 'Query execution was interrupted']; return $exception; diff --git a/tests/unit/SQLOperatorBindingTest.php b/tests/unit/SQLOperatorBindingTest.php new file mode 100644 index 0000000000..1b280127fb --- /dev/null +++ b/tests/unit/SQLOperatorBindingTest.php @@ -0,0 +1,42 @@ +setDatabase('operators')->setNamespace('operators'); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(Collection::create( + id: 'scores', + attributes: [Attribute::integer(key: 'value')], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + $database->createDocument('scores', new Document(['$id' => 'first', 'value' => 1])); + + $database->upsertDocuments('scores', [new Document(['$id' => 'first', 'value' => Operator::increment(2)])]); + + $this->assertSame(3, $database->getDocument('scores', 'first')->getAttribute('value')); + } +} diff --git a/tests/unit/SQLTimeoutScopeTest.php b/tests/unit/SQLTimeoutScopeTest.php new file mode 100644 index 0000000000..b8d2f50172 --- /dev/null +++ b/tests/unit/SQLTimeoutScopeTest.php @@ -0,0 +1,254 @@ +getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2)) + ->method('exec') + ->willReturnCallback(function (string $sql) use (&$session): int { + $session[] = $sql; + + return 0; + }); + + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->exactly(2))->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willReturn([]); + $statement->expects($this->once())->method('closeCursor')->willReturn(true); + $pdo->expects($this->exactly(2))->method('prepare')->willReturn($statement); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $adapter->setTimeout(25, Event::DocumentFind); + + $adapter->find( + new Document(['$id' => 'movies']), + orderAttributes: ['$sequence'], + ); + $this->assertTrue($adapter->deleteCollection('movies')); + + $this->assertSame([ + 'SET SESSION MAX_EXECUTION_TIME = 25', + 'SET SESSION MAX_EXECUTION_TIME = 0', + ], $session); + } + + public function testPublicStatementsUseExactBatchSchemaAndPermissionEvents(): void + { + $transform = new class () implements Transform { + /** @var list */ + public array $events = []; + + #[\Override] + public function transform(Event $event, string $query): string + { + $this->events[] = $event; + + return $query; + } + }; + + $adapter = new SQLite(new DatabasePDO('sqlite::memory:', null, null)); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $adapter->addTransform('events', $transform); + + $this->assertTrue($adapter->createCollection('movies')); + $this->assertContains(Event::CollectionCreate, $transform->events); + $this->assertNotContains(Event::IndexCreate, $transform->events); + $transform->events = []; + + $this->assertTrue($adapter->createAttributes('movies', [ + Attribute::integer(key: 'score'), + Attribute::string(key: 'label', size: 32), + ])); + $this->assertContains(Event::AttributesCreate, $transform->events); + $this->assertNotContains(Event::AttributeCreate, $transform->events); + $transform->events = []; + + $collection = new Document(['$id' => 'movies']); + $created = new Document(['$id' => 'batch', 'score' => 1, 'label' => 'before', '$permissions' => []]); + $adapter->createDocuments($collection, [$created]); + $this->assertContains(Event::DocumentsCreate, $transform->events); + $this->assertNotContains(Event::DocumentCreate, $transform->events); + $created = $adapter->getDocument($collection, 'batch'); + $updated = new Document([ + '$id' => 'batch', + '$sequence' => $created->getSequence(), + 'score' => 2, + 'label' => 'after', + '$permissions' => [], + ]); + $transform->events = []; + $adapter->upsertDocuments($collection, [new Change($created, $updated)]); + $this->assertContains(Event::DocumentsUpsert, $transform->events); + $this->assertNotContains(Event::DocumentCreate, $transform->events); + $transform->events = []; + $adapter->increaseDocumentAttribute($collection, 'batch', 'score', 1, '2026-08-13 00:00:00.000'); + $adapter->increaseDocumentAttribute($collection, 'batch', 'score', -1, '2026-08-13 00:00:00.000'); + $this->assertContains(Event::DocumentIncrease, $transform->events); + $this->assertContains(Event::DocumentDecrease, $transform->events); + + $adapter->addWriteHook(new Permissions()); + $permissionDocument = $adapter->createDocument($collection, new Document([ + '$id' => 'permissioned', + '$permissions' => [Permission::read(Role::any())], + ])); + $permissionDocument['$permissions'] = [Permission::read(Role::user('one'))]; + $adapter->updateDocument($collection, 'permissioned', $permissionDocument, false); + $adapter->deleteDocument($collection, 'permissioned'); + + foreach ([ + Event::PermissionsCreate, + Event::PermissionsRead, + Event::PermissionsDelete, + ] as $event) { + $this->assertContains($event, $transform->events); + } + } + + public function testMySQLGlobalTimeoutUsesOnlyTheMySQLSessionVariable(): void + { + $statements = []; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2)) + ->method('exec') + ->willReturnCallback(function (string $sql) use (&$statements): int { + $statements[] = $sql; + + return 0; + }); + + $adapter = new MySQL($pdo); + $adapter->setTimeout(1000); + $adapter->clearTimeout(); + + $this->assertSame([ + 'SET SESSION MAX_EXECUTION_TIME = 1000', + 'SET SESSION MAX_EXECUTION_TIME = 0', + ], $statements); + } + + public function testClearingOneScopePreservesTheGlobalTimeout(): void + { + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2))->method('exec')->willReturn(0); + + $adapter = new MariaDB($pdo); + $adapter->setTimeout(1000); + $adapter->setTimeout(25, Event::DocumentFind); + + $this->assertSame(25, $adapter->getTimeout(Event::DocumentFind)); + $this->assertSame(1000, $adapter->getTimeout(Event::DocumentCreate)); + + $adapter->clearTimeout(Event::DocumentFind); + + $this->assertSame(1000, $adapter->getTimeout(Event::DocumentFind)); + $this->assertSame(1000, $adapter->getTimeout(Event::DocumentCreate)); + + $adapter->clearTimeout(); + $this->assertSame(0, $adapter->getTimeout(Event::DocumentFind)); + } + + /** + * @param class-string $adapterClass + * @param list $expected + */ + #[DataProvider('adapters')] + public function testReadScopeIsAppliedOnlyAroundMatchingOperations(string $adapterClass, array $expected): void + { + $statements = []; + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->exactly(2)) + ->method('exec') + ->willReturnCallback(function (string $sql) use (&$statements): int { + $statements[] = $sql; + + return 0; + }); + + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->exactly(2))->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('fetch')->willReturn([Storage::SEQUENCE => 1]); + $statement->method('closeCursor')->willReturn(true); + $pdo->expects($this->exactly(2))->method('prepare')->willReturn($statement); + $pdo->method('lastInsertId')->willReturn('1'); + + $adapter = new $adapterClass($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + $adapter->setTimeout(25, Event::DocumentFind); + + $collection = new Document(['$id' => 'movies']); + $adapter->find($collection, orderAttributes: [Document::SEQUENCE]); + $adapter->createDocument($collection, new Document(['$id' => 'movie', '$permissions' => []])); + + $this->assertSame($expected, $statements); + } + + /** + * @return iterable, list}> + */ + public static function adapters(): iterable + { + yield 'MariaDB' => [MariaDB::class, [ + 'SET max_statement_time = 0.025000', + 'SET max_statement_time = 0.000000', + ]]; + yield 'MySQL' => [MySQL::class, [ + 'SET SESSION MAX_EXECUTION_TIME = 25', + 'SET SESSION MAX_EXECUTION_TIME = 0', + ]]; + yield 'PostgreSQL' => [Postgres::class, [ + "SET statement_timeout = '25ms'", + 'RESET statement_timeout', + ]]; + } +} diff --git a/tests/unit/SQLTransactionTest.php b/tests/unit/SQLTransactionTest.php index fc4f5667fa..95cf54aa6e 100644 --- a/tests/unit/SQLTransactionTest.php +++ b/tests/unit/SQLTransactionTest.php @@ -4,10 +4,9 @@ use PDOException; use PHPUnit\Framework\TestCase; -use ReflectionMethod; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Adapter\Postgres; -use Utopia\Database\Adapter\SQL; +use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Transaction as TransactionException; final class SQLTransactionTest extends TestCase @@ -63,9 +62,6 @@ public function testStartTransactionDoesNotMaskBeginFailureAfterDesyncedRollback public function testPostgresStartTransactionRecoversFromDesyncedRollback(): void { - $method = new ReflectionMethod(Postgres::class, 'startTransaction'); - $this->assertSame(SQL::class, $method->getDeclaringClass()->getName()); - $pdo = $this->getMockBuilder(\PDO::class) ->disableOriginalConstructor() ->getMock(); @@ -83,4 +79,58 @@ public function testPostgresStartTransactionRecoversFromDesyncedRollback(): void $this->assertTrue($adapter->startTransaction()); $this->assertTrue($adapter->inTransaction()); } + + public function testPostgresStartTransactionPreservesFalseResultFailure(): void + { + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + + $pdo->method('inTransaction')->willReturn(true); + $pdo->expects($this->once()) + ->method('rollBack') + ->willReturn(true); + $pdo->expects($this->once()) + ->method('beginTransaction') + ->willReturn(false); + + $adapter = new Postgres($pdo); + + $this->expectException(TransactionException::class); + $this->expectExceptionMessage('Failed to start transaction'); + + $adapter->startTransaction(); + } + + public function testPostgresRollbackPreservesFalseResultFailure(): void + { + $statement = self::createStub(\PDOStatement::class); + $statement->method('execute')->willReturn(true); + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->method('prepare')->willReturn($statement); + $pdo->method('beginTransaction')->willReturn(true); + $pdo->expects($this->once()) + ->method('rollBack') + ->willReturn(false); + + $adapter = new Postgres($pdo); + $this->assertTrue($adapter->startTransaction()); + + $this->expectException(TransactionException::class); + $this->expectExceptionMessage('Failed to rollback transaction'); + + $adapter->rollbackTransaction(); + } + + public function testAConnectionThatIsNotAPdoIsRefusedOnUse(): void + { + $adapter = new MySQL(new \stdClass()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQL adapter requires Utopia\\Database\\PDO, Swoole\\Database\\PDOProxy, or PDO'); + + $adapter->reconnect(); + } } diff --git a/tests/unit/SQLiteArrayContainsTest.php b/tests/unit/SQLiteArrayContainsTest.php new file mode 100644 index 0000000000..072fff9d97 --- /dev/null +++ b/tests/unit/SQLiteArrayContainsTest.php @@ -0,0 +1,209 @@ +> + */ + private const array DOCUMENTS = [ + 't1' => ['labels' => ['a', 'b'], 'numbers' => [1, 2], 'scores' => [0.1, 1.5], 'flags' => [true]], + 't2' => ['labels' => ['c'], 'numbers' => [3], 'scores' => [2.5], 'flags' => [false]], + 't3' => ['labels' => [], 'numbers' => [], 'scores' => [], 'flags' => []], + 't4' => ['labels' => ['é', 'q"x', '1'], 'numbers' => [10], 'scores' => [], 'flags' => []], + 't5' => [], + ]; + + /** + * @return iterable}> + */ + public static function containsAnyQueries(): iterable + { + yield 'strings' => [Query::containsAny('labels', ['a', 'c']), ['t1', 't2']]; + yield 'a non-ASCII string' => [Query::containsAny('labels', ['é']), ['t4']]; + yield 'a string with a double quote' => [Query::containsAny('labels', ['q"x']), ['t4']]; + yield 'a numeric string' => [Query::containsAny('labels', ['1']), ['t4']]; + yield 'integers' => [Query::containsAny('numbers', [2, 3]), ['t1', 't2']]; + yield 'doubles' => [Query::containsAny('scores', [0.1, 2.5]), ['t1', 't2']]; + yield 'true' => [Query::containsAny('flags', [true]), ['t1']]; + yield 'false' => [Query::containsAny('flags', [false]), ['t2']]; + yield 'no element' => [Query::containsAny('labels', ['z']), []]; + } + + /** + * @return iterable}> + */ + public static function containsAllQueries(): iterable + { + yield 'every string present' => [Query::containsAll('labels', ['a', 'b']), ['t1']]; + yield 'one string missing' => [Query::containsAll('labels', ['a', 'c']), []]; + yield 'non-ASCII and quoted strings' => [Query::containsAll('labels', ['é', 'q"x']), ['t4']]; + yield 'integers' => [Query::containsAll('numbers', [1, 2]), ['t1']]; + yield 'doubles' => [Query::containsAll('scores', [0.1, 1.5]), ['t1']]; + yield 'a boolean' => [Query::containsAll('flags', [false]), ['t2']]; + } + + /** + * @return iterable}> + */ + public static function notContainsQueries(): iterable + { + yield 'a string' => [Query::notContains('labels', ['a']), ['t2', 't3', 't4']]; + yield 'any of several strings' => [Query::notContains('labels', ['a', 'c']), ['t3', 't4']]; + yield 'a non-ASCII string' => [Query::notContains('labels', ['é']), ['t1', 't2', 't3']]; + yield 'an integer' => [Query::notContains('numbers', [1]), ['t2', 't3', 't4']]; + yield 'a double' => [Query::notContains('scores', [2.5]), ['t1', 't3', 't4']]; + yield 'a boolean' => [Query::notContains('flags', [true]), ['t2', 't3', 't4']]; + } + + /** + * @return iterable}> + */ + public static function deprecatedContainsQueries(): iterable + { + yield 'a string' => [new Query(Method::Contains, 'labels', ['a']), ['t1']]; + yield 'integers' => [new Query(Method::Contains, 'numbers', [3, 10]), ['t2', 't4']]; + yield 'a boolean' => [new Query(Method::Contains, 'flags', [false]), ['t2']]; + } + + /** + * @param list $expected + */ + #[DataProvider('containsAnyQueries')] + public function testContainsAnyMatchesAnyElement(Query $query, array $expected): void + { + $this->assertMatches($query, $expected); + } + + /** + * @param list $expected + */ + #[DataProvider('containsAllQueries')] + public function testContainsAllMatchesEveryElement(Query $query, array $expected): void + { + $this->assertMatches($query, $expected); + } + + /** + * @param list $expected + */ + #[DataProvider('notContainsQueries')] + public function testNotContainsExcludesMatchingRows(Query $query, array $expected): void + { + $this->assertMatches($query, $expected); + } + + /** + * @param list $expected + */ + #[DataProvider('deprecatedContainsQueries')] + public function testDeprecatedContainsOnArrays(Query $query, array $expected): void + { + $this->assertMatches($query, $expected); + } + + public function testJsonFiltersCompareElementsByValue(): void + { + $pdo = new PDO('sqlite::memory:'); + $pdo->exec('CREATE TABLE '.self::TABLE.' (id TEXT, labels TEXT)'); + $insert = $pdo->prepare('INSERT INTO '.self::TABLE.' (id, labels) VALUES (?, ?)'); + foreach (['t1' => '["a","b"]', 't2' => '["c"]', 't3' => '[]', 't4' => '[1,2.5,"1"]', 't5' => null] as $id => $labels) { + $insert->execute([$id, $labels]); + } + + $this->assertSame(['t1', 't2'], $this->selectIds($pdo, $this->builder()->filterJsonOverlaps('labels', ['a', 'c']))); + $this->assertSame(['t4'], $this->selectIds($pdo, $this->builder()->filterJsonOverlaps('labels', [2.5]))); + $this->assertSame(['t1'], $this->selectIds($pdo, $this->builder()->filterJsonContains('labels', ['a', 'b']))); + $this->assertSame(['t4'], $this->selectIds($pdo, $this->builder()->filterJsonContains('labels', [1, '1']))); + $this->assertSame([], $this->selectIds($pdo, $this->builder()->filterJsonContains('labels', ['a', 'c']))); + $this->assertSame(['t2', 't3', 't4'], $this->selectIds($pdo, $this->builder()->filterJsonNotContains('labels', 'a'))); + } + + /** + * @param list $expected + */ + private function assertMatches(Query $query, array $expected): void + { + $database = $this->database(); + $countQuery = clone $query; + + $ids = \array_map( + fn (Document $document): string => $document->getId(), + $database->find(self::COLLECTION, [$query]), + ); + \sort($ids); + + $this->assertSame($expected, $ids); + $this->assertSame(\count($expected), $database->count(self::COLLECTION, [$countQuery])); + } + + private function database(): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setDatabase('array_contains') + ->setNamespace('array_contains') + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string('labels', size: 32, array: true), + Attribute::integer('numbers', array: true), + Attribute::double('scores', array: true), + Attribute::boolean('flags', array: true), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + )); + + foreach (self::DOCUMENTS as $id => $attributes) { + $database->createDocument(self::COLLECTION, new Document(['$id' => $id, ...$attributes])); + } + + return $database; + } + + private function builder(): SQLiteBuilder + { + return (new SQLiteBuilder())->from(self::TABLE)->select(['id']); + } + + /** + * @return list + */ + private function selectIds(PDO $pdo, SQLiteBuilder $builder): array + { + $statement = $builder->sortAsc('id')->build(); + $prepared = $pdo->prepare($statement->query); + $prepared->execute($statement->bindings); + + /** @var list */ + return $prepared->fetchAll(PDO::FETCH_COLUMN); + } +} diff --git a/tests/unit/SQLiteDigitLeadingNamespaceTest.php b/tests/unit/SQLiteDigitLeadingNamespaceTest.php new file mode 100644 index 0000000000..318a404f1c --- /dev/null +++ b/tests/unit/SQLiteDigitLeadingNamespaceTest.php @@ -0,0 +1,79 @@ +database($schema, $namespace); + + $this->assertSame(['public'], \array_map( + static fn (Document $document): string => $document->getId(), + $database->find(self::COLLECTION), + )); + $this->assertSame(1, $database->count(self::COLLECTION)); + $this->assertSame('public', $database->getDocument(self::COLLECTION, 'public')->getId()); + $this->assertTrue($database->getDocument(self::COLLECTION, 'private')->isEmpty()); + } + + /** + * @return iterable + */ + public static function names(): iterable + { + yield 'a leading letter' => ['digit_leading', 'ns1']; + yield 'a leading digit' => ['1db', '1ns']; + yield 'a leading hyphen' => ['-db', '-ns']; + } + + private function database(string $schema, string $namespace): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = (new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None()))) + ->setAuthorization($authorization) + ->setDatabase($schema) + ->setNamespace($namespace) + ->addHook(new Permissions()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'public', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Readable', + ])); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => 'private', + '$permissions' => [Permission::read(Role::user('owner'))], + 'title' => 'Hidden', + ])); + + return $database; + } +} diff --git a/tests/unit/SQLiteForUpdateDocumentTest.php b/tests/unit/SQLiteForUpdateDocumentTest.php new file mode 100644 index 0000000000..6ed7d24014 --- /dev/null +++ b/tests/unit/SQLiteForUpdateDocumentTest.php @@ -0,0 +1,63 @@ +setDatabase('for_update') + ->setNamespace('for_update_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(Collection::create( + id: 'migrations', + attributes: [ + Attribute::string('status'), + Attribute::string('stage'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::delete(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + + $active = $database->createDocument('migrations', new Document([ + '$id' => 'migration', + 'status' => 'processing', + 'stage' => 'processing', + ])); + + $locked = $database->getDocument('migrations', $active->getId(), forUpdate: true); + $this->assertArrayNotHasKey(0, $locked->getArrayCopy()); + $this->assertNull($locked->getAttribute('0')); + + $database->setPreserveDates(true); + $newer = $database->updateDocument('migrations', $active->getId(), new Document([ + '$updatedAt' => $active->getUpdatedAt(), + 'stage' => 'migrating', + ])); + + $this->assertSame('migrating', $newer->getAttribute('stage')); + $this->assertSame('processing', $newer->getAttribute('status')); + $this->assertSame($active->getUpdatedAt(), $newer->getUpdatedAt()); + } +} diff --git a/tests/unit/SQLiteInsertStatementTest.php b/tests/unit/SQLiteInsertStatementTest.php new file mode 100644 index 0000000000..0ef28acb09 --- /dev/null +++ b/tests/unit/SQLiteInsertStatementTest.php @@ -0,0 +1,101 @@ + + */ + public static function tables(): iterable + { + yield 'plain tables' => [false, false]; + yield 'plain tables, document permissions' => [false, true]; + yield 'shared tables' => [true, false]; + yield 'shared tables, document permissions' => [true, true]; + } + + #[DataProvider('tables')] + public function testCreateDocumentRunsOneInsertPerTableAndNoSelect(bool $shared, bool $documentPermissions): void + { + $database = $this->database($shared); + $database->createDocument(self::COLLECTION, $this->document('warmup', $documentPermissions)); + + $profiler = $database->setProfiling(true)->getProfiler(); + $this->assertNotNull($profiler); + $profiler->reset(); + + $created = $database->createDocument(self::COLLECTION, $this->document('measured', $documentPermissions)); + + $statements = \array_map( + static fn (Log $log): string => \strtoupper(\ltrim((string) \preg_replace('#/\*.*?\*/#s', '', $log->query))), + $profiler->getLogs(), + ); + $inserts = \array_values(\array_filter($statements, static fn (string $statement): bool => \str_starts_with($statement, 'INSERT'))); + $selects = \array_values(\array_filter($statements, static fn (string $statement): bool => \str_starts_with($statement, 'SELECT'))); + + $this->assertSame([], $selects); + $this->assertCount($documentPermissions ? 2 : 1, $inserts); + $this->assertSame(\count($inserts), \count(\array_unique(\array_map( + static fn (string $statement): string => (string) \preg_replace('/^INSERT\s+INTO\s+(\S+).*$/s', '$1', $statement), + $inserts, + )))); + + $this->assertSame('2', $created->getSequence()); + $this->assertSame('measured', $database->getDocument(self::COLLECTION, 'measured')->getId()); + } + + private function document(string $id, bool $documentPermissions): Document + { + return new Document([ + Document::ID => $id, + Document::PERMISSIONS => $documentPermissions ? [Permission::read(Role::any())] : [], + 'body' => $id, + ]); + } + + private function database(bool $shared): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new Memory())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('insert_statement') + ->setNamespace('insert_statement'); + + if ($shared) { + $database->setSharedTables(true)->setTenant(1); + } + + $database->create(); + $database->addHook(new Permissions()); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'body', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: true, + )); + + return $database; + } +} diff --git a/tests/unit/SQLiteLikeEscapeTest.php b/tests/unit/SQLiteLikeEscapeTest.php new file mode 100644 index 0000000000..ee7d3d816c --- /dev/null +++ b/tests/unit/SQLiteLikeEscapeTest.php @@ -0,0 +1,123 @@ +}> + */ + public static function patterns(): iterable + { + yield 'contains an underscore' => [Query::containsString('name', ['a_b']), ['a_b']]; + yield 'contains a percent sign' => [Query::containsString('name', ['c%d']), ['c%d']]; + yield 'contains a backslash' => [Query::containsString('name', ['e\\f']), ['e\\f']]; + yield 'contains any of several' => [Query::containsAny('name', ['a_b', 'c%d']), ['a_b', 'c%d']]; + yield 'contains all of several' => [Query::containsAll('name', ['c%', '%d']), ['c%d']]; + yield 'does not contain an underscore' => [Query::notContains('name', ['_']), ['axb', 'c%d', 'cxxd', 'e\\f', 'e\\\\f']]; + yield 'contains none of several' => [Query::notContains('name', ['%', '\\']), ['a_b', 'axb', 'cxxd']]; + yield 'starts with an underscore' => [Query::startsWith('name', 'a_'), ['a_b']]; + yield 'starts with a percent sign' => [Query::startsWith('name', 'c%'), ['c%d']]; + yield 'starts with a backslash' => [Query::startsWith('name', 'e\\f'), ['e\\f']]; + yield 'ends with an underscore' => [Query::endsWith('name', '_b'), ['a_b']]; + yield 'ends with a percent sign' => [Query::endsWith('name', '%d'), ['c%d']]; + yield 'ends with backslashes' => [Query::endsWith('name', '\\\\f'), ['e\\\\f']]; + yield 'does not start with an underscore' => [Query::notStartsWith('name', 'a_'), ['axb', 'c%d', 'cxxd', 'e\\f', 'e\\\\f']]; + yield 'does not start with a backslash' => [Query::notStartsWith('name', 'e\\'), ['a_b', 'axb', 'c%d', 'cxxd']]; + yield 'does not end with a percent sign' => [Query::notEndsWith('name', '%d'), ['a_b', 'axb', 'cxxd', 'e\\f', 'e\\\\f']]; + } + + /** + * @param list $expected + */ + #[DataProvider('patterns')] + public function testPatternQueriesMatchWildcardCharactersLiterally(Query $query, array $expected): void + { + $database = $this->database(); + + $names = \array_map( + fn (Document $document): mixed => $document->getAttribute('name'), + $database->find(self::COLLECTION, [$query]), + ); + \sort($names); + \sort($expected); + + $this->assertSame($expected, $names); + $this->assertSame(\count($expected), $database->count(self::COLLECTION, [$query])); + } + + /** + * @return iterable}> + */ + public static function unindexedSearches(): iterable + { + yield 'one backslash' => ['e\\f', ['e\\f']]; + yield 'two backslashes' => ['e\\\\f', ['e\\\\f']]; + yield 'an underscore' => ['a_b', ['a_b']]; + } + + /** + * @param list $expected + */ + #[DataProvider('unindexedSearches')] + public function testSearchFallbackMatchesALiteralBackslash(string $term, array $expected): void + { + $database = $this->database(); + + $names = $database->skipValidation(fn (): array => \array_map( + fn (Document $document): mixed => $document->getAttribute('name'), + $database->find(self::COLLECTION, [Query::search('name', $term)]), + )); + + $this->assertSame($expected, $names); + } + + private function database(): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setDatabase('like_escape') + ->setNamespace('like_escape') + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string('name', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + )); + + foreach (self::NAMES as $name) { + $database->createDocument(self::COLLECTION, new Document(['name' => $name])); + } + + return $database; + } +} diff --git a/tests/unit/SQLiteOperatorBehaviorTest.php b/tests/unit/SQLiteOperatorBehaviorTest.php new file mode 100644 index 0000000000..cdc0145f2a --- /dev/null +++ b/tests/unit/SQLiteOperatorBehaviorTest.php @@ -0,0 +1,88 @@ +adapter = new SQLite(new \PDO('sqlite::memory:')); + $this->adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $this->adapter->setAuthorization($authorization); + + $this->collection = new Document([ + '$id' => 'operators', + 'attributes' => [new Document(['$id' => 'value', 'type' => ColumnType::Double->value])], + ]); + $this->adapter->createCollection('operators', [ + Attribute::double(key: 'value'), + ]); + } + + public function testInclusiveIncrementBoundAppliesExactMaximum(): void + { + $this->create('increment', 5.0); + + $this->update('increment', Operator::increment(5, 10)); + + $this->assertSame(10.0, $this->value('increment')); + } + + public function testPowerAboveMaximumLeavesValueUnchanged(): void + { + $this->create('power', 5.0); + + $this->update('power', Operator::power(3, 100)); + + $this->assertSame(5.0, $this->value('power')); + } + + public function testFractionalPowerBelowMaximumIsApplied(): void + { + $this->create('root', 100.0); + + $this->update('root', Operator::power(0.5, 50)); + + $this->assertSame(10.0, $this->value('root')); + } + + private function create(string $id, float $value): void + { + $this->adapter->createDocument($this->collection, new Document([ + '$id' => $id, + '$permissions' => [], + 'value' => $value, + ])); + } + + private function update(string $id, Operator $operator): void + { + $this->adapter->updateDocument($this->collection, $id, new Document([ + '$id' => $id, + 'value' => $operator, + ]), true); + } + + private function value(string $id): float + { + $value = $this->adapter->getDocument($this->collection, $id)->getAttribute('value'); + $this->assertIsNumeric($value); + + return (float) $value; + } +} diff --git a/tests/unit/SQLitePlanTest.php b/tests/unit/SQLitePlanTest.php new file mode 100644 index 0000000000..2e873e8267 --- /dev/null +++ b/tests/unit/SQLitePlanTest.php @@ -0,0 +1,411 @@ +}> + */ + private ArrayObject $statements; + + #[\Override] + protected function setUp(): void + { + $this->statements = new ArrayObject(); + } + + #[\Override] + protected function tearDown(): void + { + if ($this->path !== '' && \is_file($this->path)) { + \unlink($this->path); + } + } + + /** + * @return iterable + */ + public static function modes(): iterable + { + foreach ([false, true] as $shared) { + foreach ([true, false] as $analyzed) { + yield self::mode($shared, $analyzed) => [$shared, $analyzed]; + } + } + } + + /** + * @return iterable + */ + public static function selfJoinCounts(): iterable + { + foreach (self::modes() as $mode => [$shared, $analyzed]) { + foreach ([false, true] as $nested) { + for ($joins = 1; $joins <= self::MAX_JOINS; $joins++) { + yield $mode.', '.$joins.($nested ? ' nested' : '').' self-joins' => [$shared, $analyzed, $joins, $nested]; + } + } + } + } + + #[DataProvider('modes')] + public function testGetDocumentSearchesTheIdIndex(bool $shared, bool $analyzed): void + { + $database = $this->database($shared, $analyzed); + + $document = $this->recording(fn (): Document => $database->getDocument(self::COLLECTION, $this->id(27))); + + $this->assertSame($this->id(27), $document->getId()); + $this->assertIndexedPlans($analyzed); + } + + #[DataProvider('modes')] + public function testFindByIdSearchesTheIdIndex(bool $shared, bool $analyzed): void + { + $database = $this->database($shared, $analyzed); + + $documents = $this->recording(fn (): array => $database->find(self::COLLECTION, [ + Query::equal('$id', [$this->id(3), $this->id(6), $this->id(7)]), + ])); + + $this->assertSame([$this->id(3), $this->id(6)], $this->ids($documents)); + $this->assertIndexedPlans($analyzed); + } + + #[DataProvider('modes')] + public function testRestrictedFindSearchesThroughThePermissionIndex(bool $shared, bool $analyzed): void + { + $database = $this->database($shared, $analyzed); + + $documents = $this->recording(fn (): array => $database->find(self::COLLECTION, [Query::limit(self::DOCUMENTS)])); + + $this->assertSame($this->readableIds(), $this->ids($documents)); + $this->assertIndexedPlans($analyzed); + } + + #[DataProvider('selfJoinCounts')] + public function testSelfJoinsSearchAnIndexPerAlias(bool $shared, bool $analyzed, int $joins, bool $nested): void + { + $database = $this->database($shared, $analyzed); + + $documents = $this->recording(fn (): array => $database->find(self::COLLECTION, [ + ...$this->selfJoins($joins, $nested), + Query::limit(self::DOCUMENTS), + ])); + + $this->assertSame($this->readableIds(), $this->ids($documents)); + $this->assertIndexedPlans($analyzed); + } + + public function testSharedSelfJoinOfFourReturnsTheRightRows(): void + { + $this->pdo = new PDO('sqlite::memory:'); + $this->authorization = new Authorization(); + $database = $this->handle(true, self::OTHER_TENANT); + $database->create(); + $this->createCollection($database); + $this->seed($database, ['a', 'b', 'c'], 'other'); + + $database = $this->handle(true, self::TENANT); + $this->createCollection($database); + $this->seed($database, ['a', 'b', 'c', 'd'], 'own'); + $this->authorization->addRole(Role::user(self::READER)->toString()); + + $documents = $database->find(self::COLLECTION, $this->selfJoins(4)); + + $rows = \array_map( + fn (Document $document): array => [ + $document->getId(), + ...\array_map( + fn (int $join): mixed => $document->getAttribute('p'.$join.'.name'), + \range(1, 4), + ), + ], + $documents, + ); + + $this->assertSame([ + ['a', 'own-a', 'own-a', 'own-a', 'own-a'], + ['d', 'own-d', 'own-d', 'own-d', 'own-d'], + ], $rows); + } + + #[DataProvider('modes')] + public function testIdComparisonsIgnoreCase(bool $shared, bool $analyzed): void + { + $database = $this->database($shared, $analyzed); + $id = \strtoupper($this->id(6)); + + $this->assertSame($this->id(6), $database->getDocument(self::COLLECTION, $id)->getId()); + $this->assertSame([$this->id(6)], $this->ids($database->find(self::COLLECTION, [Query::equal('$id', [$id])]))); + $this->assertSame( + \array_values(\array_diff($this->readableIds(), [$this->id(6)])), + $this->ids($database->find(self::COLLECTION, [Query::notEqual('$id', $id), Query::limit(self::DOCUMENTS)])), + ); + } + + private static function mode(bool $shared, bool $analyzed): string + { + return ($shared ? 'shared' : 'plain').' tables '.($analyzed ? 'with' : 'without').' statistics'; + } + + private function database(bool $shared, bool $analyzed = true): Database + { + $this->path = (string) \tempnam(\sys_get_temp_dir(), 'sqlite-plan-'); + $this->pdo = new PDO('sqlite:'.$this->path); + $this->authorization = new Authorization(); + + $database = $this->handle($shared, self::TENANT); + $database->create(); + $this->createCollection($database); + + $ids = \array_map($this->id(...), \range(0, self::DOCUMENTS - 1)); + $this->seed($database, $ids, 'name'); + + if ($analyzed) { + $this->pdo->exec('ANALYZE'); + } + $this->authorization->addRole(Role::user(self::READER)->toString()); + + return $database; + } + + private function handle(bool $shared, int $tenant): Database + { + $adapter = new class ($this->pdo, $this->statements) extends SQLite { + /** + * @param ArrayObject}> $statements + */ + public function __construct(object $pdo, private readonly ArrayObject $statements) + { + parent::__construct($pdo); + } + + #[\Override] + protected function prepareStatement(string $sql, ?Event $event = null): DatabasePDOStatement|PDOStatementProxy|PDOStatement + { + $this->statements->append([$sql, []]); + + return parent::prepareStatement($sql, $event); + } + + #[\Override] + protected function executeResult(Statement $result, ?Event $event = null, string $collection = ''): PDOStatement|DatabasePDOStatement|PDOStatementProxy + { + $statement = parent::executeResult($result, $event, $collection); + $this->statements[$this->statements->count() - 1] = [$result->query, $result->bindings]; + + return $statement; + } + }; + + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setAuthorization($this->authorization); + $database->addHook(new Permissions()); + + if ($shared) { + $database->setSharedTables(true)->setTenant($tenant); + } + + return $database; + } + + private function createCollection(Database $database): void + { + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string('name', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + } + + /** + * Every third document, up to READABLE of them, is readable by READER; each of the others by a + * user of its own, so the statistics see many users with a few documents each. + * + * @param list $ids + */ + private function seed(Database $database, array $ids, string $prefix): void + { + $readable = 0; + $documents = []; + foreach ($ids as $position => $id) { + $reader = $position % 3 === 0 && $readable++ < self::READABLE ? self::READER : self::OTHER_READER.$id; + $documents[] = new Document([ + '$id' => $id, + 'name' => $prefix.'-'.$id, + '$permissions' => [Permission::read(Role::user($reader))], + ]); + } + + $database->createDocuments(self::COLLECTION, $documents); + } + + /** + * @template T + * + * @param callable(): T $operation + * @return T + */ + private function recording(callable $operation): mixed + { + $this->statements->exchangeArray([]); + + return $operation(); + } + + /** + * Without statistics SQLite may walk the (_tenant, _id) index for the driving table to serve the + * ORDER BY, so only there the driving table need not be looked up by id. + */ + private function assertIndexedPlans(bool $analyzed): void + { + $plans = []; + foreach ($this->statements as [$sql, $bindings]) { + if (! \str_contains($sql, 'SELECT') || ! \str_contains($sql, self::TABLE)) { + continue; + } + $plans[] = [$sql, $this->plan($sql, $bindings)]; + } + + $this->assertNotSame([], $plans, 'The operation must have read the collection'); + + foreach ($plans as [$sql, $details]) { + $report = $sql."\n ".\implode("\n ", $details); + foreach ($details as $detail) { + $this->assertStringStartsNotWith('SCAN ', $detail, 'A read scanned a table: '.$report); + $this->assertStringNotContainsString('AUTOMATIC', $detail, 'A read built a throwaway index: '.$report); + + if ( + \preg_match(self::SEARCH_PATTERN, $detail, $match) === 1 + && $match[1] !== self::PERMISSIONS_TABLE + && ($analyzed || $match[1] !== Query::DEFAULT_ALIAS) + ) { + $this->assertStringContainsString(self::ID_SEARCH, $detail, 'Every alias of the collection must be looked up by id: '.$report); + } + } + } + } + + /** + * @param list $bindings + * @return list + */ + private function plan(string $sql, array $bindings): array + { + $statement = $this->pdo->prepare('EXPLAIN QUERY PLAN '.$sql); + $this->assertInstanceOf(PDOStatement::class, $statement); + if ($bindings === []) { + $statement->execute(); + } else { + $statement->execute(\array_map(static fn (mixed $value): mixed => \is_bool($value) ? (int) $value : $value, $bindings)); + } + + /** @var list> $rows */ + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + return \array_map(static fn (array $row): string => (string) $row[self::DETAIL_COLUMN], $rows); + } + + /** + * @return list + */ + private function selfJoins(int $joins, bool $nested = false): array + { + return \array_map( + static fn (int $join): Query => $nested + ? Query::join(self::COLLECTION, 'p'.$join, [Query::on('$id', '$id')]) + : Query::join(self::COLLECTION, 'p'.$join, [Query::on('$id', '$id')]), + \range(1, $joins), + ); + } + + private function id(int $position): string + { + return 'doc'.$position; + } + + /** + * @return list + */ + private function readableIds(): array + { + return \array_map(fn (int $position): string => $this->id($position * 3), \range(0, self::READABLE - 1)); + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): string => $document->getId(), $documents)); + } +} diff --git a/tests/unit/SQLitePrepareTest.php b/tests/unit/SQLitePrepareTest.php new file mode 100644 index 0000000000..8963e67607 --- /dev/null +++ b/tests/unit/SQLitePrepareTest.php @@ -0,0 +1,50 @@ +setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $this->assertTrue($adapter->createCollection('movies')); + + $document = $adapter->createDocument( + new Document(['$id' => 'movies']), + new Document(['$id' => 'movie', '$permissions' => []]), + ); + + $this->assertSame('movie', $document->getId()); + $this->assertSame('movie', $adapter->getDocument(new Document(['$id' => 'movies']), 'movie')->getId()); + } + + public function testExistsThrowsDatabaseExceptionWhenPrepareReturnsFalse(): void + { + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturn(false); + + $adapter = new SQLite($pdo); + $adapter->setNamespace('namespace'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Failed to prepare collection existence query'); + + $adapter->collectionExists('database', 'movies'); + } +} diff --git a/tests/unit/SQLiteRegexTest.php b/tests/unit/SQLiteRegexTest.php new file mode 100644 index 0000000000..b22acf5aec --- /dev/null +++ b/tests/unit/SQLiteRegexTest.php @@ -0,0 +1,79 @@ +database(); + + $names = \array_map( + static fn (Document $document): mixed => $document->getAttribute('name'), + $database->find(self::COLLECTION, [Query::regex('name', self::PATTERN)]), + ); + + $this->assertSame(['abc', 'axc', 'a.c'], $names); + $this->assertSame(3, $database->count(self::COLLECTION, [Query::regex('name', self::PATTERN)])); + } + + public function testRegexNeedsTheUserFunction(): void + { + $database = $this->database(new PDO('sqlite::memory:')); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage('no such function: REGEXP'); + $database->find(self::COLLECTION, [Query::regex('name', self::PATTERN)]); + } + + private function database(PDO|DatabasePDO $connection = new DatabasePDO('sqlite::memory:', null, null)): Database + { + $database = new Database(new SQLite($connection), new Cache(new None())); + $database + ->setDatabase('regex') + ->setNamespace('regex') + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string('name', size: 32)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + )); + + foreach (self::NAMES as $name) { + $database->createDocument(self::COLLECTION, new Document(['name' => $name])); + } + + return $database; + } +} diff --git a/tests/unit/SQLiteSchemaIndexesTest.php b/tests/unit/SQLiteSchemaIndexesTest.php new file mode 100644 index 0000000000..1942201cff --- /dev/null +++ b/tests/unit/SQLiteSchemaIndexesTest.php @@ -0,0 +1,234 @@ + + */ + public static function tables(): iterable + { + yield 'plain tables' => [false]; + yield 'shared tables' => [true]; + } + + #[DataProvider('tables')] + public function testFulltextIndexesAreListedUnderTheirIds(bool $shared): void + { + $database = $this->database($shared); + $database->createIndex(self::COLLECTION, Index::fulltext(key: 'title_search', attributes: ['title'])); + $database->createIndex(self::COLLECTION, Index::fulltext(key: 'body_search', attributes: ['body'])); + + $this->assertSame([ + 'body_search' => [IndexType::Fulltext, ['body']], + 'title_search' => [IndexType::Fulltext, ['title']], + ], $this->fulltextIndexes($database)); + + $database->renameIndex(self::COLLECTION, 'title_search', 'title_lookup'); + + $this->assertSame([ + 'body_search' => [IndexType::Fulltext, ['body']], + 'title_lookup' => [IndexType::Fulltext, ['title']], + ], $this->fulltextIndexes($database)); + } + + #[DataProvider('tables')] + public function testDeletingOneOfTwoFulltextIndexesKeepsTheOther(bool $shared): void + { + $database = $this->database($shared); + $database->createIndex(self::COLLECTION, Index::fulltext(key: 'title_search', attributes: ['title'])); + $database->createIndex(self::COLLECTION, Index::fulltext(key: 'body_search', attributes: ['body'])); + + $database->deleteIndex(self::COLLECTION, 'title_search'); + + $this->assertSame(['body_search' => [IndexType::Fulltext, ['body']]], $this->fulltextIndexes($database)); + $this->assertSame(['fox'], $this->search($database, 'body', 'lazy')); + + try { + $this->search($database, 'title', 'quick'); + $this->fail('A search on an attribute whose fulltext index was deleted must be refused'); + } catch (QueryException $error) { + $this->assertSame('Searching by attribute "title" requires a fulltext index.', $error->getMessage()); + } + + $database->createIndex(self::COLLECTION, Index::fulltext(key: 'title_search', attributes: ['title'])); + $this->assertSame(['fox'], $this->search($database, 'title', 'quick')); + } + + #[DataProvider('tables')] + public function testIndexesAreListedUnderTheirIds(bool $shared): void + { + $database = $this->database($shared); + $database->createIndex(self::COLLECTION, Index::key(key: 'by_title', attributes: ['title'])); + $database->createIndex(self::COLLECTION, Index::unique(key: 'by_body', attributes: ['body'])); + $tenant = $shared ? ['_tenant'] : []; + + $indexes = $this->indexes($database); + + $this->assertSame([IndexType::Key, [...$tenant, 'title']], $indexes['by_title'] ?? null); + $this->assertSame([IndexType::Unique, [...$tenant, 'body']], $indexes['by_body'] ?? null); + $this->assertArrayHasKey('_index1', $indexes); + $namespace = $database->getNamespace(); + $this->assertNotSame('', $namespace); + foreach (\array_keys($indexes) as $id) { + $this->assertStringStartsNotWith($namespace, $id); + } + } + + public function testSharedTablesListEveryTenantsIndexOnceAndPreferTheirOwn(): void + { + $database = $this->database(true); + $database->withTenant(2, function () use ($database): void { + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::string(key: 'body', size: 64), + ], + )); + }); + $database->createIndex(self::COLLECTION, Index::key(key: 'by_title', attributes: ['title'])); + $database->getAdapter()->createIndex(self::COLLECTION, Index::key(key: 'lookup', attributes: ['title'])); + $database->withTenant(2, fn (): bool => $database->getAdapter()->createIndex(self::COLLECTION, Index::unique(key: 'lookup', attributes: ['body']))); + + $first = $this->indexes($database); + $second = $database->withTenant(2, fn (): array => $this->indexes($database)); + + $this->assertSame(\array_keys($first), \array_keys($second)); + $this->assertSame([IndexType::Key, ['_tenant', 'title']], $first['by_title'] ?? null); + $this->assertSame([IndexType::Key, ['_tenant', 'title']], $second['by_title'] ?? null); + $this->assertSame([IndexType::Key, ['_tenant', 'title']], $first['lookup'] ?? null); + $this->assertSame([IndexType::Unique, ['_tenant', 'body']], $second['lookup'] ?? null); + } + + public function testAnOrphanIndexIsListedForReconciliation(): void + { + $database = $this->database(false); + $adapter = $database->getAdapter(); + $adapter->createIndex(self::COLLECTION, Index::key(key: 'lookup', attributes: ['title'])); + + $this->assertSame([IndexType::Key, ['title']], $this->indexes($database)['lookup'] ?? null); + + $this->assertTrue($adapter->deleteIndex(self::COLLECTION, 'lookup')); + $this->assertArrayNotHasKey('lookup', $this->indexes($database)); + + $this->assertTrue($adapter->createIndex(self::COLLECTION, Index::unique(key: 'lookup', attributes: ['body']))); + $this->assertSame([IndexType::Unique, ['body']], $this->indexes($database)['lookup'] ?? null); + } + + #[DataProvider('tables')] + public function testRenamingAnIndexTheSchemaNoLongerHasRebuildsItUnderTheNewName(bool $shared): void + { + $database = $this->database($shared); + $database->createIndex(self::COLLECTION, Index::key(key: 'by_title', attributes: ['title'])); + $database->getAdapter()->deleteIndex(self::COLLECTION, 'by_title'); + $this->assertArrayNotHasKey('by_title', $this->indexes($database)); + + $database->renameIndex(self::COLLECTION, 'by_title', 'by_heading'); + + $indexes = $this->indexes($database); + $this->assertArrayNotHasKey('by_title', $indexes); + $this->assertSame([IndexType::Key, [...($shared ? ['_tenant'] : []), 'title']], $indexes['by_heading'] ?? null, 'the metadata names an index the schema has'); + $this->assertSame(['by_heading'], \array_map( + static fn (Index $index): string => $index->key, + $database->getCollection(self::COLLECTION)->indexes(), + )); + } + + /** + * @return array}> + */ + private function indexes(Database $database): array + { + $indexes = []; + foreach ($database->getSchemaIndexes(self::COLLECTION) as $index) { + $this->assertArrayNotHasKey($index->name, $indexes, 'Each index is listed once'); + $indexes[$index->name] = [$index->type, $index->columns]; + } + \ksort($indexes); + + return $indexes; + } + + /** + * @return array + */ + private function search(Database $database, string $attribute, string $term): array + { + return \array_map( + static fn (Document $document): string => $document->getId(), + $database->find(self::COLLECTION, [Query::search($attribute, $term)]), + ); + } + + /** + * @return array}> + */ + private function fulltextIndexes(Database $database): array + { + $indexes = []; + foreach ($database->getSchemaIndexes(self::COLLECTION) as $index) { + if ($index->type === IndexType::Fulltext) { + $indexes[$index->name] = [$index->type, $index->columns]; + } + } + \ksort($indexes); + + return $indexes; + } + + private function database(bool $shared): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('schema_indexes') + ->setNamespace('schema_indexes'); + + if ($shared) { + $database->setSharedTables(true)->setTenant(1); + } + + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64), + Attribute::string(key: 'body', size: 64), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + $database->createDocument(self::COLLECTION, new Document([ + Document::ID => 'fox', + 'title' => 'quick brown fox', + 'body' => 'lazy dog', + ])); + + return $database; + } +} diff --git a/tests/unit/SQLiteSearchAggregateTest.php b/tests/unit/SQLiteSearchAggregateTest.php new file mode 100644 index 0000000000..91654aa733 --- /dev/null +++ b/tests/unit/SQLiteSearchAggregateTest.php @@ -0,0 +1,63 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $this->database + ->setAuthorization(new Authorization()) + ->setDatabase('search') + ->setNamespace('search_'.\uniqid()); + $this->database->create(); + $this->database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'body', size: 128), + Attribute::integer(key: 'views'), + ], + indexes: [Index::fulltext(key: 'body_search', attributes: ['body'])], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: false, + )); + $this->database->createDocument(self::COLLECTION, new Document([Document::ID => 'match', 'body' => 'apple pie', 'views' => 3])); + $this->database->createDocument(self::COLLECTION, new Document([Document::ID => 'other', 'body' => 'banana bread', 'views' => 5])); + } + + public function testCountAppliesASearch(): void + { + $this->assertSame(1, $this->database->count(self::COLLECTION, [Query::search('body', 'apple')])); + $this->assertSame(1, $this->database->count(self::COLLECTION, [Query::notSearch('body', 'apple')])); + } + + public function testSumAppliesASearch(): void + { + $this->assertSame(3, $this->database->sum(self::COLLECTION, 'views', [Query::search('body', 'apple')])); + $this->assertSame(5, $this->database->sum(self::COLLECTION, 'views', [Query::notSearch('body', 'apple')])); + } +} diff --git a/tests/unit/SQLiteSearchJoinTest.php b/tests/unit/SQLiteSearchJoinTest.php new file mode 100644 index 0000000000..f2838b3eec --- /dev/null +++ b/tests/unit/SQLiteSearchJoinTest.php @@ -0,0 +1,178 @@ + 'the quick brown fox', + 'lazy' => 'a lazy dog sleeps', + 'foxes' => 'foxes run at night', + 'phrase' => 'quick fox', + ]; + + /** + * @return iterable + */ + public static function searches(): iterable + { + foreach (['plain tables' => false, 'shared tables' => true] as $tables => $shared) { + yield $tables.', two words' => [$shared, 'quick fox']; + yield $tables.', one word' => [$shared, 'lazy']; + yield $tables.', exact phrase' => [$shared, '"quick fox"']; + } + } + + #[DataProvider('searches')] + public function testJoinedSearchMatchesTheJoinedCollectionsSearch(bool $shared, string $term): void + { + $database = $this->database($shared); + + $expected = $this->authorIds($database->find(self::POSTS, [Query::search('body', $term)])); + $this->assertNotSame([], $expected); + + $found = $this->ids($database->find(self::AUTHORS, [ + $this->join(), + Query::search(self::ALIAS.'.body', $term), + ])); + + $this->assertSame($expected, $found); + $this->assertSame(\count($expected), $database->count(self::AUTHORS, [ + $this->join(), + Query::search(self::ALIAS.'.body', $term), + ])); + } + + #[DataProvider('searches')] + public function testJoinedNotSearchIsTheComplement(bool $shared, string $term): void + { + $database = $this->database($shared); + + $matching = $this->authorIds($database->find(self::POSTS, [Query::search('body', $term)])); + $expected = \array_values(\array_diff(\array_keys(self::BODIES), $matching)); + \sort($expected); + + $found = $this->ids($database->find(self::AUTHORS, [ + $this->join(), + Query::notSearch(self::ALIAS.'.body', $term), + ])); + + $this->assertSame($expected, $found); + $this->assertSame(\count($expected), $database->count(self::AUTHORS, [ + $this->join(), + Query::notSearch(self::ALIAS.'.body', $term), + ])); + } + + private function join(): Query + { + return Query::join(self::POSTS, self::ALIAS, [Query::on('$id', 'authorId')]); + } + + /** + * @param array $posts + * @return list + */ + private function authorIds(array $posts): array + { + $ids = []; + foreach ($posts as $post) { + $authorId = $post->getAttribute('authorId'); + $this->assertIsString($authorId); + $ids[] = $authorId; + } + \sort($ids); + + return $ids; + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + $ids = \array_map(static fn (Document $document): string => $document->getId(), $documents); + \sort($ids); + + return $ids; + } + + private function database(bool $shared): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('search_join') + ->setNamespace('search_join'); + + if ($shared) { + $database->setSharedTables(true)->setTenant(1); + } + + $database->create(); + + $this->seed($database, static fn (string $author): string => self::BODIES[$author]); + + if ($shared) { + $database->withTenant(2, fn () => $this->seed($database, static fn (string $author): string => 'unrelated words')); + } + + return $database; + } + + /** + * @param callable(string): string $body + */ + private function seed(Database $database, callable $body): void + { + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ]; + + $database->createCollection(Collection::create( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(Collection::create( + id: self::POSTS, + attributes: [ + Attribute::string(key: 'authorId', size: 64), + Attribute::string(key: 'body', size: 256), + ], + indexes: [Index::fulltext(key: 'body_search', attributes: ['body'])], + permissions: $permissions, + documentSecurity: false, + )); + + foreach (\array_keys(self::BODIES) as $author) { + $database->createDocument(self::AUTHORS, new Document([Document::ID => $author, 'name' => $author])); + $database->createDocument(self::POSTS, new Document([Document::ID => 'post_'.$author, 'authorId' => $author, 'body' => $body($author)])); + } + } +} diff --git a/tests/unit/SQLiteTenantIndexTest.php b/tests/unit/SQLiteTenantIndexTest.php new file mode 100644 index 0000000000..4d38040dfa --- /dev/null +++ b/tests/unit/SQLiteTenantIndexTest.php @@ -0,0 +1,110 @@ +database('acme.1'); + $database->createIndex(self::COLLECTION, Index::unique(key: 'email', attributes: ['email'])); + + $database->deleteIndex(self::COLLECTION, 'email'); + $this->assertSame([], $this->indexes(), 'The index deleteIndex() reported as dropped must be gone'); + + $database->createDocument(self::COLLECTION, new Document(['email' => 'user@example.com'])); + $database->createDocument(self::COLLECTION, new Document(['email' => 'user@example.com'])); + + $this->assertSame(2, $database->count(self::COLLECTION), 'A deleted unique index must stop rejecting duplicates'); + } + + public function testCreatingAnIndexThatAlreadyExistsIsANoOp(): void + { + $database = $this->database('acme.1'); + $index = Index::key(key: 'email', attributes: ['email']); + $database->createIndex(self::COLLECTION, $index); + + $this->assertTrue($this->adapter->createIndex(self::COLLECTION, $index)); + $this->assertSame([self::NAMESPACE.'_acme1_'.self::COLLECTION.'_email'], $this->indexes()); + } + + public function testATenantCannotBreakOutOfTheIndexIdentifier(): void + { + $database = $this->database('a`b'); + $database->createIndex(self::COLLECTION, Index::key(key: 'email', attributes: ['email'])); + + $this->assertSame([self::NAMESPACE.'_ab_'.self::COLLECTION.'_email'], $this->indexes()); + } + + private function database(string $tenant): Database + { + $this->pdo = new PDO('sqlite::memory:'); + $this->adapter = new SQLite($this->pdo); + + $database = new Database($this->adapter, new Cache(new None())); + $database + ->setDatabase(self::NAMESPACE) + ->setNamespace(self::NAMESPACE) + ->setSharedTables(true) + ->setTenant($tenant) + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string('email', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + )); + + return $database; + } + + /** + * @return list + */ + private function indexes(): array + { + $statement = $this->pdo->query("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = '".self::NAMESPACE.'_'.self::COLLECTION."'"); + $this->assertInstanceOf(PDOStatement::class, $statement); + + $names = []; + foreach ($statement->fetchAll(PDO::FETCH_COLUMN) as $name) { + if (\is_string($name) && \str_ends_with($name, '_email')) { + $names[] = $name; + } + } + + return $names; + } +} diff --git a/tests/unit/SQLiteUserFunctionsTest.php b/tests/unit/SQLiteUserFunctionsTest.php new file mode 100644 index 0000000000..02bdc8b969 --- /dev/null +++ b/tests/unit/SQLiteUserFunctionsTest.php @@ -0,0 +1,173 @@ +assertSame([], $deprecations); + $this->assertRegexp($connection); + } + + public function testRegistersRegexpOnNativeSqliteSubclass(): void + { + $connection = PdoSqlite::connect('sqlite::memory:'); + + $this->assertInstanceOf(PdoSqlite::class, $connection); + + new SQLite($connection); + + $this->assertRegexp($connection); + } + + public function testReconnectRegistersRegexpOnReplacementConnection(): void + { + $connection = new DatabasePDO('sqlite::memory:', null, null); + $adapter = new SQLite($connection); + + $this->assertRegexp($connection); + + $adapter->reconnect(); + + $this->assertRegexp($connection); + } + + public function testDoesNotUseDeprecatedFallbackForGenericPdo(): void + { + $deprecations = []; + set_error_handler( + static function (int $severity, string $message, string $file, int $line) use (&$deprecations): bool { + if ($severity !== E_DEPRECATED) { + return false; + } + + $deprecations[] = $message; + throw new ErrorException($message, 0, $severity, $file, $line); + } + ); + + $connection = new PDO('sqlite::memory:'); + try { + new SQLite($connection); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); + $this->expectException(PDOException::class); + $this->expectExceptionMessage('no such function: REGEXP'); + $connection->query("SELECT 'appwrite' REGEXP '^app'"); + } + + public function testDispatchesToTheModernWrapperMethod(): void + { + $connection = new class () extends DatabasePDO { + /** @var array */ + public array $calls = []; + + public function __construct() + { + } + + #[\Override] + public function __call(string $method, array $args): mixed + { + $this->calls[] = $method; + + return true; + } + }; + + new SQLite($connection); + + $this->assertSame(['createFunction'], $connection->calls); + } + + public function testARefusedRegistrationLeavesTheAdapterUsable(): void + { + $connection = new class () extends PDO { + public function __construct() + { + } + + public function createFunction( + string $name, + callable $callback, + int $arguments = -1, + int $flags = 0, + ): bool { + return false; + } + }; + + $this->expectNotToPerformAssertions(); + + new SQLite($connection); + } + + public function testAFailedRegistrationLeavesTheAdapterUsable(): void + { + $connection = new class () extends PDO { + public function __construct() + { + } + + public function createFunction( + string $name, + callable $callback, + int $arguments = -1, + int $flags = 0, + ): bool { + throw new RuntimeException('Registration failed'); + } + }; + + $this->expectNotToPerformAssertions(); + + new SQLite($connection); + } + + private function assertRegexp(DatabasePDO|PDO $connection): void + { + $statement = $connection->query(<<<'SQL' + SELECT + 'appwrite' REGEXP '^app' AS matches_pattern, + 'utopia' REGEXP '^app' AS misses_pattern + SQL); + + $this->assertNotFalse($statement); + $this->assertSame([ + 'matches_pattern' => 1, + 'misses_pattern' => 0, + ], $statement->fetch(\PDO::FETCH_ASSOC)); + } +} diff --git a/tests/unit/Schemaless/SchemalessValidationTest.php b/tests/unit/Schemaless/SchemalessValidationTest.php new file mode 100644 index 0000000000..9fbb30fd97 --- /dev/null +++ b/tests/unit/Schemaless/SchemalessValidationTest.php @@ -0,0 +1,272 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::Integer, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexTtl, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createDocument')->willReturnArgument(1); + $this->adapter->method('createDocuments')->willReturnCallback(function (Document $col, array $docs) { + return $docs; + }); + $this->adapter->method('createIndex')->willReturn(true); + $this->adapter->method('deleteIndex')->willReturn(true); + $this->adapter->method('getSequences')->willReturnArgument(1); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + private function metaCollection(): Document + { + return new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $attributes + * @param array $indexes + */ + private function makeCollection(string $id, array $attributes = [], array $indexes = []): Document + { + return new Document([ + '$id' => $id, + '$sequence' => (string) \crc32($id), + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true, + ]); + } + + /** + * @param array $collections + */ + private function setupCollections(array $collections): void + { + $meta = $this->metaCollection(); + /** @var array $map */ + $map = []; + foreach ($collections as $col) { + $map[$col->getId()] = $col; + } + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($meta, &$map) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + if ($col->getId() === Database::METADATA && isset($map[$docId])) { + return $map[$docId]; + } + + return new Document(); + } + ); + + // Persist metadata writes back to the map so subsequent reads observe + // mutations made by createIndex/createAttribute/etc. + $this->adapter->method('updateDocument')->willReturnCallback( + function (Document $col, string $docId, Document $document) use (&$map) { + if ($col->getId() === Database::METADATA) { + $map[$docId] = $document; + } + + return $document; + } + ); + } + + public function testSchemalessDocumentInvalidInteralAttributeValidation(): void + { + $col = $this->makeCollection('schemaless1'); + $this->setupCollections([$col]); + + try { + $docs = [ + new Document(['$id' => true, 'freeA' => 'doc1']), + new Document(['$id' => true, 'freeB' => 'test']), + new Document(['$id' => true]), + ]; + $this->database->createDocuments('schemaless1', $docs); + $this->fail('Expected StructureException for invalid $id type'); + } catch (\Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + try { + $docs = [ + new Document(['$createdAt' => true, 'freeA' => 'doc1']), + new Document(['$updatedAt' => true, 'freeB' => 'test']), + new Document(['$permissions' => 12]), + ]; + $this->database->createDocuments('schemaless1', $docs); + $this->fail('Expected StructureException for invalid internal attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + } + + public function testSchemalessIndexDuplicatePrevention(): void + { + $col = $this->makeCollection('sl_idx_dup'); + $this->setupCollections([$col]); + + $this->database->createDocument('sl_idx_dup', new Document([ + '$id' => 'a', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'x', + ])); + + $this->assertSame('duplicate', $this->database->createIndex( + 'sl_idx_dup', + Index::key(key: 'duplicate', attributes: ['name'], lengths: [0], orders: [OrderDirection::Asc]) + )->key); + + try { + $this->database->createIndex( + 'sl_idx_dup', + Index::key(key: 'duplicate', attributes: ['name'], lengths: [0], orders: [OrderDirection::Asc]) + ); + $this->fail('Failed to throw exception'); + } catch (\Exception $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + } + + public function testSchemalessInternalAttributes(): void + { + $col = $this->makeCollection('sl_internal'); + $this->setupCollections([$col]); + + $doc = $this->database->createDocument('sl_internal', new Document([ + '$id' => 'i1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'alpha', + ])); + + $this->assertEquals('i1', $doc->getId()); + $this->assertEquals('sl_internal', $doc->getCollection()); + $this->assertNotEmpty($doc->getAttribute('$createdAt')); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + $perms = $doc->getPermissions(); + $this->assertContains(Permission::read(Role::any()), $perms); + $this->assertContains(Permission::update(Role::any()), $perms); + $this->assertContains(Permission::delete(Role::any()), $perms); + } + + public function testSchemalessTTLIndexDuplicatePrevention(): void + { + $col = $this->makeCollection('sl_ttl_dup'); + $this->setupCollections([$col]); + + $this->assertSame('idx_ttl_expires', $this->database->createIndex( + 'sl_ttl_dup', + Index::ttl(key: 'idx_ttl_expires', attribute: 'expiresAt', ttl: 3600) + )->key); + + try { + $this->database->createIndex( + 'sl_ttl_dup', + Index::ttl(key: 'idx_ttl_expires_duplicate', attribute: 'expiresAt', ttl: 7200) + ); + $this->fail('Expected exception for duplicate TTL index'); + } catch (\Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There can be only one TTL index in a collection', $e->getMessage()); + } + } +} diff --git a/tests/unit/ScopedToggleCoroutineTest.php b/tests/unit/ScopedToggleCoroutineTest.php new file mode 100644 index 0000000000..97c3c31bfd --- /dev/null +++ b/tests/unit/ScopedToggleCoroutineTest.php @@ -0,0 +1,760 @@ +markTestSkipped('ext-swoole is required for coroutine-scoped toggles'); + } + } + + /** + * @return array + */ + public static function scopes(): array + { + return [ + 'skipFilters' => [ + static fn (Database $database, Closure $callback): mixed => $database->skipFilters($callback), + self::decodedTitle(...), + 'quiet', + 'QUIET', + ], + 'skipFilters by name' => [ + static fn (Database $database, Closure $callback): mixed => $database->skipFilters($callback, [self::FILTER]), + self::decodedTitle(...), + 'quiet', + 'QUIET', + ], + 'withFiltering(false)' => [ + static fn (Database $database, Closure $callback): mixed => $database->withFiltering(false, $callback), + self::decodedTitle(...), + 'quiet', + 'QUIET', + ], + 'withValidation(false)' => [ + static fn (Database $database, Closure $callback): mixed => $database->withValidation(false, $callback), + static fn (Database $database): bool => $database->isValidating(), + false, + true, + ], + 'skipValidation' => [ + static fn (Database $database, Closure $callback): mixed => $database->skipValidation($callback), + static fn (Database $database): bool => $database->isValidating(), + false, + true, + ], + 'withPreserveDates' => [ + static fn (Database $database, Closure $callback): mixed => $database->withPreserveDates(true, $callback), + static fn (Database $database): bool => $database->isPreservingDates(), + true, + false, + ], + 'withPreserveSequence' => [ + static fn (Database $database, Closure $callback): mixed => $database->withPreserveSequence(true, $callback), + static fn (Database $database): bool => $database->isPreservingSequence(), + true, + false, + ], + 'withTenant' => [ + static fn (Database $database, Closure $callback): mixed => $database->withTenant(self::SCOPED_TENANT, $callback), + static fn (Database $database): int|string|null => $database->getTenant(), + self::SCOPED_TENANT, + self::TENANT, + ], + 'withRequestTimestamp' => [ + static fn (Database $database, Closure $callback): mixed => $database->withRequestTimestamp(new DateTime(self::PAST), $callback), + self::updateOutcome(...), + 'conflict', + 'updated', + ], + 'ignoreDuplicates' => [ + static fn (Database $database, Closure $callback): mixed => $database->ignoreDuplicates($callback), + self::duplicateOutcome(...), + 'skipped', + 'rejected', + ], + 'adapter ignoreDuplicates' => [ + static fn (Database $database, Closure $callback): mixed => $database->getAdapter()->ignoreDuplicates($callback), + self::duplicateOutcome(...), + 'skipped', + 'rejected', + ], + ]; + } + + /** + * @param Closure(Database, Closure): mixed $scope + * @param Closure(Database): mixed $probe + */ + #[DataProvider('scopes')] + public function testAScopeIsSeenByItsCoroutineAndItsChildrenButNotByASibling(Closure $scope, Closure $probe, mixed $inside, mixed $outside): void + { + $database = $this->database(new Memory()); + $expected = [ + 'inside' => $inside, + 'child' => $inside, + 'parent' => $outside, + 'sibling' => $outside, + 'insideAfterSibling' => $inside, + 'after' => $outside, + ]; + + for ($round = 0; $round < self::ROUNDS; $round++) { + $this->assertSame( + $expected, + $this->whileASiblingIsInside($database, $scope, $probe, siblingFirst: $round % 2 === 1), + "round {$round}", + ); + } + } + + /** + * @param Closure(Database, Closure): mixed $scope + * @param Closure(Database): mixed $probe + */ + #[DataProvider('scopes')] + public function testOverlappingScopesInSiblingsLeaveTheHandleWideValue(Closure $scope, Closure $probe, mixed $inside, mixed $outside): void + { + $database = $this->database(new Memory()); + + for ($round = 0; $round < self::ROUNDS; $round++) { + $seen = []; + + $this->inCoroutine(function () use ($database, $scope, $probe, &$seen): void { + $firstEntered = new Channel(1); + $secondEntered = new Channel(1); + $firstLeft = new Channel(1); + $secondLeft = new Channel(1); + + Coroutine::create(function () use ($database, $scope, $probe, &$seen, $firstEntered, $secondEntered, $firstLeft): void { + $scope($database, function () use ($firstEntered, $secondEntered): void { + $firstEntered->push(true); + $secondEntered->pop(); + }); + $seen['firstAfter'] = self::guarded($probe, $database); + $firstLeft->push(true); + }); + + $firstEntered->pop(); + + Coroutine::create(function () use ($database, $scope, $probe, &$seen, $secondEntered, $firstLeft, $secondLeft): void { + $scope($database, function () use ($database, $probe, &$seen, $secondEntered, $firstLeft): void { + $secondEntered->push(true); + $firstLeft->pop(); + $seen['secondInsideAfterFirstLeft'] = self::guarded($probe, $database); + }); + $secondLeft->push(true); + }); + + $secondLeft->pop(); + $seen['after'] = self::guarded($probe, $database); + }); + + $this->assertSame( + ['firstAfter' => $outside, 'secondInsideAfterFirstLeft' => $inside, 'after' => $outside], + $seen, + "round {$round}", + ); + } + } + + /** + * @param Closure(Database, Closure): mixed $scope + * @param Closure(Database): mixed $probe + */ + #[DataProvider('scopes')] + public function testOutsideACoroutineAScopeLastsForItsCallback(Closure $scope, Closure $probe, mixed $inside, mixed $outside): void + { + $database = $this->database(new Memory()); + + $seen = $scope($database, fn (): array => [ + 'inside' => self::guarded($probe, $database), + 'nested' => $scope($database, fn (): mixed => self::guarded($probe, $database)), + 'afterNested' => self::guarded($probe, $database), + ]); + $this->assertIsArray($seen); + $seen['after'] = self::guarded($probe, $database); + + $thrown = null; + try { + $scope($database, static fn (): never => throw new RuntimeException('failed')); + } catch (RuntimeException $error) { + $thrown = $error; + } + $seen['afterFailure'] = self::guarded($probe, $database); + + $this->assertInstanceOf(RuntimeException::class, $thrown); + $this->assertSame( + ['inside' => $inside, 'nested' => $inside, 'afterNested' => $inside, 'after' => $outside, 'afterFailure' => $outside], + $seen, + ); + } + + public function testASetterInsideAScopeChangesTheScopeOnly(): void + { + $database = $this->database(new Memory()); + + $seen = $database->skipValidation(function () use ($database): array { + $database->setValidation(true); + $database->setPreserveDates(true); + + return [$database->isValidating(), $database->withPreserveDates(true, fn (): bool => $database->isPreservingDates())]; + }); + + $this->assertSame([true, true], $seen); + $this->assertTrue($database->isValidating()); + $this->assertTrue($database->isPreservingDates(), 'setPreserveDates() outside a withPreserveDates() scope changes the handle-wide value'); + + $database->withTenant(self::SCOPED_TENANT, fn (): Database => $database->setTenant(self::OTHER_TENANT)); + + $this->assertSame(self::TENANT, $database->getTenant(), 'setTenant() inside withTenant() changes that scope only'); + } + + public function testASiblingWritesUnderItsOwnTenantWhileAnotherIsInsideWithTenant(): void + { + $database = $this->sharedTablesDatabase(); + $failures = []; + + $this->inCoroutine(function () use ($database, &$failures): void { + $entered = new Channel(1); + $written = new Channel(1); + $closed = new Channel(1); + + Coroutine::create(function () use ($database, &$failures, $entered, $written, $closed): void { + $database->withTenant(self::SCOPED_TENANT, function () use ($database, &$failures, $entered, $written): void { + $entered->push(true); + $written->pop(); + $failures['scoped'] = $this->failureOf(fn (): Document => $database->createDocument(self::COLLECTION, $this->note('scoped'))); + }); + $closed->push(true); + }); + + $entered->pop(); + Coroutine::create(function () use ($database, &$failures, $written): void { + $failures['sibling'] = $this->failureOf(fn (): Document => $database->createDocument(self::COLLECTION, $this->note('sibling'))); + $failures['own'] = $this->failureOf(fn (): Document => $database->withTenant( + self::OTHER_TENANT, + fn (): Document => $database->createDocument(self::COLLECTION, $this->note('own')), + )); + $written->push(true); + }); + $closed->pop(); + }); + + $this->assertSame(['sibling' => null, 'own' => null, 'scoped' => null], $failures); + $this->assertSame(self::TENANT, $database->getTenant()); + $this->assertSame( + [self::TENANT => ['sibling'], self::SCOPED_TENANT => ['scoped'], self::OTHER_TENANT => ['own']], + [ + self::TENANT => $this->idsUnder($database, self::TENANT), + self::SCOPED_TENANT => $this->idsUnder($database, self::SCOPED_TENANT), + self::OTHER_TENANT => $this->idsUnder($database, self::OTHER_TENANT), + ], + ); + } + + public function testASearchAfterAScopedTenantsSearchUsesItsOwnTenantsFulltextIndex(): void + { + $database = $this->sharedTablesDatabase(fulltext: true); + $database->createDocument(self::COLLECTION, $this->note('apple')); + + $search = static fn (): array => \array_map( + static fn (Document $document): string => $document->getId(), + $database->find(self::COLLECTION, [Query::search('title', 'apple*')]), + ); + + $this->assertSame([], $database->withTenant(self::SCOPED_TENANT, $search)); + $this->assertSame(['apple'], $search()); + } + + public function testASiblingsDatesAreNotPreservedWhileAnotherIsInsideWithPreserveDates(): void + { + $database = $this->database(new Memory()); + + $this->inCoroutine(function () use ($database): void { + $entered = new Channel(1); + $written = new Channel(1); + $closed = new Channel(1); + + Coroutine::create(function () use ($database, $entered, $written, $closed): void { + $database->withPreserveDates(true, function () use ($database, $entered, $written): void { + $entered->push(true); + $written->pop(); + $this->failureOf(fn (): Document => $database->createDocument(self::COLLECTION, $this->dated('preserved'))); + }); + $closed->push(true); + }); + + $entered->pop(); + Coroutine::create(function () use ($database, $written): void { + $this->failureOf(fn (): Document => $database->createDocument(self::COLLECTION, $this->dated('stamped'))); + $written->push(true); + }); + $closed->pop(); + }); + + $this->assertSame(self::PAST, $database->getDocument(self::COLLECTION, 'preserved')->getCreatedAt()); + $this->assertNotSame(self::PAST, $database->getDocument(self::COLLECTION, 'stamped')->getCreatedAt()); + } + + public function testASnapshotCarriesTheScopedTogglesIntoACoroutineThatOutlivesTheScope(): void + { + $database = $this->database(new Memory()); + $seen = []; + + $this->inCoroutine(function () use ($database, &$seen): void { + $released = new Channel(1); + $done = new Channel(1); + + $this->insideEveryScope($database, function () use ($database, &$seen, $released, $done): void { + $snapshot = $database->snapshot(); + + Coroutine::create(function () use ($database, $snapshot, &$seen, $released, $done): void { + $released->pop(); + $seen['carried'] = $database->withSnapshot($snapshot, function () use ($database): array { + $state = $this->state($database); + $database->setValidation(true); + $database->setTenant(9); + + return $state; + }); + $seen['afterSnapshot'] = $this->state($database); + $done->push(true); + }); + }); + + $released->push(true); + $done->pop(); + $seen['caller'] = $this->state($database); + }); + + $this->assertSame($this->scopedState(), $seen['carried']); + $this->assertSame($this->handleState(), $seen['afterSnapshot']); + $this->assertSame($this->handleState(), $seen['caller']); + } + + public function testASnapshotAppliesTheScopedTogglesToAnotherHandle(): void + { + $source = $this->database(new Memory()); + $destination = $this->database(new Memory()); + + $snapshot = $this->insideEveryScope($source, static fn (): Snapshot => $source->snapshot()); + $carried = $destination->withSnapshot($snapshot, fn (): array => $this->state($destination)); + + $this->assertSame($this->scopedState(), $carried); + $this->assertSame($this->handleState(), $this->state($destination)); + $this->assertSame($this->handleState(), $this->state($source)); + } + + public function testASnapshotTakenOutsideAnyScopeCarriesTheHandleWideValues(): void + { + $source = $this->database(new Memory()); + $source->setValidation(false)->setPreserveDates(true); + $destination = $this->database(new Memory()); + + $carried = $destination->withSnapshot($source->snapshot(), fn (): array => $this->state($destination)); + + $this->assertSame( + ['validation' => false, 'preserveDates' => true] + $this->handleState(), + $carried, + ); + $this->assertSame($this->handleState(), $this->state($destination)); + } + + public function testAdapterSkipDuplicatesThroughAPoolReachesTheBorrowedAdapterOnlyForItsCoroutine(): void + { + $database = $this->database($this->pool(new Memory())); + + $this->assertSame( + ['inside' => 'skipped', 'child' => 'skipped', 'parent' => 'rejected', 'sibling' => 'rejected', 'insideAfterSibling' => 'skipped', 'after' => 'rejected'], + $this->whileASiblingIsInside( + $database, + static fn (Database $database, Closure $callback): mixed => $database->getAdapter()->ignoreDuplicates($callback), + self::duplicateOutcome(...), + siblingFirst: false, + ), + ); + } + + /** + * Opens a scope in one coroutine and probes it from inside, from a coroutine started inside, from the parent and + * from a sibling while the scope is open, from inside again after the sibling ran, and from the parent after it + * closed. + * + * @param Closure(Database, Closure): mixed $scope + * @param Closure(Database): mixed $probe + * @return array + */ + private function whileASiblingIsInside(Database $database, Closure $scope, Closure $probe, bool $siblingFirst): array + { + $seen = []; + + $this->inCoroutine(function () use ($database, $scope, $probe, $siblingFirst, &$seen): void { + $entered = new Channel(1); + $released = new Channel(1); + $closed = new Channel(1); + + Coroutine::create(function () use ($database, $scope, $probe, $siblingFirst, &$seen, $entered, $released, $closed): void { + $scope($database, function () use ($database, $probe, $siblingFirst, &$seen, $entered, $released): void { + $seen['inside'] = self::guarded($probe, $database); + $childDone = new Channel(1); + Coroutine::create(function () use ($database, $probe, $siblingFirst, &$seen, $childDone, $entered, $released): void { + if ($siblingFirst) { + $entered->push(true); + $released->pop(); + } + Coroutine::sleep(0.001); + $seen['child'] = self::guarded($probe, $database); + $childDone->push(true); + }); + if (! $siblingFirst) { + $childDone->pop(); + $entered->push(true); + $released->pop(); + } else { + $childDone->pop(); + } + $seen['insideAfterSibling'] = self::guarded($probe, $database); + }); + $closed->push(true); + }); + + $entered->pop(); + $seen['parent'] = self::guarded($probe, $database); + + Coroutine::create(function () use ($database, $probe, &$seen, $released): void { + Coroutine::sleep(0.001); + $seen['sibling'] = self::guarded($probe, $database); + $released->push(true); + }); + + $closed->pop(); + $seen['after'] = self::guarded($probe, $database); + }); + + return [ + 'inside' => $seen['inside'] ?? null, + 'child' => $seen['child'] ?? null, + 'parent' => $seen['parent'] ?? null, + 'sibling' => $seen['sibling'] ?? null, + 'insideAfterSibling' => $seen['insideAfterSibling'] ?? null, + 'after' => $seen['after'] ?? null, + ]; + } + + /** + * @template T + * + * @param Closure(): T $callback + * @return T + */ + private function insideEveryScope(Database $database, Closure $callback): mixed + { + return $database->skipValidation( + fn (): mixed => $database->withPreserveDates( + true, + fn (): mixed => $database->withPreserveSequence( + true, + fn (): mixed => $database->withTenant( + self::SCOPED_TENANT, + fn (): mixed => $database->withRequestTimestamp( + new DateTime(self::PAST), + fn (): mixed => $database->ignoreDuplicates( + fn (): mixed => $database->skipFilters($callback, [self::FILTER]), + ), + ), + ), + ), + ), + ); + } + + /** + * @return array + */ + private function state(Database $database): array + { + return [ + 'validation' => $database->isValidating(), + 'preserveDates' => $database->isPreservingDates(), + 'preserveSequence' => $database->isPreservingSequence(), + 'tenant' => $database->getTenant(), + 'title' => self::guarded(self::decodedTitle(...), $database), + 'update' => self::guarded(self::updateOutcome(...), $database), + 'duplicate' => self::guarded(self::duplicateOutcome(...), $database), + ]; + } + + /** + * @return array + */ + private function scopedState(): array + { + return [ + 'validation' => false, + 'preserveDates' => true, + 'preserveSequence' => true, + 'tenant' => self::SCOPED_TENANT, + 'title' => 'quiet', + 'update' => 'conflict', + 'duplicate' => 'skipped', + ]; + } + + /** + * @return array + */ + private function handleState(): array + { + return [ + 'validation' => true, + 'preserveDates' => false, + 'preserveSequence' => false, + 'tenant' => self::TENANT, + 'title' => 'QUIET', + 'update' => 'updated', + 'duplicate' => 'rejected', + ]; + } + + private static function decodedTitle(Database $database): mixed + { + $collection = new Document(['attributes' => [[ + '$id' => 'title', + 'type' => 'string', + 'filters' => [self::FILTER], + ]]]); + + return $database->decode($collection, new Document(['title' => 'quiet']))->getAttribute('title'); + } + + private static function updateOutcome(Database $database): string + { + try { + $database->withTenant( + self::TENANT, + static fn (): Document => $database->updateDocument(self::COLLECTION, self::SEEDED, new Document(['views' => 2])), + ); + + return 'updated'; + } catch (ConflictException) { + return 'conflict'; + } + } + + private static function duplicateOutcome(Database $database): string + { + try { + $database->withTenant( + self::TENANT, + static fn (): int => $database->createDocuments(self::COLLECTION, [new Document([ + '$id' => self::SEEDED, + 'title' => 'duplicate', + 'views' => 1, + ])]), + ); + + return 'skipped'; + } catch (DuplicateException) { + return 'rejected'; + } + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None()), self::filters()); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('toggles') + ->setNamespace('toggles_'.\uniqid()) + ->setTenant(self::TENANT); + $database->create(); + $database->createCollection($this->collection()); + $database->createDocument(self::COLLECTION, new Document([ + '$id' => self::SEEDED, + 'title' => 'quiet', + 'views' => 1, + ])); + + return $database; + } + + private function sharedTablesDatabase(bool $fulltext = false): Database + { + $database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None()), self::filters()); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('toggles') + ->setNamespace('toggles_'.\uniqid()) + ->setSharedTables(true) + ->setTenant(self::TENANT); + $database->create(); + foreach ([self::TENANT, self::SCOPED_TENANT, self::OTHER_TENANT] as $tenant) { + $database->withTenant($tenant, fn (): Document => $database->createCollection($this->collection($fulltext))); + } + + return $database; + } + + /** + * @return list + */ + private static function filters(): array + { + return [ + new Callback( + self::FILTER, + static fn (mixed $value): mixed => \is_string($value) ? \strtolower($value) : $value, + static fn (mixed $value): mixed => \is_string($value) ? \strtoupper($value) : $value, + ), + ]; + } + + private function collection(bool $fulltext = false): Collection + { + return Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64, filters: [self::FILTER]), + Attribute::integer(key: 'views'), + ], + indexes: $fulltext ? [Index::fulltext(key: 'title_search', attributes: ['title'])] : [], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ); + } + + /** + * @return list + */ + private function idsUnder(Database $database, int $tenant): array + { + $ids = \array_map( + static fn (Document $document): string => $document->getId(), + $database->withTenant($tenant, static fn (): array => $database->find(self::COLLECTION, [Query::limit(10)])), + ); + \sort($ids); + + return $ids; + } + + /** + * @param Closure(Database): mixed $probe + */ + private static function guarded(Closure $probe, Database $database): mixed + { + try { + return $probe($database); + } catch (Throwable $error) { + return 'failed: '.$error->getMessage(); + } + } + + private function failureOf(Closure $write): ?string + { + try { + $write(); + + return null; + } catch (Throwable $error) { + return $error->getMessage(); + } + } + + private function note(string $id): Document + { + return new Document(['$id' => $id, 'title' => $id, 'views' => 1]); + } + + private function dated(string $id): Document + { + return new Document([ + '$id' => $id, + 'title' => $id, + 'views' => 1, + '$createdAt' => self::PAST, + '$updatedAt' => self::PAST, + ]); + } + + private function pool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + return new Pool($connections); + } + + private function inCoroutine(Closure $test): void + { + $hookFlags = Runtime::getHookFlags(); + + try { + run($test); + } finally { + Runtime::setHookFlags($hookFlags); + } + } +} diff --git a/tests/unit/SelectFilterSkipTest.php b/tests/unit/SelectFilterSkipTest.php new file mode 100644 index 0000000000..da9391882a --- /dev/null +++ b/tests/unit/SelectFilterSkipTest.php @@ -0,0 +1,87 @@ +setDatabase('utopiaTests') + ->setNamespace('select_filter_'.\uniqid()); + + $database->create(); + + $calls = new \stdClass(); + $calls->count = 0; + $database->addFilter( + 'subQueryProbeUnit', + fn (mixed $value) => null, + function (mixed $value) use ($calls) { + $calls->count++; + + return ['fanned', 'out']; + } + ); + + $database->createCollection(Collection::create(id: 'filterSelect')); + $database->createAttribute('filterSelect', Attribute::string(key: 'plain', size: 128)); + $database->createAttribute('filterSelect', Attribute::string(key: 'kids', size: 128, filters: ['subQueryProbeUnit'])); + + $database->createDocument('filterSelect', new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'plain' => 'x', + ])); + + $calls->count = 0; + $document = $database->getDocument('filterSelect', 'doc1'); + $this->assertSame(1, $calls->count); + $this->assertSame(['fanned', 'out'], $document->getAttribute('kids')); + + $calls->count = 0; + $document = $database->getDocument('filterSelect', 'doc1', [Query::select(['$id', 'plain'])]); + $this->assertSame(0, $calls->count); + $this->assertNull($document->getAttribute('kids')); + $this->assertSame('x', $document->getAttribute('plain')); + + $calls->count = 0; + $document = $database->getDocument('filterSelect', 'doc1', [Query::select(['$id', 'kids'])]); + $this->assertSame(1, $calls->count); + $this->assertSame(['fanned', 'out'], $document->getAttribute('kids')); + + $calls->count = 0; + $document = $database->getDocument('filterSelect', 'doc1', [Query::select(['*'])]); + $this->assertSame(1, $calls->count); + $this->assertSame(['fanned', 'out'], $document->getAttribute('kids')); + + $calls->count = 0; + $documents = $database->find('filterSelect', [Query::select(['$id', 'plain'])]); + $this->assertCount(1, $documents); + $this->assertSame(0, $calls->count); + $this->assertNull($documents[0]->getAttribute('kids')); + + $calls->count = 0; + $documents = $database->find('filterSelect'); + $this->assertCount(1, $documents); + $this->assertSame(1, $calls->count); + $this->assertSame(['fanned', 'out'], $documents[0]->getAttribute('kids')); + } +} diff --git a/tests/unit/SelectProjectionTest.php b/tests/unit/SelectProjectionTest.php index 262e609e1b..9937924f97 100644 --- a/tests/unit/SelectProjectionTest.php +++ b/tests/unit/SelectProjectionTest.php @@ -2,16 +2,20 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Utopia\Cache\Adapter\Memory as CacheMemory; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; +use Utopia\Query\Method; /** * Drives Database::find(), the entry point the HTTP layer calls, rather than the @@ -26,6 +30,7 @@ class SelectProjectionTest extends TestCase { private Database $database; + #[\Override] protected function setUp(): void { $this->database = new Database(new DatabaseMemory(), new Cache(new CacheMemory())); @@ -34,8 +39,8 @@ protected function setUp(): void ->setNamespace('select_' . \uniqid()); $this->database->create(); - $this->database->createCollection('widgets'); - $this->database->createAttribute('widgets', 'sku', Database::VAR_STRING, 255, false); + $this->database->createCollection(Collection::create(id: 'widgets')); + $this->database->createAttribute('widgets', Attribute::string(key: 'sku')); $this->database->createDocument('widgets', new Document([ '$id' => 'widget', '$permissions' => [Permission::read(Role::any())], @@ -43,17 +48,14 @@ protected function setUp(): void ])); } - /** - * @param array $values - * - * @dataProvider malformedSelections - */ + /** @param array $values */ + #[DataProvider('malformedSelections')] public function testAMalformedSelectionIsRefusedRatherThanFatal(array $values): void { $this->expectException(QueryException::class); $this->expectExceptionMessage('Attribute selection must be a string, got'); - $this->database->find('widgets', [Query::select($values)]); + $this->database->find('widgets', [new Query(Method::Select, values: $values)]); } /** @@ -74,15 +76,14 @@ public static function malformedSelections(): array * catching Exception — as the HTTP layer does — never sees it and returns a 500. * * @param array $values - * - * @dataProvider malformedSelections */ + #[DataProvider('malformedSelections')] public function testTheRefusalIsCatchableAsAnException(array $values): void { $caught = null; try { - $this->database->find('widgets', [Query::select($values)]); + $this->database->find('widgets', [new Query(Method::Select, values: $values)]); } catch (\Exception $exception) { $caught = $exception; } diff --git a/tests/unit/SetRecordingDocument.php b/tests/unit/SetRecordingDocument.php new file mode 100644 index 0000000000..1b9bd9c990 --- /dev/null +++ b/tests/unit/SetRecordingDocument.php @@ -0,0 +1,22 @@ + + */ + public array $sets = []; + + #[\Override] + public function setAttribute(string $key, mixed $value, SetType $type = SetType::Assign): static + { + $this->sets[] = $key; + + return parent::setAttribute($key, $value, $type); + } +} diff --git a/tests/unit/SharedCollectionCleanupTest.php b/tests/unit/SharedCollectionCleanupTest.php new file mode 100644 index 0000000000..462c8fa205 --- /dev/null +++ b/tests/unit/SharedCollectionCleanupTest.php @@ -0,0 +1,187 @@ + */ + public array $dropped = []; + + public function __construct() + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } + + #[\Override] + public function getHost(): string + { + return 'mongo'; + } + + #[\Override] + public function isReplicaSet(): bool + { + return false; + } + + /** + * @param array $command + */ + #[\Override] + public function query(array $command, ?string $db = null): stdClass + { + return (object) ['cursor' => (object) ['firstBatch' => [['name' => 'adopted']], 'id' => 0]]; + } + + /** + * @param array $indexes + * @param array $options + */ + #[\Override] + public function createIndexes(string $collection, array $indexes, array $options = []): bool + { + return true; + } + + /** + * @param array $options + */ + #[\Override] + public function dropCollection(string $name, array $options = []): bool + { + $this->dropped[] = $name; + + return true; + } + }; + $adapter = new class ($client) extends Mongo { + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + return new Document(); + } + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + throw new RuntimeException('not writable primary'); + } + }; + $adapter->setSharedTables(true); + $adapter->setTenant(2); + + $this->attemptCreate($this->database($adapter)); + + $this->assertSame([], $client->dropped, 'A shared collection other tenants use must not be dropped'); + } + + public function testASQLCreateWhoseDefinitionIsNotStoredKeepsATableAnotherTenantAdopted(): void + { + $pdo = new PDO('sqlite::memory:'); + $peer = $this->database($this->sqlite($pdo, 1)); + $peer->create(); + + $adapter = new class ($pdo) extends SQLite { + /** @var (\Closure(): void)|null */ + public ?\Closure $beforeDefinition = null; + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + if ($collection->getId() === Database::METADATA && $this->beforeDefinition !== null) { + ($this->beforeDefinition)(); + + throw new RuntimeException('write conflict'); + } + + return parent::createDocument($collection, $document); + } + }; + $adapter->setSharedTables(true); + $adapter->setTenant(2); + $adapter->beforeDefinition = static function () use ($peer): void { + $peer->createCollection(self::definition()); + $peer->createDocument('logs', new Document(['$id' => 'kept', 'message' => 'peer'])); + }; + + $this->attemptCreate($this->database($adapter)); + + $this->assertSame('peer', $peer->getDocument('logs', 'kept')->getAttribute('message'), 'The table another tenant adopted must not be dropped'); + } + + private function attemptCreate(Database $database): void + { + try { + $database->createCollection(self::definition()); + } catch (Throwable) { + return; + } + + $this->fail('A create whose definition is not stored must fail'); + } + + private static function definition(): Collection + { + return Collection::create( + id: 'logs', + attributes: [Attribute::string(key: 'message', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + ); + } + + private function sqlite(PDO $pdo, int $tenant): SQLite + { + $adapter = new SQLite($pdo); + $adapter->setSharedTables(true); + $adapter->setTenant($tenant); + + return $adapter; + } + + private function database(Mongo|SQLite $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + return (new Database($adapter, new Cache(new None()))) + ->setAuthorization($authorization) + ->setDatabase('shared') + ->setNamespace('shared'); + } +} diff --git a/tests/unit/Spatial/SpatialValidationTest.php b/tests/unit/Spatial/SpatialValidationTest.php new file mode 100644 index 0000000000..7c52b03308 --- /dev/null +++ b/tests/unit/Spatial/SpatialValidationTest.php @@ -0,0 +1,365 @@ +adapter = self::createStub(SpatialAdapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createAttribute')->willReturn(true); + $this->adapter->method('createIndex')->willReturn(true); + $this->adapter->method('deleteIndex')->willReturn(true); + $this->adapter->method('createDocument')->willReturnArgument(1); + $this->adapter->method('updateDocument')->willReturnArgument(2); + $this->adapter->method('getSequences')->willReturnArgument(1); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + private function metaCollection(): Document + { + return new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $attributes + * @param array $indexes + */ + private function makeCollection(string $id, array $attributes = [], array $indexes = []): Document + { + return new Document([ + '$id' => $id, + '$sequence' => $id, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true, + ]); + } + + /** + * @param array $collections + */ + private function setupCollections(array $collections): void + { + $meta = $this->metaCollection(); + $map = []; + foreach ($collections as $col) { + $map[$col->getId()] = $col; + } + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($meta, $map) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + if ($col->getId() === Database::METADATA && isset($map[$docId])) { + return $map[$docId]; + } + + return new Document(); + } + ); + $this->adapter->method('updateDocument')->willReturnArgument(2); + } + + public function testSpatialAttributeDefaults(): void + { + $encoded = []; + $this->adapter->method('hasFeature')->willReturnCallback(static fn (string $feature): bool => $feature === Feature\Spatial::class); + $this->adapter->method('encode')->willReturnCallback(static function (mixed $value, ColumnType $type) use (&$encoded): string { + $wkt = Wkt::encode($value, $type); + $encoded[$wkt] = $value; + + return $wkt; + }); + $this->adapter->method('decode')->willReturnCallback(static function (string $value) use (&$encoded): mixed { + return $encoded[$value]; + }); + + $ptAttr = new Document([ + '$id' => 'pt', 'key' => 'pt', 'type' => ColumnType::Point->value, + 'size' => 0, 'required' => false, 'default' => [1.0, 2.0], + 'signed' => true, 'array' => false, 'filters' => ['point'], + ]); + $lnAttr = new Document([ + '$id' => 'ln', 'key' => 'ln', 'type' => ColumnType::Linestring->value, + 'size' => 0, 'required' => false, 'default' => [[0.0, 0.0], [1.0, 1.0]], + 'signed' => true, 'array' => false, 'filters' => ['linestring'], + ]); + $pgAttr = new Document([ + '$id' => 'pg', 'key' => 'pg', 'type' => ColumnType::Polygon->value, + 'size' => 0, 'required' => false, 'default' => [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], + 'signed' => true, 'array' => false, 'filters' => ['polygon'], + ]); + + $col = $this->makeCollection('spatial_defaults', [$ptAttr, $lnAttr, $pgAttr]); + $this->setupCollections([$col]); + + $doc = $this->database->createDocument('spatial_defaults', new Document([ + '$id' => Id::custom('d1'), + '$permissions' => [Permission::read(Role::any())], + ])); + + $this->assertEquals([1.0, 2.0], $doc->getAttribute('pt')); + $this->assertEquals([[0.0, 0.0], [1.0, 1.0]], $doc->getAttribute('ln')); + $this->assertEquals([[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], $doc->getAttribute('pg')); + } + + public function testInvalidSpatialTypes(): void + { + $pointAttr = new Document([ + '$id' => 'pointAttr', 'key' => 'pointAttr', 'type' => ColumnType::Point->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [ColumnType::Point->value], + ]); + $lineAttr = new Document([ + '$id' => 'lineAttr', 'key' => 'lineAttr', 'type' => ColumnType::Linestring->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [ColumnType::Linestring->value], + ]); + $polyAttr = new Document([ + '$id' => 'polyAttr', 'key' => 'polyAttr', 'type' => ColumnType::Polygon->value, + 'size' => 0, 'required' => false, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [ColumnType::Polygon->value], + ]); + + $col = $this->makeCollection('test_invalid_spatial', [$pointAttr, $lineAttr, $polyAttr]); + $this->setupCollections([$col]); + + try { + $this->database->createDocument('test_invalid_spatial', new Document([ + 'pointAttr' => [10.0], + ])); + $this->fail('Expected StructureException for invalid point'); + } catch (\Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + try { + $this->database->createDocument('test_invalid_spatial', new Document([ + 'lineAttr' => [[10.0, 20.0]], + ])); + $this->fail('Expected StructureException for invalid line'); + } catch (\Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + try { + $this->database->createDocument('test_invalid_spatial', new Document([ + 'polyAttr' => [10.0, 20.0], + ])); + $this->fail('Expected StructureException for invalid polygon'); + } catch (\Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + } + + public function testSpatialDistanceQueryOnNonSpatialAttribute(): void + { + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + $locAttr = new Document([ + '$id' => 'loc', 'key' => 'loc', 'type' => ColumnType::Point->value, + 'size' => 0, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->makeCollection('spatial_distance_error', [$nameAttr, $locAttr]); + $this->setupCollections([$col]); + + try { + $this->database->find('spatial_distance_error', [ + Query::distanceLessThan('name', [0.0, 0.0], 1000), + ]); + $this->fail('Expected QueryException'); + } catch (\Exception $e) { + $this->assertInstanceOf(QueryException::class, $e); + $msg = strtolower($e->getMessage()); + $this->assertStringContainsString('spatial', $msg); + } + } + + public function testSpatialIndexSingleAttributeOnly(): void + { + $locAttr = new Document([ + '$id' => 'loc', 'key' => 'loc', 'type' => ColumnType::Point->value, + 'size' => 0, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [ColumnType::Point->value], + ]); + $loc2Attr = new Document([ + '$id' => 'loc2', 'key' => 'loc2', 'type' => ColumnType::Point->value, + 'size' => 0, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [ColumnType::Point->value], + ]); + $titleAttr = new Document([ + '$id' => 'title', 'key' => 'title', 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->makeCollection('spatial_idx_single', [$locAttr, $loc2Attr, $titleAttr]); + $this->setupCollections([$col]); + + try { + $this->database->createIndex('spatial_idx_single', Index::fromArray(['key' => 'idx_multi', 'type' => IndexType::Spatial, 'attributes' => ['loc', 'loc2']])); + $this->fail('Expected exception for spatial index on multiple attributes'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + } + + public function testSpatialIndexOnNonSpatial(): void + { + $locAttr = new Document([ + '$id' => 'loc', 'key' => 'loc', 'type' => ColumnType::Point->value, + 'size' => 0, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [ColumnType::Point->value], + ]); + $nameAttr = new Document([ + '$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, + 'size' => 4, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->makeCollection('spatial_nonspatial', [$locAttr, $nameAttr]); + $this->setupCollections([$col]); + + try { + $this->database->createIndex('spatial_nonspatial', Index::spatial(key: 'idx_name_spatial', attribute: 'name')); + $this->fail('Expected exception for spatial index on non-spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + + try { + $this->database->createIndex('spatial_nonspatial', Index::key(key: 'idx_loc_key', attributes: ['loc'])); + $this->fail('Expected exception for non-spatial index on spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(IndexException::class, $e); + } + } + + public function testInvalidCoordinateDocuments(): void + { + $pointAttr = new Document([ + '$id' => 'pointAttr', 'key' => 'pointAttr', 'type' => ColumnType::Point->value, + 'size' => 0, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [ColumnType::Point->value], + ]); + + $col = $this->makeCollection('test_invalid_coord', [$pointAttr]); + $this->setupCollections([$col]); + + $this->expectException(StructureException::class); + + $this->database->createDocument('test_invalid_coord', new Document([ + '$id' => 'invalidDoc1', + '$permissions' => [Permission::read(Role::any())], + 'pointAttr' => [200.0, 20.0], + ])); + } +} diff --git a/tests/unit/SpatialFilterTest.php b/tests/unit/SpatialFilterTest.php index 01ead20821..a8587b9fa3 100644 --- a/tests/unit/SpatialFilterTest.php +++ b/tests/unit/SpatialFilterTest.php @@ -5,9 +5,11 @@ use PHPUnit\Framework\TestCase; use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; -use Utopia\Database\Adapter; +use Utopia\Database\Adapter\Feature\Spatial; +use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Query\Schema\ColumnType; class SpatialFilterTest extends TestCase { @@ -16,13 +18,15 @@ class SpatialFilterTest extends TestCase */ private function createDatabase(array $point): Database { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(false); + $adapter = $this->createStub(MariaDB::class); + $adapter->method('hasFeature')->willReturnCallback( + static fn (string $feature): bool => $feature === Spatial::class, + ); $adapter->method('getTenant')->willReturn(null); $adapter->method('getNamespace')->willReturn('test'); - $adapter->method('getSharedTables')->willReturn(false); + $adapter->method('hasSharedTables')->willReturn(false); $adapter->method('filter')->willReturnArgument(0); - $adapter->method('decodePoint')->willReturn($point); + $adapter->method('decode')->willReturn($point); return new Database($adapter, new Cache(new None())); } @@ -34,9 +38,9 @@ private function pointCollection(): Document 'attributes' => [ new Document([ '$id' => 'location', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'array' => false, - 'filters' => [Database::VAR_POINT], + 'filters' => [ColumnType::Point->value], ]), ], ]); diff --git a/tests/unit/State/CoroutineStateTest.php b/tests/unit/State/CoroutineStateTest.php new file mode 100644 index 0000000000..5e89d68c8e --- /dev/null +++ b/tests/unit/State/CoroutineStateTest.php @@ -0,0 +1,274 @@ +markTestSkipped('ext-swoole is required for coroutine state'); + } + + $this->database = HookFixture::memory(); + $this->hook = new Relationships(); + $this->database->addHook($this->hook); + } + + public function testSkipRelationshipsInOneSiblingLeavesTheParentAndAnotherSiblingEnabled(): void + { + $seen = $this->whileASiblingIsInside( + fn (Closure $inside): mixed => $this->database->skipRelationships($inside), + fn (): bool => $this->hook->isEnabled(), + ); + + $this->assertSame(['inside' => false, 'parent' => true, 'sibling' => true, 'after' => true], $seen); + } + + public function testSkipRelationshipsExistCheckInOneSiblingLeavesTheParentAndAnotherSiblingChecking(): void + { + $seen = $this->whileASiblingIsInside( + fn (Closure $inside): mixed => $this->database->skipRelationshipsExistCheck($inside), + fn (): bool => $this->hook->shouldCheckExist(), + ); + + $this->assertSame(['inside' => false, 'parent' => true, 'sibling' => true, 'after' => true], $seen); + } + + public function testPopulationInOneSiblingLeavesAnotherSiblingFreeToPopulate(): void + { + $seen = $this->whileASiblingIsInside( + fn (Closure $inside): mixed => $this->hook->withSnapshot($this->snapshot(population: true), $inside), + fn (): bool => $this->hook->isInBatchPopulation(), + ); + + $this->assertSame(['inside' => true, 'parent' => false, 'sibling' => false, 'after' => false], $seen); + } + + public function testSilentIsSeenByTheCoroutinesItStarts(): void + { + $recorder = new RecordingLifecycle(); + $this->database->addHook($recorder); + + $this->inCoroutine(function (): void { + $this->database->silent(function (): void { + $done = new Channel(1); + Coroutine::create(function () use ($done): void { + $this->database->getCollection(HookFixture::COLLECTION); + $done->push(true); + }); + $done->pop(); + }); + }); + + $this->assertSame([], $recorder->getEvents()); + } + + public function testSilentInOneSiblingLeavesAnotherSiblingsEventsDelivered(): void + { + $recorder = new RecordingLifecycle(); + $this->database->addHook($recorder); + + $this->whileASiblingIsInside( + fn (Closure $inside): mixed => $this->database->silent($inside), + fn (): bool => $this->database->findCollection(HookFixture::COLLECTION) === null, + ); + + $this->assertSame([Event::CollectionRead, Event::CollectionRead, Event::CollectionRead], $recorder->getEvents()); + } + + public function testSnapshotCarriesTheCallersStateIntoACoroutineThatOutlivesIt(): void + { + $audits = new NamedRecordingLifecycle('audits'); + $this->database->addHook($audits); + /** @var ArrayObject $seen */ + $seen = new ArrayObject(); + + $this->inCoroutine(function () use ($seen): void { + $released = new Channel(1); + $done = new Channel(1); + + $this->database->getAuthorization()->skip(fn () => $this->database->skipRelationships( + fn () => $this->database->silent(function () use ($seen, $released, $done): void { + $snapshot = $this->database->snapshot(); + + Coroutine::create(function () use ($snapshot, $seen, $released, $done): void { + $released->pop(); + $this->database->withSnapshot($snapshot, function () use ($seen): void { + $seen['authorization'] = $this->database->getAuthorization()->getStatus(); + $seen['relationships'] = $this->hook->isEnabled(); + $this->database->getCollection(HookFixture::COLLECTION); + $this->database->getAuthorization()->disable(); + $this->hook->setEnabled(false); + }); + $done->push(true); + }); + }, ['audits']), + )); + + $released->push(true); + $done->pop(); + $seen['callerAuthorization'] = $this->database->getAuthorization()->getStatus(); + $seen['callerRelationships'] = $this->hook->isEnabled(); + }); + + $this->assertSame([ + 'authorization' => false, + 'relationships' => false, + 'callerAuthorization' => true, + 'callerRelationships' => true, + ], $seen->getArrayCopy()); + $this->assertSame([], $audits->getEvents()); + } + + public function testSnapshotAppliesToAnotherHandle(): void + { + $destination = HookFixture::memory(); + $destination->setAuthorization(new Authorization()); + $destinationHook = new Relationships(); + $destination->addHook($destinationHook); + + $snapshot = $this->database->getAuthorization()->skip( + fn (): Snapshot => $this->database->skipRelationshipsExistCheck(fn (): Snapshot => $this->database->snapshot()), + ); + + $seen = $destination->withSnapshot($snapshot, fn (): array => [ + $destination->getAuthorization()->getStatus(), + $destinationHook->shouldCheckExist(), + ]); + + $this->assertSame([false, false], $seen); + $this->assertTrue($destination->getAuthorization()->getStatus()); + $this->assertTrue($destinationHook->shouldCheckExist()); + } + + public function testSetTenantInACoroutineWhoseStarterHasReturnedStaysInThatCoroutine(): void + { + $this->database->setTenant(1); + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->database->withTenant(2, function () use (&$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use (&$seen, $done): void { + Coroutine::create(function () use (&$seen, $done): void { + Coroutine::sleep(0.01); + $seen['detachedBefore'] = $this->database->getTenant(); + $this->database->setTenant(3); + $seen['detachedAfter'] = $this->database->getTenant(); + $done->push(true); + }); + }); + + $done->pop(); + $seen['owner'] = $this->database->getTenant(); + }); + + $unrelatedDone = new Channel(1); + Coroutine::create(function () use (&$seen, $unrelatedDone): void { + $seen['unrelated'] = $this->database->getTenant(); + $unrelatedDone->push(true); + }); + $unrelatedDone->pop(); + }); + + $this->assertSame(['detachedBefore' => 1, 'detachedAfter' => 3, 'owner' => 2, 'unrelated' => 1], $seen); + $this->assertSame(1, $this->database->getTenant()); + } + + /** + * Opens a scope in one coroutine and reads the state from inside it, from the parent and from a sibling while + * the scope is open, and from the parent after it closed. + * + * @param Closure(Closure): mixed $scope + * @param Closure(): bool $read + * @return array + */ + private function whileASiblingIsInside(Closure $scope, Closure $read): array + { + $seen = []; + + $this->inCoroutine(function () use ($scope, $read, &$seen): void { + $entered = new Channel(1); + $released = new Channel(1); + $closed = new Channel(1); + + Coroutine::create(function () use ($scope, $read, &$seen, $entered, $released, $closed): void { + $scope(function () use ($read, &$seen, $entered, $released): void { + $seen['inside'] = $read(); + $entered->push(true); + $released->pop(); + }); + $closed->push(true); + }); + + $entered->pop(); + $seen['parent'] = $read(); + + Coroutine::create(function () use ($read, &$seen, $released): void { + $seen['sibling'] = $read(); + $released->push(true); + }); + + $closed->pop(); + $seen['after'] = $read(); + }); + + return $seen; + } + + private function snapshot(bool $population): Snapshot + { + return new Snapshot( + authorization: true, + roles: ['any'], + relationships: true, + existCheck: true, + population: $population, + silenced: false, + silencedListeners: [], + tenant: null, + filters: true, + disabledFilters: [], + validation: true, + preserveDates: false, + preserveSequence: false, + ignoreDuplicates: false, + requestTimestamp: null, + ); + } + + private function inCoroutine(Closure $test): void + { + $hookFlags = Runtime::getHookFlags(); + + try { + run($test); + } finally { + Runtime::setHookFlags($hookFlags); + } + } +} diff --git a/tests/unit/State/ValueTest.php b/tests/unit/State/ValueTest.php new file mode 100644 index 0000000000..9d133248f3 --- /dev/null +++ b/tests/unit/State/ValueTest.php @@ -0,0 +1,539 @@ +with('override', function () use ($value): array { + $seen = [$value->get()]; + $value->set('changed'); + $seen[] = $value->get(); + + return $seen; + }); + + $this->assertSame(['override', 'changed'], $inside); + $this->assertSame('handle', $value->get()); + } + + public function testAWriteOutsideAnOverrideChangesTheHandleWideValue(): void + { + $value = new Value('handle'); + + $value->set('changed'); + + $this->assertSame('changed', $value->get()); + } + + public function testAnOverrideIsRemovedWhenItsCallbackThrows(): void + { + $value = new Value('handle'); + + $thrown = null; + try { + $value->with('override', static fn (): never => throw new RuntimeException('failed')); + } catch (RuntimeException $error) { + $thrown = $error; + } + + $this->assertSame('failed', $thrown->getMessage()); + $this->assertSame('handle', $value->get()); + } + + public function testValuesKeepTheirOwnOverrides(): void + { + $first = new Value('first'); + $second = new Value('second'); + + $seen = $first->with('override', static fn (): array => [$first->get(), $second->get()]); + + $this->assertSame(['override', 'second'], $seen); + } + + public function testAnOverrideIsSeenByTheCoroutinesItStartsButNotBySiblingsOrTheParent(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $entered = new Channel(1); + $released = new Channel(1); + $childDone = new Channel(1); + + Coroutine::create(function () use ($value, &$seen, $entered, $released, $childDone): void { + $value->with('override', function () use ($value, &$seen, $entered, $released, $childDone): void { + Coroutine::create(function () use ($value, &$seen, $childDone): void { + Coroutine::sleep(0.001); + $seen['child'] = $value->get(); + $childDone->push(true); + }); + $childDone->pop(); + $entered->push(true); + $released->pop(); + }); + }); + + $entered->pop(); + $seen['parent'] = $value->get(); + + Coroutine::create(function () use ($value, &$seen, $released): void { + $seen['sibling'] = $value->get(); + $released->push(true); + }); + }); + + $this->assertSame(['child' => 'override', 'parent' => 'handle', 'sibling' => 'handle'], $seen); + } + + public function testAWriteInsideACoroutinesOverrideStaysInThatOverride(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('override', function () use ($value, &$seen): void { + $value->set('changed'); + $seen['inside'] = $value->get(); + }); + $seen['after'] = $value->get(); + }); + + $this->assertSame(['inside' => 'changed', 'after' => 'handle'], $seen); + } + + public function testAWriteInACoroutineWithoutAnOverrideChangesTheHandleWideValue(): void + { + $value = new Value('handle'); + + $this->inCoroutine(function () use ($value): void { + Coroutine::create(static fn () => $value->set('changed')); + }); + + $this->assertSame('changed', $value->get()); + } + + public function testAWriteInACoroutineStartedInsideAnOverrideStaysInThatCoroutine(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('override', function () use ($value, &$seen): void { + $written = new Channel(1); + $released = new Channel(1); + + Coroutine::create(function () use ($value, &$seen, $written, $released): void { + $value->set('child'); + $seen['child'] = $value->get(); + $written->push(true); + $released->pop(); + }); + + $written->pop(); + $seen['parent'] = $value->get(); + + $siblingDone = new Channel(1); + Coroutine::create(function () use ($value, &$seen, $siblingDone): void { + $seen['sibling'] = $value->get(); + $siblingDone->push(true); + }); + $siblingDone->pop(); + $released->push(true); + }); + + Coroutine::create(function () use ($value, &$seen): void { + $seen['unrelated'] = $value->get(); + }); + }); + + $this->assertSame( + ['child' => 'child', 'parent' => 'override', 'sibling' => 'override', 'unrelated' => 'handle'], + $seen, + ); + $this->assertSame('handle', $value->get()); + } + + public function testAWriteInACoroutineStartedInsideAnOverrideEndsWithThatOverride(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $written = new Channel(1); + $closed = new Channel(1); + $done = new Channel(1); + + $value->with('override', function () use ($value, &$seen, $written, $closed, $done): void { + Coroutine::create(function () use ($value, &$seen, $written, $closed, $done): void { + $value->set('child'); + $written->push(true); + $closed->pop(); + $seen['afterScope'] = $value->get(); + $done->push(true); + }); + + $written->pop(); + }); + + $closed->push(true); + $done->pop(); + $seen['parent'] = $value->get(); + }); + + $this->assertSame(['afterScope' => 'handle', 'parent' => 'handle'], $seen); + } + + public function testAWriteInACoroutineStartedInsideAnOverrideIsSeenByTheCoroutinesItStarts(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('override', function () use ($value, &$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use ($value, &$seen, $done): void { + $value->set('child'); + $grandchildDone = new Channel(1); + + Coroutine::create(function () use ($value, &$seen, $grandchildDone): void { + $seen['grandchild'] = $value->get(); + $value->set('grandchild'); + $seen['grandchildAfterWrite'] = $value->get(); + $grandchildDone->push(true); + }); + + $grandchildDone->pop(); + $seen['child'] = $value->get(); + $done->push(true); + }); + + $done->pop(); + $seen['parent'] = $value->get(); + }); + }); + + $this->assertSame( + ['grandchild' => 'child', 'grandchildAfterWrite' => 'grandchild', 'child' => 'child', 'parent' => 'override'], + $seen, + ); + $this->assertSame('handle', $value->get()); + } + + public function testAWriteInACoroutineUnderAnOverrideOpenedOutsideCoroutinesStaysInThatCoroutine(): void + { + $value = new Value('handle'); + $seen = []; + + $value->with('override', function () use ($value, &$seen): void { + $this->inCoroutine(function () use ($value, &$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use ($value, &$seen, $done): void { + $value->set('child'); + $seen['child'] = $value->get(); + $done->push(true); + }); + + $done->pop(); + $seen['parent'] = $value->get(); + }); + + $seen['outside'] = $value->get(); + }); + + $this->assertSame(['child' => 'child', 'parent' => 'override', 'outside' => 'override'], $seen); + $this->assertSame('handle', $value->get()); + } + + public function testANullWriteInACoroutineStartedInsideAnOverrideIsKept(): void + { + /** @var Value $value */ + $value = new Value('handle'); + $seen = null; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('override', function () use ($value, &$seen): void { + $done = new Channel(1); + + Coroutine::create(function () use ($value, &$seen, $done): void { + $value->set(null); + $seen = [$value->get()]; + $done->push(true); + }); + + $done->pop(); + }); + }); + + $this->assertSame([null], $seen); + } + + public function testAChildsOverrideLeavesItsParentUnchanged(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('parent', function () use ($value, &$seen): void { + $done = new Channel(1); + Coroutine::create(function () use ($value, &$seen, $done): void { + $value->with('child', function () use ($value, &$seen): void { + Coroutine::sleep(0.001); + $seen['child'] = $value->get(); + }); + $done->push(true); + }); + $seen['parent'] = $value->get(); + $done->pop(); + $seen['parentAfter'] = $value->get(); + }); + }); + + $this->assertSame(['parent' => 'parent', 'child' => 'child', 'parentAfter' => 'parent'], $seen); + } + + public function testAnOverrideOpenedOutsideCoroutinesIsSeenInsideThem(): void + { + $value = new Value('handle'); + $seen = null; + + $value->with('override', function () use ($value, &$seen): void { + $this->inCoroutine(function () use ($value, &$seen): void { + $seen = $value->get(); + }); + }); + + $this->assertSame('override', $seen); + $this->assertSame('handle', $value->get()); + } + + public function testAWriteInACoroutineWhoseStarterHasReturnedStaysInThatCoroutine(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('override', function () use ($value, &$seen): void { + $done = new Channel(1); + + $this->startDetached(function () use ($value, &$seen, $done): void { + $seen['before'] = $value->get(); + $value->set('first'); + $value->set('second'); + $seen['after'] = $value->get(); + + $childDone = new Channel(1); + Coroutine::create(function () use ($value, &$seen, $childDone): void { + $seen['child'] = $value->get(); + $childDone->push(true); + }); + $childDone->pop(); + $done->push(true); + }); + + $done->pop(); + $seen['owner'] = $value->get(); + }); + + $unrelatedDone = new Channel(1); + Coroutine::create(function () use ($value, &$seen, $unrelatedDone): void { + $seen['unrelated'] = $value->get(); + $unrelatedDone->push(true); + }); + $unrelatedDone->pop(); + }); + + $this->assertSame([ + 'before' => 'handle', + 'after' => 'second', + 'child' => 'second', + 'owner' => 'override', + 'unrelated' => 'handle', + ], $seen); + $this->assertSame('handle', $value->get()); + } + + public function testAWriteInACoroutineWhoseStarterHasReturnedEndsWithThatCoroutine(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('override', function () use ($value, &$seen): void { + $exited = new Channel(1); + + $this->startDetached(function () use ($value, $exited): void { + Coroutine::defer(static fn () => $exited->push(true)); + $value->set('detached'); + }); + + $exited->pop(); + + $childDone = new Channel(1); + Coroutine::create(function () use ($value, &$seen, $childDone): void { + $seen['child'] = $value->get(); + $childDone->push(true); + }); + $childDone->pop(); + }); + + $seen['afterScope'] = $value->get(); + + $written = new Channel(1); + Coroutine::create(function () use ($value, $written): void { + $value->set('later'); + $written->push(true); + }); + $written->pop(); + }); + + $this->assertSame(['child' => 'override', 'afterScope' => 'handle'], $seen); + $this->assertSame('later', $value->get()); + } + + public function testAWriteInACoroutineWhoseStarterHasReturnedKeepsItsValueAcrossItsOwnOverride(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $value->with('override', function () use ($value, &$seen): void { + $done = new Channel(1); + + $this->startDetached(function () use ($value, &$seen, $done): void { + $value->set('detached'); + $seen['inner'] = $value->with('inner', static fn (): string => $value->get()); + $seen['afterInner'] = $value->get(); + $done->push(true); + }); + + $done->pop(); + }); + }); + + $this->assertSame(['inner' => 'inner', 'afterInner' => 'detached'], $seen); + $this->assertSame('handle', $value->get()); + } + + public function testADetachedWriteAfterEveryOverrideEndedChangesTheSharedValueWhileAnOrphanKeepsItsOwn(): void + { + $value = new Value('handle'); + $seen = []; + + $this->inCoroutine(function () use ($value, &$seen): void { + $written = new Channel(1); + $released = new Channel(1); + $done = new Channel(1); + + $value->with('override', function () use ($value, &$seen, $written, $released, $done): void { + $this->startDetached(function () use ($value, &$seen, $written, $released, $done): void { + $value->set('orphan'); + $written->push(true); + $released->pop(); + $seen['orphan'] = $value->get(); + $done->push(true); + }); + + $written->pop(); + }); + + $shared = new Channel(1); + $this->startDetached(function () use ($value, $shared): void { + $value->set('shared'); + $shared->push(true); + }); + $shared->pop(); + $seen['owner'] = $value->get(); + + $released->push(true); + $done->pop(); + }); + + $this->assertSame(['owner' => 'shared', 'orphan' => 'orphan'], $seen); + $this->assertSame('shared', $value->get()); + } + + public function testAWriteInACoroutineWhoseStarterHasReturnedUnderAnOverrideOpenedOutsideCoroutinesStaysInThatCoroutine(): void + { + $value = new Value('handle'); + $seen = []; + + $value->with('override', function () use ($value, &$seen): void { + $this->inCoroutine(function () use ($value, &$seen): void { + $done = new Channel(1); + + $this->startDetached(function () use ($value, &$seen, $done): void { + $seen['before'] = $value->get(); + $value->set('child'); + $seen['after'] = $value->get(); + $done->push(true); + }); + + $done->pop(); + $seen['parent'] = $value->get(); + }); + + $seen['outside'] = $value->get(); + }); + + $this->assertSame( + ['before' => 'override', 'after' => 'child', 'parent' => 'override', 'outside' => 'override'], + $seen, + ); + $this->assertSame('handle', $value->get()); + } + + public function testAWriteInACoroutineWhoseStarterHasReturnedWithoutAnyOverrideChangesTheHandleWideValue(): void + { + $value = new Value('handle'); + + $this->inCoroutine(function () use ($value): void { + $this->startDetached(static fn () => $value->set('changed')); + }); + + $this->assertSame('changed', $value->get()); + } + + /** + * Runs the work in a coroutine whose parent has already returned. + */ + private function startDetached(Closure $work): void + { + Coroutine::create(static function () use ($work): void { + Coroutine::create(static function () use ($work): void { + Coroutine::sleep(0.01); + $work(); + }); + }); + } + + private function inCoroutine(Closure $test): void + { + if (! \extension_loaded('swoole')) { + $this->markTestSkipped('ext-swoole is required for coroutine scopes'); + } + + $hookFlags = Runtime::getHookFlags(); + + try { + run($test); + } finally { + Runtime::setHookFlags($hookFlags); + } + } +} diff --git a/tests/unit/StatisticalAggregateContractTest.php b/tests/unit/StatisticalAggregateContractTest.php new file mode 100644 index 0000000000..b45afe9ae1 --- /dev/null +++ b/tests/unit/StatisticalAggregateContractTest.php @@ -0,0 +1,114 @@ +}> + */ + public static function adapters(): iterable + { + yield 'mysql' => [MySQL::class]; + yield 'mariadb' => [MariaDB::class]; + yield 'postgres' => [Postgres::class]; + } + + /** + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testStddevCompilesToThePopulationForm(string $adapter): void + { + $sql = $this->captureFindSql($adapter, [Query::stddev('price', 'result')]); + + $this->assertMatchesRegularExpression('/\bSTDDEV_POP\s*\(/i', $sql, $sql); + $this->assertDoesNotMatchRegularExpression('/\bSTDDEV\s*\(/i', $sql, $sql); + $this->assertDoesNotMatchRegularExpression('/\bSTDDEV_SAMP\s*\(/i', $sql, $sql); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testVarianceCompilesToThePopulationForm(string $adapter): void + { + $sql = $this->captureFindSql($adapter, [Query::variance('price', 'result')]); + + $this->assertMatchesRegularExpression('/\bVAR_POP\s*\(/i', $sql, $sql); + $this->assertDoesNotMatchRegularExpression('/\bVARIANCE\s*\(/i', $sql, $sql); + $this->assertDoesNotMatchRegularExpression('/\bVAR_SAMP\s*\(/i', $sql, $sql); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testTheExplicitSampleFormsAreLeftAlone(string $adapter): void + { + $this->assertMatchesRegularExpression( + '/\bSTDDEV_SAMP\s*\(/i', + $this->captureFindSql($adapter, [Query::stddevSamp('price', 'result')]), + ); + $this->assertMatchesRegularExpression( + '/\bVAR_SAMP\s*\(/i', + $this->captureFindSql($adapter, [Query::varSamp('price', 'result')]), + ); + } + + /** + * @param class-string $adapter + * @param array $queries + */ + private function captureFindSql(string $adapter, array $queries): string + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('closeCursor')->willReturn(true); + + $sql = ''; + $pdo = $this->getMockBuilder(PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturnCallback(function (string $query) use (&$sql, $statement): PDOStatement { + $sql = $query; + + return $statement; + }); + + $instance = new $adapter($pdo); + $instance->setDatabase('database'); + $instance->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $instance->setAuthorization($authorization); + + $instance->find(new Document(['$id' => 'collection']), $queries, limit: 25); + + $this->assertNotSame('', $sql); + + return $sql; + } +} diff --git a/tests/unit/StorageTest.php b/tests/unit/StorageTest.php new file mode 100644 index 0000000000..139bdc1f9b --- /dev/null +++ b/tests/unit/StorageTest.php @@ -0,0 +1,31 @@ +assertSame(Storage::UID, Storage::column(Document::ID)); + $this->assertSame(Storage::SEQUENCE, Storage::column(Document::SEQUENCE)); + $this->assertSame('$internalId', Storage::column('$internalId')); + $this->assertSame('title', Storage::column('title')); + $this->assertFalse(\defined(Storage::class.'::ID')); + } + + public function test_maps_invert(): void + { + foreach (Storage::attributeMap() as $attribute => $column) { + $this->assertSame($attribute, Storage::attribute($column)); + } + } + + public function test_permissions_table(): void + { + $this->assertSame('movies_perms', Storage::permissionsTable('movies')); + } +} diff --git a/tests/unit/Support/BoundedJoinSortSQLite.php b/tests/unit/Support/BoundedJoinSortSQLite.php new file mode 100644 index 0000000000..b174070ddd --- /dev/null +++ b/tests/unit/Support/BoundedJoinSortSQLite.php @@ -0,0 +1,19 @@ + $queries + */ + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + if ($collection->getId() === Database::METADATA) { + $this->metadataReads++; + } else { + $this->documentReads++; + } + + return parent::getDocument($collection, $id, $queries, $forUpdate); + } + + /** + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @return array + */ + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array + { + if ($collection->getId() === Database::METADATA) { + $this->metadataReads++; + } else { + $this->finds++; + } + + return parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); + } + + public function reset(): void + { + $this->metadataReads = 0; + $this->documentReads = 0; + $this->finds = 0; + } +} diff --git a/tests/unit/Support/EngineError.php b/tests/unit/Support/EngineError.php new file mode 100644 index 0000000000..e7fa999676 --- /dev/null +++ b/tests/unit/Support/EngineError.php @@ -0,0 +1,26 @@ +code = $state; + } + }; + $error->errorInfo = [$state, $code, $message]; + + return $error; + } +} diff --git a/tests/unit/Support/FullOuterJoinSQLiteBuilder.php b/tests/unit/Support/FullOuterJoinSQLiteBuilder.php new file mode 100644 index 0000000000..b48a3d6b3c --- /dev/null +++ b/tests/unit/Support/FullOuterJoinSQLiteBuilder.php @@ -0,0 +1,18 @@ +afterDefinitionRead = $callback; + } + + /** + * @param array $queries + */ + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $document = parent::getDocument($collection, $id, $queries, $forUpdate); + + $callback = $this->afterDefinitionRead; + if ($callback !== null && $collection->getId() === Database::METADATA) { + $this->afterDefinitionRead = null; + $callback(); + } + + return $document; + } +} diff --git a/tests/unit/Support/NativeFullOuterJoinSQLite.php b/tests/unit/Support/NativeFullOuterJoinSQLite.php new file mode 100644 index 0000000000..3b589bb0cb --- /dev/null +++ b/tests/unit/Support/NativeFullOuterJoinSQLite.php @@ -0,0 +1,21 @@ + $capabilities + * @param list $features + * @param list $internalIndexKeys + */ + public static function of( + array $capabilities = [], + array $features = [], + bool $sharedTables = false, + bool $migrating = false, + int $string = 0, + int $varchar = 0, + int $integer = 0, + int $attributes = 0, + int $indexLength = 0, + int $uidLength = 36, + int $documentSize = 0, + ColumnType $idType = ColumnType::Integer, + ?DateTime $minDateTime = null, + ?DateTime $maxDateTime = null, + array $internalIndexKeys = [], + ): Profile { + return new Profile( + new Limits( + string: $string, + varchar: $varchar, + integer: $integer, + bigInteger: $integer, + attributes: $attributes, + indexes: 0, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: $indexLength, + uidLength: $uidLength, + documentSize: $documentSize, + minDateTime: $minDateTime ?? new DateTime('0000-01-01'), + maxDateTime: $maxDateTime ?? new DateTime('9999-12-31'), + idType: $idType, + keywords: [], + internalIndexKeys: $internalIndexKeys, + ), + $capabilities, + $features, + $sharedTables, + $migrating, + ); + } +} diff --git a/tests/unit/Support/RelationshipMemory.php b/tests/unit/Support/RelationshipMemory.php new file mode 100644 index 0000000000..d9eee04f50 --- /dev/null +++ b/tests/unit/Support/RelationshipMemory.php @@ -0,0 +1,50 @@ + + */ + #[\Override] + public function capabilities(): array + { + $capabilities = parent::capabilities(); + if ($this->savepoints) { + return $capabilities; + } + + return \array_values(\array_filter( + $capabilities, + static fn (Capability $capability): bool => $capability !== Capability::TransactionNested, + )); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T + */ + #[\Override] + public function withTransaction(callable $callback): mixed + { + if (! $this->savepoints && $this->inTransaction()) { + return $callback(); + } + + return parent::withTransaction($callback); + } +} diff --git a/tests/unit/Support/RelationshipSQLite.php b/tests/unit/Support/RelationshipSQLite.php new file mode 100644 index 0000000000..40b673640e --- /dev/null +++ b/tests/unit/Support/RelationshipSQLite.php @@ -0,0 +1,53 @@ + + */ + #[\Override] + public function capabilities(): array + { + $capabilities = parent::capabilities(); + if ($this->savepoints) { + return $capabilities; + } + + return \array_values(\array_filter( + $capabilities, + static fn (Capability $capability): bool => $capability !== Capability::TransactionNested, + )); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T + */ + #[\Override] + public function withTransaction(callable $callback): mixed + { + if (! $this->savepoints && $this->inTransaction()) { + return $callback(); + } + + return parent::withTransaction($callback); + } +} diff --git a/tests/unit/Support/ReplicaSetClient.php b/tests/unit/Support/ReplicaSetClient.php new file mode 100644 index 0000000000..71747f1304 --- /dev/null +++ b/tests/unit/Support/ReplicaSetClient.php @@ -0,0 +1,95 @@ +replicaSet; + } + + /** + * @param array $options + * @return array + */ + #[\Override] + public function startSession(array $options = []): array + { + $this->sessions++; + + return ['id' => (object) ['id' => $this->sessions]]; + } + + /** + * @param array $session + * @param array $options + */ + #[\Override] + public function startTransaction(array $session, array $options = []): bool + { + return true; + } + + /** + * @param array $session + * @param array $options + */ + #[\Override] + public function commitTransaction(array $session, array $options = []): bool + { + $this->commits++; + + return true; + } + + /** + * @param array $session + * @param array $options + */ + #[\Override] + public function abortTransaction(array $session, array $options = []): bool + { + $this->aborts++; + + return true; + } + + /** + * @param array $sessions + * @param array $options + */ + #[\Override] + public function endSessions(array $sessions, array $options = []): bool + { + return true; + } +} diff --git a/tests/unit/Support/StderrCapture.php b/tests/unit/Support/StderrCapture.php new file mode 100644 index 0000000000..09182efd43 --- /dev/null +++ b/tests/unit/Support/StderrCapture.php @@ -0,0 +1,55 @@ + $chunks */ + $chunks = new ArrayObject(); + $filter = \stream_filter_append(\STDERR, self::FILTER, \STREAM_FILTER_WRITE, $chunks); + + try { + $callback(); + } finally { + if ($filter !== false) { + \stream_filter_remove($filter); + } + } + + return \implode('', $chunks->getArrayCopy()); + } + + /** + * @param resource $in + * @param resource $out + * @param int $consumed + */ + #[\Override] + public function filter($in, $out, &$consumed, bool $closing): int + { + while ($bucket = \stream_bucket_make_writeable($in)) { + if ($this->params instanceof ArrayObject) { + $this->params->append($bucket->data); + } + $consumed += $bucket->datalen; + } + + return \PSFS_PASS_ON; + } +} diff --git a/tests/unit/Support/UncachedTwin.php b/tests/unit/Support/UncachedTwin.php new file mode 100644 index 0000000000..48d85d4409 --- /dev/null +++ b/tests/unit/Support/UncachedTwin.php @@ -0,0 +1,18 @@ +getAdapter(), new Cache(new None()))) + ->setAuthorization($database->getAuthorization()) + ->setDatabase($database->getDatabase()) + ->setNamespace($database->getNamespace()); + } +} diff --git a/tests/unit/Support/VerdictMemory.php b/tests/unit/Support/VerdictMemory.php new file mode 100644 index 0000000000..eeaf3cd994 --- /dev/null +++ b/tests/unit/Support/VerdictMemory.php @@ -0,0 +1,139 @@ + + */ + public array $verdicts = []; + + #[\Override] + public function create(string $name): bool + { + return $this->verdict(__FUNCTION__) ?? parent::create($name); + } + + #[\Override] + public function update(string $name, string $new): bool + { + return $this->verdict(__FUNCTION__) ?? parent::update($name, $new); + } + + #[\Override] + public function delete(string $name): bool + { + return $this->verdict(__FUNCTION__) ?? parent::delete($name); + } + + /** + * @param list $attributes + * @param list $indexes + */ + #[\Override] + public function createCollection(string $collection, array $attributes = [], array $indexes = []): bool + { + return $this->verdict(__FUNCTION__) ?? parent::createCollection($collection, $attributes, $indexes); + } + + #[\Override] + public function deleteCollection(string $collection): bool + { + return $this->verdict(__FUNCTION__) ?? parent::deleteCollection($collection); + } + + #[\Override] + public function createAttribute(string $collection, Attribute $attribute): bool + { + return $this->verdict(__FUNCTION__) ?? parent::createAttribute($collection, $attribute); + } + + /** + * @param list $attributes + */ + #[\Override] + public function createAttributes(string $collection, array $attributes): bool + { + return $this->verdict(__FUNCTION__) ?? parent::createAttributes($collection, $attributes); + } + + #[\Override] + public function updateAttribute(string $collection, string $key, Attribute $attribute): bool + { + return $this->verdict(__FUNCTION__) ?? parent::updateAttribute($collection, $key, $attribute); + } + + #[\Override] + public function relaxAttributeRequired(string $collection, string $id): bool + { + return $this->verdict(__FUNCTION__) ?? parent::relaxAttributeRequired($collection, $id); + } + + #[\Override] + public function deleteAttribute(string $collection, string $key): bool + { + return $this->verdict(__FUNCTION__) ?? parent::deleteAttribute($collection, $key); + } + + #[\Override] + public function renameAttribute(string $collection, string $old, string $new): bool + { + return $this->verdict(__FUNCTION__) ?? parent::renameAttribute($collection, $old, $new); + } + + #[\Override] + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool + { + return $this->verdict(__FUNCTION__) ?? parent::createIndex($collection, $index, $indexAttributeTypes, $collation); + } + + #[\Override] + public function deleteIndex(string $collection, string $key): bool + { + return $this->verdict(__FUNCTION__) ?? parent::deleteIndex($collection, $key); + } + + #[\Override] + public function renameIndex(string $collection, string $old, string $new): bool + { + return $this->verdict(__FUNCTION__) ?? parent::renameIndex($collection, $old, $new); + } + + #[\Override] + public function createRelationship(string $collection, Relationship $relationship): bool + { + return $this->verdict(__FUNCTION__) ?? parent::createRelationship($collection, $relationship); + } + + #[\Override] + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + { + return $this->verdict(__FUNCTION__) ?? parent::updateRelationship($collection, $relationship, $side, $update); + } + + #[\Override] + public function deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool + { + return $this->verdict(__FUNCTION__) ?? parent::deleteRelationship($collection, $relationship, $side); + } + + private function verdict(string $method): ?bool + { + $verdict = $this->verdicts[$method] ?? null; + + return $verdict === null ? null : $verdict(); + } +} diff --git a/tests/unit/Support/swoole-absent-classes.php b/tests/unit/Support/swoole-absent-classes.php new file mode 100644 index 0000000000..945b0d1883 --- /dev/null +++ b/tests/unit/Support/swoole-absent-classes.php @@ -0,0 +1,31 @@ +getExtension() === 'php') { + $classes[] = 'Utopia\\'.str_replace('/', '\\', substr($file->getPathname(), strlen($root) + 1, -4)); + } +} +sort($classes); + +foreach ($classes as $class) { + echo 'loading='.$class.PHP_EOL; + if (! class_exists($class) && ! interface_exists($class) && ! trait_exists($class) && ! enum_exists($class)) { + echo 'missing='.$class.PHP_EOL; + exit(1); + } +} + +echo 'loaded='.count($classes).PHP_EOL; diff --git a/tests/unit/Support/swoole-absent.php b/tests/unit/Support/swoole-absent.php new file mode 100644 index 0000000000..50b20e8670 --- /dev/null +++ b/tests/unit/Support/swoole-absent.php @@ -0,0 +1,77 @@ +setDatabase('utopiaTests')->setNamespace('swoole_absent'); +$database->create(); +$database->getAuthorization()->addRole(Role::any()->toString()); + +echo 'create=ok' . PHP_EOL; + +echo 'silent=' . $database->silent(fn () => 'ok') . PHP_EOL; + +$database->createCollection(Collection::create( + id: 'logs', + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, +)); + +foreach (['a', 'b', 'c'] as $id) { + $database->createDocument('logs', new Document(['$id' => $id])); +} + +$deleted = $database->deleteDocuments('logs', [Query::limit(10)]); + +echo 'deleted=' . $deleted . PHP_EOL; +echo 'remaining=' . \count($database->find('logs', [Query::limit(10)])) . PHP_EOL; +$idle = new PDOException('SQLSTATE[HY000]: General error: 4031'); +$idle->errorInfo = ['HY000', 4031, 'The client was disconnected by the server because of inactivity.']; + +$errors = [ + new RuntimeException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'), + new RuntimeException('Lost connection to MySQL server during query'), + new RuntimeException('SQLSTATE[08006] server closed the connection unexpectedly'), + new RuntimeException('Max connect timeout reached'), + new RuntimeException('Communication link failure'), + new RuntimeException('SQLSTATE[HY000]: General error: 4031 The client was disconnected by the server because of inactivity. See wait_timeout and interactive_timeout for configuring this behavior.'), + new RuntimeException('SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: EOF detected'), + new RuntimeException("Error reading result set's header"), + new RuntimeException('fwrite(): Send of 1024 bytes failed with errno=32 Broken pipe'), + new RuntimeException('Connection lost while reading the response'), + new RuntimeException('The database server went away'), + $idle, +]; + +$lost = 0; +foreach ($errors as $error) { + $lost += Connection::hasError($error) ? 1 : 0; +} + +echo 'lostDetected=' . $lost . '/' . \count($errors) . PHP_EOL; +echo 'unrelatedDetected=' . (Connection::hasError(new RuntimeException('syntax error near FROM')) ? '1' : '0') . PHP_EOL; diff --git a/tests/unit/SwooleAbsentTest.php b/tests/unit/SwooleAbsentTest.php new file mode 100644 index 0000000000..6a3e791777 --- /dev/null +++ b/tests/unit/SwooleAbsentTest.php @@ -0,0 +1,98 @@ + $flags + * @return array{status: int, output: string} + */ + private function runFixture(array $flags, string $fixture = 'swoole-absent.php'): array + { + $command = \escapeshellarg(PHP_BINARY); + + foreach ($flags as $flag) { + $command .= ' ' . $flag; + } + + $command .= ' ' . \escapeshellarg(__DIR__ . '/Support/' . $fixture) . ' 2>&1'; + + \exec($command, $lines, $status); + + return ['status' => $status, 'output' => \implode(PHP_EOL, $lines)]; + } + + /** + * ext-swoole is optional: it is absent from composer.json's require block, so a + * consumer may run the library on a PHP that does not have it. An extension cannot + * be unloaded from a running interpreter, so this drives a subprocess started with + * -n, which skips php.ini and every conf.d file and therefore loads no shared + * extension. Swoole ships as a shared extension in the test image and in every + * environment that installs it through pecl. + */ + public function testDatabaseOperatesWithoutSwoole(): void + { + ['status' => $status, 'output' => $output] = $this->runFixture(['-n']); + + if (\str_contains($output, 'swoole=1')) { + $this->markTestSkipped('swoole is statically compiled into ' . PHP_BINARY . ', so its absence cannot be exercised'); + } + + $this->assertSame(0, $status, "Fixture exited {$status} without swoole:" . PHP_EOL . $output); + + $this->assertStringContainsString('create=ok', $output, $output); + $this->assertStringContainsString('silent=ok', $output, $output); + $this->assertStringContainsString('deleted=3', $output, $output); + $this->assertStringContainsString('remaining=0', $output, $output); + $this->assertStringContainsString('lostDetected=12/12', $output, $output); + $this->assertStringContainsString('unrelatedDetected=0', $output, $output); + } + + /** + * -n drops every shared extension, so the drivers the adapters build on are loaded again by name, leaving out + * only swoole and the debuggers. + */ + public function testEveryClassLoadsWithoutSwoole(): void + { + $flags = ['-n']; + $directory = (string) \ini_get('extension_dir'); + foreach (['pdo_mysql', 'pdo_pgsql', 'mongodb', 'redis'] as $extension) { + if (\is_file($directory.'/'.$extension.'.so')) { + $flags[] = '-d extension='.$extension; + } + } + + ['status' => $status, 'output' => $output] = $this->runFixture($flags, 'swoole-absent-classes.php'); + + if (\str_contains($output, 'swoole=1')) { + $this->markTestSkipped('swoole is statically compiled into '.PHP_BINARY.', so its absence cannot be exercised'); + } + + $this->assertSame(0, $status, "Loading the library's classes without swoole exited {$status}:".PHP_EOL.$output); + $this->assertMatchesRegularExpression('/^loaded=\d+$/m', $output, $output); + $this->assertStringContainsString('loading=Utopia\Database\Adapter\SQLite', $output); + $this->assertStringContainsString('loading=Utopia\Database\Adapter\Mongo', $output); + } + + /** + * Swoole\Database\DetectsLostConnections comes from Swoole's PHP-land library, which + * swoole.enable_library=Off switches off while leaving the extension loaded. Lost + * connections must still be recognised, so detection cannot rest on that class. + */ + public function testLostConnectionsAreDetectedWithoutSwooleLibrary(): void + { + if (! \extension_loaded('swoole')) { + $this->markTestSkipped('swoole is not loaded, so its library cannot be switched off'); + } + + ['status' => $status, 'output' => $output] = $this->runFixture(['-d swoole.enable_library=Off']); + + $this->assertSame(0, $status, "Fixture exited {$status} with the swoole library disabled:" . PHP_EOL . $output); + + $this->assertStringContainsString('lostDetected=12/12', $output, $output); + $this->assertStringContainsString('unrelatedDetected=0', $output, $output); + } +} diff --git a/tests/unit/TenantIdentityTest.php b/tests/unit/TenantIdentityTest.php new file mode 100644 index 0000000000..f176d96766 --- /dev/null +++ b/tests/unit/TenantIdentityTest.php @@ -0,0 +1,73 @@ +createStub(PDO::class)); + $adapter->setDatabase('test'); + $adapter->setNamespace('test'); + $adapter->setSharedTables(true); + $adapter->setTenant($tenant); + + return new Database($adapter, new Cache(new None())); + } + + private function hashKey(int|string|null $tenant): string + { + [, , $hashKey] = $this->database($tenant)->getCacheKeys('col', 'doc1'); + + return $hashKey; + } + + public function testTheCacheKeyFollowsTheTenantTheColumnWillHold(): void + { + $this->assertSame( + $this->hashKey(1), + $this->hashKey('001'), + 'The engine stores "001" as 1 and returns those rows for tenant 1, so the cache key must not separate them', + ); + } + + public function testAStringifiedTenantIsTheSameTenantAsItsInteger(): void + { + $this->assertSame($this->hashKey(1), $this->hashKey('1')); + + $adapter = new MariaDB($this->createStub(PDO::class)); + $adapter->setTenant('1'); + + $this->assertSame(1, $adapter->getTenant(), 'A driver that stringifies the column must still compare equal to the integer tenant'); + } + + public function testADocumentTenantNormalisesTheSameWayTheAdapterDoes(): void + { + $this->assertSame(1, (new Document(['$tenant' => '1']))->getTenant()); + $this->assertSame(1, (new Document(['$tenant' => '001']))->getTenant()); + } + + public function testANonNumericTenantIsLeftAlone(): void + { + $adapter = new MariaDB($this->createStub(PDO::class)); + $adapter->setTenant('tenant-a'); + + $this->assertSame('tenant-a', $adapter->getTenant()); + } +} diff --git a/tests/unit/TogglesNote.php b/tests/unit/TogglesNote.php new file mode 100644 index 0000000000..8588ae75b1 --- /dev/null +++ b/tests/unit/TogglesNote.php @@ -0,0 +1,9 @@ + + */ + public static function toggles(): iterable + { + yield 'validation' => [ + static fn (Database $database, bool $value): Database => $database->setValidation($value), + static fn (Database $database): bool => $database->isValidating(), + true, + ]; + yield 'filtering' => [ + static fn (Database $database, bool $value): Database => $database->setFiltering($value), + static fn (Database $database): bool => $database->isFiltering(), + true, + ]; + yield 'profiling' => [ + static fn (Database $database, bool $value): Database => $database->setProfiling($value), + static fn (Database $database): bool => $database->isProfiling(), + false, + ]; + yield 'preserve dates' => [ + static fn (Database $database, bool $value): Database => $database->setPreserveDates($value), + static fn (Database $database): bool => $database->isPreservingDates(), + false, + ]; + yield 'preserve sequence' => [ + static fn (Database $database, bool $value): Database => $database->setPreserveSequence($value), + static fn (Database $database): bool => $database->isPreservingSequence(), + false, + ]; + yield 'migrating' => [ + static fn (Database $database, bool $value): Database => $database->setMigrating($value), + static fn (Database $database): bool => $database->isMigrating(), + false, + ]; + yield 'drop unknown attributes' => [ + static fn (Database $database, bool $value): Database => $database->setDropUnknownAttributes($value), + static fn (Database $database): bool => $database->isDroppingUnknownAttributes(), + false, + ]; + yield 'shared tables' => [ + static fn (Database $database, bool $value): Database => $database->setSharedTables($value), + static fn (Database $database): bool => $database->hasSharedTables(), + false, + ]; + yield 'tenant per document' => [ + static fn (Database $database, bool $value): Database => $database->setTenantPerDocument($value), + static fn (Database $database): bool => $database->isTenantPerDocument(), + false, + ]; + } + + /** + * @param Closure(Database, bool): Database $set + * @param Closure(Database): bool $read + */ + #[DataProvider('toggles')] + public function testSetterReturnsTheDatabaseAndTheReaderFollowsIt(Closure $set, Closure $read, bool $default): void + { + $database = $this->database(); + + $this->assertSame($default, $read($database)); + $this->assertSame($database, $set($database, ! $default)); + $this->assertSame(! $default, $read($database)); + $this->assertSame($database, $set($database, $default)); + $this->assertSame($default, $read($database)); + } + + public function testConfigurationSettersReturnTheDatabase(): void + { + $database = $this->database(); + + $this->assertSame($database, $database->setAuthorization(new Authorization())); + $this->assertSame($database, $database->setMaxQueryValues(10)); + $this->assertSame(10, $database->getMaxQueryValues()); + $this->assertSame($database, $database->setLocks(true)); + $this->assertSame($database, $database->setTenant(7)); + $this->assertSame(7, $database->getTenant()); + } + + /** + * @return iterable + */ + public static function scopes(): iterable + { + yield 'withValidation' => [ + static fn (Database $database, bool $value, Closure $callback): mixed => $database->withValidation($value, $callback), + static fn (Database $database): bool => $database->isValidating(), + true, + ]; + yield 'withFiltering' => [ + static fn (Database $database, bool $value, Closure $callback): mixed => $database->withFiltering($value, $callback), + static fn (Database $database): bool => $database->isFiltering(), + true, + ]; + yield 'withPreserveDates' => [ + static fn (Database $database, bool $value, Closure $callback): mixed => $database->withPreserveDates($value, $callback), + static fn (Database $database): bool => $database->isPreservingDates(), + false, + ]; + yield 'withPreserveSequence' => [ + static fn (Database $database, bool $value, Closure $callback): mixed => $database->withPreserveSequence($value, $callback), + static fn (Database $database): bool => $database->isPreservingSequence(), + false, + ]; + } + + /** + * @param Closure(Database, bool, Closure(): mixed): mixed $scope + * @param Closure(Database): bool $read + */ + #[DataProvider('scopes')] + public function testScopeAppliesTheGivenValueOnlyInsideTheCallback(Closure $scope, Closure $read, bool $default): void + { + $database = $this->database(); + + $this->assertSame(! $default, $scope($database, ! $default, static fn (): bool => $read($database))); + $this->assertSame($default, $read($database)); + $this->assertSame($default, $scope($database, $default, static fn (): bool => $read($database))); + } + + /** + * @param Closure(Database, bool, Closure(): mixed): mixed $scope + * @param Closure(Database): bool $read + */ + #[DataProvider('scopes')] + public function testScopeCanTurnBackOnWhatTheHandleTurnedOff(Closure $scope, Closure $read, bool $default): void + { + $database = $this->database(); + $inner = static fn (bool $value): mixed => $scope($database, $value, static fn (): bool => $read($database)); + + $this->assertSame($default, $scope($database, ! $default, static fn (): mixed => $inner($default))); + $this->assertSame(! $default, $scope($database, $default, static fn (): mixed => $inner(! $default))); + } + + public function testSkipValidationAndSkipFiltersTurnTheirToggleOffInsideTheCallback(): void + { + $database = $this->database(); + + $this->assertFalse($database->skipValidation(static fn (): bool => $database->isValidating())); + $this->assertFalse($database->skipFilters(static fn (): bool => $database->isFiltering())); + $this->assertTrue($database->isValidating()); + $this->assertTrue($database->isFiltering()); + } + + public function testFilteringOffReadsTheStoredValue(): void + { + $database = $this->seeded(); + + $this->assertSame('QUIET', $this->title($database)); + $this->assertSame('quiet', $database->withFiltering(false, fn (): string => $this->title($database))); + $database->setFiltering(false); + $this->assertSame('quiet', $this->title($database)); + $this->assertSame('QUIET', $database->withFiltering(true, fn (): string => $this->title($database))); + } + + public function testNamedFiltersAreTurnedOffAndBackOn(): void + { + $database = $this->seeded(); + + $this->assertSame('quiet', $database->withFiltering(false, fn (): string => $this->title($database), [self::FILTER])); + $this->assertTrue($database->withFiltering(false, static fn (): bool => $database->isFiltering(), [self::FILTER])); + $this->assertSame('QUIET', $database->skipFilters( + fn (): string => $database->withFiltering(true, fn (): string => $this->title($database), [self::FILTER]), + [self::FILTER], + )); + } + + public function testPreservedDatesKeepTheGivenCreationTime(): void + { + $database = $this->seeded(); + $create = static fn (string $id): Document => $database->createDocument(self::COLLECTION, new Document([ + '$id' => $id, + '$createdAt' => self::PAST, + 'title' => $id, + ])); + + $preserved = $database->withPreserveDates(true, static fn (): Document => $create('preserved')); + $stamped = $database->withPreserveDates(false, static fn (): Document => $create('stamped')); + + $this->assertSame(self::PAST, $preserved->getCreatedAt()); + $this->assertNotSame(self::PAST, $stamped->getCreatedAt()); + } + + public function testIgnoreDuplicatesSkipsAnExistingIdOnlyInsideTheCallback(): void + { + $database = $this->seeded(); + $duplicate = static fn (): int => $database->createDocuments(self::COLLECTION, [new Document(['$id' => 'note', 'title' => 'again'])]); + + $this->assertSame(0, $database->ignoreDuplicates($duplicate)); + $this->assertSame('QUIET', $this->title($database)); + + $this->expectException(DuplicateException::class); + $duplicate(); + } + + public function testClearDocumentTypesKeepsTheMetadataCollection(): void + { + $database = $this->database(); + $database->setDocumentType(self::COLLECTION, TogglesNote::class); + + $database->clearDocumentType(self::COLLECTION); + $this->assertNull($database->getDocumentType(self::COLLECTION)); + + $database->setDocumentType(self::COLLECTION, TogglesNote::class); + $database->clearDocumentTypes(); + + $this->assertNull($database->getDocumentType(self::COLLECTION)); + $this->assertSame(Collection::class, $database->getDocumentType(Database::METADATA)); + } + + private function database(): Database + { + $database = new Database(new Memory(), new Cache(new None()), [new Callback( + self::FILTER, + static fn (mixed $value): mixed => \is_string($value) ? \strtolower($value) : $value, + static fn (mixed $value): mixed => \is_string($value) ? \strtoupper($value) : $value, + )]); + + return $database + ->setAuthorization(new Authorization()) + ->setDatabase('toggles') + ->setNamespace('toggles_'.\uniqid()); + } + + private function seeded(): Database + { + $database = $this->database(); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64, filters: [self::FILTER])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument(self::COLLECTION, new Document(['$id' => 'note', 'title' => 'Quiet'])); + + return $database; + } + + /** + * @phpstan-impure + */ + private function title(Database $database): string + { + $title = $database->getDocument(self::COLLECTION, 'note')->getAttribute('title'); + $this->assertIsString($title); + + return $title; + } +} diff --git a/tests/unit/TransactionRetryTest.php b/tests/unit/TransactionRetryTest.php index 77fdf73d68..0fa16d09b0 100644 --- a/tests/unit/TransactionRetryTest.php +++ b/tests/unit/TransactionRetryTest.php @@ -2,90 +2,364 @@ namespace Tests\Unit; +use Closure; +use InvalidArgumentException; +use LogicException; +use PDO; +use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +use RuntimeException; +use Tests\Unit\Support\EngineError; +use Throwable; +use Utopia\Cache\Adapter\None as NoCache; +use Utopia\Cache\Cache; +use Utopia\Database\Adapter; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Adapter\Pool; +use Utopia\Database\Adapter\ReadWritePool; use Utopia\Database\Adapter\Redis as RedisAdapter; +use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Database; +use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Authorization as AuthorizationException; +use Utopia\Database\Exception\Character as CharacterException; +use Utopia\Database\Exception\Conflict as ConflictException; +use Utopia\Database\Exception\Contention as ContentionException; +use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Index as IndexException; +use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\Mismatch as MismatchException; +use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Operator as OperatorException; +use Utopia\Database\Exception\Order as OrderException; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Relationship as RelationshipException; +use Utopia\Database\Exception\Restricted as RestrictedException; +use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Exception\Transaction as TransactionException; +use Utopia\Database\Exception\Truncate as TruncateException; +use Utopia\Database\Exception\Type as TypeException; +use Utopia\Database\Exception\Unique as UniqueException; +use Utopia\Database\Mirror; +use Utopia\Database\Validator\Authorization; +use Utopia\Pools\Pool as UtopiaPool; /** - * Covers the retry policy of Adapter::withTransaction(): which exceptions abort - * immediately versus which are retried up to the built-in attempt budget. + * Covers the retry policy of withTransaction(): only a failure that can succeed when the attempt runs again is + * retried, up to the built-in attempt budget, on every entry point that runs a transaction. */ class TransactionRetryTest extends TestCase { - private DatabaseMemory $adapter; + private const int ATTEMPTS = 3; - protected function setUp(): void + private const string LOST_CONNECTION = 'SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'; + + private const string ADAPTER = 'adapter'; + + private const string SQLITE = 'sqlite'; + + private const string DATABASE = 'database'; + + private const string POOL = 'pool'; + + private const string READ_WRITE_POOL = 'read-write pool'; + + private const string MIRROR = 'mirror'; + + /** + * @return array + */ + public static function deterministicFailures(): array + { + return [ + 'structure' => [new StructureException('Invalid document structure: Unknown attribute: "name"')], + 'not found' => [new NotFoundException('Collection not found')], + 'query' => [new QueryException('Invalid query')], + 'type' => [new TypeException('Invalid operation')], + 'index' => [new IndexException('Index already exists')], + 'dependency' => [new DependencyException('Attribute cannot be deleted because it is used in an index')], + 'order' => [new OrderException('Invalid order')], + 'character' => [new CharacterException('Invalid character')], + 'truncate' => [new TruncateException('Resize would result in data truncation')], + 'operator' => [new OperatorException('Invalid operator')], + 'mismatch' => [new MismatchException('Document already exists with different data')], + 'unique' => [new UniqueException(UniqueException::MESSAGE)], + 'duplicate' => [new DuplicateException('Document already exists')], + 'restricted' => [new RestrictedException('Restricted')], + 'authorization' => [new AuthorizationException('Missing "create" permission')], + 'relationship' => [new RelationshipException('Invalid relationship')], + 'conflict' => [new ConflictException('Document was updated after the request timestamp')], + 'limit' => [new LimitException('Value out of range')], + 'timeout' => [new TimeoutException('Query timed out')], + 'typed failure wrapping a lost connection' => [new StructureException('Invalid document', previous: new PDOException(self::LOST_CONNECTION))], + 'database' => [new DatabaseException('Missing ID')], + 'invalid argument' => [new InvalidArgumentException('Invalid argument')], + 'logic' => [new LogicException('Unsupported')], + 'runtime' => [new RuntimeException('Unknown failure')], + 'driver error' => [EngineError::create('42000', 1064, 'SQLSTATE[42000]: Syntax error or access violation: 1064')], + ]; + } + + /** + * @return array + */ + public static function transientFailures(): array + { + return [ + 'contention' => [new ContentionException('Deadlock detected')], + 'transaction' => [new TransactionException('Failed to start transaction')], + 'lost connection' => [new PDOException(self::LOST_CONNECTION)], + 'lost connection by driver code' => [EngineError::create('HY000', 2013, 'Lost')], + 'failure wrapping a lost connection' => [new DatabaseException('Failed to commit transaction', previous: new PDOException(self::LOST_CONNECTION))], + 'statement refused after a lost connection' => [new PDOException('The transaction was lost with the connection: roll it back before running another statement', previous: new PDOException(self::LOST_CONNECTION))], + ]; + } + + /** + * @return array + */ + public static function entries(): array { - $this->adapter = new DatabaseMemory(); + return [ + self::ADAPTER => [self::ADAPTER], + self::SQLITE => [self::SQLITE], + self::DATABASE => [self::DATABASE], + self::POOL => [self::POOL], + self::READ_WRITE_POOL => [self::READ_WRITE_POOL], + self::MIRROR => [self::MIRROR], + ]; } /** - * A statement timeout already spent the full timeout budget on this attempt; - * retrying re-runs it for another full budget and amplifies lock convoys. - * It must abort after a single attempt. + * @return array */ - public function testTimeoutIsNotRetried(): void + public static function pools(): array { + return [ + self::POOL => [self::POOL], + self::READ_WRITE_POOL => [self::READ_WRITE_POOL], + ]; + } + + /** + * Running the attempt again fails the same way: the caller gets the failure at once. + */ + #[DataProvider('deterministicFailures')] + public function testDeterministicFailureRunsOnce(Throwable $failure): void + { + $adapter = new DatabaseMemory(); + [$thrown, $attempts] = $this->attempt($adapter->withTransaction(...), $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(1, $attempts, 'A deterministic failure must not run again'); + $this->assertFalse($adapter->inTransaction()); + } + + #[DataProvider('transientFailures')] + public function testTransientFailureRunsEveryAttempt(Throwable $failure): void + { + $adapter = new DatabaseMemory(); + [$thrown, $attempts] = $this->attempt($adapter->withTransaction(...), $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(self::ATTEMPTS, $attempts); + $this->assertFalse($adapter->inTransaction()); + } + + public function testTransientFailureSucceedsWhenItRunsAgain(): void + { + $adapter = new DatabaseMemory(); $attempts = 0; - $thrown = null; + $stored = \uniqid(); - try { - $this->adapter->withTransaction(function () use (&$attempts) { - $attempts++; - throw new TimeoutException('Query timed out'); - }); - } catch (TimeoutException $e) { - $thrown = $e; + $result = $adapter->withTransaction(function () use (&$attempts, $stored): string { + $attempts++; + if ($attempts === 1) { + throw new ContentionException('Lock wait timeout exceeded'); + } + + return $stored; + }); + + $this->assertSame($stored, $result); + $this->assertSame(2, $attempts); + $this->assertFalse($adapter->inTransaction()); + } + + #[DataProvider('entries')] + public function testEveryEntryPointRunsADeterministicFailureOnce(string $entry): void + { + foreach ([new StructureException('Invalid document structure'), new InvalidArgumentException('Invalid argument')] as $failure) { + [$transaction, $adapter] = $this->entry($entry); + [$thrown, $attempts] = $this->attempt($transaction, $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(1, $attempts, "{$entry} must not run a deterministic failure again"); + $this->assertFalse($adapter->inTransaction()); } + } - $this->assertInstanceOf(TimeoutException::class, $thrown); + #[DataProvider('entries')] + public function testEveryEntryPointRetriesContention(string $entry): void + { + [$transaction, $adapter] = $this->entry($entry); + $failure = new ContentionException('Deadlock detected'); + [$thrown, $attempts] = $this->attempt($transaction, $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(self::ATTEMPTS, $attempts, "{$entry} must retry a lock conflict"); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A lock conflict the driver raised without the adapter mapping it is still a lock conflict. + */ + public function testUnmappedDriverContentionIsRetried(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $failure = EngineError::create('HY000', 5, 'SQLSTATE[HY000]: General error: 5 database is locked'); + [$thrown, $attempts] = $this->attempt($adapter->withTransaction(...), $failure); + + $this->assertSame($failure, $thrown); + $this->assertSame(self::ATTEMPTS, $attempts); + $this->assertFalse($adapter->inTransaction()); + } + + public function testUnmappedDeterministicDriverErrorRunsOnce(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $failure = EngineError::create('HY000', 1, 'SQLSTATE[HY000]: General error: 1 no such table: missing'); + [$thrown, $attempts] = $this->attempt($adapter->withTransaction(...), $failure); + + $this->assertSame($failure, $thrown); $this->assertSame(1, $attempts); + $this->assertFalse($adapter->inTransaction()); } /** - * A duplicate is deterministic, so it also aborts on the first attempt. - * Anchors the timeout case against an existing no-retry exception. + * A pool answers whether a failure is retried as the adapters it lends out do. */ - public function testDuplicateIsNotRetried(): void + #[DataProvider('pools')] + public function testPoolClassifiesFailuresAsItsAdapterDoes(string $entry): void { - $attempts = 0; - $thrown = null; + $adapter = new SQLite(new PDO('sqlite::memory:')); + $pool = $entry === self::POOL + ? new Pool($this->connections($adapter)) + : new ReadWritePool($this->connections($adapter), $this->connections(new DatabaseMemory())); + $pool->setAuthorization(new Authorization()); - try { - $this->adapter->withTransaction(function () use (&$attempts) { - $attempts++; - throw new DuplicateException('Duplicate'); + $this->assertTrue($pool->isRetryable(EngineError::create('HY000', 5, 'SQLSTATE[HY000]: General error: 5 database is locked'))); + $this->assertFalse($pool->isRetryable(EngineError::create('HY000', 1, 'SQLSTATE[HY000]: General error: 1 no such table: missing'))); + $this->assertTrue($pool->isRetryable(new ContentionException('Deadlock detected'))); + $this->assertFalse($pool->isRetryable(new StructureException('Invalid document structure'))); + } + + /** + * A nested call does not retry a deterministic failure in its savepoint, and the enclosing call does not run + * the whole unit again for it either. + */ + public function testDeterministicFailureInANestedCallRunsOnce(): void + { + $adapter = new DatabaseMemory(); + $failure = new StructureException('Invalid document structure'); + $outer = 0; + $nested = 0; + + $thrown = $this->capture(function () use ($adapter, $failure, &$outer, &$nested): void { + $adapter->withTransaction(function () use ($adapter, $failure, &$outer, &$nested): void { + $outer++; + $adapter->withTransaction(function () use ($failure, &$nested): never { + $nested++; + + throw $failure; + }); }); - } catch (DuplicateException $e) { - $thrown = $e; - } + }); - $this->assertInstanceOf(DuplicateException::class, $thrown); - $this->assertSame(1, $attempts); + $this->assertSame($failure, $thrown); + $this->assertSame(1, $nested); + $this->assertSame(1, $outer); + $this->assertFalse($adapter->inTransaction()); } /** - * A transient/unknown failure is still retried across the full attempt - * budget (3 attempts: initial + 2 retries) before the error propagates. + * Only the outermost call retries: a nested call rolls a lock conflict back to its savepoint + * and rethrows it, and the outermost call runs the whole unit again. */ - public function testGenericFailureIsRetried(): void + public function testTransientFailureInANestedCallRunsTheOuterTransactionAgain(): void { - $attempts = 0; - $thrown = null; + $adapter = new DatabaseMemory(); + $outer = 0; + $nested = 0; + $stored = \uniqid(); - try { - $this->adapter->withTransaction(function () use (&$attempts) { - $attempts++; - throw new \RuntimeException('transient'); + $result = $adapter->withTransaction(function () use ($adapter, &$outer, &$nested, $stored): string { + $outer++; + + return $adapter->withTransaction(function () use (&$nested, $stored): string { + $nested++; + if ($nested === 1) { + throw new ContentionException('Lock not available'); + } + + return $stored; }); - } catch (\RuntimeException $e) { - $thrown = $e; - } + }); - $this->assertInstanceOf(\RuntimeException::class, $thrown); - $this->assertSame(3, $attempts); + $this->assertSame($stored, $result); + $this->assertSame(2, $nested); + $this->assertSame(2, $outer); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A lock conflict that keeps failing runs the nested callback once per attempt of the + * outermost call, on every entry point, instead of once per attempt of each call. + */ + #[DataProvider('entries')] + public function testEveryEntryPointRunsAPersistentNestedLockConflictOncePerAttempt(string $entry): void + { + [$transaction, $adapter] = $this->entry($entry); + $failure = new ContentionException('Lock wait timeout exceeded'); + $outer = 0; + $nested = 0; + + $thrown = $this->capture(function () use ($transaction, $failure, &$outer, &$nested): void { + $transaction(function () use ($transaction, $failure, &$outer, &$nested): void { + $outer++; + $transaction(function () use ($failure, &$nested): never { + $nested++; + + throw $failure; + }); + }); + }); + + $this->assertSame($failure, $thrown); + $this->assertSame(self::ATTEMPTS, $outer, "{$entry} must retry the outermost call"); + $this->assertSame(self::ATTEMPTS, $nested, "{$entry} must run the nested call once per attempt"); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * The count of enclosing calls unwinds with every call, so a later top-level call owns its + * retries again. + */ + public function testATopLevelCallAfterANestedFailureRetriesAgain(): void + { + $adapter = new DatabaseMemory(); + $this->capture(fn () => $adapter->withTransaction(fn () => $adapter->withTransaction(static function (): never { + throw new ContentionException('Lock not available'); + }))); + + [$thrown, $attempts] = $this->attempt($adapter->withTransaction(...), new ContentionException('Lock not available')); + + $this->assertInstanceOf(ContentionException::class, $thrown); + $this->assertSame(self::ATTEMPTS, $attempts); } /** @@ -97,6 +371,7 @@ public function testGenericFailureIsRetried(): void public function testNonRetriableActionAbortsWhenRollbackFails(): void { $adapter = new class () extends DatabaseMemory { + #[\Override] public function rollbackTransaction(): bool { throw new \RuntimeException('rollback failed'); @@ -119,22 +394,53 @@ public function rollbackTransaction(): bool $this->assertSame(1, $attempts); } - /** - * A failed rollback in the Redis adapter must reset the depth counter and - * the journal stack together. Resetting only the counter would strand the - * parent frames, breaking the count($journalStack) === inTransaction - * invariant and letting later transactions merge into a stale frame. - */ - public function testRedisRollbackFailureClearsJournalStack(): void + public function testTransientActionRunsAgainWhenRollbackFails(): void + { + $adapter = new class () extends DatabaseMemory { + #[\Override] + public function rollbackTransaction(): bool + { + parent::rollbackTransaction(); + + throw new DatabaseException('Failed to rollback transaction'); + } + }; + $attempts = 0; + $stored = \uniqid(); + + $result = $adapter->withTransaction(function () use (&$attempts, $stored): string { + $attempts++; + if ($attempts === 1) { + throw new ContentionException('Deadlock detected'); + } + + return $stored; + }); + + $this->assertSame($stored, $result); + $this->assertSame(2, $attempts); + $this->assertFalse($adapter->inTransaction()); + } + + public function testRedisRollbackFailureEndsTheTransaction(): void { if (!\extension_loaded('redis')) { $this->markTestSkipped('redis extension not loaded'); } $adapter = new class (new \Redis()) extends RedisAdapter { + public bool $failReplay = true; + + #[\Override] protected function rollbackJournal(): void { - throw new \RuntimeException('rollback replay failed'); + if ($this->failReplay) { + $this->failReplay = false; + + throw new \RuntimeException('rollback replay failed'); + } + + parent::rollbackJournal(); } }; @@ -149,13 +455,87 @@ protected function rollbackJournal(): void } $this->assertInstanceOf(\RuntimeException::class, $thrown); + $this->assertFalse($adapter->inTransaction()); + $this->assertFalse($adapter->commitTransaction(), 'No transaction is left open to commit'); + $this->assertFalse($adapter->rollbackTransaction(), 'No transaction is left open to roll back'); + + $this->assertTrue($adapter->startTransaction()); + $this->assertTrue($adapter->startTransaction()); + $this->assertTrue($adapter->commitTransaction()); + $this->assertTrue($adapter->rollbackTransaction()); + $this->assertFalse($adapter->inTransaction(), 'A later nested transaction must unwind to no transaction'); + } + + /** + * @return array{Closure(callable(): mixed): mixed, Adapter} The entry point's withTransaction() and the adapter + * that holds its transaction + */ + private function entry(string $entry): array + { + $adapter = $entry === self::SQLITE ? new SQLite(new PDO('sqlite::memory:')) : new DatabaseMemory(); + $adapter->setAuthorization(new Authorization()); + + $target = match ($entry) { + self::ADAPTER, self::SQLITE => $adapter, + self::DATABASE => new Database($adapter, new Cache(new NoCache())), + self::POOL => new Pool($this->connections($adapter)), + self::READ_WRITE_POOL => new ReadWritePool($this->connections($adapter), $this->connections(new DatabaseMemory())), + self::MIRROR => new Mirror(new Database($adapter, new Cache(new NoCache()))), + default => throw new LogicException("Unknown entry point {$entry}"), + }; + if ($target instanceof Pool) { + $target->setAuthorization(new Authorization()); + } + + return [$target->withTransaction(...), $adapter]; + } + + /** + * @return UtopiaPool&Stub + */ + private function connections(Adapter $adapter): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); - $inTransaction = new \ReflectionProperty(RedisAdapter::class, 'inTransaction'); - $inTransaction->setAccessible(true); - $this->assertSame(0, $inTransaction->getValue($adapter)); + return $connections; + } + + /** + * Run a callback that always fails through a withTransaction(). + * + * @param callable(callable(): never): mixed $transaction + * @return array{?Throwable, int} What the transaction threw and how many times it ran the callback + */ + private function attempt(callable $transaction, Throwable $failure): array + { + $attempts = 0; + + $thrown = $this->capture(function () use ($transaction, $failure, &$attempts): void { + $transaction(function () use ($failure, &$attempts): never { + $attempts++; + + throw $failure; + }); + }); + + return [$thrown, $attempts]; + } + + /** + * @param callable(): mixed $callback + */ + private function capture(callable $callback): ?Throwable + { + try { + $callback(); + } catch (Throwable $error) { + return $error; + } - $journalStack = new \ReflectionProperty(RedisAdapter::class, 'journalStack'); - $journalStack->setAccessible(true); - $this->assertSame([], $journalStack->getValue($adapter)); + return null; } } diff --git a/tests/unit/TransactionStateConnection.php b/tests/unit/TransactionStateConnection.php new file mode 100644 index 0000000000..6e72b2aff1 --- /dev/null +++ b/tests/unit/TransactionStateConnection.php @@ -0,0 +1,183 @@ + + */ + private array $savepoints = []; + + public function __construct(private readonly PDOStatement $statement) + { + } + + public function endSession(): void + { + $this->ended = true; + } + + public function reconnectSilently(): void + { + $this->discardTransaction(); + } + + /** + * Make the next statement lose a deadlock, as MariaDB and MySQL report it: the engine + * rolls the whole transaction back, savepoints included. + */ + public function deadlock(): void + { + $this->deadlocked = true; + } + + /** + * Make the next statement time out waiting for a lock, as MariaDB and MySQL report it by + * default: only the statement is rolled back, and the transaction and its savepoints hold. + */ + public function lockWaitTimeout(): void + { + $this->failure = EngineError::create('HY000', 1205, 'SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction'); + } + + /** + * Make the next statement find its lock taken, as PostgreSQL reports a NOWAIT lock or a + * lock timeout: rolling back to a savepoint recovers the transaction. + */ + public function lockNotAvailable(): void + { + $this->failure = EngineError::create('55P03', 7, 'SQLSTATE[55P03]: Lock not available: 7 ERROR: could not obtain lock on row in relation "aggregations"'); + } + + public function executeStatement(): bool + { + if ($this->failure !== null) { + $failure = $this->failure; + $this->failure = null; + + throw $failure; + } + + if (! $this->deadlocked) { + return true; + } + + $this->deadlocked = false; + $this->discardTransaction(); + + throw EngineError::create('40001', 1213, 'SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction'); + } + + #[\Override] + public function beginTransaction(): bool + { + $this->reconnectIfEnded(); + $this->begins++; + $this->transaction = true; + + return true; + } + + #[\Override] + public function commit(): bool + { + if (! $this->transaction) { + throw new PDOException('There is no active transaction'); + } + + $this->commits++; + $this->discardTransaction(); + + return true; + } + + #[\Override] + public function rollBack(): bool + { + if (! $this->transaction) { + throw new PDOException('There is no active transaction'); + } + + $this->discardTransaction(); + + return true; + } + + #[\Override] + public function inTransaction(): bool + { + return $this->transaction; + } + + #[\Override] + public function exec(string $statement): int + { + $this->reconnectIfEnded(); + + if (\str_starts_with($statement, 'SAVEPOINT ')) { + $this->savepoints[] = \substr($statement, \strlen('SAVEPOINT ')); + + return 0; + } + + if (\str_starts_with($statement, 'ROLLBACK TO ')) { + $savepoint = \substr($statement, \strlen('ROLLBACK TO ')); + if (! \in_array($savepoint, $this->savepoints, true)) { + throw new PDOException("SQLSTATE[42000]: Syntax error or access violation: 1305 SAVEPOINT {$savepoint} does not exist"); + } + } + + return 0; + } + + /** + * @param array $options + */ + #[\Override] + public function prepare(string $query, array $options = []): PDOStatement + { + return $this->statement; + } + + private function discardTransaction(): void + { + $this->transaction = false; + $this->savepoints = []; + } + + private function reconnectIfEnded(): void + { + if (! $this->ended) { + return; + } + + $this->ended = false; + $this->reconnectSilently(); + + throw new PDOException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'); + } +} diff --git a/tests/unit/TransactionStateTest.php b/tests/unit/TransactionStateTest.php new file mode 100644 index 0000000000..1395e8cb80 --- /dev/null +++ b/tests/unit/TransactionStateTest.php @@ -0,0 +1,561 @@ +createConnection(); + $adapter = new MariaDB($connection); + $writes = []; + $nested = null; + + $outer = $this->capture(function () use ($adapter, $connection, &$writes, &$nested): void { + $adapter->withTransaction(function () use ($adapter, $connection, &$writes, &$nested): void { + $writes[] = 'A'; + $connection->endSession(); + + $nested = $this->capture(function () use ($adapter, &$writes): void { + $adapter->withTransaction(function () use (&$writes): void { + $writes[] = 'B'; + }); + }); + + if ($nested !== null) { + throw $nested; + } + }); + }); + + $this->assertInstanceOf(TransactionException::class, $nested, 'The nested call must fail once the enclosing transaction is lost'); + $this->assertInstanceOf(TransactionException::class, $outer, 'The outer call must fail once its transaction is lost'); + $this->assertSame(['A'], $writes, 'The nested work must not run in a fresh transaction of its own'); + $this->assertSame(1, $connection->begins, 'Only the outer call may begin a transaction'); + $this->assertSame(0, $connection->commits, 'Nothing may be committed after the transaction was lost'); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A reconnect the callback did not surface leaves the driver without the transaction + * the adapter still counts. The nested commit cannot release a savepoint of a + * transaction the connection no longer holds. + */ + public function testNestedCommitAfterTheDriverLostTheTransactionFailsTheOuterCall(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $nested = null; + + $outer = $this->capture(function () use ($adapter, $connection, &$nested): void { + $adapter->withTransaction(function () use ($adapter, $connection, &$nested): void { + $nested = $this->capture(function () use ($adapter, $connection): void { + $adapter->withTransaction(function () use ($connection): void { + $connection->reconnectSilently(); + }); + }); + + if ($nested !== null) { + throw $nested; + } + }); + }); + + $this->assertInstanceOf(TransactionException::class, $nested, 'The nested commit must fail when the driver lost the transaction'); + $this->assertInstanceOf(TransactionException::class, $outer, 'The outer call must fail once its transaction is lost'); + $this->assertSame(1, $connection->begins, 'Only the outer call may begin a transaction'); + $this->assertSame(0, $connection->commits, 'Nothing may be committed after the transaction was lost'); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A caller that expects a duplicate from the nested call and carries on must not + * receive it when the savepoint rollback found the enclosing transaction gone. + */ + public function testNestedDuplicateAfterALostConnectionFailsTheOuterCall(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + + $outer = $this->capture(function () use ($adapter, $connection): void { + $adapter->withTransaction(function () use ($adapter, $connection): void { + try { + $adapter->withTransaction(function () use ($connection): void { + $connection->endSession(); + + throw new DuplicateException('Document already exists'); + }); + } catch (DuplicateException) { + } + }); + }); + + $this->assertInstanceOf(TransactionException::class, $outer, 'A lost transaction must not surface as the duplicate the caller expects'); + $this->assertSame(1, $connection->begins, 'Only the outer call may begin a transaction'); + $this->assertSame(0, $connection->commits, 'Nothing may be committed after the transaction was lost'); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A lock wait timeout rolls back only the statement, so the nested call's savepoint holds. + * The nested call rolls back to it and rethrows, and the outermost call, which owns the + * retries, runs the whole unit again: the nested call never runs twice in one attempt. + */ + public function testNestedLockWaitTimeoutRunsTheOutermostTransactionAgain(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $outer = 0; + /** @var list $nested */ + $nested = []; + /** @var list $failures */ + $failures = []; + $stored = \uniqid(); + + $result = $adapter->withTransaction(function () use ($adapter, $connection, &$outer, &$nested, &$failures, $stored): string { + $outer++; + $attempt = $outer; + + try { + return $adapter->withTransaction(function () use ($adapter, $connection, $attempt, &$nested, $stored): string { + $nested[] = $attempt; + if ($attempt === 1) { + $connection->lockWaitTimeout(); + } + $adapter->collectionExists('database', 'aggregations'); + + return $stored; + }); + } catch (Throwable $error) { + $failures[] = $error; + + throw $error; + } + }); + + $this->assertSame($stored, $result); + $this->assertSame(2, $outer); + $this->assertSame([1, 2], $nested, 'The nested call must not run again inside the same attempt'); + $this->assertCount(1, $failures); + $this->assertInstanceOf(ContentionException::class, $failures[0]); + $this->assertSame(2, $connection->begins); + $this->assertSame(1, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A lock that stays held fails every attempt: the nested call runs once per attempt of the + * outermost call, three times in all, not three times per attempt. + */ + public function testPersistentNestedLockWaitTimeoutRunsTheNestedCallOncePerAttempt(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + + [$error, $outer, $nested] = $this->lockedNestedCall($adapter, $connection->lockWaitTimeout(...)); + + $this->assertInstanceOf(ContentionException::class, $error); + $this->assertSame('Lock wait timeout exceeded', $error->getMessage()); + $this->assertSame(self::ATTEMPTS, $outer); + $this->assertSame(self::ATTEMPTS, $nested); + $this->assertSame(self::ATTEMPTS, $connection->begins); + $this->assertSame(0, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + public function testPersistentNestedLockNotAvailableRunsTheNestedCallOncePerAttempt(): void + { + $connection = $this->createConnection(); + $adapter = new Postgres($connection); + + [$error, $outer, $nested] = $this->lockedNestedCall($adapter, $connection->lockNotAvailable(...)); + + $this->assertInstanceOf(ContentionException::class, $error); + $this->assertSame('Lock not available', $error->getMessage()); + $this->assertSame(self::ATTEMPTS, $outer); + $this->assertSame(self::ATTEMPTS, $nested); + $this->assertSame(self::ATTEMPTS, $connection->begins); + $this->assertSame(0, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * No withTransaction() encloses a call nested in a transaction begun by hand, so the nested + * call owns its retries and runs again in its savepoint. + */ + public function testNestedCallInATransactionBegunByHandRetriesInItsSavepoint(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $nested = 0; + $stored = \uniqid(); + + $adapter->startTransaction(); + $result = $adapter->withTransaction(function () use ($adapter, $connection, &$nested, $stored): string { + $nested++; + if ($nested === 1) { + $connection->lockWaitTimeout(); + } + $adapter->collectionExists('database', 'aggregations'); + + return $stored; + }); + $adapter->commitTransaction(); + + $this->assertSame($stored, $result); + $this->assertSame(2, $nested); + $this->assertSame(1, $connection->begins); + $this->assertSame(1, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A top-level transaction that could not begin holds no work yet, so it begins again. + */ + public function testTopLevelTransactionRetriesAfterItFailedToBegin(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $connection->endSession(); + $stored = \uniqid(); + + $result = $adapter->withTransaction(fn (): string => $stored); + + $this->assertSame($stored, $result); + $this->assertSame(1, $connection->begins); + $this->assertSame(1, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A reconnect the callback did not surface leaves the driver without the top-level + * transaction the adapter still counts. Its commit has nothing to commit, so the call + * must fail instead of returning as if the work were stored, and must not run the + * callback again: statements after the reconnect may already have run on their own. + */ + public function testTopLevelCommitAfterTheDriverLostTheTransactionFails(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $attempts = 0; + + $error = $this->capture(function () use ($adapter, $connection, &$attempts): void { + $adapter->withTransaction(function () use ($connection, &$attempts): void { + $attempts++; + $connection->reconnectSilently(); + }); + }); + + $this->assertInstanceOf(TransactionException::class, $error, 'A commit of a transaction the driver no longer holds must fail'); + $this->assertSame(1, $attempts, 'The work of a lost transaction must not run again'); + $this->assertSame(1, $connection->begins); + $this->assertSame(0, $connection->commits, 'Nothing may be committed after the transaction was lost'); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * MariaDB and MySQL roll the whole transaction back when a statement loses a deadlock, + * and the nested call's savepoint goes with it. Nothing of the attempt is stored, so the + * nested call surfaces the deadlock without running again in a transaction that no + * longer exists, and the outermost call runs the whole unit again. + */ + public function testOutermostTransactionRetriesAfterADeadlockRolledBackANestedCall(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + /** @var int $attempts */ + $attempts = 0; + /** @var list $nested */ + $nested = []; + /** @var list $failures */ + $failures = []; + $stored = \uniqid(); + + $result = $adapter->withTransaction(function () use ($adapter, $connection, &$attempts, &$nested, &$failures, $stored): string { + $attempts++; + $attempt = $attempts; + + try { + return $adapter->withTransaction(function () use ($adapter, $connection, $attempt, &$nested, $stored): string { + $nested[] = $attempt; + if ($attempt === 1) { + $connection->deadlock(); + } + $adapter->collectionExists('database', 'aggregations'); + + return $stored; + }); + } catch (Throwable $error) { + $failures[] = $error; + + throw $error; + } + }); + + $this->assertSame($stored, $result); + $this->assertSame(2, $attempts, 'The outermost call must run again after the engine rolled its transaction back'); + $this->assertSame([1, 2], $nested, 'The nested call must not run again inside the rolled-back transaction'); + $this->assertCount(1, $failures); + $this->assertInstanceOf(ContentionException::class, $failures[0]); + $this->assertSame('Deadlock detected', $failures[0]->getMessage(), 'The nested call must surface the deadlock, not a lost transaction'); + $this->assertSame(2, $connection->begins); + $this->assertSame(1, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * An outermost call that keeps losing deadlocks gives up after as many attempts as a + * failed top-level call, and rethrows the deadlock. + */ + public function testOutermostTransactionRethrowsTheDeadlockAfterItsRetries(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $attempts = 0; + + $error = $this->capture(function () use ($adapter, $connection, &$attempts): void { + $adapter->withTransaction(function () use ($adapter, $connection, &$attempts): void { + $attempts++; + $adapter->withTransaction(function () use ($adapter, $connection): void { + $connection->deadlock(); + $adapter->collectionExists('database', 'aggregations'); + }); + }); + }); + + $this->assertInstanceOf(ContentionException::class, $error); + $this->assertSame('Deadlock detected', $error->getMessage()); + $this->assertSame(3, $attempts); + $this->assertSame(3, $connection->begins); + $this->assertSame(0, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A deadlock in the top-level callback itself leaves nothing to roll back either, and + * the call runs again, as it did in 7.x. + */ + public function testTopLevelTransactionRetriesAfterADeadlock(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $attempts = 0; + + $adapter->withTransaction(function () use ($adapter, $connection, &$attempts): void { + $attempts++; + if ($attempts === 1) { + $connection->deadlock(); + } + $adapter->collectionExists('database', 'aggregations'); + }); + + $this->assertSame(2, $attempts); + $this->assertSame(2, $connection->begins); + $this->assertSame(1, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * An invalid write fails the same way however often it runs: the call rethrows it at + * once without beginning another transaction. + */ + public function testDeterministicFailureIsNotRetried(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $failure = new StructureException('Invalid document structure'); + $attempts = 0; + + $error = $this->capture(function () use ($adapter, $failure, &$attempts): void { + $adapter->withTransaction(function () use ($failure, &$attempts): never { + $attempts++; + + throw $failure; + }); + }); + + $this->assertSame($failure, $error); + $this->assertSame(1, $attempts); + $this->assertSame(1, $connection->begins); + $this->assertSame(0, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A nested call rolls a deterministic failure back to its savepoint without running it + * again, and the outermost call does not run the whole unit again for it either. + */ + public function testDeterministicFailureInANestedCallIsNotRetried(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $failure = new NotFoundException('Collection not found'); + $outer = 0; + $nested = 0; + + $error = $this->capture(function () use ($adapter, $failure, &$outer, &$nested): void { + $adapter->withTransaction(function () use ($adapter, $failure, &$outer, &$nested): void { + $outer++; + $adapter->withTransaction(function () use ($failure, &$nested): never { + $nested++; + + throw $failure; + }); + }); + }); + + $this->assertSame($failure, $error); + $this->assertSame(1, $outer); + $this->assertSame(1, $nested); + $this->assertSame(1, $connection->begins); + $this->assertSame(0, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A deadlock the driver raised without the adapter mapping it is still a lock conflict, + * and the call runs again. + */ + public function testUnmappedDeadlockIsRetried(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $failure = EngineError::create('40001', 1213, 'SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock'); + $attempts = 0; + + $error = $this->capture(function () use ($adapter, $failure, &$attempts): void { + $adapter->withTransaction(function () use ($failure, &$attempts): never { + $attempts++; + + throw $failure; + }); + }); + + $this->assertSame($failure, $error); + $this->assertSame(3, $attempts); + $this->assertSame(3, $connection->begins); + $this->assertSame(0, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + public function testUnmappedDeterministicDriverErrorIsNotRetried(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $failure = EngineError::create('42000', 1064, 'SQLSTATE[42000]: Syntax error or access violation: 1064'); + $attempts = 0; + + $error = $this->capture(function () use ($adapter, $failure, &$attempts): void { + $adapter->withTransaction(function () use ($failure, &$attempts): never { + $attempts++; + + throw $failure; + }); + }); + + $this->assertSame($failure, $error); + $this->assertSame(1, $attempts); + $this->assertSame(1, $connection->begins); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * A top-level callback that lost its connection stored nothing, so it runs again on a + * fresh transaction, as it did in 7.x, even though rolling back the lost transaction + * fails. + */ + public function testTopLevelTransactionRetriesAfterTheCallbackLostTheConnection(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $attempts = 0; + $stored = \uniqid(); + + $result = $adapter->withTransaction(function () use ($connection, &$attempts, $stored): string { + $attempts++; + if ($attempts === 1) { + $connection->reconnectSilently(); + + throw new PDOException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'); + } + + return $stored; + }); + + $this->assertSame($stored, $result); + $this->assertSame(2, $attempts); + $this->assertSame(2, $connection->begins); + $this->assertSame(1, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * Run a nested call whose statement fails on the lock $lock arms, inside an outer call. + * + * @param callable(): void $lock + * @return array{?Throwable, int, int} What the outer call threw, and how many times the outer and the nested + * callbacks ran + */ + private function lockedNestedCall(MariaDB|Postgres $adapter, callable $lock): array + { + $outer = 0; + $nested = 0; + + $error = $this->capture(function () use ($adapter, $lock, &$outer, &$nested): void { + $adapter->withTransaction(function () use ($adapter, $lock, &$outer, &$nested): void { + $outer++; + $adapter->withTransaction(function () use ($adapter, $lock, &$nested): void { + $nested++; + $lock(); + $adapter->collectionExists('database', 'aggregations'); + }); + }); + }); + + return [$error, $outer, $nested]; + } + + /** + * @param callable(): mixed $callback + */ + private function capture(callable $callback): ?Throwable + { + try { + $callback(); + } catch (Throwable $error) { + return $error; + } + + return null; + } + + private function createConnection(): TransactionStateConnection + { + $statement = $this->createStub(PDOStatement::class); + $connection = new TransactionStateConnection($statement); + $statement->method('execute')->willReturnCallback($connection->executeStatement(...)); + + return $connection; + } +} diff --git a/tests/unit/Transactions/NoRetryTest.php b/tests/unit/Transactions/NoRetryTest.php new file mode 100644 index 0000000000..39d12947d8 --- /dev/null +++ b/tests/unit/Transactions/NoRetryTest.php @@ -0,0 +1,83 @@ + + */ + public static function failures(): array + { + $failures = [ + 'timeout' => new Timeout('Query timed out'), + 'unconfirmed' => new Unconfirmed('Commit could not be confirmed'), + ]; + + $cases = []; + foreach ([self::MEMORY, self::POOL] as $entry) { + foreach ($failures as $name => $failure) { + $cases["{$entry} {$name}"] = [$entry, $failure]; + } + } + + return $cases; + } + + #[DataProvider('failures')] + public function testTheCallbackRunsOnceAndTheFailurePropagates(string $entry, Throwable $failure): void + { + $adapter = $this->adapter($entry); + $runs = 0; + $thrown = null; + + try { + $adapter->withTransaction(function () use ($failure, &$runs): never { + $runs++; + + throw $failure; + }); + } catch (Throwable $caught) { + $thrown = $caught; + } + + $this->assertSame($failure, $thrown); + $this->assertSame(1, $runs); + $this->assertFalse($adapter->inTransaction()); + } + + private function adapter(string $entry): Adapter + { + $memory = new Memory(); + $memory->setAuthorization(new Authorization()); + + if ($entry === self::MEMORY) { + return $memory; + } + + $pool = new Pool(new UtopiaPool(new Stack(), self::MEMORY, 1, static fn (): Memory => $memory, timeout: 0.0)); + $pool->setAuthorization(new Authorization()); + + return $pool; + } +} diff --git a/tests/unit/UnconfirmedSchemaChangeTest.php b/tests/unit/UnconfirmedSchemaChangeTest.php new file mode 100644 index 0000000000..70998a33d4 --- /dev/null +++ b/tests/unit/UnconfirmedSchemaChangeTest.php @@ -0,0 +1,269 @@ +database($unconfirmed, collection: 'logs'); + + $thrown = $this->attempt(fn (): mixed => $database->createCollection(Collection::create(id: 'logs'))); + + $this->assertSame(1, $unconfirmed, 'An unconfirmed definition write must not run again'); + $this->assertTrue($database->collectionExists('logs'), 'The table of a definition that may be stored must be kept'); + $this->assertNotNull($database->findCollection('logs')); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + } + + public function testAnAttributeWhoseDefinitionIsUnconfirmedKeepsItsColumn(): void + { + $unconfirmed = 0; + $database = $this->database($unconfirmed, collection: 'logs', key: 'count'); + $database->createCollection(Collection::create(id: 'logs')); + + $thrown = $this->attempt(fn (): Attribute => $database->createAttribute('logs', Attribute::integer(key: 'count'))); + + $this->assertSame(1, $unconfirmed, 'An unconfirmed definition write must not run again'); + $this->assertTrue($this->hasSchemaAttribute($database, 'logs', 'count'), 'The column of a definition that may be stored must be kept'); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + } + + public function testAnIndexWhoseDefinitionIsUnconfirmedKeepsItsIndex(): void + { + $unconfirmed = 0; + $database = $this->database($unconfirmed, collection: 'logs', key: 'by_count'); + $database->createCollection(Collection::create(id: 'logs')); + $database->createAttribute('logs', Attribute::integer(key: 'count')); + + $thrown = $this->attempt(fn (): Index => $database->createIndex('logs', Index::key(key: 'by_count', attributes: ['count']))); + + $this->assertSame(1, $unconfirmed, 'An unconfirmed definition write must not run again'); + $this->assertTrue($this->hasSchemaIndex($database, 'logs', 'by_count'), 'The index of a definition that may be stored must be kept'); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + } + + public function testARelationshipWhoseDefinitionIsUnconfirmedKeepsItsColumnsAndCreatesItsIndexes(): void + { + $unconfirmed = 0; + $database = $this->database($unconfirmed, collection: 'profiles', key: 'account'); + $database->createCollection(Collection::create(id: 'profiles')); + $database->createCollection(Collection::create(id: 'accounts')); + + $thrown = $this->attempt(fn (): Relationship => $database->createRelationship('profiles', $this->profileAccount())); + + $this->assertSame(1, $unconfirmed, 'An unconfirmed definition write must not run again'); + $this->assertTrue($this->hasSchemaAttribute($database, 'profiles', 'account'), 'The columns of a relationship that may be stored must be kept'); + $this->assertTrue($this->hasSchemaAttribute($database, 'accounts', 'profile'), 'The columns of a relationship that may be stored must be kept'); + $this->assertTrue($this->hasSchemaIndex($database, 'profiles', '_index_account'), 'The indexes of a relationship that may be stored must still be created'); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + } + + public function testARelationshipWhoseIndexDefinitionIsUnconfirmedKeepsItsColumns(): void + { + $unconfirmed = 0; + $database = $this->database($unconfirmed, collection: 'profiles', key: '_index_account'); + $database->createCollection(Collection::create(id: 'profiles')); + $database->createCollection(Collection::create(id: 'accounts')); + + $thrown = $this->attempt(fn (): Relationship => $database->createRelationship('profiles', $this->profileAccount())); + + $this->assertSame(1, $unconfirmed, 'An unconfirmed definition write must not run again'); + $this->assertTrue($this->hasSchemaAttribute($database, 'profiles', 'account'), 'The columns of a relationship that may be stored must be kept'); + $this->assertTrue($this->hasSchemaAttribute($database, 'accounts', 'profile'), 'The columns of a relationship that may be stored must be kept'); + $this->assertTrue($this->hasSchemaIndex($database, 'profiles', '_index_account'), 'The index of a definition that may be stored must be kept'); + $this->assertSame(['account'], $this->attributeKeys($database, 'profiles')); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); + } + + /** + * A database over an adapter that throws Exception\Unconfirmed once an outermost transaction commits the target + * write: the first definition write of $collection that adds an attribute or index $key to it (or, without a key, + * that writes it at all), or the same definition written again. $unconfirmed counts those commits. + */ + private function database(int &$unconfirmed, string $collection, ?string $key = null): Database + { + $count = function () use (&$unconfirmed): void { + $unconfirmed++; + }; + + $adapter = new class (new PDO('sqlite::memory:'), $count, $collection, $key) extends SQLite { + private ?string $target = null; + + private bool $writesTarget = false; + + /** + * @param Closure(): void $count + */ + public function __construct( + PDO $pdo, + private readonly Closure $count, + private readonly string $collection, + private readonly ?string $key, + ) { + parent::__construct($pdo); + } + + #[\Override] + public function withTransaction(callable $callback): mixed + { + if ($this->inTransaction()) { + return parent::withTransaction($callback); + } + + try { + $result = parent::withTransaction($callback); + $writesTarget = $this->writesTarget; + } finally { + $this->writesTarget = false; + } + + if ($writesTarget) { + ($this->count)(); + + throw new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); + } + + return $result; + } + + #[\Override] + public function createDocument(Document $collection, Document $document): Document + { + $this->inspect($collection, $document); + + return parent::createDocument($collection, $document); + } + + #[\Override] + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + $this->inspect($collection, $document); + + return parent::updateDocument($collection, $id, $document, $skipPermissions); + } + + private function inspect(Document $collection, Document $definition): void + { + if ($collection->getId() !== Database::METADATA || $definition->getId() !== $this->collection) { + return; + } + + $content = \json_encode([$definition->getAttribute('attributes'), $definition->getAttribute('indexes')]) ?: ''; + + if ($this->target === null && ($this->key === null || $this->names($definition, $this->key))) { + $this->target = $content; + } + + if ($content === $this->target) { + $this->writesTarget = true; + } + } + + private function names(Document $definition, string $key): bool + { + foreach (['attributes', 'indexes'] as $field) { + $entries = $definition->getAttribute($field, []); + if (\is_string($entries)) { + $entries = \json_decode($entries, true); + } + + foreach (\is_array($entries) ? $entries : [] as $entry) { + if (\is_array($entry) && ($entry['$id'] ?? null) === $key) { + return true; + } + } + } + + return false; + } + }; + + $database = new Database($adapter, new Cache(new None())); + $database + ->setDatabase('unconfirmed_schema') + ->setNamespace('unconfirmed_schema_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + + return $database; + } + + private function profileAccount(): Relationship + { + return Relationship::oneToOne( + relatedCollection: 'accounts', + twoWay: true, + key: 'account', + twoWayKey: 'profile', + ); + } + + /** + * @return list + */ + private function attributeKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + $database->getCollection($collection)->attributes(), + ); + } + + private function hasSchemaAttribute(Database $database, string $collection, string $key): bool + { + foreach ($database->getSchemaAttributes($collection) as $column) { + if ($column->name === $key) { + return true; + } + } + + return false; + } + + private function hasSchemaIndex(Database $database, string $collection, string $key): bool + { + foreach ($database->getSchemaIndexes($collection) as $index) { + if (\str_contains($index->name, $key)) { + return true; + } + } + + return false; + } + + /** + * @param callable(): mixed $operation + */ + private function attempt(callable $operation): ?Throwable + { + try { + $operation(); + } catch (Throwable $error) { + return $error; + } + + return null; + } +} diff --git a/tests/unit/UnindexedJoinTenantTest.php b/tests/unit/UnindexedJoinTenantTest.php new file mode 100644 index 0000000000..018de02c71 --- /dev/null +++ b/tests/unit/UnindexedJoinTenantTest.php @@ -0,0 +1,250 @@ + + */ + private array $bindings = []; + + /** + * @return iterable + */ + public static function unindexed(): iterable + { + yield 'an inner join on an unindexed attribute' => [Query::join(self::LABELS, 'named', [Query::on('name', 'name')]), 'named']; + yield 'a left join on an unindexed attribute' => [Query::leftJoin(self::LABELS, 'named', [Query::on('name', 'name')]), 'named']; + yield 'an inner join compared other than for equality' => [Query::join(self::LABELS, 'named', [Query::on('name', 'code', '>')]), 'named']; + } + + #[DataProvider('unindexed')] + public function testJoinNoIndexServesMatchesItsTenantAsARange(Query $join, string $alias): void + { + $this->find(new MySQL($this->pdo()), [$join]); + + $this->assertSame(1, \substr_count($this->sql, $this->range($alias)), 'The tenant of a join no index serves is a range: '.$this->sql); + $this->assertStringNotContainsString($this->equality($alias), $this->sql); + $this->assertSame(2, \count(\array_keys($this->bindings, self::TENANT, true)) - $this->equalities(), 'The range binds the tenant at both ends: '.$this->sql); + } + + /** + * @return iterable + */ + public static function indexed(): iterable + { + yield 'a join on an indexed attribute' => [Query::join(self::LABELS, 'coded', [Query::on('name', 'code')]), 'coded']; + yield 'a join on the joined $id' => [Query::join(self::LABELS, 'byId', [Query::on('name', '$id')]), 'byId']; + yield 'a join on the joined $sequence' => [Query::join(self::LABELS, 'bySequence', [Query::on('$sequence', '$sequence')]), 'bySequence']; + yield 'a join on the joined $createdAt' => [Query::join(self::LABELS, 'byCreation', [Query::on('$createdAt', '$createdAt')]), 'byCreation']; + yield 'a right join on an unindexed attribute' => [Query::rightJoin(self::LABELS, 'named', [Query::on('name', 'name')]), 'named']; + } + + #[DataProvider('indexed')] + public function testJoinAnIndexServesKeepsItsTenantEquality(Query $join, string $alias): void + { + $this->find(new MySQL($this->pdo()), [$join]); + + $this->assertStringContainsString($this->equality($alias), $this->sql); + $this->assertStringNotContainsString($this->range($alias), $this->sql); + } + + /** + * @return iterable + */ + public static function chained(): iterable + { + $named = [Query::on('name', 'name')]; + $coded = [Query::on('named.code', 'code')]; + + yield 'a left join then a left join' => [Query::leftJoin(self::LABELS, 'named', $named), Query::leftJoin(self::LABELS, 'coded', $coded)]; + yield 'an inner join then a left join' => [Query::join(self::LABELS, 'named', $named), Query::leftJoin(self::LABELS, 'coded', $coded)]; + yield 'a left join then an inner join' => [Query::leftJoin(self::LABELS, 'named', $named), Query::join(self::LABELS, 'coded', $coded)]; + yield 'an inner join then an inner join' => [Query::join(self::LABELS, 'named', $named), Query::join(self::LABELS, 'coded', $coded)]; + } + + #[DataProvider('chained')] + public function testLaterJoinComparingAnIndexedColumnLeavesTheEarlierJoinUnserved(Query $named, Query $coded): void + { + $this->find(new MySQL($this->pdo()), [$named, $coded]); + + $this->assertStringContainsString($this->range('named'), $this->sql, 'Only the ON of named reaches named, and it compares no indexed column: '.$this->sql); + $this->assertStringNotContainsString($this->equality('named'), $this->sql); + $this->assertStringContainsString($this->equality('coded'), $this->sql); + } + + public function testEveryJoinedTableOfAMixedReadIsKeptToTheTenantAndChecked(): void + { + $this->find(new MySQL($this->pdo()), [ + Query::join(self::CUSTOMERS, 'peer', [Query::on('$id', '$id')]), + Query::join(self::LABELS, 'named', [Query::on('name', 'name')]), + Query::leftJoin(self::LABELS, 'coded', [Query::on('name', 'code')]), + ]); + + $this->assertStringContainsString($this->equality('peer'), $this->sql); + $this->assertStringContainsString($this->range('named'), $this->sql); + $this->assertStringContainsString($this->equality('coded'), $this->sql); + $this->assertStringContainsString('`table_main`._tenant IN (?)', $this->sql, 'The main table keeps its equality'); + + foreach (['table_main', 'peer', 'named', 'coded'] as $alias) { + $this->assertMatchesRegularExpression( + '/`'.$alias.'`\.`_uid` IN \(SELECT (\/\*\+ NO_SEMIJOIN\(\) \*\/ )?_document FROM `[^`]+`\.`[^`]+_perms` WHERE _permission IN \([?, ]+\) AND _type = \? AND _tenant IN \(\?\)\)/', + $this->sql, + 'The permission check of '.$alias.' compares its own `_uid` within the tenant: '.$this->sql, + ); + } + } + + public function testCountOfAJoinNoIndexServesMatchesItsTenantAsARange(): void + { + $adapter = new MySQL($this->pdo()); + $this->configure($adapter, true); + + $adapter->count($this->collection(), [Query::join(self::LABELS, 'named', [Query::on('name', 'name')])]); + + $this->assertStringContainsString($this->range('named'), $this->sql); + } + + public function testJoinOfACollectionWithoutDescribedIndexesKeepsItsTenantEquality(): void + { + $adapter = new MySQL($this->pdo()); + $this->configure($adapter, true); + + $adapter->find( + new Document(['$id' => self::CUSTOMERS, 'documentSecurity' => true]), + [Query::join(self::LABELS, 'named', [Query::on('name', 'name')])], + limit: 25, + ); + + $this->assertStringContainsString($this->equality('named'), $this->sql); + } + + public function testMariaDBKeepsTheTenantEquality(): void + { + $this->find(new MariaDB($this->pdo()), [Query::join(self::LABELS, 'named', [Query::on('name', 'name')])]); + + $this->assertStringContainsString($this->equality('named'), $this->sql); + $this->assertStringNotContainsString($this->range('named'), $this->sql); + } + + public function testPlainTablesHaveNoTenantCondition(): void + { + $adapter = new MySQL($this->pdo()); + $this->configure($adapter, false); + + $adapter->find($this->collection(), [Query::join(self::LABELS, 'named', [Query::on('name', 'name')])], limit: 25); + + $this->assertStringNotContainsString('_tenant', $this->sql); + } + + /** + * @param list $queries + */ + private function find(MariaDB $adapter, array $queries): void + { + $this->configure($adapter, true); + + $adapter->find($this->collection(), $queries, limit: 25); + + $this->assertNotSame('', $this->sql); + } + + private function collection(): Document + { + return new Document([ + '$id' => self::CUSTOMERS, + 'documentSecurity' => true, + Database::JOIN_DOCUMENT_SECURITY => [self::CUSTOMERS => true, self::LABELS => true], + Database::JOIN_INDEXED => [self::CUSTOMERS => [], self::LABELS => self::LABEL_INDEXED], + ]); + } + + private function configure(MariaDB $adapter, bool $shared): void + { + $adapter->setDatabase(self::NAMESPACE); + $adapter->setNamespace(self::NAMESPACE); + $adapter->setSharedTables($shared); + $adapter->setTenant($shared ? self::TENANT : null); + $authorization = new Authorization(); + $authorization->addRole(Role::user(self::READER)->toString()); + $adapter->setAuthorization($authorization); + } + + private function pdo(): PDO + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('bindValue')->willReturnCallback(function (int|string $position, mixed $value): bool { + $this->bindings[] = $value; + + return true; + }); + $statement->method('execute')->willReturn(true); + $statement->method('fetchAll')->willReturn([]); + $statement->method('fetch')->willReturn(false); + $statement->method('closeCursor')->willReturn(true); + + $pdo = $this->createStub(PDO::class); + $pdo->method('prepare')->willReturnCallback(function (string $query) use ($statement): PDOStatement { + $this->sql = $query; + $this->bindings = []; + + return $statement; + }); + + return $pdo; + } + + private function range(string $alias): string + { + return '(`'.$alias.'`._tenant >= ? AND `'.$alias.'`._tenant <= ?)'; + } + + private function equality(string $alias): string + { + return '`'.$alias.'`._tenant IN (?)'; + } + + /** + * How many tenant equalities the statement binds. + */ + private function equalities(): int + { + return \substr_count($this->sql, '_tenant IN (?)'); + } +} diff --git a/tests/unit/UniqueViolationTest.php b/tests/unit/UniqueViolationTest.php index 8d7c8950c1..6cb67516c3 100644 --- a/tests/unit/UniqueViolationTest.php +++ b/tests/unit/UniqueViolationTest.php @@ -2,133 +2,256 @@ namespace Tests\Unit; -use Exception; +use PDO; use PDOException; +use PDOStatement; use PHPUnit\Framework\TestCase; -use ReflectionClass; -use ReflectionProperty; +use Redis; use Throwable; +use Utopia\Cache\Adapter\None as NoCache; +use Utopia\Cache\Cache; +use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\Mongo; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Adapter\Redis as RedisAdapter; +use Utopia\Database\Adapter\SQL; use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; +use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Unique as UniqueException; +use Utopia\Database\Index; +use Utopia\Database\Permission; +use Utopia\Database\Query; +use Utopia\Database\Role; +use Utopia\Database\Validator\Authorization; +use Utopia\Mongo\Client; +use Utopia\Mongo\Exception as MongoException; +use Utopia\Query\Schema\IndexType; final class UniqueViolationTest extends TestCase { + private const string COLLECTION = 'movies'; + + private const string INDEX = 'slug_unique'; + + private const string TAKEN_SLUG = 'prefix_uid_suffix'; + public function testMySQLDocumentIdConflictIsDuplicate(): void { - $this->assertDuplicate(MySQL::class, $this->mysqlException( + $this->assertDuplicate($this->createFailure(MySQL::class, $this->mysqlException( "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'movie-1' for key 'movies._uid'" - )); + ))); } public function testMySQLPrimaryKeyConflictIsDuplicate(): void { - $this->assertDuplicate(MySQL::class, $this->mysqlException( + $this->assertDuplicate($this->createFailure(MySQL::class, $this->mysqlException( "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '5' for key 'PRIMARY'" - )); + ))); } public function testMySQLUniqueIndexConflictWithUidInValueIsUnique(): void { - $this->assertUnique(MySQL::class, $this->mysqlException( + $this->assertUnique($this->createFailure(MySQL::class, $this->mysqlException( "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'prefix_uid_suffix' for key 'slug'" - )); + ))); } public function testMySQLUniqueIndexConflictWithUidInIndexNameIsUnique(): void { - $this->assertUnique(MySQL::class, $this->mysqlException( + $this->assertUnique($this->createFailure(MySQL::class, $this->mysqlException( "SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'a' for key 'movies.slug_uid_index'" - )); + ))); } public function testMySQLUnparsableMessageIsDuplicate(): void { - $this->assertDuplicate(MySQL::class, $this->mysqlException( + $this->assertDuplicate($this->createFailure(MySQL::class, $this->mysqlException( 'SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry' - )); + ))); } public function testPostgresDocumentIdConflictIsDuplicate(): void { - $this->assertDuplicate(Postgres::class, $this->postgresException( + $this->assertDuplicate($this->createFailure(Postgres::class, $this->postgresException( 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_uid"' . "\nDETAIL: Key (_uid, _tenant)=(movie-1, 1) already exists." - )); + ))); } public function testPostgresUniqueIndexConflictWithUidInValueIsUnique(): void { - $this->assertUnique(Postgres::class, $this->postgresException( + $this->assertUnique($this->createFailure(Postgres::class, $this->postgresException( 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_slug"' . "\nDETAIL: Key (slug)=(prefix_uid_suffix) already exists." - )); + ))); } public function testPostgresCompositeIndexOnDocumentIdIsUnique(): void { - $this->assertUnique(Postgres::class, $this->postgresException( + $this->assertUnique($this->createFailure(Postgres::class, $this->postgresException( 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_pair"' . "\nDETAIL: Key (_uid, email)=(movie-1, a@b.co) already exists." - )); + ))); } public function testPostgresMissingDetailIsDuplicate(): void { - $this->assertDuplicate(Postgres::class, $this->postgresException( + $this->assertDuplicate($this->createFailure(Postgres::class, $this->postgresException( 'SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "ns_1_movies_uid"' - )); + ))); } public function testSQLiteDocumentIdConflictIsDuplicate(): void { - $this->assertDuplicate(SQLite::class, $this->sqliteException( + $this->assertDuplicate($this->createFailure(SQLite::class, $this->sqliteException( 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: ns_movies._tenant, ns_movies._uid' - )); + ))); } public function testSQLiteCompositeIndexOnDocumentIdIsUnique(): void { - $this->assertUnique(SQLite::class, $this->sqliteException( + $this->assertUnique($this->createFailure(SQLite::class, $this->sqliteException( 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: ns_movies._uid, ns_movies.email' - )); + ))); } public function testSQLiteUniqueIndexConflictIsUnique(): void { - $this->assertUnique(SQLite::class, $this->sqliteException( + $this->assertUnique($this->createFailure(SQLite::class, $this->sqliteException( 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: ns_movies.slug' - )); + ))); } public function testMongoDocumentIdConflictIsDuplicate(): void { - $this->assertDuplicate(Mongo::class, new Exception( + $this->assertDuplicate($this->mongoCreateFailure(new MongoException( 'E11000 duplicate key error collection: db.ns_movies index: _uid dup key: { _uid: "movie-1" }', 11000 - )); + ))); } public function testMongoUniqueIndexConflictWithUidInValueIsUnique(): void { - $this->assertUnique(Mongo::class, new Exception( + $this->assertUnique($this->mongoCreateFailure(new MongoException( 'E11000 duplicate key error collection: db.ns_movies index: slug dup key: { slug: "prefix_uid_suffix" }', 11000 - )); + ))); } public function testMongoUnparsableMessageIsDuplicate(): void { - $this->assertDuplicate(Mongo::class, new Exception('E11000 duplicate key error', 11000)); + $this->assertDuplicate($this->mongoCreateFailure(new MongoException('E11000 duplicate key error', 11000))); + } + + public function testMemoryDocumentIdConflictIsDuplicate(): void + { + $database = $this->memory(); + + $this->assertDuplicate($this->thrown( + fn () => $database->createDocument(self::COLLECTION, $this->movie('movie-1', 'sequel')) + )); + } + + public function testMemoryUniqueIndexConflictWithUidInValueIsUnique(): void + { + $database = $this->memory(); + + $this->assertUnique($this->thrown( + fn () => $database->createDocument(self::COLLECTION, $this->movie('movie-3', self::TAKEN_SLUG)) + )); + } + + public function testMemoryUniqueIndexConflictOnUpdateIsUnique(): void + { + $database = $this->memory(); + + $this->assertUnique($this->thrown( + fn () => $database->updateDocument(self::COLLECTION, 'movie-2', new Document(['slug' => self::TAKEN_SLUG])) + )); + } + + public function testMemoryBatchUpdateConflictWithStoredRowIsUnique(): void + { + $database = $this->memory(); + + $this->assertUnique($this->thrown(fn () => $database->updateDocuments( + self::COLLECTION, + new Document(['slug' => self::TAKEN_SLUG]), + [Query::equal('$id', ['movie-2'])], + ))); + } + + public function testMemoryBatchUpdateConflictBetweenUpdatedRowsIsUnique(): void + { + $database = $this->memory(); + + $this->assertUnique($this->thrown( + fn () => $database->updateDocuments(self::COLLECTION, new Document(['slug' => 'sequel'])) + )); + } + + public function testMemoryUniqueHashCollisionIsUnique(): void + { + $memory = new class () extends Memory { + public function collide(string $collection, string $index): void + { + $this->uniqueIndexHashes[$collection][$index]['signature'] = 'movie-1'; + $this->probeUniqueHash($collection, $index, 'signature', null, 'movie-2'); + } + }; + + $this->assertUnique($this->thrown(fn () => $memory->collide(self::COLLECTION, self::INDEX))); + } + + public function testRedisDocumentIdConflictIsDuplicate(): void + { + $redis = $this->redis(documentExists: true); + + $this->assertDuplicate($this->thrown( + fn () => $redis->createDocument($this->collection(), $this->movie('movie-1', 'sequel')) + )); + } + + public function testRedisUniqueIndexConflictWithUidInValueIsUnique(): void + { + $redis = $this->redis(); + + $this->assertUnique($this->thrown( + fn () => $redis->createDocument($this->collection(), $this->movie('movie-3', self::TAKEN_SLUG)) + )); + } + + public function testRedisUniqueIndexConflictOnUpdateIsUnique(): void + { + $redis = $this->redis(); + + $this->assertUnique($this->thrown( + fn () => $redis->updateDocument($this->collection(), 'movie-2', $this->movie('movie-2', self::TAKEN_SLUG), false) + )); + } + + public function testRedisUniqueIndexConflictIsSkippedWhenSkippingDuplicates(): void + { + $redis = $this->redis(); + $movie = $this->movie('movie-3', self::TAKEN_SLUG); + + $this->assertSame($movie, $redis->ignoreDuplicates(fn () => $redis->createDocument($this->collection(), $movie))); } private function mysqlException(string $message): PDOException { - $exception = new PDOException($message); - (new ReflectionProperty(Exception::class, 'code'))->setValue($exception, '23000'); + $exception = new class ($message, '23000') extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; $exception->errorInfo = ['23000', 1062, $message]; return $exception; @@ -136,8 +259,13 @@ private function mysqlException(string $message): PDOException private function postgresException(string $message): PDOException { - $exception = new PDOException($message); - (new ReflectionProperty(Exception::class, 'code'))->setValue($exception, '23505'); + $exception = new class ($message, '23505') extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; $exception->errorInfo = ['23505', 7, $message]; return $exception; @@ -145,40 +273,168 @@ private function postgresException(string $message): PDOException private function sqliteException(string $message): PDOException { - $exception = new PDOException($message); - (new ReflectionProperty(Exception::class, 'code'))->setValue($exception, 'HY000'); + $exception = new class ($message, 'HY000') extends PDOException { + public function __construct(string $message, string $state) + { + parent::__construct($message); + $this->code = $state; + } + }; $exception->errorInfo = ['HY000', 19, $message]; return $exception; } - /** - * @param class-string $adapter - */ - private function assertDuplicate(string $adapter, Throwable $exception): void + private function memory(): Database { - $processed = $this->process($adapter, $exception); + $database = new Database(new Memory(), new Cache(new NoCache())); + $database + ->setDatabase('unique_violation') + ->setNamespace('unique_violation') + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'slug', size: 128)], + indexes: [Index::unique(key: self::INDEX, attributes: ['slug'], lengths: [128])], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + + $database->createDocuments(self::COLLECTION, [ + $this->movie('movie-1', self::TAKEN_SLUG), + $this->movie('movie-2', 'frozen'), + ]); - $this->assertInstanceOf(DuplicateException::class, $processed); - $this->assertNotInstanceOf(UniqueException::class, $processed); + return $database; } - /** - * @param class-string $adapter - */ - private function assertUnique(string $adapter, Throwable $exception): void + private function redis(bool $documentExists = false): RedisAdapter { - $this->assertInstanceOf(UniqueException::class, $this->process($adapter, $exception)); + $payloads = [ + 'movie-1' => \json_encode($this->movie('movie-1', self::TAKEN_SLUG)->getArrayCopy(), JSON_THROW_ON_ERROR), + 'movie-2' => \json_encode($this->movie('movie-2', 'frozen')->getArrayCopy(), JSON_THROW_ON_ERROR), + ]; + $indexes = \json_encode([ + ['$id' => self::INDEX, 'type' => IndexType::Unique->value, 'attributes' => ['slug']], + ], JSON_THROW_ON_ERROR); + $read = fn (mixed $key): string|false => \is_string($key) ? ($payloads[self::documentId($key)] ?? false) : false; + + $client = self::createStub(Redis::class); + $client->method('exists')->willReturn($documentExists ? 1 : 0); + $client->method('sMembers')->willReturn(\array_keys($payloads)); + $client->method('get')->willReturnCallback($read); + $client->method('mGet')->willReturnCallback(fn (array $keys): array => \array_map($read, $keys)); + $client->method('hGet')->willReturnCallback( + fn (string $key, string $field): string|false => $field === 'indexes' ? $indexes : false + ); + + $adapter = new RedisAdapter($client); + $adapter->setNamespace('unique_violation'); + $adapter->setDatabase('unique_violation'); + + return $adapter; + } + + private static function documentId(string $key): string + { + $separator = \strrpos($key, RedisAdapter::SEP); + + return $separator === false ? $key : \substr($key, $separator + 1); + } + + private function collection(): Document + { + return new Document(['$id' => self::COLLECTION]); + } + + private function movie(string $id, string $slug): Document + { + return new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'slug' => $slug, + ]); } /** - * @param class-string $adapter + * @param class-string $adapter */ - private function process(string $adapter, Throwable $exception): Throwable + private function createFailure(string $adapter, PDOException $exception): Throwable { - $class = new ReflectionClass($adapter); - $method = $class->getMethod('processException'); + $statement = self::createStub(PDOStatement::class); + $statement->method('execute')->willThrowException($exception); + $pdo = self::createStub(PDO::class); + $pdo->method('prepare')->willReturn($statement); + + $sql = new $adapter($pdo); + $sql->setDatabase('unique_violation'); + $sql->setNamespace('unique_violation'); + + return $this->thrown(fn () => $sql->createDocument($this->collection(), $this->movie('movie-1', self::TAKEN_SLUG))); + } + + private function mongoCreateFailure(MongoException $exception): Throwable + { + $client = new class ($exception) extends Client { + public function __construct(private readonly MongoException $failure) + { + } + + #[\Override] + public function connect(): self + { + return $this; + } + + #[\Override] + public function close(): void + { + } - return $method->invoke($class->newInstanceWithoutConstructor(), $exception); + /** + * @param array $document + * @param array $options + * @return array + */ + #[\Override] + public function insert(string $collection, array $document, array $options = []): array + { + throw $this->failure; + } + }; + + $mongo = new Mongo($client); + $mongo->setNamespace('unique_violation'); + + return $this->thrown(fn () => $mongo->createDocument($this->collection(), $this->movie('movie-1', self::TAKEN_SLUG))); + } + + private function thrown(callable $action): Throwable + { + try { + $action(); + } catch (Throwable $exception) { + return $exception; + } + + $this->fail('Expected a duplicate or unique violation'); + } + + private function assertDuplicate(Throwable $exception): void + { + $this->assertInstanceOf(DuplicateException::class, $exception, $exception->getMessage()); + $this->assertNotInstanceOf(UniqueException::class, $exception); + $this->assertSame('Document already exists', $exception->getMessage()); + } + + private function assertUnique(Throwable $exception): void + { + $this->assertInstanceOf(UniqueException::class, $exception, $exception->getMessage()); + $this->assertSame('Document with the requested unique attributes already exists', $exception->getMessage()); } } diff --git a/tests/unit/UpsertTenancyTest.php b/tests/unit/UpsertTenancyTest.php new file mode 100644 index 0000000000..aed1b6dcb5 --- /dev/null +++ b/tests/unit/UpsertTenancyTest.php @@ -0,0 +1,225 @@ +pdo = new PDO('sqlite::memory:'); + $this->authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + $this->namespace = 'upsert_tenancy_'.\uniqid(); + + $database = $this->database($this->adapter()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'title', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + } + + public function testAFirstWriteUpsertStoresItsPermissionRowsUnderTheTenant(): void + { + $this->database($this->adapter())->upsertDocuments(self::COLLECTION, [$this->note('upserted')]); + + $this->assertSame( + [ + ['upserted', PermissionType::Read->value, self::TENANT], + ['upserted', PermissionType::Update->value, self::TENANT], + ], + $this->permissionRows(), + 'An adapter whose first write is an upsert must store the permission rows under the tenant, like every other write path', + ); + } + + public function testAFirstWriteUpsertIsVisibleToAPermissionFilteredRead(): void + { + $database = $this->database($this->adapter()); + $database->upsertDocuments(self::COLLECTION, [$this->note('upserted')]); + + $this->readAs(self::READER); + + $this->assertSame(['upserted'], $this->ids($database->find(self::COLLECTION))); + $this->assertSame(1, $database->count(self::COLLECTION)); + } + + public function testEveryUpsertThroughAPoolIsVisibleToAPermissionFilteredRead(): void + { + $database = $this->database($this->pool($this->adapter())); + $database->createDocument(self::COLLECTION, $this->note('created')); + $database->upsertDocuments(self::COLLECTION, [$this->note('upserted')]); + + $this->readAs(self::READER); + + $this->assertSame( + ['created', 'upserted'], + $this->ids($database->find(self::COLLECTION)), + 'The pool hands every call its own write hooks, so an upsert cannot rely on an earlier write having registered the tenant', + ); + } + + public function testAnAdapterUpsertStoresADocumentWithoutATenantUnderTheSelectedTenant(): void + { + $adapter = $this->adapter(); + $database = $this->database($adapter); + + $adapter->upsertDocuments($database->getCollection(self::COLLECTION), [ + new Change(new Document(), $this->note('upserted')), + ]); + + $this->assertSame( + [self::TENANT], + $this->tenantsOf(self::COLLECTION), + 'A document without a tenant is stored under the selected tenant, as createDocuments() stores it', + ); + $this->assertSame( + [ + ['upserted', PermissionType::Read->value, self::TENANT], + ['upserted', PermissionType::Update->value, self::TENANT], + ], + $this->permissionRows(), + ); + $this->assertSame( + 'upserted', + $this->authorization->skip(fn (): Document => $database->getDocument(self::COLLECTION, 'upserted'))->getId(), + ); + } + + private function adapter(): SQLite + { + return new SQLite($this->pdo); + } + + private function database(Adapter $adapter): Database + { + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($this->authorization) + ->setDatabase('upsert_tenancy') + ->setNamespace($this->namespace) + ->setSharedTables(true) + ->setTenant(self::TENANT) + ->addHook(new Permissions()); + + return $database; + } + + private function pool(Adapter $adapter): Pool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback( + static fn (callable $callback): mixed => $callback($adapter), + ); + + return new Pool($connections); + } + + private function note(string $id): Document + { + return new Document([ + '$id' => $id, + 'title' => $id, + '$permissions' => [ + Permission::read(Role::user(self::READER)), + Permission::update(Role::user(self::READER)), + ], + ]); + } + + private function readAs(string $user): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::user($user)->toString()); + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + return \array_map(static fn (Document $document): string => $document->getId(), \array_values($documents)); + } + + /** + * @return list + */ + private function permissionRows(): array + { + $table = $this->table(Storage::permissionsTable(self::COLLECTION)); + $statement = $this->pdo->query( + 'SELECT '.Storage::PERMISSIONS_DOCUMENT.', '.Storage::PERMISSIONS_TYPE.', '.Storage::TENANT + ." FROM {$table} ORDER BY ".Storage::PERMISSIONS_DOCUMENT.', '.Storage::PERMISSIONS_TYPE, + ); + $this->assertNotFalse($statement); + + /** @var list $rows */ + $rows = $statement->fetchAll(PDO::FETCH_NUM); + + return $rows; + } + + /** + * @return list + */ + private function tenantsOf(string $collection): array + { + $statement = $this->pdo->query('SELECT '.Storage::TENANT." FROM {$this->table($collection)}"); + $this->assertNotFalse($statement); + + /** @var list $tenants */ + $tenants = $statement->fetchAll(PDO::FETCH_COLUMN); + + return $tenants; + } + + private function table(string $name): string + { + return "`{$this->namespace}_{$name}`"; + } +} diff --git a/tests/unit/Validator/AttributeDefaultTypeTest.php b/tests/unit/Validator/AttributeDefaultTypeTest.php new file mode 100644 index 0000000000..825243d9b9 --- /dev/null +++ b/tests/unit/Validator/AttributeDefaultTypeTest.php @@ -0,0 +1,100 @@ + + */ + public static function scalarDefaultsOfTypesWithoutADefaultRule(): array + { + return [ + 'object with a string' => [ColumnType::Object, 'x'], + 'point with a string' => [ColumnType::Point, 'POINT(1 2)'], + 'linestring with an integer' => [ColumnType::Linestring, 5], + 'polygon with a boolean' => [ColumnType::Polygon, true], + 'id with a string' => [ColumnType::Id, 'x'], + 'relationship with a string' => [ColumnType::Relationship, 'x'], + ]; + } + + #[DataProvider('scalarDefaultsOfTypesWithoutADefaultRule')] + public function testAScalarDefaultOfATypeWithoutADefaultRuleIsAnUnknownType(ColumnType $type, mixed $default): void + { + $validator = $this->validator(vectors: false, spatial: false); + + $message = $this->refusal($validator, Attribute::fromArray([ + 'key' => 'value', + 'type' => $type, + 'default' => $default, + 'options' => ['relatedCollection' => 'others', 'relationType' => RelationshipType::OneToOne->value, 'side' => RelationshipSide::Parent->value], + ])); + + $this->assertStringStartsWith("Unknown attribute type: {$type->value}. Must be one of ", $message); + $this->assertStringContainsString(ColumnType::String->value, $message); + $this->assertStringContainsString(ColumnType::Relationship->value, $message); + $this->assertStringNotContainsString(ColumnType::Vector->value, $message); + $this->assertStringNotContainsString(ColumnType::Point->value.',', $message); + } + + public function testTheListedTypesFollowTheVectorAndSpatialSupport(): void + { + $message = $this->refusal( + $this->validator(vectors: true, spatial: true), + Attribute::fromArray(['key' => 'value', 'type' => ColumnType::Object, 'default' => 'x']), + ); + + $this->assertStringContainsString(ColumnType::Vector->value, $message); + foreach ([ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon] as $spatial) { + $this->assertStringContainsString($spatial->value, $message); + } + + $vectorsOnly = $this->refusal( + $this->validator(vectors: true, spatial: false), + Attribute::id(key: 'value', default: 'x'), + ); + $this->assertStringContainsString(ColumnType::Vector->value, $vectorsOnly); + $this->assertStringNotContainsString(ColumnType::Polygon->value, $vectorsOnly); + } + + public function testAnArrayDefaultOfASpatialOrObjectTypeIsNotCheckedItemByItem(): void + { + $validator = $this->validator(vectors: true, spatial: true); + + $this->assertTrue($validator->checkDefaultValue(Attribute::object(key: 'value', default: ['nested' => 'x']))); + } + + private function refusal(AttributeDefinition $validator, Attribute $attribute): string + { + try { + $validator->checkDefaultValue($attribute); + } catch (DatabaseException $error) { + $this->assertSame($error->getMessage(), $validator->getDescription()); + + return $error->getMessage(); + } + + $this->fail("A scalar default on {$attribute->type->value} must be refused"); + } + + private function validator(bool $vectors, bool $spatial): AttributeDefinition + { + return new AttributeDefinition( + attributes: [], + profile: Profiles::of(capabilities: [Capability::Objects, ...($vectors ? [Capability::Vectors] : [])], features: [...($spatial ? [Feature\Spatial::class] : [])]), + ); + } +} diff --git a/tests/unit/Validator/AttributeDefinitionFiltersTest.php b/tests/unit/Validator/AttributeDefinitionFiltersTest.php new file mode 100644 index 0000000000..bebb5fee00 --- /dev/null +++ b/tests/unit/Validator/AttributeDefinitionFiltersTest.php @@ -0,0 +1,20 @@ +toDocument()->setAttribute('filters', [Filter::Datetime]); + + $this->assertTrue($validator->isValid($declaration), $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/AttributeJsonDefaultTest.php b/tests/unit/Validator/AttributeJsonDefaultTest.php new file mode 100644 index 0000000000..21d4419564 --- /dev/null +++ b/tests/unit/Validator/AttributeJsonDefaultTest.php @@ -0,0 +1,86 @@ +validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + } + + /** + * @return array + */ + public static function scalarDefaults(): array + { + return [ + 'integer' => [12345, 'Default value 12345 does not match given type string'], + 'float' => [1.5, 'Default value 1.5 does not match given type string'], + 'boolean' => [true, 'Default value true does not match given type string'], + ]; + } + + #[DataProvider('scalarDefaults')] + public function test_scalar_default_must_match_the_storage_type(mixed $default, string $message): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage($message); + + $this->validator->isValid(Attribute::string(key: 'meta', size: 65535, default: $default, filters: [Filter::Json])); + } + + public function test_structured_default_must_be_json_encodable(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Default value of json attribute "meta" is not JSON-encodable: Malformed UTF-8 characters, possibly incorrectly encoded'); + + $this->validator->isValid(Attribute::string(key: 'meta', size: 65535, default: ['name' => "\xB1\x31"], filters: [Filter::Json])); + } + + public function test_json_filter_does_not_exempt_non_string_types(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot set an array default value for a non-array attribute'); + + $this->validator->isValid(Attribute::integer(key: 'count', default: [], filters: [Filter::Json])); + } + + /** + * @return array + */ + public static function jsonDocuments(): array + { + return [ + 'empty list' => [[]], + 'list' => [['a', 'b']], + 'map' => [['cost' => 12, 'memory' => 65536]], + 'object' => [new stdClass()], + 'document' => [new Document(['cost' => 12])], + 'json text' => ['{}'], + 'empty text' => [''], + ]; + } + + #[DataProvider('jsonDocuments')] + public function test_json_document_default_is_valid(mixed $default): void + { + $this->assertTrue($this->validator->isValid(Attribute::string(key: 'meta', size: 65535, default: $default, filters: [Filter::Json]))); + } +} diff --git a/tests/unit/Validator/AttributeTest.php b/tests/unit/Validator/AttributeTest.php index 65a01c3a75..b1b93ba51a 100644 --- a/tests/unit/Validator/AttributeTest.php +++ b/tests/unit/Validator/AttributeTest.php @@ -3,41 +3,123 @@ namespace Tests\Unit\Validator; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Tests\Unit\Support\Profiles; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Validator\Attribute; +use Utopia\Database\Exception\Structure as StructureException; +use Utopia\Database\Id; +use Utopia\Database\RelationshipSide; +use Utopia\Database\RelationshipType; +use Utopia\Database\Schema\Column; +use Utopia\Database\Validator\AttributeDefinition; +use Utopia\Database\Validator\Structure; +use Utopia\Query\Schema\ColumnType; class AttributeTest extends TestCase { - public function testDuplicateAttributeId(): void + public function testLegacyBigIntegerMetadataNormalizesToCanonicalType(): void { - $validator = new Attribute( + $attribute = Attribute::fromDocument(new Document([ + '$id' => 'total', + 'type' => 'bigint', + 'size' => 8, + ])); + + $this->assertSame(ColumnType::BigInteger, $attribute->type); + $this->assertNull($attribute->size); + $this->assertSame('bigint', $attribute->toDocument()->getAttribute('type')); + + $arrayAttribute = Attribute::fromArray([ + '$id' => 'arrayTotal', + 'type' => 'bigint', + 'size' => 64, + ]); + + $this->assertSame(ColumnType::BigInteger, $arrayAttribute->type); + $this->assertNull($arrayAttribute->size); + } + + public function testBigIntegerDefaultsSupportNativeAndStringBoundaries(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(capabilities: [Capability::UnsignedBigInt], integer: 100), + ); + + $this->assertTrue($validator->isValid(Attribute::bigInteger( + key: 'signed', + default: PHP_INT_MAX, + ))); + $this->assertTrue($validator->isValid(Attribute::bigInteger( + key: 'signedMinimum', + default: '-9223372036854775808', + ))); + $this->assertTrue($validator->isValid(Attribute::bigInteger( + key: 'unsigned', + default: '18446744073709551615', + signed: false, + ))); + $this->assertTrue($validator->isValid(Attribute::bigInteger( + key: 'values', + default: ['-9223372036854775808', PHP_INT_MAX], + array: true, + ))); + } + + public function testBigIntegerDefaultRejectsValuesOutsideSignedRange(): void + { + $validator = new AttributeDefinition(attributes: [], profile: Profiles::of()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not match given type bigint'); + $validator->isValid(Attribute::bigInteger( + key: 'total', + default: '9223372036854775808', + )); + } + + public function testBigIntegerArrayDefaultValidatesEveryValue(): void + { + $validator = new AttributeDefinition(attributes: [], profile: Profiles::of(capabilities: [Capability::UnsignedBigInt])); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not match given type bigint'); + $validator->isValid(Attribute::bigInteger( + key: 'totals', + default: ['1', '18446744073709551616'], + signed: false, + array: true, + )); + } + + public function test_duplicate_attribute_id(): void + { + $validator = new AttributeDefinition( attributes: [ new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, 'signed' => true, 'array' => false, 'filters' => [], - ]) + ]), ], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -51,19 +133,17 @@ public function testDuplicateAttributeId(): void $validator->isValid($attribute); } - public function testValidStringAttribute(): void + public function test_valid_string_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -75,19 +155,17 @@ public function testValidStringAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testStringSizeTooLarge(): void + public function test_string_size_too_large(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 1000, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 1000, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 2000, 'required' => false, 'default' => null, @@ -101,19 +179,17 @@ public function testStringSizeTooLarge(): void $validator->isValid($attribute); } - public function testVarcharSizeTooLarge(): void + public function test_varchar_size_too_large(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 1000, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 1000, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 2000, 'required' => false, 'default' => null, @@ -127,19 +203,17 @@ public function testVarcharSizeTooLarge(): void $validator->isValid($attribute); } - public function testTextSizeTooLarge(): void + public function test_text_size_too_large(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 70000, 'required' => false, 'default' => null, @@ -153,19 +227,17 @@ public function testTextSizeTooLarge(): void $validator->isValid($attribute); } - public function testMediumtextSizeTooLarge(): void + public function test_mediumtext_size_too_large(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'size' => 20000000, 'required' => false, 'default' => null, @@ -179,19 +251,17 @@ public function testMediumtextSizeTooLarge(): void $validator->isValid($attribute); } - public function testIntegerSizeTooLarge(): void + public function test_integer_size_too_large(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: 100, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: 100), ); $attribute = new Document([ - '$id' => ID::custom('count'), + '$id' => Id::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 200, 'required' => false, 'default' => null, @@ -205,17 +275,15 @@ public function testIntegerSizeTooLarge(): void $validator->isValid($attribute); } - public function testUnknownType(): void + public function test_unknown_type(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('test'), + '$id' => Id::custom('test'), 'key' => 'test', 'type' => 'unknown_type', 'size' => 0, @@ -231,19 +299,17 @@ public function testUnknownType(): void $validator->isValid($attribute); } - public function testRequiredFiltersForDatetime(): void + public function test_required_filters_for_datetime(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('created'), + '$id' => Id::custom('created'), 'key' => 'created', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'default' => null, @@ -257,19 +323,17 @@ public function testRequiredFiltersForDatetime(): void $validator->isValid($attribute); } - public function testValidDatetimeWithFilter(): void + public function test_valid_datetime_with_filter(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('created'), + '$id' => Id::custom('created'), 'key' => 'created', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'default' => null, @@ -281,19 +345,17 @@ public function testValidDatetimeWithFilter(): void $this->assertTrue($validator->isValid($attribute)); } - public function testDefaultValueOnRequiredAttribute(): void + public function test_default_value_on_required_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => true, 'default' => 'default value', @@ -307,19 +369,17 @@ public function testDefaultValueOnRequiredAttribute(): void $validator->isValid($attribute); } - public function testDefaultValueTypeMismatch(): void + public function test_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('count'), + '$id' => Id::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 4, 'required' => false, 'default' => 'not_an_integer', @@ -329,24 +389,21 @@ public function testDefaultValueTypeMismatch(): void ]); $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Default value not_an_integer does not match given type integer'); + $this->expectExceptionMessage('Default value "not_an_integer" does not match given type integer'); $validator->isValid($attribute); } - public function testVectorNotSupported(): void + public function test_vector_not_supported(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: false, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 128, 'required' => false, 'default' => null, @@ -360,20 +417,17 @@ public function testVectorNotSupported(): void $validator->isValid($attribute); } - public function testVectorCannotBeArray(): void + public function test_vector_cannot_be_array(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embeddings'), + '$id' => Id::custom('embeddings'), 'key' => 'embeddings', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 128, 'required' => false, 'default' => null, @@ -387,20 +441,17 @@ public function testVectorCannotBeArray(): void $validator->isValid($attribute); } - public function testVectorInvalidDimensions(): void + public function test_vector_invalid_dimensions(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 0, 'required' => false, 'default' => null, @@ -414,20 +465,17 @@ public function testVectorInvalidDimensions(): void $validator->isValid($attribute); } - public function testVectorDimensionsExceedsMax(): void + public function test_vector_dimensions_exceeds_max(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 20000, 'required' => false, 'default' => null, @@ -441,20 +489,17 @@ public function testVectorDimensionsExceedsMax(): void $validator->isValid($attribute); } - public function testSpatialNotSupported(): void + public function test_spatial_not_supported(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSpatialAttributes: false, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('location'), + '$id' => Id::custom('location'), 'key' => 'location', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 0, 'required' => false, 'default' => null, @@ -468,20 +513,17 @@ public function testSpatialNotSupported(): void $validator->isValid($attribute); } - public function testSpatialCannotBeArray(): void + public function test_spatial_cannot_be_array(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSpatialAttributes: true, + profile: Profiles::of(features: [Feature\Spatial::class], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('locations'), + '$id' => Id::custom('locations'), 'key' => 'locations', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 0, 'required' => false, 'default' => null, @@ -495,20 +537,17 @@ public function testSpatialCannotBeArray(): void $validator->isValid($attribute); } - public function testSpatialMustHaveEmptySize(): void + public function test_spatial_must_have_empty_size(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSpatialAttributes: true, + profile: Profiles::of(features: [Feature\Spatial::class], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('location'), + '$id' => Id::custom('location'), 'key' => 'location', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 100, 'required' => false, 'default' => null, @@ -522,20 +561,17 @@ public function testSpatialMustHaveEmptySize(): void $validator->isValid($attribute); } - public function testObjectNotSupported(): void + public function test_object_not_supported(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForObject: false, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('metadata'), + '$id' => Id::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 0, 'required' => false, 'default' => null, @@ -549,20 +585,17 @@ public function testObjectNotSupported(): void $validator->isValid($attribute); } - public function testObjectCannotBeArray(): void + public function test_object_cannot_be_array(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForObject: true, + profile: Profiles::of(capabilities: [Capability::Objects], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('metadata'), + '$id' => Id::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 0, 'required' => false, 'default' => null, @@ -576,20 +609,17 @@ public function testObjectCannotBeArray(): void $validator->isValid($attribute); } - public function testObjectMustHaveEmptySize(): void + public function test_object_must_have_empty_size(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForObject: true, + profile: Profiles::of(capabilities: [Capability::Objects], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('metadata'), + '$id' => Id::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 100, 'required' => false, 'default' => null, @@ -603,23 +633,19 @@ public function testObjectMustHaveEmptySize(): void $validator->isValid($attribute); } - public function testAttributeLimitExceeded(): void + public function test_attribute_limit_exceeded(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxAttributes: 5, - maxWidth: 0, - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - attributeCountCallback: fn () => 10, - attributeWidthCallback: fn () => 100, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX, attributes: 5), + attributeCount: fn () => 10, + attributeWidth: fn () => 100, ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -633,23 +659,19 @@ public function testAttributeLimitExceeded(): void $validator->isValid($attribute); } - public function testRowWidthLimitExceeded(): void + public function test_row_width_limit_exceeded(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxAttributes: 100, - maxWidth: 1000, - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - attributeCountCallback: fn () => 5, - attributeWidthCallback: fn () => 1500, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX, attributes: 100, documentSize: 1000), + attributeCount: fn () => 5, + attributeWidth: fn () => 1500, ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -663,20 +685,17 @@ public function testRowWidthLimitExceeded(): void $validator->isValid($attribute); } - public function testVectorDefaultValueNotArray(): void + public function test_vector_default_value_not_array(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 3, 'required' => false, 'default' => 'not_an_array', @@ -690,20 +709,17 @@ public function testVectorDefaultValueNotArray(): void $validator->isValid($attribute); } - public function testVectorDefaultValueWrongElementCount(): void + public function test_vector_default_value_wrong_element_count(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 3, 'required' => false, 'default' => [1.0, 2.0], @@ -717,20 +733,17 @@ public function testVectorDefaultValueWrongElementCount(): void $validator->isValid($attribute); } - public function testVectorDefaultValueNonNumericElements(): void + public function test_vector_default_value_non_numeric_elements(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 3, 'required' => false, 'default' => [1.0, 'not_a_number', 3.0], @@ -744,19 +757,17 @@ public function testVectorDefaultValueNonNumericElements(): void $validator->isValid($attribute); } - public function testLongtextSizeTooLarge(): void + public function test_longtext_size_too_large(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'size' => 5000000000, 'required' => false, 'default' => null, @@ -770,19 +781,17 @@ public function testLongtextSizeTooLarge(): void $validator->isValid($attribute); } - public function testValidVarcharAttribute(): void + public function test_valid_varchar_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('name'), + '$id' => Id::custom('name'), 'key' => 'name', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 255, 'required' => false, 'default' => null, @@ -794,19 +803,17 @@ public function testValidVarcharAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidTextAttribute(): void + public function test_valid_text_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 65535, 'required' => false, 'default' => null, @@ -818,19 +825,17 @@ public function testValidTextAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidMediumtextAttribute(): void + public function test_valid_mediumtext_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'size' => 16777215, 'required' => false, 'default' => null, @@ -842,19 +847,17 @@ public function testValidMediumtextAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidLongtextAttribute(): void + public function test_valid_longtext_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'size' => 4294967295, 'required' => false, 'default' => null, @@ -866,19 +869,17 @@ public function testValidLongtextAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidFloatAttribute(): void + public function test_valid_float_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('price'), + '$id' => Id::custom('price'), 'key' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'size' => 0, 'required' => false, 'default' => null, @@ -890,19 +891,17 @@ public function testValidFloatAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidBooleanAttribute(): void + public function test_valid_boolean_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('active'), + '$id' => Id::custom('active'), 'key' => 'active', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'size' => 0, 'required' => false, 'default' => null, @@ -914,19 +913,17 @@ public function testValidBooleanAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testFloatDefaultValueTypeMismatch(): void + public function test_float_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('price'), + '$id' => Id::custom('price'), 'key' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'size' => 0, 'required' => false, 'default' => 'not_a_float', @@ -936,23 +933,21 @@ public function testFloatDefaultValueTypeMismatch(): void ]); $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Default value not_a_float does not match given type double'); + $this->expectExceptionMessage('Default value "not_a_float" does not match given type double'); $validator->isValid($attribute); } - public function testBooleanDefaultValueTypeMismatch(): void + public function test_boolean_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('active'), + '$id' => Id::custom('active'), 'key' => 'active', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'size' => 0, 'required' => false, 'default' => 'not_a_boolean', @@ -962,23 +957,21 @@ public function testBooleanDefaultValueTypeMismatch(): void ]); $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Default value not_a_boolean does not match given type boolean'); + $this->expectExceptionMessage('Default value "not_a_boolean" does not match given type boolean'); $validator->isValid($attribute); } - public function testStringDefaultValueTypeMismatch(): void + public function test_string_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => 123, @@ -992,19 +985,17 @@ public function testStringDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testValidStringWithDefaultValue(): void + public function test_valid_string_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => 'default title', @@ -1016,19 +1007,17 @@ public function testValidStringWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidIntegerWithDefaultValue(): void + public function test_valid_integer_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('count'), + '$id' => Id::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 4, 'required' => false, 'default' => 42, @@ -1040,69 +1029,17 @@ public function testValidIntegerWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testInvalidBigIntDefaultValueTypeStringNotNumeric(): void + public function test_valid_float_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('counter'), - 'key' => 'counter', - 'type' => Database::VAR_BIGINT, - 'size' => 0, - 'required' => false, - 'default' => 'not_a_bigint', - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Default value not_a_bigint is not a valid integer string for type bigint'); - $validator->isValid($attribute); - } - - public function testValidBigIntDefaultValueTypeStringNumeric(): void - { - $validator = new Attribute( - attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - ); - - $attribute = new Document([ - '$id' => ID::custom('counter'), - 'key' => 'counter', - 'type' => Database::VAR_BIGINT, - 'size' => 0, - 'required' => false, - 'default' => '123', - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - - $this->assertTrue($validator->isValid($attribute)); - } - - public function testValidFloatWithDefaultValue(): void - { - $validator = new Attribute( - attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - ); - - $attribute = new Document([ - '$id' => ID::custom('price'), + '$id' => Id::custom('price'), 'key' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'size' => 0, 'required' => false, 'default' => 19.99, @@ -1114,19 +1051,17 @@ public function testValidFloatWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidBooleanWithDefaultValue(): void + public function test_valid_boolean_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('active'), + '$id' => Id::custom('active'), 'key' => 'active', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'size' => 0, 'required' => false, 'default' => true, @@ -1138,20 +1073,18 @@ public function testValidBooleanWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testUnsignedIntegerSizeLimit(): void + public function test_unsigned_integer_size_limit(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: 100, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: 100), ); // Unsigned allows double the size $attribute = new Document([ - '$id' => ID::custom('count'), + '$id' => Id::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 80, 'required' => false, 'default' => null, @@ -1163,19 +1096,17 @@ public function testUnsignedIntegerSizeLimit(): void $this->assertTrue($validator->isValid($attribute)); } - public function testUnsignedIntegerSizeTooLarge(): void + public function test_unsigned_integer_size_too_large(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: 100, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: 100), ); $attribute = new Document([ - '$id' => ID::custom('count'), + '$id' => Id::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 150, 'required' => false, 'default' => null, @@ -1189,81 +1120,29 @@ public function testUnsignedIntegerSizeTooLarge(): void $validator->isValid($attribute); } - public function testBigIntSizeNotLimited(): void - { - $validator = new Attribute( - attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - maxBigIntLength: 200, - ); - - $attribute = new Document([ - '$id' => ID::custom('counter'), - 'key' => 'counter', - 'type' => Database::VAR_BIGINT, - 'size' => 101, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - - $this->assertTrue($validator->isValid($attribute)); - } - - public function testUnsignedBigIntSizeLimit(): void + public function test_duplicate_attribute_id_case_insensitive(): void { - $validator = new Attribute( - attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - maxBigIntLength: 200, - ); - - $attribute = new Document([ - '$id' => ID::custom('counter'), - 'key' => 'counter', - 'type' => Database::VAR_BIGINT, - 'size' => 200, - 'required' => false, - 'default' => null, - 'signed' => false, - 'array' => false, - 'filters' => [], - ]); - - $this->assertTrue($validator->isValid($attribute)); - } - - public function testDuplicateAttributeIdCaseInsensitive(): void - { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [ new Document([ - '$id' => ID::custom('Title'), + '$id' => Id::custom('Title'), 'key' => 'Title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, 'signed' => true, 'array' => false, 'filters' => [], - ]) + ]), ], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1277,28 +1156,20 @@ public function testDuplicateAttributeIdCaseInsensitive(): void $validator->isValid($attribute); } - public function testDuplicateInSchema(): void + public function test_duplicate_in_schema(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], + profile: Profiles::of(capabilities: [Capability::SchemaIntrospection], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), schemaAttributes: [ - new Document([ - '$id' => ID::custom('existing_column'), - 'key' => 'existing_column', - 'type' => Database::VAR_STRING, - 'size' => 255, - ]) + new Column(name: 'existing_column', type: 'VARCHAR(255)', length: 255, nullable: true), ], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSchemaAttributes: true, ); $attribute = new Document([ - '$id' => ID::custom('existing_column'), + '$id' => Id::custom('existing_column'), 'key' => 'existing_column', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1312,30 +1183,20 @@ public function testDuplicateInSchema(): void $validator->isValid($attribute); } - public function testSchemaCheckSkippedWhenMigrating(): void + public function test_schema_check_skipped_when_migrating(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], + profile: Profiles::of(capabilities: [Capability::SchemaIntrospection], sharedTables: true, migrating: true, string: 16777216, varchar: 65535, integer: PHP_INT_MAX), schemaAttributes: [ - new Document([ - '$id' => ID::custom('existing_column'), - 'key' => 'existing_column', - 'type' => Database::VAR_STRING, - 'size' => 255, - ]) + new Column(name: 'existing_column', type: 'VARCHAR(255)', length: 255, nullable: true), ], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSchemaAttributes: true, - isMigrating: true, - sharedTables: true, ); $attribute = new Document([ - '$id' => ID::custom('existing_column'), + '$id' => Id::custom('existing_column'), 'key' => 'existing_column', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1347,20 +1208,17 @@ public function testSchemaCheckSkippedWhenMigrating(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidLinestringAttribute(): void + public function test_valid_linestring_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSpatialAttributes: true, + profile: Profiles::of(features: [Feature\Spatial::class], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('route'), + '$id' => Id::custom('route'), 'key' => 'route', - 'type' => Database::VAR_LINESTRING, + 'type' => ColumnType::Linestring->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1372,20 +1230,17 @@ public function testValidLinestringAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidPolygonAttribute(): void + public function test_valid_polygon_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSpatialAttributes: true, + profile: Profiles::of(features: [Feature\Spatial::class], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('area'), + '$id' => Id::custom('area'), 'key' => 'area', - 'type' => Database::VAR_POLYGON, + 'type' => ColumnType::Polygon->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1397,20 +1252,17 @@ public function testValidPolygonAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidPointAttribute(): void + public function test_valid_point_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForSpatialAttributes: true, + profile: Profiles::of(features: [Feature\Spatial::class], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('location'), + '$id' => Id::custom('location'), 'key' => 'location', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1422,20 +1274,17 @@ public function testValidPointAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidVectorAttribute(): void + public function test_valid_vector_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 128, 'required' => false, 'default' => null, @@ -1447,20 +1296,17 @@ public function testValidVectorAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidVectorWithDefaultValue(): void + public function test_valid_vector_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForVectors: true, + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('embedding'), + '$id' => Id::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 3, 'required' => false, 'default' => [1.0, 2.0, 3.0], @@ -1472,20 +1318,17 @@ public function testValidVectorWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidObjectAttribute(): void + public function test_valid_object_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, - supportForObject: true, + profile: Profiles::of(capabilities: [Capability::Objects], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('metadata'), + '$id' => Id::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1497,19 +1340,17 @@ public function testValidObjectAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testArrayStringAttribute(): void + public function test_array_string_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('tags'), + '$id' => Id::custom('tags'), 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1521,19 +1362,17 @@ public function testArrayStringAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testArrayWithDefaultValues(): void + public function test_array_with_default_values(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('tags'), + '$id' => Id::custom('tags'), 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => ['tag1', 'tag2', 'tag3'], @@ -1545,19 +1384,17 @@ public function testArrayWithDefaultValues(): void $this->assertTrue($validator->isValid($attribute)); } - public function testArrayDefaultValueTypeMismatch(): void + public function test_array_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('tags'), + '$id' => Id::custom('tags'), 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => ['tag1', 123, 'tag3'], @@ -1571,19 +1408,17 @@ public function testArrayDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testDatetimeDefaultValueMustBeString(): void + public function test_datetime_default_value_must_be_string(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('created'), + '$id' => Id::custom('created'), 'key' => 'created', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'default' => 12345, @@ -1597,19 +1432,17 @@ public function testDatetimeDefaultValueMustBeString(): void $validator->isValid($attribute); } - public function testValidDatetimeWithDefaultValue(): void + public function test_valid_datetime_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('created'), + '$id' => Id::custom('created'), 'key' => 'created', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'default' => '2024-01-01T00:00:00.000Z', @@ -1621,19 +1454,17 @@ public function testValidDatetimeWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testVarcharDefaultValueTypeMismatch(): void + public function test_varchar_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('name'), + '$id' => Id::custom('name'), 'key' => 'name', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 255, 'required' => false, 'default' => 123, @@ -1647,19 +1478,17 @@ public function testVarcharDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testTextDefaultValueTypeMismatch(): void + public function test_text_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 65535, 'required' => false, 'default' => 123, @@ -1673,19 +1502,17 @@ public function testTextDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testMediumtextDefaultValueTypeMismatch(): void + public function test_mediumtext_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'size' => 16777215, 'required' => false, 'default' => 123, @@ -1699,19 +1526,17 @@ public function testMediumtextDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testLongtextDefaultValueTypeMismatch(): void + public function test_longtext_default_value_type_mismatch(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'size' => 4294967295, 'required' => false, 'default' => 123, @@ -1725,19 +1550,17 @@ public function testLongtextDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testValidVarcharWithDefaultValue(): void + public function test_valid_varchar_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('name'), + '$id' => Id::custom('name'), 'key' => 'name', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 255, 'required' => false, 'default' => 'default name', @@ -1749,19 +1572,17 @@ public function testValidVarcharWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidTextWithDefaultValue(): void + public function test_valid_text_with_default_value(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('content'), + '$id' => Id::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 65535, 'required' => false, 'default' => 'default content', @@ -1773,19 +1594,17 @@ public function testValidTextWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidIntegerAttribute(): void + public function test_valid_integer_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('count'), + '$id' => Id::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 4, 'required' => false, 'default' => null, @@ -1797,19 +1616,17 @@ public function testValidIntegerAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testNullDefaultValueAllowed(): void + public function test_null_default_value_allowed(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1821,19 +1638,17 @@ public function testNullDefaultValueAllowed(): void $this->assertTrue($validator->isValid($attribute)); } - public function testArrayDefaultOnNonArrayAttribute(): void + public function test_array_default_on_non_array_attribute(): void { - $validator = new Attribute( + $validator = new AttributeDefinition( attributes: [], - maxStringLength: 16777216, - maxVarcharLength: 65535, - maxIntLength: PHP_INT_MAX, + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), ); $attribute = new Document([ - '$id' => ID::custom('title'), + '$id' => Id::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => ['not', 'allowed'], @@ -1846,4 +1661,325 @@ public function testArrayDefaultOnNonArrayAttribute(): void $this->expectExceptionMessage('Cannot set an array default value for a non-array attribute'); $validator->isValid($attribute); } + + public function test_array_default_allowed_on_json_filter_attribute(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attribute = new Document([ + '$id' => Id::custom('services'), + 'key' => 'services', + 'type' => ColumnType::String->value, + 'size' => 16384, + 'required' => false, + 'default' => [], + 'signed' => true, + 'array' => false, + 'filters' => ['json'], + ]); + + $this->assertTrue($validator->isValid($attribute)); + } + + public function test_object_default_allowed_on_json_filter_attribute(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attribute = new Document([ + '$id' => Id::custom('data'), + 'key' => 'data', + 'type' => ColumnType::String->value, + 'size' => 65535, + 'required' => false, + 'default' => new \stdClass(), + 'signed' => true, + 'array' => false, + 'filters' => ['json', 'encrypt'], + ]); + + $this->assertTrue($validator->isValid($attribute)); + } + + public function test_get_type(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $this->assertEquals('object', $validator->getType()); + } + + public function test_get_description(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $this->assertEquals('Invalid attribute', $validator->getDescription()); + } + + public function test_is_array(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $this->assertFalse($validator->isArray()); + } + + public function test_is_valid_with_attribute_vo_directly(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attrVO = Attribute::string( + key: 'directAttr', + size: 255, + required: false, + default: null, + array: false, + filters: [], + ); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function test_attribute_does_not_collide_with_schema(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(capabilities: [Capability::SchemaIntrospection], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + schemaAttributes: [ + new Column(name: 'existing_column', type: 'VARCHAR(255)', length: 255, nullable: true), + ], + ); + + $attribute = new Document([ + '$id' => Id::custom('new_column'), + 'key' => 'new_column', + 'type' => ColumnType::String->value, + 'size' => 255, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]); + + $this->assertTrue($validator->isValid($attribute)); + } + + public function test_invalid_format_for_type(): void + { + Structure::addFormat('testformat', function (mixed $attribute) { + return new \Utopia\Validator\Text(100); + }, ColumnType::Integer); + + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attribute = new Document([ + '$id' => Id::custom('formatted'), + 'key' => 'formatted', + 'type' => ColumnType::String->value, + 'size' => 255, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'format' => 'testformat', + 'filters' => [], + ]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Format ("testformat") not available for this attribute type ("string")'); + $validator->isValid($attribute); + } + + public function test_id_type_attribute_validation(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attrVO = Attribute::id( + key: 'myId', + required: false, + default: null, + array: false, + ); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function test_unknown_column_type_in_check_type(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: enum'); + $validator->isValid(Attribute::fromArray([ + 'key' => 'badtype', + 'type' => ColumnType::Enum, + 'size' => 0, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ])); + } + + public function test_null_default_value_in_validate_default_types(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attrVO = Attribute::string( + key: 'nullableField', + size: 255, + required: false, + default: null, + array: false, + filters: [], + ); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function test_vector_component_non_numeric_default_type(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attrVO = Attribute::vector( + key: 'vec', + dimensions: 3, + required: false, + default: [1.0, 2.0, 3.0], + ); + + $this->assertTrue($validator->isValid($attrVO)); + + $validator2 = new AttributeDefinition( + attributes: [], + profile: Profiles::of(capabilities: [Capability::Vectors], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attrVO2 = Attribute::fromArray([ + 'key' => 'vec2', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'required' => false, + 'default' => [1.0, 'notANumber', 3.0], + ]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Vector default value must contain only numeric elements'); + $validator2->isValid($attrVO2); + } + + public function test_unknown_column_type_with_default_value(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: enum'); + $validator->isValid(Attribute::fromArray([ + 'key' => 'baddefault', + 'type' => ColumnType::Enum, + 'size' => 0, + 'required' => false, + 'default' => 'somevalue', + 'signed' => true, + 'array' => false, + 'filters' => [], + ])); + } + + public function test_schema_duplicate_check_with_filter_callback(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(capabilities: [Capability::SchemaIntrospection], string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + schemaAttributes: [ + new Column(name: '_prefix_column', type: 'VARCHAR(255)', length: 255, nullable: true), + ], + filter: fn (string $key) => str_replace('_prefix_', '', $key), + ); + + $attribute = new Document([ + '$id' => Id::custom('column'), + 'key' => 'column', + 'type' => ColumnType::String->value, + 'size' => 255, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]); + + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Attribute already exists in schema'); + $validator->isValid($attribute); + } + + public function test_relationship_type_passes_check_type(): void + { + $validator = new AttributeDefinition( + attributes: [], + profile: Profiles::of(string: 16777216, varchar: 65535, integer: PHP_INT_MAX), + ); + + $attrVO = Attribute::fromArray([ + 'key' => 'parent', + 'type' => ColumnType::Relationship, + 'size' => 0, + 'required' => false, + 'default' => null, + 'signed' => false, + 'array' => false, + 'filters' => [], + 'options' => ['relatedCollection' => 'parents', 'relationType' => RelationshipType::ManyToOne->value, 'side' => RelationshipSide::Child->value], + ]); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function testBigIntegerDefaultRejectsNonNumericString(): void + { + $validator = new AttributeDefinition(attributes: [], profile: Profiles::of()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not match given type bigint'); + $validator->isValid(Attribute::bigInteger( + key: 'counter', + default: 'not_a_bigint', + )); + } } diff --git a/tests/unit/Validator/AuthorizationTest.php b/tests/unit/Validator/AuthorizationTest.php index e8685549e3..7cd36d4b05 100644 --- a/tests/unit/Validator/AuthorizationTest.php +++ b/tests/unit/Validator/AuthorizationTest.php @@ -3,11 +3,11 @@ namespace Tests\Unit\Validator; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\PermissionType; +use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Authorization\Input; @@ -15,25 +15,27 @@ class AuthorizationTest extends TestCase { protected Authorization $authorization; - public function setUp(): void + #[\Override] + protected function setUp(): void { $this->authorization = new Authorization(); } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } - public function testValues(): void + public function test_values(): void { $this->authorization->addRole(Role::any()->toString()); $document = new Document([ - '$id' => ID::unique(), - '$collection' => ID::unique(), + '$id' => Id::unique(), + '$collection' => Id::unique(), '$permissions' => [ - Permission::read(Role::user(ID::custom('123'))), - Permission::read(Role::team(ID::custom('123'))), + Permission::read(Role::user(Id::custom('123'))), + Permission::read(Role::team(Id::custom('123'))), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), @@ -42,8 +44,8 @@ public function testValues(): void $object = $this->authorization; - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), false); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, [])), false); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), false); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, [])), false); $this->assertEquals($object->getDescription(), 'No permissions provided for action \'read\''); $this->authorization->addRole(Role::user('456')->toString()); @@ -54,37 +56,39 @@ public function testValues(): void $this->assertEquals($this->authorization->hasRole(''), false); $this->assertEquals($this->authorization->hasRole(Role::any()->toString()), true); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), true); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), true); $this->authorization->cleanRoles(); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), false); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), false); $this->authorization->addRole(Role::team('123')->toString()); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), true); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), true); $this->authorization->cleanRoles(); $this->authorization->disable(); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), true); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), true); $this->authorization->reset(); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), false); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), false); - $this->authorization->setDefaultStatus(false); + $this->authorization = (new Authorization(defaultStatus: false))->cleanRoles(); + $object = $this->authorization; + $this->assertFalse($object->getStatus()); $this->authorization->disable(); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), true); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), true); $this->authorization->reset(); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), true); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), true); $this->authorization->enable(); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), false); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), false); $this->authorization->addRole('textX'); @@ -95,13 +99,13 @@ public function testValues(): void $this->assertNotContains('textX', $this->authorization->getRoles()); // Test skip method - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), false); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))), false); $this->assertEquals($this->authorization->skip(function () use ($object, $document) { - return $object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())); + return $object->isValid(new Input(PermissionType::Read, $document->getPermissionsByType(PermissionType::Read))); }), true); } - public function testNestedSkips(): void + public function test_nested_skips(): void { $this->assertEquals(true, $this->authorization->getStatus()); @@ -123,4 +127,32 @@ public function testNestedSkips(): void $this->assertEquals(true, $this->authorization->getStatus()); } + + public function test_custom_action_granted(): void + { + $this->authorization->addRole(Role::user(Id::custom('123'))->toString()); + + $this->assertTrue($this->authorization->isValid(new Input('execute', [Role::user(Id::custom('123'))->toString()]))); + $this->assertTrue($this->authorization->isValid(new Input('subscribe', [Role::any()->toString()]))); + } + + public function test_custom_action_denied(): void + { + $input = new Input('execute', [Role::user(Id::custom('456'))->toString()]); + + $this->assertSame('execute', $input->getAction()); + $this->assertFalse($this->authorization->isValid($input)); + $this->assertStringContainsString('Missing "execute" permission', $this->authorization->getDescription()); + + $this->assertFalse($this->authorization->isValid(new Input('execute', []))); + $this->assertSame("No permissions provided for action 'execute'", $this->authorization->getDescription()); + } + + public function test_set_action_accepts_custom_string_and_enum(): void + { + $input = new Input(PermissionType::Read, []); + + $this->assertSame('subscribe', $input->setAction('subscribe')->getAction()); + $this->assertSame('update', $input->setAction(PermissionType::Update)->getAction()); + } } diff --git a/tests/unit/Validator/BigIntTest.php b/tests/unit/Validator/BigIntTest.php new file mode 100644 index 0000000000..35e817291f --- /dev/null +++ b/tests/unit/Validator/BigIntTest.php @@ -0,0 +1,142 @@ +assertSame(ColumnType::BigInteger->value, (new BigInt(true))->getType()); + $this->assertSame(ColumnType::BigInteger->value, (new BigInt(false))->getType()); + } + + public function testNegatingZeroGivesTheIntegerZero(): void + { + $this->assertSame(0, BigInt::negate(0)); + $this->assertSame(0, BigInt::negate('0')); + $this->assertSame(0, BigInt::negate('-0')); + $this->assertSame(5, BigInt::subtract(5, 0)); + } + + public function testDivisionSignsTheQuotient(): void + { + $this->assertSame(-3, BigInt::divide(-10, 3)); + $this->assertSame(-3, BigInt::divide(10, -3)); + $this->assertSame(3, BigInt::divide(-10, -3)); + $this->assertSame(0, BigInt::divide(-1, 3)); + } + + public function testDivisionByZeroIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Division by zero is not allowed.'); + + BigInt::divide(1, 0); + } + + public function testModuloByZeroIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Modulo by zero is not allowed.'); + + BigInt::modulo(5, 0); + } + + public function testNormalizingAcceptsWholeFloatsAndIntegerStrings(): void + { + $this->assertSame('5', BigInt::normalizeInteger(5.0)); + $this->assertSame('-5', BigInt::normalizeInteger(-5.0)); + $this->assertSame('7', BigInt::normalizeInteger('007')); + $this->assertSame('0', BigInt::normalizeInteger('-0')); + $this->assertSame(BigInt::UNSIGNED_MAX, BigInt::normalizeInteger(BigInt::UNSIGNED_MAX)); + } + + #[DataProvider('valuesThatAreNotIntegers')] + public function testNormalizingRejectsAValueThatIsNotAnInteger(mixed $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Value must be an integer.'); + + BigInt::normalizeInteger($value); + } + + /** + * @return iterable + */ + public static function valuesThatAreNotIntegers(): iterable + { + yield 'a fractional float' => [1.5]; + yield 'infinity' => [\INF]; + yield 'not a number' => [\NAN]; + yield 'a float above the integer range' => [1e20]; + yield 'a float below the integer range' => [-1e20]; + yield 'a boolean' => [true]; + yield 'null' => [null]; + yield 'a word' => ['abc']; + yield 'a decimal string' => ['1.5']; + } + + public function testPowerRefusesANegativeExponent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Integer power exponent must not be negative.'); + + BigInt::power(5, -1); + } + + public function testPowerOfZeroExponentAndUnitBases(): void + { + $this->assertSame(1, BigInt::power(2, 0)); + $this->assertSame(1, BigInt::power(0, 0)); + $this->assertSame(0, BigInt::power(0, 99)); + $this->assertSame(1, BigInt::power(1, 99)); + $this->assertSame(-1, BigInt::power(-1, 3)); + $this->assertSame(1, BigInt::power(-1, 4)); + $this->assertSame(-1, BigInt::power(-1, '99999999999999999999999')); + } + + public function testPowerAboveSixtyFourIsAnOverflowSentinel(): void + { + $this->assertSame(BigInt::UNSIGNED_MAX.'0', BigInt::power(2, 65)); + $this->assertSame('-'.BigInt::UNSIGNED_MAX.'0', BigInt::power(-2, 65)); + $this->assertSame(BigInt::UNSIGNED_MAX.'0', BigInt::power(-2, 66)); + $this->assertSame(BigInt::UNSIGNED_MAX.'0', BigInt::power(2, '99999999999999999999')); + } + + public function testNativeIntegerArithmeticAtTheSignedLimits(): void + { + $this->assertSame(\PHP_INT_MAX, BigInt::add(\PHP_INT_MAX - 1, 1)); + $this->assertSame(\PHP_INT_MIN, BigInt::add(\PHP_INT_MIN + 1, -1)); + $this->assertSame(\PHP_INT_MAX, BigInt::subtract(\PHP_INT_MAX - 1, -1)); + $this->assertSame(12, BigInt::add('5', '7')); + $this->assertSame('9223372036854775808', BigInt::add(\PHP_INT_MAX, 1)); + $this->assertSame('-9223372036854775809', BigInt::add(\PHP_INT_MIN, -1)); + $this->assertSame(\PHP_INT_MIN, BigInt::subtract(\PHP_INT_MIN + 1, 1)); + $this->assertSame('-9223372036854775809', BigInt::subtract(\PHP_INT_MIN, 1)); + $this->assertSame('9223372036854775808', BigInt::negate(\PHP_INT_MIN)); + $this->assertSame(-\PHP_INT_MAX, BigInt::negate(\PHP_INT_MAX)); + $this->assertSame(0, BigInt::add(5, -5)); + } + + public function testComparingNativeAndStringIntegersAgrees(): void + { + $this->assertSame(-1, BigInt::compare(-2, 1)); + $this->assertSame(0, BigInt::compare(7, '7')); + $this->assertSame(1, BigInt::compare(\PHP_INT_MAX, \PHP_INT_MIN)); + $this->assertSame(-1, BigInt::compare(\PHP_INT_MAX, BigInt::UNSIGNED_MAX)); + } + + public function testNativeIntegersNormalizeToTheirDigits(): void + { + $this->assertSame('-42', BigInt::normalizeInteger(-42)); + $this->assertSame('0', BigInt::normalizeInteger(0)); + $this->assertSame(\PHP_INT_MIN, BigInt::toNative(\PHP_INT_MIN)); + $this->assertSame(12, BigInt::toNative(12)); + } +} diff --git a/tests/unit/Validator/CursorDocumentTest.php b/tests/unit/Validator/CursorDocumentTest.php new file mode 100644 index 0000000000..50658f2984 --- /dev/null +++ b/tests/unit/Validator/CursorDocumentTest.php @@ -0,0 +1,51 @@ + + */ + public static function directions(): array + { + return [ + 'after' => [Query::cursorAfter(new Document([Document::ID => 'movie1']))], + 'before' => [Query::cursorBefore(new Document([Document::ID => 'movie1']))], + ]; + } + + #[DataProvider('directions')] + public function testADocumentIsAccepted(Query $query): void + { + $this->assertTrue((new Cursor())->isValid($query)); + } + + public function testADocumentIdIsAccepted(): void + { + $validator = new Cursor(); + + $this->assertTrue($validator->isValid(new Query(Method::CursorAfter, values: ['movie1']))); + $this->assertTrue($validator->isValid(new Query(Method::CursorBefore, values: ['movie1']))); + } + + public function testAnArrayIsRejected(): void + { + $validator = new Cursor(); + + $this->assertFalse($validator->isValid(new Query(Method::CursorAfter, values: [[Document::ID => 'movie1']]))); + $this->assertStringContainsString('Invalid cursor', $validator->getDescription()); + $this->assertFalse($validator->isValid(new Query(Method::CursorBefore, values: [[Document::ID => 'movie1']]))); + } +} diff --git a/tests/unit/Validator/DateTimeTest.php b/tests/unit/Validator/DateTimeTest.php index 106080c298..0fe6a88be1 100644 --- a/tests/unit/Validator/DateTimeTest.php +++ b/tests/unit/Validator/DateTimeTest.php @@ -9,35 +9,34 @@ class DateTimeTest extends TestCase { private \DateTime $minAllowed; + private \DateTime $maxAllowed; + private string $minString = '0000-01-01 00:00:00'; + private string $maxString = '9999-12-31 23:59:59'; - public function __construct() + #[\Override] + protected function setUp(): void { - parent::__construct(); - $this->minAllowed = new \DateTime($this->minString); $this->maxAllowed = new \DateTime($this->maxString); } - public function setUp(): void + #[\Override] + protected function tearDown(): void { } - public function tearDown(): void - { - } - - public function testCreateDatetime(): void + public function test_create_datetime(): void { $dateValidator = new DatetimeValidator($this->minAllowed, $this->maxAllowed); $this->assertGreaterThan(DateTime::addSeconds(new \DateTime(), -3), DateTime::now()); - $this->assertEquals(true, $dateValidator->isValid("2022-12-04")); - $this->assertEquals(true, $dateValidator->isValid("2022-1-4 11:31")); - $this->assertEquals(true, $dateValidator->isValid("2022-12-04 11:31:52")); - $this->assertEquals(true, $dateValidator->isValid("2022-1-4 11:31:52.123456789")); + $this->assertEquals(true, $dateValidator->isValid('2022-12-04')); + $this->assertEquals(true, $dateValidator->isValid('2022-1-4 11:31')); + $this->assertEquals(true, $dateValidator->isValid('2022-12-04 11:31:52')); + $this->assertEquals(true, $dateValidator->isValid('2022-1-4 11:31:52.123456789')); $this->assertGreaterThan('2022-7-2', '2022-7-2 11:31:52.680'); $now = DateTime::now(); $this->assertEquals(23, strlen($now)); @@ -55,21 +54,21 @@ public function testCreateDatetime(): void $this->assertEquals('52', $dateObject->format('s')); $this->assertEquals('680', $dateObject->format('v')); - $this->assertEquals(true, $dateValidator->isValid("2022-12-04 11:31:52.680+02:00")); + $this->assertEquals(true, $dateValidator->isValid('2022-12-04 11:31:52.680+02:00')); $this->assertEquals('UTC', date_default_timezone_get()); - $this->assertEquals("2022-12-04 09:31:52.680", DateTime::setTimezone("2022-12-04 11:31:52.680+02:00")); - $this->assertEquals("2022-12-04T09:31:52.681+00:00", DateTime::formatTz("2022-12-04 09:31:52.681")); + $this->assertEquals('2022-12-04 09:31:52.680', DateTime::setTimezone('2022-12-04 11:31:52.680+02:00')); + $this->assertEquals('2022-12-04T09:31:52.681+00:00', DateTime::formatTz('2022-12-04 09:31:52.681')); /** * Test for Failure */ - $this->assertEquals(false, $dateValidator->isValid("2022-13-04 11:31:52.680")); - $this->assertEquals(false, $dateValidator->isValid("-0001-13-04 00:00:00")); - $this->assertEquals(false, $dateValidator->isValid("0000-00-00 00:00:00")); - $this->assertEquals(false, $dateValidator->isValid("10000-01-01 00:00:00")); + $this->assertEquals(false, $dateValidator->isValid('2022-13-04 11:31:52.680')); + $this->assertEquals(false, $dateValidator->isValid('-0001-13-04 00:00:00')); + $this->assertEquals(false, $dateValidator->isValid('0000-00-00 00:00:00')); + $this->assertEquals(false, $dateValidator->isValid('10000-01-01 00:00:00')); } - public function testPastDateValidation(): void + public function test_past_date_validation(): void { $dateValidator = new DatetimeValidator( $this->minAllowed, @@ -92,7 +91,7 @@ public function testPastDateValidation(): void $this->assertEquals("Value must be valid date between {$this->minString} and {$this->maxString}.", $dateValidator->getDescription()); } - public function testDatePrecision(): void + public function test_date_precision(): void { $dateValidator = new DatetimeValidator( $this->minAllowed, @@ -151,7 +150,7 @@ public function testDatePrecision(): void $this->assertEquals("Value must be valid date with minutes precision between {$this->minString} and {$this->maxString}.", $dateValidator->getDescription()); } - public function testOffset(): void + public function test_offset(): void { $dateValidator = new DatetimeValidator( $this->minAllowed, @@ -191,4 +190,50 @@ public function testOffset(): void $this->assertEquals('Offset must be a positive integer.', $e->getMessage()); } } + + public function test_empty_and_non_string_values(): void + { + $dateValidator = new DatetimeValidator($this->minAllowed, $this->maxAllowed); + + $this->assertFalse($dateValidator->isValid('')); + $this->assertFalse($dateValidator->isValid(12345)); + $this->assertFalse($dateValidator->isValid(null)); + $this->assertFalse($dateValidator->isValid([])); + $this->assertFalse($dateValidator->isValid(false)); + } + + public function test_year_outside_min_max_range(): void + { + $dateValidator = new DatetimeValidator( + new \DateTime('2000-01-01'), + new \DateTime('2050-12-31'), + ); + + $this->assertFalse($dateValidator->isValid('1999-06-15 12:00:00')); + $this->assertFalse($dateValidator->isValid('2051-01-01 00:00:00')); + $this->assertTrue($dateValidator->isValid('2025-06-15 12:00:00')); + } + + public function test_value_without_four_digit_year(): void + { + $dateValidator = new DatetimeValidator($this->minAllowed, $this->maxAllowed); + + $this->assertFalse($dateValidator->isValid('noon')); + $this->assertFalse($dateValidator->isValid('tomorrow')); + $this->assertFalse($dateValidator->isValid('next Monday')); + } + + public function test_is_array(): void + { + $dateValidator = new DatetimeValidator($this->minAllowed, $this->maxAllowed); + + $this->assertFalse($dateValidator->isArray()); + } + + public function test_get_type(): void + { + $dateValidator = new DatetimeValidator($this->minAllowed, $this->maxAllowed); + + $this->assertEquals('string', $dateValidator->getType()); + } } diff --git a/tests/unit/Validator/DocumentQueriesTest.php b/tests/unit/Validator/DocumentQueriesTest.php index 558d0b455d..8da6902636 100644 --- a/tests/unit/Validator/DocumentQueriesTest.php +++ b/tests/unit/Validator/DocumentQueriesTest.php @@ -4,63 +4,61 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Tests\Unit\Support\Profiles; +use Utopia\Database\Capability; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Queries\Document as DocumentQueries; +use Utopia\Query\Schema\ColumnType; class DocumentQueriesTest extends TestCase { /** - * @var array + * @var array */ - protected array $collection = []; + protected array $attributes = []; /** * @throws Exception */ - public function setUp(): void - { - $this->collection = [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('movies'), - 'name' => 'movies', - 'attributes' => [ - new Document([ - '$id' => 'title', - 'key' => 'title', - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => 'price', - 'key' => 'price', - 'type' => Database::VAR_FLOAT, - 'size' => 5, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]) - ] + #[\Override] + protected function setUp(): void + { + $this->attributes = [ + new Document([ + '$id' => 'title', + 'key' => 'title', + 'type' => ColumnType::String->value, + 'size' => 256, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'size' => 5, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), ]; } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } /** * @throws Exception */ - public function testValidQueries(): void + public function test_valid_queries(): void { - $validator = new DocumentQueries($this->collection['attributes']); + $validator = new DocumentQueries($this->attributes, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); $queries = [ Query::select(['title']), @@ -75,10 +73,176 @@ public function testValidQueries(): void /** * @throws Exception */ - public function testInvalidQueries(): void + public function test_invalid_queries(): void { - $validator = new DocumentQueries($this->collection['attributes']); + $validator = new DocumentQueries($this->attributes, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); $queries = [Query::limit(1)]; $this->assertEquals(false, $validator->isValid($queries)); } + + public function testJoinIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(true, $validator->isValid([ + Query::join('orders', 'j0', [Query::on('$id', 'customerId')]), + ]), $validator->getDescription()); + } + + public function testSelectWithJoinAliasIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(true, $validator->isValid([ + Query::select(['ord.amount']), + Query::join('orders', 'ord', [Query::on('$id', 'customerId')]), + ]), $validator->getDescription()); + } + + public function testSelectUnprefixedJoinAttributeIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([ + Query::select(['score']), + Query::leftJoin('reviews', 'j0', [Query::on('$id', 'prod_uid')]), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: score', $validator->getDescription()); + } + + public function testSelectWithLeftJoinAliasIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(true, $validator->isValid([ + Query::select(['rev.score']), + Query::leftJoin('reviews', 'rev', [Query::on('$id', 'prod_uid')]), + ]), $validator->getDescription()); + } + + public function testCountIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([Query::count('*', 'cnt')])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testGroupByIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([Query::groupBy(['name'])])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testHavingIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([ + Query::having([Query::greaterThan('amount', 1)]), + ])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testDistinctIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([Query::distinct()])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testUnionIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([ + Query::union([Query::equal('name', ['x'])]), + ])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testUnionAllIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([ + Query::unionAll([Query::equal('name', ['x'])]), + ])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testNaturalJoinIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([Query::naturalJoin('orders', 'j0')])); + $this->assertStringContainsString('Natural joins are not supported', $validator->getDescription()); + } + + public function testNestedJoinOnIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(true, $validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnWithFilterIsValidWithoutFilterValidator(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(true, $validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', ['paid']), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnRequiresColumns(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(false, $validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('', 'customerId'), + ]), + ])); + $this->assertStringContainsString('Join ON requires left and right columns', $validator->getDescription()); + } + + public function testSelectWithNestedJoinAliasIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertSame(true, $validator->isValid([ + Query::select(['ord.amount']), + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + ]), + ]), $validator->getDescription()); + } + + /** + * @return array + */ + private function documentAttributes(): array + { + return [ + new Document([ + 'key' => 'name', + 'type' => ColumnType::String->value, + ]), + new Document([ + 'key' => 'amount', + 'type' => ColumnType::Integer->value, + ]), + ]; + } } diff --git a/tests/unit/Validator/DocumentsQueriesGrammarTest.php b/tests/unit/Validator/DocumentsQueriesGrammarTest.php new file mode 100644 index 0000000000..bb33a0b5bb --- /dev/null +++ b/tests/unit/Validator/DocumentsQueriesGrammarTest.php @@ -0,0 +1,130 @@ + + */ + private array $attributes; + + #[\Override] + protected function setUp(): void + { + $this->attributes = [ + new Document([ + '$id' => 'rating', + 'key' => 'rating', + 'type' => ColumnType::Integer->value, + 'size' => 5, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ]; + } + + /** + * @return array + */ + public static function joinQueries(): array + { + return [ + 'join' => [Query::join('orders', 'o', [Query::on('$id', 'customerId')]), 'join'], + 'left join' => [Query::leftJoin('orders', 'o', [Query::on('$id', 'customerId')]), 'leftJoin'], + 'right join' => [Query::rightJoin('orders', 'o', [Query::on('$id', 'customerId')]), 'rightJoin'], + 'cross join' => [Query::crossJoin('orders', 'o'), 'crossJoin'], + 'full outer join' => [Query::fullOuterJoin('orders', 'o', [Query::on('$id', 'customerId')]), 'fullOuterJoin'], + ]; + } + + /** + * @return array + */ + public static function aggregationQueries(): array + { + return [ + 'count' => [Query::count('*', 'total'), 'count'], + 'sum' => [Query::sum('rating', 'total'), 'sum'], + 'group by' => [Query::groupBy(['rating']), 'groupBy'], + 'having' => [Query::having([Query::greaterThan('rating', 1)]), 'having'], + 'distinct' => [Query::distinct(), 'distinct'], + ]; + } + + #[DataProvider('joinQueries')] + #[DataProvider('aggregationQueries')] + public function test_default_grammar_rejects_extended_methods(Query $query, string $method): void + { + $validator = new Documents( + $this->attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom]), + ); + + $this->assertFalse($validator->isValid([$query])); + $this->assertSame('Invalid query method: '.$method, $validator->getDescription()); + } + + #[DataProvider('joinQueries')] + public function test_joins_are_accepted_when_enabled(Query $query, string $method): void + { + $validator = new Documents( + $this->attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::OrderRandom]), + ); + + $this->assertTrue($validator->isValid([$query]), $method.': '.$validator->getDescription()); + } + + #[DataProvider('aggregationQueries')] + public function test_aggregations_are_accepted_when_enabled(Query $query, string $method): void + { + $validator = new Documents( + $this->attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Aggregations, Capability::OrderRandom]), + ); + + $queries = $method === 'having' ? [Query::groupBy(['rating']), $query] : [$query]; + $this->assertTrue($validator->isValid($queries), $method.': '.$validator->getDescription()); + } + + #[DataProvider('aggregationQueries')] + public function test_enabling_joins_does_not_enable_aggregations(Query $query, string $method): void + { + $validator = new Documents( + $this->attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::OrderRandom]), + ); + + $this->assertFalse($validator->isValid([$query])); + $this->assertSame('Invalid query method: '.$method, $validator->getDescription()); + } + + #[DataProvider('joinQueries')] + public function test_enabling_aggregations_does_not_enable_joins(Query $query, string $method): void + { + $validator = new Documents( + $this->attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Aggregations, Capability::OrderRandom]), + ); + + $this->assertFalse($validator->isValid([$query])); + $this->assertSame('Invalid query method: '.$method, $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/DocumentsQueriesTest.php b/tests/unit/Validator/DocumentsQueriesTest.php index 6530ad2999..7097474a8a 100644 --- a/tests/unit/Validator/DocumentsQueriesTest.php +++ b/tests/unit/Validator/DocumentsQueriesTest.php @@ -4,129 +4,134 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Tests\Unit\Support\Profiles; +use Utopia\Database\Capability; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; +use Utopia\Database\Id; use Utopia\Database\Query; use Utopia\Database\Validator\Queries\Documents; +use Utopia\Query\Schema\ColumnType; class DocumentsQueriesTest extends TestCase { /** - * @var array + * @var array */ - protected array $collection = []; + protected array $attributes = []; + + /** + * @var array + */ + protected array $indexes = []; /** * @throws Exception */ - public function setUp(): void + #[\Override] + protected function setUp(): void { - $this->collection = [ - '$id' => Database::METADATA, - '$collection' => Database::METADATA, - 'name' => 'movies', - 'attributes' => [ - new Document([ - '$id' => 'title', - 'key' => 'title', - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => 'description', - 'key' => 'description', - 'type' => Database::VAR_STRING, - 'size' => 1000000, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => 'rating', - 'key' => 'rating', - 'type' => Database::VAR_INTEGER, - 'size' => 5, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => 'price', - 'key' => 'price', - 'type' => Database::VAR_FLOAT, - 'size' => 5, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => 'is_bool', - 'key' => 'is_bool', - 'type' => Database::VAR_BOOLEAN, - 'size' => 0, - 'required' => false, - 'signed' => false, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => 'id', - 'key' => 'id', - 'type' => Database::VAR_ID, - 'size' => 0, - 'required' => false, - 'signed' => false, - 'array' => false, - 'filters' => [], - ]) - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('testindex2'), - 'type' => 'key', - 'attributes' => [ - 'title', - 'description', - 'price' - ], - 'orders' => [ - 'ASC', - 'DESC' - ], - ]), - new Document([ - '$id' => ID::custom('testindex3'), - 'type' => 'fulltext', - 'attributes' => [ - 'title' - ], - 'orders' => [] - ]), - ], + $this->attributes = [ + new Document([ + '$id' => 'title', + 'key' => 'title', + 'type' => ColumnType::String->value, + 'size' => 256, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'description', + 'key' => 'description', + 'type' => ColumnType::String->value, + 'size' => 1000000, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'rating', + 'key' => 'rating', + 'type' => ColumnType::Integer->value, + 'size' => 5, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'size' => 5, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'is_bool', + 'key' => 'is_bool', + 'type' => ColumnType::Boolean->value, + 'size' => 0, + 'required' => false, + 'signed' => false, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'id', + 'key' => 'id', + 'type' => ColumnType::Id->value, + 'size' => 0, + 'required' => false, + 'signed' => false, + 'array' => false, + 'filters' => [], + ]), + ]; + + $this->indexes = [ + new Document([ + '$id' => Id::custom('testindex2'), + 'type' => 'key', + 'attributes' => [ + 'title', + 'description', + 'price', + ], + 'orders' => [ + 'ASC', + 'DESC', + ], + ]), + new Document([ + '$id' => Id::custom('testindex3'), + 'type' => 'fulltext', + 'attributes' => [ + 'title', + ], + 'orders' => [], + ]), ]; } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } /** * @throws Exception */ - public function testValidQueries(): void + public function test_valid_queries(): void { $validator = new Documents( - $this->collection['attributes'], - $this->collection['indexes'], - Database::VAR_INTEGER + $this->attributes, + $this->indexes, + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom]), ); $queries = [ @@ -159,12 +164,12 @@ public function testValidQueries(): void /** * @throws Exception */ - public function testInvalidQueries(): void + public function test_invalid_queries(): void { $validator = new Documents( - $this->collection['attributes'], - $this->collection['indexes'], - Database::VAR_INTEGER + $this->attributes, + $this->indexes, + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom]), ); $queries = ['{"method":"notEqual","attribute":"title","values":["Iron Man","Ant Man"]}']; @@ -181,12 +186,74 @@ public function testInvalidQueries(): void $queries = [Query::limit(-1)]; $this->assertEquals(false, $validator->isValid($queries)); - $this->assertEquals('Invalid query: Invalid limit: Value must be a valid range between 1 and ' . number_format(PHP_INT_MAX), $validator->getDescription()); + $this->assertEquals('Invalid query: Invalid limit: Value must be a valid range between 1 and '.number_format(PHP_INT_MAX), $validator->getDescription()); $queries = [Query::equal('title', [])]; // empty array $this->assertEquals(false, $validator->isValid($queries)); $this->assertEquals('Invalid query: Equal queries require at least one value.', $validator->getDescription()); + } + + public function test_filter_with_join_alias_is_valid(): void + { + $validator = new Documents( + $this->attributes, + $this->indexes, + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::OrderRandom]), + ); + + $this->assertTrue($validator->isValid([ + Query::equal('sec.amount', [777]), + Query::join('orders', 'sec', [Query::on('$id', 'customerId')]), + ]), $validator->getDescription()); + } + + public function test_order_with_join_alias_is_valid(): void + { + $validator = new Documents( + $this->attributes, + $this->indexes, + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::OrderRandom]), + ); + + $this->assertTrue($validator->isValid([ + Query::orderAsc('sec.amount'), + Query::join('orders', 'sec', [Query::on('$id', 'customerId')]), + ]), $validator->getDescription()); + } + + public function test_unknown_join_alias_filter_is_invalid(): void + { + $validator = new Documents( + $this->attributes, + $this->indexes, + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::OrderRandom]), + ); + $this->assertFalse($validator->isValid([ + Query::equal('other.amount', [777]), + Query::join('orders', 'sec', [Query::on('$id', 'customerId')]), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: other', $validator->getDescription()); + } + + public function test_nested_and_or_join_alias_is_valid(): void + { + $validator = new Documents( + $this->attributes, + $this->indexes, + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::OrderRandom]), + ); + + $this->assertTrue($validator->isValid([ + Query::join('meta', 'meta', [Query::on('$id', 'mainId')]), + Query::and([ + Query::equal('title', ['Main']), + Query::or([ + Query::equal('meta.score', [10]), + Query::equal('rating', [2]), + ]), + ]), + ]), $validator->getDescription()); } } diff --git a/tests/unit/Validator/DocumentsQueriesUnsignedBigIntTest.php b/tests/unit/Validator/DocumentsQueriesUnsignedBigIntTest.php new file mode 100644 index 0000000000..924d7b2767 --- /dev/null +++ b/tests/unit/Validator/DocumentsQueriesUnsignedBigIntTest.php @@ -0,0 +1,72 @@ + + */ + private array $attributes; + + #[\Override] + protected function setUp(): void + { + $this->attributes = [ + new Document([ + '$id' => 'counter', + 'key' => 'counter', + 'type' => ColumnType::BigInteger->value, + 'size' => 0, + 'required' => false, + 'signed' => false, + 'array' => false, + 'filters' => [], + ]), + ]; + } + + public function test_documents_validator_accepts_unsigned_values_above_signed_max_by_default(): void + { + $validator = new Documents( + $this->attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom]), + ); + + $this->assertTrue($validator->isValid([Query::equal('counter', [self::ABOVE_SIGNED_MAX])]), $validator->getDescription()); + } + + public function test_filter_validator_accepts_unsigned_values_above_signed_max_by_default(): void + { + $validator = new Filter( + attributes: $this->attributes, + idAttributeType: ColumnType::Integer->value, + ); + + $this->assertTrue($validator->isValid(Query::equal('counter', [self::ABOVE_SIGNED_MAX])), $validator->getDescription()); + } + + public function test_adapters_without_unsigned_bigint_still_reject_values_above_signed_max(): void + { + $validator = new Documents( + $this->attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::OrderRandom]), + ); + + $this->assertFalse($validator->isValid([Query::equal('counter', [self::ABOVE_SIGNED_MAX])])); + $this->assertSame('Invalid query: Query value is invalid for attribute "counter"', $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/IndexFloatLengthTest.php b/tests/unit/Validator/IndexFloatLengthTest.php new file mode 100644 index 0000000000..00a78c78b0 --- /dev/null +++ b/tests/unit/Validator/IndexFloatLengthTest.php @@ -0,0 +1,96 @@ + + */ + public static function eightByteNumbers(): array + { + return [ + 'float' => [Attribute::float(key: 'score')], + 'double' => [Attribute::double(key: 'score')], + ]; + } + + #[DataProvider('eightByteNumbers')] + public function test_index_over_the_byte_limit_is_rejected(Attribute $number): void + { + $validator = new IndexDefinition( + [Attribute::string(key: 'title', size: 767), $number], + [], + Profiles::of(capabilities: self::CAPABILITIES, indexLength: self::MARIADB_MAX_INDEX_LENGTH), + ); + + $this->assertFalse($validator->isValid(Index::key(key: 'idx_title_score', attributes: ['title', 'score']))); + $this->assertSame('Index length is longer than the maximum: 768', $validator->getDescription()); + } + + #[DataProvider('eightByteNumbers')] + public function test_index_at_the_byte_limit_is_valid(Attribute $number): void + { + $validator = new IndexDefinition( + [Attribute::string(key: 'title', size: 766), $number], + [], + Profiles::of(capabilities: self::CAPABILITIES, indexLength: self::MARIADB_MAX_INDEX_LENGTH), + ); + + $this->assertTrue($validator->isValid(Index::key(key: 'idx_title_score', attributes: ['title', 'score'])), $validator->getDescription()); + } + + /** + * @return array + */ + public static function eightByteIntegers(): array + { + return [ + 'big integer' => [Attribute::bigInteger(key: 'score')], + 'id' => [Attribute::id(key: 'score')], + 'integer stored as a big integer' => [Attribute::integer(key: 'score', width: IntegerWidth::Bits64)], + ]; + } + + #[DataProvider('eightByteIntegers')] + public function testBigIntColumnsCountEightBytes(Attribute $number): void + { + $over = new IndexDefinition( + [Attribute::string(key: 'title', size: 767), $number], + [], + Profiles::of(capabilities: self::CAPABILITIES, indexLength: self::MARIADB_MAX_INDEX_LENGTH), + ); + $this->assertFalse($over->isValid(Index::key(key: 'idx_title_score', attributes: ['title', 'score']))); + $this->assertSame('Index length is longer than the maximum: 768', $over->getDescription()); + + $at = new IndexDefinition( + [Attribute::string(key: 'title', size: 766), $number], + [], + Profiles::of(capabilities: self::CAPABILITIES, indexLength: self::MARIADB_MAX_INDEX_LENGTH), + ); + $this->assertTrue($at->isValid(Index::key(key: 'idx_title_score', attributes: ['title', 'score'])), $at->getDescription()); + } +} diff --git a/tests/unit/Validator/IndexObjectPathTest.php b/tests/unit/Validator/IndexObjectPathTest.php new file mode 100644 index 0000000000..e4acf72724 --- /dev/null +++ b/tests/unit/Validator/IndexObjectPathTest.php @@ -0,0 +1,67 @@ +validator = new IndexValidator( + [ + Attribute::object(key: 'data'), + Attribute::string(key: 'title', size: 256), + ], + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::IndexFulltextMultiple, Capability::IndexIdentical, Capability::IndexObject, Capability::IndexTrigram, Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext, Capability::Objects], indexLength: 768), + ); + } + + public function test_fulltext_index_on_object_path_is_rejected(): void + { + $this->assertFalse($this->validator->isValid(Index::fulltext(key: 'idx_fulltext', attributes: ['data.title']))); + $this->assertSame('Attribute "data.title" cannot be part of a fulltext index, must be of type string', $this->validator->getDescription()); + } + + public function test_index_length_on_object_path_is_rejected(): void + { + $this->assertFalse($this->validator->isValid(Index::key(key: 'idx_key', attributes: ['data.title'], lengths: [128]))); + $this->assertSame('Cannot set a length on "" attributes', $this->validator->getDescription()); + } + + public function test_trigram_index_on_object_path_is_rejected(): void + { + $this->assertFalse($this->validator->isValid(Index::trigram(key: 'idx_trigram', attributes: ['data.title']))); + $this->assertSame('Trigram index can only be created on string type attributes', $this->validator->getDescription()); + } + + /** + * @return array + */ + public static function acceptedIndexes(): array + { + return [ + 'key on object path' => [Index::key(key: 'idx_key', attributes: ['data.title'])], + 'unique on object path' => [Index::unique(key: 'idx_unique', attributes: ['data.title'])], + 'fulltext on string' => [Index::fulltext(key: 'idx_fulltext', attributes: ['title'])], + 'length on string' => [Index::key(key: 'idx_length', attributes: ['title'], lengths: [128])], + 'trigram on string' => [Index::trigram(key: 'idx_trigram', attributes: ['title'])], + ]; + } + + #[DataProvider('acceptedIndexes')] + public function test_indexes_on_known_types_stay_valid(Index $index): void + { + $this->assertTrue($this->validator->isValid($index), $this->validator->getDescription()); + } +} diff --git a/tests/unit/Validator/IndexRevalidationTest.php b/tests/unit/Validator/IndexRevalidationTest.php new file mode 100644 index 0000000000..e2bdb65fbf --- /dev/null +++ b/tests/unit/Validator/IndexRevalidationTest.php @@ -0,0 +1,42 @@ +setDatabase('index_revalidation') + ->setNamespace('index_revalidation_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $this->assertFalse($database->getAdapter()->supports(Capability::IndexIdentical)); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 64)], + indexes: [Index::key(key: 'by_name', attributes: ['name'])], + )); + + $updated = $database->updateAttribute(self::COLLECTION, 'name', new AttributeUpdate(size: 128)); + + $this->assertSame(128, $updated->size); + $this->assertSame(128, $database->getCollection(self::COLLECTION)->attributes()[0]->size); + } +} diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 322973e543..c3e789dd55 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -4,55 +4,56 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Tests\Unit\Support\Profiles; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Validator\Index; +use Utopia\Database\Exception\Index as IndexException; +use Utopia\Database\Index; +use Utopia\Database\Validator\IndexDefinition; +use Utopia\Database\Validator\Queries\Indexed; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\IndexType; class IndexTest extends TestCase { - public function setUp(): void + /** + * What an index validator supported by default before it took a profile. + */ + private const array CAPABILITIES = [ + Capability::DefinedAttributes, + Capability::IndexFulltextMultiple, + Capability::IndexIdentical, + Capability::IndexKey, + Capability::IndexUnique, + Capability::IndexFulltext, + ]; + + #[\Override] + protected function setUp(): void { } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } /** * @throws Exception */ - public function testAttributeNotFound(): void + public function test_attribute_not_found(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]) - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['not_exist'], - 'lengths' => [], - 'orders' => [], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title'), + ]; + + $indexes = [ + Index::key(key: 'index1', attributes: ['not_exist']), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('Invalid index attribute "not_exist" not found', $validator->getDescription()); } @@ -60,48 +61,19 @@ public function testAttributeNotFound(): void /** * @throws Exception */ - public function testFulltextWithNonString(): void + public function test_fulltext_with_non_string(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('date'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]), - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['title', 'date'], - 'lengths' => [], - 'orders' => [], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title'), + Attribute::datetime(key: 'date'), + ]; + + $indexes = [ + Index::fulltext(key: 'index1', attributes: ['title', 'date']), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('Attribute "date" cannot be part of a fulltext index, must be of type string', $validator->getDescription()); } @@ -109,37 +81,18 @@ public function testFulltextWithNonString(): void /** * @throws Exception */ - public function testIndexLength(): void + public function test_index_length(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 769, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['title'], - 'lengths' => [], - 'orders' => [], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title', size: 769), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $indexes = [ + Index::key(key: 'index1', attributes: ['title']), + ]; + + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('Index length is longer than the maximum: 768', $validator->getDescription()); } @@ -147,93 +100,43 @@ public function testIndexLength(): void /** * @throws Exception */ - public function testMultipleIndexLength(): void + public function test_multiple_index_length(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('description'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 1024, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['title'], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title', size: 256), + Attribute::string(key: 'description', size: 1024), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $indexes = [ + Index::fulltext(key: 'index1', attributes: ['title']), + ]; + + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertTrue($validator->isValid($index)); - $index = new Document([ - '$id' => ID::custom('index2'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['title', 'description'], - ]); + $index2 = Index::key(key: 'index2', attributes: ['title', 'description']); - $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); - $this->assertFalse($validator->isValid($index)); + // Validator does not track new indexes added; just validate the new one + $this->assertFalse($validator->isValid($index2)); $this->assertEquals('Index length is longer than the maximum: 768', $validator->getDescription()); } /** * @throws Exception */ - public function testEmptyAttributes(): void + public function test_empty_attributes(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 769, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => [], - 'lengths' => [], - 'orders' => [], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title', size: 769), + ]; + + $indexes = [ + Index::fromArray(['key' => 'index1', 'type' => IndexType::Key]), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('No attributes provided for index', $validator->getDescription()); } @@ -241,86 +144,40 @@ public function testEmptyAttributes(): void /** * @throws Exception */ - public function testObjectIndexValidation(): void + public function test_object_index_validation(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('data'), - 'type' => Database::VAR_OBJECT, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]) - ], - 'indexes' => [] - ]); + $attributes = [ + Attribute::object(key: 'data', required: true), + Attribute::string(key: 'name'), + ]; + + /** @var array $emptyIndexes */ + $emptyIndexes = []; // Validator with supportForObjectIndexes enabled - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, supportForObjectIndexes:true); + $validator = new IndexDefinition($attributes, $emptyIndexes, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::IndexFulltextMultiple, Capability::IndexIdentical, Capability::IndexObject, Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext], indexLength: 768)); // Valid: Object index on single VAR_OBJECT attribute - $validIndex = new Document([ - '$id' => ID::custom('idx_gin_valid'), - 'type' => Database::INDEX_OBJECT, - 'attributes' => ['data'], - 'lengths' => [], - 'orders' => [], - ]); + $validIndex = Index::object(key: 'idx_gin_valid', attribute: 'data'); $this->assertTrue($validator->isValid($validIndex)); // Invalid: Object index on non-object attribute - $invalidIndexType = new Document([ - '$id' => ID::custom('idx_gin_invalid_type'), - 'type' => Database::INDEX_OBJECT, - 'attributes' => ['name'], - 'lengths' => [], - 'orders' => [], - ]); + $invalidIndexType = Index::object(key: 'idx_gin_invalid_type', attribute: 'name'); $this->assertFalse($validator->isValid($invalidIndexType)); $this->assertStringContainsString('Object index can only be created on object attributes', $validator->getDescription()); // Invalid: Object index on multiple attributes - $invalidIndexMulti = new Document([ - '$id' => ID::custom('idx_gin_multi'), - 'type' => Database::INDEX_OBJECT, - 'attributes' => ['data', 'name'], - 'lengths' => [], - 'orders' => [], - ]); + $invalidIndexMulti = Index::fromArray(['key' => 'idx_gin_multi', 'type' => IndexType::Object, 'attributes' => ['data', 'name']]); $this->assertFalse($validator->isValid($invalidIndexMulti)); $this->assertStringContainsString('Object index can be created on a single object attribute', $validator->getDescription()); // Invalid: Object index with orders - $invalidIndexOrder = new Document([ - '$id' => ID::custom('idx_gin_order'), - 'type' => Database::INDEX_OBJECT, - 'attributes' => ['data'], - 'lengths' => [], - 'orders' => ['asc'], - ]); + $invalidIndexOrder = Index::fromArray(['key' => 'idx_gin_order', 'type' => IndexType::Object, 'attributes' => ['data'], 'orders' => [OrderDirection::Asc]]); $this->assertFalse($validator->isValid($invalidIndexOrder)); $this->assertStringContainsString('Object index do not support explicit orders', $validator->getDescription()); // Validator with supportForObjectIndexes disabled should reject GIN - $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false); + $validatorNoSupport = new IndexDefinition($attributes, $emptyIndexes, Profiles::of(capabilities: [Capability::IndexFulltextMultiple, Capability::IndexIdentical, Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext], indexLength: 768)); $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('Object indexes are not supported', $validatorNoSupport->getDescription()); } @@ -328,150 +185,63 @@ public function testObjectIndexValidation(): void /** * @throws Exception */ - public function testNestedObjectPathIndexValidation(): void + public function test_nested_object_path_index_validation(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('data'), - 'type' => Database::VAR_OBJECT, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('metadata'), - 'type' => Database::VAR_OBJECT, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]) - ], - 'indexes' => [] - ]); + $attributes = [ + Attribute::object(key: 'data', required: true), + Attribute::object(key: 'metadata'), + Attribute::string(key: 'name'), + ]; + + /** @var array $emptyIndexes */ + $emptyIndexes = []; // Validator with supportForObjectIndexes enabled - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, true, true, true, true, supportForObjects:true); + $validator = new IndexDefinition($attributes, $emptyIndexes, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::IndexFulltextMultiple, Capability::IndexIdentical, Capability::IndexObject, Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext, Capability::Objects], indexLength: 768)); // InValid: INDEX_OBJECT on nested path (dot notation) - $validNestedObjectIndex = new Document([ - '$id' => ID::custom('idx_nested_object'), - 'type' => Database::INDEX_OBJECT, - 'attributes' => ['data.key.nestedKey'], - 'lengths' => [], - 'orders' => [], - ]); + $validNestedObjectIndex = Index::object(key: 'idx_nested_object', attribute: 'data.key.nestedKey'); $this->assertFalse($validator->isValid($validNestedObjectIndex)); // Valid: INDEX_UNIQUE on nested path (for Postgres/Mongo) - $validNestedUniqueIndex = new Document([ - '$id' => ID::custom('idx_nested_unique'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['data.key.nestedKey'], - 'lengths' => [], - 'orders' => [], - ]); + $validNestedUniqueIndex = Index::unique(key: 'idx_nested_unique', attributes: ['data.key.nestedKey']); $this->assertTrue($validator->isValid($validNestedUniqueIndex)); // Valid: INDEX_KEY on nested path - $validNestedKeyIndex = new Document([ - '$id' => ID::custom('idx_nested_key'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['metadata.user.id'], - 'lengths' => [], - 'orders' => [], - ]); + $validNestedKeyIndex = Index::key(key: 'idx_nested_key', attributes: ['metadata.user.id']); $this->assertTrue($validator->isValid($validNestedKeyIndex)); // Invalid: Nested path on non-object attribute - $invalidNestedPath = new Document([ - '$id' => ID::custom('idx_invalid_nested'), - 'type' => Database::INDEX_OBJECT, - 'attributes' => ['name.key'], - 'lengths' => [], - 'orders' => [], - ]); + $invalidNestedPath = Index::object(key: 'idx_invalid_nested', attribute: 'name.key'); $this->assertFalse($validator->isValid($invalidNestedPath)); $this->assertStringContainsString('Index attribute "name.key" is only supported on object attributes', $validator->getDescription()); // Invalid: Nested path with non-existent base attribute - $invalidBaseAttribute = new Document([ - '$id' => ID::custom('idx_invalid_base'), - 'type' => Database::INDEX_OBJECT, - 'attributes' => ['nonexistent.key'], - 'lengths' => [], - 'orders' => [], - ]); + $invalidBaseAttribute = Index::object(key: 'idx_invalid_base', attribute: 'nonexistent.key'); $this->assertFalse($validator->isValid($invalidBaseAttribute)); $this->assertStringContainsString('Invalid index attribute', $validator->getDescription()); // Valid: Multiple nested paths in same index - $validMultiNested = new Document([ - '$id' => ID::custom('idx_multi_nested'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['data.key1', 'data.key2'], - 'lengths' => [], - 'orders' => [], - ]); + $validMultiNested = Index::key(key: 'idx_multi_nested', attributes: ['data.key1', 'data.key2']); $this->assertTrue($validator->isValid($validMultiNested)); } /** * @throws Exception */ - public function testDuplicatedAttributes(): void + public function test_duplicated_attributes(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]) - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['title', 'title'], - 'lengths' => [], - 'orders' => [], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title'), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $indexes = [ + Index::fulltext(key: 'index1', attributes: ['title', 'title']), + ]; + + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('Duplicate attributes provided', $validator->getDescription()); } @@ -479,233 +249,108 @@ public function testDuplicatedAttributes(): void /** * @throws Exception */ - public function testDuplicatedAttributesDifferentOrder(): void + public function test_duplicated_attributes_different_order(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]) - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['title', 'title'], - 'lengths' => [], - 'orders' => ['asc', 'desc'], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title'), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $indexes = [ + Index::fulltext(key: 'index1', attributes: ['title', 'title']), + ]; + + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); } /** * @throws Exception */ - public function testReservedIndexKey(): void + public function test_reserved_index_key(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]) - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('primary'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['title'], - 'lengths' => [], - 'orders' => [], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title'), + ]; + + $indexes = [ + Index::fulltext(key: 'primary', attributes: ['title']), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768, ['PRIMARY']); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768, internalIndexKeys: ['PRIMARY'])); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); } /** * @throws Exception - */ - public function testIndexWithNoAttributeSupport(): void + */ + public function test_index_with_no_attribute_support(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('title'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 769, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], - 'indexes' => [ - new Document([ - '$id' => ID::custom('index1'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['new'], - 'lengths' => [], - 'orders' => [], - ]), - ], - ]); + $attributes = [ + Attribute::string(key: 'title', size: 769), + ]; + + $indexes = [ + Index::key(key: 'index1', attributes: ['new']), + ]; - $validator = new Index(attributes: $collection->getAttribute('attributes'), indexes: $collection->getAttribute('indexes'), maxLength: 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); - $validator = new Index(attributes: $collection->getAttribute('attributes'), indexes: $collection->getAttribute('indexes'), maxLength: 768, supportForAttributes: false); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexDefinition($attributes, $indexes, Profiles::of(capabilities: [Capability::IndexFulltextMultiple, Capability::IndexIdentical, Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext], indexLength: 768)); + $index = $indexes[0]; $this->assertTrue($validator->isValid($index)); } /** * @throws Exception */ - public function testTrigramIndexValidation(): void + public function test_trigram_index_validation(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('description'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - new Document([ - '$id' => ID::custom('age'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], - 'indexes' => [] - ]); + $attributes = [ + Attribute::string(key: 'name'), + Attribute::string(key: 'description', size: 512), + Attribute::integer(key: 'age'), + ]; + + /** @var array $emptyIndexes */ + $emptyIndexes = []; // Validator with supportForTrigramIndexes enabled - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, supportForTrigramIndexes: true); + $validator = new IndexDefinition($attributes, $emptyIndexes, Profiles::of(capabilities: [Capability::IndexTrigram, Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext], indexLength: 768)); // Valid: Trigram index on single VAR_STRING attribute - $validIndex = new Document([ - '$id' => ID::custom('idx_trigram_valid'), - 'type' => Database::INDEX_TRIGRAM, - 'attributes' => ['name'], - 'lengths' => [], - 'orders' => [], - ]); + $validIndex = Index::trigram(key: 'idx_trigram_valid', attributes: ['name']); $this->assertTrue($validator->isValid($validIndex)); // Valid: Trigram index on multiple string attributes - $validIndexMulti = new Document([ - '$id' => ID::custom('idx_trigram_multi_valid'), - 'type' => Database::INDEX_TRIGRAM, - 'attributes' => ['name', 'description'], - 'lengths' => [], - 'orders' => [], - ]); + $validIndexMulti = Index::trigram(key: 'idx_trigram_multi_valid', attributes: ['name', 'description']); $this->assertTrue($validator->isValid($validIndexMulti)); // Invalid: Trigram index on non-string attribute - $invalidIndexType = new Document([ - '$id' => ID::custom('idx_trigram_invalid_type'), - 'type' => Database::INDEX_TRIGRAM, - 'attributes' => ['age'], - 'lengths' => [], - 'orders' => [], - ]); + $invalidIndexType = Index::trigram(key: 'idx_trigram_invalid_type', attributes: ['age']); $this->assertFalse($validator->isValid($invalidIndexType)); $this->assertStringContainsString('Trigram index can only be created on string type attributes', $validator->getDescription()); // Invalid: Trigram index with mixed string and non-string attributes - $invalidIndexMixed = new Document([ - '$id' => ID::custom('idx_trigram_mixed'), - 'type' => Database::INDEX_TRIGRAM, - 'attributes' => ['name', 'age'], - 'lengths' => [], - 'orders' => [], - ]); + $invalidIndexMixed = Index::trigram(key: 'idx_trigram_mixed', attributes: ['name', 'age']); $this->assertFalse($validator->isValid($invalidIndexMixed)); $this->assertStringContainsString('Trigram index can only be created on string type attributes', $validator->getDescription()); // Invalid: Trigram index with orders - $invalidIndexOrder = new Document([ - '$id' => ID::custom('idx_trigram_order'), - 'type' => Database::INDEX_TRIGRAM, - 'attributes' => ['name'], - 'lengths' => [], - 'orders' => ['asc'], - ]); + $invalidIndexOrder = Index::fromArray(['key' => 'idx_trigram_order', 'type' => IndexType::Trigram, 'attributes' => ['name'], 'orders' => [OrderDirection::Asc]]); $this->assertFalse($validator->isValid($invalidIndexOrder)); $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $validator->getDescription()); // Invalid: Trigram index with lengths - $invalidIndexLength = new Document([ - '$id' => ID::custom('idx_trigram_length'), - 'type' => Database::INDEX_TRIGRAM, - 'attributes' => ['name'], - 'lengths' => [128], - 'orders' => [], - ]); + $invalidIndexLength = Index::fromArray(['key' => 'idx_trigram_length', 'type' => IndexType::Trigram, 'attributes' => ['name'], 'lengths' => [128]]); $this->assertFalse($validator->isValid($invalidIndexLength)); $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $validator->getDescription()); // Validator with supportForTrigramIndexes disabled should reject trigram - $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, false); + $validatorNoSupport = new IndexDefinition($attributes, $emptyIndexes, Profiles::of(capabilities: [Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext], indexLength: 768)); $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('Trigram indexes are not supported', $validatorNoSupport->getDescription()); } @@ -713,167 +358,184 @@ public function testTrigramIndexValidation(): void /** * @throws Exception */ - public function testTTLIndexValidation(): void + public function test_ttl_index_validation(): void { - $collection = new Document([ - '$id' => ID::custom('test'), - 'name' => 'test', - 'attributes' => [ - new Document([ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]), - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 255, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ]), - ], - 'indexes' => [] - ]); + $attributes = [ + Attribute::datetime(key: 'expiresAt'), + Attribute::string(key: 'name'), + ]; + + /** @var array $emptyIndexes */ + $emptyIndexes = []; // Validator with supportForTTLIndexes enabled - $validator = new Index( - $collection->getAttribute('attributes'), - $collection->getAttribute('indexes', []), - 768, - [], - false, // supportForArrayIndexes - false, // supportForSpatialIndexNull - false, // supportForSpatialIndexOrder - false, // supportForVectorIndexes - true, // supportForAttributes - true, // supportForMultipleFulltextIndexes - true, // supportForIdenticalIndexes - false, // supportForObjectIndexes - false, // supportForTrigramIndexes - false, // supportForSpatialIndexes - true, // supportForKeyIndexes - true, // supportForUniqueIndexes - true, // supportForFulltextIndexes - true // supportForTTLIndexes + $validator = new IndexDefinition( + $attributes, + $emptyIndexes, + Profiles::of(capabilities: [...self::CAPABILITIES, Capability::IndexTtl], indexLength: 768), ); // Valid: TTL index on single datetime attribute with valid TTL - $validIndex = new Document([ - '$id' => ID::custom('idx_ttl_valid'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 3600, - ]); + $validIndex = Index::ttl(key: 'idx_ttl_valid', attribute: 'expiresAt', ttl: 3600); $this->assertTrue($validator->isValid($validIndex)); - // Invalid: TTL index with ttl = 1 - $invalidIndexZero = new Document([ - '$id' => ID::custom('idx_ttl_zero'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 0, - ]); - $this->assertFalse($validator->isValid($invalidIndexZero)); - $this->assertEquals('TTL must be at least 1 second', $validator->getDescription()); + // Invalid: TTL index with ttl = 0 + $this->assertTtlRefused(0); // Invalid: TTL index with TTL < 0 - $invalidIndexNegative = new Document([ - '$id' => ID::custom('idx_ttl_negative'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => -100, - ]); - $this->assertFalse($validator->isValid($invalidIndexNegative)); - $this->assertEquals('TTL must be at least 1 second', $validator->getDescription()); + $this->assertTtlRefused(-100); + + // Invalid: stored TTL index without a TTL + $this->assertFalse($validator->isValid(new Document(['$id' => 'idx_ttl_missing', 'type' => IndexType::Ttl->value, 'attributes' => ['expiresAt']]))); + $this->assertSame('TTL must be at least 1 second', $validator->getDescription()); // Invalid: TTL index on non-datetime attribute - $invalidIndexType = new Document([ - '$id' => ID::custom('idx_ttl_invalid_type'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['name'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 3600, - ]); + $invalidIndexType = Index::ttl(key: 'idx_ttl_invalid_type', attribute: 'name', ttl: 3600); $this->assertFalse($validator->isValid($invalidIndexType)); $this->assertStringContainsString('TTL index can only be created on datetime attributes', $validator->getDescription()); // Invalid: TTL index on multiple attributes - $invalidIndexMulti = new Document([ - '$id' => ID::custom('idx_ttl_multi'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt', 'name'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], - 'ttl' => 3600, - ]); + $invalidIndexMulti = Index::fromArray(['key' => 'idx_ttl_multi', 'type' => IndexType::Ttl, 'attributes' => ['expiresAt', 'name'], 'orders' => [OrderDirection::Asc, OrderDirection::Asc], 'ttl' => 3600]); $this->assertFalse($validator->isValid($invalidIndexMulti)); $this->assertStringContainsString('TTL indexes must be created on a single datetime attribute', $validator->getDescription()); // Valid: TTL index with minimum valid TTL (1 second) - $validIndexMin = new Document([ - '$id' => ID::custom('idx_ttl_min'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 1, - ]); + $validIndexMin = Index::ttl(key: 'idx_ttl_min', attribute: 'expiresAt', ttl: 1); $this->assertTrue($validator->isValid($validIndexMin)); // Invalid: any additional TTL index when another TTL index already exists - $collection->setAttribute('indexes', $validIndex, Document::SET_TYPE_APPEND); - $validatorWithExisting = new Index( - $collection->getAttribute('attributes'), - $collection->getAttribute('indexes', []), - 768, - [], - false, // supportForArrayIndexes - false, // supportForSpatialIndexNull - false, // supportForSpatialIndexOrder - false, // supportForVectorIndexes - true, // supportForAttributes - true, // supportForMultipleFulltextIndexes - true, // supportForIdenticalIndexes - false, // supportForObjectIndexes - false, // supportForTrigramIndexes - false, // supportForSpatialIndexes - true, // supportForKeyIndexes - true, // supportForUniqueIndexes - true, // supportForFulltextIndexes - true // supportForTTLIndexes + $indexesWithTTL = [$validIndex]; + $validatorWithExisting = new IndexDefinition( + $attributes, + $indexesWithTTL, + Profiles::of(capabilities: [...self::CAPABILITIES, Capability::IndexTtl], indexLength: 768), ); - $duplicateTTLIndex = new Document([ - '$id' => ID::custom('idx_ttl_duplicate'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200, - ]); + $duplicateTTLIndex = Index::ttl(key: 'idx_ttl_duplicate', attribute: 'expiresAt', ttl: 7200); $this->assertFalse($validatorWithExisting->isValid($duplicateTTLIndex)); $this->assertEquals('There can be only one TTL index in a collection', $validatorWithExisting->getDescription()); - // Validator with supportForTrigramIndexes disabled should reject TTL - $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, false); + // Validator with supportForTTLIndexes disabled should reject TTL + $validatorNoSupport = new IndexDefinition($attributes, $indexesWithTTL, Profiles::of(capabilities: [Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext], indexLength: 768)); $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('TTL indexes are not supported', $validatorNoSupport->getDescription()); } + + public function testIndexWithoutATypeIsRejected(): void + { + $validator = new IndexDefinition([Attribute::string(key: 'title', size: 64)], [], Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + + $this->assertFalse($validator->isValid(new Document([ + Document::ID => 'by_title', + 'attributes' => ['title'], + ]))); + $this->assertStringStartsWith('Unknown index type: . Must be one of ', $validator->getDescription()); + } + + public function testTtlIndexWithoutATtlIsRejected(): void + { + $validator = new IndexDefinition( + [Attribute::datetime(key: 'expiresAt')], + [], + Profiles::of(capabilities: [...self::CAPABILITIES, Capability::IndexTtl], indexLength: 768), + ); + + $this->assertFalse($validator->isValid(new Document([ + Document::ID => 'expiry', + 'type' => IndexType::Ttl->value, + 'attributes' => ['expiresAt'], + ]))); + $this->assertSame('TTL must be at least 1 second', $validator->getDescription()); + } + + public function testUnknownIndexTypeIsAValidationFailure(): void + { + $validator = new IndexDefinition([Attribute::string(key: 'title', size: 64)], [], Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + + $this->assertFalse($validator->isValid(new Document([ + Document::ID => 'by_title', + 'type' => 'bogus', + 'attributes' => ['title'], + ]))); + $this->assertStringStartsWith('Unknown index type: bogus. Must be one of ', $validator->getDescription()); + } + + public function testStoredIndexOfAnUnknownTypeIsReadLeniently(): void + { + $stored = new Document([ + Document::ID => 'by_title', + 'type' => 'bogus', + 'attributes' => ['title'], + ]); + + $index = Index::fromDocument($stored); + + $this->assertSame(IndexType::Key, $index->type); + $this->assertSame(['title'], $index->attributes); + $this->assertTrue((new Indexed([Attribute::string(key: 'title', size: 64)], [$stored]))->isValid([])); + } + + public function testTextAttributeWithoutASizeIsJudgedAgainstTheTextMaximum(): void + { + $validator = new IndexDefinition([Attribute::text(key: 'body')], [], Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + + $this->assertTrue($validator->isValid(Index::key(key: 'by_body', attributes: ['body'], lengths: [100])), $validator->getDescription()); + + $this->assertFalse($validator->isValid(Index::key(key: 'by_body', attributes: ['body']))); + $this->assertSame('Index length is longer than the maximum: 768', $validator->getDescription()); + } + + public function testKeyAndUniqueIndexesAreRejectedWithoutAdapterSupport(): void + { + $validator = new IndexDefinition( + [Attribute::string(key: 'title', size: 64)], + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::IndexFulltextMultiple, Capability::IndexIdentical, Capability::IndexFulltext], indexLength: 768), + ); + $key = Index::key(key: 'by_title', attributes: ['title']); + $unique = Index::unique(key: 'by_title', attributes: ['title']); + + $this->assertFalse($validator->isValid($key)); + $this->assertSame('Key index is not supported', $validator->getDescription()); + $this->assertFalse($validator->checkKeyUniqueFulltextSupport($key)); + $this->assertSame('Key index is not supported', $validator->getDescription()); + + $this->assertFalse($validator->isValid($unique)); + $this->assertSame('Unique index is not supported', $validator->getDescription()); + $this->assertFalse($validator->checkKeyUniqueFulltextSupport($unique)); + $this->assertSame('Unique index is not supported', $validator->getDescription()); + } + + public function testStoredLegacyIndexTypeIsValidatedAsAKeyIndex(): void + { + $validator = new IndexDefinition([Attribute::string(key: 'title', size: 64)], [], Profiles::of(capabilities: self::CAPABILITIES, indexLength: 768)); + $stored = new Document([Document::ID => 'by_title', 'type' => IndexType::Index->value, 'attributes' => ['title']]); + + $this->assertSame(IndexType::Key, Index::fromDocument($stored)->type); + $this->assertTrue($validator->isValid($stored), $validator->getDescription()); + } + + public function testOrderOnAnArrayAttributeIsRejected(): void + { + $validator = new IndexDefinition( + [Attribute::string(key: 'tags', size: 64, array: true)], + [], + Profiles::of(capabilities: [Capability::IndexArray, Capability::DefinedAttributes, Capability::IndexFulltextMultiple, Capability::IndexIdentical, Capability::IndexKey, Capability::IndexUnique, Capability::IndexFulltext], indexLength: 768), + ); + + $this->assertFalse($validator->isValid(Index::key(key: 'by_tags', attributes: ['tags'], lengths: [64], orders: [OrderDirection::Asc]))); + $this->assertSame('Invalid index order "'.OrderDirection::Asc->value.'" on array attribute "tags"', $validator->getDescription()); + + $this->assertTrue($validator->isValid(Index::key(key: 'by_tags', attributes: ['tags'], lengths: [64])), $validator->getDescription()); + } + + private function assertTtlRefused(int $ttl): void + { + try { + Index::fromArray(['key' => 'idx_ttl', 'type' => IndexType::Ttl, 'attributes' => ['expiresAt'], 'ttl' => $ttl]); + $this->fail('A TTL index with a TTL of '.$ttl.' must be refused'); + } catch (IndexException $error) { + $this->assertSame('TTL must be at least 1 second', $error->getMessage()); + } + } } diff --git a/tests/unit/Validator/IndexedQueriesTest.php b/tests/unit/Validator/IndexedQueriesTest.php deleted file mode 100644 index 409fcf3653..0000000000 --- a/tests/unit/Validator/IndexedQueriesTest.php +++ /dev/null @@ -1,219 +0,0 @@ -assertEquals(true, $validator->isValid([])); - } - - public function testInvalidQuery(): void - { - $validator = new IndexedQueries(); - - $this->assertEquals(false, $validator->isValid(["this.is.invalid"])); - } - - public function testInvalidMethod(): void - { - $validator = new IndexedQueries(); - $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); - - $validator = new IndexedQueries([], [], [new Limit()]); - $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); - } - - public function testInvalidValue(): void - { - $validator = new IndexedQueries([], [], [new Limit()]); - $this->assertEquals(false, $validator->isValid(['limit(-1)'])); - } - - public function testValid(): void - { - $attributes = [ - new Document([ - '$id' => 'name', - 'key' => 'name', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - ]; - - $indexes = [ - new Document([ - 'type' => Database::INDEX_KEY, - 'attributes' => ['name'], - ]), - new Document([ - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['name'], - ]), - ]; - - $validator = new IndexedQueries( - $attributes, - $indexes, - [ - new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), - new Limit(), - new Offset(), - new Order($attributes) - ] - ); - - $query = Query::cursorAfter(new Document(['$id' => 'abc'])); - $this->assertEquals(true, $validator->isValid([$query])); - $query = Query::parse('{"method":"cursorAfter","attribute":"","values":["abc"]}'); - $this->assertEquals(true, $validator->isValid([$query])); - - $query = Query::parse('{"method":"cursorAfter","values":["abc"]}'); // No attribute required - $this->assertEquals(true, $validator->isValid([$query])); - - $query = Query::equal('name', ['value']); - $this->assertEquals(true, $validator->isValid([$query])); - $query = Query::parse('{"method":"equal","attribute":"name","values":["value"]}'); - $this->assertEquals(true, $validator->isValid([$query])); - - $query = Query::limit(10); - $this->assertEquals(true, $validator->isValid([$query])); - $query = Query::parse('{"method":"limit","values":[10]}'); - $this->assertEquals(true, $validator->isValid([$query])); - - $query = Query::offset(10); - $this->assertEquals(true, $validator->isValid([$query])); - $query = Query::parse('{"method":"offset","values":[10]}'); - $this->assertEquals(true, $validator->isValid([$query])); - - $query = Query::orderAsc('name'); - $this->assertEquals(true, $validator->isValid([$query])); - $query = Query::parse('{"method":"orderAsc","attribute":"name"}'); // No values required - $this->assertEquals(true, $validator->isValid([$query])); - - $query = Query::search('name', 'value'); - $this->assertEquals(true, $validator->isValid([$query])); - $query = Query::parse('{"method":"search","attribute":"name","values":["value"]}'); - $this->assertEquals(true, $validator->isValid([$query])); - } - - public function testMissingIndex(): void - { - $attributes = [ - new Document([ - 'key' => 'name', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - ]; - - $indexes = [ - new Document([ - 'type' => Database::INDEX_KEY, - 'attributes' => ['name'], - ]), - ]; - - $validator = new IndexedQueries( - $attributes, - $indexes, - [ - new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), - new Limit(), - new Offset(), - new Order($attributes) - ] - ); - - $query = Query::equal('dne', ['value']); - $this->assertEquals(false, $validator->isValid([$query])); - $this->assertEquals('Invalid query: Attribute not found in schema: dne', $validator->getDescription()); - - $query = Query::orderAsc('dne'); - $this->assertEquals(false, $validator->isValid([$query])); - $this->assertEquals('Invalid query: Attribute not found in schema: dne', $validator->getDescription()); - - $query = Query::search('dne', 'phrase'); - $this->assertEquals(false, $validator->isValid([$query])); - $this->assertEquals('Invalid query: Attribute not found in schema: dne', $validator->getDescription()); - - $query = Query::search('name', 'phrase'); - $this->assertEquals(false, $validator->isValid([$query])); - $this->assertEquals('Searching by attribute "name" requires a fulltext index.', $validator->getDescription()); - } - - public function testTwoAttributesFulltext(): void - { - $attributes = [ - new Document([ - '$id' => 'ft1', - 'key' => 'ft1', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - new Document([ - '$id' => 'ft2', - 'key' => 'ft2', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - ]; - - $indexes = [ - new Document([ - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['ft1','ft2'], - ]), - ]; - - $validator = new IndexedQueries( - $attributes, - $indexes, - [ - new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), - new Limit(), - new Offset(), - new Order($attributes) - ] - ); - - $this->assertEquals(false, $validator->isValid([Query::search('ft1', 'value')])); - } - - - public function testJsonParse(): void - { - try { - Query::parse('{"method":"equal","attribute":"name","values":["value"]'); // broken Json; - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Invalid query: Syntax error', $e->getMessage()); - } - } -} diff --git a/tests/unit/Validator/JoinConditionColumnsTest.php b/tests/unit/Validator/JoinConditionColumnsTest.php new file mode 100644 index 0000000000..31f30b1f60 --- /dev/null +++ b/tests/unit/Validator/JoinConditionColumnsTest.php @@ -0,0 +1,360 @@ +}> + */ + public static function joinsOverColumnsTheTablesHave(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + yield 'main $id to a joined attribute' => [[$note]]; + yield 'an on condition under the join alias' => [[Query::leftJoin('notes', 'note', [Query::on('$id', 'note.customerId'), Query::on('$createdAt', '$updatedAt', '<')])]]; + yield 'a join after the join it names' => [[$note, Query::join('replies', 'reply', [Query::on('note.$id', 'noteId')])]]; + yield 'a join after a cross join it names' => [[Query::crossJoin('replies', 'reply'), Query::rightJoin('notes', 'note', [Query::on('reply.noteId', '$id')])]]; + yield 'internal attributes on both sides' => [[Query::join('notes', 'note', [Query::on('$sequence', '$sequence', '<')])]]; + yield 'a relationship that holds a column on the left' => [[Query::join('libraries', 'lib', [Query::on('library', '$id')])]]; + yield 'a relationship that holds a column on the right' => [[Query::leftJoin('books', 'book', [Query::on('$id', 'owner')])]]; + yield 'a join without an alias' => [[Query::join('notes', 'j0', [Query::on('$id', 'customerId')])]]; + } + + /** + * @param list $joins + */ + #[DataProvider('joinsOverColumnsTheTablesHave')] + public function testJoinOverColumnsTheTablesHaveIsValid(array $joins): void + { + foreach ($this->validators() as $label => $validator) { + $this->assertTrue($validator->isValid($joins), $label.': '.$validator->getDescription()); + } + } + + /** + * @return iterable, string}> + */ + public static function joinsNamingNoColumn(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + $notFound = 'Invalid query: Attribute not found in schema: '; + $left = 'Invalid query: The left column of a join condition must belong to the main collection or to a join declared before it: '; + $right = 'Invalid query: The right column of a join condition must belong to the joined collection: '; + + yield 'an unknown left column' => [[Query::join('notes', 'note', [Query::on('nothing', 'customerId')])], $notFound.'nothing']; + yield 'an unknown right column' => [[Query::join('notes', 'note', [Query::on('$id', 'nothing')])], $notFound.'nothing']; + yield 'an unknown left column of an on condition' => [[Query::leftJoin('notes', 'note', [Query::on('nothing', 'customerId')])], $notFound.'nothing']; + yield 'an unknown right column of an on condition' => [[Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::on('$id', 'note.nothing')])], $notFound.'note.nothing']; + yield 'an unknown column of an earlier join' => [[$note, Query::join('replies', 'reply', [Query::on('note.nothing', 'noteId')])], $notFound.'note.nothing']; + yield 'an internal attribute a filter cannot compare' => [[Query::join('notes', 'note', [Query::on('$permissions', 'customerId')])], $notFound.'$permissions']; + yield 'a derived internal attribute' => [[Query::join('notes', 'note', [Query::on('$id', '$collection')])], $notFound.'$collection']; + yield 'a relationship without a column on the left' => [[Query::join('books', 'book', [Query::on('books', '$id')])], 'Invalid query: Cannot join on virtual relationship attribute: books']; + yield 'a relationship without a column on the right' => [[Query::join('libraries', 'lib', [Query::on('$id', 'person')])], 'Invalid query: Cannot join on virtual relationship attribute: person']; + yield 'a join declared after it' => [[Query::join('replies', 'reply', [Query::on('note.$id', 'noteId')]), $note], $left.'note.$id']; + yield 'its own alias on the left' => [[Query::join('notes', 'note', [Query::on('note.customerId', '$id')])], $left.'note.customerId']; + yield 'an alias no join declares on the left' => [[Query::join('notes', 'note', [Query::on('other.$id', 'customerId')])], $left.'other.$id']; + yield 'the main alias on the left' => [[Query::join('notes', 'note', [Query::on(Query::DEFAULT_ALIAS.'.$id', 'customerId')])], $left.Query::DEFAULT_ALIAS.'.$id']; + yield 'an earlier join on the right' => [[$note, Query::join('replies', 'reply', [Query::on('$id', 'note.$id')])], $right.'note.$id']; + yield 'the main alias on the right' => [[Query::join('notes', 'note', [Query::on('$id', Query::DEFAULT_ALIAS.'.name')])], $right.Query::DEFAULT_ALIAS.'.name']; + yield 'no right column' => [[Query::join('notes', 'note', [Query::on('$id', '')])], 'Invalid query: Join ON requires left and right columns']; + yield 'a right column that is not a string' => [[Query::leftJoin('notes', 'note', [new Query(Method::On, '', ['$id', '=', 5])])], 'Invalid query: Join ON requires left and right columns']; + yield 'an operator no engine compares with' => [[Query::join('notes', 'note', [Query::on('$id', 'customerId', '~')])], 'Invalid query: Invalid join operator: ~']; + } + + /** + * @param list $joins + */ + #[DataProvider('joinsNamingNoColumn')] + public function testJoinNamingNoColumnIsInvalid(array $joins, string $message): void + { + foreach ($this->validators() as $label => $validator) { + $this->assertFalse($validator->isValid($joins), $label.': the join was accepted'); + $this->assertSame($message, $validator->getDescription(), $label); + } + } + + public function testColumnsOfACollectionTheValidatorDoesNotKnowAreNotChecked(): void + { + $validator = new Base([new Join()]); + + $this->assertTrue($validator->isValid([Query::join('orders', 'j0', [Query::on('user_id', 'id')])]), $validator->getDescription()); + $this->assertTrue($validator->isValid([Query::join('orders', 'ord', [Query::on('user_id', 'id')]), Query::join('items', 'item', [Query::on('ord.anything', 'orderId')])]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::join('items', 'item', [Query::on('ord.anything', 'orderId')]), Query::join('orders', 'ord', [Query::on('user_id', 'id')])]), 'the order of the joins is still checked'); + $this->assertSame('Invalid query: The left column of a join condition must belong to the main collection or to a join declared before it: ord.anything', $validator->getDescription()); + } + + public function testSchemalessJoinsCheckOnlyWhichTablesAColumnBelongsTo(): void + { + $validator = new Base([new Join($this->customers(), supportForAttributes: false)]); + $validator->setJoinedCollections($this->collections()); + + $this->assertTrue($validator->isValid([Query::join('notes', 'note', [Query::on('anything', 'whatever')])]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::join('notes', 'note', [Query::on('reply.anything', 'whatever')])])); + $this->assertSame('Invalid query: The left column of a join condition must belong to the main collection or to a join declared before it: reply.anything', $validator->getDescription()); + } + + public function testDocumentQueriesCheckTheColumnsOfAJoinCondition(): void + { + $validator = new DocumentQueries($this->customers(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + $validator->setJoinedCollections($this->collections()); + + $this->assertTrue($validator->isValid([Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')])]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::leftJoin('notes', 'note', [Query::on('$id', 'nothing')])])); + $this->assertSame('Invalid query: Attribute not found in schema: nothing', $validator->getDescription()); + } + + public function testValidatorForgetsTheJoinsOfThePreviousQuerySet(): void + { + $validator = $this->validators()['documents']; + + $this->assertTrue($validator->isValid([Query::join('notes', 'note', [Query::on('$id', 'customerId')])]), $validator->getDescription()); + $this->assertFalse($validator->isValid([Query::join('replies', 'reply', [Query::on('note.$id', 'noteId')])]), 'an alias of the previous query set is not declared in this one'); + } + + /** + * @return iterable, string, bool}> + */ + public static function internalAttributesWithoutAColumn(): iterable + { + $note = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + yield '$collection counted' => [[Query::count('$collection', 'total')], '$collection', false]; + yield '$collection grouped' => [[Query::count('*', 'rows'), Query::groupBy(['$collection'])], '$collection', false]; + yield '$tenant counted' => [[Query::count('$tenant', 'total')], '$tenant', true]; + yield '$tenant grouped' => [[Query::count('*', 'rows'), Query::groupBy(['$tenant'])], '$tenant', true]; + yield '$tenant selected' => [[Query::select(['name', '$tenant'])], '$tenant', true]; + yield 'a joined $tenant counted' => [[$note, Query::count('note.$tenant', 'total')], 'note.$tenant', true]; + yield 'a joined $tenant grouped' => [[$note, Query::count('*', 'rows'), Query::groupBy(['note.$tenant'])], 'note.$tenant', true]; + yield 'a joined $tenant selected' => [[$note, Query::select(['name', 'note.$tenant'])], 'note.$tenant', true]; + } + + /** + * @param list $queries + */ + #[DataProvider('internalAttributesWithoutAColumn')] + public function testInternalAttributeWithoutAColumnIsInvalid(array $queries, string $attribute, bool $validUnderSharedTables): void + { + $validator = $this->documents(sharedTables: false); + + $this->assertFalse($validator->isValid($queries), 'accepted without shared tables'); + $this->assertSame('Invalid query: Attribute not found in schema: '.$attribute, $validator->getDescription()); + + $shared = $this->documents(sharedTables: true); + $this->assertSame($validUnderSharedTables, $shared->isValid($queries), $shared->getDescription()); + } + + public function testCollectionStaysSelectableOnTheMainCollection(): void + { + foreach ([false, true] as $sharedTables) { + $validator = $this->documents($sharedTables); + + $this->assertTrue($validator->isValid([Query::select(['name', '$collection'])]), $validator->getDescription()); + $this->assertFalse($validator->isValid([Query::join('notes', 'note', [Query::on('$id', 'customerId')]), Query::select(['note.$collection'])])); + } + } + + public function testTenantIsRejectedByValidatorsBuiltWithoutSharedTables(): void + { + $attributes = $this->customers(); + + $this->assertFalse((new Aggregate($attributes))->isValid(Query::count('$tenant', 'total'))); + $this->assertTrue((new Aggregate($attributes, sharedTables: true))->isValid(Query::count('$tenant', 'total'))); + $this->assertFalse((new Aggregate($attributes, sharedTables: true))->isValid(Query::count('$collection', 'total'))); + $this->assertFalse((new GroupBy($attributes))->isValid(Query::groupBy(['$tenant']))); + $this->assertTrue((new GroupBy($attributes, sharedTables: true))->isValid(Query::groupBy(['$tenant']))); + $this->assertFalse((new Select($attributes))->isValid(Query::select(['$tenant']))); + $this->assertTrue((new Select($attributes, sharedTables: true))->isValid(Query::select(['$tenant']))); + } + + public function testEncryptedJoinedAttributeCannotBeFiltered(): void + { + $join = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + $validator = $this->documents(sharedTables: false); + + foreach ([ + Query::equal('note.secret', ['x']), + Query::isNull('note.secret'), + Query::and([Query::equal('note.body', ['x']), Query::startsWith('note.secret', 'x')]), + ] as $filter) { + $this->assertFalse($validator->isValid([$join, $filter]), 'an encrypted joined attribute was filtered'); + $this->assertSame('Invalid query: Cannot query encrypted attribute: note.secret', $validator->getDescription()); + } + + $this->assertTrue($validator->isValid([$join, Query::select(['name', 'note.secret']), Query::orderAsc('note.secret')]), $validator->getDescription()); + $this->assertFalse($validator->isValid([Query::equal('secret', ['x'])])); + $this->assertSame('Invalid query: Cannot query encrypted attribute: secret', $validator->getDescription()); + } + + /** + * @return iterable, string|null}> + */ + public static function aggregateAliases(): iterable + { + $grouped = static fn (string $alias, string $attribute): string => 'Invalid query: Aggregate alias "'.$alias.'" is the name the groupBy attribute "'.$attribute.'" is returned under'; + $join = Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + + yield 'a grouped attribute' => [[Query::count('*', 'name'), Query::groupBy(['name'])], $grouped('name', 'name')]; + yield 'a grouped joined attribute' => [[$join, Query::count('*', 'body'), Query::groupBy(['name', 'note.body'])], $grouped('body', 'note.body')]; + yield 'the column of a grouped internal attribute' => [[Query::count('*', '_createdAt'), Query::groupBy(['$createdAt'])], $grouped('_createdAt', '$createdAt')]; + yield 'another aggregate' => [[Query::count('*', 'total'), Query::sum('score', 'total')], 'Invalid query: Aggregate alias "total" is given to more than one aggregate']; + yield 'an attribute that is not grouped' => [[Query::sum('score', 'name'), Query::groupBy(['score'])], null]; + yield 'aliases of their own' => [[$join, Query::count('*', 'rows'), Query::sum('note.score', 'score'), Query::groupBy(['name', 'note.body'])], null]; + } + + /** + * @param list $queries + */ + #[DataProvider('aggregateAliases')] + public function testAggregateAliasNamesOneColumnOfTheResult(array $queries, ?string $message): void + { + $validator = $this->documents(sharedTables: false); + + $this->assertSame($message === null, $validator->isValid($queries), $validator->getDescription()); + if ($message !== null) { + $this->assertSame($message, $validator->getDescription()); + } + } + + /** + * @return array + */ + private function validators(): array + { + $documents = $this->documents(sharedTables: false); + + $document = new DocumentQueries($this->customers(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + $document->setJoinedCollections($this->collections()); + + $joins = new Base([new Join($this->customers())]); + $joins->setJoinedCollections($this->collections()); + + return ['documents' => $documents, 'document' => $document, 'join' => $joins]; + } + + private function documents(bool $sharedTables): DocumentsQueries + { + $validator = new DocumentsQueries( + $this->customers(), + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::Aggregations, Capability::OrderRandom], sharedTables: $sharedTables), + ); + $validator->setJoinedCollections($this->collections()); + + return $validator; + } + + /** + * @return array + */ + private function customers(): array + { + return [ + $this->attribute('name', ColumnType::String), + $this->attribute('score', ColumnType::Integer), + $this->attribute('secret', ColumnType::String, filters: ['encrypt']), + $this->relationship('library', RelationshipType::OneToOne, RelationshipSide::Parent), + $this->relationship('books', RelationshipType::OneToMany, RelationshipSide::Parent), + ]; + } + + /** + * @return array + */ + private function collections(): array + { + return [ + $this->collection('notes', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('body', ColumnType::String), + $this->attribute('score', ColumnType::Integer), + $this->attribute('secret', ColumnType::String, filters: ['encrypt']), + ]), + $this->collection('replies', [ + $this->attribute('noteId', ColumnType::String), + ]), + $this->collection('libraries', [ + $this->attribute('name', ColumnType::String), + $this->relationship('person', RelationshipType::OneToOne, RelationshipSide::Child), + ]), + $this->collection('books', [ + $this->attribute('title', ColumnType::String), + $this->relationship('owner', RelationshipType::OneToMany, RelationshipSide::Child), + ]), + ]; + } + + /** + * @param array $attributes + */ + private function collection(string $id, array $attributes): Document + { + return new Document(['$id' => $id, 'attributes' => $attributes, 'indexes' => []]); + } + + /** + * @param list $filters + */ + private function attribute(string $key, ColumnType $type, array $filters = []): Document + { + return new Document([ + '$id' => $key, + 'key' => $key, + 'type' => $type->value, + 'size' => $type === ColumnType::String ? 256 : 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => $filters, + ]); + } + + private function relationship(string $key, RelationshipType $type, RelationshipSide $side): Document + { + return new Document([ + '$id' => $key, + 'key' => $key, + 'type' => ColumnType::Relationship->value, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + 'options' => [ + 'relatedCollection' => 'related', + 'relationType' => $type->value, + 'twoWay' => false, + 'twoWayKey' => 'back', + 'onDelete' => 'restrict', + 'side' => $side->value, + ], + ]); + } +} diff --git a/tests/unit/Validator/JoinedColumnTest.php b/tests/unit/Validator/JoinedColumnTest.php new file mode 100644 index 0000000000..19a1cd8be8 --- /dev/null +++ b/tests/unit/Validator/JoinedColumnTest.php @@ -0,0 +1,350 @@ +}> + */ + public static function internalAttributeProvider(): array + { + $types = [ + 'filter' => static fn (string $attribute): array => [Query::isNotNull($attribute)], + 'select' => static fn (string $attribute): array => [Query::select([$attribute])], + 'order' => static fn (string $attribute): array => [Query::orderAsc($attribute)], + 'aggregate' => static fn (string $attribute): array => [Query::count($attribute, 'result')], + 'groupBy' => static fn (string $attribute): array => [Query::count('*', 'rows'), Query::groupBy([$attribute])], + ]; + + $cases = []; + foreach (['$id', '$sequence', '$createdAt', '$updatedAt', '$permissions', '$tenant'] as $attribute) { + foreach ($types as $type => $queries) { + $cases[$attribute.' in '.$type] = [$attribute, $queries]; + } + } + + return $cases; + } + + /** + * @param Closure(string): list $queries + */ + #[DataProvider('internalAttributeProvider')] + public function testInternalAttributeUnderAnAliasFollowsTheRuleOfTheMainCollection(string $attribute, Closure $queries): void + { + $validator = $this->validator([$this->notes()]); + + $unaliased = $validator->isValid($queries($attribute)); + $aliased = $validator->isValid([self::join(), ...$queries('note.'.$attribute)]); + + $this->assertSame($unaliased, $aliased, $validator->getDescription()); + } + + public function testCollectionOfAJoinedRowCannotBeReferenced(): void + { + $validator = $this->validator([$this->notes()]); + + $this->assertTrue($validator->isValid([Query::select(['$collection'])]), $validator->getDescription()); + + foreach ([ + [Query::select(['note.$collection'])], + [Query::count('note.$collection', 'result')], + [Query::count('*', 'rows'), Query::groupBy(['note.$collection'])], + ] as $queries) { + $this->assertFalse($validator->isValid([self::join(), ...$queries])); + $this->assertSame('Invalid query: Attribute not found in schema: note.$collection', $validator->getDescription()); + } + } + + /** + * @return array + */ + public static function everyQueryTypeProvider(): array + { + return [ + 'filter' => [static fn (string $attribute): Query => Query::equal($attribute, ['x'])], + 'filter in a logical group' => [static fn (string $attribute): Query => Query::or([Query::equal('name', ['x']), Query::equal($attribute, ['x'])])], + 'select' => [static fn (string $attribute): Query => Query::select(['name', $attribute])], + 'order' => [static fn (string $attribute): Query => Query::orderDesc($attribute)], + 'aggregate' => [static fn (string $attribute): Query => Query::countDistinct($attribute, 'result')], + 'groupBy' => [static fn (string $attribute): Query => Query::groupBy(['name', $attribute])], + ]; + } + + /** + * @param Closure(string): Query $query + */ + #[DataProvider('everyQueryTypeProvider')] + public function testColumnTheJoinedCollectionDoesNotDeclareIsRejected(Closure $query): void + { + $validator = $this->validator([$this->notes()]); + + $this->assertTrue($validator->isValid([self::join(), $query('note.body')]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([self::join(), $query('note.visits')]), 'an attribute of the main collection is not a column of the join'); + $this->assertSame('Invalid query: Attribute not found in schema: note.visits', $validator->getDescription()); + + $this->assertFalse($validator->isValid([self::join(), $query('note.customer')]), 'a relationship side that holds no column is no column a join reads'); + $this->assertSame('Invalid query: Attribute not found in schema: note.customer', $validator->getDescription()); + } + + /** + * @param Closure(string): Query $query + */ + #[DataProvider('everyQueryTypeProvider')] + public function testAnyPlainColumnIsAcceptedUnderAnAliasWhoseCollectionIsUnknown(Closure $query): void + { + $validator = $this->validator(); + + $this->assertTrue($validator->isValid([self::join(), $query('note.anything')]), $validator->getDescription()); + } + + /** + * @param Closure(string): Query $query + */ + #[DataProvider('everyQueryTypeProvider')] + public function testSchemalessValidatorsAcceptAnyJoinedColumn(Closure $query): void + { + $validator = $this->validator([$this->notes()], supportForAttributes: false); + + $this->assertTrue($validator->isValid([self::join(), $query('note.anything')]), $validator->getDescription()); + } + + public function testEachAliasResolvesToItsOwnCollection(): void + { + $validator = $this->validator([$this->notes(), $this->orders()]); + $joins = [self::join(), Query::join('orders', 'purchase', [Query::on('$id', 'customerId')])]; + + $this->assertTrue($validator->isValid([...$joins, Query::equal('purchase.amount', [1]), Query::equal('note.body', ['x'])]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([...$joins, Query::equal('purchase.body', ['x'])])); + $this->assertSame('Invalid query: Attribute not found in schema: purchase.body', $validator->getDescription()); + } + + public function testCollectionJoinedTwiceIsCheckedUnderBothAliases(): void + { + $validator = $this->validator([$this->notes()]); + $joins = [self::join(), Query::leftJoin('notes', 'again', [Query::on('$id', 'customerId')])]; + + $this->assertTrue($validator->isValid([...$joins, Query::select(['note.body', 'again.body'])]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([...$joins, Query::select(['again.nothing'])])); + $this->assertSame('Invalid query: Attribute not found in schema: again.nothing', $validator->getDescription()); + } + + public function testJoinConditionIsCheckedAgainstTheJoinedCollection(): void + { + $validator = $this->validator([$this->notes()]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.body', ['x'])]), + ]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.nothing', ['x'])]), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: note.nothing', $validator->getDescription()); + } + + /** + * @return array + */ + public static function joinedAggregateProvider(): array + { + return [ + 'stddev of a joined integer' => [Query::stddev('note.score', 'result'), true], + 'varPop of a joined double' => [Query::varPop('note.ratio', 'result'), true], + 'variance of a bare name resolved to a joined integer' => [Query::variance('score', 'result'), true], + 'bitAnd of a joined integer' => [Query::bitAnd('note.score', 'result'), true], + 'bitXor of a bare name resolved to a joined integer' => [Query::bitXor('score', 'result'), true], + 'bitOr of a joined double' => [Query::bitOr('note.ratio', 'result'), false], + 'stddevSamp of a joined string' => [Query::stddevSamp('note.body', 'result'), false], + 'varSamp of a bare name resolved to a joined string array' => [Query::varSamp('tags', 'result'), false], + 'min of a joined string' => [Query::min('note.body', 'result'), true], + 'max of a joined string array' => [Query::max('note.tags', 'result'), false], + ]; + } + + #[DataProvider('joinedAggregateProvider')] + public function testArithmeticAndBitwiseAggregatesTypeAJoinedAttributeByItsCollection(Query $aggregate, bool $valid): void + { + $validator = $this->validator([$this->notes()]); + + $this->assertSame($valid, $validator->isValid([self::join(), $aggregate]), $validator->getDescription()); + } + + public function testMainCollectionAttributeKeepsItsOwnTypeWhenAJoinDeclaresItToo(): void + { + $validator = $this->validator([$this->collection('notes', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('visits', ColumnType::String), + ])]); + + $this->assertTrue($validator->isValid([self::join(), Query::sum('visits', 'result')]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([self::join(), Query::sum('note.visits', 'result')])); + $this->assertSame('Invalid query: Aggregate sum requires a numeric attribute that is not an array: note.visits', $validator->getDescription()); + } + + public function testDocumentQueriesCheckJoinConditionsAgainstTheJoinedCollection(): void + { + $validator = new DocumentQueries($this->attributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + $validator->setJoinedCollections([$this->notes()]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('notes', 'note', [ + Query::on('$id', 'customerId'), + Query::or([Query::equal('note.body', ['x']), Query::equal('note.$id', ['y'])]), + Query::greaterThan('$sequence', 1), + ]), + Query::select(['name', 'note.body', 'note.$permissions']), + ]), $validator->getDescription()); + + foreach ([ + 'an unknown joined column' => [Query::equal('note.nothing', ['x']), 'Invalid query: Attribute not found in schema: note.nothing'], + 'the joined permissions' => [Query::equal('note.$permissions', ['x']), 'Invalid query: Attribute not found in schema: note.$permissions'], + 'a value the main attribute cannot hold' => [Query::equal('visits', ['many']), 'Invalid query: Query value is invalid for attribute "visits"'], + 'an unknown column in a logical group' => [Query::or([Query::equal('note.body', ['x']), Query::equal('note.nothing', ['y'])]), 'Invalid query: Attribute not found in schema: note.nothing'], + ] as $label => [$condition, $message]) { + $this->assertFalse($validator->isValid([ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId'), $condition]), + ]), $label); + $this->assertSame($message, $validator->getDescription(), $label); + } + + $this->assertFalse($validator->isValid([ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::select(['note.nothing']), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: note.nothing', $validator->getDescription()); + } + + public function testDocumentQueriesKeepRejectingTopLevelFilters(): void + { + $validator = new DocumentQueries($this->attributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + $validator->setJoinedCollections([$this->notes()]); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::equal('note.body', ['x']), + ])); + $this->assertSame('Invalid query method: equal', $validator->getDescription()); + } + + private static function join(): Query + { + return Query::join('notes', 'note', [Query::on('$id', 'customerId')]); + } + + /** + * @param array $joinedCollections + */ + private function validator(array $joinedCollections = [], bool $supportForAttributes = true): Base + { + $attributes = [ + ...$this->attributes(), + $this->attribute('$id', ColumnType::String), + $this->attribute('$sequence', ColumnType::Id), + $this->attribute('$createdAt', ColumnType::Datetime), + $this->attribute('$updatedAt', ColumnType::Datetime), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value, supportForAttributes: $supportForAttributes), + new Select($attributes, $supportForAttributes), + new Order($attributes, $supportForAttributes), + new Join(), + new Aggregate($attributes, $supportForAttributes), + new GroupBy($attributes, $supportForAttributes), + ]); + $validator->setJoinedCollections($joinedCollections); + + return $validator; + } + + /** + * @return array + */ + private function attributes(): array + { + return [ + $this->attribute('name', ColumnType::String), + $this->attribute('visits', ColumnType::Integer), + ]; + } + + private function notes(): Document + { + return $this->collection('notes', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('body', ColumnType::String), + $this->attribute('score', ColumnType::Integer), + $this->attribute('ratio', ColumnType::Double), + $this->attribute('tags', ColumnType::String, array: true), + new Document([ + '$id' => 'customer', + 'key' => 'customer', + 'type' => ColumnType::Relationship->value, + 'options' => ['relatedCollection' => 'customers', 'relationType' => RelationshipType::ManyToMany->value, 'side' => RelationshipSide::Parent->value], + ]), + ]); + } + + private function orders(): Document + { + return $this->collection('orders', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('amount', ColumnType::Integer), + ]); + } + + /** + * @param array $attributes + */ + private function collection(string $id, array $attributes): Document + { + return new Document([ + '$id' => $id, + 'attributes' => $attributes, + 'indexes' => [], + ]); + } + + private function attribute(string $key, ColumnType $type, bool $array = false): Document + { + return new Document([ + '$id' => $key, + 'key' => $key, + 'type' => $type->value, + 'size' => $type === ColumnType::String ? 256 : 0, + 'required' => false, + 'signed' => true, + 'array' => $array, + 'filters' => [], + ]); + } +} diff --git a/tests/unit/Validator/KeyTest.php b/tests/unit/Validator/KeyTest.php index 3c19346d81..fdbc22a58e 100644 --- a/tests/unit/Validator/KeyTest.php +++ b/tests/unit/Validator/KeyTest.php @@ -7,21 +7,20 @@ class KeyTest extends TestCase { - /** - * @var Key - */ - protected ?Key $object = null; + protected Key $object; - public function setUp(): void + #[\Override] + protected function setUp(): void { $this->object = new Key(); } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } - public function testValues(): void + public function test_values(): void { // Must be strings $this->assertEquals(false, $this->object->isValid(false)); diff --git a/tests/unit/Validator/LabelTest.php b/tests/unit/Validator/LabelTest.php index a6dd50bef9..ef25888287 100644 --- a/tests/unit/Validator/LabelTest.php +++ b/tests/unit/Validator/LabelTest.php @@ -7,21 +7,20 @@ class LabelTest extends TestCase { - /** - * @var Label - */ - protected ?Label $object = null; + protected Label $object; - public function setUp(): void + #[\Override] + protected function setUp(): void { $this->object = new Label(); } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } - public function testValues(): void + public function test_values(): void { // Must be strings $this->assertEquals(false, $this->object->isValid(false)); @@ -62,4 +61,14 @@ public function testValues(): void $this->assertEquals(true, $this->object->isValid(str_repeat('a', 36))); $this->assertEquals(false, $this->object->isValid(str_repeat('a', 256))); } + + public function test_non_string_values_rejected(): void + { + $this->assertFalse($this->object->isValid(42)); + $this->assertFalse($this->object->isValid(null)); + $this->assertFalse($this->object->isValid(['abc'])); + $this->assertFalse($this->object->isValid(true)); + $this->assertFalse($this->object->isValid(3.14)); + $this->assertFalse($this->object->isValid(new \stdClass())); + } } diff --git a/tests/unit/Validator/NarrowTest.php b/tests/unit/Validator/NarrowTest.php new file mode 100644 index 0000000000..3577ffe185 --- /dev/null +++ b/tests/unit/Validator/NarrowTest.php @@ -0,0 +1,297 @@ +}> + */ + public static function narrowLists(): array + { + $lists = [ + 'no queries' => [], + 'equal string' => [Query::equal('title', ['Dune'])], + 'equal string with a number' => [Query::equal('title', [1])], + 'equal too many values' => [Query::equal('title', ['a', 'b', 'c', 'd'])], + 'equal no values' => [Query::equal('title', [])], + 'equal integer' => [Query::equal('count', [5])], + 'equal integer with a string' => [Query::equal('count', ['five'])], + 'integer over 32 bits' => [Query::greaterThan('count', 2147483648)], + 'big integer column over 32 bits' => [Query::greaterThan('big', 2147483648)], + 'unsigned below zero' => [Query::lessThan('unsigned', -1)], + 'bigint signed' => [Query::equal('huge', ['9223372036854775807'])], + 'bigint unsigned' => [Query::equal('hugeUnsigned', ['18446744073709551615'])], + 'float' => [Query::lessThanEqual('price', 9.5)], + 'float with a string' => [Query::lessThanEqual('price', 'cheap')], + 'boolean' => [Query::equal('active', [true])], + 'boolean with a string' => [Query::equal('active', ['yes'])], + 'datetime' => [Query::greaterThanEqual('published', '2020-01-01T00:00:00.000+00:00')], + 'datetime out of range' => [Query::greaterThanEqual('published', '10000-01-01T00:00:00.000+00:00')], + 'datetime not a date' => [Query::greaterThanEqual('published', 'yesterday-ish')], + 'between' => [Query::between('price', 1, 2)], + 'between one value' => [new Query(Method::Between, 'price', [1])], + 'not between' => [Query::notBetween('count', 1, 9)], + 'single value with two' => [new Query(Method::LessThan, 'count', [1, 2])], + 'starts with' => [Query::startsWith('title', 'Du')], + 'not starts with' => [Query::notStartsWith('title', 'Du')], + 'ends with' => [Query::endsWith('title', 'ne')], + 'not ends with' => [Query::notEndsWith('title', 'ne')], + 'regex' => [Query::regex('title', '^D')], + 'contains on an array' => [Query::contains('tags', ['sci-fi'])], + 'contains on a string' => [Query::contains('title', ['un'])], + 'contains on an integer' => [Query::contains('count', [1])], + 'contains no values' => [Query::contains('tags', [])], + 'contains any' => [Query::containsAny('tags', ['a', 'b'])], + 'contains all' => [Query::containsAll('tags', ['a', 'b'])], + 'not contains' => [Query::notContains('tags', ['a'])], + 'equal on an array' => [Query::equal('tags', ['a'])], + 'is null' => [Query::isNull('title')], + 'is not null on an array' => [Query::isNotNull('tags')], + 'not equal' => [Query::notEqual('title', 'Dune')], + 'encrypted' => [Query::equal('secret', ['x'])], + 'missing attribute' => [Query::equal('missing', ['x'])], + 'empty attribute' => [Query::equal('', ['x'])], + 'virtual one to one child' => [Query::equal('author', ['x'])], + 'virtual one to many parent' => [Query::equal('books', ['x'])], + 'virtual many to one child' => [Query::equal('editor', ['x'])], + 'many to many' => [Query::equal('readers', ['x'])], + 'many to one parent' => [Query::equal('owner', ['x'])], + 'object containment' => [Query::equal('meta', [['level' => 1]])], + 'object mixed list' => [Query::equal('meta', [['a' => [1, 'b' => [2]]]])], + 'point with equal' => [Query::equal('location', [[1, 2]])], + 'point not an array' => [Query::equal('location', ['here'])], + 'vector with equal' => [Query::equal('embedding', [[1, 2, 3]])], + 'id' => [Query::equal('$id', ['dune'])], + 'sequence' => [Query::equal('$sequence', ['12'])], + 'sequence not a number' => [Query::equal('$sequence', ['twelve'])], + 'created at' => [Query::greaterThan('$createdAt', '2020-01-01T00:00:00.000+00:00')], + 'updated at not a date' => [Query::lessThan('$updatedAt', 'soon')], + 'permissions are not an attribute' => [Query::equal('$permissions', ['read("any")'])], + 'duplicated key, last definition wins' => [Query::equal('dup', ['x'])], + 'duplicated key with a number' => [Query::equal('dup', [5])], + 'limit' => [Query::limit(25)], + 'limit zero' => [Query::limit(0)], + 'offset' => [Query::offset(10)], + 'offset below zero' => [Query::offset(-1)], + 'cursor after' => [Query::cursorAfter(new Document([Document::ID => 'dune']))], + 'cursor before an id' => [Query::cursorBefore(new Document([Document::ID => 'dune']))], + 'cursor too long' => [Query::cursorAfter(new Document([Document::ID => \str_repeat('x', 40)]))], + 'order asc' => [Query::orderAsc('title')], + 'order desc on a missing attribute' => [Query::orderDesc('missing')], + 'order asc on an internal attribute' => [Query::orderAsc('$sequence')], + 'order on an empty attribute' => [Query::orderAsc('')], + 'order random' => [Query::orderRandom()], + 'a page' => [Query::equal('title', ['Dune']), Query::greaterThan('count', 1), Query::orderDesc('$createdAt'), Query::limit(10), Query::offset(20)], + 'the first refusal is the reported one' => [Query::equal('title', ['Dune']), Query::equal('missing', ['x']), Query::equal('count', ['five'])], + 'a refusal after an order' => [Query::orderAsc('title'), Query::limit(5), Query::equal('active', ['yes'])], + 'cursor page' => [Query::cursorAfter(new Document([Document::ID => 'dune'])), Query::orderAsc('price'), Query::isNotNull('title')], + ]; + + $named = []; + foreach ($lists as $name => $queries) { + $named[$name] = [$queries]; + } + + return $named; + } + + /** + * @param list $queries + */ + #[DataProvider('narrowLists')] + public function testANarrowListIsJudgedAsTheDocumentsValidatorJudgesIt(array $queries): void + { + $this->assertTrue(Narrow::accepts($queries)); + + foreach ([true, false] as $supportForAttributes) { + foreach ([true, false] as $orderRandom) { + $documents = $this->documents($supportForAttributes, $orderRandom); + $narrow = $this->narrow($queries, $supportForAttributes, $orderRandom); + $this->assertNotNull($narrow); + + $valid = $documents->isValid($queries); + + $context = ($supportForAttributes ? 'defined attributes' : 'schemaless').($orderRandom ? ', random order' : ''); + $this->assertSame($valid, $narrow->isValid($queries), $context.': '.$documents->getDescription()); + if (! $valid) { + $this->assertSame($documents->getDescription(), $narrow->getDescription(), $context); + } + } + } + } + + /** + * @return array}> + */ + public static function otherLists(): array + { + return [ + 'dotted filter' => [[Query::equal('author.name', ['x'])]], + 'dotted order' => [[Query::orderAsc('meta.level')]], + 'search' => [[Query::search('title', 'dune')]], + 'not search' => [[Query::notSearch('title', 'dune')]], + 'or' => [[Query::or([Query::equal('title', ['a']), Query::equal('title', ['b'])])]], + 'and' => [[Query::and([Query::equal('title', ['a']), Query::equal('count', [1])])]], + 'exists' => [[Query::exists(['title'])]], + 'select' => [[Query::select(['title'])]], + 'vector' => [[Query::vectorDot('embedding', [1.0, 2.0, 3.0])]], + 'distance' => [[Query::distanceLessThan('location', [1, 2], 10)]], + 'spatial' => [[Query::intersects('location', [1, 2])]], + 'aggregate' => [[Query::count()]], + 'group by' => [[Query::groupBy(['title'])]], + 'join' => [[Query::join('other', 'j0', [Query::on('title', 'title')])]], + 'a string' => [['equal("title", ["Dune"])']], + 'one other query among narrow ones' => [[Query::equal('title', ['Dune']), Query::select(['title']), Query::limit(1)]], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('otherLists')] + public function testAnyOtherListIsLeftToTheDocumentsValidator(array $queries): void + { + $this->assertFalse(Narrow::accepts($queries)); + $this->assertNull($this->narrow($queries, true, true)); + } + + public function testEachListIsCheckedAgainstTheAttributesItWasGiven(): void + { + $queries = [Query::equal('title', ['Dune'])]; + $string = [Attribute::string(key: 'title', size: 64)]; + $integer = [Attribute::integer(key: 'title')]; + + $this->assertTrue(Narrow::of($queries, $string, $this->profile(true, true), self::MAX_VALUES)?->isValid($queries)); + + $refused = Narrow::of($queries, $integer, $this->profile(true, true), self::MAX_VALUES); + $this->assertFalse($refused?->isValid($queries)); + $this->assertSame('Invalid query: Query value is invalid for attribute "title"', $refused->getDescription()); + + $missing = Narrow::of($queries, [], $this->profile(true, true), self::MAX_VALUES); + $this->assertFalse($missing?->isValid($queries)); + $this->assertSame('Invalid query: Attribute not found in schema: title', $missing->getDescription()); + } + + public function testAListItWasNotMadeOfIsCheckedByItsValidators(): void + { + $narrow = Narrow::of([Query::equal('title', ['Dune'])], $this->attributes(), $this->profile(true, true), self::MAX_VALUES); + + $this->assertFalse($narrow?->isValid([Query::limit(5)])); + $this->assertSame('Invalid query method: limit', $narrow->getDescription()); + $this->assertFalse($narrow->isValid([Query::equal('count', [5])])); + $this->assertSame('Invalid query: Attribute not found in schema: count', $narrow->getDescription()); + $this->assertFalse($narrow->isValid('not a list')); + $this->assertSame('Queries must be an array', $narrow->getDescription()); + $this->assertTrue($narrow->isValid([Query::equal('title', ['Dune'])])); + } + + public function testNothingAnEarlierListRegisteredReachesTheNextOne(): void + { + $narrow = Narrow::of([Query::orderAsc('title'), Query::equal('title', ['Dune'])], $this->attributes(), $this->profile(true, true), self::MAX_VALUES); + + $this->assertFalse($narrow?->isValid([Query::count('*', 'total'), Query::join('other', 'o', [Query::on('title', 'title')]), Query::orderAsc('total')])); + $this->assertFalse($narrow->isValid([Query::orderAsc('total')])); + $this->assertSame('Invalid query: Attribute not found in schema: total', $narrow->getDescription()); + + $narrow->setJoinedCollections([new Document([Document::ID => 'other', 'attributes' => [Attribute::string(key: 'nickname', size: 16)]])]); + $this->assertFalse($narrow->isValid([Query::join('other', 'o', [Query::on('title', 'nickname')]), Query::orderAsc('nickname')])); + $this->assertFalse($narrow->isValid([Query::orderAsc('nickname')])); + $this->assertSame('Invalid query: Attribute not found in schema: nickname', $narrow->getDescription()); + } + + /** + * @param list $queries + */ + private function narrow(array $queries, bool $supportForAttributes, bool $orderRandom): ?Narrow + { + return Narrow::of($queries, $this->attributes(), $this->profile($supportForAttributes, $orderRandom), self::MAX_VALUES); + } + + private function documents(bool $supportForAttributes, bool $orderRandom): Documents + { + return new Documents( + $this->attributes(), + [Index::fulltext('title_fulltext', ['title']), Index::key('count_key', ['count'])], + $this->profile($supportForAttributes, $orderRandom), + self::MAX_VALUES, + ); + } + + private function profile(bool $supportForAttributes, bool $orderRandom): Profile + { + return Profiles::of( + capabilities: [ + Capability::UnsignedBigInt, + Capability::Joins, + Capability::Aggregations, + ...($supportForAttributes ? [Capability::DefinedAttributes] : []), + ...($orderRandom ? [Capability::OrderRandom] : []), + ], + maxDateTime: new DateTime('9999-12-31 23:59:59'), + ); + } + + /** + * @return list + */ + private function attributes(): array + { + return [ + Attribute::string(key: 'title', size: 64), + Attribute::integer(key: 'count'), + Attribute::integer(key: 'unsigned', signed: false), + Attribute::integer(key: 'big', width: IntegerWidth::Bits64), + Attribute::bigInteger(key: 'huge'), + Attribute::bigInteger(key: 'hugeUnsigned', signed: false), + Attribute::float(key: 'price'), + Attribute::boolean(key: 'active'), + Attribute::datetime(key: 'published'), + Attribute::string(key: 'tags', size: 32, array: true), + Attribute::string(key: 'secret', size: 64, filters: ['encrypt']), + Attribute::string(key: 'dup', size: 8), + Attribute::integer(key: 'dup'), + Attribute::object(key: 'meta'), + Attribute::point(key: 'location'), + Attribute::vector(key: 'embedding', dimensions: 3), + Attribute::integer(key: Document::ID), + $this->relationship('author', RelationshipType::OneToOne, false, RelationshipSide::Child), + $this->relationship('books', RelationshipType::OneToMany, true, RelationshipSide::Parent), + $this->relationship('editor', RelationshipType::ManyToOne, true, RelationshipSide::Child), + $this->relationship('readers', RelationshipType::ManyToMany, true, RelationshipSide::Parent), + $this->relationship('owner', RelationshipType::ManyToOne, true, RelationshipSide::Parent), + ]; + } + + private function relationship(string $key, RelationshipType $type, bool $twoWay, RelationshipSide $side): Attribute + { + return Attribute::fromArray(['key' => $key, 'type' => ColumnType::Relationship, 'options' => [ + 'relatedCollection' => 'people', + 'relationType' => $type->value, + 'twoWay' => $twoWay, + 'twoWayKey' => $key.'Back', + 'onDelete' => 'restrict', + 'side' => $side->value, + ]]); + } +} diff --git a/tests/unit/Validator/ObjectTest.php b/tests/unit/Validator/ObjectTest.php index 4563fe2eb8..143fb09617 100644 --- a/tests/unit/Validator/ObjectTest.php +++ b/tests/unit/Validator/ObjectTest.php @@ -3,21 +3,21 @@ namespace Tests\Unit\Validator; use PHPUnit\Framework\TestCase; -use Utopia\Database\Validator\ObjectValidator; +use Utopia\Database\Validator\ObjectValue; class ObjectTest extends TestCase { - public function testValidAssociativeObjects(): void + public function test_valid_associative_objects(): void { - $validator = new ObjectValidator(); + $validator = new ObjectValue(); $this->assertTrue($validator->isValid(['key' => 'value'])); $this->assertTrue($validator->isValid([ 'a' => [ 'b' => [ - 'c' => 123 - ] - ] + 'c' => 123, + ], + ], ])); $this->assertTrue($validator->isValid([ @@ -25,43 +25,65 @@ public function testValidAssociativeObjects(): void 'metadata' => [ 'rating' => 4.5, 'info' => [ - 'category' => 'science' - ] - ] + 'category' => 'science', + ], + ], ])); $this->assertTrue($validator->isValid([ 'key1' => null, - 'key2' => ['nested' => null] + 'key2' => ['nested' => null], ])); $this->assertTrue($validator->isValid([ - 'meta' => (object)['x' => 1] + 'meta' => (object) ['x' => 1], ])); $this->assertTrue($validator->isValid([ 'a' => 1, - 2 => 'b' + 2 => 'b', ])); } - public function testInvalidStructures(): void + public function test_invalid_structures(): void { - $validator = new ObjectValidator(); + $validator = new ObjectValue(); $this->assertFalse($validator->isValid(['a', 'b', 'c'])); $this->assertFalse($validator->isValid('not an array')); $this->assertFalse($validator->isValid([ - 0 => 'value' + 0 => 'value', ])); } - public function testEmptyCases(): void + public function test_scalar_json_strings_are_not_objects(): void { - $validator = new ObjectValidator(); + $validator = new ObjectValue(); + + $this->assertFalse($validator->isValid('123'), 'a JSON number is not an object'); + $this->assertFalse($validator->isValid('0'), 'a falsy JSON number is not an object'); + $this->assertFalse($validator->isValid('true'), 'a JSON boolean is not an object'); + $this->assertFalse($validator->isValid('null'), 'JSON null is not an object'); + $this->assertFalse($validator->isValid('"str"'), 'a JSON string is not an object'); + $this->assertFalse($validator->isValid('""'), 'an empty JSON string is not an object'); + $this->assertFalse($validator->isValid('[1, 2]'), 'a JSON list is not an object'); + } + + public function test_json_object_strings_are_objects(): void + { + $validator = new ObjectValue(); + + $this->assertTrue($validator->isValid('{"a": 1}')); + $this->assertTrue($validator->isValid('{}')); + $this->assertTrue($validator->isValid('[]'), 'an empty JSON array matches the empty-array case'); + } + + public function test_empty_cases(): void + { + $validator = new ObjectValue(); $this->assertTrue($validator->isValid([])); $this->assertTrue($validator->isValid(new \stdClass())); diff --git a/tests/unit/Validator/OperatorTest.php b/tests/unit/Validator/OperatorTest.php index e89d391042..28193784ea 100644 --- a/tests/unit/Validator/OperatorTest.php +++ b/tests/unit/Validator/OperatorTest.php @@ -3,16 +3,17 @@ namespace Tests\Unit\Validator; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Operator; use Utopia\Database\Validator\Operator as OperatorValidator; +use Utopia\Query\Schema\ColumnType; class OperatorTest extends TestCase { protected Document $collection; - public function setUp(): void + #[\Override] + protected function setUp(): void { $this->collection = new Document([ '$id' => 'test_collection', @@ -20,50 +21,51 @@ public function setUp(): void new Document([ '$id' => 'count', 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'array' => false, ]), new Document([ '$id' => 'score', 'key' => 'score', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'array' => false, ]), new Document([ '$id' => 'title', 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, 'size' => 100, ]), new Document([ '$id' => 'tags', 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => true, ]), new Document([ '$id' => 'active', 'key' => 'active', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'array' => false, ]), new Document([ '$id' => 'createdAt', 'key' => 'createdAt', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'array' => false, ]), ], ]); } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } // Test parsing string operators (new functionality) - public function testParseStringOperator(): void + public function test_parse_string_operator(): void { $validator = new OperatorValidator($this->collection); @@ -76,7 +78,7 @@ public function testParseStringOperator(): void $this->assertTrue($validator->isValid($json), $validator->getDescription()); } - public function testParseInvalidStringOperator(): void + public function test_parse_invalid_string_operator(): void { $validator = new OperatorValidator($this->collection); @@ -85,7 +87,7 @@ public function testParseInvalidStringOperator(): void $this->assertStringContainsString('Invalid operator:', $validator->getDescription()); } - public function testParseStringOperatorWithInvalidMethod(): void + public function test_parse_string_operator_with_invalid_method(): void { $validator = new OperatorValidator($this->collection); @@ -93,7 +95,7 @@ public function testParseStringOperatorWithInvalidMethod(): void $invalidOperator = json_encode([ 'method' => 'invalidMethod', 'attribute' => 'count', - 'values' => [1] + 'values' => [1], ]); $this->assertFalse($validator->isValid($invalidOperator)); @@ -101,7 +103,7 @@ public function testParseStringOperatorWithInvalidMethod(): void } // Test numeric operators - public function testIncrementOperator(): void + public function test_increment_operator(): void { $validator = new OperatorValidator($this->collection); @@ -111,7 +113,7 @@ public function testIncrementOperator(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testIncrementOnNonNumeric(): void + public function test_increment_on_non_numeric(): void { $validator = new OperatorValidator($this->collection); @@ -122,7 +124,7 @@ public function testIncrementOnNonNumeric(): void $this->assertStringContainsString('Cannot apply increment operator to non-numeric field', $validator->getDescription()); } - public function testDecrementOperator(): void + public function test_decrement_operator(): void { $validator = new OperatorValidator($this->collection); @@ -132,7 +134,7 @@ public function testDecrementOperator(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testMultiplyOperator(): void + public function test_multiply_operator(): void { $validator = new OperatorValidator($this->collection); @@ -142,7 +144,7 @@ public function testMultiplyOperator(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testDivideByZero(): void + public function test_divide_by_zero(): void { $validator = new OperatorValidator($this->collection); @@ -153,7 +155,7 @@ public function testDivideByZero(): void $operator = Operator::divide(0); } - public function testModuloByZero(): void + public function test_modulo_by_zero(): void { $validator = new OperatorValidator($this->collection); @@ -165,7 +167,7 @@ public function testModuloByZero(): void } // Test array operators - public function testArrayAppend(): void + public function test_array_append(): void { $validator = new OperatorValidator($this->collection); @@ -175,7 +177,7 @@ public function testArrayAppend(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testArrayAppendOnNonArray(): void + public function test_array_append_on_non_array(): void { $validator = new OperatorValidator($this->collection); @@ -186,7 +188,7 @@ public function testArrayAppendOnNonArray(): void $this->assertStringContainsString('Cannot apply arrayAppend operator to non-array field', $validator->getDescription()); } - public function testArrayUnique(): void + public function test_array_unique(): void { $validator = new OperatorValidator($this->collection); @@ -196,7 +198,7 @@ public function testArrayUnique(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testArrayUniqueOnNonArray(): void + public function test_array_unique_on_non_array(): void { $validator = new OperatorValidator($this->collection); @@ -207,7 +209,7 @@ public function testArrayUniqueOnNonArray(): void $this->assertStringContainsString('Cannot apply arrayUnique operator to non-array field', $validator->getDescription()); } - public function testArrayIntersect(): void + public function test_array_intersect(): void { $validator = new OperatorValidator($this->collection); @@ -217,7 +219,7 @@ public function testArrayIntersect(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testArrayIntersectWithEmptyArray(): void + public function test_array_intersect_with_empty_array(): void { $validator = new OperatorValidator($this->collection); @@ -228,7 +230,7 @@ public function testArrayIntersectWithEmptyArray(): void $this->assertStringContainsString('requires a non-empty array value', $validator->getDescription()); } - public function testArrayDiff(): void + public function test_array_diff(): void { $validator = new OperatorValidator($this->collection); @@ -238,7 +240,7 @@ public function testArrayDiff(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testArrayFilter(): void + public function test_array_filter(): void { $validator = new OperatorValidator($this->collection); @@ -248,7 +250,7 @@ public function testArrayFilter(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testArrayFilterInvalidCondition(): void + public function test_array_filter_invalid_condition(): void { $validator = new OperatorValidator($this->collection); @@ -260,7 +262,7 @@ public function testArrayFilterInvalidCondition(): void } // Test string operators - public function testStringConcat(): void + public function test_string_concat(): void { $validator = new OperatorValidator($this->collection); @@ -270,7 +272,7 @@ public function testStringConcat(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testStringConcatOnNonString(): void + public function test_string_concat_on_non_string(): void { $validator = new OperatorValidator($this->collection); @@ -281,7 +283,7 @@ public function testStringConcatOnNonString(): void $this->assertStringContainsString('Cannot apply stringConcat operator to non-string field', $validator->getDescription()); } - public function testStringReplace(): void + public function test_string_replace(): void { $validator = new OperatorValidator($this->collection); @@ -292,7 +294,7 @@ public function testStringReplace(): void } // Test boolean operators - public function testToggle(): void + public function test_toggle(): void { $validator = new OperatorValidator($this->collection); @@ -302,7 +304,7 @@ public function testToggle(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testToggleOnNonBoolean(): void + public function test_toggle_on_non_boolean(): void { $validator = new OperatorValidator($this->collection); @@ -314,7 +316,7 @@ public function testToggleOnNonBoolean(): void } // Test date operators - public function testDateAddDays(): void + public function test_date_add_days(): void { $validator = new OperatorValidator($this->collection); @@ -324,7 +326,7 @@ public function testDateAddDays(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testDateAddDaysOnNonDateTime(): void + public function test_date_add_days_on_non_date_time(): void { $validator = new OperatorValidator($this->collection); @@ -335,7 +337,7 @@ public function testDateAddDaysOnNonDateTime(): void $this->assertStringContainsString('Cannot apply dateAddDays operator to non-datetime field', $validator->getDescription()); } - public function testDateSubDays(): void + public function test_date_sub_days(): void { $validator = new OperatorValidator($this->collection); @@ -345,7 +347,7 @@ public function testDateSubDays(): void $this->assertTrue($validator->isValid($operator), $validator->getDescription()); } - public function testDateSubDaysOnNonDateTime(): void + public function test_date_sub_days_on_non_date_time(): void { $validator = new OperatorValidator($this->collection); @@ -356,7 +358,7 @@ public function testDateSubDaysOnNonDateTime(): void $this->assertStringContainsString('Cannot apply dateSubDays operator to non-datetime field', $validator->getDescription()); } - public function testDateSetNow(): void + public function test_date_set_now(): void { $validator = new OperatorValidator($this->collection); @@ -367,7 +369,7 @@ public function testDateSetNow(): void } // Test attribute validation - public function testNonExistentAttribute(): void + public function test_non_existent_attribute(): void { $validator = new OperatorValidator($this->collection); @@ -379,7 +381,7 @@ public function testNonExistentAttribute(): void } // Test multiple operators as strings (like Query validator does) - public function testMultipleStringOperators(): void + public function test_multiple_string_operators(): void { $validator = new OperatorValidator($this->collection); @@ -397,7 +399,7 @@ public function testMultipleStringOperators(): void foreach ($operators as $index => $operator) { $operator->setAttribute($attributes[$index]); $json = $operator->toString(); - $this->assertTrue($validator->isValid($json), "Failed for operator {$attributes[$index]}: " . $validator->getDescription()); + $this->assertTrue($validator->isValid($json), "Failed for operator {$attributes[$index]}: ".$validator->getDescription()); } } } diff --git a/tests/unit/Validator/OperatorValidatorTest.php b/tests/unit/Validator/OperatorValidatorTest.php new file mode 100644 index 0000000000..894c38a985 --- /dev/null +++ b/tests/unit/Validator/OperatorValidatorTest.php @@ -0,0 +1,301 @@ + + */ + public static function singleValueSides(): array + { + return [ + 'one-to-one parent' => [RelationshipType::OneToOne, RelationshipSide::Parent], + 'one-to-one child' => [RelationshipType::OneToOne, RelationshipSide::Child], + 'one-to-many child' => [RelationshipType::OneToMany, RelationshipSide::Child], + 'many-to-one parent' => [RelationshipType::ManyToOne, RelationshipSide::Parent], + ]; + } + + /** + * @return array}> + */ + public static function arrayOperators(): array + { + return [ + 'arrayUnique' => [OperatorType::ArrayUnique, []], + 'arrayInsert' => [OperatorType::ArrayInsert, [0, 'b']], + 'arrayRemove' => [OperatorType::ArrayRemove, ['b']], + 'arrayIntersect' => [OperatorType::ArrayIntersect, ['b']], + 'arrayDiff' => [OperatorType::ArrayDiff, ['b']], + 'arrayFilter' => [OperatorType::ArrayFilter, ['isNotNull']], + ]; + } + + /** + * @return array}> + */ + public static function arrayOperatorsOnSingleValueRelationships(): array + { + $cases = []; + foreach (self::singleValueSides() as $sideName => [$type, $side]) { + foreach (self::arrayOperators() as $operatorName => [$method, $values]) { + $cases["{$operatorName} on {$sideName}"] = [$type, $side, $method, $values]; + } + } + + return $cases; + } + + /** + * @param array $values + */ + #[DataProvider('arrayOperatorsOnSingleValueRelationships')] + public function testArrayOperatorOnASingleValueRelationshipIsRejected(RelationshipType $type, RelationshipSide $side, OperatorType $method, array $values): void + { + $validator = $this->relationshipValidator($type, $side->value); + + $this->assertFalse($validator->isValid(new Operator($method, self::RELATION, $values))); + $this->assertStringContainsString(self::SINGLE_VALUE, $validator->getDescription()); + } + + /** + * @return array}> + */ + public static function nonIdentifierValues(): array + { + return [ + 'arrayAppend of an integer' => [OperatorType::ArrayAppend, [5]], + 'arrayAppend of a list' => [OperatorType::ArrayAppend, [['x']]], + 'arrayPrepend of a boolean' => [OperatorType::ArrayPrepend, [true]], + 'arrayInsert of an integer' => [OperatorType::ArrayInsert, [0, 5]], + 'arrayInsert of a list' => [OperatorType::ArrayInsert, [0, ['x']]], + 'arrayRemove of an integer' => [OperatorType::ArrayRemove, [5]], + 'arrayRemove of a float in a list' => [OperatorType::ArrayRemove, [[1.5]]], + 'arrayIntersect of an integer' => [OperatorType::ArrayIntersect, [5]], + 'arrayDiff of an integer' => [OperatorType::ArrayDiff, [5]], + ]; + } + + /** + * @param array $values + */ + #[DataProvider('nonIdentifierValues')] + public function testNonIdentifierRelationshipValuesAreRejected(OperatorType $method, array $values): void + { + $validator = $this->relationshipValidator(RelationshipType::ManyToMany, RelationshipSide::Parent->value); + + $this->assertFalse($validator->isValid(new Operator($method, self::RELATION, $values))); + $this->assertStringContainsString(self::DOCUMENT_IDS, $validator->getDescription()); + } + + /** + * @return array}> + */ + public static function identifierValues(): array + { + return [ + 'arrayAppend' => [OperatorType::ArrayAppend, ['b', new Document([Document::ID => 'c'])]], + 'arrayInsert' => [OperatorType::ArrayInsert, [0, new Document([Document::ID => 'c'])]], + 'arrayRemove' => [OperatorType::ArrayRemove, [['b', 'c']]], + 'arrayIntersect' => [OperatorType::ArrayIntersect, ['b']], + 'arrayDiff' => [OperatorType::ArrayDiff, [new Document([Document::ID => 'b'])]], + 'arrayUnique' => [OperatorType::ArrayUnique, []], + 'arrayFilter' => [OperatorType::ArrayFilter, ['isNotNull']], + ]; + } + + /** + * @param array $values + */ + #[DataProvider('identifierValues')] + public function testIdentifierValuesOnAManyToManyRelationshipAreAccepted(OperatorType $method, array $values): void + { + $validator = $this->relationshipValidator(RelationshipType::ManyToMany, RelationshipSide::Child->value); + + $this->assertTrue($validator->isValid(new Operator($method, self::RELATION, $values)), $validator->getDescription()); + } + + /** + * @return array + */ + public static function storedValuesOutsideTheRange(): array + { + return [ + 'non-numeric' => ['abc'], + 'above the integer maximum' => [Database::MAX_INT + 1], + 'below the integer minimum' => [Database::MIN_INT - 1], + 'fractional' => [1.5], + ]; + } + + #[DataProvider('storedValuesOutsideTheRange')] + public function testAStoredValueOutsideTheAttributeRangeIsRejected(mixed $stored): void + { + $validator = $this->numericValidator(new Document(['count' => $stored])); + + $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [1]))); + $this->assertSame('Cannot apply increment operator: current value is outside the attribute range', $validator->getDescription()); + } + + public function testAStoredValueInsideTheAttributeRangeIsAccepted(): void + { + $validator = $this->numericValidator(new Document(['count' => Database::MAX_INT - 1])); + + $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, 'count', [1])), $validator->getDescription()); + } + + public function testAResultThatCannotBePredictedIsRejected(): void + { + $validator = $this->numericValidator(new Document(['count' => 2])); + + $this->assertFalse($validator->isValid(new Operator(OperatorType::Power, 'count', [-1]))); + $this->assertSame('Cannot apply power operator: result is outside the attribute range', $validator->getDescription()); + + $this->assertTrue($validator->isValid(new Operator(OperatorType::Power, 'count', [3])), $validator->getDescription()); + } + + /** + * @return array + */ + public static function enumSides(): array + { + return [ + 'one-to-many parent' => [RelationshipType::OneToMany, RelationshipSide::Parent, true], + 'one-to-many child' => [RelationshipType::OneToMany, RelationshipSide::Child, false], + 'many-to-one child' => [RelationshipType::ManyToOne, RelationshipSide::Child, true], + 'many-to-one parent' => [RelationshipType::ManyToOne, RelationshipSide::Parent, false], + ]; + } + + #[DataProvider('enumSides')] + public function testASideGivenAsTheEnumDecidesWhetherTheRelationshipHoldsAList(RelationshipType $type, RelationshipSide $side, bool $holdsAList): void + { + $validator = $this->relationshipValidator($type, $side); + + $this->assertSame($holdsAList, $validator->isValid(new Operator(OperatorType::ArrayAppend, self::RELATION, ['b'])), $validator->getDescription()); + $this->assertSame( + $holdsAList, + $this->relationshipValidator($type, $side->value)->isValid(new Operator(OperatorType::ArrayAppend, self::RELATION, ['b'])), + 'the enum and its string value must agree', + ); + } + + /** + * @return array + */ + public static function invalidFloatOperands(): array + { + return [ + 'non-numeric string' => ['abc'], + 'infinity' => [\INF], + 'negative infinity' => [-\INF], + 'not a number' => [\NAN], + 'string overflowing to infinity' => ['1e999'], + 'boolean' => [true], + ]; + } + + #[DataProvider('invalidFloatOperands')] + public function testNonNumericAndNonFiniteOperandsOnADoubleAreRejected(mixed $operand): void + { + $validator = $this->doubleValidator(); + + $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'ratio', [$operand]))); + $this->assertStringStartsWith('Cannot apply increment operator: value must be numeric', $validator->getDescription()); + } + + /** + * @return array + */ + public static function numericStringOperands(): array + { + return [ + 'fractional string' => ['1.5'], + 'integer string' => ['10'], + 'exponent string' => ['2e3'], + 'negative string' => ['-0.25'], + ]; + } + + #[DataProvider('numericStringOperands')] + public function testNumericStringOperandsOnADoubleAreAccepted(string $operand): void + { + $validator = $this->doubleValidator(); + + $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, 'ratio', [$operand])), $validator->getDescription()); + $this->assertTrue($validator->isValid(new Operator(OperatorType::Multiply, 'ratio', [$operand, '100'])), $validator->getDescription()); + } + + public function testAWholeFloatOperandOnAnIntegerIsAcceptedAsTheInteger(): void + { + $validator = $this->numericValidator(new Document(['count' => Database::MAX_INT - 5])); + + $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, 'count', [5.0])), $validator->getDescription()); + $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [6.0]))); + $this->assertStringContainsString('would overflow maximum value', $validator->getDescription()); + $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [5.5]))); + $this->assertStringStartsWith('Cannot apply increment operator: value must be numeric', $validator->getDescription()); + $this->assertFalse($validator->isValid(new Operator(OperatorType::Divide, 'count', [0.0]))); + $this->assertSame('Cannot apply divide operator: division by zero', $validator->getDescription()); + } + + private function doubleValidator(): OperatorValidator + { + return new OperatorValidator($this->collection([Attribute::double(key: 'ratio')->toDocument()])); + } + + private function numericValidator(?Document $current = null): OperatorValidator + { + return new OperatorValidator($this->collection([Attribute::integer(key: 'count')->toDocument()]), $current); + } + + private function relationshipValidator(RelationshipType $type, RelationshipSide|string $side): OperatorValidator + { + return new OperatorValidator($this->collection([new Document([ + Document::ID => self::RELATION, + 'key' => self::RELATION, + 'type' => ColumnType::Relationship->value, + 'array' => false, + 'options' => [ + 'relatedCollection' => 'others', + 'relationType' => $type->value, + 'twoWay' => true, + 'twoWayKey' => 'back', + 'side' => $side, + ], + ])])); + } + + /** + * @param array $attributes + */ + private function collection(array $attributes): Document + { + return new Document([ + Document::ID => 'operands', + Document::COLLECTION => Database::METADATA, + 'name' => 'operands', + 'attributes' => $attributes, + 'indexes' => [], + ]); + } +} diff --git a/tests/unit/Validator/PermissionsTest.php b/tests/unit/Validator/PermissionsTest.php index d574644633..c60d85c96c 100644 --- a/tests/unit/Validator/PermissionsTest.php +++ b/tests/unit/Validator/PermissionsTest.php @@ -5,42 +5,44 @@ use PHPUnit\Framework\TestCase; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Permission; +use Utopia\Database\Role; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\Roles; class PermissionsTest extends TestCase { - public function setUp(): void + #[\Override] + protected function setUp(): void { } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } /** * @throws DatabaseException */ - public function testSingleMethodSingleValue(): void + public function test_single_method_single_value(): void { $object = new Permissions(); $document = new Document([ - '$id' => ID::unique(), - '$collection' => ID::unique(), + '$id' => Id::unique(), + '$collection' => Id::unique(), '$permissions' => [Permission::create(Role::any())], ]); $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::create(Role::users())]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::create(Role::user(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::create(Role::user(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::create(Role::team(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::create(Role::team(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::create(Role::team(ID::custom('123abc'), 'edit'))]; + $document['$permissions'] = [Permission::create(Role::team(Id::custom('123abc'), 'edit'))]; $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::create(Role::guests())]; $this->assertTrue($object->isValid($document->getPermissions())); @@ -51,11 +53,11 @@ public function testSingleMethodSingleValue(): void $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::read(Role::users())]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::read(Role::user(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::read(Role::user(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::read(Role::team(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::read(Role::team(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::read(Role::team(ID::custom('123abc'), 'viewer'))]; + $document['$permissions'] = [Permission::read(Role::team(Id::custom('123abc'), 'viewer'))]; $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::read(Role::guests())]; $this->assertTrue($object->isValid($document->getPermissions())); @@ -66,11 +68,11 @@ public function testSingleMethodSingleValue(): void $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::update(Role::users())]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::update(Role::user(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::update(Role::user(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::update(Role::team(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::update(Role::team(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::update(Role::team(ID::custom('123abc'), 'edit'))]; + $document['$permissions'] = [Permission::update(Role::team(Id::custom('123abc'), 'edit'))]; $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::update(Role::guests())]; $this->assertTrue($object->isValid($document->getPermissions())); @@ -81,11 +83,11 @@ public function testSingleMethodSingleValue(): void $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::delete(Role::users())]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::delete(Role::user(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::delete(Role::user(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::delete(Role::team(ID::custom('123abc')))]; + $document['$permissions'] = [Permission::delete(Role::team(Id::custom('123abc')))]; $this->assertTrue($object->isValid($document->getPermissions())); - $document['$permissions'] = [Permission::delete(Role::team(ID::custom('123abc'), 'edit'))]; + $document['$permissions'] = [Permission::delete(Role::team(Id::custom('123abc'), 'edit'))]; $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [Permission::delete(Role::guests())]; $this->assertTrue($object->isValid($document->getPermissions())); @@ -95,13 +97,13 @@ public function testSingleMethodSingleValue(): void $this->assertTrue($object->isValid($document->getPermissions())); } - public function testMultipleMethodSingleValue(): void + public function test_multiple_method_single_value(): void { $object = new Permissions(); $document = new Document([ - '$id' => ID::unique(), - '$collection' => ID::unique(), + '$id' => Id::unique(), + '$collection' => Id::unique(), '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -118,23 +120,23 @@ public function testMultipleMethodSingleValue(): void $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [ - Permission::read(Role::user(ID::custom('123abc'))), - Permission::create(Role::user(ID::custom('123abc'))), - Permission::update(Role::user(ID::custom('123abc'))) + Permission::read(Role::user(Id::custom('123abc'))), + Permission::create(Role::user(Id::custom('123abc'))), + Permission::update(Role::user(Id::custom('123abc'))), ]; $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [ - Permission::read(Role::team(ID::custom('123abc'))), - Permission::create(Role::team(ID::custom('123abc'))), - Permission::update(Role::team(ID::custom('123abc'))) + Permission::read(Role::team(Id::custom('123abc'))), + Permission::create(Role::team(Id::custom('123abc'))), + Permission::update(Role::team(Id::custom('123abc'))), ]; $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [ - Permission::read(Role::team(ID::custom('123abc'), 'viewer')), - Permission::create(Role::team(ID::custom('123abc'), 'viewer')), - Permission::update(Role::team(ID::custom('123abc'), 'viewer')) + Permission::read(Role::team(Id::custom('123abc'), 'viewer')), + Permission::create(Role::team(Id::custom('123abc'), 'viewer')), + Permission::update(Role::team(Id::custom('123abc'), 'viewer')), ]; $this->assertTrue($object->isValid($document->getPermissions())); @@ -153,43 +155,43 @@ public function testMultipleMethodSingleValue(): void $this->assertTrue($object->isValid($document->getPermissions())); } - public function testMultipleMethodMultipleValues(): void + public function test_multiple_method_multiple_values(): void { $object = new Permissions(); $document = new Document([ - '$id' => ID::unique(), - '$collection' => ID::unique(), + '$id' => Id::unique(), + '$collection' => Id::unique(), '$permissions' => [ Permission::read(Role::users()), - Permission::create(Role::user(ID::custom('123abc'))), - Permission::create(Role::team(ID::custom('123abc'))), - Permission::update(Role::user(ID::custom('123abc'))), - Permission::update(Role::team(ID::custom('123abc'))), - Permission::delete(Role::user(ID::custom('123abc'))), + Permission::create(Role::user(Id::custom('123abc'))), + Permission::create(Role::team(Id::custom('123abc'))), + Permission::update(Role::user(Id::custom('123abc'))), + Permission::update(Role::team(Id::custom('123abc'))), + Permission::delete(Role::user(Id::custom('123abc'))), ], ]); $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [ - Permission::read(Role::user(ID::custom('123abc'))), - Permission::read(Role::team(ID::custom('123abc'))), - Permission::create(Role::user(ID::custom('123abc'))), - Permission::create(Role::team(ID::custom('123abc'))), - Permission::update(Role::user(ID::custom('123abc'))), - Permission::update(Role::team(ID::custom('123abc'))), - Permission::delete(Role::user(ID::custom('123abc'))) + Permission::read(Role::user(Id::custom('123abc'))), + Permission::read(Role::team(Id::custom('123abc'))), + Permission::create(Role::user(Id::custom('123abc'))), + Permission::create(Role::team(Id::custom('123abc'))), + Permission::update(Role::user(Id::custom('123abc'))), + Permission::update(Role::team(Id::custom('123abc'))), + Permission::delete(Role::user(Id::custom('123abc'))), ]; $this->assertTrue($object->isValid($document->getPermissions())); $document['$permissions'] = [ Permission::read(Role::any()), Permission::create(Role::guests()), - Permission::update(Role::team(ID::custom('123abc'), 'edit')), - Permission::delete(Role::team(ID::custom('123abc'), 'edit')) + Permission::update(Role::team(Id::custom('123abc'), 'edit')), + Permission::delete(Role::team(Id::custom('123abc'), 'edit')), ]; $this->assertTrue($object->isValid($document->getPermissions())); } - public function testInvalidPermissions(): void + public function test_invalid_permissions(): void { $object = new Permissions(); @@ -239,11 +241,11 @@ public function testInvalidPermissions(): void // Permission role:$value must be one of: all, guest, member $this->assertFalse($object->isValid(['read("anyy")'])); - $this->assertEquals('Role "anyy" is not allowed. Must be one of: ' . \implode(', ', Roles::ROLES) . '.', $object->getDescription()); + $this->assertEquals('Role "anyy" is not allowed. Must be one of: '.\implode(', ', Roles::ROLES).'.', $object->getDescription()); $this->assertFalse($object->isValid(['read("gguest")'])); - $this->assertEquals('Role "gguest" is not allowed. Must be one of: ' . \implode(', ', Roles::ROLES) . '.', $object->getDescription()); + $this->assertEquals('Role "gguest" is not allowed. Must be one of: '.\implode(', ', Roles::ROLES).'.', $object->getDescription()); $this->assertFalse($object->isValid(['read("memer:123abc")'])); - $this->assertEquals('Role "memer" is not allowed. Must be one of: ' . \implode(', ', Roles::ROLES) . '.', $object->getDescription()); + $this->assertEquals('Role "memer" is not allowed. Must be one of: '.\implode(', ', Roles::ROLES).'.', $object->getDescription()); // team:$value, member:$value and user:$value must have valid Key for $value // No leading special chars @@ -264,36 +266,36 @@ public function testInvalidPermissions(): void // Shorter than 36 chars - $this->assertTrue($object->isValid([Permission::read(Role::user(ID::custom(str_repeat('a', 36))))])); - $this->assertFalse($object->isValid([Permission::read(Role::user(ID::custom(str_repeat('a', 256))))])); + $this->assertTrue($object->isValid([Permission::read(Role::user(Id::custom(str_repeat('a', 36))))])); + $this->assertFalse($object->isValid([Permission::read(Role::user(Id::custom(str_repeat('a', 256))))])); $this->assertEquals('Role "user" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); // Permission role must begin with one of: member, role, team, user $this->assertFalse($object->isValid(['update("memmber:1234")'])); - $this->assertEquals('Role "memmber" is not allowed. Must be one of: ' . \implode(', ', Roles::ROLES) . '.', $object->getDescription()); + $this->assertEquals('Role "memmber" is not allowed. Must be one of: '.\implode(', ', Roles::ROLES).'.', $object->getDescription()); $this->assertFalse($object->isValid(['update("tteam:1234")'])); - $this->assertEquals('Role "tteam" is not allowed. Must be one of: ' . \implode(', ', Roles::ROLES) . '.', $object->getDescription()); + $this->assertEquals('Role "tteam" is not allowed. Must be one of: '.\implode(', ', Roles::ROLES).'.', $object->getDescription()); $this->assertFalse($object->isValid(['update("userr:1234")'])); - $this->assertEquals('Role "userr" is not allowed. Must be one of: ' . \implode(', ', Roles::ROLES) . '.', $object->getDescription()); + $this->assertEquals('Role "userr" is not allowed. Must be one of: '.\implode(', ', Roles::ROLES).'.', $object->getDescription()); // Team permission - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('_abcd')))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom('_abcd')))])); $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd/')))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom('abcd/')))])); $this->assertEquals('Dimension must not be empty', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom(''), 'abcd'))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom(''), 'abcd'))])); $this->assertEquals('Role "team" must have an ID value.', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd'), '/efgh'))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom('abcd'), '/efgh'))])); $this->assertEquals('Only one dimension can be provided', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd'), 'e/fgh'))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom('abcd'), 'e/fgh'))])); $this->assertEquals('Only one dimension can be provided', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('ab&cd3'), 'efgh'))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom('ab&cd3'), 'efgh'))])); $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom(str_repeat('a', 37)), 'efgh'))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom(str_repeat('a', 37)), 'efgh'))])); $this->assertEquals('Role "team" identifier value is invalid: Parameter must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd'), 'ef*gh'))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom('abcd'), 'ef*gh'))])); $this->assertEquals('Role "team" dimension value is invalid: Parameter must contain at most 81 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); - $this->assertFalse($object->isValid([Permission::read(Role::team(ID::custom('abcd'), str_repeat('a', 82)))])); + $this->assertFalse($object->isValid([Permission::read(Role::team(Id::custom('abcd'), str_repeat('a', 82)))])); $this->assertEquals('Role "team" dimension value is invalid: Parameter must contain at most 81 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', $object->getDescription()); // Permission-list length must be valid @@ -308,11 +310,11 @@ public function testInvalidPermissions(): void /* * Test for checking duplicate methods input. The getPermissions should return an a list array */ - public function testDuplicateMethods(): void + public function test_duplicate_methods(): void { $validator = new Permissions(); - $user = ID::unique(); + $user = Id::unique(); $document = new Document([ '$id' => uniqid(), @@ -327,23 +329,31 @@ public function testDuplicateMethods(): void ], 'title' => 'This is a test.', 'list' => [ - 'one' + 'one', ], 'children' => [ new Document(['name' => 'x']), new Document(['name' => 'y']), new Document(['name' => 'z']), - ] + ], ]); $this->assertTrue($validator->isValid($document->getPermissions())); $permissions = $document->getPermissions(); $this->assertEquals(5, count($permissions)); $this->assertEquals([ 'read("any")', - 'read("user:' . $user . '")', - 'write("user:' . $user . '")', - 'update("user:' . $user . '")', - 'delete("user:' . $user . '")', + 'read("user:'.$user.'")', + 'write("user:'.$user.'")', + 'update("user:'.$user.'")', + 'delete("user:'.$user.'")', ], $permissions); } + + public function test_custom_type_rejected_as_in_7_4(): void + { + $object = new Permissions(); + + $this->assertFalse($object->isValid(['execute("any")'])); + $this->assertSame('Permission "execute("any")" is not allowed. Must be one of: create, read, update, delete, write.', $object->getDescription()); + } } diff --git a/tests/unit/Validator/ProfileConstructionTest.php b/tests/unit/Validator/ProfileConstructionTest.php new file mode 100644 index 0000000000..8c59b6657c --- /dev/null +++ b/tests/unit/Validator/ProfileConstructionTest.php @@ -0,0 +1,183 @@ +assertTrue($validator->isValid(Attribute::string(key: 'title', size: 100))); + $this->assertRefused('Max size allowed for string is: 100', static fn (): bool => $validator->isValid(Attribute::string(key: 'title', size: 101))); + $this->assertRefused('Max size allowed for varchar is: 50', static fn (): bool => $validator->isValid(Attribute::varchar(key: 'code', size: 51))); + } + + public function testAnAttributeDefinitionCountsColumnsAndWidthAgainstTheLimits(): void + { + $profile = Profiles::of(string: 1000, attributes: 3, documentSize: 100); + $attribute = Attribute::string(key: 'title', size: 10); + + $withinLimits = new AttributeDefinition([], $profile, attributeCount: static fn (Document $attribute): int => 3, attributeWidth: static fn (Document $attribute): int => 99); + $this->assertTrue($withinLimits->isValid($attribute)); + + $tooMany = new AttributeDefinition([], $profile, attributeCount: static fn (Document $attribute): int => 4, attributeWidth: static fn (Document $attribute): int => 0); + $this->expectException(LimitException::class); + $this->expectExceptionMessage('Current attribute count is 4 but the maximum is 3'); + $tooMany->isValid($attribute); + } + + public function testAnAttributeDefinitionAcceptsTheTypesTheProfileOffers(): void + { + $bare = new AttributeDefinition([], Profiles::of()); + $this->assertRefused('Vector types are not supported by the current database', static fn (): bool => $bare->isValid(Attribute::vector(key: 'embedding', dimensions: 3))); + $this->assertRefused('Spatial attributes are not supported', static fn (): bool => $bare->isValid(Attribute::point(key: 'location'))); + $this->assertRefused('Object attributes are not supported', static fn (): bool => $bare->isValid(Attribute::object(key: 'meta'))); + + $capable = new AttributeDefinition([], Profiles::of(capabilities: [Capability::Vectors, Capability::Objects], features: [Feature\Spatial::class])); + $this->assertTrue($capable->isValid(Attribute::vector(key: 'embedding', dimensions: 3))); + $this->assertTrue($capable->isValid(Attribute::point(key: 'location'))); + $this->assertTrue($capable->isValid(Attribute::object(key: 'meta'))); + } + + public function testTheAvailableTypesFollowTheProfile(): void + { + $bare = Attribute::availableTypes(Profiles::of()); + $this->assertNotContains(ColumnType::Object, $bare); + $this->assertNotContains(ColumnType::Point, $bare); + $this->assertNotContains(ColumnType::Linestring, $bare); + $this->assertNotContains(ColumnType::Polygon, $bare); + $this->assertNotContains(ColumnType::Vector, $bare); + $this->assertContains(ColumnType::String, $bare); + $this->assertContains(ColumnType::Relationship, $bare); + + $full = Attribute::availableTypes(Profiles::of(capabilities: [Capability::Vectors, Capability::Objects], features: [Feature\Spatial::class])); + $this->assertSame(Attribute::TYPES, $full); + + $spatialOnly = Attribute::availableTypes(Profiles::of(features: [Feature\Spatial::class])); + $this->assertSame( + [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], + \array_values(\array_filter($spatialOnly, static fn (ColumnType $type): bool => ! \in_array($type, $bare, true))), + ); + } + + public function testAnIndexDefinitionTakesItsLengthAndReservedKeysFromTheLimits(): void + { + $attributes = [Attribute::string(key: 'title', size: 100)]; + $profile = Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::IndexKey], indexLength: 99, internalIndexKeys: ['primary']); + + $validator = new IndexDefinition($attributes, [], $profile); + $this->assertFalse($validator->isValid(Index::key(key: 'title_key', attributes: ['title']))); + $this->assertSame('Index length is longer than the maximum: 99', $validator->getDescription()); + + $validator = new IndexDefinition($attributes, [], $profile); + $this->assertFalse($validator->isValid(Index::key(key: 'primary', attributes: ['title'], lengths: [10]))); + $this->assertSame('Index key name is reserved', $validator->getDescription()); + } + + public function testAnIndexDefinitionAcceptsOnlyTheIndexTypesTheProfileSupports(): void + { + $attributes = [Attribute::string(key: 'title', size: 100)]; + $index = Index::key(key: 'title_key', attributes: ['title']); + + $without = new IndexDefinition($attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes], indexLength: 768)); + $this->assertFalse($without->isValid($index)); + $this->assertSame('Key index is not supported', $without->getDescription()); + + $with = new IndexDefinition($attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::IndexKey], indexLength: 768)); + $this->assertTrue($with->isValid($index)); + } + + public function testADocumentsValidatorTakesItsGrammarFromTheProfile(): void + { + $attributes = [Attribute::string(key: 'title', size: 64)]; + $count = [Query::count('*', 'rows')]; + $join = [Query::join('other', 'o', [Query::on('$id', 'o.$id')])]; + + $plain = new Documents($attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes])); + $this->assertFalse($plain->isValid($count)); + $this->assertSame('Invalid query method: count', $plain->getDescription()); + $this->assertFalse($plain->isValid($join)); + + $grammar = new Documents($attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::Aggregations, Capability::Joins])); + $this->assertTrue($grammar->isValid($count)); + } + + public function testQueryValidatorsTakeTheUidLengthFromTheLimits(): void + { + $attributes = [Attribute::string(key: 'title', size: 64)]; + $cursor = [Query::cursorAfter(new Document(['$id' => \str_repeat('a', 40)]))]; + + $this->assertFalse(new Documents($attributes, [], Profiles::of(uidLength: 36))->isValid($cursor)); + $this->assertTrue(new Documents($attributes, [], Profiles::of(uidLength: 255))->isValid($cursor)); + $this->assertFalse(Narrow::of($cursor, $attributes, Profiles::of(uidLength: 36), 5000)?->isValid($cursor) ?? true); + $this->assertTrue(Narrow::of($cursor, $attributes, Profiles::of(uidLength: 255), 5000)?->isValid($cursor) ?? false); + } + + public function testADocumentValidatorAcceptsTheTenantOnlyUnderSharedTables(): void + { + $select = [Query::select(['$tenant'])]; + + $this->assertFalse(new DocumentValidator([], Profiles::of(capabilities: [Capability::DefinedAttributes]))->isValid($select)); + $this->assertTrue(new DocumentValidator([], Profiles::of(capabilities: [Capability::DefinedAttributes], sharedTables: true))->isValid($select)); + } + + public function testAStructureTakesItsDatetimeRangeFromTheLimits(): void + { + $collection = Collection::create(id: 'events', attributes: [Attribute::datetime(key: 'at')]); + $collection->setAttribute('$collection', Database::METADATA); + $profile = Profiles::of(capabilities: [Capability::DefinedAttributes], minDateTime: new DateTime('2000-01-01'), maxDateTime: new DateTime('2100-01-01')); + + $this->assertTrue(new Structure($collection, $profile)->isValid($this->event('2050-06-01 00:00:00'))); + + $structure = new Structure($collection, $profile); + $this->assertFalse($structure->isValid($this->event('1999-12-31 00:00:00'))); + $this->assertStringContainsString('2000-01-01', $structure->getDescription()); + } + + private function event(string $at): Document + { + return new Document([ + '$id' => 'event', + '$collection' => 'events', + '$permissions' => [], + '$createdAt' => '2050-01-01 00:00:00', + '$updatedAt' => '2050-01-01 00:00:00', + 'at' => $at, + ]); + } + + /** + * @param callable(): bool $validation + */ + private function assertRefused(string $message, callable $validation): void + { + try { + $validation(); + $this->fail('Expected the validator to refuse with: '.$message); + } catch (DatabaseException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + } +} diff --git a/tests/unit/Validator/Queries/IndexedTest.php b/tests/unit/Validator/Queries/IndexedTest.php new file mode 100644 index 0000000000..71fc5a6270 --- /dev/null +++ b/tests/unit/Validator/Queries/IndexedTest.php @@ -0,0 +1,556 @@ +assertEquals(true, $validator->isValid([])); + } + + public function test_invalid_query(): void + { + $validator = new Indexed(); + + $this->assertEquals(false, $validator->isValid(['this.is.invalid'])); + } + + public function test_invalid_method(): void + { + $validator = new Indexed(); + $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); + + $validator = new Indexed([], [], [new Limit()]); + $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); + } + + public function test_invalid_value(): void + { + $validator = new Indexed([], [], [new Limit()]); + $this->assertEquals(false, $validator->isValid(['limit(-1)'])); + } + + public function test_valid(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $indexes = [ + new Document([ + 'type' => IndexType::Key->value, + 'attributes' => ['name'], + ]), + new Document([ + 'type' => IndexType::Fulltext->value, + 'attributes' => ['name'], + ]), + ]; + + $validator = new Indexed( + $attributes, + $indexes, + [ + new Cursor(), + new Filter($attributes, ColumnType::Integer->value), + new Limit(), + new Offset(), + new Order($attributes), + ] + ); + + $query = Query::cursorAfter(new Document(['$id' => 'abc'])); + $this->assertEquals(true, $validator->isValid([$query])); + $query = Query::parse('{"method":"cursorAfter","attribute":"","values":["abc"]}'); + $this->assertEquals(true, $validator->isValid([$query])); + + $query = Query::parse('{"method":"cursorAfter","values":["abc"]}'); // No attribute required + $this->assertEquals(true, $validator->isValid([$query])); + + $query = Query::equal('name', ['value']); + $this->assertEquals(true, $validator->isValid([$query])); + $query = Query::parse('{"method":"equal","attribute":"name","values":["value"]}'); + $this->assertEquals(true, $validator->isValid([$query])); + + $query = Query::limit(10); + $this->assertEquals(true, $validator->isValid([$query])); + $query = Query::parse('{"method":"limit","values":[10]}'); + $this->assertEquals(true, $validator->isValid([$query])); + + $query = Query::offset(10); + $this->assertEquals(true, $validator->isValid([$query])); + $query = Query::parse('{"method":"offset","values":[10]}'); + $this->assertEquals(true, $validator->isValid([$query])); + + $query = Query::orderAsc('name'); + $this->assertEquals(true, $validator->isValid([$query])); + $query = Query::parse('{"method":"orderAsc","attribute":"name"}'); // No values required + $this->assertEquals(true, $validator->isValid([$query])); + + $query = Query::search('name', 'value'); + $this->assertEquals(true, $validator->isValid([$query])); + $query = Query::parse('{"method":"search","attribute":"name","values":["value"]}'); + $this->assertEquals(true, $validator->isValid([$query])); + } + + public function test_missing_index(): void + { + $attributes = [ + new Document([ + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $indexes = [ + new Document([ + 'type' => IndexType::Key->value, + 'attributes' => ['name'], + ]), + ]; + + $validator = new Indexed( + $attributes, + $indexes, + [ + new Cursor(), + new Filter($attributes, ColumnType::Integer->value), + new Limit(), + new Offset(), + new Order($attributes), + ] + ); + + $query = Query::equal('dne', ['value']); + $this->assertEquals(false, $validator->isValid([$query])); + $this->assertEquals('Invalid query: Attribute not found in schema: dne', $validator->getDescription()); + + $query = Query::orderAsc('dne'); + $this->assertEquals(false, $validator->isValid([$query])); + $this->assertEquals('Invalid query: Attribute not found in schema: dne', $validator->getDescription()); + + $query = Query::search('dne', 'phrase'); + $this->assertEquals(false, $validator->isValid([$query])); + $this->assertEquals('Invalid query: Attribute not found in schema: dne', $validator->getDescription()); + + $query = Query::search('name', 'phrase'); + $this->assertEquals(false, $validator->isValid([$query])); + $this->assertEquals('Searching by attribute "name" requires a fulltext index.', $validator->getDescription()); + } + + public function test_join_side_search_skips_main_fulltext_index(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Indexed( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + $validator->setJoinedCollections([new Document([ + '$id' => 'meta', + 'attributes' => [ + new Document([ + '$id' => 'mainId', + 'key' => 'mainId', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'body', + 'key' => 'body', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ], + 'indexes' => [ + new Document([ + '$id' => 'body_fulltext', + 'type' => IndexType::Fulltext->value, + 'attributes' => ['body'], + ]), + ], + ])]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('meta', 'meta', [Query::on('$id', 'mainId')]), + Query::search('meta.body', 'needle'), + ]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::search('name', 'needle'), + ])); + $this->assertSame( + 'Searching by attribute "name" requires a fulltext index.', + $validator->getDescription() + ); + } + + public function testNestedJoinOnRefusesSearchWithOrWithoutAFulltextIndex(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Indexed( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + $validator->setJoinedCollections([new Document([ + '$id' => 'meta', + 'attributes' => [ + new Document([ + '$id' => 'mainId', + 'key' => 'mainId', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'body', + 'key' => 'body', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ], + 'indexes' => [ + new Document([ + '$id' => 'body_fulltext', + 'type' => IndexType::Fulltext->value, + 'attributes' => ['body'], + ]), + ], + ])]); + + $this->assertFalse($validator->isValid([ + new Query(Method::LeftJoin, 'meta', [ + Query::on('$id', 'mainId'), + Query::search('name', 'needle'), + ], 'meta'), + ])); + $this->assertSame( + 'Invalid query: Unsupported join ON condition: search', + $validator->getDescription() + ); + + $this->assertFalse($validator->isValid([ + new Query(Method::LeftJoin, 'meta', [ + Query::on('$id', 'mainId'), + Query::search('meta.body', 'needle'), + ], 'meta'), + ]), 'the builder compiles no search into an ON list, fulltext index or not'); + $this->assertSame( + 'Invalid query: Unsupported join ON condition: search', + $validator->getDescription() + ); + } + + public function testNestedJoinOnRefusesAVectorQueryNextToAnother(): void + { + $attributes = [ + new Document([ + '$id' => 'embedding', + 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'array' => false, + ]), + ]; + + $validator = new Indexed( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + + $this->assertFalse($validator->isValid([ + Query::vectorDot('embedding', [0.1, 0.2, 0.3]), + new Query(Method::LeftJoin, 'meta', [ + Query::on('$id', 'mainId'), + Query::vectorCosine('embedding', [0.3, 0.4, 0.5]), + ], 'meta'), + ])); + $this->assertSame( + 'Invalid query: Unsupported join ON condition: vectorCosine', + $validator->getDescription() + ); + } + + public function testNestedJoinOnRefusesASingleVectorQuery(): void + { + $attributes = [ + new Document([ + '$id' => 'embedding', + 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'array' => false, + ]), + ]; + + $validator = new Indexed( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + + $this->assertFalse($validator->isValid([ + new Query(Method::LeftJoin, 'meta', [ + Query::on('$id', 'mainId'), + Query::vectorCosine('embedding', [0.3, 0.4, 0.5]), + ], 'meta'), + ])); + $this->assertSame( + 'Invalid query: Unsupported join ON condition: vectorCosine', + $validator->getDescription() + ); + } + + public function test_two_attributes_fulltext(): void + { + $attributes = [ + new Document([ + '$id' => 'ft1', + 'key' => 'ft1', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'ft2', + 'key' => 'ft2', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $indexes = [ + new Document([ + 'type' => IndexType::Fulltext->value, + 'attributes' => ['ft1', 'ft2'], + ]), + ]; + + $validator = new Indexed( + $attributes, + $indexes, + [ + new Cursor(), + new Filter($attributes, ColumnType::Integer->value), + new Limit(), + new Offset(), + new Order($attributes), + ] + ); + + $this->assertEquals(false, $validator->isValid([Query::search('ft1', 'value')])); + } + + public function test_json_parse(): void + { + try { + Query::parse('{"method":"equal","attribute":"name","values":["value"]'); // broken Json; + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Invalid query: Syntax error', $e->getMessage()); + } + } + + public function test_single_vector_query_passes(): void + { + $attributes = [ + new Document([ + '$id' => 'embedding', + 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'array' => false, + ]), + ]; + + $validator = new Indexed( + $attributes, + [], + [new Filter($attributes, ColumnType::Integer->value)] + ); + + $vectorQuery = Query::vectorCosine('embedding', [0.1, 0.2, 0.3]); + $this->assertTrue($validator->isValid([$vectorQuery])); + } + + public function test_nested_queries_containing_vector_methods(): void + { + $attributes = [ + new Document([ + '$id' => 'embedding', + 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'array' => false, + ]), + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Indexed( + $attributes, + [], + [new Filter($attributes, ColumnType::Integer->value)] + ); + + $orQuery = Query::or([ + Query::equal('name', ['alice']), + Query::equal('name', ['bob']), + ]); + $vectorQuery = Query::vectorDot('embedding', [0.1, 0.2, 0.3]); + $this->assertTrue($validator->isValid([$orQuery, $vectorQuery])); + } + + public function test_unparseable_string_query_returns_error(): void + { + $validator = new Indexed([], [], [new Limit()]); + + $this->assertFalse($validator->isValid(['totally broken }{'])); + $this->assertStringContainsString('Invalid query', $validator->getDescription()); + } + + public function test_nested_non_having_with_invalid_sub_queries(): void + { + $validator = new Indexed([], [], [new Filter([], ColumnType::Integer->value)]); + + $nestedOr = Query::or([Query::equal('nonexistent', ['value'])]); + $this->assertFalse($validator->isValid([$nestedOr])); + } + + public function test_nested_search_requires_fulltext_index(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'title', + 'key' => 'title', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $indexes = [ + new Document([ + 'type' => IndexType::Fulltext->value, + 'attributes' => ['title'], + ]), + ]; + + $validator = new Indexed( + $attributes, + $indexes, + [new Filter($attributes, ColumnType::Integer->value)] + ); + + $this->assertTrue($validator->isValid([ + Query::or([ + Query::search('title', 'foo'), + Query::equal('name', ['bar']), + ]), + ])); + + $this->assertFalse($validator->isValid([ + Query::or([ + Query::search('name', 'foo'), + Query::equal('title', ['bar']), + ]), + ])); + $this->assertEquals('Searching by attribute "name" requires a fulltext index.', $validator->getDescription()); + } + + public function test_multiple_vector_queries_fails(): void + { + $attributes = [ + new Document([ + '$id' => 'embedding', + 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => 3, + 'array' => false, + ]), + ]; + + $validator = new Indexed( + $attributes, + [], + [new Filter($attributes, ColumnType::Integer->value)] + ); + + $vectorQuery1 = Query::vectorCosine('embedding', [0.1, 0.2, 0.3]); + $vectorQuery2 = Query::vectorEuclidean('embedding', [0.4, 0.5, 0.6]); + + $this->assertFalse($validator->isValid([$vectorQuery1, $vectorQuery2])); + $this->assertEquals('Cannot use multiple vector queries in a single request', $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 40e8d7671b..7d386673cc 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -4,75 +4,84 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Queries\Base; +use Utopia\Database\Validator\Query\Aggregate; use Utopia\Database\Validator\Query\Cursor; +use Utopia\Database\Validator\Query\Distinct; use Utopia\Database\Validator\Query\Filter; +use Utopia\Database\Validator\Query\GroupBy; +use Utopia\Database\Validator\Query\Having; +use Utopia\Database\Validator\Query\Join; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Order; +use Utopia\Database\Validator\Query\Select; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; class QueriesTest extends TestCase { - public function setUp(): void + #[\Override] + protected function setUp(): void { } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } - public function testEmptyQueries(): void + public function test_empty_queries(): void { - $validator = new Queries(); + $validator = new Base(); $this->assertEquals(true, $validator->isValid([])); } - public function testInvalidMethod(): void + public function test_invalid_method(): void { - $validator = new Queries(); - $this->assertEquals(false, $validator->isValid([Query::equal('attr', ["value"])])); + $validator = new Base(); + $this->assertEquals(false, $validator->isValid([Query::equal('attr', ['value'])])); - $validator = new Queries([new Limit()]); - $this->assertEquals(false, $validator->isValid([Query::equal('attr', ["value"])])); + $validator = new Base([new Limit()]); + $this->assertEquals(false, $validator->isValid([Query::equal('attr', ['value'])])); } - public function testInvalidValue(): void + public function test_invalid_value(): void { - $validator = new Queries([new Limit()]); + $validator = new Base([new Limit()]); $this->assertEquals(false, $validator->isValid([Query::limit(-1)])); } /** * @throws Exception */ - public function testValid(): void + public function test_valid(): void { $attributes = [ new Document([ '$id' => 'name', 'key' => 'name', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), new Document([ '$id' => 'meta', 'key' => 'meta', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'array' => false, ]), ]; - $validator = new Queries( + $validator = new Base( [ new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), + new Filter($attributes, ColumnType::Integer->value), new Limit(), new Offset(), - new Order($attributes) + new Order($attributes), ] ); @@ -105,7 +114,7 @@ public function testValid(): void // Object attribute query: disallowed complex multi-key nested structure $this->assertTrue( $validator->isValid([ - Query::contains('meta', [ + Query::containsAny('meta', [ [ 'role' => [ 'name' => ['test1', 'test2'], @@ -116,4 +125,555 @@ public function testValid(): void ]) ); } + + public function test_non_array_value_returns_false(): void + { + $validator = new Base(); + + $this->assertFalse($validator->isValid('not_an_array')); + $this->assertEquals('Queries must be an array', $validator->getDescription()); + + $this->assertFalse($validator->isValid(42)); + $this->assertFalse($validator->isValid(null)); + } + + public function test_query_count_exceeds_length(): void + { + $validator = new Base([new Limit()], length: 2); + + $this->assertFalse($validator->isValid([ + Query::limit(10), + Query::limit(20), + Query::limit(30), + ])); + } + + public function test_aggregation_queries_add_aliases_to_order_validators(): void + { + $attributes = [ + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Aggregate($attributes), + new Order($attributes), + ]); + + $this->assertTrue($validator->isValid([ + Query::avg('price', 'avg_price'), + Query::orderAsc('avg_price'), + ])); + } + + public function test_variance_and_stddev_method_type_mapping(): void + { + $validator = new Base([new Aggregate(supportForAttributes: false)]); + + $this->assertTrue($validator->isValid([Query::variance('col', 'var_col')])); + $this->assertTrue($validator->isValid([Query::stddev('col', 'std_col')])); + } + + public function test_distinct_method_type_mapping(): void + { + $validator = new Base([new Distinct()]); + + $this->assertTrue($validator->isValid([Query::distinct()])); + } + + public function test_group_by_method_type_mapping(): void + { + $validator = new Base([new GroupBy(supportForAttributes: false)]); + + $this->assertTrue($validator->isValid([Query::groupBy(['category'])])); + } + + public function test_having_method_type_mapping(): void + { + $validator = new Base([new Having()]); + + $this->assertTrue($validator->isValid([Query::having([Query::greaterThan('count', 5)])])); + } + + public function test_join_method_type_mapping(): void + { + $validator = new Base([new Join()]); + + $this->assertTrue($validator->isValid([Query::join('orders', 'j0', [Query::on('user_id', 'id')])])); + } + + public function test_aggregate_and_group_by_accept_joined_attributes(): void + { + // `score` and `rev.score` live on the joined collection, so they are absent from + // this collection's schema. Rejecting them broke every join aggregation test. + $attributes = [ + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Aggregate($attributes), + new GroupBy($attributes), + new Join(), + ]); + $validator->setJoinedCollections([new Document([ + '$id' => 'reviews', + 'attributes' => [ + new Document([ + '$id' => 'score', + 'key' => 'score', + 'type' => ColumnType::Integer->value, + 'array' => false, + ]), + ], + ])]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('reviews', 'rev', [Query::on('productId', '$id')]), + Query::sum('rev.score'), + Query::groupBy(['score']), + ])); + } + + public function test_aggregate_and_group_by_reject_unknown_attributes_without_a_join(): void + { + $attributes = [ + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Aggregate($attributes), + new GroupBy($attributes), + new Join(), + ]); + + $this->assertFalse($validator->isValid([Query::sum('score')])); + $this->assertFalse($validator->isValid([Query::groupBy(['score'])])); + } + + public function test_aggregate_and_group_by_reject_an_undeclared_join_alias(): void + { + $attributes = [ + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Aggregate($attributes), + new GroupBy($attributes), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'rev', [Query::on('productId', '$id')]), + Query::sum('revv.score'), + ]), 'an aggregate qualified with an undeclared alias must not pass'); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'rev', [Query::on('productId', '$id')]), + Query::groupBy(['revv.score']), + ]), 'a groupBy qualified with an undeclared alias must not pass'); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'rev', [Query::on('productId', '$id')]), + Query::sum('rev.score.nested'), + ]), 'a multi-segment join column must not pass'); + } + + public function test_join_alias_stand_down_does_not_leak_into_the_next_query_set(): void + { + $attributes = [ + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Aggregate($attributes), + new GroupBy($attributes), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('reviews', 'rev', [Query::on('productId', '$id')]), + Query::sum('rev.score'), + ])); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'other', [Query::on('productId', '$id')]), + Query::sum('rev.score'), + ]), 'an alias from the previous query set must not stay valid'); + } + + public function test_joined_attribute_stand_down_does_not_leak_into_the_next_query_set(): void + { + // Queries caches its validators, so a join in one request must not leave the + // schema check disabled for the next one. + $attributes = [ + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Aggregate($attributes), + new GroupBy($attributes), + new Join(), + ]); + $validator->setJoinedCollections([new Document([ + '$id' => 'reviews', + 'attributes' => [ + new Document([ + '$id' => 'score', + 'key' => 'score', + 'type' => ColumnType::Integer->value, + 'array' => false, + ]), + ], + ])]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('reviews', 'rev', [Query::on('productId', '$id')]), + Query::sum('score'), + ])); + + $this->assertFalse($validator->isValid([Query::sum('score')])); + } + + public function test_select_before_join_accepts_dotted_alias(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Select($attributes), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::select(['ord.amount']), + Query::join('orders', 'ord', [Query::on('$id', 'customer_uid')]), + ]), $validator->getDescription()); + } + + public function test_filter_before_join_accepts_dotted_alias(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::equal('sec.amount', [777]), + Query::join('orders', 'sec', [Query::on('$id', 'customer_uid')]), + ]), $validator->getDescription()); + } + + public function testNestedJoinAliasIsCollected(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Select($attributes), + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::select(['ord.amount']), + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customer_uid'), + Query::equal('ord.status', ['paid']), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnFilterUnknownAliasIsInvalid(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customer_uid'), + Query::equal('missing.status', ['paid']), + ]), + ])); + $this->assertStringContainsString('Attribute not found in schema', $validator->getDescription()); + } + + public function test_order_before_join_accepts_dotted_alias(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Order($attributes), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::orderAsc('sec.amount'), + Query::join('orders', 'sec', [Query::on('$id', 'customer_uid')]), + ]), $validator->getDescription()); + } + + public function test_unknown_join_alias_filter_is_rejected(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::equal('other.amount', [777]), + Query::join('orders', 'sec', [Query::on('$id', 'customer_uid')]), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: other', $validator->getDescription()); + } + + public function test_nested_and_or_join_alias_is_accepted(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'rank', + 'key' => 'rank', + 'type' => ColumnType::Integer->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::join('meta', 'meta', [Query::on('$id', 'mainId')]), + Query::and([ + Query::equal('name', ['Main']), + Query::or([ + Query::equal('meta.score', [10]), + Query::equal('rank', [2]), + ]), + ]), + ]), $validator->getDescription()); + } + + public function test_nested_and_or_unknown_join_alias_is_rejected(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'rank', + 'key' => 'rank', + 'type' => ColumnType::Integer->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::join('meta', 'meta', [Query::on('$id', 'mainId')]), + Query::and([ + Query::equal('name', ['Main']), + Query::or([ + Query::equal('other.score', [10]), + Query::equal('rank', [2]), + ]), + ]), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: other', $validator->getDescription()); + } + + public function test_nested_and_or_multi_segment_join_column_is_rejected(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'rank', + 'key' => 'rank', + 'type' => ColumnType::Integer->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::join('meta', 'meta', [Query::on('$id', 'mainId')]), + Query::and([ + Query::equal('name', ['Main']), + Query::or([ + Query::equal('meta.foo.bar', [10]), + Query::equal('rank', [2]), + ]), + ]), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: meta', $validator->getDescription()); + } + + public function test_join_alias_reset_between_isValid_calls(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'rank', + 'key' => 'rank', + 'type' => ColumnType::Integer->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::join('meta', 'meta', [Query::on('$id', 'mainId')]), + Query::equal('meta.score', [10]), + ]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::join('peer', 'peer', [Query::on('$id', 'mainId')]), + Query::equal('meta.score', [8686]), + ])); + $this->assertStringContainsString('Attribute not found', $validator->getDescription()); + $this->assertStringContainsString('meta', $validator->getDescription()); + } + + public function test_nested_non_string_query_is_rejected(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + ]); + + $this->assertFalse($validator->isValid([ + new Query(Method::And, '', [123, Query::equal('name', ['Main'])]), + ])); + $this->assertSame('Invalid query: nested query must be a string', $validator->getDescription()); + } + + public function test_is_array(): void + { + $validator = new Base(); + + $this->assertTrue($validator->isArray()); + } + + public function test_get_type(): void + { + $validator = new Base(); + + $this->assertEquals('object', $validator->getType()); + } } diff --git a/tests/unit/Validator/Query/AggregateTest.php b/tests/unit/Validator/Query/AggregateTest.php new file mode 100644 index 0000000000..7c6a05f2a1 --- /dev/null +++ b/tests/unit/Validator/Query/AggregateTest.php @@ -0,0 +1,62 @@ +validator = new Aggregate( + attributes: [ + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => ColumnType::Double->value, + 'array' => false, + ]), + ], + ); + } + + public function testValueSuccess(): void + { + $this->assertTrue($this->validator->isValid(Query::sum('price'))); + $this->assertTrue($this->validator->isValid(Query::avg('price', 'avg_price'))); + $this->assertTrue($this->validator->isValid(Query::count('*', 'cnt'))); + $this->assertTrue($this->validator->isValid(Query::count())); + $this->assertTrue($this->validator->isValid(Query::countDistinct('$id'))); + } + + public function testUnknownAttributeRejected(): void + { + // Reaches the adapter as a bare identifier, so an unknown name is a 500 from the + // engine rather than a 400 from validation, and doubles as a probe for column names. + $this->assertFalse($this->validator->isValid(Query::sum('nonexistent'))); + $this->assertSame('Attribute not found in schema: nonexistent', $this->validator->getDescription()); + } + + public function testInvalidAliasRejected(): void + { + $this->assertFalse($this->validator->isValid(Query::sum('price', 'a b'))); + $this->assertSame('Invalid aggregate alias', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(Query::sum('price', '1alias'))); + $this->assertSame('Invalid aggregate alias', $this->validator->getDescription()); + } + + public function testUnknownAttributeAllowedWithoutAttributeSupport(): void + { + $validator = new Aggregate(attributes: [], supportForAttributes: false); + + $this->assertTrue($validator->isValid(Query::sum('anything'))); + } +} diff --git a/tests/unit/Validator/Query/CursorTest.php b/tests/unit/Validator/Query/CursorTest.php index 7f18065498..2421cf40c5 100644 --- a/tests/unit/Validator/Query/CursorTest.php +++ b/tests/unit/Validator/Query/CursorTest.php @@ -5,18 +5,19 @@ use PHPUnit\Framework\TestCase; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Cursor; +use Utopia\Query\Method; class CursorTest extends TestCase { - public function testValueSuccess(): void + public function test_value_success(): void { $validator = new Cursor(); - $this->assertTrue($validator->isValid(new Query(Query::TYPE_CURSOR_AFTER, values: ['asdf']))); - $this->assertTrue($validator->isValid(new Query(Query::TYPE_CURSOR_BEFORE, values: ['asdf']))); + $this->assertTrue($validator->isValid(new Query(Method::CursorAfter, values: ['asdf']))); + $this->assertTrue($validator->isValid(new Query(Method::CursorBefore, values: ['asdf']))); } - public function testValueFailure(): void + public function test_value_failure(): void { $validator = new Cursor(); @@ -29,4 +30,27 @@ public function testValueFailure(): void $this->assertFalse($validator->isValid(Query::orderAsc('attr'))); $this->assertFalse($validator->isValid(Query::orderDesc('attr'))); } + + public function test_non_query_value_returns_false(): void + { + $validator = new Cursor(); + + $this->assertFalse($validator->isValid('some_string')); + $this->assertFalse($validator->isValid(42)); + $this->assertFalse($validator->isValid(null)); + $this->assertFalse($validator->isValid(['array'])); + } + + public function test_invalid_cursor_value_fails_uid_validation(): void + { + $validator = new Cursor(); + + $tooLong = str_repeat('x', 300); + $query = new Query(Method::CursorAfter, values: [$tooLong]); + $this->assertFalse($validator->isValid($query)); + $this->assertStringContainsString('Invalid cursor', $validator->getDescription()); + + $emptyQuery = new Query(Method::CursorBefore, values: ['']); + $this->assertFalse($validator->isValid($emptyQuery)); + } } diff --git a/tests/unit/Validator/Query/FilterObjectPathTest.php b/tests/unit/Validator/Query/FilterObjectPathTest.php new file mode 100644 index 0000000000..c90b3bdec6 --- /dev/null +++ b/tests/unit/Validator/Query/FilterObjectPathTest.php @@ -0,0 +1,88 @@ +validator = new Filter( + attributes: [ + Attribute::object(key: 'meta'), + Attribute::string(key: 'secret', size: 64), + ], + idAttributeType: ColumnType::Integer->value, + ); + } + + /** + * @return array + */ + public static function acceptedPaths(): array + { + return [ + 'one key' => [Query::equal('meta.a', ['x'])], + 'nested keys' => [Query::equal('meta.user-info.home_city2', ['x'])], + 'starts with' => [Query::startsWith('meta.user.email', 'alice@')], + 'is null' => [Query::isNull('meta.a')], + 'inside or' => [Query::or([Query::equal('meta.a', ['x']), Query::equal('meta.b', ['y'])])], + 'base attribute' => [Query::equal('meta', [['a' => 'x']])], + ]; + } + + #[DataProvider('acceptedPaths')] + public function testAcceptsPathsOfPlainKeys(Query $query): void + { + $this->assertTrue($this->validator->isValid($query), $this->validator->getDescription()); + } + + /** + * @return array + */ + public static function refusedPaths(): array + { + return [ + 'quote in the last key' => [Query::equal("meta.a' IN ('x') OR secret='s2' OR 'x", ['x'])], + 'operator in the last key' => [Query::equal("meta.a'||(select 1)||'", ['x'])], + 'comment in the last key' => [Query::equal("meta.a' OR 1=1 --", ['x'])], + 'quote in a middle key' => [Query::equal("meta.a'b.c", ['x'])], + 'space in a key' => [Query::equal('meta.first name', ['x'])], + 'empty key' => [Query::equal('meta..a', ['x'])], + 'trailing dot' => [Query::equal('meta.', ['x'])], + 'trailing newline' => [Query::equal("meta.a\n", ['x'])], + 'less than' => [Query::lessThan("meta.a'", '5')], + 'starts with' => [Query::startsWith("meta.a'", 'x')], + 'is null' => [Query::isNull("meta.a'")], + 'inside or' => [Query::or([Query::equal('meta.a', ['x']), Query::equal("meta.b'", ['y'])])], + 'inside and' => [Query::and([Query::equal('meta.a', ['x']), Query::isNotNull("meta.b'")])], + ]; + } + + #[DataProvider('refusedPaths')] + public function testRefusesPathsWithKeysOutsideTheAllowedChars(Query $query): void + { + $this->assertFalse($this->validator->isValid($query)); + } + + public function testRefusesUnsafePathsWithoutDefinedAttributes(): void + { + $validator = new Filter( + attributes: [Attribute::object(key: 'meta')], + idAttributeType: ColumnType::Integer->value, + supportForAttributes: false, + ); + + $this->assertTrue($validator->isValid(Query::equal('meta.a', ['x']))); + $this->assertFalse($validator->isValid(Query::equal("meta.a' OR 'x", ['x']))); + } +} diff --git a/tests/unit/Validator/Query/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index 21ba8f4046..9ae56806e4 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -3,83 +3,95 @@ namespace Tests\Unit\Validator\Query; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Filter; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; class FilterTest extends TestCase { - protected Filter|null $validator = null; + protected Filter $validator; /** - * @throws \Utopia\Database\Exception + * @throws Exception */ - public function setUp(): void + #[\Override] + protected function setUp(): void { $attributes = [ new Document([ '$id' => 'string', 'key' => 'string', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), new Document([ '$id' => 'string_array', 'key' => 'string_array', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => true, ]), new Document([ '$id' => 'integer_array', 'key' => 'integer_array', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'array' => true, ]), new Document([ '$id' => 'integer', 'key' => 'integer', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'array' => false, ]), new Document([ '$id' => 'bigint_unsigned', 'key' => 'bigint_unsigned', - 'type' => Database::VAR_BIGINT, + 'type' => ColumnType::BigInteger->value, 'array' => false, 'signed' => false, ]), new Document([ '$id' => 'bigint_signed', 'key' => 'bigint_signed', - 'type' => Database::VAR_BIGINT, + 'type' => ColumnType::BigInteger->value, + 'array' => false, + 'signed' => true, + ]), + new Document([ + '$id' => 'bigint_legacy', + 'key' => 'bigint_legacy', + 'type' => 'bigint', 'array' => false, 'signed' => true, ]), ]; $this->validator = new Filter( - $attributes, - Database::VAR_INTEGER + attributes: $attributes, + idAttributeType: ColumnType::Integer->value, + supportUnsignedBigInt: true, ); } - public function testSuccess(): void + public function test_success(): void { $this->assertTrue($this->validator->isValid(Query::between('string', '1975-12-06', '2050-12-06'))); $this->assertTrue($this->validator->isValid(Query::isNotNull('string'))); $this->assertTrue($this->validator->isValid(Query::isNull('string'))); $this->assertTrue($this->validator->isValid(Query::startsWith('string', 'super'))); $this->assertTrue($this->validator->isValid(Query::endsWith('string', 'man'))); - $this->assertTrue($this->validator->isValid(Query::contains('string_array', ['super']))); - $this->assertTrue($this->validator->isValid(Query::contains('integer_array', [100,10,-1]))); - $this->assertTrue($this->validator->isValid(Query::contains('string_array', ["1","10","-1"]))); - $this->assertTrue($this->validator->isValid(Query::contains('string', ['super']))); + $this->assertTrue($this->validator->isValid(Query::containsAny('string_array', ['super']))); + $this->assertTrue($this->validator->isValid(Query::containsAny('integer_array', [100, 10, -1]))); + $this->assertTrue($this->validator->isValid(Query::containsAny('string_array', ['1', '10', '-1']))); + $this->assertTrue($this->validator->isValid(Query::containsString('string', ['super']))); $this->assertTrue($this->validator->isValid(Query::equal('bigint_unsigned', ['18446744073709551615']))); $this->assertTrue($this->validator->isValid(Query::equal('bigint_signed', ['-9223372036854775808']))); + $this->assertTrue($this->validator->isValid(Query::equal('bigint_legacy', ['9223372036854775807']))); } - public function testFailure(): void + public function test_failure(): void { $this->assertFalse($this->validator->isValid(Query::select(['attr']))); $this->assertEquals('Invalid query', $this->validator->getDescription()); @@ -97,14 +109,14 @@ public function testFailure(): void $this->assertFalse($this->validator->isValid(Query::equal('', ['v']))); $this->assertFalse($this->validator->isValid(Query::orderAsc('string'))); $this->assertFalse($this->validator->isValid(Query::orderDesc('string'))); - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_CURSOR_AFTER, values: ['asdf']))); - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_CURSOR_BEFORE, values: ['asdf']))); - $this->assertFalse($this->validator->isValid(Query::contains('integer', ['super']))); - $this->assertFalse($this->validator->isValid(Query::equal('integer_array', [100,-1]))); - $this->assertFalse($this->validator->isValid(Query::contains('integer_array', [10.6]))); + $this->assertFalse($this->validator->isValid(new Query(Method::CursorAfter, values: ['asdf']))); + $this->assertFalse($this->validator->isValid(new Query(Method::CursorBefore, values: ['asdf']))); + $this->assertFalse($this->validator->isValid(Query::containsString('integer', ['super']))); + $this->assertFalse($this->validator->isValid(Query::equal('integer_array', [100, -1]))); + $this->assertFalse($this->validator->isValid(Query::containsAny('integer_array', [10.6]))); } - public function testTypeMismatch(): void + public function test_type_mismatch(): void { $this->assertFalse($this->validator->isValid(Query::equal('string', [false]))); $this->assertEquals('Query value is invalid for attribute "string"', $this->validator->getDescription()); @@ -113,16 +125,16 @@ public function testTypeMismatch(): void $this->assertEquals('Query value is invalid for attribute "string"', $this->validator->getDescription()); } - public function testEmptyValues(): void + public function test_empty_values(): void { - $this->assertFalse($this->validator->isValid(Query::contains('string', []))); + $this->assertFalse($this->validator->isValid(Query::containsString('string', []))); $this->assertEquals('Contains queries require at least one value.', $this->validator->getDescription()); $this->assertFalse($this->validator->isValid(Query::equal('string', []))); $this->assertEquals('Equal queries require at least one value.', $this->validator->getDescription()); } - public function testMaxValuesCount(): void + public function test_max_values_count(): void { $max = $this->validator->getMaxValuesCount(); $values = []; @@ -134,7 +146,7 @@ public function testMaxValuesCount(): void $this->assertEquals('Query on attribute has greater than '.$max.' values: integer', $this->validator->getDescription()); } - public function testNotContains(): void + public function test_not_contains(): void { // Test valid notContains queries $this->assertTrue($this->validator->isValid(Query::notContains('string', ['unwanted']))); @@ -146,7 +158,7 @@ public function testNotContains(): void $this->assertEquals('NotContains queries require at least one value.', $this->validator->getDescription()); } - public function testNotSearch(): void + public function test_not_search(): void { // Test valid notSearch queries $this->assertTrue($this->validator->isValid(Query::notSearch('string', 'unwanted'))); @@ -156,11 +168,11 @@ public function testNotSearch(): void $this->assertEquals('Cannot query notSearch on attribute "string_array" because it is an array.', $this->validator->getDescription()); // Test multiple values not allowed - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_SEARCH, 'string', ['word1', 'word2']))); + $this->assertFalse($this->validator->isValid(new Query(Method::NotSearch, 'string', ['word1', 'word2']))); $this->assertEquals('NotSearch queries require exactly one value.', $this->validator->getDescription()); } - public function testNotStartsWith(): void + public function test_not_starts_with(): void { // Test valid notStartsWith queries $this->assertTrue($this->validator->isValid(Query::notStartsWith('string', 'temp'))); @@ -170,11 +182,11 @@ public function testNotStartsWith(): void $this->assertEquals('Cannot query notStartsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); // Test multiple values not allowed - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_STARTS_WITH, 'string', ['prefix1', 'prefix2']))); + $this->assertFalse($this->validator->isValid(new Query(Method::NotStartsWith, 'string', ['prefix1', 'prefix2']))); $this->assertEquals('NotStartsWith queries require exactly one value.', $this->validator->getDescription()); } - public function testNotEndsWith(): void + public function test_not_ends_with(): void { // Test valid notEndsWith queries $this->assertTrue($this->validator->isValid(Query::notEndsWith('string', '.tmp'))); @@ -184,11 +196,11 @@ public function testNotEndsWith(): void $this->assertEquals('Cannot query notEndsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); // Test multiple values not allowed - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_ENDS_WITH, 'string', ['suffix1', 'suffix2']))); + $this->assertFalse($this->validator->isValid(new Query(Method::NotEndsWith, 'string', ['suffix1', 'suffix2']))); $this->assertEquals('NotEndsWith queries require exactly one value.', $this->validator->getDescription()); } - public function testNotBetween(): void + public function test_not_between(): void { // Test valid notBetween queries $this->assertTrue($this->validator->isValid(Query::notBetween('integer', 0, 50))); @@ -198,10 +210,136 @@ public function testNotBetween(): void $this->assertEquals('Cannot query notBetween on attribute "integer_array" because it is an array.', $this->validator->getDescription()); // Test wrong number of values - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_BETWEEN, 'integer', [10]))); + $this->assertFalse($this->validator->isValid(new Query(Method::NotBetween, 'integer', [10]))); $this->assertEquals('NotBetween queries require exactly two values.', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_BETWEEN, 'integer', [10, 20, 30]))); + $this->assertFalse($this->validator->isValid(new Query(Method::NotBetween, 'integer', [10, 20, 30]))); $this->assertEquals('NotBetween queries require exactly two values.', $this->validator->getDescription()); } + + public function test_dotted_join_alias_is_accepted_after_allow_join_aliases(): void + { + $this->validator->allowJoinAliases(['sec']); + + $this->assertTrue($this->validator->isValid(Query::equal('sec.amount', [777]))); + $this->assertTrue($this->validator->isValid(Query::equal('sec.$id', ['abc']))); + } + + public function test_unknown_join_alias_is_rejected(): void + { + $this->validator->allowJoinAliases(['sec']); + + $this->assertFalse($this->validator->isValid(Query::equal('other.amount', [777]))); + $this->assertSame('Attribute not found in schema: other', $this->validator->getDescription()); + } + + public function test_unqualified_join_attribute_is_still_rejected(): void + { + $this->validator->allowJoinAliases(['sec']); + + $this->assertFalse($this->validator->isValid(Query::equal('amount', [777]))); + $this->assertSame('Attribute not found in schema: amount', $this->validator->getDescription()); + } + + public function test_join_alias_is_rejected_after_reset(): void + { + $this->validator->allowJoinAliases(['sec']); + $this->validator->resetJoinAliases(); + + $this->assertFalse($this->validator->isValid(Query::equal('sec.amount', [777]))); + $this->assertSame('Attribute not found in schema: sec', $this->validator->getDescription()); + } + + public function test_nested_and_or_join_alias_is_accepted(): void + { + $this->validator->allowJoinAliases(['meta']); + + $this->assertTrue($this->validator->isValid(Query::and([ + Query::equal('string', ['Main']), + Query::or([ + Query::equal('meta.score', [10]), + Query::equal('integer', [2]), + ]), + ]))); + } + + public function test_nested_and_or_unknown_join_alias_is_rejected(): void + { + $this->validator->allowJoinAliases(['meta']); + + $this->assertFalse($this->validator->isValid(Query::and([ + Query::equal('string', ['Main']), + Query::or([ + Query::equal('other.score', [10]), + Query::equal('integer', [2]), + ]), + ]))); + $this->assertSame('Attribute not found in schema: other', $this->validator->getDescription()); + } + + public function test_and_or_string_child_is_rejected(): void + { + $this->assertFalse($this->validator->isValid(new Query(Method::And, '', [ + Query::equal('string', ['Main'])->toString(), + Query::equal('integer', [2])->toString(), + ]))); + $this->assertSame('And queries can only contain filter queries', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(new Query(Method::Or, '', [ + Query::equal('string', ['Main'])->toString(), + Query::equal('integer', [2])->toString(), + ]))); + $this->assertSame('Or queries can only contain filter queries', $this->validator->getDescription()); + } + + public function test_exists_without_attributes_is_rejected(): void + { + $this->assertFalse($this->validator->isValid(Query::exists([]))); + $this->assertSame('Exists queries require at least one value.', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(Query::notExists([]))); + $this->assertSame('NotExists queries require at least one value.', $this->validator->getDescription()); + } + + public function test_or_with_non_document_cursor_is_rejected_as_non_filter(): void + { + $this->assertFalse($this->validator->isValid(Query::or([ + Query::equal('string', ['Main']), + Query::cursorAfter(['$id' => 'row']), + ]))); + $this->assertSame('Or queries can only contain filter queries', $this->validator->getDescription()); + } + + public function test_and_with_non_document_cursor_is_rejected_as_non_filter(): void + { + $this->assertFalse($this->validator->isValid(Query::and([ + Query::equal('string', ['Main']), + Query::cursorBefore(new \stdClass()), + ]))); + $this->assertSame('And queries can only contain filter queries', $this->validator->getDescription()); + } + + public function test_elem_match_with_non_document_cursor_is_rejected_as_non_filter(): void + { + $validator = new Filter( + attributes: [], + idAttributeType: ColumnType::Integer->value, + supportForAttributes: false, + ); + + $this->assertFalse($validator->isValid(Query::elemMatch('items', [ + Query::equal('name', ['Main']), + Query::cursorAfter(['$id' => 'row']), + ]))); + $this->assertSame('elemMatch queries can only contain filter queries', $validator->getDescription()); + } + + public function test_or_with_document_cursor_is_rejected_as_non_filter(): void + { + $this->assertFalse($this->validator->isValid(Query::or([ + Query::equal('string', ['Main']), + Query::cursorAfter(new Document(['$id' => 'row'])), + ]))); + $this->assertSame('Or queries can only contain filter queries', $this->validator->getDescription()); + } } diff --git a/tests/unit/Validator/Query/FilterTypeBranchesTest.php b/tests/unit/Validator/Query/FilterTypeBranchesTest.php new file mode 100644 index 0000000000..4f43904d98 --- /dev/null +++ b/tests/unit/Validator/Query/FilterTypeBranchesTest.php @@ -0,0 +1,132 @@ +validator = new Filter( + attributes: [ + new Document([ + '$id' => 'vector', + 'key' => 'vector', + 'type' => ColumnType::Vector->value, + 'array' => false, + 'size' => 3, + ]), + new Document([ + '$id' => 'point', + 'key' => 'point', + 'type' => ColumnType::Point->value, + 'array' => false, + ]), + new Document([ + '$id' => 'object', + 'key' => 'object', + 'type' => ColumnType::Object->value, + 'array' => false, + ]), + new Document([ + '$id' => 'text', + 'key' => 'text', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ], + idAttributeType: ColumnType::Integer->value, + supportUnsignedBigInt: true, + ); + } + + public function test_vector_requires_a_numeric_array_of_the_declared_size(): void + { + $this->assertTrue($this->validator->isValid(Query::equal('vector', [[1.0, 2.0, 3.0]]))); + + $this->assertFalse($this->validator->isValid(Query::equal('vector', ['not-an-array']))); + $this->assertSame('Vector query value must be an array', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(Query::equal('vector', [[1.0, 'two', 3.0]]))); + $this->assertSame('Vector query value must contain only numeric values', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(Query::equal('vector', [[1.0, 2.0]]))); + $this->assertSame('Vector query value must have 3 elements', $this->validator->getDescription()); + } + + public function test_spatial_requires_an_array_value(): void + { + $this->assertTrue($this->validator->isValid(Query::equal('point', [[1.0, 2.0]]))); + + $this->assertFalse($this->validator->isValid(Query::equal('point', ['1,2']))); + $this->assertSame('Spatial data must be an array', $this->validator->getDescription()); + } + + /** + * @return iterable + */ + public static function nonFiniteDistances(): iterable + { + foreach (['not a number' => NAN, 'infinite' => INF, 'negatively infinite' => -INF] as $name => $distance) { + yield 'distanceEqual '.$name => [Query::distanceEqual('point', [1.0, 2.0], $distance)]; + yield 'distanceNotEqual '.$name => [Query::distanceNotEqual('point', [1.0, 2.0], $distance)]; + yield 'distanceGreaterThan '.$name => [Query::distanceGreaterThan('point', [1.0, 2.0], $distance)]; + yield 'distanceLessThan '.$name.' in meters' => [Query::distanceLessThan('point', [1.0, 2.0], $distance, true)]; + } + } + + #[DataProvider('nonFiniteDistances')] + public function test_distance_must_be_finite(Query $query): void + { + $this->assertFalse($this->validator->isValid($query)); + $this->assertSame('Distance query requires a finite distance', $this->validator->getDescription()); + } + + public function test_finite_distance_is_valid(): void + { + $this->assertTrue($this->validator->isValid(Query::distanceLessThan('point', [1.0, 2.0], 1000, true))); + $this->assertTrue($this->validator->isValid(Query::distanceGreaterThan('point', [1.0, 2.0], 0.5))); + } + + public function test_object_containment_rejects_mixed_key_arrays(): void + { + $this->assertTrue($this->validator->isValid(Query::equal('object', [['a' => 1]]))); + $this->assertTrue($this->validator->isValid(Query::equal('object', ['plain-string'])), 'a scalar is not an object shape to check'); + + // A map and a list at once is the shape the check exists to refuse: + // it cannot be encoded as either a JSON object or a JSON array. + $this->assertFalse($this->validator->isValid(Query::equal('object', [['a' => 1, 0 => 'b']]))); + $this->assertSame( + 'Invalid object query structure for attribute "object"', + $this->validator->getDescription(), + ); + + $this->assertFalse($this->validator->isValid(Query::equal('object', [['nested' => ['x' => 1, 0 => 'y']]])), 'the check recurses'); + } + + public function test_a_dotted_object_path_is_validated_as_a_string(): void + { + $this->assertTrue($this->validator->isValid(Query::equal('object.nested', ['a-string']))); + } + + public function test_every_value_is_checked_not_just_the_first(): void + { + $this->assertFalse($this->validator->isValid(Query::equal('vector', [[1.0, 2.0, 3.0], 'not-an-array']))); + $this->assertSame('Vector query value must be an array', $this->validator->getDescription()); + } +} diff --git a/tests/unit/Validator/Query/GroupByTest.php b/tests/unit/Validator/Query/GroupByTest.php new file mode 100644 index 0000000000..34e7f8f27c --- /dev/null +++ b/tests/unit/Validator/Query/GroupByTest.php @@ -0,0 +1,73 @@ +validator = new GroupBy( + attributes: [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + new Document([ + '$id' => 'status', + 'key' => 'status', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ], + ); + } + + public function testValueSuccess(): void + { + $this->assertTrue($this->validator->isValid(Query::groupBy(['name']))); + $this->assertTrue($this->validator->isValid(Query::groupBy(['name', 'status']))); + $this->assertTrue($this->validator->isValid(Query::groupBy(['$id']))); + } + + public function testValueFailure(): void + { + $this->assertFalse($this->validator->isValid(Query::groupBy([]))); + $this->assertSame('GroupBy requires at least one attribute', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(new Query(Method::GroupBy, '', [123]))); + $this->assertSame('GroupBy attributes must be non-empty strings', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(new Query(Method::GroupBy, '', ['']))); + $this->assertSame('GroupBy attributes must be non-empty strings', $this->validator->getDescription()); + } + + public function testUnknownAttributeRejected(): void + { + // Reaches the adapter as a bare identifier, so an unknown name is a 500 from the + // engine rather than a 400 from validation, and doubles as a probe for column names. + $this->assertFalse($this->validator->isValid(Query::groupBy(['nonexistent']))); + $this->assertSame('Attribute not found in schema: nonexistent', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(Query::groupBy(['name', 'nonexistent']))); + $this->assertSame('Attribute not found in schema: nonexistent', $this->validator->getDescription()); + } + + public function testUnknownAttributeAllowedWithoutAttributeSupport(): void + { + $validator = new GroupBy(attributes: [], supportForAttributes: false); + + $this->assertTrue($validator->isValid(Query::groupBy(['anything']))); + } +} diff --git a/tests/unit/Validator/Query/HavingTest.php b/tests/unit/Validator/Query/HavingTest.php new file mode 100644 index 0000000000..f211cfb83d --- /dev/null +++ b/tests/unit/Validator/Query/HavingTest.php @@ -0,0 +1,29 @@ +assertTrue($validator->isValid(Query::having([Query::equal('count', [1])]))); + } + + public function testValueFailure(): void + { + $validator = new Having(); + + $this->assertFalse($validator->isValid(Query::having([]))); + $this->assertSame('Having requires at least one condition', $validator->getDescription()); + + $this->assertFalse($validator->isValid(new Query(Method::Having, '', ['count > 1']))); + $this->assertSame('Having conditions must be Query instances', $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/Query/JoinAliasTest.php b/tests/unit/Validator/Query/JoinAliasTest.php new file mode 100644 index 0000000000..6fe43cc794 --- /dev/null +++ b/tests/unit/Validator/Query/JoinAliasTest.php @@ -0,0 +1,61 @@ +assertTrue($validator->isValid($join), $validator->getDescription()); + } + + /** + * @return iterable + */ + public static function acceptedJoins(): iterable + { + yield 'a short identifier with a digit' => [Query::join('orders', 'j0', [Query::on('$id', 'customerId')])]; + yield 'an identifier' => [Query::join('orders', 'ord', [Query::on('$id', 'customerId')])]; + yield 'an identifier with digits and underscores' => [Query::leftJoin('orders', '_order_2', [Query::on('$id', 'customerId')])]; + yield 'a cross join alias' => [Query::crossJoin('orders', 'ord')]; + yield 'a nested join alias' => [Query::rightJoin('orders', 'ord', [Query::on('$id', 'customerId')])]; + } + + #[DataProvider('rejectedJoins')] + public function testRejectsAlias(Query $join, string $message): void + { + $validator = new Join(); + + $this->assertFalse($validator->isValid($join)); + $this->assertSame($message, $validator->getDescription()); + } + + /** + * @return iterable + */ + public static function rejectedJoins(): iterable + { + $main = Query::DEFAULT_ALIAS; + $upper = \strtoupper(Query::DEFAULT_ALIAS); + $invalid = 'Join alias must start with a letter or an underscore and contain only letters, digits and underscores'; + + yield 'no alias' => [new Query(Method::Join, 'orders', [Query::on('$id', 'customerId')], ''), 'Join alias is required']; + yield 'the main collection alias' => [Query::join('orders', $main, [Query::on('$id', 'customerId')]), "Join alias \"{$main}\" is reserved for the main collection"]; + yield 'the main collection alias in upper case' => [Query::join('orders', $upper, [Query::on('$id', 'customerId')]), "Join alias \"{$upper}\" is reserved for the main collection"]; + yield 'the main collection alias on a cross join' => [Query::crossJoin('orders', $main), "Join alias \"{$main}\" is reserved for the main collection"]; + yield 'the main collection alias on a nested join' => [Query::fullOuterJoin('orders', $main, [Query::on('$id', 'customerId')]), "Join alias \"{$main}\" is reserved for the main collection"]; + yield 'a hyphen' => [Query::join('orders', 'my-alias', [Query::on('$id', 'customerId')]), $invalid]; + yield 'a leading digit' => [Query::leftJoin('orders', '1st', [Query::on('$id', 'customerId')]), $invalid]; + yield 'a dot' => [Query::join('orders', 'a.b', [Query::on('$id', 'customerId')]), $invalid]; + yield 'a quote' => [Query::crossJoin('orders', 'x`y'), $invalid]; + } +} diff --git a/tests/unit/Validator/Query/Joined/AttributesTest.php b/tests/unit/Validator/Query/Joined/AttributesTest.php new file mode 100644 index 0000000000..7c80474d06 --- /dev/null +++ b/tests/unit/Validator/Query/Joined/AttributesTest.php @@ -0,0 +1,308 @@ +customers = $this->collection('customers', [ + $this->attribute('name', ColumnType::String), + $this->attribute('visits', ColumnType::Integer), + ]); + $this->orders = $this->collection('orders', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('amount', ColumnType::Integer), + $this->attribute('status', ColumnType::String), + $this->attribute('memo', ColumnType::String), + Attribute::relationship('customer', Relationship::manyToOne('customers'), RelationshipSide::Parent)->toDocument(), + ]); + $this->refunds = $this->collection('refunds', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('amount', ColumnType::Integer), + ]); + $this->notes = $this->collection('notes', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('body', ColumnType::String), + ], [ + new Document([ + '$id' => 'body_fulltext', + 'key' => 'body_fulltext', + 'type' => IndexType::Fulltext->value, + 'attributes' => ['body'], + ]), + ]); + $this->profiles = $this->collection('profiles', [ + $this->attribute('customerId', ColumnType::String), + $this->attribute('visits', ColumnType::Integer), + ]); + } + + public function testBareAttributeOfTheMainCollectionStaysValidWhenAJoinDeclaresItToo(): void + { + $validator = $this->validator([$this->profiles]); + + $this->assertTrue($validator->isValid([ + Query::join('profiles', 'profile', [Query::on('$id', 'customerId')]), + Query::sum('visits', 'total'), + Query::groupBy(['name', 'visits']), + ]), $validator->getDescription()); + } + + public function testBareAttributeResolvesToTheOneJoinThatDeclaresIt(): void + { + $validator = $this->validator([$this->notes, $this->orders]); + + $this->assertTrue($validator->isValid([ + Query::join('notes', 'note', [Query::on('$id', 'customerId')]), + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + Query::groupBy(['status']), + ]), $validator->getDescription()); + + $this->assertTrue($validator->isValid([ + Query::join('notes', 'j0', [Query::on('$id', 'customerId')]), + Query::join('orders', 'j1', [Query::on('$id', 'customerId')]), + Query::avg('amount', 'average'), + Query::groupBy(['status']), + ]), 'a join without an alias still declares its attributes: '.$validator->getDescription()); + } + + public function testBareAttributeNoCollectionDeclaresIsNotFound(): void + { + $validator = $this->validator([$this->orders]); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('orders', 'j', [Query::on('$id', 'customerId')]), + Query::groupBy(['anything_at_all']), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: anything_at_all', $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('orders', 'j', [Query::on('$id', 'customerId')]), + Query::sum('also_anything', 'total'), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: also_anything', $validator->getDescription()); + } + + public function testBareAttributeSeveralJoinsDeclareIsAmbiguous(): void + { + $validator = $this->validator([$this->orders, $this->refunds]); + $joins = [ + Query::join('orders', 'alpha', [Query::on('$id', 'customerId')]), + Query::join('refunds', 'beta', [Query::on('$id', 'customerId')]), + ]; + + $this->assertFalse($validator->isValid([...$joins, Query::sum('amount', 'total')])); + $this->assertSame(self::AMBIGUOUS_AMOUNT, $validator->getDescription()); + + $this->assertFalse($validator->isValid([...$joins, Query::groupBy(['amount'])])); + $this->assertSame(self::AMBIGUOUS_AMOUNT, $validator->getDescription()); + + $this->assertTrue($validator->isValid([ + ...$joins, + Query::sum('beta.amount', 'total'), + Query::groupBy(['alpha.amount']), + ]), $validator->getDescription()); + } + + public function testCollectionJoinedUnderTwoAliasesMakesItsAttributesAmbiguous(): void + { + $validator = $this->validator([$this->orders]); + $joins = [ + Query::join('orders', 'first', [Query::on('$id', 'customerId')]), + Query::leftJoin('orders', 'second', [Query::on('$id', 'customerId')]), + ]; + + $this->assertFalse($validator->isValid([...$joins, Query::max('amount', 'largest')])); + $this->assertSame(self::AMBIGUOUS_AMOUNT, $validator->getDescription()); + + $this->assertTrue($validator->isValid([ + ...$joins, + Query::max('first.amount', 'largest'), + Query::min('second.amount', 'smallest'), + ]), $validator->getDescription()); + } + + public function testBareAttributeCannotResolveThroughAJoinWhoseCollectionIsUnknown(): void + { + $validator = $this->validator(); + + $this->assertFalse($validator->isValid([ + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: amount', $validator->getDescription()); + + $this->assertTrue($validator->isValid([ + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('purchase.amount', 'total'), + ]), $validator->getDescription()); + } + + public function testJoinedRelationshipAttributeIsNotResolvedByItsBareName(): void + { + $validator = $this->validator([$this->orders]); + + $this->assertFalse($validator->isValid([ + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::groupBy(['customer']), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: customer', $validator->getDescription()); + } + + public function testInternalAttributesResolveToTheMainCollection(): void + { + $validator = $this->validator([$this->orders]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::count('$id', 'customers'), + Query::groupBy(['$createdAt']), + ]), $validator->getDescription()); + } + + public function testSearchOnAJoinAliasRequiresAFulltextIndexOnTheJoinedAttribute(): void + { + $validator = $this->validator([$this->orders, $this->notes]); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::search('purchase.memo', 'gift'), + ])); + $this->assertSame('Searching by attribute "purchase.memo" requires a fulltext index.', $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::notSearch('purchase.memo', 'gift'), + ])); + $this->assertSame('Searching by attribute "purchase.memo" requires a fulltext index.', $validator->getDescription()); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::search('note.body', 'needle'), + ]), $validator->getDescription()); + } + + public function testSearchInAJoinConditionIsRefusedWithOrWithoutAFulltextIndex(): void + { + $validator = $this->validator([$this->orders, $this->notes]); + + $this->assertFalse($validator->isValid([ + new Query(Method::LeftJoin, 'orders', [ + Query::on('$id', 'customerId'), + Query::search('purchase.memo', 'gift'), + ], 'purchase'), + ])); + $this->assertSame('Invalid query: Unsupported join ON condition: search', $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + new Query(Method::LeftJoin, 'notes', [ + Query::on('$id', 'customerId'), + Query::search('note.body', 'needle'), + ], 'note'), + ]), 'the builder compiles no search into an ON list, fulltext index or not'); + $this->assertSame('Invalid query: Unsupported join ON condition: search', $validator->getDescription()); + } + + public function testSearchOnAJoinAliasIsInvalidWhenTheJoinedIndexesAreUnknown(): void + { + $validator = $this->validator(); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('notes', 'note', [Query::on('$id', 'customerId')]), + Query::search('note.body', 'needle'), + ])); + $this->assertSame('Searching by attribute "note.body" requires a fulltext index.', $validator->getDescription()); + } + + public function testJoinedCollectionsDoNotWidenAQuerySetWithoutJoins(): void + { + $validator = $this->validator([$this->orders]); + + $this->assertTrue($validator->isValid([ + Query::join('orders', 'purchase', [Query::on('$id', 'customerId')]), + Query::sum('amount', 'total'), + ]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::sum('amount', 'total')])); + $this->assertSame('Invalid query: Attribute not found in schema: amount', $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::groupBy(['status'])])); + $this->assertSame('Invalid query: Attribute not found in schema: status', $validator->getDescription()); + } + + /** + * @param array $joinedCollections + */ + private function validator(array $joinedCollections = []): Documents + { + /** @var array $attributes */ + $attributes = $this->customers->getAttribute('attributes', []); + + $validator = new Documents( + $attributes, + [], + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins, Capability::Aggregations, Capability::OrderRandom]), + ); + + if ($joinedCollections !== []) { + $validator->setJoinedCollections($joinedCollections); + } + + return $validator; + } + + /** + * @param array $attributes + * @param array $indexes + */ + private function collection(string $id, array $attributes, array $indexes = []): Document + { + return new Document([ + '$id' => $id, + 'attributes' => $attributes, + 'indexes' => $indexes, + ]); + } + + private function attribute(string $key, ColumnType $type): Document + { + return new Document([ + '$id' => $key, + 'key' => $key, + 'type' => $type->value, + 'size' => $type === ColumnType::String ? 256 : 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]); + } +} diff --git a/tests/unit/Validator/Query/LimitTest.php b/tests/unit/Validator/Query/LimitTest.php index f0c598d3d8..be287ac71a 100644 --- a/tests/unit/Validator/Query/LimitTest.php +++ b/tests/unit/Validator/Query/LimitTest.php @@ -8,7 +8,7 @@ class LimitTest extends TestCase { - public function testValueSuccess(): void + public function test_value_success(): void { $validator = new Limit(100); @@ -16,7 +16,7 @@ public function testValueSuccess(): void $this->assertTrue($validator->isValid(Query::limit(100))); } - public function testValueFailure(): void + public function test_value_failure(): void { $validator = new Limit(100); diff --git a/tests/unit/Validator/Query/OffsetTest.php b/tests/unit/Validator/Query/OffsetTest.php index 9484083465..5071f0130a 100644 --- a/tests/unit/Validator/Query/OffsetTest.php +++ b/tests/unit/Validator/Query/OffsetTest.php @@ -5,10 +5,11 @@ use PHPUnit\Framework\TestCase; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Offset; +use Utopia\Query\Method; class OffsetTest extends TestCase { - public function testValueSuccess(): void + public function test_value_success(): void { $validator = new Offset(5000); @@ -17,13 +18,15 @@ public function testValueSuccess(): void $this->assertTrue($validator->isValid(Query::offset(5000))); } - public function testValueFailure(): void + public function test_value_failure(): void { $validator = new Offset(5000); $this->assertFalse($validator->isValid(Query::offset(-1))); $this->assertEquals('Invalid offset: Value must be a valid range between 0 and 5,000', $validator->getDescription()); $this->assertFalse($validator->isValid(Query::offset(5001))); + $this->assertFalse($validator->isValid(new Query(Method::Offset, '', ['foo']))); + $this->assertSame('Invalid offset: Value must be a valid number', $validator->getDescription()); $this->assertFalse($validator->isValid(Query::equal('attr', ['v']))); $this->assertFalse($validator->isValid(Query::orderAsc('attr'))); $this->assertFalse($validator->isValid(Query::orderDesc('attr'))); diff --git a/tests/unit/Validator/Query/OrderTest.php b/tests/unit/Validator/Query/OrderTest.php index b84d896d16..ddf7eb22bb 100644 --- a/tests/unit/Validator/Query/OrderTest.php +++ b/tests/unit/Validator/Query/OrderTest.php @@ -3,41 +3,44 @@ namespace Tests\Unit\Validator\Query; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Utopia\Database\Attribute; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Query; -use Utopia\Database\Validator\Query\Base; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; use Utopia\Database\Validator\Query\Order; +use Utopia\Query\Schema\ColumnType; class OrderTest extends TestCase { - protected Base|null $validator = null; + protected Order $validator; /** * @throws Exception */ - public function setUp(): void + #[\Override] + protected function setUp(): void { $this->validator = new Order( attributes: [ new Document([ '$id' => 'attr', 'key' => 'attr', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), new Document([ '$id' => '$sequence', 'key' => '$sequence', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), ], ); } - public function testValueSuccess(): void + public function test_value_success(): void { $this->assertTrue($this->validator->isValid(Query::orderAsc('attr'))); $this->assertTrue($this->validator->isValid(Query::orderAsc())); @@ -45,7 +48,7 @@ public function testValueSuccess(): void $this->assertTrue($this->validator->isValid(Query::orderDesc())); } - public function testValueFailure(): void + public function test_value_failure(): void { $this->assertFalse($this->validator->isValid(Query::limit(-1))); $this->assertEquals('Invalid query', $this->validator->getDescription()); @@ -58,4 +61,68 @@ public function testValueFailure(): void $this->assertFalse($this->validator->isValid(Query::orderDesc('dne'))); $this->assertFalse($this->validator->isValid(Query::orderAsc('dne'))); } + + public function test_dotted_attribute_with_relationship_base(): void + { + $validator = new Order( + attributes: [ + Attribute::relationship('profile', Relationship::oneToOne('profiles'), RelationshipSide::Parent)->toDocument(), + ], + ); + + $this->assertFalse($validator->isValid(Query::orderAsc('profile.name'))); + $this->assertEquals('Cannot order by nested attribute: profile', $validator->getDescription()); + } + + public function test_dotted_attribute_not_in_schema(): void + { + $this->assertFalse($this->validator->isValid(Query::orderAsc('unknown.field'))); + $this->assertEquals('Attribute not found in schema: unknown', $this->validator->getDescription()); + } + + public function test_non_query_input_returns_false(): void + { + $this->assertFalse($this->validator->isValid('not_a_query')); + $this->assertFalse($this->validator->isValid(42)); + $this->assertFalse($this->validator->isValid(null)); + } + + public function test_order_random_is_valid(): void + { + $query = Query::orderRandom(); + $this->assertTrue($this->validator->isValid($query)); + } + + public function test_add_aggregation_aliases(): void + { + $this->validator->addAggregationAliases(['total_count', 'avg_price']); + + $this->assertTrue($this->validator->isValid(Query::orderAsc('total_count'))); + $this->assertTrue($this->validator->isValid(Query::orderDesc('avg_price'))); + } + + public function testDottedJoinAliasIsAcceptedAfterAllowJoinAliases(): void + { + $this->validator->allowJoinAliases(['sec']); + + $this->assertTrue($this->validator->isValid(Query::orderAsc('sec.amount'))); + $this->assertTrue($this->validator->isValid(Query::orderDesc('sec.$id'))); + } + + public function testUnknownJoinAliasIsRejected(): void + { + $this->validator->allowJoinAliases(['sec']); + + $this->assertFalse($this->validator->isValid(Query::orderAsc('other.amount'))); + $this->assertSame('Attribute not found in schema: other', $this->validator->getDescription()); + } + + public function testJoinAliasIsRejectedAfterReset(): void + { + $this->validator->allowJoinAliases(['sec']); + $this->validator->resetJoinAliases(); + + $this->assertFalse($this->validator->isValid(Query::orderAsc('sec.amount'))); + $this->assertSame('Attribute not found in schema: sec', $this->validator->getDescription()); + } } diff --git a/tests/unit/Validator/Query/SelectTest.php b/tests/unit/Validator/Query/SelectTest.php index 86b8d2495c..1473170579 100644 --- a/tests/unit/Validator/Query/SelectTest.php +++ b/tests/unit/Validator/Query/SelectTest.php @@ -2,48 +2,48 @@ namespace Tests\Unit\Validator\Query; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Utopia\Database\Attribute; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Query; -use Utopia\Database\Validator\Query\Base; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; use Utopia\Database\Validator\Query\Select; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; class SelectTest extends TestCase { - protected Base|null $validator = null; + protected Select $validator; /** * @throws Exception */ - public function setUp(): void + #[\Override] + protected function setUp(): void { $this->validator = new Select( attributes: [ new Document([ '$id' => 'attr', 'key' => 'attr', - 'type' => Database::VAR_STRING, - 'array' => false, - ]), - new Document([ - '$id' => 'artist', - 'key' => 'artist', - 'type' => Database::VAR_RELATIONSHIP, + 'type' => ColumnType::String->value, 'array' => false, ]), + Attribute::relationship('artist', Relationship::manyToOne('artists'), RelationshipSide::Parent)->toDocument(), ], ); } - public function testValueSuccess(): void + public function test_value_success(): void { $this->assertTrue($this->validator->isValid(Query::select(['*', 'attr']))); $this->assertTrue($this->validator->isValid(Query::select(['artist.name']))); } - public function testValueFailure(): void + public function test_value_failure(): void { $this->assertFalse($this->validator->isValid(Query::limit(1))); $this->assertEquals('Invalid query', $this->validator->getDescription()); @@ -57,12 +57,11 @@ public function testValueFailure(): void * a malformed value that way. * * @param array $values - * - * @dataProvider nonStringSelections */ + #[DataProvider('nonStringSelections')] public function testANonStringSelectionIsRefusedByType(array $values, string $expected): void { - $this->assertFalse($this->validator->isValid(Query::select($values))); + $this->assertFalse($this->validator->isValid(new Query(Method::Select, values: $values))); $this->assertSame($expected, $this->validator->getDescription()); } @@ -105,4 +104,20 @@ public function testTheLegitimateFlatFormStillPasses(): void $this->assertTrue($this->validator->isValid(Query::select(['$id', '$createdAt']))); $this->assertTrue($this->validator->isValid(Query::select(['artist.name']))); } + + public function testDottedJoinAliasIsAcceptedAfterAllowJoinAliases(): void + { + $this->validator->allowJoinAliases(['ord']); + + $this->assertTrue($this->validator->isValid(Query::select(['ord.amount']))); + $this->assertTrue($this->validator->isValid(Query::select(['ord.$id']))); + } + + public function testUnqualifiedAmountIsStillRejected(): void + { + $this->validator->allowJoinAliases(['ord']); + + $this->assertFalse($this->validator->isValid(Query::select(['amount']))); + $this->assertSame('Attribute not found in schema: amount', $this->validator->getDescription()); + } } diff --git a/tests/unit/Validator/QueryShapeAggregateTest.php b/tests/unit/Validator/QueryShapeAggregateTest.php new file mode 100644 index 0000000000..7a770cab49 --- /dev/null +++ b/tests/unit/Validator/QueryShapeAggregateTest.php @@ -0,0 +1,126 @@ + + */ + public static function numericMethodProvider(): array + { + return [ + 'sum' => ['sum'], + 'avg' => ['avg'], + 'stddev' => ['stddev'], + 'stddevPop' => ['stddevPop'], + 'stddevSamp' => ['stddevSamp'], + 'variance' => ['variance'], + 'varPop' => ['varPop'], + 'varSamp' => ['varSamp'], + 'bitAnd' => ['bitAnd'], + 'bitOr' => ['bitOr'], + 'bitXor' => ['bitXor'], + ]; + } + + private function aggregate(string $method, string $attribute): Query + { + return match ($method) { + 'sum' => Query::sum($attribute, 'result'), + 'avg' => Query::avg($attribute, 'result'), + 'stddev' => Query::stddev($attribute, 'result'), + 'stddevPop' => Query::stddevPop($attribute, 'result'), + 'stddevSamp' => Query::stddevSamp($attribute, 'result'), + 'variance' => Query::variance($attribute, 'result'), + 'varPop' => Query::varPop($attribute, 'result'), + 'varSamp' => Query::varSamp($attribute, 'result'), + 'bitAnd' => Query::bitAnd($attribute, 'result'), + 'bitOr' => Query::bitOr($attribute, 'result'), + 'bitXor' => Query::bitXor($attribute, 'result'), + 'count' => Query::count($attribute, 'result'), + 'countDistinct' => Query::countDistinct($attribute, 'result'), + 'min' => Query::min($attribute, 'result'), + 'max' => Query::max($attribute, 'result'), + default => throw new \InvalidArgumentException('Unknown aggregate: '.$method), + }; + } + + #[DataProvider('numericMethodProvider')] + public function testNumericAggregatesRequireANumericAttributeThatIsNotAnArray(string $method): void + { + $validator = $this->validator(); + + foreach (['name', 'active', 'created', 'tags', 'scores', '$id', '$sequence', '$createdAt'] as $attribute) { + $this->assertFalse($validator->isValid([$this->aggregate($method, $attribute)]), $method.' on '.$attribute.' must be rejected'); + $this->assertStringStartsWith('Invalid query: Aggregate '.$method.' requires ', $validator->getDescription()); + $this->assertStringEndsWith(' attribute that is not an array: '.$attribute, $validator->getDescription()); + } + + foreach (['price', 'stock'] as $attribute) { + $this->assertTrue($validator->isValid([$this->aggregate($method, $attribute)]), $validator->getDescription()); + } + } + + public function testBitwiseAggregatesRequireAnIntegerAttribute(): void + { + $validator = $this->validator(); + + foreach (['bitAnd', 'bitOr', 'bitXor'] as $method) { + $this->assertFalse($validator->isValid([$this->aggregate($method, 'rating')])); + $this->assertSame('Invalid query: Aggregate '.$method.' requires an integer attribute that is not an array: rating', $validator->getDescription()); + } + + foreach (['sum', 'avg', 'stddev', 'varSamp'] as $method) { + $this->assertTrue($validator->isValid([$this->aggregate($method, 'rating')]), $validator->getDescription()); + } + } + + public function testJoinedAttributesAreTypedByTheirOwnCollection(): void + { + $validator = $this->validator(); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('reviews', 'review', [Query::on('$id', 'product')]), + Query::sum('review.score', 'total'), + Query::bitAnd('review.flags', 'bits'), + ]), $validator->getDescription()); + } + + public function testCountAcceptsEveryAttributeAndExtremaEveryOrderedOne(): void + { + $validator = $this->validator(); + + foreach (['count', 'countDistinct', 'min', 'max'] as $method) { + foreach (['name', 'created', 'rating', '$id'] as $attribute) { + $this->assertTrue($validator->isValid([$this->aggregate($method, $attribute)]), $validator->getDescription()); + } + } + + foreach (['count', 'countDistinct'] as $method) { + $this->assertTrue($validator->isValid([$this->aggregate($method, 'active')]), $validator->getDescription()); + } + + foreach (['min', 'max'] as $method) { + $this->assertFalse($validator->isValid([$this->aggregate($method, 'active')]), $method.' of a boolean'); + } + } + + public function testOnlyCountAggregatesEveryRow(): void + { + $validator = $this->validator(); + + $this->assertTrue($validator->isValid([Query::count('*', 'rows')]), $validator->getDescription()); + + foreach (['countDistinct', 'min', 'max', 'sum', 'avg', 'bitOr'] as $method) { + $this->assertFalse($validator->isValid([$this->aggregate($method, '*')]), $method.' of "*" must be rejected'); + $this->assertSame('Invalid query: Only count can aggregate "*"', $validator->getDescription()); + } + } +} diff --git a/tests/unit/Validator/QueryShapeAttributes.php b/tests/unit/Validator/QueryShapeAttributes.php new file mode 100644 index 0000000000..c15333fa53 --- /dev/null +++ b/tests/unit/Validator/QueryShapeAttributes.php @@ -0,0 +1,71 @@ + + */ + private function attributes(): array + { + $attributes = [ + 'name' => [ColumnType::String, false], + 'body' => [ColumnType::String, false], + 'price' => [ColumnType::Integer, false], + 'stock' => [ColumnType::BigInteger, false], + 'rating' => [ColumnType::Double, false], + 'active' => [ColumnType::Boolean, false], + 'created' => [ColumnType::Datetime, false], + 'tags' => [ColumnType::String, true], + 'scores' => [ColumnType::Integer, true], + ]; + + $documents = []; + foreach ($attributes as $key => [$type, $array]) { + $documents[] = new Document([ + '$id' => $key, + 'key' => $key, + 'type' => $type->value, + 'size' => $type === ColumnType::String ? 1000 : 0, + 'signed' => true, + 'array' => $array, + ]); + } + + return $documents; + } + + /** + * @param array $indexes + */ + private function validator(array $indexes = []): Indexed + { + $attributes = $this->attributes(); + + return new Indexed($attributes, $indexes, [ + new Limit(), + new Filter($attributes, ColumnType::Integer->value, self::MAX_VALUES), + new Order($attributes), + new Select($attributes), + new Join(), + new Aggregate($attributes), + new GroupBy($attributes), + new Having(), + ]); + } +} diff --git a/tests/unit/Validator/QueryShapeHavingTest.php b/tests/unit/Validator/QueryShapeHavingTest.php new file mode 100644 index 0000000000..aa4895d1b6 --- /dev/null +++ b/tests/unit/Validator/QueryShapeHavingTest.php @@ -0,0 +1,197 @@ + + */ + private function values(int $count): array + { + return \array_map(fn (int $index): string => 'value'.$index, \range(1, $count)); + } + + /** + * @return array}> + */ + public static function validHavingProvider(): array + { + return [ + 'alias greater than' => [[Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::greaterThan('total', 100)])]], + 'alias between' => [[Query::count('*', 'rows'), Query::groupBy(['name']), Query::having([Query::between('rows', 1, 5)])]], + 'alias equal to several values' => [[Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::equal('total', [1, 2.5])])]], + 'alias is null' => [[Query::avg('rating', 'mean'), Query::groupBy(['name']), Query::having([Query::isNull('mean')])]], + 'maximum compared as its attribute' => [[Query::max('name', 'last'), Query::groupBy(['price']), Query::having([Query::greaterThan('last', 'm')])]], + 'grouped attribute' => [[Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::equal('name', ['a', 'b'])])]], + 'grouped attributes in a logical group' => [[Query::sum('price', 'total'), Query::groupBy(['name', 'price']), Query::having([Query::or([Query::equal('name', ['a']), Query::greaterThan('price', 5)])])]], + 'several conditions' => [[Query::count('*', 'rows'), Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::greaterThanEqual('rows', 2), Query::lessThan('total', 500)])]], + 'grouped attribute of a join' => [[Query::leftJoin('orders', 'ord', [Query::on('$id', 'customer')]), Query::count('*', 'rows'), Query::groupBy(['ord.status']), Query::having([Query::equal('ord.status', ['paid'])])]], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('validHavingProvider')] + public function testHavingAcceptsAliasesAndGroupedAttributes(array $queries): void + { + $validator = $this->validator(); + + $this->assertTrue($validator->isValid($queries), $validator->getDescription()); + } + + /** + * @return array, 1: string}> + */ + public static function invalidHavingProvider(): array + { + return [ + 'attribute outside the schema' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::equal('no_such_attribute', ['x'])])], + 'Having can only compare an aggregate alias or a groupBy attribute: no_such_attribute', + ], + 'attribute that is not grouped' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::equal('body', ['x'])])], + 'Having can only compare an aggregate alias or a groupBy attribute: body', + ], + 'attribute without any aggregation' => [ + [Query::having([Query::equal('name', ['x'])])], + 'Having can only compare an aggregate alias or a groupBy attribute: name', + ], + 'grouped attribute with a value of the wrong type' => [ + [Query::sum('price', 'total'), Query::groupBy(['price']), Query::having([Query::equal('price', ['abc'])])], + 'Query value is invalid for attribute "price"', + ], + 'grouped boolean with a value of the wrong type' => [ + [Query::count('*', 'rows'), Query::groupBy(['active']), Query::having([Query::greaterThan('active', 'yes')])], + 'Query value is invalid for attribute "active"', + ], + 'numeric alias compared with text' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::greaterThan('total', 'abc')])], + 'Query value is invalid for aggregate alias "total"', + ], + 'numeric alias compared with a boolean' => [ + [Query::count('*', 'rows'), Query::groupBy(['name']), Query::having([Query::equal('rows', [true])])], + 'Query value is invalid for aggregate alias "rows"', + ], + 'maximum compared with a value its attribute cannot hold' => [ + [Query::max('name', 'last'), Query::groupBy(['price']), Query::having([Query::greaterThan('last', 5)])], + 'Query value is invalid for attribute "name"', + ], + 'operator an aggregate cannot take' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::startsWith('total', '1')])], + 'Aggregate alias "total" cannot be compared with startsWith', + ], + 'alias inside a logical group' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::or([Query::greaterThan('total', 10), Query::lessThan('total', 1)])])], + 'Aggregate alias "total" can only be compared at the top level of having', + ], + 'alias comparison with too many values' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([new Query(Method::GreaterThan, 'total', [1, 2])])], + 'GreaterThan queries require exactly one value.', + ], + 'alias between with one value' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([new Query(Method::Between, 'total', [1])])], + 'Between queries require exactly two values.', + ], + 'alias equal without values' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([new Query(Method::Equal, 'total', [])])], + 'Equal queries require at least one value.', + ], + 'aggregate as a condition' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::sum('price', 'again')])], + 'Having conditions must be filter queries', + ], + 'nested having' => [ + [Query::sum('price', 'total'), Query::groupBy(['name']), Query::having([Query::having([Query::greaterThan('total', 1)])])], + 'Having conditions must be filter queries', + ], + ]; + } + + /** + * @param list $queries + */ + #[DataProvider('invalidHavingProvider')] + public function testHavingRejectsConditionsTheFilterRulesRefuse(array $queries, string $message): void + { + $validator = $this->validator(); + + $this->assertFalse($validator->isValid($queries)); + $this->assertSame('Invalid query: '.$message, $validator->getDescription()); + } + + public function testHavingRequiresAFulltextIndexToSearch(): void + { + $queries = [ + Query::count('*', 'rows'), + Query::groupBy(['body']), + Query::having([Query::search('body', 'word')]), + ]; + + $validator = $this->validator(); + $this->assertFalse($validator->isValid($queries)); + $this->assertSame('Searching by attribute "body" requires a fulltext index.', $validator->getDescription()); + + $indexed = $this->validator([ + new Document(['$id' => 'body_fulltext', 'type' => IndexType::Fulltext->value, 'attributes' => ['body']]), + ]); + $this->assertTrue($indexed->isValid($queries), $indexed->getDescription()); + } + + public function testHavingCapsTheValuesOfEveryCondition(): void + { + $validator = $this->validator(); + + $this->assertTrue($validator->isValid([ + Query::count('*', 'rows'), + Query::groupBy(['name']), + Query::having([Query::equal('name', $this->values(self::MAX_VALUES))]), + ]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::count('*', 'rows'), + Query::groupBy(['name']), + Query::having([Query::equal('name', $this->values(self::MAX_VALUES + 1))]), + ])); + $this->assertSame('Invalid query: Query on attribute has greater than '.self::MAX_VALUES.' values: name', $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::count('*', 'rows'), + Query::groupBy(['name']), + Query::having([Query::equal('rows', \range(1, self::MAX_VALUES + 1))]), + ])); + $this->assertSame('Invalid query: Query on aggregate alias has greater than '.self::MAX_VALUES.' values: rows', $validator->getDescription()); + } + + public function testHavingStateDoesNotLeakIntoTheNextQuerySet(): void + { + $validator = $this->validator(); + + $this->assertTrue($validator->isValid([ + Query::sum('price', 'total'), + Query::groupBy(['name']), + Query::having([Query::greaterThan('total', 1), Query::equal('name', ['a'])]), + ]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::groupBy(['price']), + Query::having([Query::greaterThan('total', 1)]), + ]), 'an alias declared by the previous query set must not stay valid'); + + $this->assertFalse($validator->isValid([ + Query::sum('price', 'total'), + Query::having([Query::equal('name', ['a'])]), + ]), 'a groupBy attribute of the previous query set must not stay valid'); + } +} diff --git a/tests/unit/Validator/QueryShapeJoinCapTest.php b/tests/unit/Validator/QueryShapeJoinCapTest.php new file mode 100644 index 0000000000..ba76b16d5f --- /dev/null +++ b/tests/unit/Validator/QueryShapeJoinCapTest.php @@ -0,0 +1,58 @@ + + */ + private function crossJoins(int $count): array + { + return \array_map(fn (int $index): Query => Query::crossJoin('other', 'joined'.$index), \range(1, $count)); + } + + public function testAtMostEightJoinsPerQuery(): void + { + $validator = new Base([new Join(), new Limit()]); + + $this->assertTrue($validator->isValid($this->crossJoins(8)), $validator->getDescription()); + + $this->assertFalse($validator->isValid($this->crossJoins(9))); + $this->assertSame('Too many joins: at most 8 are allowed', $validator->getDescription()); + + $this->assertFalse($validator->isValid([...$this->crossJoins(61), Query::limit(1)])); + $this->assertSame('Too many joins: at most 8 are allowed', $validator->getDescription()); + + $mixed = [ + ...$this->crossJoins(4), + Query::join('orders', 'first', [Query::on('$id', 'customer')]), + Query::leftJoin('orders', 'second', [Query::on('$id', 'customer')]), + Query::rightJoin('orders', 'third', [Query::on('$id', 'customer')]), + Query::fullOuterJoin('orders', 'fourth', [Query::on('$id', 'customer')]), + Query::join('orders', 'fifth', [Query::on('$id', 'fifth.customer')]), + ]; + $this->assertFalse($validator->isValid($mixed), 'every kind of join counts towards the cap'); + $this->assertSame('Too many joins: at most 8 are allowed', $validator->getDescription()); + } + + public function testJoinCapAppliesToSingleDocumentQueries(): void + { + $validator = new DocumentQueries($this->attributes(), Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + + $this->assertTrue($validator->isValid($this->crossJoins(8)), $validator->getDescription()); + $this->assertFalse($validator->isValid($this->crossJoins(9))); + $this->assertSame('Too many joins: at most 8 are allowed', $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/QueryShapeLengthTest.php b/tests/unit/Validator/QueryShapeLengthTest.php new file mode 100644 index 0000000000..828dfe3669 --- /dev/null +++ b/tests/unit/Validator/QueryShapeLengthTest.php @@ -0,0 +1,72 @@ + + */ + private function three(): array + { + return [Query::equal('name', ['a']), Query::equal('name', ['b']), Query::equal('name', ['c'])]; + } + + public function testLengthCapsEveryNestedGroup(): void + { + $validator = new Base([new Filter($this->attributes(), ColumnType::Integer->value)], length: 2); + + $this->assertTrue($validator->isValid([Query::or([Query::equal('name', ['a']), Query::equal('name', ['b'])])]), $validator->getDescription()); + $this->assertFalse($validator->isValid($this->three()), 'the top level holds three queries'); + $this->assertFalse($validator->isValid([Query::or($this->three())]), 'an or group holds three queries'); + $this->assertFalse($validator->isValid([Query::and($this->three())]), 'an and group holds three queries'); + $this->assertFalse($validator->isValid([Query::and([Query::or($this->three()), Query::equal('name', ['d'])])]), 'a group two levels down holds three queries'); + + $schemaless = new Base([new Filter($this->attributes(), ColumnType::Integer->value, supportForAttributes: false)], length: 2); + $this->assertTrue($schemaless->isValid([Query::elemMatch('items', [Query::equal('sku', ['a']), Query::equal('sku', ['b'])])]), $schemaless->getDescription()); + $this->assertFalse($schemaless->isValid([Query::elemMatch('items', [Query::equal('sku', ['a']), Query::equal('sku', ['b']), Query::equal('sku', ['c'])])]), 'an elemMatch group holds three queries'); + } + + public function testLengthRejectionsNameTheGroupThatIsTooLong(): void + { + $validator = new Base([new Filter($this->attributes(), ColumnType::Integer->value)], length: 2); + + $this->assertFalse($validator->isValid($this->three())); + $this->assertSame('Too many queries: at most 2 are allowed', $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::and([Query::or($this->three()), Query::equal('name', ['d'])])])); + $this->assertSame('Too many queries in or: at most 2 are allowed', $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::and($this->three())])); + $this->assertSame('Too many queries in and: at most 2 are allowed', $validator->getDescription()); + } + + public function testLengthCapsHavingConditions(): void + { + $attributes = $this->attributes(); + $validator = new Base([ + new Filter($attributes, ColumnType::Integer->value), + new Aggregate($attributes), + new GroupBy($attributes), + new Having(), + ], length: 3); + + $this->assertFalse($validator->isValid([ + Query::sum('price', 'total'), + Query::groupBy(['name']), + Query::having([Query::greaterThan('total', 1), Query::lessThan('total', 9), Query::equal('name', ['a']), Query::notEqual('name', 'b')]), + ])); + $this->assertSame('Too many queries in having: at most 3 are allowed', $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/QueryTest.php b/tests/unit/Validator/QueryTest.php index 8433f47f21..bdc931e355 100644 --- a/tests/unit/Validator/QueryTest.php +++ b/tests/unit/Validator/QueryTest.php @@ -4,10 +4,13 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Tests\Unit\Support\Profiles; +use Utopia\Database\Capability; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Queries\Documents; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; class QueryTest extends TestCase { @@ -19,13 +22,14 @@ class QueryTest extends TestCase /** * @throws Exception */ - public function setUp(): void + #[\Override] + protected function setUp(): void { $attributes = [ [ '$id' => 'title', 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 256, 'required' => true, 'signed' => true, @@ -35,7 +39,7 @@ public function setUp(): void [ '$id' => 'description', 'key' => 'description', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 1000000, 'required' => true, 'signed' => true, @@ -45,7 +49,7 @@ public function setUp(): void [ '$id' => 'rating', 'key' => 'rating', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 5, 'required' => true, 'signed' => true, @@ -55,7 +59,7 @@ public function setUp(): void [ '$id' => 'price', 'key' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'size' => 5, 'required' => true, 'signed' => true, @@ -65,7 +69,7 @@ public function setUp(): void [ '$id' => 'published', 'key' => 'published', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'size' => 5, 'required' => true, 'signed' => true, @@ -75,7 +79,7 @@ public function setUp(): void [ '$id' => 'tags', 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 55, 'required' => true, 'signed' => true, @@ -85,7 +89,7 @@ public function setUp(): void [ '$id' => 'birthDay', 'key' => 'birthDay', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'signed' => false, @@ -99,16 +103,17 @@ public function setUp(): void } } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } /** * @throws Exception */ - public function testQuery(): void + public function test_query(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $this->assertEquals(true, $validator->isValid([Query::equal('$id', ['Iron Man', 'Ant Man'])])); $this->assertEquals(true, $validator->isValid([Query::equal('$id', ['Iron Man'])])); @@ -117,8 +122,8 @@ public function testQuery(): void $this->assertEquals(true, $validator->isValid([Query::notEqual('title', 'Iron Man')])); $this->assertEquals(true, $validator->isValid([Query::lessThan('price', 6.50)])); $this->assertEquals(true, $validator->isValid([Query::lessThanEqual('price', 6)])); - $this->assertEquals(true, $validator->isValid([Query::contains('tags', ['action1', 'action2'])])); - $this->assertEquals(true, $validator->isValid([Query::contains('tags', ['action1'])])); + $this->assertEquals(true, $validator->isValid([Query::containsAny('tags', ['action1', 'action2'])])); + $this->assertEquals(true, $validator->isValid([Query::containsAny('tags', ['action1'])])); $this->assertEquals(true, $validator->isValid([Query::cursorAfter(new Document(['$id' => 'docId']))])); $this->assertEquals(true, $validator->isValid([Query::cursorBefore(new Document(['$id' => 'docId']))])); $this->assertEquals(true, $validator->isValid([Query::orderAsc('title')])); @@ -136,9 +141,9 @@ public function testQuery(): void /** * @throws Exception */ - public function testAttributeNotFound(): void + public function test_attribute_not_found(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::equal('name', ['Iron Man'])]); $this->assertEquals(false, $response); @@ -152,9 +157,9 @@ public function testAttributeNotFound(): void /** * @throws Exception */ - public function testAttributeWrongType(): void + public function test_attribute_wrong_type(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::equal('title', [1776])]); $this->assertEquals(false, $response); @@ -164,9 +169,9 @@ public function testAttributeWrongType(): void /** * @throws Exception */ - public function testQueryDate(): void + public function test_query_date(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::greaterThan('birthDay', '1960-01-01 10:10:10')]); $this->assertEquals(true, $response); @@ -175,9 +180,9 @@ public function testQueryDate(): void /** * @throws Exception */ - public function testQueryLimit(): void + public function test_query_limit(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::limit(25)]); $this->assertEquals(true, $response); @@ -189,9 +194,9 @@ public function testQueryLimit(): void /** * @throws Exception */ - public function testQueryOffset(): void + public function test_query_offset(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::offset(25)]); $this->assertEquals(true, $response); @@ -203,9 +208,9 @@ public function testQueryOffset(): void /** * @throws Exception */ - public function testQueryOrder(): void + public function test_query_order(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::orderAsc('title')]); $this->assertEquals(true, $response); @@ -223,9 +228,9 @@ public function testQueryOrder(): void /** * @throws Exception */ - public function testQueryCursor(): void + public function test_query_cursor(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::cursorAfter(new Document(['$id' => 'asdf']))]); $this->assertEquals(true, $response); @@ -234,7 +239,7 @@ public function testQueryCursor(): void /** * @throws Exception */ - public function testQueryGetByType(): void + public function test_query_get_by_type(): void { $queries = [ Query::equal('key', ['value']), @@ -242,29 +247,26 @@ public function testQueryGetByType(): void Query::cursorAfter(new Document([])), ]; - $queries1 = Query::getByType($queries, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + $queries1 = Query::getByType($queries, [Method::CursorAfter, Method::CursorBefore]); $this->assertCount(2, $queries1); foreach ($queries1 as $query) { - $this->assertEquals(true, in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE])); + $this->assertEquals(true, in_array($query->getMethod(), [Method::CursorAfter, Method::CursorBefore])); } $cursor = reset($queries1); - - $this->assertInstanceOf(Query::class, $cursor); - $cursor->setValue(new Document(['$id' => 'hello1'])); $query1 = $queries[1]; - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $query1->getMethod()); + $this->assertEquals(Method::CursorBefore, $query1->getMethod()); $this->assertInstanceOf(Document::class, $query1->getValue()); $this->assertTrue($query1->getValue()->isEmpty()); // Cursor Document is not updated /** * Using reference $queries2 => $queries */ - $queries2 = Query::getByType($queries, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], false); + $queries2 = Query::getByType($queries, [Method::CursorAfter, Method::CursorBefore], false); $cursor = reset($queries2); $this->assertInstanceOf(Query::class, $cursor); @@ -274,7 +276,7 @@ public function testQueryGetByType(): void $query2 = $queries[1]; $this->assertCount(2, $queries2); - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $query2->getMethod()); + $this->assertEquals(Method::CursorBefore, $query2->getMethod()); $this->assertInstanceOf(Document::class, $query2->getValue()); $this->assertEquals('hello1', $query2->getValue()->getId()); // Cursor Document is updated @@ -297,7 +299,7 @@ public function testQueryGetByType(): void $query3 = $queries[1]; $this->assertCount(2, $queries3); - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $query3->getMethod()); + $this->assertEquals(Method::CursorBefore, $query3->getMethod()); $this->assertInstanceOf(Document::class, $query3->getValue()); $this->assertEquals('hello3', $query3->getValue()->getId()); // Cursor Document is updated } @@ -305,9 +307,9 @@ public function testQueryGetByType(): void /** * @throws Exception */ - public function testQueryEmpty(): void + public function test_query_empty(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $response = $validator->isValid([Query::equal('title', [''])]); $this->assertEquals(true, $response); @@ -334,9 +336,9 @@ public function testQueryEmpty(): void /** * @throws Exception */ - public function testOrQuery(): void + public function test_or_query(): void { - $validator = new Documents($this->attributes, [], Database::VAR_INTEGER); + $validator = new Documents($this->attributes, [], Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::OrderRandom])); $this->assertFalse($validator->isValid( [Query::or( @@ -351,7 +353,7 @@ public function testOrQuery(): void Query::or( [ Query::equal('price', [0]), - Query::equal('not_found', ['']) + Query::equal('not_found', ['']), ] )] )); @@ -364,7 +366,7 @@ public function testOrQuery(): void Query::or( [ Query::select(['price']), - Query::limit(1) + Query::limit(1), ] )] )); diff --git a/tests/unit/Validator/QueryValidationTest.php b/tests/unit/Validator/QueryValidationTest.php new file mode 100644 index 0000000000..a7842b0996 --- /dev/null +++ b/tests/unit/Validator/QueryValidationTest.php @@ -0,0 +1,482 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new NoCache())); + $this->database + ->setDatabase('query_validation') + ->setNamespace('query_validation_'.\uniqid()) + ->setAuthorization(new Authorization()); + $this->database->addHook(new Permissions()); + $this->database->addHook(new Relationships()); + $this->database->create(); + + $this->createCollection('authors', [Attribute::string(key: 'name', size: 32)]); + $this->createCollection('users', [Attribute::string(key: 'name', size: 32)]); + $this->createCollection('posts', [Attribute::string(key: 'owner', size: 32), Attribute::integer(key: 'votes')]); + $this->database->createRelationship('posts', Relationship::manyToOne( + relatedCollection: 'authors', + key: 'author', + onDelete: RelationshipDeleteAction::SetNull, + )); + + $this->createDocument('authors', 'ann', ['name' => 'Ann']); + $this->createDocument('authors', 'bob', ['name' => 'Bob']); + $this->createDocument('users', 'bob', ['name' => 'Bob']); + $this->createDocument('users', 'ann', ['name' => 'Ann']); + $this->createDocument('posts', 'first', ['owner' => 'bob', 'votes' => 3, 'author' => 'ann']); + $this->createDocument('posts', 'second', ['owner' => 'ann', 'votes' => 5, 'author' => 'bob']); + + $this->createCollection('owners', [ + Attribute::string(key: 'name', size: 32), + Attribute::integer(key: 'score'), + Attribute::boolean(key: 'active'), + Attribute::string(key: 'tags', size: 32, array: true), + ]); + $this->createCollection('items', [ + Attribute::string(key: 'title', size: 32), + Attribute::integer(key: 'price'), + Attribute::boolean(key: 'featured'), + Attribute::string(key: 'labels', size: 32, array: true), + Attribute::string(key: 'ownerRef', size: 32), + ]); + $this->database->createRelationship('owners', Relationship::oneToMany( + relatedCollection: 'items', + twoWay: true, + key: 'items', + twoWayKey: 'owner', + onDelete: RelationshipDeleteAction::SetNull, + )); + + $this->createDocument('owners', 'ann', ['name' => 'Ann', 'score' => 2, 'active' => true, 'tags' => ['a']]); + $this->createDocument('owners', 'bob', ['name' => 'Bob', 'score' => 4, 'active' => false, 'tags' => ['b']]); + $this->createDocument('items', 'pen', ['title' => 'pen', 'price' => 5, 'featured' => true, 'labels' => ['x'], 'ownerRef' => 'ann', 'owner' => 'ann']); + $this->createDocument('items', 'cup', ['title' => 'cup', 'price' => 7, 'featured' => false, 'labels' => ['y'], 'ownerRef' => 'bob', 'owner' => 'bob']); + } + + /** + * With a join aliased `author` next to the relationship `author`, `author.name` would be checked + * against the joined users but run as a filter on the related authors, returning the other post. + */ + public function testAJoinAliasEqualToARelationshipKeyIsRejected(): void + { + $message = 'Join alias "author" is the key of the relationship attribute "author": give the join another alias'; + + foreach ([ + 'inline condition' => Query::join('users', 'author', [Query::on('owner', '$id')]), + 'on() condition' => Query::join('users', 'author', [Query::on('owner', '$id')]), + 'left join' => Query::leftJoin('users', 'author', [Query::on('owner', '$id')]), + ] as $shape => $join) { + $queries = [$join, Query::equal('author.name', ['Bob'])]; + + $this->assertInvalidQuery($message, fn (): mixed => $this->database->find('posts', $queries), $shape.': find()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->count('posts', $queries), $shape.': count()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->sum('posts', 'votes', $queries), $shape.': sum()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->getDocument('posts', 'first', [$join]), $shape.': getDocument()'); + } + + $this->assertInvalidQuery( + $message, + fn (): mixed => $this->database->aggregate('posts', [Query::join('users', 'author', [Query::on('owner', '$id')]), Query::count('*', 'rows'), Query::select(['author.*'])]), + 'an aggregate next to the relationship wildcard', + ); + + $joined = $this->database->find('posts', [Query::join('users', 'usr', [Query::on('owner', '$id')]), Query::equal('usr.name', ['Bob'])]); + $this->assertSame(['first'], $this->ids($joined), 'another alias filters the joined users'); + $this->assertSame(1, $this->database->count('posts', [Query::join('users', 'usr', [Query::on('owner', '$id')]), Query::equal('usr.name', ['Bob'])])); + $this->assertSame(['second'], $this->ids($this->database->find('posts', [Query::equal('author.name', ['Bob'])])), 'without a join author.name filters the related authors'); + } + + public function testTheJoinValidatorNamesTheRelationshipAnAliasCollidesWith(): void + { + $validator = new Join([ + new Document(['$id' => 'owner', 'key' => 'owner', 'type' => ColumnType::String->value]), + new Document(['$id' => 'author', 'key' => 'author', 'type' => ColumnType::Relationship->value, 'options' => ['relationType' => RelationshipType::ManyToOne->value, 'side' => 'parent', 'relatedCollection' => 'authors']]), + ]); + + $this->assertFalse($validator->isValid(Query::join('users', 'author', [Query::on('owner', '$id')]))); + $this->assertSame('Join alias "author" is the key of the relationship attribute "author": give the join another alias', $validator->getDescription()); + + $validator->resetJoinAliases(); + $this->assertTrue($validator->isValid(Query::join('users', 'Author', [Query::on('owner', '$id')])), 'relationship keys are matched as the relationship hook matches them, by exact name'); + + $validator->resetJoinAliases(); + $this->assertTrue($validator->isValid(Query::join('users', 'owner', [Query::on('owner', '$id')])), 'an alias may still equal an attribute that is not a relationship'); + } + + /** + * @return iterable + */ + public static function refusedJoinConditions(): iterable + { + yield 'limit' => [Query::limit(1), 'limit']; + yield 'offset' => [Query::offset(1), 'offset']; + yield 'cursor' => [Query::cursorAfter(new Document(['$id' => 'pen'])), 'cursorAfter']; + yield 'order' => [Query::orderAsc('title'), 'orderAsc']; + yield 'select' => [Query::select(['title']), 'select']; + yield 'aggregate' => [Query::count('*', 'rows'), 'count']; + yield 'join' => [Query::join('owners', 'nested', [Query::on('$id', '$id')]), 'join']; + yield 'containsAll' => [Query::containsAll('it.labels', ['x']), 'containsAll']; + yield 'search' => [Query::search('it.title', 'pen'), 'search']; + yield 'regex' => [Query::regex('it.title', '^p'), 'regex']; + yield 'regex inside or()' => [Query::or([Query::equal('it.title', ['pen']), Query::regex('it.title', '^p')]), 'regex']; + } + + /** + * The join factories accept on() conditions and plain filters only. A join built around them still + * has its ON list refused while the read builds the statement. + */ + #[DataProvider('refusedJoinConditions')] + public function testAJoinOnListAcceptsOnlyConditionsAndPlainFilters(Query $condition, string $method): void + { + $on = [Query::on('$id', 'ownerRef'), $condition]; + $message = 'Unsupported join ON condition: '.$method; + + try { + Query::join('items', 'it', $on); + $this->fail('Query::join(): the ON list was accepted'); + } catch (ValidationException $error) { + $this->assertSame($message, $error->getMessage(), 'Query::join()'); + } + + $queries = [new Query(Method::Join, 'items', $on, 'it')]; + + $this->assertInvalidQuery($message, fn (): mixed => $this->database->find('owners', $queries), 'find()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->count('owners', $queries), 'count()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->sum('owners', 'score', $queries), 'sum()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->getDocument('owners', 'ann', $queries), 'getDocument()'); + } + + public function testAJoinOnListRunsItsPlainFilters(): void + { + foreach ([ + 'equal' => Query::equal('it.title', ['pen']), + 'or()' => Query::or([Query::equal('it.title', ['pen']), Query::startsWith('it.title', 'pe')]), + 'contains on an array' => Query::contains('it.labels', ['x']), + ] as $shape => $filter) { + $queries = [Query::join('items', 'it', [Query::on('$id', 'ownerRef'), $filter])]; + + $this->assertSame(['ann'], $this->ids($this->database->find('owners', $queries)), $shape); + $this->assertSame(1, $this->database->count('owners', $queries), $shape); + $this->assertSame(2, $this->database->sum('owners', 'score', $queries), $shape); + } + } + + /** + * @return iterable + */ + public static function refusedExistsQueries(): iterable + { + yield 'an internal column next to an attribute' => [new Query(Method::Exists, 'name', ['_permissions']), 'Attribute not found in schema: _permissions']; + yield 'an internal column' => [Query::exists(['_uid']), 'Attribute not found in schema: _uid']; + yield 'an internal column, notExists' => [Query::notExists(['_permissions']), 'Attribute not found in schema: _permissions']; + yield 'an unknown attribute' => [Query::exists(['name', 'missing']), 'Attribute not found in schema: missing']; + yield 'a relationship side without a column' => [Query::exists(['items']), 'Cannot query on virtual relationship attribute']; + yield 'a related document\'s attribute' => [Query::exists(['items.title']), 'Exists queries take attributes of the collection or of a join alias: items.title']; + yield 'a value that is not a name' => [Query::notExists([7]), 'NotExists queries take attribute names']; + } + + /** + * exists() and notExists() test the columns their values name, so each value has to name an + * attribute a filter could name. + */ + #[DataProvider('refusedExistsQueries')] + public function testExistsValuesNameAttributesOfTheCollection(Query $query, string $message): void + { + $this->assertInvalidQuery($message, fn (): mixed => $this->database->find('owners', [$query]), 'find()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->count('owners', [$query]), 'count()'); + } + + public function testExistsInItsDocumentedFormRuns(): void + { + $this->assertSame(['ann', 'bob'], $this->ids($this->database->find('owners', [Query::exists(['name'])]))); + $this->assertSame(['ann', 'bob'], $this->ids($this->database->find('owners', [Query::exists(['name', 'score', '$createdAt'])]))); + $this->assertSame([], $this->ids($this->database->find('owners', [Query::notExists('name')]))); + $this->assertSame(2, $this->database->count('owners', [Query::exists(['tags'])])); + $this->assertSame(['pen', 'cup'], $this->ids($this->database->find('items', [Query::exists(['owner'])])), 'the side of a relationship that holds a column'); + + $joined = [Query::join('items', 'it', [Query::on('$id', 'ownerRef')]), Query::exists(['it.title'])]; + $this->assertSame(['ann', 'bob'], $this->ids($this->database->find('owners', $joined)), 'a column under a join alias'); + } + + /** + * @return iterable, string}> + */ + public static function relationshipSidesWithoutAColumn(): iterable + { + yield 'groupBy' => [[Query::groupBy(['items']), Query::count('*', 'rows')], 'Cannot group by virtual relationship attribute: items']; + yield 'count' => [[Query::count('items', 'rows')], 'Cannot aggregate virtual relationship attribute: items']; + yield 'countDistinct' => [[Query::countDistinct('items', 'rows')], 'Cannot aggregate virtual relationship attribute: items']; + yield 'min' => [[Query::min('items', 'least')], 'Cannot aggregate virtual relationship attribute: items']; + yield 'max' => [[Query::max('items', 'most')], 'Cannot aggregate virtual relationship attribute: items']; + } + + /** + * The parent side of a one-to-many holds no column, so the engine has nothing to aggregate or + * group by; filters and join conditions refuse it already. + * + * @param list $queries + */ + #[DataProvider('relationshipSidesWithoutAColumn')] + public function testAggregatesAndGroupsRefuseARelationshipSideWithoutAColumn(array $queries, string $message): void + { + $this->assertInvalidQuery($message, fn (): mixed => $this->database->find('owners', $queries), 'find()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->count('owners', $queries), 'count()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->sum('owners', 'score', $queries), 'sum()'); + } + + /** + * @return iterable, string}> + */ + public static function extremaWithoutAnOrder(): iterable + { + $join = Query::join('items', 'it', [Query::on('$id', 'ownerRef')]); + + yield 'max of a boolean' => [[Query::max('active', 'most')], 'max', 'active']; + yield 'min of an array' => [[Query::min('tags', 'least')], 'min', 'tags']; + yield 'max of a joined boolean' => [[$join, Query::max('it.featured', 'most')], 'max', 'it.featured']; + yield 'min of a joined array' => [[$join, Query::min('it.labels', 'least')], 'min', 'it.labels']; + yield 'max of a bare joined boolean' => [[$join, Query::max('featured', 'most')], 'max', 'featured']; + } + + /** + * PostgreSQL has no min() or max() for booleans, JSON (arrays and objects), geometries or vectors, + * so they are refused on every engine. + * + * @param list $queries + */ + #[DataProvider('extremaWithoutAnOrder')] + public function testMinAndMaxRefuseValuesWithoutAnOrder(array $queries, string $method, string $attribute): void + { + $message = 'Aggregate '.$method.' requires an attribute whose values are ordered, not an array, object, boolean, spatial or vector one: '.$attribute; + + $this->assertInvalidQuery($message, fn (): mixed => $this->database->find('owners', $queries), 'find()'); + $this->assertInvalidQuery($message, fn (): mixed => $this->database->count('owners', $queries), 'count()'); + } + + public function testMinAndMaxAcceptOrderedValues(): void + { + $join = Query::join('items', 'it', [Query::on('$id', 'ownerRef')]); + + $rows = $this->database->aggregate('owners', [ + $join, + Query::min('name', 'first'), + Query::max('score', 'best'), + Query::max('$createdAt', 'latest'), + Query::max('it.price', 'dearest'), + Query::min('it.title', 'title'), + ]); + $this->assertCount(1, $rows); + $this->assertSame('Ann', $rows[0]['first']); + $this->assertSame(4, $rows[0]['best']); + $this->assertSame(7, $rows[0]['dearest']); + $this->assertSame('cup', $rows[0]['title']); + + $owners = $this->database->aggregate('items', [Query::groupBy(['owner']), Query::count('*', 'rows'), Query::max('owner', 'last'), Query::orderAsc('owner')]); + $this->assertSame(['ann', 'bob'], \array_map(static fn (array $group): mixed => $group['owner'], $owners), 'the side of a relationship that holds a column'); + } + + public function testTheAggregateValidatorRefusesExtremaOfUnorderedTypes(): void + { + $definition = static fn (string $key, ColumnType $type, bool $array = false): Document => new Document(['$id' => $key, 'key' => $key, 'type' => $type->value, 'array' => $array]); + $validator = new Aggregate([ + $definition('meta', ColumnType::Object), + $definition('place', ColumnType::Point), + $definition('route', ColumnType::Linestring), + $definition('area', ColumnType::Polygon), + $definition('embedding', ColumnType::Vector), + $definition('flag', ColumnType::Boolean), + $definition('codes', ColumnType::Integer, true), + $definition('name', ColumnType::String), + $definition('size', ColumnType::Integer), + $definition('ratio', ColumnType::Double), + $definition('born', ColumnType::Datetime), + ]); + + foreach (['meta', 'place', 'route', 'area', 'embedding', 'flag', 'codes'] as $attribute) { + foreach ([Query::min($attribute), Query::max($attribute)] as $query) { + $this->assertFalse($validator->isValid($query), $attribute); + $this->assertStringEndsWith(': '.$attribute, $validator->getDescription()); + } + $this->assertTrue($validator->isValid(Query::count($attribute)), $attribute.': count needs no order'); + } + + foreach (['name', 'size', 'ratio', 'born', '$id', '$createdAt'] as $attribute) { + $this->assertTrue($validator->isValid(Query::min($attribute)), $attribute.': '.$validator->getDescription()); + $this->assertTrue($validator->isValid(Query::max($attribute)), $attribute.': '.$validator->getDescription()); + } + } + + /** + * A join returns a joined collection's relationship attributes only when a select names them; + * the side that stores the related id holds a column, so it can be selected and ordered by. + */ + public function testAJoinedRelationshipSideThatHoldsAColumnIsSelectableAndOrderable(): void + { + $rows = $this->database->find('owners', [ + Query::join('items', 'it', [Query::on('$id', 'ownerRef')]), + Query::select(['name', 'it.owner']), + Query::orderDesc('it.owner'), + ]); + + $this->assertSame(['bob', 'ann'], \array_map(static fn (Document $row): mixed => $row->getAttribute('it.owner'), $rows)); + $this->assertSame(['Bob', 'Ann'], \array_map(static fn (Document $row): mixed => $row->getAttribute('name'), $rows)); + + $virtual = [Query::join('owners', 'ow', [Query::on('ownerRef', '$id')]), Query::select(['title', 'ow.items'])]; + $this->assertInvalidQuery('Attribute not found in schema: ow.items', fn (): mixed => $this->database->find('items', $virtual), 'the side that holds no column'); + $this->assertInvalidQuery('Attribute not found in schema: ow.items', fn (): mixed => $this->database->find('items', [Query::join('owners', 'ow', [Query::on('ownerRef', '$id')]), Query::orderAsc('ow.items')]), 'ordered by the side that holds no column'); + } + + /** + * A document read validator accepts joins by default, as its callers expect, and refuses them + * with the Documents validator's opt-in flag turned off. + */ + public function testDocumentQueriesRejectJoinsUnlessEnabled(): void + { + $attributes = [new Document(['$id' => 'name', 'key' => 'name', 'type' => ColumnType::String->value, 'array' => false])]; + $joins = [ + 'inline condition' => Query::join('notes', 'note', [Query::on('$id', 'customerId')]), + 'on() list with a filter' => Query::join('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.body', ['x'])]), + ]; + + $enabled = new DocumentQueries($attributes, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt, Capability::Joins])); + $disabled = new DocumentQueries($attributes, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); + + foreach ($joins as $shape => $join) { + $this->assertTrue($enabled->isValid([$join]), $shape.': '.$enabled->getDescription()); + + $this->assertFalse($disabled->isValid([$join]), $shape); + $this->assertSame('Invalid query method: join', $disabled->getDescription(), $shape); + } + + $this->assertTrue($disabled->isValid([Query::select(['name'])]), $disabled->getDescription()); + } + + /** + * getDocument() builds its query validator with the adapter's join support, so on an adapter + * without joins a join is refused by validation, as find() refuses it. + */ + public function testGetDocumentRefusesAJoinOnAnAdapterWithoutJoins(): void + { + $database = new Database(new Memory(), new Cache(new NoCache())); + $database + ->setDatabase('query_validation') + ->setNamespace('query_validation_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(Collection::create(id: 'customers', attributes: [Attribute::string(key: 'name', size: 32)], permissions: $permissions, documentSecurity: false)); + $database->createCollection(Collection::create(id: 'notes', attributes: [Attribute::string(key: 'customerId', size: 32)], permissions: $permissions, documentSecurity: false)); + $database->createDocument('customers', new Document(['$id' => 'c1', 'name' => 'Ann'])); + + $this->assertInvalidQuery('Invalid query method: join', fn (): mixed => $database->getDocument('customers', 'c1', [Query::join('notes', 'note', [Query::on('$id', 'customerId')])])); + $this->assertSame('Ann', $database->getDocument('customers', 'c1', [Query::select(['name'])])->getAttribute('name')); + } + + /** + * rawQuery() runs SQL as written, with no permission or tenant scope, so like from() and + * execute() it runs only while authorization is skipped. + */ + public function testRawQueryRunsOnlyWhileAuthorizationIsSkipped(): void + { + try { + $this->database->rawQuery('SELECT ? AS answer', [42]); + $this->fail('rawQuery() ran with authorization enabled'); + } catch (AuthorizationException $error) { + $this->assertStringContainsString('getAuthorization()->skip()', $error->getMessage()); + } + + $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->rawQuery('SELECT ? AS answer', [42])); + + $this->assertCount(1, $rows); + $this->assertSame(42, $rows[0]['answer']); + } + + /** + * @param array $documents + * @return list + */ + private function ids(array $documents): array + { + return \array_map(static fn (Document $document): string => $document->getId(), \array_values($documents)); + } + + private function assertInvalidQuery(string $message, Closure $run, string $case = ''): void + { + try { + $run(); + $this->fail($case.': the query ran instead of being rejected with "'.$message.'"'); + } catch (QueryException $error) { + $this->assertStringContainsString($message, $error->getMessage(), $case); + } + } + + /** + * @param list $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(Collection::create( + id: $id, + attributes: $attributes, + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + } + + /** + * @param array $attributes + */ + private function createDocument(string $collection, string $id, array $attributes): void + { + $this->database->createDocument($collection, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + ...$attributes, + ])); + } +} diff --git a/tests/unit/Validator/QueryValidatorCoverageTest.php b/tests/unit/Validator/QueryValidatorCoverageTest.php new file mode 100644 index 0000000000..1f2b467c6b --- /dev/null +++ b/tests/unit/Validator/QueryValidatorCoverageTest.php @@ -0,0 +1,281 @@ +filter(); + $filter->allowJoinAliases(['joined']); + + $this->assertTrue($filter->isValid(Query::equal('joined.anything', ['a', 'b'])), $filter->getDescription()); + $this->assertFalse($filter->isValid(Query::equal('joined.anything', ['a', 'b', 'c']))); + $this->assertSame('Query on attribute has greater than 2 values: joined.anything', $filter->getDescription()); + } + + public function testAnUndeclaredAttributeInSchemalessModeKeepsTheValueLimit(): void + { + $filter = $this->filter(supportForAttributes: false); + + $this->assertTrue($filter->isValid(Query::equal('undeclared', ['a', 'b'])), $filter->getDescription()); + $this->assertFalse($filter->isValid(Query::equal('undeclared', ['a', 'b', 'c']))); + $this->assertSame('Query on attribute has greater than 2 values: undeclared', $filter->getDescription()); + } + + /** + * @return array + */ + public static function typesWithoutAValueRule(): array + { + return [ + 'decimal' => [ColumnType::Decimal->value], + 'json' => [ColumnType::Json->value], + 'unknown name' => ['mystery'], + ]; + } + + #[DataProvider('typesWithoutAValueRule')] + public function testAnAttributeTypeWithoutAValueRuleIsRefused(string $type): void + { + $this->expectException(StructureException::class); + $this->expectExceptionMessage('Unknown attribute type: '.$type); + + new Filter([new Document([ + Document::ID => 'amount', + 'key' => 'amount', + 'type' => $type, + 'array' => false, + ])], ColumnType::Integer->value, self::MAX_VALUES); + } + + public function testAnElemMatchWithAnInvalidNestedFilterIsRejected(): void + { + $filter = $this->filter(supportForAttributes: false); + + $this->assertTrue($filter->isValid(new Query(Method::ElemMatch, 'items', [Query::equal('sku', ['a'])])), $filter->getDescription()); + + $this->assertFalse($filter->isValid(new Query(Method::ElemMatch, 'items', [Query::equal('sku', ['a']), new Query(Method::Equal, 'sku', [])]))); + $this->assertSame('Equal queries require at least one value.', $filter->getDescription()); + } + + public function testAnElemMatchChildMustBeAQuery(): void + { + $filter = $this->filter(supportForAttributes: false); + + $this->assertFalse($filter->isValid(new Query(Method::ElemMatch, 'items', ['{"method":"equal","attribute":"sku","values":["a"]}']))); + $this->assertSame('elemMatch queries can only contain filter queries', $filter->getDescription()); + } + + /** + * @return array + */ + public static function spatialMethods(): array + { + return [ + 'crosses' => [Method::Crosses], + 'intersects' => [Method::Intersects], + 'overlaps' => [Method::Overlaps], + 'touches' => [Method::NotTouches], + 'covers' => [Method::Covers], + 'spatial equals' => [Method::SpatialEquals], + ]; + } + + #[DataProvider('spatialMethods')] + public function testASpatialQueryNeedsAValue(Method $method): void + { + $filter = $this->filter(); + + $this->assertFalse($filter->isValid(new Query($method, 'location', []))); + $this->assertSame(\ucfirst($method->value).' queries require at least one value.', $filter->getDescription()); + + $this->assertTrue($filter->isValid(new Query($method, 'location', [[1.0, 2.0]])), $filter->getDescription()); + } + + public function testAVectorQueryTakesExactlyOneVector(): void + { + $filter = $this->filter(); + + $this->assertTrue($filter->isValid(new Query(Method::VectorDot, 'embedding', [[1.0, 2.0, 3.0]])), $filter->getDescription()); + + $this->assertFalse($filter->isValid(new Query(Method::VectorDot, 'embedding', [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]))); + $this->assertSame('VectorDot queries require exactly one vector value.', $filter->getDescription()); + } + + public function testAVectorQueryOnAJoinedAttributeTakesExactlyOneVectorAndIsThenRefused(): void + { + $filter = $this->filter(); + $filter->allowJoinAliases(['joined']); + + $this->assertFalse($filter->isValid(new Query(Method::VectorCosine, 'joined.embedding', [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]))); + $this->assertSame('VectorCosine queries require exactly one vector value.', $filter->getDescription()); + + $this->assertFalse($filter->isValid(new Query(Method::VectorCosine, 'joined.embedding', [[1.0, 2.0, 3.0]]))); + $this->assertSame('Vector queries cannot be used on a joined attribute: joined.embedding', $filter->getDescription()); + } + + public function testAVectorQueryOnARelationshipPathIsCheckedAgainstTheRelationship(): void + { + $filter = $this->filter(); + + $this->assertFalse($filter->isValid(new Query(Method::VectorEuclidean, 'author.embedding', [[1.0, 2.0, 3.0]]))); + $this->assertSame('Vector queries can only be used on vector attributes', $filter->getDescription()); + } + + public function testStringQueriesAreCheckedAgainstTheIndexesLikeQueryObjects(): void + { + $search = '{"method":"search","attribute":"name","values":["phrase"]}'; + $attributes = [new Document([Document::ID => 'name', 'key' => 'name', 'type' => ColumnType::String->value, 'array' => false])]; + + $withoutFulltext = new Queries\Indexed($attributes, [], [new Filter($attributes, ColumnType::Integer->value)]); + $this->assertFalse($withoutFulltext->isValid([$search])); + $this->assertSame('Searching by attribute "name" requires a fulltext index.', $withoutFulltext->getDescription()); + $this->assertFalse($withoutFulltext->isValid([Query::parse($search)])); + $this->assertSame('Searching by attribute "name" requires a fulltext index.', $withoutFulltext->getDescription()); + + $withFulltext = new Queries\Indexed( + $attributes, + [new Document(['type' => IndexType::Fulltext->value, 'attributes' => ['name']])], + [new Filter($attributes, ColumnType::Integer->value)], + ); + $this->assertTrue($withFulltext->isValid([$search]), $withFulltext->getDescription()); + $this->assertFalse($withFulltext->isValid(['{"method":"search"'])); + $this->assertStringStartsWith('Invalid query: ', $withFulltext->getDescription()); + } + + public function testStringChildrenOfALogicalQueryAreParsedAndAnUnparseableOneIsRejected(): void + { + $validator = new Queries\Base([$this->filter()]); + $child = '{"method":"equal","attribute":"embedding","values":[[1,2,3]]}'; + + $this->assertFalse($validator->isValid([new Query(Method::Or, '', [$child, '{"method":"equal"'])])); + $this->assertStringStartsWith('Invalid query: ', $validator->getDescription()); + $this->assertStringNotContainsString('can only contain filter queries', $validator->getDescription()); + + $this->assertFalse($validator->isValid([new Query(Method::And, '', [$child, 5])])); + $this->assertSame('Invalid query: nested query must be a string', $validator->getDescription()); + + $this->assertFalse($validator->isValid([new Query(Method::Or, '', [$child, $child])])); + $this->assertStringContainsString('Or queries can only contain filter queries', $validator->getDescription()); + } + + /** + * @return array + */ + public static function validatorsGivenANonQuery(): array + { + $validators = [ + 'aggregate' => static fn (): Base => new Aggregate(), + 'distinct' => static fn (): Base => new Distinct(), + 'group by' => static fn (): Base => new GroupBy(), + 'having' => static fn (): Base => new Having(), + 'join' => static fn (): Base => new Join(), + ]; + + $cases = []; + foreach ($validators as $name => $validator) { + $cases["{$name} given a string"] = [$validator, 'limit(1)']; + $cases["{$name} given an array"] = [$validator, ['method' => 'limit', 'values' => [1]]]; + $cases["{$name} given null"] = [$validator, null]; + } + + return $cases; + } + + /** + * @param \Closure(): Base $validator + */ + #[DataProvider('validatorsGivenANonQuery')] + public function testAQueryMethodValidatorGivenANonQueryRefusesIt(\Closure $validator, mixed $value): void + { + $instance = $validator(); + + $this->assertFalse($instance->isValid($value)); + $this->assertSame('Value must be a Query', $instance->getDescription()); + } + + /** + * @return array + */ + public static function missingTableNames(): array + { + return [ + 'empty' => [''], + 'zero' => ['0'], + ]; + } + + #[DataProvider('missingTableNames')] + public function testAJoinWithoutATableNameIsRefused(string $table): void + { + $validator = new Join(); + + $this->assertFalse($validator->isValid(Query::join($table, 'author', [Query::on('authorId', 'id')]))); + $this->assertSame('Join requires a table name', $validator->getDescription()); + + $this->assertTrue($validator->isValid(Query::join('authors', 'author', [Query::on('authorId', 'id')])), $validator->getDescription()); + } + + public function testTheLimitValidatorRefusesAnotherMethodAndANonNumericLimit(): void + { + $validator = new Limit(); + + $this->assertFalse($validator->isValid(Query::offset(5))); + $this->assertSame('Invalid query method: offset', $validator->getDescription()); + + $this->assertFalse($validator->isValid(new Query(Method::Limit, '', ['abc']))); + $this->assertStringStartsWith('Invalid limit: ', $validator->getDescription()); + + $this->assertFalse($validator->isValid('limit(5)')); + $this->assertTrue($validator->isValid(Query::limit(5)), $validator->getDescription()); + } + + private function filter(bool $supportForAttributes = true): Filter + { + return new Filter( + [ + new Document([Document::ID => 'location', 'key' => 'location', 'type' => ColumnType::Point->value, 'array' => false]), + new Document([Document::ID => 'embedding', 'key' => 'embedding', 'type' => ColumnType::Vector->value, 'size' => 3, 'array' => false]), + new Document([ + Document::ID => 'author', + 'key' => 'author', + 'type' => ColumnType::Relationship->value, + 'array' => false, + 'options' => [ + 'relatedCollection' => 'authors', + 'relationType' => RelationshipType::ManyToOne->value, + 'twoWay' => false, + 'twoWayKey' => 'books', + 'side' => RelationshipSide::Parent->value, + ], + ]), + ], + ColumnType::Integer->value, + self::MAX_VALUES, + supportForAttributes: $supportForAttributes, + ); + } +} diff --git a/tests/unit/Validator/RolesTest.php b/tests/unit/Validator/RolesTest.php index a0ac63ed75..baa1bf250c 100644 --- a/tests/unit/Validator/RolesTest.php +++ b/tests/unit/Validator/RolesTest.php @@ -2,89 +2,138 @@ namespace Tests\Unit\Validator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Id; +use Utopia\Database\Role; use Utopia\Database\Validator\Roles; class RolesTest extends TestCase { - public function setUp(): void + #[\Override] + protected function setUp(): void { } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } /** * @throws \Exception */ - public function testValidRole(): void + public function test_valid_role(): void { $object = new Roles(); $this->assertTrue($object->isValid([Role::users()->toString()])); $this->assertTrue($object->isValid([Role::users(Roles::DIMENSION_VERIFIED)->toString()])); $this->assertTrue($object->isValid([Role::users(Roles::DIMENSION_UNVERIFIED)->toString()])); - $this->assertTrue($object->isValid([Role::team(ID::custom('696f34ea003d48edab8e'))->toString()])); - $this->assertTrue($object->isValid([Role::team(ID::custom('696f34ea003d48edab8e'), 'project-696f34ea003d48edab8e-owner')->toString()])); - $this->assertTrue($object->isValid([Role::team(ID::custom('696f34ea003d48edab8e'), 'project-696f34ea003d48edab8e')->toString()])); + $this->assertTrue($object->isValid([Role::team(Id::custom('696f34ea003d48edab8e'))->toString()])); + $this->assertTrue($object->isValid([Role::team(Id::custom('696f34ea003d48edab8e'), 'project-696f34ea003d48edab8e-owner')->toString()])); + $this->assertTrue($object->isValid([Role::team(Id::custom('696f34ea003d48edab8e'), 'project-696f34ea003d48edab8e')->toString()])); $this->assertTrue($object->isValid([Role::label('vip')->toString()])); } - public function testNotAnArray(): void + public function test_not_an_array(): void { $object = new Roles(); $this->assertFalse($object->isValid('not an array')); $this->assertEquals('Roles must be an array of strings.', $object->getDescription()); } - public function testExceedLength(): void + public function test_exceed_length(): void { $object = new Roles(2); $this->assertFalse($object->isValid([ Role::users()->toString(), Role::users()->toString(), - Role::users()->toString() + Role::users()->toString(), ])); $this->assertEquals('You can only provide up to 2 roles.', $object->getDescription()); } - public function testNotAllStrings(): void + public function test_not_all_strings(): void { $object = new Roles(); $this->assertFalse($object->isValid([ Role::users()->toString(), - 123 + 123, ])); $this->assertEquals('Every role must be of type string.', $object->getDescription()); } - public function testObsoleteWildcardRole(): void + public function test_obsolete_wildcard_role(): void { $object = new Roles(); $this->assertFalse($object->isValid(['*'])); $this->assertEquals('Wildcard role "*" has been replaced. Use "any" instead.', $object->getDescription()); } - public function testObsoleteRolePrefix(): void + public function test_obsolete_role_prefix(): void { $object = new Roles(); $this->assertFalse($object->isValid(['read("role:123")'])); $this->assertEquals('Roles using the "role:" prefix have been removed. Use "users", "guests", or "any" instead.', $object->getDescription()); } - public function testDisallowedRoles(): void + public function test_disallowed_roles(): void { $object = new Roles(allowed: [Roles::ROLE_USERS]); $this->assertFalse($object->isValid([Role::any()->toString()])); $this->assertEquals('Role "any" is not allowed. Must be one of: users.', $object->getDescription()); } - public function testLabels(): void + public function test_labels(): void { $object = new Roles(); $this->assertTrue($object->isValid(['label:123'])); $this->assertFalse($object->isValid(['label:not-alphanumeric'])); } + + #[DataProvider('unparseableRoles')] + public function test_unparseable_role(string $role, string $message): void + { + $object = new Roles(); + + $this->assertFalse($object->isValid([$role])); + $this->assertSame($message, $object->getDescription()); + } + + /** + * @return iterable + */ + public static function unparseableRoles(): iterable + { + yield 'an empty dimension' => ['users/', 'Dimension must not be empty']; + yield 'two dimensions' => ['users/verified/extra', 'Only one dimension can be provided']; + } + + #[DataProvider('misplacedIdsAndDimensions')] + public function test_misplaced_id_or_dimension(string $role, string $message): void + { + $object = new Roles(); + + $this->assertFalse($object->isValid([$role])); + $this->assertSame($message, $object->getDescription()); + } + + /** + * @return iterable + */ + public static function misplacedIdsAndDimensions(): iterable + { + yield 'an ID on any' => ['any:abc', 'Role "any" can not have an ID value.']; + yield 'a dimension on any' => ['any/verified', 'Role "any" can not have a dimension value.']; + yield 'a dimension on guests' => ['guests/verified', 'Role "guests" can not have a dimension value.']; + yield 'a dimension outside the options of users' => ['users/foo', 'Role "users" dimension value is invalid. Must be one of: verified, unverified.']; + } + + public function test_dimension_among_the_options(): void + { + $object = new Roles(); + + $this->assertTrue($object->isValid(['users/verified'])); + $this->assertTrue($object->isValid(['user:abc/unverified'])); + } } diff --git a/tests/unit/Validator/SpatialTest.php b/tests/unit/Validator/SpatialTest.php index e8df4d3d10..6478e3bc55 100644 --- a/tests/unit/Validator/SpatialTest.php +++ b/tests/unit/Validator/SpatialTest.php @@ -3,14 +3,14 @@ namespace Tests\Unit\Validator; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; use Utopia\Database\Validator\Spatial; +use Utopia\Query\Schema\ColumnType; class SpatialTest extends TestCase { - public function testValidPoint(): void + public function test_valid_point(): void { - $validator = new Spatial(Database::VAR_POINT); + $validator = new Spatial(ColumnType::Point->value); $this->assertTrue($validator->isValid([10, 20])); $this->assertTrue($validator->isValid([0, 0])); @@ -22,9 +22,9 @@ public function testValidPoint(): void $this->assertFalse($validator->isValid([[10, 20]])); // Nested array } - public function testValidLineString(): void + public function test_valid_line_string(): void { - $validator = new Spatial(Database::VAR_LINESTRING); + $validator = new Spatial(ColumnType::Linestring->value); $this->assertTrue($validator->isValid([[0, 0], [1, 1]])); @@ -36,9 +36,9 @@ public function testValidLineString(): void $this->assertFalse($validator->isValid([[10, 10], ['x', 'y']])); // Non-numeric } - public function testValidPolygon(): void + public function test_valid_polygon(): void { - $validator = new Spatial(Database::VAR_POLYGON); + $validator = new Spatial(ColumnType::Polygon->value); // Single ring polygon (closed) $this->assertTrue($validator->isValid([ @@ -46,66 +46,66 @@ public function testValidPolygon(): void [0, 1], [1, 1], [1, 0], - [0, 0] + [0, 0], ])); // Multi-ring polygon $this->assertTrue($validator->isValid([ [ // Outer ring - [0, 0], [0, 4], [4, 4], [4, 0], [0, 0] + [0, 0], [0, 4], [4, 4], [4, 0], [0, 0], ], [ // Hole - [1, 1], [1, 2], [2, 2], [2, 1], [1, 1] - ] + [1, 1], [1, 2], [2, 2], [2, 1], [1, 1], + ], ])); // Invalid polygons $this->assertFalse($validator->isValid([])); // Empty $this->assertFalse($validator->isValid([ - [0, 0], [1, 1], [2, 2] // Not closed, less than 4 points + [0, 0], [1, 1], [2, 2], // Not closed, less than 4 points ])); $this->assertFalse($validator->isValid([ - [[0, 0], [1, 1], [1, 0]] // Not closed + [[0, 0], [1, 1], [1, 0]], // Not closed ])); $this->assertFalse($validator->isValid([ - [[0, 0], [1, 1], [1, 'a'], [0, 0]] // Non-numeric + [[0, 0], [1, 1], [1, 'a'], [0, 0]], // Non-numeric ])); } - public function testWKTStrings(): void + public function test_wkt_strings(): void { - $this->assertTrue(Spatial::isWKTString('POINT(1 2)')); - $this->assertTrue(Spatial::isWKTString('LINESTRING(0 0,1 1)')); - $this->assertTrue(Spatial::isWKTString('POLYGON((0 0,1 0,1 1,0 1,0 0))')); + $this->assertTrue(Spatial::isWktString('POINT(1 2)')); + $this->assertTrue(Spatial::isWktString('LINESTRING(0 0,1 1)')); + $this->assertTrue(Spatial::isWktString('POLYGON((0 0,1 0,1 1,0 1,0 0))')); - $this->assertFalse(Spatial::isWKTString('CIRCLE(0 0,1)')); - $this->assertFalse(Spatial::isWKTString('POINT1(1 2)')); + $this->assertFalse(Spatial::isWktString('CIRCLE(0 0,1)')); + $this->assertFalse(Spatial::isWktString('POINT1(1 2)')); } - public function testInvalidCoordinate(): void + public function test_invalid_coordinate(): void { // Point with invalid longitude - $validator = new Spatial(Database::VAR_POINT); + $validator = new Spatial(ColumnType::Point->value); $this->assertFalse($validator->isValid([200, 10])); // longitude > 180 $this->assertStringContainsString('Longitude', $validator->getDescription()); // Point with invalid latitude - $validator = new Spatial(Database::VAR_POINT); + $validator = new Spatial(ColumnType::Point->value); $this->assertFalse($validator->isValid([10, -100])); // latitude < -90 $this->assertStringContainsString('Latitude', $validator->getDescription()); // LineString with invalid coordinates - $validator = new Spatial(Database::VAR_LINESTRING); + $validator = new Spatial(ColumnType::Linestring->value); $this->assertFalse($validator->isValid([ [0, 0], - [181, 45] // invalid longitude + [181, 45], // invalid longitude ])); $this->assertStringContainsString('Invalid coordinates', $validator->getDescription()); // Polygon with invalid coordinates - $validator = new Spatial(Database::VAR_POLYGON); + $validator = new Spatial(ColumnType::Polygon->value); $this->assertFalse($validator->isValid([ - [[0, 0], [1, 1], [190, 5], [0, 0]] // invalid longitude in ring + [[0, 0], [1, 1], [190, 5], [0, 0]], // invalid longitude in ring ])); $this->assertStringContainsString('Invalid coordinates', $validator->getDescription()); } diff --git a/tests/unit/Validator/SpatialTypeTest.php b/tests/unit/Validator/SpatialTypeTest.php new file mode 100644 index 0000000000..8ee47fb358 --- /dev/null +++ b/tests/unit/Validator/SpatialTypeTest.php @@ -0,0 +1,42 @@ + + */ + public static function nonSpatialTypes(): array + { + return [ + 'string' => [ColumnType::String->value], + 'vector' => [ColumnType::Vector->value], + 'empty' => [''], + 'unknown name' => ['circle'], + ]; + } + + #[DataProvider('nonSpatialTypes')] + public function testAnArrayForAValidatorOfANonSpatialTypeIsAnUnknownSpatialType(string $type): void + { + $validator = new Spatial($type); + + $this->assertFalse($validator->isValid([1.0, 2.0])); + $this->assertStringEndsWith('Unknown spatial type: '.$type, $validator->getDescription()); + $this->assertTrue($validator->isValid(null), 'null stays valid whatever the type'); + } + + public function testAnArrayForAPointValidatorIsCheckedAsAPoint(): void + { + $validator = new Spatial(ColumnType::Point->value); + + $this->assertTrue($validator->isValid([1.0, 2.0]), $validator->getDescription()); + $this->assertStringNotContainsString('Unknown spatial type', $validator->getDescription()); + } +} diff --git a/tests/unit/Validator/StructureFormatTest.php b/tests/unit/Validator/StructureFormatTest.php new file mode 100644 index 0000000000..89cb1a8384 --- /dev/null +++ b/tests/unit/Validator/StructureFormatTest.php @@ -0,0 +1,139 @@ + + */ + private array $received = []; + + #[\Override] + protected function setUp(): void + { + $this->received = []; + Structure::addFormat(self::FORMAT, function (array $attribute): Text { + $this->received[] = $attribute; + $options = $attribute['formatOptions'] ?? []; + $size = \is_array($options) ? ($options['maximum'] ?? 0) : 0; + + return new Text(\is_int($size) ? $size : 0); + }, ColumnType::String); + } + + #[\Override] + protected function tearDown(): void + { + Structure::removeFormat(self::FORMAT); + } + + public function testAFormatRegisteredForAnotherTypeIsRefused(): void + { + $this->assertSame(ColumnType::String->value, Structure::getFormat(self::FORMAT, ColumnType::String)['type']); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Format "'.self::FORMAT.'" not available for attribute type "integer"'); + + Structure::getFormat(self::FORMAT, ColumnType::Integer); + } + + /** + * @return array + */ + public static function codes(): array + { + return [ + 'within the format' => ['abc', true], + 'past the format' => ['abcdef', false], + ]; + } + + #[DataProvider('codes')] + public function testAFormatSeesAnAttributeDefinedAsAPlainArrayInItsStoredShape(string $code, bool $valid): void + { + $definition = $this->definition(); + $collection = $this->collection(); + $collection->setAttribute('attributes', [$definition]); + + $validator = new Structure($collection, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); + + $this->assertSame($valid, $validator->isValid($this->document($code)), $validator->getDescription()); + $this->assertSame([$this->storedShape($definition)], $this->received, 'the format callback receives the attribute in its stored shape'); + } + + #[DataProvider('codes')] + public function testAFormatSeesAnAttributeDefinedAsADocumentInItsStoredShape(string $code, bool $valid): void + { + $definition = $this->definition(); + $collection = $this->collection(); + $collection->setAttribute('attributes', [new Document($definition)]); + + $validator = new Structure($collection, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); + + $this->assertSame($valid, $validator->isValid($this->document($code)), $validator->getDescription()); + $this->assertSame([$this->storedShape($definition)], $this->received); + } + + /** + * @return array + */ + private function definition(): array + { + return [ + Document::ID => 'code', + 'type' => ColumnType::String->value, + 'format' => self::FORMAT, + 'formatOptions' => ['maximum' => 4], + 'size' => 32, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]; + } + + /** + * @param array $definition + * @return array + */ + private function storedShape(array $definition): array + { + return Attribute::fromArray($definition)->toDocument()->getArrayCopy(); + } + + private function collection(): Document + { + return new Document([ + Document::ID => 'codes', + Document::COLLECTION => Database::METADATA, + 'name' => 'codes', + 'attributes' => [], + 'indexes' => [], + ]); + } + + private function document(string $code): Document + { + return new Document([ + Document::COLLECTION => 'codes', + 'code' => $code, + Document::CREATED_AT => '2026-09-30T00:00:00.000+00:00', + Document::UPDATED_AT => '2026-09-30T00:00:00.000+00:00', + ]); + } +} diff --git a/tests/unit/Validator/StructureTest.php b/tests/unit/Validator/StructureTest.php index e82a1cae56..ec065e0399 100644 --- a/tests/unit/Validator/StructureTest.php +++ b/tests/unit/Validator/StructureTest.php @@ -4,12 +4,16 @@ use PHPUnit\Framework\TestCase; use Tests\Unit\Format; +use Tests\Unit\Support\Profiles; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; -use Utopia\Database\Helpers\ID; +use Utopia\Database\Id; use Utopia\Database\Operator; use Utopia\Database\Validator\Structure; +use Utopia\Query\Schema\ColumnType; class StructureTest extends TestCase { @@ -23,7 +27,7 @@ class StructureTest extends TestCase 'attributes' => [ [ '$id' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'format' => '', 'size' => 256, 'required' => true, @@ -33,7 +37,7 @@ class StructureTest extends TestCase ], [ '$id' => 'description', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'format' => '', 'size' => 1000000, 'required' => false, @@ -43,7 +47,7 @@ class StructureTest extends TestCase ], [ '$id' => 'rating', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'format' => '', 'size' => 5, 'required' => true, @@ -53,7 +57,7 @@ class StructureTest extends TestCase ], [ '$id' => 'reviews', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'format' => '', 'size' => 5, 'required' => false, @@ -63,7 +67,7 @@ class StructureTest extends TestCase ], [ '$id' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'format' => '', 'size' => 5, 'required' => true, @@ -73,7 +77,7 @@ class StructureTest extends TestCase ], [ '$id' => 'published', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'format' => '', 'size' => 5, 'required' => true, @@ -83,7 +87,7 @@ class StructureTest extends TestCase ], [ '$id' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'format' => '', 'size' => 55, 'required' => false, @@ -93,7 +97,7 @@ class StructureTest extends TestCase ], [ '$id' => 'id', - 'type' => Database::VAR_ID, + 'type' => ColumnType::Id->value, 'format' => '', 'size' => 0, 'required' => false, @@ -103,7 +107,7 @@ class StructureTest extends TestCase ], [ '$id' => 'varchar_field', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'format' => '', 'size' => 255, 'required' => false, @@ -113,7 +117,7 @@ class StructureTest extends TestCase ], [ '$id' => 'text_field', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'format' => '', 'size' => 65535, 'required' => false, @@ -123,7 +127,7 @@ class StructureTest extends TestCase ], [ '$id' => 'mediumtext_field', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'format' => '', 'size' => 16777215, 'required' => false, @@ -133,7 +137,7 @@ class StructureTest extends TestCase ], [ '$id' => 'longtext_field', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'format' => '', 'size' => 4294967295, 'required' => false, @@ -145,18 +149,24 @@ class StructureTest extends TestCase 'indexes' => [], ]; - public function setUp(): void + #[\Override] + protected function setUp(): void { - Structure::addFormat('email', function ($attribute) { - $size = $attribute['size'] ?? 0; + Structure::addFormat('email', function (mixed $attribute) { + /** @var array $attribute */ + $sizeRaw = $attribute['size'] ?? 0; + $size = is_numeric($sizeRaw) ? (int) $sizeRaw : 0; + return new Format($size); - }, Database::VAR_STRING); + }, ColumnType::String); // Cannot encode format when defining constants // So add feedback attribute on startup - $this->collection['attributes'][] = [ - '$id' => ID::custom('feedback'), - 'type' => Database::VAR_STRING, + /** @var array> $attrs */ + $attrs = $this->collection['attributes']; + $attrs[] = [ + '$id' => Id::custom('feedback'), + 'type' => ColumnType::String->value, 'format' => 'email', 'size' => 55, 'required' => true, @@ -164,17 +174,19 @@ public function setUp(): void 'array' => false, 'filters' => [], ]; + $this->collection['attributes'] = $attrs; } - public function tearDown(): void + #[\Override] + protected function tearDown(): void { } - public function testDocumentInstance(): void + public function test_document_instance(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid('string')); @@ -185,11 +197,11 @@ public function testDocumentInstance(): void $this->assertEquals('Invalid document structure: Value must be an instance of Document', $validator->getDescription()); } - public function testCollectionAttribute(): void + public function test_collection_attribute(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document())); @@ -197,15 +209,15 @@ public function testCollectionAttribute(): void $this->assertEquals('Invalid document structure: Missing collection attribute $collection', $validator->getDescription()); } - public function testCollection(): void + public function test_collection(): void { $validator = new Structure( new Document(), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -214,21 +226,21 @@ public function testCollection(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Collection not found', $validator->getDescription()); } - public function testRequiredKeys(): void + public function test_required_keys(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'description' => 'Demo description', 'rating' => 5, 'price' => 1.99, @@ -236,53 +248,51 @@ public function testRequiredKeys(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Missing required attribute "title"', $validator->getDescription()); } - public function testRequiredKeyStoredAsNull(): void + public function test_required_key_stored_as_null(): void { + $profile = Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]); $document = [ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'description' => 'Demo description', 'rating' => 5, 'price' => 1.99, 'published' => true, 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]; - $validator = new Structure( - new Document($this->collection), - Database::VAR_INTEGER, - currentDocument: new Document(['title' => null]) - ); + $validator = new Structure(new Document($this->collection), $profile, currentDocument: new Document(['title' => null])); $this->assertTrue($validator->isValid(new Document($document)), $validator->getDescription()); $this->assertTrue($validator->isValid(new Document([...$document, 'title' => null])), $validator->getDescription()); - $validator = new Structure( - new Document($this->collection), - Database::VAR_INTEGER, - currentDocument: new Document(['title' => 'My Title']) - ); + $validator = new Structure(new Document($this->collection), $profile, currentDocument: new Document(['title' => 'My Title'])); $this->assertFalse($validator->isValid(new Document([...$document, 'title' => null]))); - $this->assertEquals('Invalid document structure: Missing required attribute "title"', $validator->getDescription()); + $this->assertSame('Invalid document structure: Missing required attribute "title"', $validator->getDescription()); + + $validator = new Structure(new Document($this->collection), $profile); + + $this->assertFalse($validator->isValid(new Document($document))); + $this->assertSame('Invalid document structure: Missing required attribute "title"', $validator->getDescription()); } - public function testNullValues(): void + public function test_null_values(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => null, 'rating' => 5, @@ -296,7 +306,7 @@ public function testNullValues(): void ]))); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => null, 'rating' => 5, @@ -305,19 +315,19 @@ public function testNullValues(): void 'tags' => ['dog', null, 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); } - public function testUnknownKeys(): void + public function test_unknown_keys(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'titlex' => 'Unknown Attribute', 'description' => 'Demo description', @@ -327,21 +337,21 @@ public function testUnknownKeys(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Unknown attribute: "titlex"', $validator->getDescription()); } - public function testIntegerAsString(): void + public function test_integer_as_string(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => '5', @@ -350,21 +360,21 @@ public function testIntegerAsString(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } - public function testValidDocument(): void + public function test_valid_document(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -373,19 +383,19 @@ public function testValidDocument(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); } - public function testStringValidation(): void + public function test_string_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 5, 'description' => 'Demo description', 'rating' => 5, @@ -394,131 +404,21 @@ public function testStringValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "title" has invalid type. Value must be a valid string and no longer than 256 chars', $validator->getDescription()); } - public function testTextByteSafeValidationTooBig(): void - { - // A legacy `text` attribute whose declared size (1MB) exceeds the real - // 65,535-byte capacity of a TEXT column. Such attributes exist in older - // databases created before VAR_TEXT was capped, so the limit must come - // from the column type, not from the (untrustworthy) declared size. - $collection = new Document([ - '$id' => ID::custom('posts'), - '$collection' => Database::METADATA, - 'name' => 'posts', - 'attributes' => [ - [ - '$id' => 'text', - 'type' => Database::VAR_TEXT, - 'format' => '', - 'size' => 1048576, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - ], - 'indexes' => [], - ]); - - $validator = new Structure($collection, Database::VAR_INTEGER); - - $base = [ - '$collection' => ID::custom('posts'), - '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00', - ]; - - // A TEXT column is limited to 65,535 bytes. Validation measures the - // value's actual byte length, so a value over that capacity is rejected - // even though the declared $size (1MB) would allow the character count. - $tooBig = \str_repeat('a', 65536); - $this->assertEquals(false, $validator->isValid(new Document($base + ['text' => $tooBig]))); - $this->assertEquals('Invalid document structure: Attribute "text" has invalid type. Value must be a valid string no longer than 65535 bytes', $validator->getDescription()); - } - - public function testTextByteSafeValidationMultibyte(): void - { - $collection = new Document([ - '$id' => ID::custom('posts'), - '$collection' => Database::METADATA, - 'name' => 'posts', - 'attributes' => [ - [ - '$id' => 'text', - 'type' => Database::VAR_TEXT, - 'format' => '', - 'size' => 1048576, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - ], - 'indexes' => [], - ]); - - $validator = new Structure($collection, Database::VAR_INTEGER); - - $base = [ - '$collection' => ID::custom('posts'), - '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00', - ]; - - // Multi-byte content over the byte capacity is rejected the same way - // (20,000 emoji = 80,000 bytes in utf8mb4). - $multibyte = \str_repeat('📝', 20000); - $this->assertEquals(false, $validator->isValid(new Document($base + ['text' => $multibyte]))); - } - - public function testTextByteSafeValidationValid(): void - { - $collection = new Document([ - '$id' => ID::custom('posts'), - '$collection' => Database::METADATA, - 'name' => 'posts', - 'attributes' => [ - [ - '$id' => 'text', - 'type' => Database::VAR_TEXT, - 'format' => '', - 'size' => 1048576, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - ], - 'indexes' => [], - ]); - - $validator = new Structure($collection, Database::VAR_INTEGER); - - $base = [ - '$collection' => ID::custom('posts'), - '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00', - ]; - - // A value that fills the column's full byte capacity is accepted. - $ok = \str_repeat('a', 65535); - $this->assertEquals(true, $validator->isValid(new Document($base + ['text' => $ok]))); - } - - public function testArrayOfStringsValidation(): void + public function test_array_of_strings_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -527,13 +427,13 @@ public function testArrayOfStringsValidation(): void 'tags' => [1, 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "tags[\'0\']" has invalid type. Value must be a valid string and no longer than 55 chars', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -542,13 +442,13 @@ public function testArrayOfStringsValidation(): void 'tags' => [true], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "tags[\'0\']" has invalid type. Value must be a valid string and no longer than 55 chars', $validator->getDescription()); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -557,11 +457,11 @@ public function testArrayOfStringsValidation(): void 'tags' => [], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -570,7 +470,7 @@ public function testArrayOfStringsValidation(): void 'tags' => ['too-long-tag-name-to-make-sure-the-length-validator-inside-string-attribute-type-fails-properly'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "tags[\'0\']" has invalid type. Value must be a valid string and no longer than 55 chars', $validator->getDescription()); @@ -579,15 +479,15 @@ public function testArrayOfStringsValidation(): void /** * @throws Exception */ - public function testArrayAsObjectValidation(): void + public function test_array_as_object_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -596,19 +496,19 @@ public function testArrayAsObjectValidation(): void 'tags' => ['name' => 'dog'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); } - public function testArrayOfObjectsValidation(): void + public function test_array_of_objects_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -617,19 +517,19 @@ public function testArrayOfObjectsValidation(): void 'tags' => [['name' => 'dog']], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); } - public function testIntegerValidation(): void + public function test_integer_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => true, @@ -638,13 +538,13 @@ public function testIntegerValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => '', @@ -653,21 +553,21 @@ public function testIntegerValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } - public function testArrayOfIntegersValidation(): void + public function test_array_of_integers_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -677,11 +577,11 @@ public function testArrayOfIntegersValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -691,11 +591,11 @@ public function testArrayOfIntegersValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -705,11 +605,11 @@ public function testArrayOfIntegersValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -719,21 +619,21 @@ public function testArrayOfIntegersValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "reviews[\'0\']" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } - public function testFloatValidation(): void + public function test_float_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -742,13 +642,13 @@ public function testFloatValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "price" has invalid type. Value must be a valid float', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -757,21 +657,21 @@ public function testFloatValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "price" has invalid type. Value must be a valid float', $validator->getDescription()); } - public function testBooleanValidation(): void + public function test_boolean_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -780,13 +680,13 @@ public function testBooleanValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "published" has invalid type. Value must be a valid boolean', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -795,21 +695,21 @@ public function testBooleanValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "published" has invalid type. Value must be a valid boolean', $validator->getDescription()); } - public function testFormatValidation(): void + public function test_format_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -818,21 +718,21 @@ public function testFormatValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team_appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "feedback" has invalid format. Value must be a valid email address', $validator->getDescription()); } - public function testIntegerMaxRange(): void + public function test_integer_max_range(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => PHP_INT_MAX, @@ -841,21 +741,21 @@ public function testIntegerMaxRange(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } - public function testDoubleUnsigned(): void + public function test_double_unsigned(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 5, @@ -864,21 +764,21 @@ public function testDoubleUnsigned(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertStringContainsString('Invalid document structure: Attribute "price" has invalid type. Value must be a valid range between 0 and ', $validator->getDescription()); } - public function testDoubleMaxRange(): void + public function test_double_max_range(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'string', 'description' => 'Demo description', 'rating' => 1, @@ -887,22 +787,22 @@ public function testDoubleMaxRange(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); } - public function testId(): void + public function test_id(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $sqlId = '1000'; $mongoId = '0198fffb-d664-710a-9765-f922b3e81e3d'; $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => null, 'rating' => 5, @@ -916,7 +816,7 @@ public function testId(): void ]))); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => null, 'rating' => 5, @@ -931,11 +831,11 @@ public function testId(): void $validator = new Structure( new Document($this->collection), - Database::VAR_UUID7 + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt], idType: ColumnType::Uuid7), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => null, 'rating' => 5, @@ -949,7 +849,7 @@ public function testId(): void ]))); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => null, 'rating' => 5, @@ -963,16 +863,16 @@ public function testId(): void ]))); } - public function testOperatorsSkippedDuringValidation(): void + public function test_operators_skipped_during_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); // Operators should be skipped during structure validation $this->assertTrue($validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => 'Demo description', 'rating' => Operator::increment(1), // Operator on required field @@ -981,20 +881,20 @@ public function testOperatorsSkippedDuringValidation(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ])), $validator->getDescription()); } - public function testMultipleOperatorsSkippedDuringValidation(): void + public function test_multiple_operators_skipped_during_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); // Multiple operators should all be skipped $this->assertTrue($validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => Operator::stringConcat(' - Updated'), 'description' => 'Demo description', 'rating' => Operator::increment(1), @@ -1003,20 +903,20 @@ public function testMultipleOperatorsSkippedDuringValidation(): void 'tags' => Operator::arrayAppend(['new']), 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ])), $validator->getDescription()); } - public function testMissingRequiredFieldWithoutOperator(): void + public function test_missing_required_field_without_operator(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); // Missing required field (not replaced by operator) should still fail $this->assertFalse($validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'My Title', 'description' => 'Demo description', // 'rating' is missing entirely - should fail @@ -1025,21 +925,21 @@ public function testMissingRequiredFieldWithoutOperator(): void 'tags' => ['dog', 'cat', 'mouse'], 'feedback' => 'team@appwrite.io', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Missing required attribute "rating"', $validator->getDescription()); } - public function testVarcharValidation(): void + public function test_varchar_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1049,11 +949,11 @@ public function testVarcharValidation(): void 'feedback' => 'team@appwrite.io', 'varchar_field' => 'Short varchar text', '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1063,13 +963,13 @@ public function testVarcharValidation(): void 'feedback' => 'team@appwrite.io', 'varchar_field' => 123, '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "varchar_field" has invalid type. Value must be a valid string and no longer than 255 chars', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1079,21 +979,21 @@ public function testVarcharValidation(): void 'feedback' => 'team@appwrite.io', 'varchar_field' => \str_repeat('a', 256), '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "varchar_field" has invalid type. Value must be a valid string and no longer than 255 chars', $validator->getDescription()); } - public function testTextValidation(): void + public function test_text_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1112,7 +1012,7 @@ public function testTextValidation(): void // ByteLength check (which would otherwise reject it, since // is_string(null) is false). Hence a null optional text value passes. $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1122,11 +1022,11 @@ public function testTextValidation(): void 'feedback' => 'team@appwrite.io', 'text_field' => null, '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1136,13 +1036,13 @@ public function testTextValidation(): void 'feedback' => 'team@appwrite.io', 'text_field' => 123, '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "text_field" has invalid type. Value must be a valid string no longer than 65535 bytes', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1152,21 +1052,21 @@ public function testTextValidation(): void 'feedback' => 'team@appwrite.io', 'text_field' => \str_repeat('a', 65536), '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "text_field" has invalid type. Value must be a valid string no longer than 65535 bytes', $validator->getDescription()); } - public function testMediumtextValidation(): void + public function test_mediumtext_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1176,11 +1076,11 @@ public function testMediumtextValidation(): void 'feedback' => 'team@appwrite.io', 'mediumtext_field' => \str_repeat('a', 100000), '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1190,7 +1090,7 @@ public function testMediumtextValidation(): void 'feedback' => 'team@appwrite.io', 'mediumtext_field' => 123, '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "mediumtext_field" has invalid type. Value must be a valid string no longer than 16777215 bytes', $validator->getDescription()); @@ -1202,13 +1102,13 @@ public function testMediumtextSizedValidation(): void // is enforced as a byte limit (ByteLength), independent of the column's // physical 16MB ceiling. $collection = new Document([ - '$id' => ID::custom('posts'), + '$id' => Id::custom('posts'), '$collection' => Database::METADATA, 'name' => 'posts', 'attributes' => [ [ '$id' => 'mediumtext', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'format' => '', 'size' => 100, 'required' => false, @@ -1220,10 +1120,10 @@ public function testMediumtextSizedValidation(): void 'indexes' => [], ]); - $validator = new Structure($collection, Database::VAR_INTEGER); + $validator = new Structure($collection, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); $base = [ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), '$createdAt' => '2000-04-01T12:00:00.000+00:00', '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]; @@ -1247,15 +1147,15 @@ public function testMediumtextSizedValidation(): void $this->assertEquals('Invalid document structure: Attribute "mediumtext" has invalid type. Value must be a valid string no longer than 100 bytes', $validator->getDescription()); } - public function testLongtextValidation(): void + public function test_longtext_validation(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1265,11 +1165,11 @@ public function testLongtextValidation(): void 'feedback' => 'team@appwrite.io', 'longtext_field' => \str_repeat('a', 1000000), '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'title' => 'Demo Title', 'description' => 'Demo description', 'rating' => 5, @@ -1279,13 +1179,13 @@ public function testLongtextValidation(): void 'feedback' => 'team@appwrite.io', 'longtext_field' => 123, '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "longtext_field" has invalid type. Value must be a valid string no longer than 4294967295 bytes', $validator->getDescription()); } - public function testStringTypeArrayValidation(): void + public function test_string_type_array_validation(): void { $collection = [ '$id' => Database::METADATA, @@ -1294,7 +1194,7 @@ public function testStringTypeArrayValidation(): void 'attributes' => [ [ '$id' => 'varchar_array', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'format' => '', 'size' => 128, 'required' => false, @@ -1304,7 +1204,7 @@ public function testStringTypeArrayValidation(): void ], [ '$id' => 'text_array', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'format' => '', 'size' => 65535, 'required' => false, @@ -1318,83 +1218,89 @@ public function testStringTypeArrayValidation(): void $validator = new Structure( new Document($collection), - Database::VAR_INTEGER + Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt]), ); $this->assertEquals(true, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'varchar_array' => ['test1', 'test2', 'test3'], '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'varchar_array' => [123, 'test2', 'test3'], '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "varchar_array[\'0\']" has invalid type. Value must be a valid string and no longer than 128 chars', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ - '$collection' => ID::custom('posts'), + '$collection' => Id::custom('posts'), 'varchar_array' => [\str_repeat('a', 129), 'test2'], '$createdAt' => '2000-04-01T12:00:00.000+00:00', - '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]))); $this->assertEquals('Invalid document structure: Attribute "varchar_array[\'0\']" has invalid type. Value must be a valid string and no longer than 128 chars', $validator->getDescription()); } - public function testBigIntSignedAcceptsNumericStringAndNormalizesToInt(): void + public function testLegacyBigIntegerMetadataUsesBigIntegerValidation(): void { - $collection = [ - '$id' => Database::METADATA, + $collection = new Document([ + '$id' => Id::custom('posts'), '$collection' => Database::METADATA, - 'name' => 'collections', + 'name' => 'posts', 'attributes' => [ [ - '$id' => 'bigint_signed', - 'type' => Database::VAR_BIGINT, + '$id' => 'signed', + 'type' => 'bigint', 'format' => '', - 'size' => 0, - 'required' => true, + 'size' => 9999, + 'required' => false, 'signed' => true, 'array' => false, 'filters' => [], ], + [ + '$id' => 'unsigned', + 'type' => ColumnType::BigInteger->value, + 'format' => '', + 'size' => 0, + 'required' => false, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], ], 'indexes' => [], - ]; - - $validator = new Structure( - new Document($collection), - Database::VAR_INTEGER - ); - - $doc = new Document([ - '$collection' => ID::custom('posts'), - 'bigint_signed' => (string)PHP_INT_MAX, + ]); + $validator = new Structure($collection, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); + $document = new Document([ + '$collection' => Id::custom('posts'), + 'signed' => '-9223372036854775808', + 'unsigned' => '18446744073709551615', '$createdAt' => '2000-04-01T12:00:00.000+00:00', '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]); - $this->assertTrue($validator->isValid($doc)); - $this->assertIsInt($doc->getAttribute('bigint_signed')); - $this->assertEquals(PHP_INT_MAX, $doc->getAttribute('bigint_signed')); + $this->assertTrue($validator->isValid($document)); + $this->assertSame(PHP_INT_MIN, $document->getAttribute('signed')); + $this->assertSame('18446744073709551615', $document->getAttribute('unsigned')); } - public function testBigIntUnsignedAcceptsLargeNumericStringAsString(): void + public function testBigIntUnsignedRejectsNegativeNumericString(): void { - $collection = [ - '$id' => Database::METADATA, + $collection = new Document([ + '$id' => Id::custom('posts'), '$collection' => Database::METADATA, - 'name' => 'collections', + 'name' => 'posts', 'attributes' => [ [ '$id' => 'bigint_unsigned', - 'type' => Database::VAR_BIGINT, + 'type' => ColumnType::BigInteger->value, 'format' => '', 'size' => 0, 'required' => true, @@ -1404,65 +1310,63 @@ public function testBigIntUnsignedAcceptsLargeNumericStringAsString(): void ], ], 'indexes' => [], - ]; - - $validator = new Structure( - new Document($collection), - Database::VAR_INTEGER - ); + ]); - $unsignedMax = '18446744073709551615'; + $validator = new Structure($collection, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); - $doc = new Document([ - '$collection' => ID::custom('posts'), - 'bigint_unsigned' => $unsignedMax, + $document = new Document([ + '$collection' => Id::custom('posts'), + 'bigint_unsigned' => '-1', '$createdAt' => '2000-04-01T12:00:00.000+00:00', '$updatedAt' => '2000-04-01T12:00:00.000+00:00', ]); - $this->assertTrue($validator->isValid($doc)); - $this->assertIsString($doc->getAttribute('bigint_unsigned')); - $this->assertEquals($unsignedMax, $doc->getAttribute('bigint_unsigned')); + $this->assertFalse($validator->isValid($document)); + $this->assertSame( + 'Invalid document structure: Attribute "bigint_unsigned" has invalid type. Value must be a valid unsigned 64-bit integer between 0 and 18,446,744,073,709,551,615', + $validator->getDescription() + ); } - public function testBigIntUnsignedRejectsNegativeNumericString(): void + /** + * A legacy text attribute can declare a size far past the 65,535 bytes a TEXT column holds, + * so the limit has to come from the column type and be measured in bytes. + */ + public function testTextValidationUsesColumnCapacityNotDeclaredSize(): void { - $collection = [ - '$id' => Database::METADATA, + $collection = new Document([ + '$id' => Id::custom('posts'), '$collection' => Database::METADATA, - 'name' => 'collections', + 'name' => 'posts', 'attributes' => [ [ - '$id' => 'bigint_unsigned', - 'type' => Database::VAR_BIGINT, + '$id' => 'text', + 'type' => ColumnType::Text->value, 'format' => '', - 'size' => 0, - 'required' => true, - 'signed' => false, + 'size' => 1048576, + 'required' => false, + 'signed' => true, 'array' => false, 'filters' => [], ], ], 'indexes' => [], - ]; + ]); - $validator = new Structure( - new Document($collection), - Database::VAR_INTEGER - ); + $validator = new Structure($collection, Profiles::of(capabilities: [Capability::DefinedAttributes, Capability::UnsignedBigInt])); - $doc = new Document([ - '$collection' => ID::custom('posts'), - 'bigint_unsigned' => '-1', + $base = [ + '$collection' => Id::custom('posts'), '$createdAt' => '2000-04-01T12:00:00.000+00:00', '$updatedAt' => '2000-04-01T12:00:00.000+00:00', - ]); + ]; - $this->assertFalse($validator->isValid($doc)); - $this->assertEquals( - 'Invalid document structure: Attribute "bigint_unsigned" has invalid type. Value must be a valid unsigned 64-bit integer between 0 and 18,446,744,073,709,551,615', - $validator->getDescription() - ); - } + $this->assertTrue($validator->isValid(new Document($base + ['text' => \str_repeat('a', 65535)])), 'A value filling the full column capacity is accepted'); + + $this->assertFalse($validator->isValid(new Document($base + ['text' => \str_repeat('a', 65536)]))); + $this->assertSame('Invalid document structure: Attribute "text" has invalid type. Value must be a valid string no longer than 65535 bytes', $validator->getDescription()); + $this->assertFalse($validator->isValid(new Document($base + ['text' => \str_repeat('📝', 20000)])), '20,000 emoji are 80,000 bytes and must be rejected by byte length'); + $this->assertSame('Invalid document structure: Attribute "text" has invalid type. Value must be a valid string no longer than 65535 bytes', $validator->getDescription()); + } } diff --git a/tests/unit/Validator/VectorTest.php b/tests/unit/Validator/VectorTest.php index be98d7ecf2..c57ff9953e 100644 --- a/tests/unit/Validator/VectorTest.php +++ b/tests/unit/Validator/VectorTest.php @@ -7,7 +7,7 @@ class VectorTest extends TestCase { - public function testVector(): void + public function test_vector(): void { // Test valid vectors $validator = new Vector(3); @@ -28,7 +28,7 @@ public function testVector(): void $this->assertFalse($validator->isValid([1.0, true, 3.0])); // Boolean value } - public function testVectorWithDifferentDimensions(): void + public function test_vector_with_different_dimensions(): void { $validator1 = new Vector(1); $this->assertTrue($validator1->isValid([5.0])); @@ -46,7 +46,7 @@ public function testVectorWithDifferentDimensions(): void $this->assertFalse($validator128->isValid($vector127)); } - public function testVectorDescription(): void + public function test_vector_description(): void { $validator = new Vector(3); $this->assertEquals('Value must be an array of 3 numeric values', $validator->getDescription()); @@ -55,7 +55,7 @@ public function testVectorDescription(): void $this->assertEquals('Value must be an array of 256 numeric values', $validator256->getDescription()); } - public function testVectorType(): void + public function test_vector_type(): void { $validator = new Vector(3); $this->assertEquals('array', $validator->getType()); diff --git a/tests/unit/Vector/VectorValidationTest.php b/tests/unit/Vector/VectorValidationTest.php new file mode 100644 index 0000000000..91b541ea9c --- /dev/null +++ b/tests/unit/Vector/VectorValidationTest.php @@ -0,0 +1,474 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('hasSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('isTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('limits')->willReturn(new Limits( + string: 16777215, + varchar: 16383, + integer: 2147483647, + bigInteger: 0, + attributes: 0, + indexes: 64, + defaultAttributes: 0, + defaultIndexes: 0, + indexLength: 768, + uidLength: 36, + documentSize: 0, + minDateTime: new DateTime('0000-01-01'), + maxDateTime: new DateTime('9999-12-31'), + idType: ColumnType::String, + keywords: [], + internalIndexKeys: [], + )); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::IndexKey, + Capability::IndexArray, + Capability::IndexUnique, + Capability::DefinedAttributes, + Capability::Vectors, + ]); + }); + $this->adapter->method('startTransaction')->willReturn(true); + $this->adapter->method('commitTransaction')->willReturn(true); + $this->adapter->method('rollbackTransaction')->willReturn(true); + $this->adapter->method('withTransaction')->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $this->adapter->method('createAttribute')->willReturn(true); + $this->adapter->method('createIndex')->willReturn(true); + $this->adapter->method('deleteIndex')->willReturn(true); + $this->adapter->method('createDocument')->willReturnArgument(1); + $this->adapter->method('updateDocument')->willReturnArgument(2); + $this->adapter->method('find')->willReturn([]); + $this->adapter->method('getSequences')->willReturnArgument(1); + + $cache = new Cache(new None()); + $this->database = new Database($this->adapter, $cache); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + } + + private function metaCollection(): Document + { + return new Document([ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'name' => 'collections', + 'attributes' => [ + new Document(['$id' => 'name', 'key' => 'name', 'type' => 'string', 'size' => 256, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + new Document(['$id' => 'attributes', 'key' => 'attributes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'indexes', 'key' => 'indexes', 'type' => 'string', 'size' => 1000000, 'required' => false, 'signed' => true, 'array' => false, 'filters' => ['json']]), + new Document(['$id' => 'documentSecurity', 'key' => 'documentSecurity', 'type' => 'boolean', 'size' => 0, 'required' => true, 'signed' => true, 'array' => false, 'filters' => []]), + ], + 'indexes' => [], + 'documentSecurity' => true, + ]); + } + + /** + * @param array $attributes + * @param array $indexes + */ + private function makeCollection(string $id, array $attributes = [], array $indexes = []): Document + { + return new Document([ + '$id' => $id, + '$sequence' => $id, + '$collection' => Database::METADATA, + '$createdAt' => '2024-01-01T00:00:00.000+00:00', + '$updatedAt' => '2024-01-01T00:00:00.000+00:00', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => $id, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true, + ]); + } + + /** + * @param array $collections + */ + private function setupCollections(array $collections): void + { + $meta = $this->metaCollection(); + $map = []; + foreach ($collections as $col) { + $map[$col->getId()] = $col; + } + + $this->adapter->method('getDocument')->willReturnCallback( + function (Document $col, string $docId) use ($meta, $map) { + if ($col->getId() === Database::METADATA && $docId === Database::METADATA) { + return $meta; + } + if ($col->getId() === Database::METADATA && isset($map[$docId])) { + return $map[$docId]; + } + + return new Document(); + } + ); + } + + /** + * @param array $extraAttrs + */ + private function vectorCollection(string $id, int $dimensions = 3, bool $required = true, array $extraAttrs = []): Document + { + $attrs = [ + new Document([ + '$id' => 'embedding', 'key' => 'embedding', + 'type' => ColumnType::Vector->value, + 'size' => $dimensions, 'required' => $required, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]), + ...$extraAttrs, + ]; + + return $this->makeCollection($id, $attrs); + } + + public function testVectorInvalidDimensions(): void + { + $col = $this->makeCollection('vectorError'); + $this->setupCollections([$col]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Vector dimensions must be a positive integer'); + + $this->database->createAttribute('vectorError', Attribute::fromArray(['key' => 'bad_embedding', 'type' => ColumnType::Vector, 'required' => true])); + } + + public function testVectorTooManyDimensions(): void + { + $col = $this->makeCollection('vectorLimit'); + $this->setupCollections([$col]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Vector dimensions cannot exceed 16000'); + + $this->database->createAttribute('vectorLimit', Attribute::vector(key: 'huge_embedding', dimensions: 16001, required: true)); + } + + public function testVectorQueryValidation(): void + { + $textAttr = new Document([ + '$id' => 'name', 'key' => 'name', + 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->vectorCollection('vectorValidation', 3, true, [$textAttr]); + $this->setupCollections([$col]); + + $this->expectException(DatabaseException::class); + + $this->database->find('vectorValidation', [ + Query::vectorDot('name', [1.0, 0.0, 0.0]), + ]); + } + + public function testVectorDimensionMismatch(): void + { + $col = $this->vectorCollection('vectorDimMismatch'); + $this->setupCollections([$col]); + + $this->expectException(DatabaseException::class); + + $this->database->createDocument('vectorDimMismatch', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [1.0, 0.0], + ])); + } + + public function testVectorWithInvalidDataTypes(): void + { + $col = $this->vectorCollection('vectorInvalidTypes'); + $this->setupCollections([$col]); + + try { + $this->database->createDocument('vectorInvalidTypes', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => ['one', 'two', 'three'], + ])); + $this->fail('Should have thrown exception for non-numeric vector values'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric values', strtolower($e->getMessage())); + } + + try { + $this->database->createDocument('vectorInvalidTypes', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [1.0, 'two', 3.0], + ])); + $this->fail('Should have thrown exception for mixed type vector values'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric values', strtolower($e->getMessage())); + } + } + + public function testVectorQueryValidationExtended(): void + { + $textAttr = new Document([ + '$id' => 'text', 'key' => 'text', + 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->vectorCollection('vectorValidation2', 3, true, [$textAttr]); + $this->setupCollections([$col]); + + try { + $this->database->find('vectorValidation2', [ + Query::vectorCosine('embedding', [1.0, 0.0]), + ]); + $this->fail('Should have thrown exception for dimension mismatch'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('elements', strtolower($e->getMessage())); + } + + try { + $this->database->find('vectorValidation2', [ + Query::vectorCosine('text', [1.0, 0.0, 0.0]), + ]); + $this->fail('Should have thrown exception for non-vector attribute'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('vector', strtolower($e->getMessage())); + } + } + + public function testVectorWithAssociativeArray(): void + { + $col = $this->vectorCollection('vectorAssoc'); + $this->setupCollections([$col]); + + try { + $this->database->createDocument('vectorAssoc', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => ['x' => 1.0, 'y' => 0.0, 'z' => 0.0], + ])); + $this->fail('Should have thrown exception for associative array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + } + + public function testVectorWithSparseArray(): void + { + $col = $this->vectorCollection('vectorSparse'); + $this->setupCollections([$col]); + + try { + $vector = []; + $vector[0] = 1.0; + $vector[2] = 1.0; + $this->database->createDocument('vectorSparse', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => $vector, + ])); + $this->fail('Should have thrown exception for sparse array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + } + + public function testVectorWithNestedArrays(): void + { + $col = $this->vectorCollection('vectorNested'); + $this->setupCollections([$col]); + + try { + $this->database->createDocument('vectorNested', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [[1.0], [0.0], [0.0]], + ])); + $this->fail('Should have thrown exception for nested array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + } + + public function testVectorWithBooleansInArray(): void + { + $col = $this->vectorCollection('vectorBooleans'); + $this->setupCollections([$col]); + + try { + $this->database->createDocument('vectorBooleans', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [true, false, true], + ])); + $this->fail('Should have thrown exception for boolean values'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + } + + public function testVectorWithStringNumbers(): void + { + $col = $this->vectorCollection('vectorStringNums'); + $this->setupCollections([$col]); + + try { + $this->database->createDocument('vectorStringNums', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => ['1.0', '2.0', '3.0'], + ])); + $this->fail('Should have thrown exception for string numbers'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + } + + public function testVectorCosineSimilarityDivisionByZero(): void + { + $col = $this->vectorCollection('vectorCosineZero'); + $this->setupCollections([$col]); + + $doc = $this->database->createDocument('vectorCosineZero', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [0.0, 0.0, 0.0], + ])); + + $this->assertNotSame('', $doc->getId()); + } + + public function testVectorSearchWithRestrictedPermissions(): void + { + $col = $this->vectorCollection('vectorPermissions'); + $this->setupCollections([$col]); + + $doc = $this->database->createDocument('vectorPermissions', new Document([ + '$permissions' => [Permission::read(Role::user('user1'))], + 'embedding' => [1.0, 0.0, 0.0], + ])); + + $this->assertNotSame('', $doc->getId()); + } + + public function testVectorPermissionFilteringAfterScoring(): void + { + $scoreAttr = new Document([ + '$id' => 'score', 'key' => 'score', + 'type' => ColumnType::Integer->value, + 'size' => 0, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->vectorCollection('vectorPermScoring', 3, true, [$scoreAttr]); + $this->setupCollections([$col]); + + $doc = $this->database->createDocument('vectorPermScoring', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'score' => 4, + 'embedding' => [0.6, 0.4, 0.0], + ])); + + $this->assertNotSame('', $doc->getId()); + } + + public function testVectorRequiredWithNullValue(): void + { + $col = $this->vectorCollection('vectorRequiredNull', 3, true); + $this->setupCollections([$col]); + + $this->expectException(DatabaseException::class); + + $this->database->createDocument('vectorRequiredNull', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => null, + ])); + } + + public function testVectorIndexCreationFailure(): void + { + $textAttr = new Document([ + '$id' => 'text', 'key' => 'text', + 'type' => ColumnType::String->value, + 'size' => 255, 'required' => true, 'default' => null, + 'signed' => true, 'array' => false, 'filters' => [], + ]); + + $col = $this->vectorCollection('vectorIdxFail', 3, true, [$textAttr]); + $this->setupCollections([$col]); + + try { + $this->database->createIndex('vectorIdxFail', Index::hnswCosine(key: 'bad_idx', attribute: 'text')); + $this->fail('Should not allow vector index on non-vector attribute'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('vector', strtolower($e->getMessage())); + } + } + + public function testVectorNonNumericValidationE2E(): void + { + $col = $this->vectorCollection('vectorNonNumeric'); + $this->setupCollections([$col]); + + try { + $this->database->createDocument('vectorNonNumeric', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [1.0, null, 0.0], + ])); + $this->fail('Should reject null in vector array'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('numeric', strtolower($e->getMessage())); + } + + try { + $this->database->createDocument('vectorNonNumeric', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'embedding' => [1.0, (object) ['x' => 1], 0.0], + ])); + $this->fail('Should reject object in vector array'); + } catch (\Throwable $e) { + $this->assertNotSame('', $e->getMessage()); + } + } +} diff --git a/tests/unit/WithCacheLeaseTest.php b/tests/unit/WithCacheLeaseTest.php index c35682a905..d59de806ce 100644 --- a/tests/unit/WithCacheLeaseTest.php +++ b/tests/unit/WithCacheLeaseTest.php @@ -2,35 +2,43 @@ namespace Tests\Unit; +use Closure; use PHPUnit\Framework\TestCase; use Utopia\Cache\Adapter; use Utopia\Cache\Cache; use Utopia\Cache\Feature\Leasable; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Permission; +use Utopia\Database\Query; +use Utopia\Database\Role; class WithCacheLeaseTest extends TestCase { - private LeasableMemoryCache $cacheAdapter; + private Cache $cache; + + private DatabaseMemory $adapter; private Database $database; private string $key; + #[\Override] protected function setUp(): void { - $this->cacheAdapter = new LeasableMemoryCache(); - $this->database = new Database(new DatabaseMemory(), new Cache($this->cacheAdapter)); + $this->cache = new Cache(new LeasableMemoryCache()); + $this->adapter = new DatabaseMemory(); + $this->database = new Database($this->adapter, $this->cache); $this->database ->setDatabase('utopiaTests') ->setNamespace('with_cache_' . \uniqid()); $this->database->create(); - $this->database->createCollection('projects'); - $this->database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $this->database->createCollection(Collection::create(id: 'projects')); + $this->database->createAttribute('projects', Attribute::string(key: 'name')); $this->database->createDocument('projects', new Document([ '$id' => 'project', '$permissions' => [ @@ -42,23 +50,83 @@ protected function setUp(): void $this->key = $this->database->getQueryCacheKey('projects'); } - public function testDocumentPurgeRemovesAllVariantsAndRejectsStaleWrites(): void + /** + * Write to the row through the adapter, bypassing Database and therefore the + * cache purge, so the cache is left holding the previous copy. + */ + private function staleCache(string $attribute, string $value): void + { + $collection = $this->database->getCollection('projects'); + $document = $this->adapter->getDocument($collection, 'project'); + $document->setAttribute($attribute, $value); + $this->adapter->updateDocument($collection, 'project', $document, true); + } + + public function testDocumentPurgeRemovesAllVariants(): void + { + $plain = fn (): mixed => $this->database->getDocument('projects', 'project')->getAttribute('name'); + $projected = fn (): mixed => $this->database + ->getDocument('projects', 'project', [Query::select(['name'])]) + ->getAttribute('name'); + + $this->assertSame('fresh', $plain()); + $this->assertSame('fresh', $projected()); + + $this->staleCache('name', 'changed'); + + // Both variants still answer 'fresh' from cache, so the purge has something to invalidate. + $this->assertSame('fresh', $plain()); + $this->assertSame('fresh', $projected()); + + $this->database->purgeCachedDocument('projects', 'project'); + + $this->assertSame('changed', $plain()); + $this->assertSame('changed', $projected()); + } + + public function testAReadInFlightDuringAPurgeCannotCacheWhatItRead(): void { - [$collectionKey, $documentKey, $firstHash] = $this->database->getCacheKeys('projects', 'project'); - [, , $secondHash] = $this->database->getCacheKeys('projects', 'project', ['name']); - $this->cacheAdapter->save($collectionKey, 'legacy', $documentKey); - $this->cacheAdapter->save($documentKey, 'first', $firstHash); - $this->cacheAdapter->save($documentKey, 'second', $secondHash); - $lease = $this->cacheAdapter->getGeneration($documentKey); + $adapter = new class () extends DatabaseMemory { + public ?Closure $afterNextDocumentRead = null; + + #[\Override] + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + $document = parent::getDocument($collection, $id, $queries, $forUpdate); + + $callback = $collection->getId() === Database::METADATA ? null : $this->afterNextDocumentRead; + if ($callback !== null) { + $this->afterNextDocumentRead = null; + $callback(); + } + + return $document; + } + }; + $database = new Database($adapter, new Cache(new LeasableMemoryCache())); + $database + ->setDatabase('utopiaTests') + ->setNamespace('with_cache_in_flight_' . \uniqid()); + $database->create(); + $database->createCollection(Collection::create(id: 'projects')); + $database->createAttribute('projects', Attribute::string(key: 'name')); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'fresh', + ])); - $this->assertTrue($this->database->purgeCachedDocument('projects', 'project')); + $adapter->afterNextDocumentRead = function () use ($adapter, $database): void { + $collection = $database->getCollection('projects'); + $row = $adapter->getDocument($collection, 'project')->setAttribute('name', 'changed'); + $adapter->updateDocument($collection, 'project', $row, true); + $database->purgeCachedDocument('projects', 'project'); + }; - $this->assertFalse($this->cacheAdapter->load($collectionKey, Database::TTL, $documentKey)); - $this->assertFalse($this->cacheAdapter->load($documentKey, Database::TTL, $firstHash)); - $this->assertFalse($this->cacheAdapter->load($documentKey, Database::TTL, $secondHash)); - $this->cacheAdapter->saveWithLease($documentKey, 'stale', $firstHash, $lease); - $this->assertFalse($this->cacheAdapter->load($documentKey, Database::TTL, $firstHash)); - $this->assertSame('fresh', $this->database->getDocument('projects', 'project')->getAttribute('name')); + $this->assertSame('fresh', $database->getDocument('projects', 'project')->getAttribute('name'), 'The read returns the row it read before the purge'); + $this->assertSame('changed', $database->getDocument('projects', 'project')->getAttribute('name'), 'The read that started before the purge must not have cached its row'); } public function testStaleListWriteAfterConcurrentPurgeIsRejected(): void @@ -71,17 +139,24 @@ public function testStaleListWriteAfterConcurrentPurgeIsRejected(): void // concurrent writer purges the query key after the read started but // before the result is cached. Without a lease the stale list below // would land in the cache after the purge. - $result = $this->database->withCache($this->key, function () use ($hash, $document) { - $this->cacheAdapter->purge($this->key, $hash); + $result = $this->database->withCache($this->key, function () use ($document) { + $this->database->purgeCachedQueries('projects'); return [$document]; }, $hash); + /** @var mixed $result */ + $this->assertIsArray($result); $this->assertCount(1, $result); - $this->assertFalse( - $this->cacheAdapter->load($this->key, Database::TTL, $hash), - 'A list read whose query key was purged mid-flight must not be re-cached.' - ); + $callbackCalls = 0; + $fresh = $this->database->withCache($this->key, function () use (&$callbackCalls): array { + $callbackCalls++; + + return []; + }, $hash); + /** @var mixed $fresh */ + $this->assertSame([], $fresh); + $this->assertSame(1, $callbackCalls); } public function testListWriteLandsWhenNoConcurrentPurge(): void @@ -92,11 +167,19 @@ public function testListWriteLandsWhenNoConcurrentPurge(): void $result = $this->database->withCache($this->key, fn () => [$document], $hash); + /** @var mixed $result */ + $this->assertIsArray($result); $this->assertCount(1, $result); - $this->assertNotFalse( - $this->cacheAdapter->load($this->key, Database::TTL, $hash), - 'A list read with no concurrent purge must populate the cache.' - ); + $callbackCalls = 0; + $cached = $this->database->withCache($this->key, function () use (&$callbackCalls): array { + $callbackCalls++; + + return []; + }, $hash); + /** @var mixed $cached */ + $this->assertIsArray($cached); + $this->assertCount(1, $cached); + $this->assertSame(0, $callbackCalls); } } @@ -105,10 +188,11 @@ class LeasableMemoryCache implements Adapter, Leasable private const string GENERATION_FIELD = '__utopia_gen__'; /** - * @var array> + * @var array|string}>> */ private array $store = []; + #[\Override] public function load(string $key, int $ttl, string $hash = ''): mixed { if ($hash === '') { @@ -124,7 +208,8 @@ public function load(string $key, int $ttl, string $hash = ''): mixed return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; } - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if (empty($key) || empty($data)) { return false; @@ -143,11 +228,15 @@ public function save(string $key, array|string $data, string $hash = ''): bool|s return $data; } + #[\Override] public function getGeneration(string $key): string { - return $this->store[$key][self::GENERATION_FIELD]['data'] ?? '0'; + $generation = $this->store[$key][self::GENERATION_FIELD]['data'] ?? '0'; + + return \is_string($generation) ? $generation : '0'; } + #[\Override] public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array { if (empty($key) || empty($data)) { @@ -161,6 +250,7 @@ public function saveWithLease(string $key, array|string $data, string $hash, str return $this->save($key, $data, $hash); } + #[\Override] public function touch(string $key, string $hash = ''): bool { if ($hash === '') { @@ -179,6 +269,7 @@ public function touch(string $key, string $hash = ''): bool /** * @return string[] */ + #[\Override] public function list(string $key): array { return \array_values(\array_filter( @@ -187,6 +278,7 @@ public function list(string $key): array )); } + #[\Override] public function purge(string $key, string $hash = ''): bool { $generation = (string) (((int) $this->getGeneration($key)) + 1); @@ -202,6 +294,7 @@ public function purge(string $key, string $hash = ''): bool return true; } + #[\Override] public function flush(): bool { $this->store = []; @@ -209,16 +302,19 @@ public function flush(): bool return true; } + #[\Override] public function ping(): bool { return true; } + #[\Override] public function getSize(): int { return \count($this->store); } + #[\Override] public function getName(?string $key = null): string { return 'leasable-memory'; diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/WithCacheTest.php similarity index 82% rename from tests/unit/QueryCacheTest.php rename to tests/unit/WithCacheTest.php index 8103a0dd27..cb86c052e2 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/WithCacheTest.php @@ -6,16 +6,20 @@ use Utopia\Cache\Adapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Utopia\Database\Filter\Callback; +use Utopia\Database\Permission; use Utopia\Database\Query; +use Utopia\Database\Role; +use Utopia\Query\Method; -class QueryCacheTest extends TestCase +class WithCacheTest extends TestCase { /** - * @param array $filters + * @param list $filters */ private function createDatabase(Adapter $cache, array $filters = [], ?DatabaseMemory $adapter = null): Database { @@ -25,10 +29,16 @@ private function createDatabase(Adapter $cache, array $filters = [], ?DatabaseMe ->setNamespace('list_cache_' . \uniqid()); $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); return $database; } + private function withCache(Database $database, string $key, callable $callback): mixed + { + return $database->withCache($key, $callback); + } + /** * @param array $queries * @return array @@ -40,7 +50,7 @@ private function findWithCache( ?string $namespace = null, ): array { foreach ($queries as $query) { - if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { + if ($query->getMethod() === Method::OrderRandom) { return $database->find($collection, $queries); } } @@ -71,6 +81,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame(['value' => 'fresh'], $value); $this->assertSame(1, $callbackCalls); @@ -82,6 +93,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame(['value' => 'fresh'], $value); $this->assertSame(1, $callbackCalls); } @@ -101,6 +113,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame([], $value); $value = $database->withCache( @@ -111,6 +124,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame([], $value); $this->assertSame(1, $callbackCalls); } @@ -122,7 +136,8 @@ public function testWithCacheCachesNullValues(): void $callbackCalls = 0; - $value = $database->withCache( + $value = $this->withCache( + $database, 'key', function () use (&$callbackCalls): mixed { $callbackCalls++; @@ -132,9 +147,10 @@ function () use (&$callbackCalls): mixed { $this->assertNull($value); - $value = $database->withCache( + $value = $this->withCache( + $database, 'key', - function () use (&$callbackCalls): string { + function () use (&$callbackCalls): mixed { $callbackCalls++; return 'miss'; }, @@ -179,12 +195,15 @@ function () use (&$firstCalls): array { 'first-field', ); + /** @var mixed $first */ + /** @var mixed $second */ + /** @var mixed $cachedFirst */ $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()); } public function testWithCacheDoesNotCacheFalseValues(): void @@ -194,9 +213,10 @@ public function testWithCacheDoesNotCacheFalseValues(): void $callbackCalls = 0; - $value = $database->withCache( + $value = $this->withCache( + $database, 'key', - function () use (&$callbackCalls): bool { + function () use (&$callbackCalls): mixed { $callbackCalls++; return false; }, @@ -213,6 +233,7 @@ function () use (&$callbackCalls): string { }, ); + /** @var mixed $value */ $this->assertSame('fresh', $value); $this->assertSame(2, $callbackCalls); } @@ -241,6 +262,8 @@ public function testWithCacheBypassesCacheForNullHash(): void hash: null, ); + /** @var mixed $first */ + /** @var mixed $second */ $this->assertSame('first', $first); $this->assertSame('second', $second); $this->assertSame(2, $callbackCalls); @@ -279,10 +302,10 @@ public function testWithCacheDoesNotCacheMixedDocumentArrays(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', permissions: [ + $database->createCollection(Collection::create(id: 'wafRules', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -315,10 +338,10 @@ public function testWithCacheCachesSingleDocument(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', permissions: [ + $database->createCollection(Collection::create(id: 'wafRules', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -355,9 +378,9 @@ public function testWithCacheCachesStaticQueryValues(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', permissions: [ + $database->createCollection(Collection::create(id: 'wafRules', permissions: [ Permission::read(Role::any()), - ]); + ], documentSecurity: false)); $callbackCalls = 0; $collection = $database->getCollection('wafRules'); @@ -381,6 +404,8 @@ public function testWithCacheCachesStaticQueryValues(): void hash: $hash, ); + /** @var mixed $first */ + /** @var mixed $second */ $this->assertSame(10, $first); $this->assertSame(10, $second); $this->assertSame(1, $callbackCalls); @@ -390,20 +415,12 @@ public function testQueryCacheUsesCacheUntilPurged(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', [ - new Document([ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + $database->createCollection(Collection::create(id: 'wafRules', attributes: [ + Attribute::string(key: 'projectId'), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -443,20 +460,12 @@ public function testQueryCacheSeparatesEntriesByAuthorizationContext(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', [ - new Document([ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + $database->createCollection(Collection::create(id: 'wafRules', attributes: [ + Attribute::string(key: 'projectId'), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -489,20 +498,12 @@ public function testQueryCacheRecastsCacheHits(): void { $cache = new JsonHashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('metrics', [ - new Document([ - '$id' => 'value', - 'type' => Database::VAR_FLOAT, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + $database->createCollection(Collection::create(id: 'metrics', attributes: [ + Attribute::double(key: 'value'), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('metrics', new Document([ '$id' => 'metric-a', @@ -525,27 +526,32 @@ public function testQueryCacheDoesNotDoubleDecodeCustomFilters(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache, [ - 'wrapped' => [ - 'encode' => static fn (mixed $value): string => 'encoded:' . $value, - 'decode' => static fn (mixed $value): string => \str_starts_with((string) $value, 'encoded:') - ? \substr((string) $value, 8) - : 'double:' . $value, - ], + new Callback( + 'wrapped', + static function (mixed $value): string { + if (! \is_scalar($value) && $value !== null) { + throw new \InvalidArgumentException('Filter input must be scalar or null'); + } + + return 'encoded:'.(string) $value; + }, + static function (mixed $value): string { + if (! \is_string($value)) { + throw new \InvalidArgumentException('Encoded filter input must be a string'); + } + + return \str_starts_with($value, 'encoded:') + ? \substr($value, 8) + : 'double:'.$value; + }, + ), ]); - $database->createCollection('secrets', [ - new Document([ - '$id' => 'secret', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => ['wrapped'], - ]), + $database->createCollection(Collection::create(id: 'secrets', attributes: [ + Attribute::string(key: 'secret', filters: ['wrapped']), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('secrets', new Document([ '$id' => 'secret-a', @@ -568,20 +574,12 @@ public function testQueryCacheBypassesCacheForRandomOrder(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', [ - new Document([ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + $database->createCollection(Collection::create(id: 'wafRules', attributes: [ + Attribute::string(key: 'projectId'), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -611,19 +609,11 @@ public function testQueryCacheReliesOnPurgeForDocumentSecurityCollections(): voi $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); $database->getAuthorization()->skip(function () use ($database): void { - $database->createCollection('secureRules', [ - new Document([ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + $database->createCollection(Collection::create(id: 'secureRules', attributes: [ + Attribute::string(key: 'projectId'), ], permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); + ])); $database->createDocument('secureRules', new Document([ '$id' => 'rule-a', @@ -670,9 +660,9 @@ public function testQueryCacheFiltersDocumentSecurityPayloadsOnHit(): void $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); $database->getAuthorization()->skip(function () use ($database): void { - $database->createCollection('secureRules', permissions: [ + $database->createCollection(Collection::create(id: 'secureRules', permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); + ])); }); $database->getAuthorization()->addRole(Role::user('user-1')->toString()); @@ -680,7 +670,8 @@ public function testQueryCacheFiltersDocumentSecurityPayloadsOnHit(): void $collection = $database->getCollection('secureRules'); $key = $database->getQueryCacheKey($collection->getId(), '_39'); $hash = $database->getQueryCacheField($collection); - $database->getCache()->save($key, [ + $this->assertNotNull($hash); + $this->storeCacheValue($cache, $key, $hash, [ 'collection' => $collection->getId(), 'type' => 'documents', 'value' => [ @@ -691,7 +682,7 @@ public function testQueryCacheFiltersDocumentSecurityPayloadsOnHit(): void ], ], ], - ], $hash); + ]); $callbackCalls = 0; $documents = $database->withCache( @@ -707,6 +698,7 @@ public function testQueryCacheFiltersDocumentSecurityPayloadsOnHit(): void hash: $hash, ); + /** @var mixed $documents */ $this->assertSame([], $documents); $this->assertSame(0, $callbackCalls); } @@ -715,17 +707,21 @@ public function testQueryCacheRehydratesNestedDocumentPayloads(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('parents', permissions: [ + $database->createCollection(Collection::create(id: 'parents', permissions: [ Permission::read(Role::any()), - ]); + ], documentSecurity: false)); $queries = [ Query::limit(25), ]; $collection = $database->getCollection('parents'); - $cache->save( + $hash = $database->getQueryCacheField($collection, $queries); + $this->assertNotNull($hash); + $this->storeCacheValue( + $cache, $database->getQueryCacheKey($collection->getId(), '_39'), + $hash, [ 'collection' => $collection->getId(), 'type' => 'documents', @@ -740,34 +736,26 @@ public function testQueryCacheRehydratesNestedDocumentPayloads(): void ], ], ], - $database->getQueryCacheField($collection, $queries), ); $parents = $this->findWithCache($database, 'parents', $queries, '_39'); $this->assertCount(1, $parents); - $this->assertInstanceOf(Document::class, $parents[0]->getAttribute('child')); - $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); + $child = $parents[0]->getAttribute('child'); + $this->assertInstanceOf(Document::class, $child); + $this->assertSame('child-a', $child->getId()); } public function testQueryCacheRefreshesInvalidPayload(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', [ - new Document([ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + $database->createCollection(Collection::create(id: 'wafRules', attributes: [ + Attribute::string(key: 'projectId'), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -781,14 +769,17 @@ public function testQueryCacheRefreshesInvalidPayload(): void ]; $collection = $database->getCollection('wafRules'); - $cache->save( + $hash = $database->getQueryCacheField($collection, $queries); + $this->assertNotNull($hash); + $this->storeCacheValue( + $cache, $database->getQueryCacheKey($collection->getId(), '_39'), + $hash, [ 'collection' => $collection->getId(), 'type' => 'documents', 'value' => 'invalid', ], - $database->getQueryCacheField($collection, $queries), ); $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); @@ -801,20 +792,12 @@ public function testQueryCacheRefreshesInvalidPayloadEntry(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', [ - new Document([ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), + $database->createCollection(Collection::create(id: 'wafRules', attributes: [ + Attribute::string(key: 'projectId'), ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -832,8 +815,12 @@ public function testQueryCacheRefreshesInvalidPayloadEntry(): void ]; $collection = $database->getCollection('wafRules'); - $cache->save( + $hash = $database->getQueryCacheField($collection, $queries); + $this->assertNotNull($hash); + $this->storeCacheValue( + $cache, $database->getQueryCacheKey($collection->getId(), '_39'), + $hash, [ 'collection' => $collection->getId(), 'type' => 'documents', @@ -845,7 +832,6 @@ public function testQueryCacheRefreshesInvalidPayloadEntry(): void 'invalid', ], ], - $database->getQueryCacheField($collection, $queries), ); $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); @@ -855,6 +841,14 @@ public function testQueryCacheRefreshesInvalidPayloadEntry(): void $rules, )); } + + /** @param array $value */ + private function storeCacheValue(HashMemoryCache $cache, string $key, string $hash, array $value): void + { + $epoch = 'test-epoch'; + $cache->save(\strtolower($key.'#epoch'), $epoch); + $cache->save(\strtolower($key.'#'.$epoch.':'.$hash), $value); + } } class HashMemoryCache implements Adapter @@ -864,6 +858,7 @@ class HashMemoryCache implements Adapter */ private array $store = []; + #[\Override] public function load(string $key, int $ttl, string $hash = ''): mixed { $hash = $hash === '' ? $key : $hash; @@ -875,7 +870,8 @@ public function load(string $key, int $ttl, string $hash = ''): mixed return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; } - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if ($key === '' || empty($data)) { return false; @@ -890,6 +886,7 @@ public function save(string $key, array|string $data, string $hash = ''): bool|s return $data; } + #[\Override] public function touch(string $key, string $hash = ''): bool { $hash = $hash === '' ? $key : $hash; @@ -905,11 +902,13 @@ public function touch(string $key, string $hash = ''): bool /** * @return array */ + #[\Override] public function list(string $key): array { return \array_keys($this->store[$key] ?? []); } + #[\Override] public function purge(string $key, string $hash = ''): bool { if ($hash !== '') { @@ -922,6 +921,7 @@ public function purge(string $key, string $hash = ''): bool return true; } + #[\Override] public function flush(): bool { $this->store = []; @@ -929,16 +929,19 @@ public function flush(): bool return true; } + #[\Override] public function ping(): bool { return true; } + #[\Override] public function getSize(): int { return \count($this->store); } + #[\Override] public function getName(?string $key = null): string { return 'hash-memory'; @@ -952,6 +955,7 @@ class JsonHashMemoryCache implements Adapter */ private array $store = []; + #[\Override] public function load(string $key, int $ttl, string $hash = ''): mixed { $hash = $hash === '' ? $key : $hash; @@ -963,7 +967,8 @@ public function load(string $key, int $ttl, string $hash = ''): mixed return \json_decode($saved['data'], true); } - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + #[\Override] + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if ($key === '' || empty($data)) { return false; @@ -978,6 +983,7 @@ public function save(string $key, array|string $data, string $hash = ''): bool|s return $data; } + #[\Override] public function touch(string $key, string $hash = ''): bool { $hash = $hash === '' ? $key : $hash; @@ -993,11 +999,13 @@ public function touch(string $key, string $hash = ''): bool /** * @return array */ + #[\Override] public function list(string $key): array { return \array_keys($this->store[$key] ?? []); } + #[\Override] public function purge(string $key, string $hash = ''): bool { if ($hash !== '') { @@ -1010,6 +1018,7 @@ public function purge(string $key, string $hash = ''): bool return true; } + #[\Override] public function flush(): bool { $this->store = []; @@ -1017,16 +1026,19 @@ public function flush(): bool return true; } + #[\Override] public function ping(): bool { return true; } + #[\Override] public function getSize(): int { return \count($this->store); } + #[\Override] public function getName(?string $key = null): string { return 'json-hash-memory';